mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-11 03:59:09 +00:00
fix(aria2): harden per-transfer DNS fallback (issue #35)
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
|
||||
const platform = {
|
||||
darwin: 'apple-darwin',
|
||||
linux: 'unknown-linux-gnu',
|
||||
win32: 'pc-windows-msvc',
|
||||
}[process.platform];
|
||||
if (!arch || !platform) {
|
||||
throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
|
||||
}
|
||||
const targetTriple = `${arch}-${platform}`;
|
||||
const argumentIndex = process.argv.indexOf('--binary');
|
||||
const binaryPath = path.resolve(
|
||||
argumentIndex >= 0
|
||||
? process.argv[argumentIndex + 1]
|
||||
: path.join(
|
||||
repoRoot,
|
||||
'src-tauri',
|
||||
'binaries',
|
||||
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
|
||||
}
|
||||
|
||||
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function availablePort() {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
const port = address && typeof address !== 'string' ? address.port : undefined;
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
if (!port) throw new Error('Could not reserve a local port');
|
||||
return port;
|
||||
}
|
||||
|
||||
async function rpc(port, secret, method, params = []) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
params: [`token:${secret}`, ...params],
|
||||
}),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
|
||||
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
function childExited(child) {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
async function waitForChildExit(child, timeoutMs = 3000) {
|
||||
if (childExited(child)) return true;
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
let timer;
|
||||
const finish = result => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.off('exit', onExit);
|
||||
resolve(result);
|
||||
};
|
||||
const onExit = () => finish(true);
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
child.once('exit', onExit);
|
||||
if (childExited(child)) finish(true);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRpc(port, secret) {
|
||||
const deadline = Date.now() + 10000;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await rpc(port, secret, 'aria2.getVersion');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
function bencode(value) {
|
||||
if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]);
|
||||
if (typeof value === 'string') return bencode(Buffer.from(value));
|
||||
if (typeof value === 'number') return Buffer.from(`i${value}e`);
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
||||
return Buffer.concat([
|
||||
Buffer.from('d'),
|
||||
...entries.flatMap(([key, child]) => [bencode(key), bencode(child)]),
|
||||
Buffer.from('e'),
|
||||
]);
|
||||
}
|
||||
throw new Error(`Unsupported bencode value: ${typeof value}`);
|
||||
}
|
||||
|
||||
async function stop(child, port, secret) {
|
||||
if (!child || childExited(child)) return;
|
||||
try {
|
||||
await rpc(port, secret, 'aria2.shutdown');
|
||||
} catch {
|
||||
// The process may already have exited.
|
||||
}
|
||||
let exited = await waitForChildExit(child);
|
||||
if (!exited) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
} catch {
|
||||
// The process may have exited between the timeout and taskkill.
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited && process.platform !== 'win32') {
|
||||
child.kill('SIGKILL');
|
||||
exited = await waitForChildExit(child);
|
||||
}
|
||||
if (!exited) {
|
||||
throw new Error(`Aria2 process ${child.pid} did not exit after forced cleanup`);
|
||||
}
|
||||
}
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-resolver-'));
|
||||
const secret = `firelink-resolver-${crypto.randomUUID()}`;
|
||||
const rpcPort = await availablePort();
|
||||
const contentServer = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-length': '1' });
|
||||
response.end('x');
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
contentServer.once('error', reject);
|
||||
contentServer.listen({ host: '127.0.0.1', port: 0 }, resolve);
|
||||
});
|
||||
const contentPort = contentServer.address().port;
|
||||
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
|
||||
const environment = fs.existsSync(libraryPath)
|
||||
? {
|
||||
...process.env,
|
||||
OPENSSL_MODULES: libraryPath,
|
||||
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
|
||||
}
|
||||
: process.env;
|
||||
const child = spawn(binaryPath, [
|
||||
'--enable-rpc=true',
|
||||
`--rpc-listen-port=${rpcPort}`,
|
||||
'--rpc-listen-all=false',
|
||||
`--rpc-secret=${secret}`,
|
||||
`--dir=${tempRoot}`,
|
||||
'--file-allocation=none',
|
||||
'--enable-dht=false',
|
||||
'--console-log-level=error',
|
||||
'--quiet=true',
|
||||
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
let stderr = '';
|
||||
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
|
||||
|
||||
try {
|
||||
const version = await waitForRpc(rpcPort, secret);
|
||||
const features = Array.isArray(version.enabledFeatures) ? version.enabledFeatures : [];
|
||||
console.log(`[INFO] aria2 ${version.version || 'unknown'}; Async DNS: ${features.includes('Async DNS') ? 'supported' : 'not advertised'}`);
|
||||
|
||||
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
|
||||
'async-dns': 'false',
|
||||
out: 'resolver-normal.bin',
|
||||
}]);
|
||||
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
|
||||
if (uriOptions['async-dns'] !== 'false') {
|
||||
throw new Error(`aria2.addUri did not retain async-dns=false: ${JSON.stringify(uriOptions)}`);
|
||||
}
|
||||
|
||||
const torrent = bencode({
|
||||
info: {
|
||||
length: 1,
|
||||
name: 'resolver-torrent.bin',
|
||||
pieces: Buffer.alloc(20),
|
||||
'piece length': 16384,
|
||||
},
|
||||
}).toString('base64');
|
||||
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
|
||||
'async-dns': 'false',
|
||||
dir: tempRoot,
|
||||
}]);
|
||||
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
|
||||
if (torrentOptions['async-dns'] !== 'false') {
|
||||
throw new Error(`aria2.addTorrent did not retain async-dns=false: ${JSON.stringify(torrentOptions)}`);
|
||||
}
|
||||
|
||||
console.log('[PASS] Aria2 retained system-resolver mode for normal and Torrent transfers');
|
||||
} catch (error) {
|
||||
const detail = stderr.trim();
|
||||
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
|
||||
} finally {
|
||||
try {
|
||||
await stop(child, rpcPort, secret);
|
||||
} finally {
|
||||
await new Promise(resolve => contentServer.close(resolve));
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user