Skip to content
Draft
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ Before opening a PR, skim [CONTRIBUTING.md](CONTRIBUTING.md); it lays out the ba

Your files stay on your disk, and nothing routes through a central server; torlink only talks to the torrent network directly. Once a download finishes it keeps seeding by default, sharing it back so the next person can find it just as easily. The network only works because people pass things along, and even a few minutes makes a real difference. If you'd rather not, opt out anytime: open the Seeding tab, press `p` to pause or stop any item, and press it again to pick it back up. Always your call.

### Search over Tor

Set `TORLINK_TOR=1` before launching and every source search goes through a local Tor SOCKS proxy (`127.0.0.1:9050`), so the indexers see Tor's address instead of yours, and their `.onion` mirrors just work. Running Tor Browser instead of the daemon? Point it at that port: `TORLINK_TOR=socks5h://127.0.0.1:9150`.

TORLINK_TOR=1 npx torlnk

This covers **search only**. Downloads still connect straight to the swarm, so peers still see your real IP — Tor can't safely carry BitTorrent, and no toggle here changes that. For the download side, use a VPN with a kill switch or a seedbox. And it fails closed: if Tor isn't running, searches error out rather than quietly falling back to a direct, de-anonymising connection.

## Star History

<a href="https://www.star-history.com/?repos=baairon%2Ftorlink&type=date&legend=top-left">
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
"ink": "^7.0.5",
"parse-torrent": "^11.0.21",
"react": "^19.2.7",
"socks": "^2.8.9",
"undici": "^7.28.0",
"webtorrent": "^2.4.1"
},
"devDependencies": {
Expand Down
5 changes: 5 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,9 @@ folder, so finished files stream to a browser or media player.
flags: --port <n> (default 9160), --host <addr> (default 127.0.0.1),
--token <secret> (required to bind a public --host; or TORLINK_FILES_TOKEN),
--dir <dir> (folder to serve; defaults to your downloads folder).

tor (search only): set TORLINK_TOR=1 to route source searches through a local
Tor SOCKS proxy (127.0.0.1:9050; set TORLINK_TOR=socks5h://host:port for another,
e.g. :9150 for Tor Browser). Hides your IP from the indexers and reaches their
.onion mirrors. Downloads still go direct — the swarm sees your IP regardless.
`;
25 changes: 25 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { render } from "ink";
import { parseCliArgs, HELP_TEXT } from "./cli/args";
import { daemonize } from "./daemon/daemonize";
import { runAttach } from "./daemon/attach";
import { installTorProxy, probeSocksPort } from "./util/proxy";
import { VERSION } from "./version";
import { App } from "./ui/App";

Expand All @@ -23,6 +24,30 @@ if (cmd.kind === "invalid") {
process.exit(1);
}

// Route source searches ("scouting") through Tor when the user opts in with
// TORLINK_TOR. Downloads still go direct — the swarm sees your IP regardless —
// so this only anonymises the search traffic to the indexers. It fails closed:
// a misconfigured proxy stops us here rather than searching over a direct,
// de-anonymising connection.
const tor = installTorProxy();
if (tor.error) {
console.error(`torlnk: ${tor.error}`);
process.exit(1);
}
if (tor.enabled && tor.proxy) {
const { host, port } = tor.proxy;
console.error(`torlnk: routing search through Tor at ${host}:${port}`);
// Best-effort heads-up; searches fail closed whether or not this warns.
void probeSocksPort(tor.proxy).then((up) => {
if (!up) {
console.error(
`torlnk: no SOCKS proxy answering at ${host}:${port} — is Tor running? ` +
`Searches will fail until it is (they will not fall back to a direct connection).`,
);
}
});
}

// Run/reattach the TUI inside a persistent tmux session (execs tmux, then exits).
if (cmd.kind === "attach") {
runAttach();
Expand Down
15 changes: 14 additions & 1 deletion src/util/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ export class HttpError extends Error {
}
}

// The fetch every source uses unless a call passes its own `fetchImpl`. Startup
// can swap it (see installTorProxy) to route all scouting through a different
// transport — a Tor SOCKS dispatcher, say — without any source knowing about it.
let defaultFetch: FetchImpl = fetch as FetchImpl;

export function setDefaultFetch(impl: FetchImpl): void {
defaultFetch = impl;
}

export function getDefaultFetch(): FetchImpl {
return defaultFetch;
}

export const RETRY_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);

const DEFAULT_RETRIES = 5;
Expand Down Expand Up @@ -86,7 +99,7 @@ export async function fetchResilient(
retries = DEFAULT_RETRIES,
baseMs = DEFAULT_BASE_MS,
capMs = DEFAULT_CAP_MS,
fetchImpl = fetch as FetchImpl,
fetchImpl = defaultFetch,
sleepImpl = realSleep,
signal,
...init
Expand Down
111 changes: 111 additions & 0 deletions src/util/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { getDefaultFetch, setDefaultFetch } from "./net";
import {
installTorProxy,
parseSocksProxy,
probeSocksPort,
resolveTorProxyUrl,
} from "./proxy";

describe("resolveTorProxyUrl", () => {
it("returns null when unset or switched off", () => {
expect(resolveTorProxyUrl(undefined)).toBeNull();
expect(resolveTorProxyUrl("")).toBeNull();
for (const off of ["0", "off", "no", "false", "OFF"]) {
expect(resolveTorProxyUrl(off)).toBeNull();
}
});

it("maps a truthy switch to Tor's default local SOCKS port", () => {
for (const on of ["1", "on", "yes", "true", "TRUE"]) {
expect(resolveTorProxyUrl(on)).toBe("socks5h://127.0.0.1:9050");
}
});

it("passes a full socks URL through untouched", () => {
expect(resolveTorProxyUrl("socks5h://127.0.0.1:9150")).toBe("socks5h://127.0.0.1:9150");
expect(resolveTorProxyUrl("socks5://box:1080")).toBe("socks5://box:1080");
});

it("upgrades a bare host or host:port to socks5h (remote DNS)", () => {
expect(resolveTorProxyUrl("127.0.0.1:9150")).toBe("socks5h://127.0.0.1:9150");
expect(resolveTorProxyUrl("torbox")).toBe("socks5h://torbox:9050");
});

it("rejects a non-SOCKS scheme", () => {
expect(resolveTorProxyUrl("http://127.0.0.1:8080")).toBeNull();
});
});

describe("parseSocksProxy", () => {
it("parses host, port, and type from each SOCKS scheme", () => {
expect(parseSocksProxy("socks5h://127.0.0.1:9050")).toEqual({
host: "127.0.0.1",
port: 9050,
type: 5,
});
expect(parseSocksProxy("socks4a://box:1080")).toEqual({ host: "box", port: 1080, type: 4 });
});

it("defaults the port to 9050 when omitted", () => {
expect(parseSocksProxy("socks5://127.0.0.1")).toEqual({
host: "127.0.0.1",
port: 9050,
type: 5,
});
});

it("rejects null, junk, non-SOCKS schemes, and bad ports", () => {
expect(parseSocksProxy(null)).toBeNull();
expect(parseSocksProxy("not a url")).toBeNull();
expect(parseSocksProxy("http://127.0.0.1:8080")).toBeNull();
expect(parseSocksProxy("socks5://127.0.0.1:0")).toBeNull();
expect(parseSocksProxy("socks5://127.0.0.1:99999")).toBeNull();
});
});

describe("installTorProxy", () => {
const original = getDefaultFetch();
afterEach(() => setDefaultFetch(original));

it("leaves the default fetch untouched when disabled", () => {
expect(installTorProxy({})).toEqual({ enabled: false });
expect(installTorProxy({ TORLINK_TOR: "0" })).toEqual({ enabled: false });
expect(getDefaultFetch()).toBe(original);
});

it("swaps in a proxied fetch when enabled", () => {
const status = installTorProxy({ TORLINK_TOR: "1" });
expect(status.enabled).toBe(true);
expect(status.proxy).toEqual({ host: "127.0.0.1", port: 9050, type: 5 });
expect(getDefaultFetch()).not.toBe(original);
});

it("fails closed on a truthy-but-invalid value: reports an error, stays direct-free", () => {
const status = installTorProxy({ TORLINK_TOR: "http://nope" });
expect(status.enabled).toBe(false);
expect(status.error).toMatch(/not a valid SOCKS proxy/);
// No proxy installed, but the caller is expected to refuse to run on error,
// so the default fetch must not have been silently swapped either way.
expect(getDefaultFetch()).toBe(original);
});
});

describe("probeSocksPort", () => {
it("resolves true when something is listening", async () => {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as net.AddressInfo;
try {
expect(await probeSocksPort({ host: "127.0.0.1", port, type: 5 })).toBe(true);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

it("resolves false when the port is closed", async () => {
// Port 1 is privileged and unbound in test environments → connection refused.
expect(await probeSocksPort({ host: "127.0.0.1", port: 1, type: 5 }, 500)).toBe(false);
});
});
129 changes: 129 additions & 0 deletions src/util/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import net from "node:net";
import { Agent, buildConnector, fetch as undiciFetch, type Dispatcher } from "undici";
import { SocksClient } from "socks";
import { setDefaultFetch, type FetchImpl } from "./net";

export interface SocksProxy {
host: string;
port: number;
type: 4 | 5;
}

const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 9050;

// Turn whatever the user put in TORLINK_TOR into a proxy URL, or null if they
// switched it off. A truthy switch (1/on/yes/true) maps to Tor's default local
// SOCKS port; `brew services start tor` and the `tor` package both listen on
// 127.0.0.1:9050, while Tor Browser uses 9150, so that's the override people
// reach for. A bare host:port is accepted and assumed to be SOCKS5 with remote
// DNS (socks5h) so .onion resolves through Tor and no lookup leaks locally.
export function resolveTorProxyUrl(raw: string | undefined): string | null {
if (!raw) return null;
const v = raw.trim();
if (v === "" || /^(0|off|no|false)$/i.test(v)) return null;
if (/^(1|on|yes|true)$/i.test(v)) return `socks5h://${DEFAULT_HOST}:${DEFAULT_PORT}`;
if (/^socks[45]h?:\/\//i.test(v)) return v;
if (v.includes("://")) return null; // some other scheme — not a SOCKS proxy
return `socks5h://${v.includes(":") ? v : `${v}:${DEFAULT_PORT}`}`;
}

export function parseSocksProxy(url: string | null): SocksProxy | null {
if (!url) return null;
let u: URL;
try {
u = new URL(url);
} catch {
return null;
}
const scheme = u.protocol.replace(/:$/, "").toLowerCase();
const type: 4 | 5 | null =
scheme === "socks4" || scheme === "socks4a"
? 4
: scheme === "socks5" || scheme === "socks5h" || scheme === "socks"
? 5
: null;
if (type === null || !u.hostname) return null;
const port = u.port ? Number(u.port) : DEFAULT_PORT;
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
return { host: u.hostname, port, type };
}

// Build an undici dispatcher that tunnels every request through the SOCKS proxy.
export function socksDispatcher(proxy: SocksProxy): Dispatcher {
const connectTls = buildConnector({});
return new Agent({
connect(opts, callback) {
const port = Number(opts.port) || (opts.protocol === "https:" ? 443 : 80);
// Hand Tor the hostname, never a pre-resolved IP, so the lookup happens at
// the exit (socks5h). That's what lets .onion work and keeps the local
// resolver from ever seeing the target.
SocksClient.createConnection({
proxy: { host: proxy.host, port: proxy.port, type: proxy.type },
command: "connect",
destination: { host: opts.hostname, port },
})
.then(({ socket }) => {
if (opts.protocol === "https:") {
// Upgrade the tunnelled TCP socket to TLS with undici's own connector.
connectTls({ ...opts, httpSocket: socket } as never, callback);
} else {
callback(null, socket.setNoDelay());
}
})
.catch((err: unknown) => callback(err as Error, null));
},
});
}

export function makeProxiedFetch(proxy: SocksProxy): FetchImpl {
const dispatcher = socksDispatcher(proxy);
return (url, init) =>
undiciFetch(url, { ...(init as object), dispatcher }) as unknown as Promise<Response>;
}

// Best-effort check that something is listening on the SOCKS port. Used only to
// warn the user; it never gates whether the proxy is enforced.
export function probeSocksPort(proxy: SocksProxy, timeoutMs = 1500): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ host: proxy.host, port: proxy.port });
const done = (ok: boolean): void => {
socket.removeAllListeners();
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => done(true));
socket.once("timeout", () => done(false));
socket.once("error", () => done(false));
});
}

export interface TorStatus {
enabled: boolean;
url?: string;
proxy?: SocksProxy;
error?: string;
}

// Install the Tor SOCKS proxy for all scouting (source search) requests when the
// user opts in via TORLINK_TOR. Fail *closed*: once enabled, every search fetch
// goes through Tor, so a proxy that is down makes searches error rather than
// quietly dropping back to a direct connection that would leak the user's IP —
// the whole reason they turned this on. A truthy-but-unparseable value is a
// misconfiguration, reported as an error so the caller can refuse to run instead
// of silently going direct.
export function installTorProxy(env: NodeJS.ProcessEnv = process.env): TorStatus {
const raw = env.TORLINK_TOR?.trim();
if (!raw || /^(0|off|no|false)$/i.test(raw)) return { enabled: false };
const url = resolveTorProxyUrl(raw);
const proxy = parseSocksProxy(url);
if (!url || !proxy) {
return {
enabled: false,
error: `TORLINK_TOR is set to "${raw}" but that is not a valid SOCKS proxy (try 1, or socks5h://127.0.0.1:9050).`,
};
}
setDefaultFetch(makeProxiedFetch(proxy));
return { enabled: true, url, proxy };
}