feat: Fix all the security issues
This commit is contained in:
parent
1859ef3d24
commit
b9e04f6601
|
|
@ -47,17 +47,19 @@ export function configRouter(config: Config) {
|
|||
return res.badRequest(c, "minUploadRateBytesPerSec must be ≤ maxUploadRateBytesPerSec");
|
||||
}
|
||||
|
||||
// Apply patch to the live config object (affects all new seeders)
|
||||
Object.assign(config, patch);
|
||||
|
||||
// Persist to disk so the next container start picks it up
|
||||
// Persist to disk first — only apply to live config if write succeeds
|
||||
const merged = { ...config, ...patch };
|
||||
try {
|
||||
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||
writeFileSync(CONFIG_PATH, dump(config), "utf8");
|
||||
writeFileSync(CONFIG_PATH, dump(merged), "utf8");
|
||||
} catch (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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import { Hono } from "hono";
|
|||
import { streamSSE } from "hono/streaming";
|
||||
import type { SeederRegistry } from "../SeederRegistry.js";
|
||||
|
||||
/** Maximum number of concurrent SSE connections */
|
||||
const MAX_SSE_CONNECTIONS = 50;
|
||||
|
||||
export function statusRouter(registry: SeederRegistry) {
|
||||
const app = new Hono();
|
||||
let activeConnections = 0;
|
||||
|
||||
/** Global stats snapshot */
|
||||
app.get("/", (c) => {
|
||||
|
|
@ -17,9 +21,25 @@ export function statusRouter(registry: SeederRegistry) {
|
|||
* Events:
|
||||
* - "stats" with global stats (every change)
|
||||
* - "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) => {
|
||||
if (activeConnections >= MAX_SSE_CONNECTIONS) {
|
||||
return c.text("Too many SSE connections", 429);
|
||||
}
|
||||
activeConnections++;
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
let cleaned = false;
|
||||
function cleanup() {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
activeConnections--;
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
// Send initial snapshot
|
||||
await stream.writeSSE({
|
||||
event: "stats",
|
||||
|
|
@ -43,27 +63,23 @@ export function statusRouter(registry: SeederRegistry) {
|
|||
data: JSON.stringify(registry.globalStats()),
|
||||
});
|
||||
} catch {
|
||||
// client disconnected
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep alive with a comment every 30s
|
||||
// Keep alive with a ping every 30s
|
||||
const keepAlive = setInterval(async () => {
|
||||
try {
|
||||
await stream.writeSSE({ event: "ping", data: "" });
|
||||
} catch {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Clean up when client disconnects
|
||||
stream.onAbort(() => {
|
||||
clearInterval(keepAlive);
|
||||
unsubscribe();
|
||||
});
|
||||
stream.onAbort(() => cleanup());
|
||||
|
||||
// Hold the connection open
|
||||
// Hold the connection open until abort
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ import { Hono } from "hono";
|
|||
import { res } from "../response.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) {
|
||||
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 */
|
||||
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 file = body.torrent;
|
||||
|
||||
|
|
@ -21,6 +37,10 @@ export function torrentsRouter(registry: SeederRegistry) {
|
|||
|
||||
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 {
|
||||
const state = await registry.addTorrent(buf);
|
||||
return res.created(c, state);
|
||||
|
|
@ -33,6 +53,8 @@ export function torrentsRouter(registry: SeederRegistry) {
|
|||
/** Get stats + announce history for a specific torrent */
|
||||
app.get("/:hash", (c) => {
|
||||
const { hash } = c.req.param();
|
||||
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||
|
||||
const list = registry.listTorrents();
|
||||
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 */
|
||||
app.post("/:hash/pause", async (c) => {
|
||||
const { hash } = c.req.param();
|
||||
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||
const ok = await registry.pauseTorrent(hash);
|
||||
if (!ok) return res.notFound(c);
|
||||
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 */
|
||||
app.post("/:hash/resume", async (c) => {
|
||||
const { hash } = c.req.param();
|
||||
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||
const ok = await registry.resumeTorrent(hash);
|
||||
if (!ok) return res.notFound(c);
|
||||
return c.json({ ok: true });
|
||||
|
|
@ -65,6 +89,7 @@ export function torrentsRouter(registry: SeederRegistry) {
|
|||
/** Remove a torrent — sends 'stopped' to trackers then removes it */
|
||||
app.delete("/:hash", async (c) => {
|
||||
const { hash } = c.req.param();
|
||||
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||
const removed = await registry.removeTorrent(hash);
|
||||
|
||||
if (!removed) {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ export function decode(data: Buffer | Uint8Array): BencodeValue {
|
|||
}
|
||||
|
||||
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];
|
||||
|
||||
if (byte === 0x69) {
|
||||
|
|
@ -68,11 +72,17 @@ function decodeInteger(buf: Buffer, offset: number): DecodeResult {
|
|||
function decodeString(buf: Buffer, offset: number): DecodeResult {
|
||||
const colonIdx = buf.indexOf(0x3a, offset); // ':'
|
||||
if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`);
|
||||
const len = Number(buf.subarray(offset, colonIdx).toString("ascii"));
|
||||
if (!Number.isInteger(len) || len < 0) {
|
||||
throw new Error(`Invalid string length at offset ${offset}`);
|
||||
const lenStr = buf.subarray(offset, colonIdx).toString("ascii");
|
||||
const len = Number.parseInt(lenStr, 10);
|
||||
if (!Number.isInteger(len) || len < 0 || lenStr !== String(len)) {
|
||||
throw new Error(`Invalid string length "${lenStr}" at offset ${offset}`);
|
||||
}
|
||||
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));
|
||||
return { value, end: start + len };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export class FakeSeeder extends EventEmitter {
|
|||
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);
|
||||
return super.on(event, listener) as this;
|
||||
}
|
||||
private readonly torrent: TorrentFileInfo;
|
||||
private readonly profile: ClientProfile;
|
||||
|
|
@ -104,14 +104,7 @@ export class FakeSeeder extends EventEmitter {
|
|||
this.setStatus("running");
|
||||
|
||||
// Start speed tick
|
||||
this.lastTickTime = Date.now();
|
||||
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);
|
||||
this.startTickTimer();
|
||||
|
||||
// First announce: started
|
||||
await this.doAnnounce("started");
|
||||
|
|
@ -154,14 +147,7 @@ export class FakeSeeder extends EventEmitter {
|
|||
this.setStatus("running");
|
||||
|
||||
// Restart speed tick
|
||||
this.lastTickTime = Date.now();
|
||||
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);
|
||||
this.startTickTimer();
|
||||
|
||||
// Re-announce as started then resume loop
|
||||
await this.doAnnounce("started");
|
||||
|
|
@ -170,7 +156,7 @@ export class FakeSeeder extends EventEmitter {
|
|||
|
||||
async stop(): Promise<void> {
|
||||
if (this.status === "stopped" || this.status === "stopping") return;
|
||||
const waspaused = this.status === "paused";
|
||||
const wasPaused = this.status === "paused";
|
||||
this.setStatus("stopping");
|
||||
|
||||
if (this.announceTimer) {
|
||||
|
|
@ -183,7 +169,7 @@ export class FakeSeeder extends EventEmitter {
|
|||
}
|
||||
|
||||
// If already paused, trackers were already notified — skip the stopped announce
|
||||
if (!waspaused) {
|
||||
if (!wasPaused) {
|
||||
await Promise.allSettled(
|
||||
this.trackers.map((t) =>
|
||||
t.stop({
|
||||
|
|
@ -204,6 +190,22 @@ export class FakeSeeder extends EventEmitter {
|
|||
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 () => {
|
||||
|
|
@ -258,19 +260,21 @@ export class FakeSeeder extends EventEmitter {
|
|||
};
|
||||
this.emit("announce", record);
|
||||
} 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
|
||||
if (tracker.scrape) {
|
||||
if (tracker.scrape && this.status === "running") {
|
||||
tracker
|
||||
.scrape(this.torrent.infoHash)
|
||||
.then((stats) => {
|
||||
if (stats) {
|
||||
if (stats && this.status === "running") {
|
||||
this.lastSeeder = stats.seeders;
|
||||
this.lastLeecher = stats.leechers;
|
||||
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[] {
|
||||
return urls
|
||||
.map((url) => {
|
||||
.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);
|
||||
}
|
||||
|
|
@ -296,7 +333,7 @@ function buildTrackers(urls: string[]): ITracker[] {
|
|||
return new UdpTracker(url);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed URLs
|
||||
console.warn(`[tracker] Skipping malformed URL: ${url}`);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ export interface TorrentFileEntry {
|
|||
}
|
||||
|
||||
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 ---
|
||||
const infoRaw = rawSlice(buf, "info");
|
||||
|
|
@ -36,15 +40,24 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
|
|||
const infoHashHex = createHash("sha1").update(infoRaw).digest("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;
|
||||
if (!info) throw new Error("Missing 'info' dict");
|
||||
|
||||
const name = bufToStr(info.name as Buffer);
|
||||
const pieceLength = info["piece length"] as number;
|
||||
if (!info.name || !Buffer.isBuffer(info.name)) {
|
||||
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 ---
|
||||
const announce = torrent.announce ? bufToStr(torrent.announce as Buffer) : "";
|
||||
const announceList = flattenAnnounceList(torrent["announce-list"] as BencodeList | undefined);
|
||||
const announce = Buffer.isBuffer(torrent.announce) ? torrent.announce.toString("utf8") : "";
|
||||
const announceList = flattenAnnounceList(torrent["announce-list"]);
|
||||
if (announce && !announceList.includes(announce)) {
|
||||
announceList.unshift(announce);
|
||||
}
|
||||
|
|
@ -55,20 +68,33 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
|
|||
const isMultiFile = "files" in info;
|
||||
|
||||
if (isMultiFile) {
|
||||
const fileList = info.files as BencodeList;
|
||||
files = fileList.map((f) => {
|
||||
if (!Array.isArray(info.files)) {
|
||||
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 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 {
|
||||
path: pathParts.join("/"),
|
||||
size: fd.length as number,
|
||||
size: fd.length,
|
||||
};
|
||||
});
|
||||
totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
} else {
|
||||
const length = info.length as number;
|
||||
files = [{ path: name, size: length }];
|
||||
totalSize = length;
|
||||
if (typeof info.length !== "number") {
|
||||
throw new Error("Missing 'info.length' for single-file torrent");
|
||||
}
|
||||
files = [{ path: name, size: info.length }];
|
||||
totalSize = info.length;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -84,17 +110,19 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
|
|||
};
|
||||
}
|
||||
|
||||
function bufToStr(buf: Buffer): string {
|
||||
return Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
||||
function bufToStr(val: unknown): string {
|
||||
if (Buffer.isBuffer(val)) return val.toString("utf8");
|
||||
if (typeof val === "string") return val;
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function flattenAnnounceList(announceList: BencodeList | undefined): string[] {
|
||||
if (!announceList) return [];
|
||||
function flattenAnnounceList(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const urls: string[] = [];
|
||||
for (const tier of announceList) {
|
||||
for (const tier of raw) {
|
||||
if (Array.isArray(tier)) {
|
||||
for (const url of tier) {
|
||||
const s = bufToStr(url as Buffer);
|
||||
const s = bufToStr(url);
|
||||
if (s && !urls.includes(s)) urls.push(s);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.j
|
|||
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.
|
||||
*
|
||||
|
|
@ -29,25 +35,37 @@ export class HttpTracker implements ITracker {
|
|||
"User-Agent": params.userAgent,
|
||||
"Accept-Encoding": "gzip",
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
|
||||
}
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
const decoded = decode(body) as BencodeDict;
|
||||
|
||||
if (decoded["failure reason"]) {
|
||||
throw new Error(`Tracker failure: ${(decoded["failure reason"] as Buffer).toString("utf8")}`);
|
||||
const arrayBuf = await res.arrayBuffer();
|
||||
if (arrayBuf.byteLength > MAX_RESPONSE_SIZE) {
|
||||
throw new Error(`Tracker response too large (${arrayBuf.byteLength} bytes)`);
|
||||
}
|
||||
|
||||
if (decoded["tracker id"]) {
|
||||
this.trackerId = (decoded["tracker id"] as Buffer).toString("utf8");
|
||||
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}`);
|
||||
}
|
||||
|
||||
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> {
|
||||
|
|
@ -73,22 +91,30 @@ export class HttpTracker implements ITracker {
|
|||
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
|
||||
const res = await fetch(fullUrl, {
|
||||
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
const decoded = decode(body) as BencodeDict;
|
||||
if (!decoded.files) return null;
|
||||
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(decoded.files as BencodeDict);
|
||||
const entries = Object.values(dict.files as BencodeDict);
|
||||
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 {
|
||||
seeders: (entry.complete as number) ?? 0,
|
||||
leechers: (entry.incomplete as number) ?? 0,
|
||||
completed: (entry.downloaded as number) ?? 0,
|
||||
seeders: typeof complete === "number" ? complete : 0,
|
||||
leechers: typeof incomplete === "number" ? incomplete : 0,
|
||||
completed: typeof downloaded === "number" ? downloaded : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -155,16 +181,18 @@ function percentEncode(buf: Buffer): string {
|
|||
}
|
||||
|
||||
function parseResponse(dict: BencodeDict): TrackerResponse {
|
||||
const interval = (dict.interval as number) ?? 1800;
|
||||
const minInterval = dict["min interval"] as number | undefined;
|
||||
const seeders = dict.complete as number | undefined;
|
||||
const leechers = dict.incomplete as number | undefined;
|
||||
const warning = dict["warning message"]
|
||||
? (dict["warning message"] as Buffer).toString("utf8")
|
||||
: undefined;
|
||||
const trackerId = dict["tracker id"]
|
||||
? (dict["tracker id"] as Buffer).toString("utf8")
|
||||
: undefined;
|
||||
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);
|
||||
|
||||
|
|
@ -177,7 +205,7 @@ function parsePeers(raw: unknown): TrackerPeer[] {
|
|||
// Compact format: 6 bytes per peer (4 IP + 2 port)
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
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 port = raw.readUInt16BE(i + 4);
|
||||
peers.push({ ip, port });
|
||||
|
|
@ -187,13 +215,12 @@ function parsePeers(raw: unknown): TrackerPeer[] {
|
|||
|
||||
// Dict format (older trackers)
|
||||
if (Array.isArray(raw)) {
|
||||
return (raw as BencodeList).map((p) => {
|
||||
const pd = p as BencodeDict;
|
||||
return {
|
||||
ip: (pd.ip as Buffer).toString("ascii"),
|
||||
port: pd.port as number,
|
||||
};
|
||||
});
|
||||
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 [];
|
||||
|
|
|
|||
|
|
@ -22,12 +22,17 @@ if (config.autoLoad) {
|
|||
const state = await registry.addTorrent(buf);
|
||||
console.log(` ✓ ${state.name} [${state.infoHashHex.slice(0, 8)}]`);
|
||||
} 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
function trackerHostname(url: string): string {
|
||||
try { return new URL(url).hostname; } catch { return url; }
|
||||
}
|
||||
|
||||
function statusColor(status: TorrentState["status"]): string {
|
||||
switch (status) {
|
||||
case "running": return "#4ade80";
|
||||
|
|
@ -65,7 +69,7 @@
|
|||
{#each torrents as t (t.infoHashHex)}
|
||||
<tr>
|
||||
<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>
|
||||
<span class="badge" style="color: {statusColor(t.status)}">{t.status}</span>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,22 @@ export interface GlobalStats {
|
|||
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[]> {
|
||||
const res = await fetch(`${BASE}/torrents`);
|
||||
if (!res.ok) throw new Error(await extractError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -34,23 +48,23 @@ export async function addTorrent(file: File): Promise<TorrentState> {
|
|||
const form = new FormData();
|
||||
form.append("torrent", file);
|
||||
const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form });
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error: string };
|
||||
throw new Error(err.error);
|
||||
}
|
||||
if (!res.ok) throw new Error(await extractError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
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 {
|
||||
|
|
@ -65,6 +79,7 @@ export interface AppConfig {
|
|||
|
||||
export async function getConfig(): Promise<AppConfig> {
|
||||
const res = await fetch(`${BASE}/config`);
|
||||
if (!res.ok) throw new Error(await extractError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -74,15 +89,13 @@ export async function updateConfig(patch: Partial<Pick<AppConfig, "minUploadRate
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error: string };
|
||||
throw new Error(typeof err.error === "string" ? err.error : JSON.stringify(err.error));
|
||||
}
|
||||
if (!res.ok) throw new Error(await extractError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getStats(): Promise<GlobalStats> {
|
||||
const res = await fetch(`${BASE}/status`);
|
||||
if (!res.ok) throw new Error(await extractError(res));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -94,15 +107,15 @@ export function connectSSE(
|
|||
const es = new EventSource(`${BASE}/status/stream`);
|
||||
|
||||
es.addEventListener("torrent", (e) => {
|
||||
onTorrent(JSON.parse(e.data));
|
||||
try { onTorrent(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
|
||||
});
|
||||
|
||||
es.addEventListener("stats", (e) => {
|
||||
onStats(JSON.parse(e.data));
|
||||
try { onStats(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
|
||||
});
|
||||
|
||||
es.addEventListener("torrents", (e) => {
|
||||
onTorrents(JSON.parse(e.data));
|
||||
try { onTorrents(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
|
||||
});
|
||||
|
||||
return es;
|
||||
|
|
|
|||
Loading…
Reference in New Issue