From 81dd6a3f8606bf9eaa861d5ecc3151bc6b96e9cb Mon Sep 17 00:00:00 2001 From: hex2077 Date: Sat, 28 Feb 2026 00:02:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(tls):=20=E6=B7=BB=E5=8A=A0=20Go=20uTLS=20s?= =?UTF-8?q?idecar=20=E4=BB=A5=E7=BB=95=E8=BF=87=20Cloudflare=20TLS=20?= =?UTF-8?q?=E6=8C=87=E7=BA=B9=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Go 语言编写的 TLS sidecar 服务,使用 uTLS 库模拟 Chrome 指纹 - 在 Dockerfile 中添加多阶段构建以编译 sidecar 二进制文件 - 扩展配置系统,支持启用/禁用 sidecar 及自定义端口 - 修改 Grok 提供商,使其请求可通过 sidecar 转发 - 在前端界面添加 TLS sidecar 配置选项和国际化支持 - 服务启动时自动启动 sidecar,关闭时优雅停止 --- Dockerfile | 17 ++ VERSION | 2 +- configs/config.json.example | 4 +- src/core/config-manager.js | 5 +- src/providers/grok/grok-core.js | 21 ++ src/services/api-server.js | 20 ++ src/utils/tls-sidecar.js | 288 ++++++++++++++++++ static/app/config-manager.js | 10 + static/app/i18n.js | 6 + static/components/section-config.html | 15 + tls-sidecar/.gitignore | 2 + tls-sidecar/go.mod | 17 ++ tls-sidecar/main.go | 406 ++++++++++++++++++++++++++ 13 files changed, 810 insertions(+), 3 deletions(-) create mode 100644 src/utils/tls-sidecar.js create mode 100644 tls-sidecar/.gitignore create mode 100644 tls-sidecar/go.mod create mode 100644 tls-sidecar/main.go diff --git a/Dockerfile b/Dockerfile index d11d3ae..cc6a56d 100644 --- a/Dockerfile +++ b/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 diff --git a/VERSION b/VERSION index e000fb7..3241fd9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.9.9.2 +2.9.9.31 diff --git a/configs/config.json.example b/configs/config.json.example index 5543780..a3f5904 100644 --- a/configs/config.json.example +++ b/configs/config.json.example @@ -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 } diff --git a/src/core/config-manager.js b/src/core/config-manager.js index 46aa7c0..4452935 100644 --- a/src/core/config-manager.js +++ b/src/core/config-manager.js @@ -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.'); } diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js index 7cb02f6..b5122d8 100644 --- a/src/providers/grok/grok-core.js +++ b/src/providers/grok/grok-core.js @@ -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); diff --git a/src/services/api-server.js b/src/services/api-server.js index 90543f2..f3b6360 100644 --- a/src/services/api-server.js +++ b/src/services/api-server.js @@ -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(); diff --git a/src/utils/tls-sidecar.js b/src/utils/tls-sidecar.js new file mode 100644 index 0000000..53f064f --- /dev/null +++ b/src/utils/tls-sidecar.js @@ -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} + */ + 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; diff --git a/static/app/config-manager.js b/static/app/config-manager.js index 7828b25..5f2e941 100644 --- a/static/app/config-manager.js +++ b/static/app/config-manager.js @@ -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); diff --git a/static/app/i18n.js b/static/app/i18n.js index cefda4f..7987ab8 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -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', diff --git a/static/components/section-config.html b/static/components/section-config.html index 6625b72..27e8f09 100644 --- a/static/components/section-config.html +++ b/static/components/section-config.html @@ -128,6 +128,21 @@ 点击选择需要通过代理访问的提供商,未选中的提供商将直接连接 +
+
+
+ + +
+
+ + +
+
+ 启用后 Grok 请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare(需重启服务) diff --git a/tls-sidecar/.gitignore b/tls-sidecar/.gitignore new file mode 100644 index 0000000..87bc36f --- /dev/null +++ b/tls-sidecar/.gitignore @@ -0,0 +1,2 @@ +tls-sidecar +tls-sidecar.exe diff --git a/tls-sidecar/go.mod b/tls-sidecar/go.mod new file mode 100644 index 0000000..3e8b6aa --- /dev/null +++ b/tls-sidecar/go.mod @@ -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 +) diff --git a/tls-sidecar/main.go b/tls-sidecar/main.go new file mode 100644 index 0000000..155ce04 --- /dev/null +++ b/tls-sidecar/main.go @@ -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:, +// 通过以下自定义 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) + } +}