sub2api/backend/internal/service/bootstrap_preflight.go
win 435ae221bc
Some checks failed
CI / test (push) Failing after 1m32s
CI / golangci-lint (push) Failing after 31s
Security Scan / backend-security (push) Failing after 1m32s
Security Scan / frontend-security (push) Failing after 9s
x
2026-04-16 19:11:47 +08:00

309 lines
9.8 KiB
Go

package service
import (
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
claude "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/telemetry"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
)
// backgroundSimulator simulates the real Claude Code CLI's background network behavior.
// Real CLI performs bootstrap, GrowthBook feature-flag polling, and policy_limits polling.
// Missing these creates a behavioral correlation gap detectable by Anthropic.
type backgroundSimulator struct {
mu sync.Mutex
called map[int64]*accountBackgroundState
client *http.Client
baseURL string
}
type BackgroundRequestOptions struct {
ProxyURL string
HTTPUpstream HTTPUpstream
TLSProfile *tlsfingerprint.Profile
InstanceSalt string
UseSharedUpstream bool
}
type accountBackgroundState struct {
bootstrapAt time.Time
growthbookAt time.Time
policyLimitsAt time.Time
// Timers for periodic polling — stopped when account goes idle
growthbookTimer *time.Timer
policyLimitsTimer *time.Timer
exitTimer *time.Timer // fires tengu_exit after idle timeout
accessToken string
accountID int64
proxyURL string
httpUpstream HTTPUpstream
tlsProfile *tlsfingerprint.Profile
instanceSalt string
useSharedUpstream bool
}
const (
bootstrapCooldown = 1 * time.Hour
growthbookInterval = 20 * time.Minute
policyLimitsInterval = 1 * time.Hour
sessionIdleTimeout = 10 * time.Minute // fire tengu_exit after no requests for 10min
)
var globalBgSim = &backgroundSimulator{
called: make(map[int64]*accountBackgroundState),
client: &http.Client{Timeout: 5 * time.Second},
}
// SetBootstrapBaseURL configures the API base URL for background simulation calls.
func SetBootstrapBaseURL(baseURL string) {
globalBgSim.baseURL = baseURL
}
// TriggerBootstrapIfNeeded fires background simulation calls for the given OAuth account.
// On first call per account: bootstrap + GrowthBook + policy_limits + start periodic timers.
// On subsequent calls: refresh idle timer (delays tengu_exit).
func TriggerBootstrapIfNeeded(accountID int64, accessToken string, opts *BackgroundRequestOptions) {
bg := globalBgSim
bg.mu.Lock()
state, exists := bg.called[accountID]
if !exists {
// First time: create state, fire all startup calls
state = &accountBackgroundState{
accessToken: accessToken,
accountID: accountID,
}
state.applyRequestOptions(opts)
bg.called[accountID] = state
bg.mu.Unlock()
// Fire-and-forget startup sequence (matches real CLI order)
go bg.doBootstrap(state)
go bg.doGrowthBookFetch(state)
go bg.doPolicyLimitsFetch(state)
bg.startPeriodicPolling(state)
bg.resetExitTimer(state)
return
}
// Update token (may have been refreshed)
state.accessToken = accessToken
state.applyRequestOptions(opts)
// Bootstrap: 1 hour cooldown
if time.Since(state.bootstrapAt) >= bootstrapCooldown {
state.bootstrapAt = time.Now()
bg.mu.Unlock()
go bg.doBootstrap(state)
} else {
bg.mu.Unlock()
}
// Reset idle timer (user is active)
bg.resetExitTimer(state)
}
func (bg *backgroundSimulator) getBaseURL() string {
if bg.baseURL != "" {
return bg.baseURL
}
return "https://api.anthropic.com"
}
func (state *accountBackgroundState) applyRequestOptions(opts *BackgroundRequestOptions) {
if state == nil || opts == nil {
return
}
state.proxyURL = opts.ProxyURL
state.httpUpstream = opts.HTTPUpstream
state.tlsProfile = opts.TLSProfile
state.instanceSalt = opts.InstanceSalt
state.useSharedUpstream = opts.UseSharedUpstream
}
func (bg *backgroundSimulator) applyBackgroundHeaders(req *http.Request, state *accountBackgroundState, contentType string) {
if req == nil || state == nil {
return
}
req.Header.Set("Accept", "application/json, text/plain, */*")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("User-Agent", claude.DefaultCodeUserAgent())
req.Header.Set("Authorization", "Bearer "+state.accessToken)
req.Header.Set("anthropic-beta", claude.BetaOAuth)
}
func (bg *backgroundSimulator) doRequest(ctx context.Context, state *accountBackgroundState, req *http.Request) (*http.Response, error) {
if req == nil {
return nil, nil
}
if state != nil && state.useSharedUpstream && state.httpUpstream != nil {
if req.URL != nil && req.URL.Scheme == "https" {
return state.httpUpstream.DoWithTLS(req, state.proxyURL, state.accountID, 0, state.tlsProfile)
}
return state.httpUpstream.Do(req, state.proxyURL, state.accountID, 0)
}
return bg.client.Do(req.WithContext(ctx))
}
// ─── Bootstrap ───────────────────────────────────────────
func (bg *backgroundSimulator) doBootstrap(state *accountBackgroundState) {
state.bootstrapAt = time.Now()
endpoint := bg.getBaseURL() + "/api/claude_cli/bootstrap"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return
}
bg.applyBackgroundHeaders(req, state, "application/json")
resp, err := bg.doRequest(ctx, state, req)
if err != nil {
logger.LegacyPrintf("service.bootstrap", "Bootstrap preflight failed: %v", err)
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
logger.LegacyPrintf("service.bootstrap", "Bootstrap completed: account=%d status=%d", state.accountID, resp.StatusCode)
}
// ─── GrowthBook Feature Flags ────────────────────────────
func (bg *backgroundSimulator) doGrowthBookFetch(state *accountBackgroundState) {
state.growthbookAt = time.Now()
// Real CLI uses GrowthBook SDK with remoteEval: true
// SDK key for external users: sdk-zAZezfDKGoZuXXKe
// Endpoint: GET {apiHost}/sub/features/{clientKey}
// Source: extracted/src/services/analytics/growthbook.ts:503-555
endpoint := bg.getBaseURL() + "/sub/features/sdk-zAZezfDKGoZuXXKe"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return
}
bg.applyBackgroundHeaders(req, state, "")
resp, err := bg.doRequest(ctx, state, req)
if err != nil {
logger.LegacyPrintf("service.bootstrap", "GrowthBook fetch failed: %v", err)
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
logger.LegacyPrintf("service.bootstrap", "GrowthBook fetch completed: account=%d status=%d", state.accountID, resp.StatusCode)
}
// ─── Policy Limits ───────────────────────────────────────
func (bg *backgroundSimulator) doPolicyLimitsFetch(state *accountBackgroundState) {
state.policyLimitsAt = time.Now()
// Source: extracted/src/services/policyLimits/index.ts:127
endpoint := bg.getBaseURL() + "/api/claude_code/policy_limits"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return
}
bg.applyBackgroundHeaders(req, state, "application/json")
resp, err := bg.doRequest(ctx, state, req)
if err != nil {
logger.LegacyPrintf("service.bootstrap", "Policy limits fetch failed: %v", err)
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
logger.LegacyPrintf("service.bootstrap", "Policy limits fetch completed: account=%d status=%d", state.accountID, resp.StatusCode)
}
// ─── Periodic Polling ────────────────────────────────────
func (bg *backgroundSimulator) startPeriodicPolling(state *accountBackgroundState) {
// GrowthBook: every 20 minutes
// Source: growthbook.ts setupPeriodicGrowthBookRefresh()
go func() {
// Add jitter to avoid all accounts polling at the same time
jitter := time.Duration(state.accountID%300) * time.Second
time.Sleep(growthbookInterval + jitter)
for {
bg.doGrowthBookFetch(state)
time.Sleep(growthbookInterval + time.Duration(state.accountID%60)*time.Second)
}
}()
// Policy limits: every hour
// Source: policyLimits/index.ts refreshPolicyLimits()
go func() {
jitter := time.Duration(state.accountID%600) * time.Second
time.Sleep(policyLimitsInterval + jitter)
for {
bg.doPolicyLimitsFetch(state)
time.Sleep(policyLimitsInterval + time.Duration(state.accountID%120)*time.Second)
}
}()
}
// ─── tengu_exit Event ────────────────────────────────────
func (bg *backgroundSimulator) resetExitTimer(state *accountBackgroundState) {
bg.mu.Lock()
defer bg.mu.Unlock()
// Cancel existing timer
if state.exitTimer != nil {
state.exitTimer.Stop()
}
// Set new timer: fire tengu_exit after idle timeout
state.exitTimer = time.AfterFunc(sessionIdleTimeout, func() {
bg.fireExitEvent(state)
})
}
func (bg *backgroundSimulator) fireExitEvent(state *accountBackgroundState) {
// tengu_exit is sent via the 1P event_logging/batch endpoint
// Source: extracted/src/services/analytics/firstPartyEventLogger.ts
// We use proxy.js's sendTelemetryEvents path (same endpoint), but since
// proxy.js runs per-request and this is idle-based, we fire directly here.
// The event is a lightweight signal — just needs to exist in Anthropic's logs.
// Real CLI sends it on process exit; we simulate on idle timeout.
logger.LegacyPrintf("service.bootstrap", "Session idle timeout, firing tengu_exit: account=%d", state.accountID)
telemetry.EmitExit(
fmt.Sprintf("%d", state.accountID),
"Bearer "+state.accessToken,
state.accessToken,
"",
"",
nil,
)
// Clean up the state to allow fresh bootstrap on next request
bg.mu.Lock()
delete(bg.called, state.accountID)
bg.mu.Unlock()
}