mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
336 lines
9.8 KiB
TypeScript
336 lines
9.8 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {randomInt, randomUUID} from 'node:crypto';
|
|
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
|
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
|
import {Logger} from '../Logger';
|
|
|
|
const ONIONOO_URL =
|
|
'https://onionoo.torproject.org/details?type=relay&running=true&flag=Exit&fields=exit_addresses,or_addresses';
|
|
const FETCH_TIMEOUT_MS = 30000;
|
|
const REFRESH_INTERVAL_MS = 30 * 60 * 1000;
|
|
const REFRESH_JITTER_MS = 2 * 60 * 1000;
|
|
const OPPORTUNISTIC_REFRESH_MIN_MS = 60000;
|
|
const OPPORTUNISTIC_REFRESH_MAX_MS = 3 * 60000;
|
|
const KV_PAYLOAD_KEY = 'fluxer:tor_exit_list:payload';
|
|
const KV_LOCK_KEY = 'fluxer:tor_exit_list:lock';
|
|
const LOCK_TTL_SECONDS = 5 * 60;
|
|
const PAYLOAD_TTL_SECONDS = 60 * 60;
|
|
const HYDRATE_WAIT_MS = 60000;
|
|
const HYDRATE_POLL_MIN_MS = 500;
|
|
const HYDRATE_POLL_MAX_MS = 5000;
|
|
|
|
type FetchResult =
|
|
| {
|
|
kind: 'fetched';
|
|
payload: string;
|
|
}
|
|
| {
|
|
kind: 'not_modified';
|
|
}
|
|
| {
|
|
kind: 'failed';
|
|
};
|
|
|
|
class TorExitListCache {
|
|
private ipv4Exits: ReadonlySet<string> = new Set();
|
|
private ipv6Exits: ReadonlySet<string> = new Set();
|
|
private kvClient: IKVProvider | null = null;
|
|
private refreshTimer: NodeJS.Timeout | null = null;
|
|
private initPromise: Promise<void> | null = null;
|
|
private lastModified: string | null = null;
|
|
private inflightFetch: AbortController | null = null;
|
|
|
|
setKvClient(kv: IKVProvider | null): void {
|
|
this.kvClient = kv;
|
|
}
|
|
|
|
initialize(): Promise<void> {
|
|
if (!this.initPromise) {
|
|
this.initPromise = this.doInitialize().catch((err) => {
|
|
this.initPromise = null;
|
|
throw err;
|
|
});
|
|
}
|
|
return this.initPromise;
|
|
}
|
|
|
|
shutdown(): void {
|
|
if (this.refreshTimer) {
|
|
clearTimeout(this.refreshTimer);
|
|
this.refreshTimer = null;
|
|
}
|
|
if (this.inflightFetch) {
|
|
this.inflightFetch.abort();
|
|
this.inflightFetch = null;
|
|
}
|
|
}
|
|
|
|
isTorExit(ip: string): boolean {
|
|
const parsed = parseIpAddress(ip);
|
|
if (!parsed) return false;
|
|
return parsed.family === 'ipv4' ? this.ipv4Exits.has(parsed.normalized) : this.ipv6Exits.has(parsed.normalized);
|
|
}
|
|
|
|
async forceRefresh(): Promise<void> {
|
|
await this.tryRefresh();
|
|
}
|
|
|
|
seedForTesting(ips: Iterable<string>): void {
|
|
this.applyPayload(Array.from(ips).join('\n'));
|
|
}
|
|
|
|
clearForTesting(): void {
|
|
this.ipv4Exits = new Set();
|
|
this.ipv6Exits = new Set();
|
|
}
|
|
|
|
private async doInitialize(): Promise<void> {
|
|
const cached = await this.readPayloadFromKv();
|
|
if (cached) {
|
|
this.applyPayload(cached);
|
|
Logger.info({ipv4Count: this.ipv4Exits.size, ipv6Count: this.ipv6Exits.size}, 'Tor exit list hydrated from KV');
|
|
this.scheduleNextRefresh(this.opportunisticRefreshDelayMs());
|
|
return;
|
|
}
|
|
await this.hydrateFromTorOrWait();
|
|
this.scheduleNextRefresh(REFRESH_INTERVAL_MS);
|
|
}
|
|
|
|
private scheduleNextRefresh(baseDelayMs: number): void {
|
|
if (this.refreshTimer) {
|
|
clearTimeout(this.refreshTimer);
|
|
this.refreshTimer = null;
|
|
}
|
|
const jitter = randomInt(REFRESH_JITTER_MS * 2) - REFRESH_JITTER_MS;
|
|
const delay = Math.max(1000, baseDelayMs + jitter);
|
|
this.refreshTimer = setTimeout(() => {
|
|
this.refreshTimer = null;
|
|
this.tryRefresh()
|
|
.catch((err) => {
|
|
Logger.warn({error: err instanceof Error ? err.message : String(err)}, 'Tor exit list refresh failed');
|
|
})
|
|
.finally(() => {
|
|
this.scheduleNextRefresh(REFRESH_INTERVAL_MS);
|
|
});
|
|
}, delay);
|
|
this.refreshTimer.unref?.();
|
|
}
|
|
|
|
private opportunisticRefreshDelayMs(): number {
|
|
const range = OPPORTUNISTIC_REFRESH_MAX_MS - OPPORTUNISTIC_REFRESH_MIN_MS;
|
|
return OPPORTUNISTIC_REFRESH_MIN_MS + randomInt(range);
|
|
}
|
|
|
|
private async tryRefresh(): Promise<void> {
|
|
const kv = this.kvClient;
|
|
if (!kv) return;
|
|
await this.withKvLock(kv, async (acquired) => {
|
|
if (!acquired) {
|
|
const cached = await this.readPayloadFromKv();
|
|
if (cached) this.applyPayload(cached);
|
|
return;
|
|
}
|
|
const result = await this.fetchFromTor();
|
|
if (result.kind !== 'fetched') return;
|
|
await kv.setex(KV_PAYLOAD_KEY, PAYLOAD_TTL_SECONDS, result.payload).catch((err) => {
|
|
Logger.warn(
|
|
{error: err instanceof Error ? err.message : String(err)},
|
|
'Failed to write Tor exit list payload to KV',
|
|
);
|
|
});
|
|
this.applyPayload(result.payload);
|
|
Logger.info({ipv4Count: this.ipv4Exits.size, ipv6Count: this.ipv6Exits.size}, 'Tor exit list refreshed');
|
|
});
|
|
}
|
|
|
|
private async hydrateFromTorOrWait(): Promise<void> {
|
|
const kv = this.kvClient;
|
|
if (!kv) {
|
|
const result = await this.fetchFromTor();
|
|
if (result.kind === 'fetched') {
|
|
this.applyPayload(result.payload);
|
|
return;
|
|
}
|
|
Logger.warn('Tor exit list bootstrap could not fetch; continuing with empty list');
|
|
return;
|
|
}
|
|
const fetchedAsWinner = await this.withKvLock(kv, async (acquired) => {
|
|
if (!acquired) return false;
|
|
const result = await this.fetchFromTor();
|
|
if (result.kind !== 'fetched') {
|
|
Logger.warn('Tor exit list bootstrap could not fetch; continuing with empty list');
|
|
return true;
|
|
}
|
|
await kv.setex(KV_PAYLOAD_KEY, PAYLOAD_TTL_SECONDS, result.payload).catch(() => undefined);
|
|
this.applyPayload(result.payload);
|
|
Logger.info(
|
|
{ipv4Count: this.ipv4Exits.size, ipv6Count: this.ipv6Exits.size},
|
|
'Tor exit list fetched and hydrated on startup',
|
|
);
|
|
return true;
|
|
});
|
|
if (fetchedAsWinner) return;
|
|
const cached = await this.pollForPeerPayload();
|
|
if (cached) {
|
|
this.applyPayload(cached);
|
|
Logger.info(
|
|
{ipv4Count: this.ipv4Exits.size, ipv6Count: this.ipv6Exits.size},
|
|
'Tor exit list hydrated from KV after peer fetch',
|
|
);
|
|
return;
|
|
}
|
|
Logger.warn('Tor exit list not available via KV after wait; fetching directly');
|
|
const result = await this.fetchFromTor();
|
|
if (result.kind !== 'fetched') {
|
|
Logger.warn('Tor exit list bootstrap could not fetch; continuing with empty list');
|
|
return;
|
|
}
|
|
this.applyPayload(result.payload);
|
|
}
|
|
|
|
private async pollForPeerPayload(): Promise<string | null> {
|
|
const deadline = Date.now() + HYDRATE_WAIT_MS;
|
|
let backoff = HYDRATE_POLL_MIN_MS;
|
|
while (Date.now() < deadline) {
|
|
await sleep(backoff);
|
|
const cached = await this.readPayloadFromKv();
|
|
if (cached) return cached;
|
|
backoff = Math.min(backoff * 2, HYDRATE_POLL_MAX_MS);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async withKvLock<T>(kv: IKVProvider, fn: (acquired: boolean) => Promise<T>): Promise<T> {
|
|
const lockToken = randomUUID();
|
|
const acquired = await kv.acquireLock(KV_LOCK_KEY, lockToken, LOCK_TTL_SECONDS).catch(() => false);
|
|
try {
|
|
return await fn(acquired);
|
|
} finally {
|
|
if (acquired) {
|
|
await kv.releaseLock(KV_LOCK_KEY, lockToken).catch((err) => {
|
|
Logger.warn(
|
|
{error: err instanceof Error ? err.message : String(err)},
|
|
'Failed to release Tor exit list KV lock (will expire on TTL)',
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async readPayloadFromKv(): Promise<string | null> {
|
|
const kv = this.kvClient;
|
|
if (!kv) return null;
|
|
try {
|
|
return await kv.get(KV_PAYLOAD_KEY);
|
|
} catch (err) {
|
|
Logger.warn({error: err instanceof Error ? err.message : String(err)}, 'Failed reading Tor exit list from KV');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async fetchFromTor(): Promise<FetchResult> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
this.inflightFetch = controller;
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
Accept: 'application/json',
|
|
'Accept-Encoding': 'gzip',
|
|
};
|
|
if (this.lastModified) {
|
|
headers['If-Modified-Since'] = this.lastModified;
|
|
}
|
|
const res = await fetch(ONIONOO_URL, {
|
|
signal: controller.signal,
|
|
headers,
|
|
});
|
|
if (res.status === 304) {
|
|
return {kind: 'not_modified'};
|
|
}
|
|
if (!res.ok) {
|
|
Logger.warn({status: res.status}, 'Onionoo fetch returned non-OK status');
|
|
return {kind: 'failed'};
|
|
}
|
|
const body = (await res.json()) as {
|
|
relays?: ReadonlyArray<{
|
|
exit_addresses?: ReadonlyArray<string>;
|
|
or_addresses?: ReadonlyArray<string>;
|
|
}>;
|
|
};
|
|
const ips = new Set<string>();
|
|
for (const relay of body.relays ?? []) {
|
|
for (const addr of relay.exit_addresses ?? []) {
|
|
const ip = stripHostPort(addr);
|
|
if (ip) ips.add(ip);
|
|
}
|
|
for (const addr of relay.or_addresses ?? []) {
|
|
const ip = stripHostPort(addr);
|
|
if (ip) ips.add(ip);
|
|
}
|
|
}
|
|
const lastModified = res.headers.get('last-modified');
|
|
if (lastModified) this.lastModified = lastModified;
|
|
return {kind: 'fetched', payload: Array.from(ips).join('\n')};
|
|
} catch (err) {
|
|
const name = (
|
|
err as
|
|
| {
|
|
name?: string;
|
|
}
|
|
| undefined
|
|
)?.name;
|
|
if (name === 'AbortError') {
|
|
Logger.warn('Onionoo fetch aborted (timeout or shutdown)');
|
|
} else {
|
|
Logger.warn({error: err instanceof Error ? err.message : String(err)}, 'Onionoo fetch failed');
|
|
}
|
|
return {kind: 'failed'};
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (this.inflightFetch === controller) {
|
|
this.inflightFetch = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
private applyPayload(payload: string): void {
|
|
const ipv4 = new Set<string>();
|
|
const ipv6 = new Set<string>();
|
|
for (const rawLine of payload.split('\n')) {
|
|
const value = rawLine.trim();
|
|
if (!value) continue;
|
|
const parsed = parseIpAddress(value);
|
|
if (!parsed) continue;
|
|
if (parsed.family === 'ipv4') {
|
|
ipv4.add(parsed.normalized);
|
|
} else {
|
|
ipv6.add(parsed.normalized);
|
|
}
|
|
}
|
|
this.ipv4Exits = ipv4;
|
|
this.ipv6Exits = ipv6;
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function stripHostPort(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.startsWith('[')) {
|
|
const end = trimmed.indexOf(']');
|
|
if (end <= 1) return null;
|
|
return trimmed.slice(1, end);
|
|
}
|
|
const firstColon = trimmed.indexOf(':');
|
|
if (firstColon > 0 && firstColon === trimmed.lastIndexOf(':')) {
|
|
return trimmed.slice(0, firstColon);
|
|
}
|
|
return trimmed || null;
|
|
}
|
|
|
|
export const torExitListCache = new TorExitListCache();
|