feat: SOCKS5 egress proxy + harden local-model path (#79)
Integrate SOCKS5 attack-traffic proxying, egress leak visibility, and local-model hardening. Preserve direct local/LAN model traffic while the proxy is active and reconcile the branch with current upstream.
This commit is contained in:
1 parent
df08627cb9
commit
7b1d12a07c
9 files changed
+1708
-138
No files matched your search
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* T3MP3ST — Outbound SOCKS5 proxy for test/attack traffic
|
||||
* ========================================================
|
||||
*
|
||||
* WHY: an operator often does not want probe/attack traffic to leave from their
|
||||
* own IP (rate-limits, WAF bans, attribution, geo-testing). This module routes
|
||||
* ALL of Node's global `fetch` (undici) through a SOCKS5 proxy so every scattered
|
||||
* `fetch()` in the arsenal is covered by a single chokepoint — no per-call edits.
|
||||
*
|
||||
* HOW: undici's global dispatcher does not speak SOCKS natively (its ProxyAgent is
|
||||
* HTTP-only), so we build an undici `Agent` with a custom `connect` that opens a
|
||||
* SOCKS5 tunnel via the `socks` package, then hands the raw tunneled socket back to
|
||||
* undici (`httpSocket`) so undici still performs the TLS upgrade for https targets.
|
||||
*
|
||||
* LOOPBACK BYPASS (critical): the local LLM (llama.cpp/Ollama on 127.0.0.1) and the
|
||||
* server's own same-origin calls must NOT be tunneled — routing localhost through an
|
||||
* external SOCKS proxy would break them. `isLoopbackHost()` sends those direct.
|
||||
*
|
||||
* LEAK CHECK: checkIp() fetches ifconfig.co both through the (possibly proxied)
|
||||
* global dispatcher (the "exit" IP the targets see) and through a guaranteed-direct
|
||||
* dispatcher (the real IP). If they match, the proxy is off or broken → the caller
|
||||
* surfaces a "real IP exposed" warning.
|
||||
*/
|
||||
|
||||
import { Agent, buildConnector, setGlobalDispatcher, fetch as undiciFetch } from 'undici';
|
||||
import { SocksClient, type SocksProxy } from 'socks';
|
||||
import tls from 'node:tls';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
// ── parsed proxy shape ───────────────────────────────────────────────────────
|
||||
interface ParsedProxy {
|
||||
socks: SocksProxy;
|
||||
host: string;
|
||||
port: number;
|
||||
type: 4 | 5;
|
||||
hasAuth: boolean;
|
||||
}
|
||||
|
||||
// ── module state ─────────────────────────────────────────────────────────────
|
||||
// A dedicated DIRECT dispatcher is kept alive for two jobs: (1) it's what we install
|
||||
// as the global dispatcher when the proxy is OFF, and (2) checkIp() uses it to learn
|
||||
// the real IP even while the proxy is ON.
|
||||
// autoSelectFamily mirrors Node's built-in fetch (Happy Eyeballs / dual-stack): without
|
||||
// it a bare undici Agent only tries the first-resolved address family and fails on
|
||||
// IPv6-first hosts like ifconfig.co.
|
||||
const directDispatcher = new Agent({ connect: { timeout: 10_000, autoSelectFamily: true } });
|
||||
let currentUrl: string | null = null;
|
||||
let enabled = false;
|
||||
|
||||
/**
|
||||
* Fetch without the configured attack-traffic proxy.
|
||||
*
|
||||
* Local model servers commonly live on RFC1918 addresses or private DNS names, not
|
||||
* just localhost. Giving those calls an explicit direct dispatcher avoids both
|
||||
* proxy leakage and brittle hostname heuristics/DNS races.
|
||||
*/
|
||||
export function directFetch(input: Parameters<typeof undiciFetch>[0], init: Parameters<typeof undiciFetch>[1] = {}) {
|
||||
return undiciFetch(input, { ...init, dispatcher: directDispatcher });
|
||||
}
|
||||
|
||||
/** Keep ordinary global fetch semantics unless a proxy is active (important for callers/tests that instrument fetch). */
|
||||
export function fetchBypassingProxy(
|
||||
input: Parameters<typeof undiciFetch>[0],
|
||||
init: Parameters<typeof undiciFetch>[1] = {},
|
||||
) {
|
||||
return enabled ? directFetch(input, init) : globalThis.fetch(input as any, init as any);
|
||||
}
|
||||
|
||||
// ── url parsing ──────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Parse socks5://[user:pass@]host:port (also socks5h:// and socks4://).
|
||||
* socks5h = remote DNS resolution, which is what the SOCKS tunnel does anyway.
|
||||
* Returns null on anything malformed so the caller can fail gracefully.
|
||||
*/
|
||||
export function parseProxyUrl(raw: string): ParsedProxy | null {
|
||||
const s = (raw || '').trim();
|
||||
if (!s) return null;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scheme = u.protocol.replace(/:$/, '').toLowerCase();
|
||||
const type: 4 | 5 = scheme === 'socks4' || scheme === 'socks4a' ? 4 : 5;
|
||||
if (!['socks', 'socks5', 'socks5h', 'socks4', 'socks4a'].includes(scheme)) return null;
|
||||
const host = u.hostname;
|
||||
const port = Number(u.port);
|
||||
if (!host || !port || !Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
|
||||
const userId = u.username ? decodeURIComponent(u.username) : undefined;
|
||||
const password = u.password ? decodeURIComponent(u.password) : undefined;
|
||||
const socks: SocksProxy = {
|
||||
host,
|
||||
port,
|
||||
type,
|
||||
...(userId ? { userId } : {}),
|
||||
...(password ? { password } : {}),
|
||||
};
|
||||
return { socks, host, port, type, hasAuth: Boolean(userId || password) };
|
||||
}
|
||||
|
||||
// ── loopback detection (bypass list) ─────────────────────────────────────────
|
||||
function isLoopbackHost(host: string | undefined): boolean {
|
||||
if (!host) return false;
|
||||
const h = host.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 brackets
|
||||
if (h === 'localhost' || h.endsWith('.localhost')) return true;
|
||||
if (h === '::1' || h === '0.0.0.0' || h === '::') return true;
|
||||
if (h.startsWith('127.')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── SOCKS-aware undici connector ─────────────────────────────────────────────
|
||||
/**
|
||||
* Build the `connect` function undici calls for every new upstream socket. Loopback
|
||||
* goes straight out; everything else is opened through the SOCKS tunnel and then the
|
||||
* raw socket is handed to undici's own connector (via `httpSocket`) so TLS still gets
|
||||
* negotiated for https:// destinations exactly as it would without a proxy.
|
||||
*/
|
||||
function makeConnect(proxy: SocksProxy) {
|
||||
// Loopback still uses undici's stock connector (net + optional TLS) unchanged.
|
||||
const direct = buildConnector({ timeout: 10_000 });
|
||||
return function connect(opts: any, callback: (err: Error | null, socket: any) => void): void {
|
||||
const hostname: string = opts.hostname;
|
||||
if (isLoopbackHost(hostname)) {
|
||||
direct(opts, callback);
|
||||
return;
|
||||
}
|
||||
const isTls = opts.protocol === 'https:';
|
||||
const destPort = Number(opts.port) || (isTls ? 443 : 80);
|
||||
SocksClient.createConnection({
|
||||
proxy,
|
||||
command: 'connect',
|
||||
destination: { host: hostname, port: destPort },
|
||||
timeout: 15_000,
|
||||
})
|
||||
.then(({ socket }) => {
|
||||
socket.setNoDelay(true);
|
||||
if (!isTls) {
|
||||
// Plain http: undici drives HTTP/1.1 directly over the raw tunnel.
|
||||
callback(null, socket);
|
||||
return;
|
||||
}
|
||||
// https: terminate TLS ourselves on top of the tunnel and hand undici a ready
|
||||
// secure socket. (undici's httpSocket upgrade path does NOT reliably fire for a
|
||||
// SOCKS-provided socket — it left the stream un-decrypted — so we do it explicitly.)
|
||||
const servername = opts.servername || hostname;
|
||||
const tlsSocket = tls.connect({
|
||||
socket,
|
||||
servername,
|
||||
ALPNProtocols: ['http/1.1'],
|
||||
});
|
||||
const onError = (err: Error) => { tlsSocket.destroy(); callback(err, null); };
|
||||
tlsSocket.once('error', onError);
|
||||
tlsSocket.once('secureConnect', () => {
|
||||
tlsSocket.removeListener('error', onError);
|
||||
callback(null, tlsSocket);
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => callback(err, null));
|
||||
};
|
||||
}
|
||||
|
||||
// ── public API ───────────────────────────────────────────────────────────────
|
||||
export interface ProxyStatus {
|
||||
enabled: boolean;
|
||||
/** Credential-redacted URL, safe to show in UI/logs. */
|
||||
url: string | null;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
type: 4 | 5 | null;
|
||||
}
|
||||
|
||||
/** Redact user:pass out of a proxy URL for display. */
|
||||
function redactUrl(raw: string): string {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.username || u.password) {
|
||||
u.username = u.username ? '***' : '';
|
||||
u.password = u.password ? '***' : '';
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return raw.replace(/\/\/[^@/]*@/, '//***@');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the outbound proxy. Pass a socks URL to enable, or null/'' to
|
||||
* disable (restores the direct dispatcher). Returns the resulting status or an error.
|
||||
*/
|
||||
export function configureProxy(url: string | null): ProxyStatus & { error?: string } {
|
||||
const wanted = (url || '').trim();
|
||||
if (!wanted) {
|
||||
setGlobalDispatcher(directDispatcher);
|
||||
currentUrl = null;
|
||||
enabled = false;
|
||||
return getProxyStatus();
|
||||
}
|
||||
const parsed = parseProxyUrl(wanted);
|
||||
if (!parsed) {
|
||||
return { ...getProxyStatus(), error: `Invalid SOCKS proxy URL: ${redactUrl(wanted)} (expected socks5://[user:pass@]host:port)` };
|
||||
}
|
||||
const agent = new Agent({ connect: makeConnect(parsed.socks) });
|
||||
// setGlobalDispatcher writes Symbol.for('undici.globalDispatcher.2') (undici >= 8).
|
||||
// Node's built-in fetch reads that SAME symbol, so this transparently proxies every
|
||||
// scattered global fetch() — VERIFIED e2e on Node 24.18 + undici 8.7. If a much older
|
||||
// Node (whose internal undici used the '.1' symbol) is ever targeted, this alignment
|
||||
// would break; pin/upgrade undici to match, or route arsenal fetch through undici's fetch.
|
||||
setGlobalDispatcher(agent);
|
||||
currentUrl = wanted;
|
||||
enabled = true;
|
||||
return getProxyStatus();
|
||||
}
|
||||
|
||||
export function getProxyStatus(): ProxyStatus {
|
||||
if (!enabled || !currentUrl) {
|
||||
return { enabled: false, url: null, host: null, port: null, type: null };
|
||||
}
|
||||
const parsed = parseProxyUrl(currentUrl);
|
||||
return {
|
||||
enabled: true,
|
||||
url: redactUrl(currentUrl),
|
||||
host: parsed?.host ?? null,
|
||||
port: parsed?.port ?? null,
|
||||
type: parsed?.type ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted/env proxy config (TEMPEST_PROXY_URL or saved settings) and
|
||||
* install it. Safe to call at startup; a bad/missing URL just leaves us direct.
|
||||
* Returns the effective status so the boot log can report it.
|
||||
*/
|
||||
export function initProxyFromConfig(): ProxyStatus & { error?: string } {
|
||||
const url = config.getProxyUrl();
|
||||
if (!url) return getProxyStatus();
|
||||
return configureProxy(url);
|
||||
}
|
||||
|
||||
// ── IP / leak checker ────────────────────────────────────────────────────────
|
||||
export interface IpInfo {
|
||||
ip: string | null;
|
||||
country?: string | null;
|
||||
city?: string | null;
|
||||
asnOrg?: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface IpCheckResult {
|
||||
proxyEnabled: boolean;
|
||||
proxy: ProxyStatus;
|
||||
/** IP the world sees for outbound test traffic (through the proxy when on). */
|
||||
exit: IpInfo;
|
||||
/** The operator's real IP (always fetched direct, bypassing the proxy). */
|
||||
direct: IpInfo;
|
||||
/**
|
||||
* True when the exit IP equals the real IP — i.e. either no proxy is configured,
|
||||
* or the proxy is not actually changing our egress. Surface this as a leak warning.
|
||||
*/
|
||||
leak: boolean;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
const IP_TIMEOUT_MS = 12_000;
|
||||
// ifconfig.co is the primary (as requested); ipify is a bare-IP fallback if it's down.
|
||||
const IFCONFIG_JSON = 'https://ifconfig.co/json';
|
||||
const IPIFY_FALLBACK = 'https://api.ipify.org?format=json';
|
||||
|
||||
/**
|
||||
* Fetch outbound IP info. `direct=true` forces the guaranteed-direct dispatcher so we
|
||||
* can read the real IP even while the global dispatcher is proxied.
|
||||
*/
|
||||
async function fetchIp(direct: boolean): Promise<IpInfo> {
|
||||
// Use undici's fetch (not Node's global) so we can pass an explicit `dispatcher`:
|
||||
// Node's built-in fetch throws UND_ERR_INVALID_ARG on a dispatcher from the separately
|
||||
// installed undici package. `direct` forces the always-direct dispatcher (real IP);
|
||||
// otherwise undici's global dispatcher is used (proxied when the proxy is on).
|
||||
const opts: any = {
|
||||
signal: AbortSignal.timeout(IP_TIMEOUT_MS),
|
||||
// ifconfig.co returns HTML to browser UAs; a curl-style UA guarantees JSON.
|
||||
headers: { 'User-Agent': 'curl/8.4.0', accept: 'application/json' },
|
||||
};
|
||||
if (direct) opts.dispatcher = directDispatcher;
|
||||
try {
|
||||
const res = await undiciFetch(IFCONFIG_JSON, opts);
|
||||
if (res.ok) {
|
||||
const j: any = await res.json();
|
||||
return {
|
||||
ip: j.ip ?? null,
|
||||
country: j.country ?? null,
|
||||
city: j.city ?? null,
|
||||
asnOrg: j.asn_org ?? null,
|
||||
source: 'ifconfig.co',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* fall through to fallback */
|
||||
}
|
||||
// Fallback: bare IP only.
|
||||
const fbOpts: any = { signal: AbortSignal.timeout(IP_TIMEOUT_MS) };
|
||||
if (direct) fbOpts.dispatcher = directDispatcher;
|
||||
const res = await undiciFetch(IPIFY_FALLBACK, fbOpts);
|
||||
const j: any = await res.json();
|
||||
return { ip: j.ip ?? null, source: 'api.ipify.org' };
|
||||
}
|
||||
|
||||
// Short cache so the UI badge can poll cheaply without hammering ifconfig.co.
|
||||
let cache: { at: number; result: IpCheckResult } | null = null;
|
||||
const CACHE_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Resolve exit + real IP and compute the leak flag. Cached for CACHE_MS; pass
|
||||
* force=true (e.g. right after changing the proxy) to bypass the cache.
|
||||
* Uses a monotonic-ish timestamp derived from the caller so we stay deterministic
|
||||
* where possible; falls back to performance.now for cache aging.
|
||||
*/
|
||||
export async function checkIp(force = false): Promise<IpCheckResult> {
|
||||
const now = performance.now();
|
||||
if (!force && cache && now - cache.at < CACHE_MS) return cache.result;
|
||||
|
||||
const proxyOn = enabled;
|
||||
// Exit uses the global dispatcher (proxied when on). Direct always bypasses.
|
||||
const [exit, direct] = await Promise.all([
|
||||
fetchIp(false).catch((e) => ({ ip: null, source: `error: ${String(e?.message || e)}` } as IpInfo)),
|
||||
fetchIp(true).catch((e) => ({ ip: null, source: `error: ${String(e?.message || e)}` } as IpInfo)),
|
||||
]);
|
||||
|
||||
const leak = Boolean(exit.ip && direct.ip && exit.ip === direct.ip);
|
||||
const result: IpCheckResult = {
|
||||
proxyEnabled: proxyOn,
|
||||
proxy: getProxyStatus(),
|
||||
exit,
|
||||
direct,
|
||||
leak,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
cache = { at: now, result };
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Drop the IP cache (call after (re)configuring the proxy). */
|
||||
export function invalidateIpCache(): void {
|
||||
cache = null;
|
||||
}
|
||||
Reference in new issue
Block a user