forked from boos/BoosAPI
fix: 支持 SOCKS5 系统代理并修复脱敏令牌重复同步 (#154)
* fix: 支持 SOCKS5 系统代理并修复脱敏令牌重复同步 * fix: 避免复用完全模糊的脱敏令牌 * fix: 修复 reviewer 反馈的令牌同步与 SOCKS5 问题 * fix: avoid masked token sync collisions --------- Co-authored-by: Brucents <mail@urlk.cn> Co-authored-by: cita <juricek.chen@gmail.com>
This commit is contained in:
@@ -12,6 +12,8 @@ const createApiTokenMock = vi.fn();
|
||||
const getUserGroupsMock = vi.fn();
|
||||
const deleteApiTokenMock = vi.fn();
|
||||
|
||||
type AccountTokenServiceModule = typeof import('../../services/accountTokenService.js');
|
||||
|
||||
vi.mock('../../services/platforms/index.js', () => ({
|
||||
getAdapter: () => ({
|
||||
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
|
||||
@@ -28,6 +30,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let maskToken: AccountTokenServiceModule['maskToken'];
|
||||
let dataDir = '';
|
||||
let seedId = 0;
|
||||
|
||||
@@ -63,9 +66,11 @@ describe('account tokens sync routes with site status', () => {
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const accountTokenServiceModule = await import('../../services/accountTokenService.js');
|
||||
const routesModule = await import('./accountTokens.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
maskToken = accountTokenServiceModule.maskToken;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.accountTokensRoutes);
|
||||
@@ -196,6 +201,247 @@ describe('account tokens sync routes with site status', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses an existing ready token when upstream only returns the matching masked token', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const fullToken = 'sk-real-token-1234';
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'masked-only',
|
||||
token: fullToken,
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'ready' as any,
|
||||
}).run();
|
||||
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'masked-only', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
|
||||
]);
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/account-tokens/sync/${account.id}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
synced: true,
|
||||
status: 'synced',
|
||||
created: 0,
|
||||
updated: 1,
|
||||
maskedPending: 0,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const tokenRows = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
expect(tokenRows).toHaveLength(1);
|
||||
expect(tokenRows[0]).toMatchObject({
|
||||
name: 'masked-only',
|
||||
token: fullToken,
|
||||
source: 'sync',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
tokenGroup: 'default',
|
||||
});
|
||||
expect((tokenRows[0] as any).valueStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('removes matching masked_pending placeholders after reusing a ready token', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const fullToken = 'sk-real-token-1234';
|
||||
const maskedToken = maskToken(fullToken);
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'masked-only',
|
||||
token: fullToken,
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'ready' as any,
|
||||
}).run();
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'masked-only',
|
||||
token: maskedToken,
|
||||
source: 'sync',
|
||||
enabled: false,
|
||||
isDefault: false,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'masked_pending' as any,
|
||||
}).run();
|
||||
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'masked-only', key: maskedToken, enabled: true, tokenGroup: 'default' },
|
||||
]);
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/account-tokens/sync/${account.id}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
synced: true,
|
||||
status: 'synced',
|
||||
created: 0,
|
||||
updated: 1,
|
||||
maskedPending: 0,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const tokenRows = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
expect(tokenRows).toHaveLength(1);
|
||||
expect(tokenRows[0]).toMatchObject({
|
||||
name: 'masked-only',
|
||||
token: fullToken,
|
||||
source: 'sync',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
tokenGroup: 'default',
|
||||
});
|
||||
expect((tokenRows[0] as any).valueStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('does not reuse a different ready token when another logical token shares the same masked value', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const firstFullToken = 'sk-real-token-1234';
|
||||
const secondFullToken = 'sk-real-zzzzz-1234';
|
||||
const sharedMaskedToken = maskToken(firstFullToken);
|
||||
|
||||
expect(maskToken(secondFullToken)).toBe(sharedMaskedToken);
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'first-token',
|
||||
token: firstFullToken,
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'ready' as any,
|
||||
}).run();
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'second-token',
|
||||
token: sharedMaskedToken,
|
||||
source: 'sync',
|
||||
enabled: false,
|
||||
isDefault: false,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'masked_pending' as any,
|
||||
}).run();
|
||||
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'second-token', key: sharedMaskedToken, enabled: true, tokenGroup: 'default' },
|
||||
]);
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/account-tokens/sync/${account.id}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
synced: true,
|
||||
status: 'synced',
|
||||
created: 0,
|
||||
updated: 1,
|
||||
maskedPending: 1,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const tokenRows = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
expect(tokenRows).toHaveLength(2);
|
||||
expect(tokenRows.find((row) => row.token === firstFullToken)).toMatchObject({
|
||||
name: 'first-token',
|
||||
token: firstFullToken,
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
tokenGroup: 'default',
|
||||
});
|
||||
const maskedRow = tokenRows.find((row) => row.name === 'second-token');
|
||||
expect(maskedRow).toMatchObject({
|
||||
token: sharedMaskedToken,
|
||||
source: 'sync',
|
||||
enabled: false,
|
||||
isDefault: false,
|
||||
tokenGroup: 'default',
|
||||
});
|
||||
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
|
||||
});
|
||||
|
||||
it('keeps fully ambiguous short masks as masked_pending instead of reusing a ready token', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const fullToken = 'sk-abcd';
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'short-token',
|
||||
token: fullToken,
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'ready' as any,
|
||||
}).run();
|
||||
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'short-token', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
|
||||
]);
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/account-tokens/sync/${account.id}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
synced: true,
|
||||
status: 'synced',
|
||||
reason: 'upstream_masked_tokens',
|
||||
created: 1,
|
||||
updated: 0,
|
||||
maskedPending: 1,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const tokenRows = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
expect(tokenRows).toHaveLength(2);
|
||||
expect(tokenRows.find((row) => row.token === fullToken)).toBeDefined();
|
||||
const maskedRow = tokenRows.find((row) => row.token === 'sk-***');
|
||||
expect(maskedRow).toMatchObject({
|
||||
name: 'short-token',
|
||||
source: 'sync',
|
||||
enabled: false,
|
||||
isDefault: false,
|
||||
tokenGroup: 'default',
|
||||
});
|
||||
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
|
||||
});
|
||||
|
||||
it('rejects sync and token management for apikey connections', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
|
||||
await db.update(schema.accounts)
|
||||
|
||||
@@ -60,6 +60,41 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean {
|
||||
return value.includes('*') || value.includes('•');
|
||||
}
|
||||
|
||||
function normalizeMaskedTokenForCompare(token: string | null | undefined): string {
|
||||
return normalizeTokenForDisplay(token).replace(/•/g, '*');
|
||||
}
|
||||
|
||||
function matchesMaskedTokenValue(
|
||||
fullToken: string | null | undefined,
|
||||
maskedToken: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedFull = normalizeTokenForDisplay(fullToken);
|
||||
const normalizedMasked = normalizeMaskedTokenForCompare(maskedToken);
|
||||
if (!normalizedFull || !normalizedMasked) return false;
|
||||
|
||||
if (!isMaskedTokenValue(normalizedMasked)) {
|
||||
return normalizedFull === normalizedMasked;
|
||||
}
|
||||
|
||||
const firstMaskIndex = normalizedMasked.search(/[\*]/);
|
||||
const lastMaskIndex = Math.max(
|
||||
normalizedMasked.lastIndexOf('*'),
|
||||
normalizedMasked.lastIndexOf('•'),
|
||||
);
|
||||
if (firstMaskIndex < 0 || lastMaskIndex < firstMaskIndex) {
|
||||
return normalizedFull === normalizedMasked;
|
||||
}
|
||||
|
||||
const prefix = normalizedMasked.slice(0, firstMaskIndex);
|
||||
const suffix = normalizedMasked.slice(lastMaskIndex + 1);
|
||||
const visiblePrefix = prefix.replace(/^sk-/i, '');
|
||||
if (!visiblePrefix && !suffix) return false;
|
||||
if (normalizedFull.length < prefix.length + suffix.length) return false;
|
||||
if (prefix && !normalizedFull.startsWith(prefix)) return false;
|
||||
if (suffix && !normalizedFull.endsWith(suffix)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
|
||||
@@ -306,6 +341,62 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingReadyByMaskedValue = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
|
||||
? existing.filter((row) => (
|
||||
resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY
|
||||
&& matchesMaskedTokenValue(row.token, tokenValue)
|
||||
&& row.name === tokenName
|
||||
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
|
||||
))
|
||||
: [];
|
||||
const readyMaskedMatch = matchingReadyByMaskedValue.length === 1
|
||||
? matchingReadyByMaskedValue[0]
|
||||
: null;
|
||||
if (readyMaskedMatch) {
|
||||
const staleMaskedPlaceholders = existing.filter((row) => (
|
||||
row.id !== readyMaskedMatch.id
|
||||
&& resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
|
||||
&& matchesMaskedTokenValue(row.token, tokenValue)
|
||||
&& row.name === tokenName
|
||||
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
|
||||
));
|
||||
|
||||
await db.update(schema.accountTokens)
|
||||
.set({
|
||||
name: tokenName,
|
||||
tokenGroup,
|
||||
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||
source: 'sync',
|
||||
enabled,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.accountTokens.id, readyMaskedMatch.id))
|
||||
.run();
|
||||
readyMaskedMatch.name = tokenName;
|
||||
readyMaskedMatch.tokenGroup = tokenGroup;
|
||||
readyMaskedMatch.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY;
|
||||
readyMaskedMatch.enabled = enabled;
|
||||
readyMaskedMatch.source = 'sync';
|
||||
readyMaskedMatch.updatedAt = now;
|
||||
|
||||
if (staleMaskedPlaceholders.length > 0) {
|
||||
for (const placeholder of staleMaskedPlaceholders) {
|
||||
await db.delete(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.id, placeholder.id))
|
||||
.run();
|
||||
}
|
||||
for (const placeholder of staleMaskedPlaceholders) {
|
||||
const placeholderIndex = existing.findIndex((row) => row.id === placeholder.id);
|
||||
if (placeholderIndex >= 0) {
|
||||
existing.splice(placeholderIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchingPlaceholder = existing.find((row) => (
|
||||
isMaskedPendingAccountToken(row)
|
||||
&& row.name === tokenName
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { connect as connectSocket } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { Headers } from 'undici';
|
||||
import { SocksClient } from 'socks';
|
||||
import { Headers, fetch } from 'undici';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
|
||||
@@ -71,6 +75,72 @@ describe('siteProxy', () => {
|
||||
expect('dispatcher' in requestInit).toBe(true);
|
||||
});
|
||||
|
||||
it('injects a working dispatcher for socks5 system proxies', async () => {
|
||||
const upstreamServer = createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
upstreamServer.listen(0, '127.0.0.1');
|
||||
await once(upstreamServer, 'listening');
|
||||
const upstreamAddress = upstreamServer.address();
|
||||
if (!upstreamAddress || typeof upstreamAddress === 'string') {
|
||||
throw new Error('Failed to determine upstream server address');
|
||||
}
|
||||
const requestUrl = `http://proxy-site.example.com:${upstreamAddress.port}/v1/chat/completions`;
|
||||
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'system_proxy_url',
|
||||
value: JSON.stringify('socks5h://127.0.0.1:1080'),
|
||||
}).run();
|
||||
await db.run(sql`
|
||||
INSERT INTO sites (name, url, platform, use_system_proxy)
|
||||
VALUES ('proxy-site', ${`http://proxy-site.example.com:${upstreamAddress.port}`}, 'new-api', 1)
|
||||
`);
|
||||
|
||||
const createConnectionSpy = vi.spyOn(SocksClient, 'createConnection').mockImplementation(async () => {
|
||||
const socket = connectSocket(upstreamAddress.port, '127.0.0.1');
|
||||
await once(socket, 'connect');
|
||||
return { socket } as Awaited<ReturnType<typeof SocksClient.createConnection>>;
|
||||
});
|
||||
|
||||
try {
|
||||
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
|
||||
const requestInit = await withSiteProxyRequestInit(requestUrl, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect('dispatcher' in requestInit).toBe(true);
|
||||
|
||||
const response = await fetch(requestUrl, requestInit);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(createConnectionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(createConnectionSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
command: 'connect',
|
||||
proxy: expect.objectContaining({
|
||||
host: '127.0.0.1',
|
||||
port: 1080,
|
||||
type: 5,
|
||||
}),
|
||||
destination: expect.objectContaining({
|
||||
host: 'proxy-site.example.com',
|
||||
port: upstreamAddress.port,
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
createConnectionSpy.mockRestore();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstreamServer.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('merges site custom headers by matched request url and keeps explicit headers authoritative', async () => {
|
||||
await db.insert(schema.sites).values({
|
||||
name: 'headers-site',
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { lookup as dnsLookup } from 'node:dns';
|
||||
import { isIP, type Socket } from 'node:net';
|
||||
import { connect as tlsConnect, type TLSSocket } from 'node:tls';
|
||||
import { SocksClient } from 'socks';
|
||||
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
||||
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
|
||||
|
||||
const SITE_PROXY_CACHE_TTL_MS = 3_000;
|
||||
@@ -15,6 +19,15 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
||||
'socks5:',
|
||||
'socks5h:',
|
||||
]);
|
||||
const SOCKS_PROXY_PROTOCOLS = new Set([
|
||||
'socks:',
|
||||
'socks4:',
|
||||
'socks4a:',
|
||||
'socks5:',
|
||||
'socks5h:',
|
||||
]);
|
||||
const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
type SiteProxyRow = {
|
||||
siteUrl: string;
|
||||
@@ -45,6 +58,27 @@ let siteProxyCache: {
|
||||
|
||||
const dispatcherCache = new Map<string, Dispatcher>();
|
||||
|
||||
type ParsedSocksProxyConfig = {
|
||||
shouldLookup: boolean;
|
||||
proxy: {
|
||||
host: string;
|
||||
port: number;
|
||||
type: 4 | 5;
|
||||
userId?: string;
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type UndiciConnectOptions = {
|
||||
hostname: string;
|
||||
host?: string;
|
||||
protocol: string;
|
||||
port: string;
|
||||
servername?: string;
|
||||
localAddress?: string | null;
|
||||
httpSocket?: Socket;
|
||||
};
|
||||
|
||||
function normalizeSiteUrl(value: string): string {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
@@ -115,7 +149,10 @@ function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const dispatcher = new ProxyAgent(normalized);
|
||||
const parsedProxyUrl = new URL(normalized);
|
||||
const dispatcher = SOCKS_PROXY_PROTOCOLS.has(parsedProxyUrl.protocol.toLowerCase())
|
||||
? createSocksDispatcher(parsedProxyUrl)
|
||||
: new ProxyAgent(normalized);
|
||||
dispatcherCache.set(normalized, dispatcher);
|
||||
return dispatcher;
|
||||
} catch {
|
||||
@@ -123,6 +160,131 @@ function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function parseSocksProxyUrl(proxyUrl: URL): ParsedSocksProxyConfig {
|
||||
let shouldLookup = false;
|
||||
let type: 4 | 5 = 5;
|
||||
|
||||
switch (proxyUrl.protocol.toLowerCase()) {
|
||||
case 'socks4:':
|
||||
shouldLookup = true;
|
||||
type = 4;
|
||||
break;
|
||||
case 'socks4a:':
|
||||
type = 4;
|
||||
break;
|
||||
case 'socks5:':
|
||||
shouldLookup = true;
|
||||
type = 5;
|
||||
break;
|
||||
case 'socks:':
|
||||
case 'socks5h:':
|
||||
type = 5;
|
||||
break;
|
||||
default:
|
||||
throw new TypeError(`Unsupported SOCKS proxy protocol: ${proxyUrl.protocol}`);
|
||||
}
|
||||
|
||||
const proxy: ParsedSocksProxyConfig['proxy'] = {
|
||||
host: proxyUrl.hostname,
|
||||
port: Number.parseInt(proxyUrl.port, 10) || 1080,
|
||||
type,
|
||||
};
|
||||
|
||||
if (proxyUrl.username) {
|
||||
proxy.userId = decodeURIComponent(proxyUrl.username);
|
||||
}
|
||||
if (proxyUrl.password) {
|
||||
proxy.password = decodeURIComponent(proxyUrl.password);
|
||||
}
|
||||
|
||||
return { shouldLookup, proxy };
|
||||
}
|
||||
|
||||
function applySocketDefaults(socket: Socket | TLSSocket) {
|
||||
socket.setNoDelay(true);
|
||||
socket.setKeepAlive(true, DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS);
|
||||
}
|
||||
|
||||
async function resolveSocksDestinationHost(hostname: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
dnsLookup(hostname, {}, (error, address) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(address);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function createSocksSocket(
|
||||
connectOptions: UndiciConnectOptions,
|
||||
socksProxy: ParsedSocksProxyConfig,
|
||||
): Promise<Socket | TLSSocket> {
|
||||
if (!connectOptions.hostname) {
|
||||
throw new Error('Missing hostname for SOCKS proxy request');
|
||||
}
|
||||
|
||||
const destinationHost = socksProxy.shouldLookup
|
||||
? await resolveSocksDestinationHost(connectOptions.hostname)
|
||||
: connectOptions.hostname;
|
||||
const destinationPort = Number.parseInt(connectOptions.port, 10)
|
||||
|| (connectOptions.protocol === 'https:' ? 443 : 80);
|
||||
|
||||
const { socket } = await SocksClient.createConnection({
|
||||
proxy: socksProxy.proxy,
|
||||
destination: {
|
||||
host: destinationHost,
|
||||
port: destinationPort,
|
||||
},
|
||||
command: 'connect',
|
||||
timeout: DEFAULT_PROXY_CONNECT_TIMEOUT_MS,
|
||||
socket_options: connectOptions.localAddress
|
||||
? { localAddress: connectOptions.localAddress } as any
|
||||
: undefined,
|
||||
});
|
||||
applySocketDefaults(socket);
|
||||
|
||||
if (connectOptions.protocol !== 'https:') {
|
||||
return socket;
|
||||
}
|
||||
|
||||
return await new Promise<TLSSocket>((resolve, reject) => {
|
||||
const tlsSocket = tlsConnect({
|
||||
socket,
|
||||
host: connectOptions.hostname,
|
||||
servername: connectOptions.servername || (!isIP(connectOptions.hostname) ? connectOptions.hostname : undefined),
|
||||
ALPNProtocols: ['http/1.1'],
|
||||
});
|
||||
|
||||
const cleanup = (error: Error) => {
|
||||
socket.destroy();
|
||||
tlsSocket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
tlsSocket.once('secureConnect', () => {
|
||||
tlsSocket.off('error', cleanup);
|
||||
applySocketDefaults(tlsSocket);
|
||||
resolve(tlsSocket);
|
||||
});
|
||||
tlsSocket.once('error', cleanup);
|
||||
});
|
||||
}
|
||||
|
||||
function createSocksDispatcher(proxyUrl: URL): Dispatcher {
|
||||
const socksProxy = parseSocksProxyUrl(proxyUrl);
|
||||
return new UndiciAgent({
|
||||
connect: (connectOptions, callback) => {
|
||||
void createSocksSocket(connectOptions, socksProxy)
|
||||
.then((socket) => callback(null, socket))
|
||||
.catch((error) => {
|
||||
callback(error instanceof Error ? error : new Error(String(error)), null as any);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeSiteProxyUrl(input: unknown): string | null {
|
||||
if (typeof input !== 'string') return null;
|
||||
const trimmed = input.trim();
|
||||
|
||||
Reference in New Issue
Block a user