Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ Klink expands on upstream torlink with key power-user features:
- 🔍 **Peer Inspector Pane**: Press `b` on an active download to view live swarm peer connections, IP addresses, client software strings, and individual peer transfer rates.
- 🐢 **Turtle Mode (Bandwidth Throttling)**: Toggle global download and upload speed limits on the fly with a single keypress (`b`) for low-bandwidth or shared network environments.
- 📁 **File Inspection & Selective Download**: Inspect multi-file torrent contents before or during download, view nested file trees, and toggle individual files on or off.
- ℹ️ **Metadata Inspector**: View detailed metadata (like plot summaries, tracklists, or release notes) for a torrent before downloading.
- 📻 **RSS Auto-Downloader**: A background daemon for subscribing to RSS feeds and automatically downloading new items as they are released.
- ⚙️ **Advanced Search Filtering**: Filter and refine search results by properties like seeders, size, and category.
- ⏯️ **Sequential Downloading**: Toggle sequential block downloading to prioritise early pieces, enabling immediate playback of media.
- 🌐 **Network Interface Binding**: Force all torrent traffic through a specific network interface (like a VPN tunnel) to prevent IP leaks.
- 🎬 **Direct Media Streaming & Themed Web UI**: Stream video files directly over HTTP while downloading (`klink serve` / `klink files`), with an integrated web interface featuring a dark/light theme switcher.
- ✅ **Completed Tab & Smart File Organisation**: Cleanly separate active downloads, seeding items, and finished downloads with directory routing.
- ⚠️ **Action Confirmation Dialogs**: Safety confirmation prompts before destructive actions like cancelling downloads or clearing history to prevent accidental data loss.
Expand Down Expand Up @@ -65,6 +70,8 @@ A short, hand-picked list of trusted sources:
| Movies | YTS, The Pirate Bay, 1337x, BitTorrented |
| TV | EZTV, The Pirate Bay, 1337x, BitTorrented |
| Anime | Nyaa, SubsPlease |
| E-Books | The Pirate Bay, 1337x, BitTorrented |
| Audiobooks | The Pirate Bay, 1337x, BitTorrented |

Games are the only category that can run code, so they come from FitGirl alone, a repacker with a long, trusted track record; everything else is plain video and subtitles. If a source is down, the search carries on without it, and Klink tells you which one is offline.

Expand Down Expand Up @@ -114,6 +121,11 @@ Before opening a PR, skim [CONTRIBUTING.md](CONTRIBUTING.md); it lays out the ba
| Peer Inspector | ❌ | ✅ |
| Turtle Mode | ❌ | ✅ |
| File Inspection | ❌ | ✅ |
| Metadata Inspector | ❌ | ✅ |
| RSS Auto-Downloader | ❌ | ✅ |
| Advanced Search Filtering | ❌ | ✅ |
| Sequential Downloading | ❌ | ✅ |
| Network Interface Binding | ❌ | ✅ |
| Media Streaming | ❌ | ✅ |
| Completion Tab | ❌ | ✅ |
| Confirmation Dialogs | ❌ | ✅ |
Expand Down
3 changes: 3 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface Config {
throttleUploadLimit: number;
webServerEnabled: boolean;
webServerPort: number;
bindAddress: string | null;
autoDownloads: AutoDownloadRule[];
autoDownloadIntervalMs: number;
}
Expand All @@ -30,6 +31,7 @@ export const defaultConfig: Config = {
throttleUploadLimit: 500000,
webServerEnabled: false,
webServerPort: 8080,
bindAddress: null,
autoDownloads: [],
autoDownloadIntervalMs: 3600000, // 1 hour default
};
Expand All @@ -56,6 +58,7 @@ export async function loadConfig(): Promise<Config> {
throttleUploadLimit: typeof parsed.throttleUploadLimit === "number" ? parsed.throttleUploadLimit : 500000,
webServerEnabled: typeof parsed.webServerEnabled === "boolean" ? parsed.webServerEnabled : false,
webServerPort: typeof parsed.webServerPort === "number" ? parsed.webServerPort : 8080,
bindAddress: typeof parsed.bindAddress === "string" ? parsed.bindAddress : null,
autoDownloads: Array.isArray(parsed.autoDownloads)
? (parsed.autoDownloads.filter((a: any) => typeof a?.source === "string" && typeof a?.query === "string" && typeof a?.match === "string") as AutoDownloadRule[])
: [],
Expand Down
1 change: 1 addition & 0 deletions src/daemon/autodownload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("AutoDownloader", () => {
throttleUploadLimit: 0,
webServerEnabled: false,
webServerPort: 8080,
bindAddress: null,
autoDownloads: [],
autoDownloadIntervalMs: 1000,
};
Expand Down
3 changes: 3 additions & 0 deletions src/daemon/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export interface Runtime {
// back to the saved config's dir when the caller doesn't override it.
export async function startRuntime(overrideDir?: string): Promise<Runtime> {
const cfg = await loadConfig();
const { applyNetworkBinding } = await import("../download/network");
applyNetworkBinding(cfg);

const queue = new DownloadQueue();
queue.setTrackers(cfg.trackers);
// Crash-boot breaker, mirroring the TUI: a marker left by the previous run
Expand Down
54 changes: 54 additions & 0 deletions src/download/network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const { mockNetConnect, mockDgramBind, mockDgramCreateSocket } = vi.hoisted(() => {
const mockDgramBind = vi.fn();
return {
mockNetConnect: vi.fn(),
mockDgramBind,
mockDgramCreateSocket: vi.fn(() => ({ bind: mockDgramBind })),
};
});

vi.mock("node:net", () => ({
default: { connect: mockNetConnect },
connect: mockNetConnect,
}));

vi.mock("node:dgram", () => ({
default: { createSocket: mockDgramCreateSocket },
createSocket: mockDgramCreateSocket,
}));

import net from "node:net";
import dgram from "node:dgram";
import { applyNetworkBinding } from "./network";
import type { Config } from "../config/config";
import { defaultConfig } from "../config/config";

describe("Network Binding", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("injects localAddress into net.connect when configured", () => {
const cfg: Config = { ...defaultConfig, bindAddress: "10.8.0.2" };
applyNetworkBinding(cfg);

net.connect(80, "example.com");

expect(mockNetConnect).toHaveBeenCalledWith(
expect.objectContaining({ port: 80, host: "example.com", localAddress: "10.8.0.2" })
);
});

it("injects address into dgram socket bind when configured", () => {
const cfg: Config = { ...defaultConfig, bindAddress: "10.8.0.2" };
applyNetworkBinding(cfg);

const socket = dgram.createSocket("udp4");
expect(mockDgramCreateSocket).toHaveBeenCalled();

socket.bind(1234);
expect(mockDgramBind).toHaveBeenCalledWith(1234, "10.8.0.2");
});
});
82 changes: 82 additions & 0 deletions src/download/network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import net from "node:net";
import dgram from "node:dgram";
import type { Config } from "../config/config";

const originalNetConnect = net.connect;
const originalNetCreateConnection = net.createConnection;
const originalDgramCreateSocket = dgram.createSocket;

let activeBindAddress: string | null = null;
let patched = false;

export function applyNetworkBinding(config: Config) {
activeBindAddress = config.bindAddress;

if (patched) return;
patched = true;

// Patch net.connect and net.createConnection
const patchNet = (original: any) => {
return function (this: any, ...args: any[]) {
if (activeBindAddress) {
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
args[0].localAddress = activeBindAddress;
} else if (typeof args[0] === "number" || typeof args[0] === "string") {
// It's a port or path, we need to inject an options object
const options = {
[typeof args[0] === "number" ? "port" : "path"]: args[0],
host: typeof args[1] === "string" ? args[1] : undefined,
localAddress: activeBindAddress,
};
// The callback might be arg 1 or arg 2
const cb = typeof args[args.length - 1] === "function" ? args[args.length - 1] : undefined;
args = cb ? [options, cb] : [options];
}
}
return original.apply(this, args);
};
};

(net as any).connect = patchNet(originalNetConnect);
(net as any).createConnection = patchNet(originalNetCreateConnection);

// Patch dgram.createSocket
(dgram as any).createSocket = function (this: any, ...args: any[]) {
const socket = originalDgramCreateSocket.apply(this, args as any);
if (activeBindAddress) {
const originalBind = socket.bind;
socket.bind = function (this: any, ...bindArgs: any[]) {
if (bindArgs.length > 0 && typeof bindArgs[0] === "object" && bindArgs[0] !== null) {
if (!bindArgs[0].address) bindArgs[0].address = activeBindAddress;
} else if (typeof bindArgs[0] === "number") {
const port = bindArgs[0];
let address = activeBindAddress;
let cb = undefined;

if (typeof bindArgs[1] === "string") {
// Some modules might bind to localhost explicitly, but we want to force our bind.
// Wait, forcing localhost to VPN interface breaks local IPC!
// We should only force bind if it's binding to 0.0.0.0 or if it's leaving the host.
// But usually BitTorrent sockets just bind to port 0.
if (bindArgs[1] === "0.0.0.0" || bindArgs[1] === "::") {
address = activeBindAddress;
} else {
address = bindArgs[1];
}
cb = bindArgs[2];
} else if (typeof bindArgs[1] === "function") {
cb = bindArgs[1];
}

bindArgs = cb ? [port, address, cb] : [port, address];
} else if (bindArgs.length === 0 || (bindArgs.length === 1 && typeof bindArgs[0] === "function")) {
// Binding to random port
const cb = bindArgs[0];
bindArgs = cb ? [0, activeBindAddress, cb] : [0, activeBindAddress];
}
return (originalBind as any).apply(this, bindArgs);
};
}
return socket;
};
}
53 changes: 42 additions & 11 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
disarmBootMarker,
wasBootInterrupted,
} from "../download/bootguard";
import { applyNetworkBinding } from "../download/network";
import { logCrash } from "../util/crashlog";
import { parseInput } from "../sources/magnet";
import { magnetFromTorrentFile } from "../sources/torrentFile";
Expand Down Expand Up @@ -50,6 +51,7 @@ import { Files } from "./components/Files";
import { Splash } from "./views/Splash";
import { FolderPrompt } from "./components/FolderPrompt";
import { TrackersPrompt } from "./components/TrackersPrompt";
import { NetworkPrompt } from "./components/NetworkPrompt";
import { ConfirmPrompt } from "./components/ConfirmPrompt";
import { footerHints } from "./keymap";
import { COLOR, ICON } from "./theme";
Expand Down Expand Up @@ -106,7 +108,7 @@ export function App({
const [showHelp, setShowHelp] = useState(false);
const [editingFolder, setEditingFolder] = useState(false);
const [editingTrackers, setEditingTrackers] = useState(false);
// A result waiting on the "download to" prompt (D); null when the prompt is
const [editingNetwork, setEditingNetwork] = useState(false);
// closed. lastDownloadToDir pre-fills the next prompt so queueing a batch
// into the same alternate folder only costs one typed path per session.
const [pendingDownload, setPendingDownload] = useState<{
Expand Down Expand Up @@ -144,6 +146,7 @@ export function App({
let alive = true;
void (async () => {
const cfg = await loadConfig();
applyNetworkBinding(cfg);
const q = new DownloadQueue();
q.setTrackers(cfg.trackers);
// Crash-boot breaker: a marker left behind by the previous boot means it
Expand Down Expand Up @@ -255,6 +258,7 @@ export function App({
const setConfig = useCallback(
(c: Config) => {
setConfigState(c);
applyNetworkBinding(c);
queue?.setTrackers(c.trackers);
if (autoDownloader) {
autoDownloader.setConfig(c);
Expand Down Expand Up @@ -293,6 +297,24 @@ export function App({
setEditingTrackers(false);
}, []);

const closeNetworkPrompt = useCallback(() => {
setEditingNetwork(false);
}, []);

const setBindAddress = useCallback(
(address: string | null) => {
closeNetworkPrompt();
if (!config) return;
if (address === config.bindAddress) {
setNotice("Network binding unchanged.");
return;
}
setConfig({ ...config, bindAddress: address });
setNotice(address ? `Network bound to: ${address}` : "Network binding cleared.");
},
[config, setConfig, closeNetworkPrompt]
);

const setTrackers = useCallback(
(list: string[]) => {
closeTrackersPrompt();
Expand Down Expand Up @@ -537,7 +559,7 @@ export function App({
submitQuery,
section,
setSection,
region: showHelp || editingFolder || editingTrackers || pendingDownload || pendingConfirm ? "help" : region,
region: showHelp || editingFolder || editingTrackers || editingNetwork || pendingDownload || pendingConfirm ? "help" : region,
setRegion,
captureMode,
setCaptureMode,
Expand Down Expand Up @@ -586,6 +608,7 @@ export function App({
showHelp,
editingFolder,
editingTrackers,
editingNetwork,
pendingDownload,
captureMode,
downloadFocus,
Expand Down Expand Up @@ -620,7 +643,7 @@ export function App({
quitAll();
return;
}
if (editingFolder || editingTrackers || pendingDownload || pendingConfirm) return; // the prompt owns input (its own esc + enter)
if (editingFolder || editingTrackers || editingNetwork || pendingDownload || pendingConfirm) return; // the prompt owns input (its own esc + enter)
if (captureMode === "text") return;
if (showHelp) {
setShowHelp(false);
Expand Down Expand Up @@ -648,22 +671,19 @@ export function App({
setEditingTrackers(true);
return;
}
if (input === "m") {
void pasteFromClipboard();
if (input === "n") {
setShowHelp(false);
setEditingNetwork(true);
return;
}
if (input === "w") {
if (inspectingPeersId) setInspectingPeersId(null);
if (input === "m") {
void pasteFromClipboard();
return;
}
if (input === "w") {
if (inspectingPeersId) setInspectingPeersId(null);
return;
}
if (input === "w") {
if (inspectingId) setInspectingId(null);
return;
}
if (key.tab) {
if (inspectingId) setInspectingId(null);
if (inspectingPeersId) setInspectingPeersId(null);
Expand Down Expand Up @@ -796,6 +816,17 @@ export function App({
</Box>
) : null}

{editingNetwork ? (
<Box marginTop={1}>
<NetworkPrompt
width={Math.max(24, Math.min(cols - 4, 62))}
value={store.config.bindAddress}
onSubmit={setBindAddress}
onCancel={closeNetworkPrompt}
/>
</Box>
) : null}

{pendingDownload ? (
<Box marginTop={1}>
<FolderPrompt
Expand Down
Loading