1
0
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:
Brucents
2026-03-18 22:41:39 +08:00
committed by GitHub
parent a71ffb3641
commit 2a51ef6e4a
6 changed files with 593 additions and 36 deletions
@@ -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
+72 -2
View File
@@ -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',
+164 -2
View File
@@ -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();