feat(tls): 添加 Go uTLS sidecar 以绕过 Cloudflare TLS 指纹检测
- 新增 Go 语言编写的 TLS sidecar 服务,使用 uTLS 库模拟 Chrome 指纹 - 在 Dockerfile 中添加多阶段构建以编译 sidecar 二进制文件 - 扩展配置系统,支持启用/禁用 sidecar 及自定义端口 - 修改 Grok 提供商,使其请求可通过 sidecar 转发 - 在前端界面添加 TLS sidecar 配置选项和国际化支持 - 服务启动时自动启动 sidecar,关闭时优雅停止
This commit is contained in:
parent
4f9189e96e
commit
81dd6a3f86
13 changed files with 810 additions and 3 deletions
17
Dockerfile
17
Dockerfile
|
|
@ -1,3 +1,16 @@
|
|||
# ── Stage 1: 编译 Go TLS sidecar ──
|
||||
FROM golang:1.22-alpine AS sidecar-builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
COPY tls-sidecar/go.mod tls-sidecar/go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY tls-sidecar/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o tls-sidecar .
|
||||
|
||||
# ── Stage 2: Node.js 应用 ──
|
||||
# 使用官方Node.js运行时作为基础镜像
|
||||
# 选择20-alpine版本以满足undici包的要求(需要Node.js >=20.18.1)
|
||||
FROM node:20-alpine
|
||||
|
|
@ -9,6 +22,10 @@ LABEL description="Docker image for AIClient2API server"
|
|||
# 安装必要的系统工具(tar 用于更新功能,git 用于版本检查)
|
||||
RUN apk add --no-cache tar git
|
||||
|
||||
# 从 sidecar 构建阶段复制二进制
|
||||
COPY --from=sidecar-builder /build/tls-sidecar /app/tls-sidecar/tls-sidecar
|
||||
RUN chmod +x /app/tls-sidecar/tls-sidecar
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.9.9.2
|
||||
2.9.9.31
|
||||
|
|
|
|||
|
|
@ -57,5 +57,7 @@
|
|||
"LOG_INCLUDE_REQUEST_ID": true,
|
||||
"LOG_INCLUDE_TIMESTAMP": true,
|
||||
"LOG_MAX_FILE_SIZE": 10485760,
|
||||
"LOG_MAX_FILES": 10
|
||||
"LOG_MAX_FILES": 10,
|
||||
"TLS_SIDECAR_ENABLED": false,
|
||||
"TLS_SIDECAR_PORT": 9090
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,10 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP
|
|||
LOG_INCLUDE_REQUEST_ID: true,
|
||||
LOG_INCLUDE_TIMESTAMP: true,
|
||||
LOG_MAX_FILE_SIZE: 10485760,
|
||||
LOG_MAX_FILES: 10
|
||||
LOG_MAX_FILES: 10,
|
||||
TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制)
|
||||
TLS_SIDECAR_PORT: 9090, // sidecar 监听端口
|
||||
TLS_SIDECAR_BINARY_PATH: null // 自定义二进制路径(默认自动搜索)
|
||||
};
|
||||
logger.info('[Config] Using default configuration.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { API_ACTIONS, isRetryableNetworkError } from '../../utils/common.js';
|
||||
import { getProviderModels } from '../provider-models.js';
|
||||
import { configureAxiosProxy } from '../../utils/proxy-utils.js';
|
||||
import { getTLSSidecar } from '../../utils/tls-sidecar.js';
|
||||
import { MODEL_PROVIDER } from '../../utils/common.js';
|
||||
import { GrokConverter } from '../../converters/strategies/GrokConverter.js';
|
||||
import { ConverterFactory } from '../../converters/ConverterFactory.js';
|
||||
|
|
@ -110,6 +111,23 @@ export class GrokApiService {
|
|||
this.lastSyncAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果 TLS sidecar 可用,将 axios 请求改为通过 sidecar 转发
|
||||
* sidecar 不可用时保持原有 https.Agent TLS 配置
|
||||
*/
|
||||
_applySidecar(axiosConfig) {
|
||||
const sidecar = getTLSSidecar();
|
||||
if (sidecar.isReady()) {
|
||||
// 获取上游代理 URL(如果有)
|
||||
const proxyUrl = this.config.PROXY_URL &&
|
||||
this.config.PROXY_ENABLED_PROVIDERS?.includes(MODEL_PROVIDER.GROK_CUSTOM)
|
||||
? this.config.PROXY_URL : null;
|
||||
sidecar.wrapAxiosConfig(axiosConfig, proxyUrl);
|
||||
logger.debug('[Grok] Request routed through TLS sidecar');
|
||||
}
|
||||
return axiosConfig;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) return;
|
||||
logger.info('[Grok] Initializing Grok API Service...');
|
||||
|
|
@ -165,6 +183,7 @@ export class GrokApiService {
|
|||
};
|
||||
|
||||
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||
this._applySidecar(axiosConfig);
|
||||
|
||||
try {
|
||||
const response = await axios(axiosConfig);
|
||||
|
|
@ -542,6 +561,7 @@ export class GrokApiService {
|
|||
};
|
||||
|
||||
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||
this._applySidecar(axiosConfig);
|
||||
|
||||
try {
|
||||
const response = await axios(axiosConfig);
|
||||
|
|
@ -604,6 +624,7 @@ export class GrokApiService {
|
|||
};
|
||||
|
||||
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
|
||||
this._applySidecar(axiosConfig);
|
||||
|
||||
try {
|
||||
const response = await axios(axiosConfig);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { initializeUIManagement } from './ui-manager.js';
|
|||
import { initializeAPIManagement } from './api-manager.js';
|
||||
import { createRequestHandler } from '../handlers/request-handler.js';
|
||||
import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js';
|
||||
import { getTLSSidecar } from '../utils/tls-sidecar.js';
|
||||
|
||||
/**
|
||||
* @license
|
||||
|
|
@ -178,6 +179,11 @@ function setupWorkerCommunication() {
|
|||
async function gracefulShutdown() {
|
||||
logger.info('[Server] Initiating graceful shutdown...');
|
||||
|
||||
// 停止 TLS sidecar
|
||||
try {
|
||||
await getTLSSidecar().stop();
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (serverInstance) {
|
||||
serverInstance.close(() => {
|
||||
logger.info('[Server] HTTP server closed');
|
||||
|
|
@ -242,6 +248,20 @@ async function startServer() {
|
|||
// logger.info('[Initialization] Checking for unlinked provider configs...');
|
||||
// await autoLinkProviderConfigs(CONFIG);
|
||||
|
||||
// Start TLS sidecar if enabled
|
||||
if (CONFIG.TLS_SIDECAR_ENABLED) {
|
||||
const sidecar = getTLSSidecar();
|
||||
const started = await sidecar.start({
|
||||
port: CONFIG.TLS_SIDECAR_PORT,
|
||||
binaryPath: CONFIG.TLS_SIDECAR_BINARY_PATH || undefined,
|
||||
});
|
||||
if (started) {
|
||||
logger.info('[Initialization] TLS sidecar started successfully');
|
||||
} else {
|
||||
logger.warn('[Initialization] TLS sidecar failed to start, falling back to Node.js TLS');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize plugin system
|
||||
logger.info('[Initialization] Discovering and initializing plugins...');
|
||||
await discoverPlugins();
|
||||
|
|
|
|||
288
src/utils/tls-sidecar.js
Normal file
288
src/utils/tls-sidecar.js
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/**
|
||||
* TLS Sidecar Manager
|
||||
*
|
||||
* 管理 Go uTLS sidecar 进程的生命周期:
|
||||
* - 启动/停止 sidecar 二进制
|
||||
* - 健康检查 & 自动重启
|
||||
* - 为 axios 提供 sidecar 代理配置
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import logger from './logger.js';
|
||||
import http from 'http';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DEFAULT_PORT = 9090;
|
||||
const HEALTH_CHECK_INTERVAL = 30000; // 30s
|
||||
const HEALTH_CHECK_TIMEOUT = 3000; // 3s
|
||||
const MAX_RESTART_ATTEMPTS = 5;
|
||||
const RESTART_DELAY = 2000; // 2s
|
||||
|
||||
class TLSSidecar {
|
||||
constructor() {
|
||||
this.process = null;
|
||||
this.port = DEFAULT_PORT;
|
||||
this.baseUrl = null;
|
||||
this.healthCheckTimer = null;
|
||||
this.restartCount = 0;
|
||||
this.isShuttingDown = false;
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 sidecar 进程
|
||||
* @param {Object} options
|
||||
* @param {number} [options.port] - 监听端口
|
||||
* @param {string} [options.binaryPath] - 自定义二进制路径
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async start(options = {}) {
|
||||
if (this.process) {
|
||||
logger.info('[TLS-Sidecar] Already running');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.port = options.port || parseInt(process.env.TLS_SIDECAR_PORT) || DEFAULT_PORT;
|
||||
this.baseUrl = `http://127.0.0.1:${this.port}`;
|
||||
|
||||
// 查找二进制文件
|
||||
const binaryPath = options.binaryPath || this._findBinary();
|
||||
if (!binaryPath) {
|
||||
logger.error('[TLS-Sidecar] Binary not found. Build it with: cd tls-sidecar && go build -o tls-sidecar');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`[TLS-Sidecar] Starting: ${binaryPath} on port ${this.port}`);
|
||||
|
||||
try {
|
||||
this.process = spawn(binaryPath, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
TLS_SIDECAR_PORT: String(this.port),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// 转发 sidecar 日志
|
||||
this.process.stdout.on('data', (data) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) logger.info(`[TLS-Sidecar] ${msg}`);
|
||||
});
|
||||
|
||||
this.process.stderr.on('data', (data) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) logger.error(`[TLS-Sidecar] ${msg}`);
|
||||
});
|
||||
|
||||
this.process.on('exit', (code, signal) => {
|
||||
logger.warn(`[TLS-Sidecar] Process exited (code=${code}, signal=${signal})`);
|
||||
this.process = null;
|
||||
this.ready = false;
|
||||
|
||||
if (!this.isShuttingDown && this.restartCount < MAX_RESTART_ATTEMPTS) {
|
||||
this.restartCount++;
|
||||
logger.info(`[TLS-Sidecar] Auto-restart attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS}`);
|
||||
setTimeout(() => this.start(options), RESTART_DELAY);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
logger.error(`[TLS-Sidecar] Spawn error: ${err.message}`);
|
||||
this.process = null;
|
||||
this.ready = false;
|
||||
});
|
||||
|
||||
// 等待 sidecar 就绪
|
||||
const ok = await this._waitForReady();
|
||||
if (ok) {
|
||||
this.ready = true;
|
||||
this.restartCount = 0;
|
||||
this._startHealthCheck();
|
||||
logger.info(`[TLS-Sidecar] Ready at ${this.baseUrl}`);
|
||||
}
|
||||
return ok;
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`[TLS-Sidecar] Failed to start: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 sidecar 进程
|
||||
*/
|
||||
async stop() {
|
||||
this.isShuttingDown = true;
|
||||
this._stopHealthCheck();
|
||||
|
||||
if (this.process) {
|
||||
logger.info('[TLS-Sidecar] Stopping...');
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.process) {
|
||||
logger.warn('[TLS-Sidecar] Force killing');
|
||||
this.process.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
this.process.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
this.process = null;
|
||||
this.ready = false;
|
||||
logger.info('[TLS-Sidecar] Stopped');
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.process.kill('SIGTERM');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 sidecar 是否正在运行且健康
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isReady() {
|
||||
return this.ready && this.process !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 sidecar base URL
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getBaseUrl() {
|
||||
return this.isReady() ? this.baseUrl : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 axios 配置 sidecar 代理
|
||||
* 将目标 URL 改为 sidecar 地址,原始目标通过 header 传递
|
||||
*
|
||||
* @param {Object} axiosConfig - axios 配置对象
|
||||
* @param {string} [proxyUrl] - 上游代理 URL(可选)
|
||||
* @returns {Object} 修改后的 axios 配置
|
||||
*/
|
||||
wrapAxiosConfig(axiosConfig, proxyUrl) {
|
||||
if (!this.isReady()) {
|
||||
return axiosConfig; // sidecar 不可用,原样返回
|
||||
}
|
||||
|
||||
const targetUrl = axiosConfig.url;
|
||||
|
||||
// 将请求指向 sidecar
|
||||
axiosConfig.url = this.baseUrl;
|
||||
|
||||
// 通过 header 传递目标和代理信息
|
||||
axiosConfig.headers = axiosConfig.headers || {};
|
||||
axiosConfig.headers['X-Target-Url'] = targetUrl;
|
||||
if (proxyUrl) {
|
||||
axiosConfig.headers['X-Proxy-Url'] = proxyUrl;
|
||||
}
|
||||
|
||||
// 走 sidecar 不需要 Node.js 侧的 TLS agent
|
||||
delete axiosConfig.httpAgent;
|
||||
delete axiosConfig.httpsAgent;
|
||||
// 确保 axios 不使用自己的代理
|
||||
axiosConfig.proxy = false;
|
||||
|
||||
return axiosConfig;
|
||||
}
|
||||
|
||||
// ──── 内部方法 ────
|
||||
|
||||
_findBinary() {
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
const isWin = process.platform === 'win32';
|
||||
const ext = isWin ? '.exe' : '';
|
||||
|
||||
const candidates = [
|
||||
path.join(projectRoot, 'tls-sidecar', `tls-sidecar${ext}`),
|
||||
path.join(projectRoot, `tls-sidecar${ext}`),
|
||||
path.join('/usr', 'local', 'bin', `tls-sidecar${ext}`),
|
||||
path.join('/app', 'tls-sidecar', `tls-sidecar${ext}`),
|
||||
path.join('/app', `tls-sidecar${ext}`),
|
||||
];
|
||||
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async _waitForReady(timeoutMs = 10000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const ok = await this._healthCheck();
|
||||
if (ok) return true;
|
||||
} catch { /* retry */ }
|
||||
await sleep(500);
|
||||
}
|
||||
logger.error('[TLS-Sidecar] Timed out waiting for sidecar to become ready');
|
||||
return false;
|
||||
}
|
||||
|
||||
_healthCheck() {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`${this.baseUrl}/health`, { timeout: HEALTH_CHECK_TIMEOUT }, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => body += chunk);
|
||||
res.on('end', () => {
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_startHealthCheck() {
|
||||
this._stopHealthCheck();
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
const ok = await this._healthCheck();
|
||||
if (!ok && this.ready) {
|
||||
logger.warn('[TLS-Sidecar] Health check failed');
|
||||
this.ready = false;
|
||||
} else if (ok && !this.ready) {
|
||||
logger.info('[TLS-Sidecar] Recovered');
|
||||
this.ready = true;
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
_stopHealthCheck() {
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// 单例
|
||||
let instance = null;
|
||||
|
||||
export function getTLSSidecar() {
|
||||
if (!instance) {
|
||||
instance = new TLSSidecar();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
export default TLSSidecar;
|
||||
|
|
@ -177,6 +177,12 @@ async function loadConfiguration() {
|
|||
if (logMaxFileSizeEl) logMaxFileSizeEl.value = data.LOG_MAX_FILE_SIZE || 10485760;
|
||||
if (logMaxFilesEl) logMaxFilesEl.value = data.LOG_MAX_FILES || 10;
|
||||
|
||||
// TLS Sidecar 配置
|
||||
const tlsSidecarEnabledEl = document.getElementById('tlsSidecarEnabled');
|
||||
const tlsSidecarPortEl = document.getElementById('tlsSidecarPort');
|
||||
if (tlsSidecarEnabledEl) tlsSidecarEnabledEl.checked = data.TLS_SIDECAR_ENABLED || false;
|
||||
if (tlsSidecarPortEl) tlsSidecarPortEl.value = data.TLS_SIDECAR_PORT || 9090;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
}
|
||||
|
|
@ -274,6 +280,10 @@ async function saveConfiguration() {
|
|||
config.LOG_INCLUDE_TIMESTAMP = document.getElementById('logIncludeTimestamp')?.checked !== false;
|
||||
config.LOG_MAX_FILE_SIZE = parseInt(document.getElementById('logMaxFileSize')?.value || 10485760);
|
||||
config.LOG_MAX_FILES = parseInt(document.getElementById('logMaxFiles')?.value || 10);
|
||||
|
||||
// TLS Sidecar 配置
|
||||
config.TLS_SIDECAR_ENABLED = document.getElementById('tlsSidecarEnabled')?.checked || false;
|
||||
config.TLS_SIDECAR_PORT = parseInt(document.getElementById('tlsSidecarPort')?.value || 9090);
|
||||
|
||||
try {
|
||||
await window.apiClient.post('/config', config);
|
||||
|
|
|
|||
|
|
@ -319,6 +319,9 @@ const translations = {
|
|||
'config.proxy.urlNote': '支持 HTTP、HTTPS 和 SOCKS5 代理,留空则不使用代理',
|
||||
'config.proxy.enabledProviders': '启用代理的提供商',
|
||||
'config.proxy.enabledProvidersNote': '选择需要通过代理访问的提供商,未选中的提供商将直接连接',
|
||||
'config.proxy.tlsSidecarEnabled': 'TLS 指纹伪装 (uTLS Sidecar)',
|
||||
'config.proxy.tlsSidecarPort': 'Sidecar 端口',
|
||||
'config.proxy.tlsSidecarNote': '启用后 Grok 请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare(需重启服务)',
|
||||
'config.log.title': '日志设置',
|
||||
'config.log.enabled': '启用日志',
|
||||
'config.log.outputMode': '日志输出模式',
|
||||
|
|
@ -1127,6 +1130,9 @@ const translations = {
|
|||
'config.proxy.urlNote': 'Supports HTTP, HTTPS and SOCKS5 proxies. Leave empty to disable proxy',
|
||||
'config.proxy.enabledProviders': 'Providers Using Proxy',
|
||||
'config.proxy.enabledProvidersNote': 'Select providers that should use the proxy. Unselected providers will connect directly',
|
||||
'config.proxy.tlsSidecarEnabled': 'TLS Fingerprint Spoofing (uTLS Sidecar)',
|
||||
'config.proxy.tlsSidecarPort': 'Sidecar Port',
|
||||
'config.proxy.tlsSidecarNote': 'When enabled, Grok requests are routed through Go uTLS sidecar for perfect Chrome TLS/H2 fingerprint to bypass Cloudflare (requires restart)',
|
||||
'config.log.title': 'Log Settings',
|
||||
'config.log.enabled': 'Enable Logging',
|
||||
'config.log.outputMode': 'Log Output Mode',
|
||||
|
|
|
|||
|
|
@ -128,6 +128,21 @@
|
|||
</div>
|
||||
<small class="form-text" data-i18n="config.proxy.enabledProvidersNote">点击选择需要通过代理访问的提供商,未选中的提供商将直接连接</small>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="config-row">
|
||||
<div class="form-group">
|
||||
<label data-i18n="config.proxy.tlsSidecarEnabled">TLS 指纹伪装 (uTLS Sidecar)</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="tlsSidecarEnabled">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tlsSidecarPort" data-i18n="config.proxy.tlsSidecarPort">Sidecar 端口</label>
|
||||
<input type="number" id="tlsSidecarPort" class="form-control" min="1024" max="65535" value="9090">
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-text" data-i18n="config.proxy.tlsSidecarNote">启用后 Grok 请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare(需重启服务)</small>
|
||||
</div>
|
||||
|
||||
<!-- 服务治理与高可用 -->
|
||||
|
|
|
|||
2
tls-sidecar/.gitignore
vendored
Normal file
2
tls-sidecar/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
tls-sidecar
|
||||
tls-sidecar.exe
|
||||
17
tls-sidecar/go.mod
Normal file
17
tls-sidecar/go.mod
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
module tls-sidecar
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/refraction-networking/utls v1.6.7
|
||||
golang.org/x/net v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/cloudflare/circl v1.5.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
406
tls-sidecar/main.go
Normal file
406
tls-sidecar/main.go
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// TLS Sidecar — Go uTLS reverse proxy
|
||||
//
|
||||
// Node.js 发请求到 http://127.0.0.1:<port>,
|
||||
// 通过以下自定义 Header 传递目标信息:
|
||||
// X-Target-Url: 实际目标 URL(必填)
|
||||
// X-Proxy-Url: 上游代理(可选,支持 http/socks5)
|
||||
//
|
||||
// 所有其他 Header 原样转发给目标服务器。
|
||||
// 响应(包括 SSE 流式)透传回 Node.js。
|
||||
//
|
||||
// uTLS 使用 Chrome 最新指纹,ALPN 协商 h2/http1.1,
|
||||
// 根据服务器返回的 ALPN 自动选择 HTTP/2 或 HTTP/1.1 传输。
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
defaultPort = 9090
|
||||
headerTarget = "X-Target-Url"
|
||||
headerProxy = "X-Proxy-Url"
|
||||
readTimeout = 30 * time.Second
|
||||
writeTimeout = 0 // SSE 流式响应不设写超时(仅监听 localhost,安全)
|
||||
idleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// 全局 RoundTripper 缓存(按 proxyURL 分组,复用 H2 连接)
|
||||
var (
|
||||
rtCacheMu sync.Mutex
|
||||
rtCache = make(map[string]*utlsRoundTripper)
|
||||
)
|
||||
|
||||
func getOrCreateRT(proxyURL string) *utlsRoundTripper {
|
||||
rtCacheMu.Lock()
|
||||
defer rtCacheMu.Unlock()
|
||||
if rt, ok := rtCache[proxyURL]; ok {
|
||||
return rt
|
||||
}
|
||||
rt := newUTLSRoundTripper(proxyURL)
|
||||
rtCache[proxyURL] = rt
|
||||
return rt
|
||||
}
|
||||
|
||||
// ──────────────── uTLS RoundTripper ────────────────
|
||||
// 根据 ALPN 协商结果自动选择 H2 或 H1 传输
|
||||
|
||||
type utlsRoundTripper struct {
|
||||
proxyURL string
|
||||
|
||||
mu sync.Mutex
|
||||
h2Conns map[string]*http2.ClientConn // H2 连接缓存 (per host)
|
||||
}
|
||||
|
||||
func newUTLSRoundTripper(proxyURL string) *utlsRoundTripper {
|
||||
return &utlsRoundTripper{
|
||||
proxyURL: proxyURL,
|
||||
h2Conns: make(map[string]*http2.ClientConn),
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
addr := req.URL.Host
|
||||
if !strings.Contains(addr, ":") {
|
||||
if req.URL.Scheme == "https" {
|
||||
addr += ":443"
|
||||
} else {
|
||||
addr += ":80"
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试复用已有的 H2 连接
|
||||
rt.mu.Lock()
|
||||
if cc, ok := rt.h2Conns[addr]; ok {
|
||||
rt.mu.Unlock()
|
||||
if cc.CanTakeNewRequest() {
|
||||
resp, err := cc.RoundTrip(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
// H2 连接已失效,清除缓存重建
|
||||
log.Printf("[TLS-Sidecar] Cached H2 conn failed for %s: %v, reconnecting", addr, err)
|
||||
}
|
||||
rt.mu.Lock()
|
||||
delete(rt.h2Conns, addr)
|
||||
rt.mu.Unlock()
|
||||
} else {
|
||||
rt.mu.Unlock()
|
||||
}
|
||||
|
||||
// 建立新的 uTLS 连接
|
||||
conn, err := dialUTLS(req.Context(), "tcp", addr, rt.proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 根据 ALPN 协商结果决定走 H2 还是 H1
|
||||
alpn := conn.ConnectionState().NegotiatedProtocol
|
||||
log.Printf("[TLS-Sidecar] Connected to %s, ALPN: %q", addr, alpn)
|
||||
|
||||
if alpn == "h2" {
|
||||
// HTTP/2: 创建 H2 ClientConn
|
||||
cc, err := (&http2.Transport{}).NewClientConn(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("h2 client conn: %w", err)
|
||||
}
|
||||
|
||||
rt.mu.Lock()
|
||||
rt.h2Conns[addr] = cc
|
||||
rt.mu.Unlock()
|
||||
|
||||
return cc.RoundTrip(req)
|
||||
}
|
||||
|
||||
// HTTP/1.1: 通过一次性 Transport 使用已建立的 TLS 连接
|
||||
// DialTLSContext 返回已完成 TLS 握手的 conn,http.Transport 不会重复握手
|
||||
used := false
|
||||
t1 := &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, a string) (net.Conn, error) {
|
||||
if !used {
|
||||
used = true
|
||||
return conn, nil
|
||||
}
|
||||
// 后续连接走正常 uTLS dial
|
||||
return dialUTLS(ctx, network, a, rt.proxyURL)
|
||||
},
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := t1.RoundTrip(req)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
t1.CloseIdleConnections()
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (rt *utlsRoundTripper) CloseIdleConnections() {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
for k, cc := range rt.h2Conns {
|
||||
cc.Close()
|
||||
delete(rt.h2Conns, k)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── Main ────────────────
|
||||
|
||||
func main() {
|
||||
port := defaultPort
|
||||
if p := os.Getenv("TLS_SIDECAR_PORT"); p != "" {
|
||||
if v, err := strconv.Atoi(p); err == nil {
|
||||
port = v
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", handleHealth)
|
||||
mux.HandleFunc("/", handleProxy)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("127.0.0.1:%d", port),
|
||||
Handler: mux,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
log.Println("[TLS-Sidecar] Shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
srv.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
log.Printf("[TLS-Sidecar] Listening on 127.0.0.1:%d (Chrome uTLS, H2+H1 auto)\n", port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[TLS-Sidecar] Fatal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── Health ────────────────
|
||||
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprintf(w, `{"status":"ok","tls":"utls-chrome-auto","protocols":"h2,http/1.1"}`)
|
||||
}
|
||||
|
||||
// ──────────────── Proxy Handler ────────────────
|
||||
|
||||
func handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
targetURL := r.Header.Get(headerTarget)
|
||||
if targetURL == "" {
|
||||
http.Error(w, `{"error":"missing X-Target-Url header"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
proxyURL := r.Header.Get(headerProxy)
|
||||
|
||||
// Parse target
|
||||
parsed, err := url.Parse(targetURL)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"invalid target url: %s"}`, err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Build outgoing request
|
||||
outReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"failed to create request: %s"}`, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Copy headers (skip internal + hop-by-hop)
|
||||
for key, vals := range r.Header {
|
||||
lk := strings.ToLower(key)
|
||||
if lk == strings.ToLower(headerTarget) || lk == strings.ToLower(headerProxy) {
|
||||
continue
|
||||
}
|
||||
if lk == "connection" || lk == "keep-alive" || lk == "transfer-encoding" ||
|
||||
lk == "te" || lk == "trailer" || lk == "upgrade" || lk == "host" {
|
||||
continue
|
||||
}
|
||||
for _, v := range vals {
|
||||
outReq.Header.Add(key, v)
|
||||
}
|
||||
}
|
||||
outReq.Host = parsed.Host
|
||||
|
||||
// Execute via uTLS RoundTripper
|
||||
rt := getOrCreateRT(proxyURL)
|
||||
resp, err := rt.RoundTrip(outReq)
|
||||
if err != nil {
|
||||
log.Printf("[TLS-Sidecar] RoundTrip error → %s: %v", parsed.Host, err)
|
||||
http.Error(w, fmt.Sprintf(`{"error":"upstream request failed: %s"}`, err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Copy response headers
|
||||
for key, vals := range resp.Header {
|
||||
for _, v := range vals {
|
||||
w.Header().Add(key, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
// Stream body (SSE-friendly: flush after every read)
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, readErr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := w.Write(buf[:n]); writeErr != nil {
|
||||
log.Printf("[TLS-Sidecar] Write error: %v", writeErr)
|
||||
return
|
||||
}
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr != io.EOF {
|
||||
log.Printf("[TLS-Sidecar] Read error: %v", readErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────── uTLS Dial ────────────────
|
||||
|
||||
func dialUTLS(ctx context.Context, network, addr string, proxyURL string) (*utls.UConn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
|
||||
// TCP 连接(可能经过代理)
|
||||
var rawConn net.Conn
|
||||
if proxyURL != "" {
|
||||
rawConn, err = dialViaProxy(ctx, network, addr, proxyURL)
|
||||
} else {
|
||||
var d net.Dialer
|
||||
rawConn, err = d.DialContext(ctx, network, addr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tcp dial failed: %w", err)
|
||||
}
|
||||
|
||||
// uTLS 握手 — Chrome 最新指纹 (HelloChrome_Auto 跟随 utls 库更新)
|
||||
tlsConn := utls.UClient(rawConn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
// 握手超时
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
tlsConn.SetDeadline(deadline)
|
||||
} else {
|
||||
tlsConn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
}
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("utls handshake failed: %w", err)
|
||||
}
|
||||
|
||||
// 握手完成,清除超时
|
||||
tlsConn.SetDeadline(time.Time{})
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// ──────────────── Proxy Dialer ────────────────
|
||||
|
||||
func dialViaProxy(ctx context.Context, network, addr string, proxyURL string) (net.Conn, error) {
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy url: %w", err)
|
||||
}
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "socks5", "socks5h", "socks4", "socks":
|
||||
var auth *proxy.Auth
|
||||
if parsed.User != nil {
|
||||
auth = &proxy.Auth{
|
||||
User: parsed.User.Username(),
|
||||
}
|
||||
auth.Password, _ = parsed.User.Password()
|
||||
}
|
||||
dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, &net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("socks5 dialer: %w", err)
|
||||
}
|
||||
if ctxDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
return ctxDialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
return dialer.Dial(network, addr)
|
||||
|
||||
case "http", "https":
|
||||
proxyConn, err := net.DialTimeout("tcp", parsed.Host, 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to http proxy: %w", err)
|
||||
}
|
||||
|
||||
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr)
|
||||
if parsed.User != nil {
|
||||
username := parsed.User.Username()
|
||||
password, _ := parsed.User.Password()
|
||||
cred := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", cred)
|
||||
}
|
||||
connectReq += "\r\n"
|
||||
|
||||
if _, err = proxyConn.Write([]byte(connectReq)); err != nil {
|
||||
proxyConn.Close()
|
||||
return nil, fmt.Errorf("proxy CONNECT write: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
n, err := proxyConn.Read(buf)
|
||||
if err != nil {
|
||||
proxyConn.Close()
|
||||
return nil, fmt.Errorf("proxy CONNECT read: %w", err)
|
||||
}
|
||||
if !strings.Contains(string(buf[:n]), "200") {
|
||||
proxyConn.Close()
|
||||
return nil, fmt.Errorf("proxy CONNECT rejected: %s", strings.TrimSpace(string(buf[:n])))
|
||||
}
|
||||
|
||||
return proxyConn, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", parsed.Scheme)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue