feat: Fix all the security issues

This commit is contained in:
Julien Quiaios 2026-04-04 20:09:47 -04:00
parent 1859ef3d24
commit b9e04f6601
Signed by: palozob
GPG Key ID: 10F46E45A96EDCDA
10 changed files with 281 additions and 114 deletions

View File

@ -47,17 +47,19 @@ export function configRouter(config: Config) {
return res.badRequest(c, "minUploadRateBytesPerSec must be ≤ maxUploadRateBytesPerSec"); return res.badRequest(c, "minUploadRateBytesPerSec must be ≤ maxUploadRateBytesPerSec");
} }
// Apply patch to the live config object (affects all new seeders) // Persist to disk first — only apply to live config if write succeeds
Object.assign(config, patch); const merged = { ...config, ...patch };
// Persist to disk so the next container start picks it up
try { try {
mkdirSync(dirname(CONFIG_PATH), { recursive: true }); mkdirSync(dirname(CONFIG_PATH), { recursive: true });
writeFileSync(CONFIG_PATH, dump(config), "utf8"); writeFileSync(CONFIG_PATH, dump(merged), "utf8");
} catch (err) { } catch (err) {
console.warn("Could not persist config to disk:", err); console.warn("Could not persist config to disk:", err);
return res.unprocessable(c, "Failed to persist config to disk");
} }
// Apply patch to the live config object (affects all new seeders)
Object.assign(config, patch);
return c.json(config); return c.json(config);
}); });

View File

@ -2,8 +2,12 @@ import { Hono } from "hono";
import { streamSSE } from "hono/streaming"; import { streamSSE } from "hono/streaming";
import type { SeederRegistry } from "../SeederRegistry.js"; import type { SeederRegistry } from "../SeederRegistry.js";
/** Maximum number of concurrent SSE connections */
const MAX_SSE_CONNECTIONS = 50;
export function statusRouter(registry: SeederRegistry) { export function statusRouter(registry: SeederRegistry) {
const app = new Hono(); const app = new Hono();
let activeConnections = 0;
/** Global stats snapshot */ /** Global stats snapshot */
app.get("/", (c) => { app.get("/", (c) => {
@ -17,9 +21,25 @@ export function statusRouter(registry: SeederRegistry) {
* Events: * Events:
* - "stats" with global stats (every change) * - "stats" with global stats (every change)
* - "torrent" with per-torrent state (on each seeder stateChange) * - "torrent" with per-torrent state (on each seeder stateChange)
* - "torrents" with full list on initial connection
* - "ping" keep-alive every 30s
*/ */
app.get("/stream", (c) => { app.get("/stream", (c) => {
if (activeConnections >= MAX_SSE_CONNECTIONS) {
return c.text("Too many SSE connections", 429);
}
activeConnections++;
return streamSSE(c, async (stream) => { return streamSSE(c, async (stream) => {
let cleaned = false;
function cleanup() {
if (cleaned) return;
cleaned = true;
activeConnections--;
clearInterval(keepAlive);
unsubscribe();
}
// Send initial snapshot // Send initial snapshot
await stream.writeSSE({ await stream.writeSSE({
event: "stats", event: "stats",
@ -43,27 +63,23 @@ export function statusRouter(registry: SeederRegistry) {
data: JSON.stringify(registry.globalStats()), data: JSON.stringify(registry.globalStats()),
}); });
} catch { } catch {
// client disconnected cleanup();
} }
}); });
// Keep alive with a comment every 30s // Keep alive with a ping every 30s
const keepAlive = setInterval(async () => { const keepAlive = setInterval(async () => {
try { try {
await stream.writeSSE({ event: "ping", data: "" }); await stream.writeSSE({ event: "ping", data: "" });
} catch { } catch {
clearInterval(keepAlive); cleanup();
unsubscribe();
} }
}, 30_000); }, 30_000);
// Clean up when client disconnects // Clean up when client disconnects
stream.onAbort(() => { stream.onAbort(() => cleanup());
clearInterval(keepAlive);
unsubscribe();
});
// Hold the connection open // Hold the connection open until abort
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
stream.onAbort(() => resolve()); stream.onAbort(() => resolve());
}); });

View File

@ -2,6 +2,16 @@ import { Hono } from "hono";
import { res } from "../response.js"; import { res } from "../response.js";
import type { SeederRegistry } from "../SeederRegistry.js"; import type { SeederRegistry } from "../SeederRegistry.js";
/** Maximum upload size for .torrent files (10 MB) */
const MAX_TORRENT_SIZE = 10 * 1024 * 1024;
/** Validates that a hash parameter is a 40-char hex string (SHA-1) */
const HASH_RE = /^[a-f0-9]{40}$/i;
function validateHash(hash: string): boolean {
return HASH_RE.test(hash);
}
export function torrentsRouter(registry: SeederRegistry) { export function torrentsRouter(registry: SeederRegistry) {
const app = new Hono(); const app = new Hono();
@ -12,6 +22,12 @@ export function torrentsRouter(registry: SeederRegistry) {
/** Add a torrent — accepts multipart/form-data with a 'torrent' file field */ /** Add a torrent — accepts multipart/form-data with a 'torrent' file field */
app.post("/", async (c) => { app.post("/", async (c) => {
// Reject oversized uploads early
const contentLength = Number(c.req.header("content-length") ?? 0);
if (contentLength > MAX_TORRENT_SIZE) {
return res.badRequest(c, `File too large (max ${MAX_TORRENT_SIZE / 1024 / 1024} MB)`);
}
const body = await c.req.parseBody(); const body = await c.req.parseBody();
const file = body.torrent; const file = body.torrent;
@ -21,6 +37,10 @@ export function torrentsRouter(registry: SeederRegistry) {
const buf = Buffer.from(await (file as File).arrayBuffer()); const buf = Buffer.from(await (file as File).arrayBuffer());
if (buf.length > MAX_TORRENT_SIZE) {
return res.badRequest(c, `File too large (max ${MAX_TORRENT_SIZE / 1024 / 1024} MB)`);
}
try { try {
const state = await registry.addTorrent(buf); const state = await registry.addTorrent(buf);
return res.created(c, state); return res.created(c, state);
@ -33,6 +53,8 @@ export function torrentsRouter(registry: SeederRegistry) {
/** Get stats + announce history for a specific torrent */ /** Get stats + announce history for a specific torrent */
app.get("/:hash", (c) => { app.get("/:hash", (c) => {
const { hash } = c.req.param(); const { hash } = c.req.param();
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
const list = registry.listTorrents(); const list = registry.listTorrents();
const torrent = list.find((t) => t.infoHashHex === hash); const torrent = list.find((t) => t.infoHashHex === hash);
@ -49,6 +71,7 @@ export function torrentsRouter(registry: SeederRegistry) {
/** Pause a torrent — sends 'stopped' to trackers and halts the announce loop */ /** Pause a torrent — sends 'stopped' to trackers and halts the announce loop */
app.post("/:hash/pause", async (c) => { app.post("/:hash/pause", async (c) => {
const { hash } = c.req.param(); const { hash } = c.req.param();
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
const ok = await registry.pauseTorrent(hash); const ok = await registry.pauseTorrent(hash);
if (!ok) return res.notFound(c); if (!ok) return res.notFound(c);
return c.json({ ok: true }); return c.json({ ok: true });
@ -57,6 +80,7 @@ export function torrentsRouter(registry: SeederRegistry) {
/** Resume a paused torrent — re-announces as 'started' and restarts the loop */ /** Resume a paused torrent — re-announces as 'started' and restarts the loop */
app.post("/:hash/resume", async (c) => { app.post("/:hash/resume", async (c) => {
const { hash } = c.req.param(); const { hash } = c.req.param();
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
const ok = await registry.resumeTorrent(hash); const ok = await registry.resumeTorrent(hash);
if (!ok) return res.notFound(c); if (!ok) return res.notFound(c);
return c.json({ ok: true }); return c.json({ ok: true });
@ -65,6 +89,7 @@ export function torrentsRouter(registry: SeederRegistry) {
/** Remove a torrent — sends 'stopped' to trackers then removes it */ /** Remove a torrent — sends 'stopped' to trackers then removes it */
app.delete("/:hash", async (c) => { app.delete("/:hash", async (c) => {
const { hash } = c.req.param(); const { hash } = c.req.param();
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
const removed = await registry.removeTorrent(hash); const removed = await registry.removeTorrent(hash);
if (!removed) { if (!removed) {

View File

@ -29,6 +29,10 @@ export function decode(data: Buffer | Uint8Array): BencodeValue {
} }
export function decodeAt(buf: Buffer, offset: number): DecodeResult { export function decodeAt(buf: Buffer, offset: number): DecodeResult {
if (offset >= buf.length) {
throw new Error(`Unexpected end of data at offset ${offset}`);
}
const byte = buf[offset]; const byte = buf[offset];
if (byte === 0x69) { if (byte === 0x69) {
@ -68,11 +72,17 @@ function decodeInteger(buf: Buffer, offset: number): DecodeResult {
function decodeString(buf: Buffer, offset: number): DecodeResult { function decodeString(buf: Buffer, offset: number): DecodeResult {
const colonIdx = buf.indexOf(0x3a, offset); // ':' const colonIdx = buf.indexOf(0x3a, offset); // ':'
if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`); if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`);
const len = Number(buf.subarray(offset, colonIdx).toString("ascii")); const lenStr = buf.subarray(offset, colonIdx).toString("ascii");
if (!Number.isInteger(len) || len < 0) { const len = Number.parseInt(lenStr, 10);
throw new Error(`Invalid string length at offset ${offset}`); if (!Number.isInteger(len) || len < 0 || lenStr !== String(len)) {
throw new Error(`Invalid string length "${lenStr}" at offset ${offset}`);
} }
const start = colonIdx + 1; const start = colonIdx + 1;
if (start + len > buf.length) {
throw new Error(
`String at offset ${offset} claims length ${len} but only ${buf.length - start} bytes remain`
);
}
const value = Buffer.from(buf.subarray(start, start + len)); const value = Buffer.from(buf.subarray(start, start + len));
return { value, end: start + len }; return { value, end: start + len };
} }

View File

@ -42,7 +42,7 @@ export class FakeSeeder extends EventEmitter {
on(event: "stopped", listener: () => void): this; on(event: "stopped", listener: () => void): this;
on(event: "stateChange", listener: (state: SeederState) => void): this; on(event: "stateChange", listener: (state: SeederState) => void): this;
on(event: string, listener: (...args: unknown[]) => void): this { on(event: string, listener: (...args: unknown[]) => void): this {
return super.on(event, listener); return super.on(event, listener) as this;
} }
private readonly torrent: TorrentFileInfo; private readonly torrent: TorrentFileInfo;
private readonly profile: ClientProfile; private readonly profile: ClientProfile;
@ -104,14 +104,7 @@ export class FakeSeeder extends EventEmitter {
this.setStatus("running"); this.setStatus("running");
// Start speed tick // Start speed tick
this.lastTickTime = Date.now(); this.startTickTimer();
this.tickTimer = setInterval(() => {
const now = Date.now();
const delta = now - this.lastTickTime;
this.lastTickTime = now;
this.uploaded += this.speedSim.tick(delta);
this.emit("stateChange", this.getState());
}, 5_000);
// First announce: started // First announce: started
await this.doAnnounce("started"); await this.doAnnounce("started");
@ -154,14 +147,7 @@ export class FakeSeeder extends EventEmitter {
this.setStatus("running"); this.setStatus("running");
// Restart speed tick // Restart speed tick
this.lastTickTime = Date.now(); this.startTickTimer();
this.tickTimer = setInterval(() => {
const now = Date.now();
const delta = now - this.lastTickTime;
this.lastTickTime = now;
this.uploaded += this.speedSim.tick(delta);
this.emit("stateChange", this.getState());
}, 5_000);
// Re-announce as started then resume loop // Re-announce as started then resume loop
await this.doAnnounce("started"); await this.doAnnounce("started");
@ -170,7 +156,7 @@ export class FakeSeeder extends EventEmitter {
async stop(): Promise<void> { async stop(): Promise<void> {
if (this.status === "stopped" || this.status === "stopping") return; if (this.status === "stopped" || this.status === "stopping") return;
const waspaused = this.status === "paused"; const wasPaused = this.status === "paused";
this.setStatus("stopping"); this.setStatus("stopping");
if (this.announceTimer) { if (this.announceTimer) {
@ -183,7 +169,7 @@ export class FakeSeeder extends EventEmitter {
} }
// If already paused, trackers were already notified — skip the stopped announce // If already paused, trackers were already notified — skip the stopped announce
if (!waspaused) { if (!wasPaused) {
await Promise.allSettled( await Promise.allSettled(
this.trackers.map((t) => this.trackers.map((t) =>
t.stop({ t.stop({
@ -204,6 +190,22 @@ export class FakeSeeder extends EventEmitter {
this.emit("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 { private scheduleNextAnnounce(): void {
const intervalMs = this.lastInterval * 1000; const intervalMs = this.lastInterval * 1000;
this.announceTimer = setTimeout(async () => { this.announceTimer = setTimeout(async () => {
@ -258,19 +260,21 @@ export class FakeSeeder extends EventEmitter {
}; };
this.emit("announce", record); this.emit("announce", record);
} else { } else {
this.error = (result.reason as Error).message; this.error = result.reason instanceof Error ? result.reason.message : String(result.reason);
// Fallback: scrape to at least get current seeder/leecher counts // Fallback: scrape to at least get current seeder/leecher counts
if (tracker.scrape) { if (tracker.scrape && this.status === "running") {
tracker tracker
.scrape(this.torrent.infoHash) .scrape(this.torrent.infoHash)
.then((stats) => { .then((stats) => {
if (stats) { if (stats && this.status === "running") {
this.lastSeeder = stats.seeders; this.lastSeeder = stats.seeders;
this.lastLeecher = stats.leechers; this.lastLeecher = stats.leechers;
this.emit("stateChange", this.getState()); this.emit("stateChange", this.getState());
} }
}) })
.catch(() => {}); .catch((err) => {
console.warn(`[seeder] Scrape fallback failed for ${tracker.url}:`, err);
});
} }
} }
} }
@ -285,10 +289,43 @@ export class FakeSeeder extends EventEmitter {
} }
} }
/**
* 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[] { function buildTrackers(urls: string[]): ITracker[] {
return urls return urls
.map((url) => { .map((url): ITracker | null => {
try { 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://")) { if (url.startsWith("http://") || url.startsWith("https://")) {
return new HttpTracker(url); return new HttpTracker(url);
} }
@ -296,7 +333,7 @@ function buildTrackers(urls: string[]): ITracker[] {
return new UdpTracker(url); return new UdpTracker(url);
} }
} catch { } catch {
// skip malformed URLs console.warn(`[tracker] Skipping malformed URL: ${url}`);
} }
return null; return null;
}) })

View File

@ -28,7 +28,11 @@ export interface TorrentFileEntry {
} }
export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo { export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
const torrent = decode(buf) as BencodeDict; const decoded = decode(buf);
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
throw new Error("Invalid torrent file: expected a bencoded dictionary");
}
const torrent = decoded as BencodeDict;
// --- info_hash: SHA-1 of raw bencoded info dict --- // --- info_hash: SHA-1 of raw bencoded info dict ---
const infoRaw = rawSlice(buf, "info"); const infoRaw = rawSlice(buf, "info");
@ -36,15 +40,24 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
const infoHashHex = createHash("sha1").update(infoRaw).digest("hex"); const infoHashHex = createHash("sha1").update(infoRaw).digest("hex");
const infoHash = Buffer.from(infoHashHex, "hex"); const infoHash = Buffer.from(infoHashHex, "hex");
if (!torrent.info || typeof torrent.info !== "object" || Array.isArray(torrent.info)) {
throw new Error("Missing or invalid 'info' dict in torrent file");
}
const info = torrent.info as BencodeDict; const info = torrent.info as BencodeDict;
if (!info) throw new Error("Missing 'info' dict");
const name = bufToStr(info.name as Buffer); if (!info.name || !Buffer.isBuffer(info.name)) {
const pieceLength = info["piece length"] as number; throw new Error("Missing or invalid 'info.name' in torrent file");
}
const name = info.name.toString("utf8");
if (typeof info["piece length"] !== "number") {
throw new Error("Missing or invalid 'info.piece length' in torrent file");
}
const pieceLength = info["piece length"];
// --- tracker URLs --- // --- tracker URLs ---
const announce = torrent.announce ? bufToStr(torrent.announce as Buffer) : ""; const announce = Buffer.isBuffer(torrent.announce) ? torrent.announce.toString("utf8") : "";
const announceList = flattenAnnounceList(torrent["announce-list"] as BencodeList | undefined); const announceList = flattenAnnounceList(torrent["announce-list"]);
if (announce && !announceList.includes(announce)) { if (announce && !announceList.includes(announce)) {
announceList.unshift(announce); announceList.unshift(announce);
} }
@ -55,20 +68,33 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
const isMultiFile = "files" in info; const isMultiFile = "files" in info;
if (isMultiFile) { if (isMultiFile) {
const fileList = info.files as BencodeList; if (!Array.isArray(info.files)) {
files = fileList.map((f) => { throw new Error("Invalid 'info.files': expected a list");
}
files = (info.files as BencodeList).map((f, i) => {
if (!f || typeof f !== "object" || Array.isArray(f)) {
throw new Error(`Invalid file entry at index ${i}`);
}
const fd = f as BencodeDict; const fd = f as BencodeDict;
const pathParts = (fd.path as BencodeList).map((p) => bufToStr(p as Buffer)); if (!Array.isArray(fd.path)) {
throw new Error(`Missing or invalid 'path' in file entry ${i}`);
}
const pathParts = (fd.path as BencodeList).map((p) => bufToStr(p));
if (typeof fd.length !== "number") {
throw new Error(`Missing or invalid 'length' in file entry ${i}`);
}
return { return {
path: pathParts.join("/"), path: pathParts.join("/"),
size: fd.length as number, size: fd.length,
}; };
}); });
totalSize = files.reduce((sum, f) => sum + f.size, 0); totalSize = files.reduce((sum, f) => sum + f.size, 0);
} else { } else {
const length = info.length as number; if (typeof info.length !== "number") {
files = [{ path: name, size: length }]; throw new Error("Missing 'info.length' for single-file torrent");
totalSize = length; }
files = [{ path: name, size: info.length }];
totalSize = info.length;
} }
return { return {
@ -84,17 +110,19 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
}; };
} }
function bufToStr(buf: Buffer): string { function bufToStr(val: unknown): string {
return Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf); if (Buffer.isBuffer(val)) return val.toString("utf8");
if (typeof val === "string") return val;
return String(val);
} }
function flattenAnnounceList(announceList: BencodeList | undefined): string[] { function flattenAnnounceList(raw: unknown): string[] {
if (!announceList) return []; if (!Array.isArray(raw)) return [];
const urls: string[] = []; const urls: string[] = [];
for (const tier of announceList) { for (const tier of raw) {
if (Array.isArray(tier)) { if (Array.isArray(tier)) {
for (const url of tier) { for (const url of tier) {
const s = bufToStr(url as Buffer); const s = bufToStr(url);
if (s && !urls.includes(s)) urls.push(s); if (s && !urls.includes(s)) urls.push(s);
} }
} }

View File

@ -2,6 +2,12 @@ import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.j
import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js"; import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js";
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.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. * HTTP/HTTPS tracker client.
* *
@ -29,25 +35,37 @@ export class HttpTracker implements ITracker {
"User-Agent": params.userAgent, "User-Agent": params.userAgent,
"Accept-Encoding": "gzip", "Accept-Encoding": "gzip",
}, },
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`); throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
} }
const body = Buffer.from(await res.arrayBuffer()); const arrayBuf = await res.arrayBuffer();
const decoded = decode(body) as BencodeDict; if (arrayBuf.byteLength > MAX_RESPONSE_SIZE) {
throw new Error(`Tracker response too large (${arrayBuf.byteLength} bytes)`);
if (decoded["failure reason"]) {
throw new Error(`Tracker failure: ${(decoded["failure reason"] as Buffer).toString("utf8")}`);
} }
if (decoded["tracker id"]) { const body = Buffer.from(arrayBuf);
this.trackerId = (decoded["tracker id"] as Buffer).toString("utf8"); 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}`);
} }
return parseResponse(decoded); 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> { async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
@ -73,22 +91,30 @@ export class HttpTracker implements ITracker {
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`; const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
const res = await fetch(fullUrl, { const res = await fetch(fullUrl, {
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" }, headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
}); });
if (!res.ok) return null; if (!res.ok) return null;
const body = Buffer.from(await res.arrayBuffer()); const body = Buffer.from(await res.arrayBuffer());
const decoded = decode(body) as BencodeDict; const decoded = decode(body);
if (!decoded.files) return null; 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(decoded.files as BencodeDict); const entries = Object.values(dict.files as BencodeDict);
if (entries.length === 0) return null; if (entries.length === 0) return null;
const entry = entries[0] as BencodeDict; 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 { return {
seeders: (entry.complete as number) ?? 0, seeders: typeof complete === "number" ? complete : 0,
leechers: (entry.incomplete as number) ?? 0, leechers: typeof incomplete === "number" ? incomplete : 0,
completed: (entry.downloaded as number) ?? 0, completed: typeof downloaded === "number" ? downloaded : 0,
}; };
} catch { } catch {
return null; return null;
@ -155,16 +181,18 @@ function percentEncode(buf: Buffer): string {
} }
function parseResponse(dict: BencodeDict): TrackerResponse { function parseResponse(dict: BencodeDict): TrackerResponse {
const interval = (dict.interval as number) ?? 1800; const rawInterval = dict.interval;
const minInterval = dict["min interval"] as number | undefined; const interval = typeof rawInterval === "number" ? rawInterval : 1800;
const seeders = dict.complete as number | undefined; const rawMinInterval = dict["min interval"];
const leechers = dict.incomplete as number | undefined; const minInterval = typeof rawMinInterval === "number" ? rawMinInterval : undefined;
const warning = dict["warning message"] const rawSeeders = dict.complete;
? (dict["warning message"] as Buffer).toString("utf8") const seeders = typeof rawSeeders === "number" ? rawSeeders : undefined;
: undefined; const rawLeechers = dict.incomplete;
const trackerId = dict["tracker id"] const leechers = typeof rawLeechers === "number" ? rawLeechers : undefined;
? (dict["tracker id"] as Buffer).toString("utf8") const rawWarning = dict["warning message"];
: undefined; 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); const peers = parsePeers(dict.peers);
@ -177,7 +205,7 @@ function parsePeers(raw: unknown): TrackerPeer[] {
// Compact format: 6 bytes per peer (4 IP + 2 port) // Compact format: 6 bytes per peer (4 IP + 2 port)
if (Buffer.isBuffer(raw)) { if (Buffer.isBuffer(raw)) {
const peers: TrackerPeer[] = []; const peers: TrackerPeer[] = [];
for (let i = 0; i + 5 < raw.length; i += 6) { for (let i = 0; i + 6 <= raw.length; i += 6) {
const ip = `${raw[i]}.${raw[i + 1]}.${raw[i + 2]}.${raw[i + 3]}`; const ip = `${raw[i]}.${raw[i + 1]}.${raw[i + 2]}.${raw[i + 3]}`;
const port = raw.readUInt16BE(i + 4); const port = raw.readUInt16BE(i + 4);
peers.push({ ip, port }); peers.push({ ip, port });
@ -187,13 +215,12 @@ function parsePeers(raw: unknown): TrackerPeer[] {
// Dict format (older trackers) // Dict format (older trackers)
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
return (raw as BencodeList).map((p) => { return (raw as BencodeList)
const pd = p as BencodeDict; .filter((p): p is BencodeDict => !!p && typeof p === "object" && !Array.isArray(p))
return { .map((pd) => ({
ip: (pd.ip as Buffer).toString("ascii"), ip: Buffer.isBuffer(pd.ip) ? pd.ip.toString("ascii") : String(pd.ip ?? "0.0.0.0"),
port: pd.port as number, port: typeof pd.port === "number" ? pd.port : 0,
}; }));
});
} }
return []; return [];

View File

@ -22,12 +22,17 @@ if (config.autoLoad) {
const state = await registry.addTorrent(buf); const state = await registry.addTorrent(buf);
console.log(`${state.name} [${state.infoHashHex.slice(0, 8)}]`); console.log(`${state.name} [${state.infoHashHex.slice(0, 8)}]`);
} catch (err) { } catch (err) {
console.warn(` ✗ Failed to load ${file}: ${(err as Error).message}`); console.warn(` ✗ Failed to load ${file}: ${err instanceof Error ? err.message : err}`);
} }
} }
} }
} catch { } catch (err) {
// torrentsDir doesn't exist yet — that's fine const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
// torrentsDir doesn't exist yet — that's fine
} else {
console.error(`Failed to read torrents directory (${config.torrentsDir}):`, err);
}
} }
} }

View File

@ -32,6 +32,10 @@
} }
} }
function trackerHostname(url: string): string {
try { return new URL(url).hostname; } catch { return url; }
}
function statusColor(status: TorrentState["status"]): string { function statusColor(status: TorrentState["status"]): string {
switch (status) { switch (status) {
case "running": return "#4ade80"; case "running": return "#4ade80";
@ -65,7 +69,7 @@
{#each torrents as t (t.infoHashHex)} {#each torrents as t (t.infoHashHex)}
<tr> <tr>
<td class="name" title={t.name}>{t.name}</td> <td class="name" title={t.name}>{t.name}</td>
<td class="hash" title={t.trackerUrl}>{new URL(t.trackerUrl).hostname}</td> <td class="hash" title={t.trackerUrl}>{trackerHostname(t.trackerUrl)}</td>
<td class="hash" title={t.infoHashHex}>{t.infoHashHex.slice(0, 8)}…</td> <td class="hash" title={t.infoHashHex}>{t.infoHashHex.slice(0, 8)}…</td>
<td> <td>
<span class="badge" style="color: {statusColor(t.status)}">{t.status}</span> <span class="badge" style="color: {statusColor(t.status)}">{t.status}</span>

View File

@ -25,8 +25,22 @@ export interface GlobalStats {
startedAt: string; startedAt: string;
} }
/** Extract a human-readable error message from a failed response */
async function extractError(res: Response): Promise<string> {
try {
const body = await res.json();
if (body && typeof body === "object" && "error" in body) {
return typeof body.error === "string" ? body.error : JSON.stringify(body.error);
}
} catch {
// response wasn't JSON
}
return `Request failed (${res.status})`;
}
export async function listTorrents(): Promise<TorrentState[]> { export async function listTorrents(): Promise<TorrentState[]> {
const res = await fetch(`${BASE}/torrents`); const res = await fetch(`${BASE}/torrents`);
if (!res.ok) throw new Error(await extractError(res));
return res.json(); return res.json();
} }
@ -34,23 +48,23 @@ export async function addTorrent(file: File): Promise<TorrentState> {
const form = new FormData(); const form = new FormData();
form.append("torrent", file); form.append("torrent", file);
const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form }); const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form });
if (!res.ok) { if (!res.ok) throw new Error(await extractError(res));
const err = await res.json() as { error: string };
throw new Error(err.error);
}
return res.json(); return res.json();
} }
export async function removeTorrent(hash: string): Promise<void> { export async function removeTorrent(hash: string): Promise<void> {
await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" }); const res = await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" });
if (!res.ok) throw new Error(await extractError(res));
} }
export async function pauseTorrent(hash: string): Promise<void> { export async function pauseTorrent(hash: string): Promise<void> {
await fetch(`${BASE}/torrents/${hash}/pause`, { method: "POST" }); const res = await fetch(`${BASE}/torrents/${hash}/pause`, { method: "POST" });
if (!res.ok) throw new Error(await extractError(res));
} }
export async function resumeTorrent(hash: string): Promise<void> { export async function resumeTorrent(hash: string): Promise<void> {
await fetch(`${BASE}/torrents/${hash}/resume`, { method: "POST" }); const res = await fetch(`${BASE}/torrents/${hash}/resume`, { method: "POST" });
if (!res.ok) throw new Error(await extractError(res));
} }
export interface AppConfig { export interface AppConfig {
@ -65,6 +79,7 @@ export interface AppConfig {
export async function getConfig(): Promise<AppConfig> { export async function getConfig(): Promise<AppConfig> {
const res = await fetch(`${BASE}/config`); const res = await fetch(`${BASE}/config`);
if (!res.ok) throw new Error(await extractError(res));
return res.json(); return res.json();
} }
@ -74,15 +89,13 @@ export async function updateConfig(patch: Partial<Pick<AppConfig, "minUploadRate
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
if (!res.ok) { if (!res.ok) throw new Error(await extractError(res));
const err = await res.json() as { error: string };
throw new Error(typeof err.error === "string" ? err.error : JSON.stringify(err.error));
}
return res.json(); return res.json();
} }
export async function getStats(): Promise<GlobalStats> { export async function getStats(): Promise<GlobalStats> {
const res = await fetch(`${BASE}/status`); const res = await fetch(`${BASE}/status`);
if (!res.ok) throw new Error(await extractError(res));
return res.json(); return res.json();
} }
@ -94,15 +107,15 @@ export function connectSSE(
const es = new EventSource(`${BASE}/status/stream`); const es = new EventSource(`${BASE}/status/stream`);
es.addEventListener("torrent", (e) => { es.addEventListener("torrent", (e) => {
onTorrent(JSON.parse(e.data)); try { onTorrent(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
}); });
es.addEventListener("stats", (e) => { es.addEventListener("stats", (e) => {
onStats(JSON.parse(e.data)); try { onStats(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
}); });
es.addEventListener("torrents", (e) => { es.addEventListener("torrents", (e) => {
onTorrents(JSON.parse(e.data)); try { onTorrents(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
}); });
return es; return es;