diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c9dc57bb..f8a7d313 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "errors" "flag" "log" + "log/slog" "net/http" "os" "os/signal" @@ -44,7 +45,25 @@ func init() { } } +// initLogger configures the default slog handler based on gin.Mode(). +// In non-release mode, Debug level logs are enabled. +func initLogger() { + var level slog.Level + if gin.Mode() == gin.ReleaseMode { + level = slog.LevelInfo + } else { + level = slog.LevelDebug + } + handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + }) + slog.SetDefault(slog.New(handler)) +} + func main() { + // Initialize slog logger based on gin mode + initLogger() + // Parse command line flags setupMode := flag.Bool("setup", false, "Run setup wizard in CLI mode") showVersion := flag.Bool("version", false, "Show version information") diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 17396c71..66b86ea0 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -166,6 +166,11 @@ func AccountFromServiceShallow(a *service.Account) *Account { enabled := true out.EnableTLSFingerprint = &enabled } + // 会话ID伪装开关 + if a.IsSessionIDMaskingEnabled() { + enabled := true + out.EnableSessionIDMasking = &enabled + } } return out diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index b3876730..4247dcbf 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -116,6 +116,11 @@ type Account struct { // 从 extra 字段提取,方便前端显示和编辑 EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"` + // 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效) + // 启用后将在15分钟内固定 metadata.user_id 中的 session ID + // 从 extra 字段提取,方便前端显示和编辑 + EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"` + Proxy *Proxy `json:"proxy,omitempty"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` diff --git a/backend/internal/pkg/tlsfingerprint/dialer.go b/backend/internal/pkg/tlsfingerprint/dialer.go index bb29ea1c..42510986 100644 --- a/backend/internal/pkg/tlsfingerprint/dialer.go +++ b/backend/internal/pkg/tlsfingerprint/dialer.go @@ -7,23 +7,15 @@ import ( "context" "encoding/base64" "fmt" - "log" + "log/slog" "net" "net/http" "net/url" - "github.com/gin-gonic/gin" utls "github.com/refraction-networking/utls" "golang.org/x/net/proxy" ) -// debugLog prints log only in non-release mode. -func debugLog(format string, v ...any) { - if gin.Mode() != gin.ReleaseMode { - log.Printf(format, v...) - } -} - // Profile contains TLS fingerprint configuration. type Profile struct { Name string // Profile name for identification @@ -229,7 +221,7 @@ func NewSOCKS5ProxyDialer(profile *Profile, proxyURL *url.URL) *SOCKS5ProxyDiale // DialTLSContext establishes a TLS connection through SOCKS5 proxy with the configured fingerprint. // Flow: SOCKS5 CONNECT to target -> TLS handshake with utls on the tunnel func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) { - debugLog("[TLS Fingerprint SOCKS5] Connecting through proxy %s for target %s", d.proxyURL.Host, addr) + slog.Debug("tls_fingerprint_socks5_connecting", "proxy", d.proxyURL.Host, "target", addr) // Step 1: Create SOCKS5 dialer var auth *proxy.Auth @@ -250,33 +242,37 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st socksDialer, err := proxy.SOCKS5("tcp", proxyAddr, auth, proxy.Direct) if err != nil { - debugLog("[TLS Fingerprint SOCKS5] Failed to create SOCKS5 dialer: %v", err) + slog.Debug("tls_fingerprint_socks5_dialer_failed", "error", err) return nil, fmt.Errorf("create SOCKS5 dialer: %w", err) } // Step 2: Establish SOCKS5 tunnel to target - debugLog("[TLS Fingerprint SOCKS5] Establishing SOCKS5 tunnel to %s", addr) + slog.Debug("tls_fingerprint_socks5_establishing_tunnel", "target", addr) conn, err := socksDialer.Dial("tcp", addr) if err != nil { - debugLog("[TLS Fingerprint SOCKS5] Failed to connect through SOCKS5: %v", err) + slog.Debug("tls_fingerprint_socks5_connect_failed", "error", err) return nil, fmt.Errorf("SOCKS5 connect: %w", err) } - debugLog("[TLS Fingerprint SOCKS5] SOCKS5 tunnel established") + slog.Debug("tls_fingerprint_socks5_tunnel_established") // Step 3: Perform TLS handshake on the tunnel with utls fingerprint host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } - debugLog("[TLS Fingerprint SOCKS5] Starting TLS handshake to %s", host) + slog.Debug("tls_fingerprint_socks5_starting_handshake", "host", host) // Build ClientHello specification from profile (Node.js/Claude CLI fingerprint) spec := buildClientHelloSpecFromProfile(d.profile) - debugLog("[TLS Fingerprint SOCKS5] ClientHello spec: CipherSuites=%d, Extensions=%d, CompressionMethods=%v, TLSVersMax=0x%04x, TLSVersMin=0x%04x", - len(spec.CipherSuites), len(spec.Extensions), spec.CompressionMethods, spec.TLSVersMax, spec.TLSVersMin) + slog.Debug("tls_fingerprint_socks5_clienthello_spec", + "cipher_suites", len(spec.CipherSuites), + "extensions", len(spec.Extensions), + "compression_methods", spec.CompressionMethods, + "tls_vers_max", fmt.Sprintf("0x%04x", spec.TLSVersMax), + "tls_vers_min", fmt.Sprintf("0x%04x", spec.TLSVersMin)) if d.profile != nil { - debugLog("[TLS Fingerprint SOCKS5] Using profile: %s, GREASE: %v", d.profile.Name, d.profile.EnableGREASE) + slog.Debug("tls_fingerprint_socks5_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE) } // Create uTLS connection on the tunnel @@ -285,20 +281,22 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st }, utls.HelloCustom) if err := tlsConn.ApplyPreset(spec); err != nil { - debugLog("[TLS Fingerprint SOCKS5] ApplyPreset failed: %v", err) + slog.Debug("tls_fingerprint_socks5_apply_preset_failed", "error", err) _ = conn.Close() return nil, fmt.Errorf("apply TLS preset: %w", err) } if err := tlsConn.Handshake(); err != nil { - debugLog("[TLS Fingerprint SOCKS5] Handshake FAILED: %v", err) + slog.Debug("tls_fingerprint_socks5_handshake_failed", "error", err) _ = conn.Close() return nil, fmt.Errorf("TLS handshake failed: %w", err) } state := tlsConn.ConnectionState() - debugLog("[TLS Fingerprint SOCKS5] Handshake SUCCESS - Version: 0x%04x, CipherSuite: 0x%04x, ALPN: %s", - state.Version, state.CipherSuite, state.NegotiatedProtocol) + slog.Debug("tls_fingerprint_socks5_handshake_success", + "version", fmt.Sprintf("0x%04x", state.Version), + "cipher_suite", fmt.Sprintf("0x%04x", state.CipherSuite), + "alpn", state.NegotiatedProtocol) return tlsConn, nil } @@ -306,7 +304,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st // DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint. // Flow: TCP connect to proxy -> CONNECT tunnel -> TLS handshake with utls func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) { - debugLog("[TLS Fingerprint HTTPProxy] Connecting to proxy %s for target %s", d.proxyURL.Host, addr) + slog.Debug("tls_fingerprint_http_proxy_connecting", "proxy", d.proxyURL.Host, "target", addr) // Step 1: TCP connect to proxy server var proxyAddr string @@ -324,10 +322,10 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri dialer := &net.Dialer{} conn, err := dialer.DialContext(ctx, "tcp", proxyAddr) if err != nil { - debugLog("[TLS Fingerprint HTTPProxy] Failed to connect to proxy: %v", err) + slog.Debug("tls_fingerprint_http_proxy_connect_failed", "error", err) return nil, fmt.Errorf("connect to proxy: %w", err) } - debugLog("[TLS Fingerprint HTTPProxy] Connected to proxy %s", proxyAddr) + slog.Debug("tls_fingerprint_http_proxy_connected", "proxy_addr", proxyAddr) // Step 2: Send CONNECT request to establish tunnel req := &http.Request{ @@ -345,10 +343,10 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri req.Header.Set("Proxy-Authorization", "Basic "+auth) } - debugLog("[TLS Fingerprint HTTPProxy] Sending CONNECT request for %s", addr) + slog.Debug("tls_fingerprint_http_proxy_sending_connect", "target", addr) if err := req.Write(conn); err != nil { _ = conn.Close() - debugLog("[TLS Fingerprint HTTPProxy] Failed to write CONNECT request: %v", err) + slog.Debug("tls_fingerprint_http_proxy_write_failed", "error", err) return nil, fmt.Errorf("write CONNECT request: %w", err) } @@ -357,32 +355,33 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri resp, err := http.ReadResponse(br, req) if err != nil { _ = conn.Close() - debugLog("[TLS Fingerprint HTTPProxy] Failed to read CONNECT response: %v", err) + slog.Debug("tls_fingerprint_http_proxy_read_response_failed", "error", err) return nil, fmt.Errorf("read CONNECT response: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { _ = conn.Close() - debugLog("[TLS Fingerprint HTTPProxy] CONNECT failed with status: %d %s", resp.StatusCode, resp.Status) + slog.Debug("tls_fingerprint_http_proxy_connect_failed_status", "status_code", resp.StatusCode, "status", resp.Status) return nil, fmt.Errorf("proxy CONNECT failed: %s", resp.Status) } - debugLog("[TLS Fingerprint HTTPProxy] CONNECT tunnel established") + slog.Debug("tls_fingerprint_http_proxy_tunnel_established") // Step 4: Perform TLS handshake on the tunnel with utls fingerprint host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } - debugLog("[TLS Fingerprint HTTPProxy] Starting TLS handshake to %s", host) + slog.Debug("tls_fingerprint_http_proxy_starting_handshake", "host", host) // Build ClientHello specification (reuse the shared method) spec := buildClientHelloSpecFromProfile(d.profile) - debugLog("[TLS Fingerprint HTTPProxy] ClientHello spec built with %d cipher suites, %d extensions", - len(spec.CipherSuites), len(spec.Extensions)) + slog.Debug("tls_fingerprint_http_proxy_clienthello_spec", + "cipher_suites", len(spec.CipherSuites), + "extensions", len(spec.Extensions)) if d.profile != nil { - debugLog("[TLS Fingerprint HTTPProxy] Using profile: %s, GREASE: %v", d.profile.Name, d.profile.EnableGREASE) + slog.Debug("tls_fingerprint_http_proxy_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE) } // Create uTLS connection on the tunnel @@ -392,20 +391,22 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri }, utls.HelloCustom) if err := tlsConn.ApplyPreset(spec); err != nil { - debugLog("[TLS Fingerprint HTTPProxy] ApplyPreset failed: %v", err) + slog.Debug("tls_fingerprint_http_proxy_apply_preset_failed", "error", err) _ = conn.Close() return nil, fmt.Errorf("apply TLS preset: %w", err) } if err := tlsConn.HandshakeContext(ctx); err != nil { - debugLog("[TLS Fingerprint HTTPProxy] Handshake FAILED: %v", err) + slog.Debug("tls_fingerprint_http_proxy_handshake_failed", "error", err) _ = conn.Close() return nil, fmt.Errorf("TLS handshake failed: %w", err) } state := tlsConn.ConnectionState() - debugLog("[TLS Fingerprint HTTPProxy] Handshake SUCCESS - Version: 0x%04x, CipherSuite: 0x%04x, ALPN: %s", - state.Version, state.CipherSuite, state.NegotiatedProtocol) + slog.Debug("tls_fingerprint_http_proxy_handshake_success", + "version", fmt.Sprintf("0x%04x", state.Version), + "cipher_suite", fmt.Sprintf("0x%04x", state.CipherSuite), + "alpn", state.NegotiatedProtocol) return tlsConn, nil } @@ -414,31 +415,32 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri // This method is designed to be used as http.Transport.DialTLSContext. func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) { // Establish TCP connection using base dialer (supports proxy) - debugLog("[TLS Fingerprint] Dialing TCP to %s", addr) + slog.Debug("tls_fingerprint_dialing_tcp", "addr", addr) conn, err := d.baseDialer(ctx, network, addr) if err != nil { - debugLog("[TLS Fingerprint] TCP dial failed: %v", err) + slog.Debug("tls_fingerprint_tcp_dial_failed", "error", err) return nil, err } - debugLog("[TLS Fingerprint] TCP connected to %s", addr) + slog.Debug("tls_fingerprint_tcp_connected", "addr", addr) // Extract hostname for SNI host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } - debugLog("[TLS Fingerprint] SNI hostname: %s", host) + slog.Debug("tls_fingerprint_sni_hostname", "host", host) // Build ClientHello specification spec := d.buildClientHelloSpec() - debugLog("[TLS Fingerprint] ClientHello spec built with %d cipher suites, %d extensions", - len(spec.CipherSuites), len(spec.Extensions)) + slog.Debug("tls_fingerprint_clienthello_spec", + "cipher_suites", len(spec.CipherSuites), + "extensions", len(spec.Extensions)) // Log profile info if d.profile != nil { - debugLog("[TLS Fingerprint] Using profile: %s, GREASE: %v", d.profile.Name, d.profile.EnableGREASE) + slog.Debug("tls_fingerprint_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE) } else { - debugLog("[TLS Fingerprint] Using default profile (no custom config)") + slog.Debug("tls_fingerprint_using_default_profile") } // Create uTLS connection @@ -449,26 +451,28 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net. // Apply fingerprint if err := tlsConn.ApplyPreset(spec); err != nil { - debugLog("[TLS Fingerprint] ApplyPreset failed: %v", err) + slog.Debug("tls_fingerprint_apply_preset_failed", "error", err) _ = conn.Close() return nil, err } - debugLog("[TLS Fingerprint] Preset applied, starting handshake...") + slog.Debug("tls_fingerprint_preset_applied") // Perform TLS handshake if err := tlsConn.HandshakeContext(ctx); err != nil { - debugLog("[TLS Fingerprint] Handshake FAILED: %v", err) - // Log more details about the connection state - debugLog("[TLS Fingerprint] Connection state - Local: %v, Remote: %v", - conn.LocalAddr(), conn.RemoteAddr()) + slog.Debug("tls_fingerprint_handshake_failed", + "error", err, + "local_addr", conn.LocalAddr(), + "remote_addr", conn.RemoteAddr()) _ = conn.Close() return nil, fmt.Errorf("TLS handshake failed: %w", err) } // Log successful handshake details state := tlsConn.ConnectionState() - debugLog("[TLS Fingerprint] Handshake SUCCESS - Version: 0x%04x, CipherSuite: 0x%04x, ALPN: %s", - state.Version, state.CipherSuite, state.NegotiatedProtocol) + slog.Debug("tls_fingerprint_handshake_success", + "version", fmt.Sprintf("0x%04x", state.Version), + "cipher_suite", fmt.Sprintf("0x%04x", state.CipherSuite), + "alpn", state.NegotiatedProtocol) return tlsConn, nil } diff --git a/backend/internal/pkg/tlsfingerprint/registry.go b/backend/internal/pkg/tlsfingerprint/registry.go index db5afeba..6e9dc539 100644 --- a/backend/internal/pkg/tlsfingerprint/registry.go +++ b/backend/internal/pkg/tlsfingerprint/registry.go @@ -2,6 +2,7 @@ package tlsfingerprint import ( + "log/slog" "sort" "sync" @@ -40,7 +41,7 @@ func NewRegistryFromConfig(cfg *config.TLSFingerprintConfig) *Registry { r := NewRegistry() if cfg == nil || !cfg.Enabled { - debugLog("[TLS Registry] TLS fingerprint disabled or no config, using default profile only") + slog.Debug("tls_registry_disabled", "reason", "disabled or no config") return r } @@ -56,10 +57,10 @@ func NewRegistryFromConfig(cfg *config.TLSFingerprintConfig) *Registry { // If the profile has empty values, they will use defaults in dialer r.RegisterProfile(name, profile) - debugLog("[TLS Registry] Loaded custom profile: %s (%s)", name, profileCfg.Name) + slog.Debug("tls_registry_loaded_profile", "key", name, "name", profileCfg.Name) } - debugLog("[TLS Registry] Initialized with %d profiles: %v", len(r.profileNames), r.profileNames) + slog.Debug("tls_registry_initialized", "profile_count", len(r.profileNames), "profiles", r.profileNames) return r } diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index f1a95daf..b0f15f19 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" "io" - "log" + "log/slog" "net" "net/http" "net/url" @@ -18,16 +18,8 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" - "github.com/gin-gonic/gin" ) -// debugLog prints log only in non-release mode. -func debugLog(format string, v ...any) { - if gin.Mode() != gin.ReleaseMode { - log.Printf(format, v...) - } -} - // 默认配置常量 // 这些值在配置文件未指定时作为回退默认值使用 const ( @@ -189,7 +181,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco if proxyURL != "" { proxyInfo = proxyURL } - debugLog("[TLS Fingerprint] Account %d: TLS fingerprint ENABLED, target=%s, proxy=%s", accountID, targetHost, proxyInfo) + slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo) if err := s.validateRequestHost(req); err != nil { return nil, err @@ -200,16 +192,16 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco profile := registry.GetProfileByAccountID(accountID) if profile == nil { // 如果获取不到 profile,回退到普通请求 - debugLog("[TLS Fingerprint] Account %d: WARNING - no profile found, falling back to standard request", accountID) + slog.Debug("tls_fingerprint_no_profile", "account_id", accountID, "fallback", "standard_request") return s.Do(req, proxyURL, accountID, accountConcurrency) } - debugLog("[TLS Fingerprint] Account %d: Using profile '%s' (GREASE=%v)", accountID, profile.Name, profile.EnableGREASE) + slog.Debug("tls_fingerprint_using_profile", "account_id", accountID, "profile", profile.Name, "grease", profile.EnableGREASE) // 获取或创建带 TLS 指纹的客户端 entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile) if err != nil { - debugLog("[TLS Fingerprint] Account %d: Failed to acquire TLS client: %v", accountID, err) + slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err) return nil, err } @@ -219,11 +211,11 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco // 请求失败,立即减少计数 atomic.AddInt64(&entry.inFlight, -1) atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano()) - debugLog("[TLS Fingerprint] Account %d: Request FAILED: %v", accountID, err) + slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err) return nil, err } - debugLog("[TLS Fingerprint] Account %d: Request SUCCESS, status=%d", accountID, resp.StatusCode) + slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode) // 包装响应体,在关闭时自动减少计数并更新时间戳 resp.Body = wrapTrackedBody(resp.Body, func() { @@ -259,7 +251,7 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i atomic.AddInt64(&entry.inFlight, 1) } s.mu.RUnlock() - debugLog("[TLS Fingerprint] Account %d: Reusing existing TLS client (cacheKey=%s)", accountID, cacheKey) + slog.Debug("tls_fingerprint_reusing_client", "account_id", accountID, "cache_key", cacheKey) return entry, nil } s.mu.RUnlock() @@ -273,11 +265,14 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i atomic.AddInt64(&entry.inFlight, 1) } s.mu.Unlock() - debugLog("[TLS Fingerprint] Account %d: Reusing existing TLS client (cacheKey=%s)", accountID, cacheKey) + slog.Debug("tls_fingerprint_reusing_client", "account_id", accountID, "cache_key", cacheKey) return entry, nil } - debugLog("[TLS Fingerprint] Account %d: Evicting stale TLS client (cacheKey=%s, proxyChanged=%v, poolChanged=%v)", - accountID, cacheKey, entry.proxyKey != proxyKey, entry.poolKey != poolKey) + slog.Debug("tls_fingerprint_evicting_stale_client", + "account_id", accountID, + "cache_key", cacheKey, + "proxy_changed", entry.proxyKey != proxyKey, + "pool_changed", entry.poolKey != poolKey) s.removeClientLocked(cacheKey, entry) } @@ -293,8 +288,7 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i } // 创建带 TLS 指纹的 Transport - debugLog("[TLS Fingerprint] Account %d: Creating NEW TLS fingerprint client (cacheKey=%s, proxy=%s)", - accountID, cacheKey, proxyKey) + slog.Debug("tls_fingerprint_creating_new_client", "account_id", accountID, "cache_key", cacheKey, "proxy", proxyKey) settings := s.resolvePoolSettings(isolation, accountConcurrency) transport, err := buildUpstreamTransportWithTLSFingerprint(settings, parsedProxy, profile) if err != nil { @@ -822,7 +816,7 @@ func buildUpstreamTransportWithTLSFingerprint(settings poolSettings, proxyURL *u // 根据代理类型选择合适的 TLS 指纹 Dialer if proxyURL == nil { // 直连:使用 TLSFingerprintDialer - debugLog("[TLS Fingerprint Transport] Using DIRECT TLS dialer (no proxy)") + slog.Debug("tls_fingerprint_transport_direct") dialer := tlsfingerprint.NewDialer(profile, nil) transport.DialTLSContext = dialer.DialTLSContext } else { @@ -830,17 +824,17 @@ func buildUpstreamTransportWithTLSFingerprint(settings poolSettings, proxyURL *u switch scheme { case "socks5", "socks5h": // SOCKS5 代理:使用 SOCKS5ProxyDialer - debugLog("[TLS Fingerprint Transport] Using SOCKS5 TLS dialer (proxy=%s)", proxyURL.Host) + slog.Debug("tls_fingerprint_transport_socks5", "proxy", proxyURL.Host) socks5Dialer := tlsfingerprint.NewSOCKS5ProxyDialer(profile, proxyURL) transport.DialTLSContext = socks5Dialer.DialTLSContext case "http", "https": // HTTP/HTTPS 代理:使用 HTTPProxyDialer(CONNECT 隧道) - debugLog("[TLS Fingerprint Transport] Using HTTP CONNECT TLS dialer (proxy=%s)", proxyURL.Host) + slog.Debug("tls_fingerprint_transport_http_connect", "proxy", proxyURL.Host) httpDialer := tlsfingerprint.NewHTTPProxyDialer(profile, proxyURL) transport.DialTLSContext = httpDialer.DialTLSContext default: // 未知代理类型,回退到普通代理配置(无 TLS 指纹) - debugLog("[TLS Fingerprint Transport] WARNING: Unknown proxy scheme '%s', falling back to standard proxy (NO TLS fingerprint)", scheme) + slog.Debug("tls_fingerprint_transport_unknown_scheme_fallback", "scheme", scheme) if err := proxyutil.ConfigureTransportProxy(transport, proxyURL); err != nil { return nil, err } diff --git a/backend/internal/repository/identity_cache.go b/backend/internal/repository/identity_cache.go index d28477b7..c4986547 100644 --- a/backend/internal/repository/identity_cache.go +++ b/backend/internal/repository/identity_cache.go @@ -11,8 +11,10 @@ import ( ) const ( - fingerprintKeyPrefix = "fingerprint:" - fingerprintTTL = 24 * time.Hour + fingerprintKeyPrefix = "fingerprint:" + fingerprintTTL = 24 * time.Hour + maskedSessionKeyPrefix = "masked_session:" + maskedSessionTTL = 15 * time.Minute ) // fingerprintKey generates the Redis key for account fingerprint cache. @@ -20,6 +22,11 @@ func fingerprintKey(accountID int64) string { return fmt.Sprintf("%s%d", fingerprintKeyPrefix, accountID) } +// maskedSessionKey generates the Redis key for masked session ID cache. +func maskedSessionKey(accountID int64) string { + return fmt.Sprintf("%s%d", maskedSessionKeyPrefix, accountID) +} + type identityCache struct { rdb *redis.Client } @@ -49,3 +56,20 @@ func (c *identityCache) SetFingerprint(ctx context.Context, accountID int64, fp } return c.rdb.Set(ctx, key, val, fingerprintTTL).Err() } + +func (c *identityCache) GetMaskedSessionID(ctx context.Context, accountID int64) (string, error) { + key := maskedSessionKey(accountID) + val, err := c.rdb.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return "", nil + } + return "", err + } + return val, nil +} + +func (c *identityCache) SetMaskedSessionID(ctx context.Context, accountID int64, sessionID string) error { + key := maskedSessionKey(accountID) + return c.rdb.Set(ctx, key, sessionID, maskedSessionTTL).Err() +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 94fa107e..27f693d6 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -595,6 +595,25 @@ func (a *Account) IsTLSFingerprintEnabled() bool { return false } +// IsSessionIDMaskingEnabled 检查是否启用会话ID伪装 +// 仅适用于 Anthropic OAuth/SetupToken 类型账号 +// 启用后将在一段时间内(15分钟)固定 metadata.user_id 中的 session ID, +// 使上游认为请求来自同一个会话 +func (a *Account) IsSessionIDMaskingEnabled() bool { + if !a.IsAnthropicOAuthOrSetupToken() { + return false + } + if a.Extra == nil { + return false + } + if v, ok := a.Extra["session_id_masking_enabled"]; ok { + if enabled, ok := v.(bool); ok { + return enabled + } + } + return false +} + // GetWindowCostLimit 获取 5h 窗口费用阈值(美元) // 返回 0 表示未启用 func (a *Account) GetWindowCostLimit() float64 { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 8dfddec7..72ed9414 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "log" + "log/slog" mathrand "math/rand" "net/http" "os" @@ -45,13 +46,6 @@ func (s *GatewayService) debugModelRoutingEnabled() bool { return v == "1" || v == "true" || v == "yes" || v == "on" } -// debugLog prints log only in non-release mode. -func debugLog(format string, v ...any) { - if gin.Mode() != gin.ReleaseMode { - log.Printf(format, v...) - } -} - func shortSessionHash(sessionHash string) string { if sessionHash == "" { return "" @@ -425,8 +419,11 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro for id := range excludedIDs { excludedIDsList = append(excludedIDsList, id) } - debugLog("[AccountScheduling] Starting account selection: groupID=%v model=%s session=%s excludedIDs=%v", - derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), excludedIDsList) + slog.Debug("account_scheduling_starting", + "group_id", derefGroupID(groupID), + "model", requestedModel, + "session", shortSessionHash(sessionHash), + "excluded_ids", excludedIDsList) cfg := s.schedulingConfig() @@ -1105,11 +1102,19 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i if s.schedulerSnapshot != nil { accounts, useMixed, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, platform, hasForcePlatform) if err == nil { - debugLog("[AccountScheduling] listSchedulableAccounts (snapshot): groupID=%v platform=%s useMixed=%v count=%d", - derefGroupID(groupID), platform, useMixed, len(accounts)) + slog.Debug("account_scheduling_list_snapshot", + "group_id", derefGroupID(groupID), + "platform", platform, + "use_mixed", useMixed, + "count", len(accounts)) for _, acc := range accounts { - debugLog("[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v", - acc.ID, acc.Name, acc.Platform, acc.Type, acc.Status, acc.IsTLSFingerprintEnabled()) + slog.Debug("account_scheduling_account_detail", + "account_id", acc.ID, + "name", acc.Name, + "platform", acc.Platform, + "type", acc.Type, + "status", acc.Status, + "tls_fingerprint", acc.IsTLSFingerprintEnabled()) } } return accounts, useMixed, err @@ -1125,7 +1130,10 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, platforms) } if err != nil { - debugLog("[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v", derefGroupID(groupID), platform, err) + slog.Debug("account_scheduling_list_failed", + "group_id", derefGroupID(groupID), + "platform", platform, + "error", err) return nil, useMixed, err } filtered := make([]Account, 0, len(accounts)) @@ -1135,11 +1143,19 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i } filtered = append(filtered, acc) } - debugLog("[AccountScheduling] listSchedulableAccounts (mixed): groupID=%v platform=%s rawCount=%d filteredCount=%d", - derefGroupID(groupID), platform, len(accounts), len(filtered)) + slog.Debug("account_scheduling_list_mixed", + "group_id", derefGroupID(groupID), + "platform", platform, + "raw_count", len(accounts), + "filtered_count", len(filtered)) for _, acc := range filtered { - debugLog("[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v", - acc.ID, acc.Name, acc.Platform, acc.Type, acc.Status, acc.IsTLSFingerprintEnabled()) + slog.Debug("account_scheduling_account_detail", + "account_id", acc.ID, + "name", acc.Name, + "platform", acc.Platform, + "type", acc.Type, + "status", acc.Status, + "tls_fingerprint", acc.IsTLSFingerprintEnabled()) } return filtered, useMixed, nil } @@ -1155,14 +1171,24 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, platform) } if err != nil { - debugLog("[AccountScheduling] listSchedulableAccounts FAILED: groupID=%v platform=%s err=%v", derefGroupID(groupID), platform, err) + slog.Debug("account_scheduling_list_failed", + "group_id", derefGroupID(groupID), + "platform", platform, + "error", err) return nil, useMixed, err } - debugLog("[AccountScheduling] listSchedulableAccounts (single): groupID=%v platform=%s count=%d", - derefGroupID(groupID), platform, len(accounts)) + slog.Debug("account_scheduling_list_single", + "group_id", derefGroupID(groupID), + "platform", platform, + "count", len(accounts)) for _, acc := range accounts { - debugLog("[AccountScheduling] - Account ID=%d Name=%s Platform=%s Type=%s Status=%s TLSFingerprint=%v", - acc.ID, acc.Name, acc.Platform, acc.Type, acc.Status, acc.IsTLSFingerprintEnabled()) + slog.Debug("account_scheduling_account_detail", + "account_id", acc.ID, + "name", acc.Name, + "platform", acc.Platform, + "type", acc.Type, + "status", acc.Status, + "tls_fingerprint", acc.IsTLSFingerprintEnabled()) } return accounts, useMixed, nil } @@ -2605,9 +2631,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex fingerprint = fp // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid) + // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserID(body, account.ID, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 { body = newBody } } @@ -3638,12 +3665,13 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } // OAuth 账号:应用统一指纹和重写 userID + // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值 if account.IsOAuth() && s.identityService != nil { fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) if err == nil { accountUUID := account.GetExtraString("account_uuid") if accountUUID != "" && fp.ClientID != "" { - if newBody, err := s.identityService.RewriteUserID(body, account.ID, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 { + if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 { body = newBody } } diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 1ffa8057..e2e723b0 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -8,9 +8,11 @@ import ( "encoding/json" "fmt" "log" + "log/slog" "net/http" "regexp" "strconv" + "strings" "time" ) @@ -49,6 +51,13 @@ type Fingerprint struct { type IdentityCache interface { GetFingerprint(ctx context.Context, accountID int64) (*Fingerprint, error) SetFingerprint(ctx context.Context, accountID int64, fp *Fingerprint) error + // GetMaskedSessionID 获取固定的会话ID(用于会话ID伪装功能) + // 返回的 sessionID 是一个 UUID 格式的字符串 + // 如果不存在或已过期(15分钟无请求),返回空字符串 + GetMaskedSessionID(ctx context.Context, accountID int64) (string, error) + // SetMaskedSessionID 设置固定的会话ID,TTL 为 15 分钟 + // 每次调用都会刷新 TTL + SetMaskedSessionID(ctx context.Context, accountID int64, sessionID string) error } // IdentityService 管理OAuth账号的请求身份指纹 @@ -203,6 +212,94 @@ func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUI return json.Marshal(reqMap) } +// RewriteUserIDWithMasking 重写body中的metadata.user_id,支持会话ID伪装 +// 如果账号启用了会话ID伪装(session_id_masking_enabled), +// 则在完成常规重写后,将 session 部分替换为固定的伪装ID(15分钟内保持不变) +func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []byte, account *Account, accountUUID, cachedClientID string) ([]byte, error) { + // 先执行常规的 RewriteUserID 逻辑 + newBody, err := s.RewriteUserID(body, account.ID, accountUUID, cachedClientID) + if err != nil { + return newBody, err + } + + // 检查是否启用会话ID伪装 + if !account.IsSessionIDMaskingEnabled() { + return newBody, nil + } + + // 解析重写后的 body,提取 user_id + var reqMap map[string]any + if err := json.Unmarshal(newBody, &reqMap); err != nil { + return newBody, nil + } + + metadata, ok := reqMap["metadata"].(map[string]any) + if !ok { + return newBody, nil + } + + userID, ok := metadata["user_id"].(string) + if !ok || userID == "" { + return newBody, nil + } + + // 查找 _session_ 的位置,替换其后的内容 + const sessionMarker = "_session_" + idx := strings.LastIndex(userID, sessionMarker) + if idx == -1 { + return newBody, nil + } + + // 获取或生成固定的伪装 session ID + maskedSessionID, err := s.cache.GetMaskedSessionID(ctx, account.ID) + if err != nil { + log.Printf("Warning: failed to get masked session ID for account %d: %v", account.ID, err) + return newBody, nil + } + + if maskedSessionID == "" { + // 首次或已过期,生成新的伪装 session ID + maskedSessionID = generateRandomUUID() + log.Printf("Generated new masked session ID for account %d: %s", account.ID, maskedSessionID) + } + + // 刷新 TTL(每次请求都刷新,保持 15 分钟有效期) + if err := s.cache.SetMaskedSessionID(ctx, account.ID, maskedSessionID); err != nil { + log.Printf("Warning: failed to set masked session ID for account %d: %v", account.ID, err) + } + + // 替换 session 部分:保留 _session_ 之前的内容,替换之后的内容 + newUserID := userID[:idx+len(sessionMarker)] + maskedSessionID + + slog.Debug("session_id_masking_applied", + "account_id", account.ID, + "before", userID, + "after", newUserID, + ) + + metadata["user_id"] = newUserID + reqMap["metadata"] = metadata + + return json.Marshal(reqMap) +} + +// generateRandomUUID 生成随机 UUID v4 格式字符串 +func generateRandomUUID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // fallback: 使用时间戳生成 + h := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + b = h[:16] + } + + // 设置 UUID v4 版本和变体位 + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%x-%x-%x-%x-%x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + // generateClientID 生成64位十六进制客户端ID(32字节随机数) func generateClientID() string { b := make([]byte, 32) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 16295803..7906cd6b 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1346,6 +1346,33 @@ + + +
+
+
+ +

+ {{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }} +

+
+ +
+
@@ -1928,6 +1955,7 @@ const sessionLimitEnabled = ref(false) const maxSessions = ref(null) const sessionIdleTimeout = ref(null) const tlsFingerprintEnabled = ref(false) +const sessionIdMaskingEnabled = ref(false) // Gemini tier selection (used as fallback when auto-detection is unavailable/fails) const geminiTierGoogleOne = ref<'google_one_free' | 'google_ai_pro' | 'google_ai_ultra'>('google_one_free') @@ -2314,6 +2342,7 @@ const resetForm = () => { maxSessions.value = null sessionIdleTimeout.value = null tlsFingerprintEnabled.value = false + sessionIdMaskingEnabled.value = false tempUnschedEnabled.value = false tempUnschedRules.value = [] geminiOAuthType.value = 'code_assist' @@ -2602,6 +2631,11 @@ const handleAnthropicExchange = async (authCode: string) => { extra.enable_tls_fingerprint = true } + // Add session ID masking settings + if (sessionIdMaskingEnabled.value) { + extra.session_id_masking_enabled = true + } + const credentials = { ...tokenInfo, ...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {}) @@ -2690,6 +2724,11 @@ const handleCookieAuth = async (sessionKey: string) => { extra.enable_tls_fingerprint = true } + // Add session ID masking settings + if (sessionIdMaskingEnabled.value) { + extra.session_id_masking_enabled = true + } + const accountName = keys.length > 1 ? `${form.name} #${i + 1}` : form.name // Merge interceptWarmupRequests into credentials diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 59c20a73..81d10932 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -759,6 +759,33 @@
+ + +
+
+
+ +

+ {{ t('admin.accounts.quotaControl.sessionIdMasking.hint') }} +

+
+ +
+
@@ -932,6 +959,7 @@ const sessionLimitEnabled = ref(false) const maxSessions = ref(null) const sessionIdleTimeout = ref(null) const tlsFingerprintEnabled = ref(false) +const sessionIdMaskingEnabled = ref(false) // Computed: current preset mappings based on platform const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic')) @@ -1266,6 +1294,7 @@ function loadQuotaControlSettings(account: Account) { maxSessions.value = null sessionIdleTimeout.value = null tlsFingerprintEnabled.value = false + sessionIdMaskingEnabled.value = false // Only applies to Anthropic OAuth/SetupToken accounts if (account.platform !== 'anthropic' || (account.type !== 'oauth' && account.type !== 'setup-token')) { @@ -1289,6 +1318,11 @@ function loadQuotaControlSettings(account: Account) { if (account.enable_tls_fingerprint === true) { tlsFingerprintEnabled.value = true } + + // Load session ID masking setting + if (account.session_id_masking_enabled === true) { + sessionIdMaskingEnabled.value = true + } } function formatTempUnschedKeywords(value: unknown) { @@ -1448,6 +1482,13 @@ const handleSubmit = async () => { delete newExtra.enable_tls_fingerprint } + // Session ID masking setting + if (sessionIdMaskingEnabled.value) { + newExtra.session_id_masking_enabled = true + } else { + delete newExtra.session_id_masking_enabled + } + updatePayload.extra = newExtra } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0f1494d2..362a1349 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1293,6 +1293,10 @@ export default { tlsFingerprint: { label: 'TLS Fingerprint Simulation', hint: 'Simulate Node.js/Claude Code client TLS fingerprint' + }, + sessionIdMasking: { + label: 'Session ID Masking', + hint: 'When enabled, fixes the session ID in metadata.user_id for 15 minutes, making upstream think requests come from the same session' } }, expired: 'Expired', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 7b477234..1efd3867 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1425,6 +1425,10 @@ export default { tlsFingerprint: { label: 'TLS 指纹模拟', hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹' + }, + sessionIdMasking: { + label: '会话 ID 伪装', + hint: '启用后将在 15 分钟内固定 metadata.user_id 中的 session ID,使上游认为请求来自同一会话' } }, expired: '已过期', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 24cd1f27..35e256e6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -483,6 +483,10 @@ export interface Account { // TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效) enable_tls_fingerprint?: boolean | null + // 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效) + // 启用后将在15分钟内固定 metadata.user_id 中的 session ID + session_id_masking_enabled?: boolean | null + // 运行时状态(仅当启用对应限制时返回) current_window_cost?: number | null // 当前窗口费用 active_sessions?: number | null // 当前活跃会话数