forked from james-6-23/codex2api
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
805 lines (747 loc) · 32.4 KB
/
Copy pathmain.go
File metadata and controls
805 lines (747 loc) · 32.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
package main
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
// Windows 与无 zoneinfo 的精简环境没有 IANA 时区库,内嵌兜底让 TZ=Asia/Shanghai
// 这类名字仍可解析(系统自带 zoneinfo 时优先用系统的)。issue #498。
_ "time/tzdata"
"github.com/codex2api/admin"
"github.com/codex2api/api"
"github.com/codex2api/auth"
"github.com/codex2api/cache"
"github.com/codex2api/config"
"github.com/codex2api/database"
"github.com/codex2api/internal/imagestore"
"github.com/codex2api/internal/version"
"github.com/codex2api/proxy"
"github.com/codex2api/proxy/wsrelay"
"github.com/codex2api/security"
"github.com/codex2api/security/promptfilter"
"github.com/gin-gonic/gin"
)
//go:embed frontend/dist/*
var frontendFS embed.FS
func migrateOnlyEnabled() bool {
value := strings.TrimSpace(os.Getenv("CODEX_MIGRATE_ONLY"))
return value == "1" || strings.EqualFold(value, "true")
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("Codex2API v2 启动中...")
// 1. 加载配置 (.env)
cfg, err := config.Load(".env")
if err != nil {
log.Fatalf("加载核心环境配置失败 (请检查 .env 文件): %v", err)
}
log.Printf("物理层配置加载成功: port=%d, database=%s, cache=%s, tz=%s", cfg.Port, cfg.Database.Label(), cfg.Cache.Label(), time.Local)
// 2. 初始化数据库
db, err := database.New(cfg.Database.Driver, cfg.Database.DSN(), cfg.Database.Schema)
if err != nil {
log.Fatalf("数据库初始化失败: %v", err)
}
defer db.Close()
if migrateOnlyEnabled() {
log.Println("数据库迁移完成,CODEX_MIGRATE_ONLY 已启用,进程退出")
return
}
switch cfg.Database.Driver {
case "sqlite":
log.Printf("%s 连接成功: %s", cfg.Database.Label(), cfg.Database.Path)
default:
if cfg.Database.Schema != "" {
log.Printf("%s 连接成功: %s:%d/%s (schema=%s)", cfg.Database.Label(), cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName, cfg.Database.Schema)
} else {
log.Printf("%s 连接成功: %s:%d/%s", cfg.Database.Label(), cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName)
}
}
// 3. 读取运行时的系统逻辑设置(需在缓存初始化之前,以获取连接池大小)
sysCtx, sysCancel := context.WithTimeout(context.Background(), 5*time.Second)
settings, err := db.GetSystemSettings(sysCtx)
sysCancel()
recommendedPromptFilter := promptfilter.RecommendedConfig()
if err == nil && settings == nil {
// 初次运行,保存初始安全设置到数据库
log.Printf("初次运行,初始化系统默认设置...")
settings = &database.SystemSettings{
SiteName: database.DefaultSiteName,
MaxConcurrency: 2,
CodexTelemetryEnabled: false, // 实验性:模拟遥测默认不外发,由部署者显式开启
GlobalRPM: 0,
TestModel: auth.DefaultTestModel,
TestContent: auth.DefaultTestContent,
TestConcurrency: 50,
MaxRateLimitRetries: 1,
BackgroundRefreshIntervalMinutes: 2,
UsageProbeMaxAgeMinutes: 10,
UsageProbeConcurrency: 16,
RecoveryProbeIntervalMinutes: 30,
LazyMode: false,
ProxyURL: "",
PgMaxConns: 50,
RedisPoolSize: 30,
AutoCleanUnauthorized: false,
AutoCleanRateLimited: false,
PromptFilterMode: recommendedPromptFilter.Mode,
PromptFilterThreshold: recommendedPromptFilter.Threshold,
PromptFilterStrictThreshold: recommendedPromptFilter.StrictThreshold,
PromptFilterStrictTerminalEnabled: recommendedPromptFilter.StrictTerminalEnabled,
PromptFilterAdvancedConfig: promptfilter.MarshalAdvancedConfig(recommendedPromptFilter.Advanced),
PromptFilterLogMatches: true,
PromptFilterMaxTextLength: 81920,
PromptFilterCustomPatterns: "[]",
PromptFilterDisabledPatterns: "[]",
ClientCompatMode: proxy.ClientCompatModePreserve,
CodexMinCLIVersion: "0.153.3",
UsageLogMode: database.UsageLogModeFull,
UsageLogBatchSize: 200,
UsageLogFlushIntervalSeconds: 5,
StreamFlushPolicy: proxy.StreamFlushPolicyImmediate,
StreamFlushIntervalMS: 20,
FirstTokenMode: proxy.FirstTokenModeStrict,
FirstTokenTimeoutSeconds: 0,
BillingTierPolicy: proxy.NormalizeBillingTierPolicy(os.Getenv("CODEX_BILLING_TIER_POLICY")),
ImageStorageConfig: "{}",
PublicKeyUsagePageEnabled: true,
PublicImageStudioPageEnabled: true,
CodexWSHideUpstreamErrors: true,
CodexWSSilentRetryEnabled: true,
CodexWSSilentMaxRetries: 2,
CodexContinueMaxRounds: 8,
AutoPause5hGuardBandPercent: 5,
AutoPause5hGuardConcurrency: 1,
SmartPacingMinConcurrency: 1,
SmartPacingWindows: "5h,7d",
AutoResetCreditsBeforeExpiryMin: 60,
UTLSShutdownTimeoutMinutes: 30,
}
_ = db.UpdateSystemSettings(context.Background(), settings)
} else if err != nil {
log.Printf("警告: 读取系统设置失败: %v,将采用安全后备策略", err)
settings = &database.SystemSettings{
SiteName: database.DefaultSiteName,
MaxConcurrency: 2,
CodexTelemetryEnabled: false, // 实验性:模拟遥测默认不外发,由部署者显式开启
GlobalRPM: 0,
TestModel: auth.DefaultTestModel,
TestContent: auth.DefaultTestContent,
TestConcurrency: 50,
MaxRateLimitRetries: 1,
BackgroundRefreshIntervalMinutes: 2,
UsageProbeMaxAgeMinutes: 10,
UsageProbeConcurrency: 16,
RecoveryProbeIntervalMinutes: 30,
LazyMode: false,
PgMaxConns: 50,
RedisPoolSize: 30,
PromptFilterMode: recommendedPromptFilter.Mode,
PromptFilterThreshold: recommendedPromptFilter.Threshold,
PromptFilterStrictThreshold: recommendedPromptFilter.StrictThreshold,
PromptFilterStrictTerminalEnabled: recommendedPromptFilter.StrictTerminalEnabled,
PromptFilterAdvancedConfig: promptfilter.MarshalAdvancedConfig(recommendedPromptFilter.Advanced),
PromptFilterLogMatches: true,
PromptFilterMaxTextLength: 81920,
PromptFilterCustomPatterns: "[]",
PromptFilterDisabledPatterns: "[]",
ClientCompatMode: proxy.ClientCompatModePreserve,
CodexMinCLIVersion: "0.153.3",
UsageLogMode: database.UsageLogModeFull,
UsageLogBatchSize: 200,
UsageLogFlushIntervalSeconds: 5,
StreamFlushPolicy: proxy.StreamFlushPolicyImmediate,
StreamFlushIntervalMS: 20,
FirstTokenMode: proxy.FirstTokenModeStrict,
FirstTokenTimeoutSeconds: 0,
BillingTierPolicy: proxy.NormalizeBillingTierPolicy(os.Getenv("CODEX_BILLING_TIER_POLICY")),
ImageStorageConfig: "{}",
PublicKeyUsagePageEnabled: true,
PublicImageStudioPageEnabled: true,
CodexWSHideUpstreamErrors: true,
CodexWSSilentRetryEnabled: true,
CodexWSSilentMaxRetries: 2,
CodexContinueMaxRounds: 8,
AutoPause5hGuardBandPercent: 5,
AutoPause5hGuardConcurrency: 1,
SmartPacingMinConcurrency: 1,
SmartPacingWindows: "5h,7d",
AutoResetCreditsBeforeExpiryMin: 60,
UTLSShutdownTimeoutMinutes: 30,
}
} else {
log.Printf("已加载持久化业务设置: ProxyURL=%s, MaxConcurrency=%d, GlobalRPM=%d, PgMaxConns=%d, RedisPoolSize=%d",
settings.ProxyURL, settings.MaxConcurrency, settings.GlobalRPM, settings.PgMaxConns, settings.RedisPoolSize)
}
modelCooldownCtx, modelCooldownCancel := context.WithTimeout(context.Background(), 3*time.Second)
modelCooldownSettings, modelCooldownErr := db.GetModelCooldownSettings(modelCooldownCtx)
modelCooldownCancel()
if modelCooldownErr != nil {
log.Printf("警告: 读取模型冷却设置失败,将采用安全默认值: %v", modelCooldownErr)
modelCooldownSettings = database.DefaultModelCooldownSettings()
}
settings.RelayModelCooldownMode = modelCooldownSettings.RelayMode
settings.RelayModelCooldownSeconds = modelCooldownSettings.RelaySeconds
settings.RelayModelCooldownBackoffEnabled = modelCooldownSettings.RelayBackoffEnabled
settings.OAuthModelCooldownMode = modelCooldownSettings.OAuthMode
settings.OAuthModelCooldownSeconds = modelCooldownSettings.OAuthSeconds
settings.OAuthModelCooldownBackoffEnabled = modelCooldownSettings.OAuthBackoffEnabled
if envPolicy := strings.TrimSpace(os.Getenv("CODEX_BILLING_TIER_POLICY")); envPolicy != "" {
settings.BillingTierPolicy = proxy.NormalizeBillingTierPolicy(envPolicy)
}
responseCacheCtx, responseCacheCancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := proxy.LoadResponseCacheSettings(responseCacheCtx, db); err != nil {
responseCacheCancel()
log.Fatalf("加载响应缓存设置失败: %v", err)
}
responseCacheCancel()
antigravityOAuthCtx, antigravityOAuthCancel := context.WithTimeout(context.Background(), 3*time.Second)
if raw, err := db.LoadAntigravityOAuthConfig(antigravityOAuthCtx); err != nil {
log.Printf("加载 Antigravity OAuth client 设置失败(继续以环境变量为准): %v", err)
} else if parsed, parseErr := auth.ParseAntigravityOAuthSettings(raw); parseErr != nil {
log.Printf("Antigravity OAuth client 设置解析失败(继续以环境变量为准,请在管理页重新保存): %v", parseErr)
} else {
auth.SetConfiguredAntigravityOAuth(parsed)
if len(parsed.Clients) > 0 {
log.Printf("Antigravity OAuth client 设置已加载: %d 个 client", len(parsed.Clients))
}
}
antigravityOAuthCancel()
antigravityCfgCtx, antigravityCfgCancel := context.WithTimeout(context.Background(), 3*time.Second)
if raw, err := db.LoadAntigravityConfig(antigravityCfgCtx); err != nil {
log.Printf("加载 Antigravity 渠道设置失败(模型重定向不生效): %v", err)
} else if parsed, parseErr := auth.ParseAntigravitySettings(raw); parseErr != nil {
log.Printf("Antigravity 渠道设置解析失败(模型重定向不生效,请在管理页重新保存): %v", parseErr)
} else {
auth.SetConfiguredAntigravitySettings(parsed)
if len(parsed.ModelRedirects) > 0 {
log.Printf("Antigravity 模型重定向已加载: %d 条", len(parsed.ModelRedirects))
}
}
antigravityCfgCancel()
appliedResponseCache := proxy.GetResponseCacheAppliedConfig()
log.Printf(
"响应缓存设置已加载: generation=%d total=%d entry=%d reconstruct=%d",
appliedResponseCache.Generation,
appliedResponseCache.LocalMaxBytes,
appliedResponseCache.LocalMaxEntryBytes,
appliedResponseCache.ReconstructMaxBytes,
)
// 4. 初始化缓存(使用数据库中保存的连接池大小)
redisPoolSize := 30
if settings.RedisPoolSize > 0 {
redisPoolSize = settings.RedisPoolSize
}
var tc cache.TokenCache
switch cfg.Cache.Driver {
case "memory":
tc = cache.NewMemory(redisPoolSize)
default:
tc, err = cache.NewRedisWithOptions(cache.RedisOptions{
Addr: cfg.Cache.Redis.Addr,
Username: cfg.Cache.Redis.Username,
Password: cfg.Cache.Redis.Password,
DB: cfg.Cache.Redis.DB,
PoolSize: redisPoolSize,
TLS: cfg.Cache.Redis.TLS,
InsecureSkipVerify: cfg.Cache.Redis.InsecureSkipVerify,
})
if err != nil {
log.Fatalf("缓存初始化失败: %v", err)
}
}
defer tc.Close()
switch cfg.Cache.Driver {
case "memory":
log.Printf("%s 缓存已启用: pool_size=%d", cfg.Cache.Label(), redisPoolSize)
default:
log.Printf("%s 连接成功: %s, pool_size=%d", cfg.Cache.Label(), cache.RedactRedisAddr(cfg.Cache.Redis.Addr), redisPoolSize)
}
proxy.SetResponseContextCache(tc)
// 4b. 应用数据库连接池设置
if settings.PgMaxConns > 0 {
db.SetMaxOpenConns(settings.PgMaxConns)
log.Printf("%s 连接池: max_conns=%d", cfg.Database.Label(), settings.PgMaxConns)
}
db.SetUsageLogConfig(settings.UsageLogMode, settings.UsageLogBatchSize, settings.UsageLogFlushIntervalSeconds)
if overrides, perr := database.ParseModelPricingOverridesJSON(settings.ModelPricingOverrides); perr == nil {
database.SetModelPricingOverrides(overrides)
}
runtimeSettings := proxy.ApplyRuntimeSettingsFromSystem(settings)
log.Printf("运行时优化配置: client_compat=%s min_cli=%s usage_log=%s batch=%d flush=%ds stream_flush=%s/%dms first_token_mode=%s first_token_timeout=%ds billing_tier_policy=%s",
runtimeSettings.ClientCompatMode,
runtimeSettings.CodexMinCLIVersion,
db.GetUsageLogMode(),
db.GetUsageLogBatchSize(),
db.GetUsageLogFlushIntervalSeconds(),
runtimeSettings.StreamFlushPolicy,
runtimeSettings.StreamFlushIntervalMS,
runtimeSettings.FirstTokenMode,
runtimeSettings.FirstTokenTimeoutSec,
runtimeSettings.BillingTierPolicy,
)
// 4b'. 应用图片存储后端配置
imgLocalDir := strings.TrimSpace(os.Getenv("IMAGE_ASSET_DIR"))
if imgLocalDir == "" {
imgLocalDir = "/data/images"
}
if imgCfg, err := imagestore.ApplyFromJSON(settings.ImageStorageConfig, imgLocalDir); err != nil {
log.Printf("图片存储配置应用失败,已回退到本地: %v", err)
} else {
log.Printf("图片存储后端: %s", imgCfg.Backend)
}
// 4c. 初始化 Resin 粘性代理池
if settings.ResinURL != "" && settings.ResinPlatformName != "" {
proxy.SetResinConfig(&proxy.ResinConfig{
BaseURL: settings.ResinURL,
PlatformName: settings.ResinPlatformName,
})
// 注入 Resin URL 装饰器到 auth 包(避免 auth → proxy 循环依赖)
auth.ResinRequestDecorator = func(targetURL, accountID string) string {
return proxy.BuildReverseProxyURL(targetURL)
}
}
// Claude CLI 同步版本先于账号加载发布,保证 GenerateClaudeFingerprint 与回写使用同一生效版本。
claudeCLIVersionCtx, claudeCLIVersionCancel := context.WithTimeout(context.Background(), 10*time.Second)
if synced, err := db.GetClaudeSyncedCLIVersion(claudeCLIVersionCtx); err == nil {
auth.SetClaudeSyncedCLIVersion(synced)
} else {
log.Printf("读取 Claude CLI 同步版本失败(使用内置 %s): %v", auth.BuiltinClaudeCLIVersion, err)
}
claudeCLIVersionCancel()
// 5. 初始化账号管理器
store := auth.NewStore(db, tc, settings)
store.SetSchedulerWaitLimits(cfg.SchedulerMaxWaiters, cfg.SchedulerMaxWaitersPerKey)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
if err := store.Init(ctx); err != nil {
cancel()
log.Fatalf("账号初始化失败: %v", err)
}
cancel()
// 全局 RPM 限流器
rateLimiter := proxy.NewRateLimiter(settings.GlobalRPM)
adminHandler := admin.NewHandler(store, db, tc, rateLimiter, cfg.AdminSecret)
// 初始化 admin handler 的连接池设置跟踪
adminHandler.SetPoolSizes(settings.PgMaxConns, settings.RedisPoolSize)
store.SetUsageProbeFunc(adminHandler.ProbeUsageSnapshot)
// 启动后台刷新
store.StartBackgroundRefresh()
store.TriggerUsageProbeAsync()
store.TriggerRecoveryProbeAsync()
store.TriggerAutoCleanupAsync()
defer store.Stop()
backgroundCtx, cancelBackground := context.WithCancel(context.Background())
defer cancelBackground()
if !proxy.StartResponseCacheSettingsPoller(backgroundCtx, db) {
log.Fatalf("启动响应缓存设置同步失败")
}
adminHandler.StartAutoResetCredits(backgroundCtx)
adminHandler.StartAutoActivate5hWindow(backgroundCtx)
// Grok 账号状态定期探测(默认关,由 grok 系统设置开关/间隔控制)
adminHandler.StartGrokStatusProbe(backgroundCtx)
// 官方结算用量按天快照:上游只保留 7 天,不落库就永久丢失,长期历史全靠这个任务。
adminHandler.StartWhamDailyUsageProbe(backgroundCtx)
// 官方模型价目轮询默认关闭;启用后只在网络解析完成后做一次短数据库写入。
adminHandler.StartOfficialPricingSync(backgroundCtx)
// Prompt 审核日志保留清理:默认保留 7 天,每小时分批清理过期行,CY 关联行不动。
adminHandler.StartPromptLogRetention(backgroundCtx)
// 后台定时同步 Codex CLI 模拟版本(启动即拉一次,之后按设置的间隔);
// 出上游新版本门槛时无需发版即可跟进。开关/间隔在设置页可调,
// CODEX_DISABLE_CLI_VERSION_SYNC 为硬关闭。
proxy.StartCodexCLIVersionSync(backgroundCtx, db, store.GetProxyURL)
// Claude Code CLI 版本同步:启动先用生效版本回写账号指纹,再按 ClaudeConfig 开关/间隔联网同步。
proxy.StartClaudeCLIVersionSync(backgroundCtx, db, store, store.GetProxyURL)
log.Printf("账号就绪: %d/%d 可用", store.AvailableCount(), store.AccountCount())
// 6. 启动 HTTP 服务
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// 默认信任本机回环与常见私有网段,兼顾同机 / Docker WAF 反代获取真实 IP 与公网直连防伪造;
// 如需收紧或扩展可信代理范围,可通过 CODEX_TRUSTED_PROXIES 显式配置 CIDR/IP。
if err := configureTrustedProxies(r, cfg.TrustedProxies); err != nil {
log.Fatalf("配置可信代理失败: %v", err)
}
r.Use(api.RecoveryMiddleware())
r.Use(api.RequestContextMiddleware())
r.Use(api.VersionMiddleware())
security.MaxRequestBodySize = cfg.MaxRequestBodySize
// 账号导入端点(multipart 文件上传)单独放宽体积上限,默认 200MB,可用
// CODEX_MAX_IMPORT_BODY_SIZE_MB 覆盖。前端按大小分批发送,单批控制在此上限内。
if v := strings.TrimSpace(os.Getenv("CODEX_MAX_IMPORT_BODY_SIZE_MB")); v != "" {
if mb, err := strconv.Atoi(v); err == nil && mb > 0 {
security.MaxImportBodySize = int64(mb) * 1024 * 1024
}
}
if security.MaxImportBodySize < int64(security.MaxRequestBodySize) {
security.MaxImportBodySize = int64(security.MaxRequestBodySize)
}
r.Use(security.RequestSizeLimiter(int64(security.MaxRequestBodySize)))
r.Use(security.RequestBodyDecompressor(int64(security.MaxRequestBodySize)))
r.Use(api.BodyCacheMiddleware())
r.Use(api.CORSMiddleware())
r.Use(api.SecurityHeadersMiddleware())
r.Use(loggerMiddleware())
r.Use(security.SecurityHeadersMiddleware())
// handler 不再接收 cfg.APIKeys
// 从环境变量读取 Codex 画像与 Beta 配置。
deviceCfg := proxy.DeviceProfileConfigFromEnv(os.Getenv)
handler := proxy.NewHandler(store, db, cfg, deviceCfg)
handler.SetRuntimeCache(tc)
defer handler.CloseAPIKeyAuthCache()
adminHandler.SetAPIKeyAuthCacheHandler(handler)
// 注册 WebSocket 执行函数(避免 proxy ↔ wsrelay 循环依赖)
proxy.WebsocketExecuteFunc = wsrelay.ExecuteRequestWebsocket
// 注册 Agent Identity task 确保函数(proxy 无 Store 引用,启动时注入)
proxy.EnsureCodexAgentIdentityTaskFunc = store.EnsureCodexAgentIdentityTask
// 上游 WS 空闲连接保活常驻任务(默认关闭:goroutine 常驻但仅在运行时开关开启时才发送 Ping)
wsKeepalive := wsrelay.NewKeepaliveTask(
wsrelay.GetManager(),
store.CodexWSKeepaliveEnabled,
store.CodexWSKeepaliveIntervalSec,
)
wsKeepalive.Start()
r.Use(rateLimiter.Middleware())
if settings.GlobalRPM > 0 {
log.Printf("全局限流已生效: %d RPM", settings.GlobalRPM)
}
log.Printf("单账号并发上限: %d", settings.MaxConcurrency)
handler.RegisterRoutes(r)
adminHandler.RegisterExternalImageRoutes(r, handler)
adminHandler.StartPromptIntelligence(backgroundCtx)
adminHandler.RegisterRoutes(r)
// 管理后台前端静态文件
subFS, err := fs.Sub(frontendFS, "frontend/dist")
if err != nil {
log.Printf("前端静态文件加载失败(开发模式可忽略): %v", err)
} else {
httpFS := http.FS(subFS)
// 预读 index.html(SPA 回退时直接返回,避免 FileServer 重定向)
indexHTML, _ := fs.ReadFile(subFS, "index.html")
serveFrontend := func(c *gin.Context) {
fp := c.Param("filepath")
// 尝试打开请求的文件(排除目录和根路径)
if fp != "/" && len(fp) > 1 {
trimmed := fp[1:] // 去掉开头的 /
if f, err := subFS.Open(trimmed); err == nil {
fi, statErr := f.Stat()
f.Close()
if statErr == nil && !fi.IsDir() {
if strings.HasPrefix(trimmed, "assets/") {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "no-cache")
}
c.FileFromFS(fp, httpFS)
return
}
}
// 带 hash 的静态资源不存在时必须返回 404。若回退到 index.html,
// 浏览器会把 HTML 当成 JS/CSS 解析并反复触发 chunk load error。
if strings.HasPrefix(trimmed, "assets/") {
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
c.Status(http.StatusNotFound)
return
}
}
// 文件不存在或者是目录 → 直接返回 index.html 字节(让 React Router 处理)
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
c.Header("Pragma", "no-cache")
if c.Query("refresh-assets") == "1" {
// 仅清理 HTTP cache,不影响登录态、localStorage 或站点配置。
c.Header("Clear-Site-Data", `"cache"`)
}
c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML)
}
serveKeyUsageFrontend := func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
enabled, err := adminHandler.PublicAPIKeyUsagePageEnabled(ctx)
if err != nil {
log.Printf("读取 API Key 自助用量页开关失败: %v", err)
c.Status(http.StatusInternalServerError)
return
}
if !enabled {
c.Status(http.StatusNotFound)
return
}
serveFrontend(c)
}
serveImageStudioFrontend := func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
enabled, err := adminHandler.PublicImageStudioPageEnabled(ctx)
if err != nil {
log.Printf("读取生图门户开关失败: %v", err)
c.Status(http.StatusInternalServerError)
return
}
if !enabled {
c.Status(http.StatusNotFound)
return
}
serveFrontend(c)
}
serveAccountPortalFrontend := func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
enabled, err := adminHandler.PublicAccountPortalPageEnabled(ctx)
if err != nil {
log.Printf("读取账号自助门户开关失败: %v", err)
c.Status(http.StatusInternalServerError)
return
}
if !enabled {
c.Status(http.StatusNotFound)
return
}
serveFrontend(c)
}
// 同时处理 /admin 和 /admin/*,避免依赖自动补斜杠重定向。
r.GET("/admin", serveFrontend)
r.GET("/admin/*filepath", serveFrontend)
r.HEAD("/admin", serveFrontend)
r.HEAD("/admin/*filepath", serveFrontend)
r.GET("/key-usage", serveKeyUsageFrontend)
r.GET("/key-usage/*filepath", serveKeyUsageFrontend)
r.HEAD("/key-usage", serveKeyUsageFrontend)
r.HEAD("/key-usage/*filepath", serveKeyUsageFrontend)
r.GET("/image-studio", serveImageStudioFrontend)
r.GET("/image-studio/*filepath", serveImageStudioFrontend)
r.HEAD("/image-studio", serveImageStudioFrontend)
r.HEAD("/image-studio/*filepath", serveImageStudioFrontend)
r.GET("/account-portal", serveAccountPortalFrontend)
r.GET("/account-portal/*filepath", serveAccountPortalFrontend)
r.HEAD("/account-portal", serveAccountPortalFrontend)
r.HEAD("/account-portal/*filepath", serveAccountPortalFrontend)
}
// 根路径重定向到管理后台(使用 302 避免浏览器永久缓存)
r.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/admin/")
})
// 健康检查:只做非阻塞的尽力统计,避免账号热路径锁竞争拖死 liveness。
// 但账号池读锁连续超过门槛一次都拿不到时视为疑似死锁,降 503 让
// healthcheck 重启实例——否则死锁实例会一直以 200 留在服务里。
healthProbe := &healthLockProbe{}
r.GET("/health", func(c *gin.Context) {
available, total, countsComplete := store.HealthCountsNonBlocking()
blocked := healthProbe.observe(total >= 0, time.Now())
if blocked >= healthStoreLockStallThreshold {
c.JSON(503, gin.H{
"status": "unavailable",
"reason": "account store lock stalled",
"blocked_seconds": int(blocked / time.Second),
"available": available,
"total": total,
"counts_complete": countsComplete,
})
return
}
c.JSON(200, gin.H{
"status": "ok",
"build_version": version.Current(),
"available": available,
"total": total,
"counts_complete": countsComplete,
})
})
// 6.5 启动安全状态自检 banner
printSecurityBanner(db, cfg, settings)
addr := fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.Port)
displayHost := cfg.BindAddress
if displayHost == "0.0.0.0" || displayHost == "::" {
displayHost = "localhost"
}
log.Println("==========================================")
log.Printf(" Codex2API v2 已启动")
log.Printf(" Listen: %s", addr)
log.Printf(" HTTP: http://%s:%d", displayHost, cfg.Port)
log.Printf(" 管理台: http://%s:%d/admin/", displayHost, cfg.Port)
log.Printf(" Key用量: http://%s:%d/key-usage", displayHost, cfg.Port)
log.Printf(" 生图门户: http://%s:%d/image-studio", displayHost, cfg.Port)
log.Printf(" 账号自助: http://%s:%d/account-portal", displayHost, cfg.Port)
log.Printf(" API: POST /v1/chat/completions")
log.Printf(" API: POST /v1/responses")
log.Printf(" API: POST /v1/images/generations")
log.Printf(" API: POST /v1/images/jobs")
log.Printf(" API: GET /v1/images/jobs/:id")
log.Printf(" API: POST /v1/messages")
log.Printf(" API: GET /v1/models")
log.Println("==========================================")
// 优雅关闭
srv := &http.Server{
Addr: addr,
Handler: r,
// ReadHeaderTimeout 防 Slowloris 慢速攻击;IdleTimeout 回收空闲 keep-alive 连接。
// 注意:WriteTimeout 必须保持 0 —— LLM 流式响应可持续数分钟,任何固定写超时都会中途切断长回答。
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP 服务启动失败: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭...")
// 先停止会产生新副作用的后台任务,再等待现有 HTTP 请求排空。
cancelBackground()
shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelShutdown()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP 服务优雅关闭超时: %v", err)
}
adminHandler.WaitAutoResetCredits()
adminHandler.WaitAutoActivate5hWindow()
wsKeepalive.Stop()
wsrelay.ShutdownExecutor()
if !proxy.DrainResponseCacheBackendWrites(2 * time.Second) {
log.Printf("部分响应上下文后台写入未在关闭窗口内完成")
}
store.Stop()
// 所有请求入口和后台生产者停止后,再排空仍可能访问 Store、缓存或数据库的短任务。
if !db.DrainBackgroundTasks(2 * time.Second) {
log.Printf("部分后台任务未在关闭窗口内退出")
}
proxy.CloseErrorLogger()
log.Println("已关闭")
}
// configureTrustedProxies 配置 Gin 的可信代理列表。
func configureTrustedProxies(r *gin.Engine, proxies []string) error {
if r == nil {
return nil
}
return r.SetTrustedProxies(proxies)
}
// loggerMiddleware 简单日志中间件(增强版,支持敏感信息脱敏)
func loggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latency := time.Since(start)
statusCode := c.Writer.Status()
if override, ok := c.Get(proxy.AccessLogStatusContextKey); ok {
if status, valid := override.(int); valid && status >= 100 && status <= 599 {
statusCode = status
}
}
if shouldSkipAccessLog(c.Request.Method, c.Request.URL.Path, statusCode) {
return
}
email, _ := c.Get("x-account-email")
proxyURL, _ := c.Get("x-account-proxy")
modelVal, _ := c.Get("x-model")
effortVal, _ := c.Get("x-reasoning-effort")
tierVal, _ := c.Get("x-service-tier")
emailStr := ""
if e, ok := email.(string); ok && e != "" {
// 脱敏邮箱
emailStr = security.MaskEmail(e)
}
proxyStr := "no proxy"
if p, ok := proxyURL.(string); ok && p != "" {
proxyStr = security.SanitizeLog(p)
}
// 构建扩展标签
var tags []string
if m, ok := modelVal.(string); ok && m != "" {
tags = append(tags, security.SanitizeLog(m))
}
if e, ok := effortVal.(string); ok && e != "" {
tags = append(tags, "effort="+security.SanitizeLog(e))
}
if t, ok := tierVal.(string); ok && t == "fast" {
tags = append(tags, "fast")
}
tagStr := ""
if len(tags) > 0 {
tagStr = " " + strings.Join(tags, " ")
}
if emailStr != "" {
log.Printf("%s %s %d %v%s [%s] [%s]", c.Request.Method, c.Request.URL.Path, statusCode, latency, tagStr, emailStr, proxyStr)
} else {
log.Printf("%s %s %d %v%s", c.Request.Method, c.Request.URL.Path, statusCode, latency, tagStr)
}
}
}
// printSecurityBanner 启动时打印安全状态自检 banner:
// - 不再自动生成 ADMIN_SECRET。若两端都空,则提示用户首次访问页面进行初始化。
// - 检查 API Key 数量、监听地址、匿名开关,命中风险时给出对应提示。
func printSecurityBanner(db *database.DB, cfg *config.Config, settings *database.SystemSettings) {
if db == nil || cfg == nil || settings == nil {
return
}
envSecret := strings.TrimSpace(cfg.AdminSecret)
dbSecret := strings.TrimSpace(settings.AdminSecret)
needsBootstrap := envSecret == "" && dbSecret == ""
apiKeyCount := 0
{
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if rows, err := db.ListAPIKeys(ctx); err == nil {
apiKeyCount = len(rows)
}
cancel()
}
bind := strings.TrimSpace(cfg.BindAddress)
publicBind := bind == "" || bind == "0.0.0.0" || bind == "::"
const sep = "=========================================================="
log.Println(sep)
log.Println("[SECURITY] Codex2API 安全状态自检")
log.Println(sep)
switch {
case needsBootstrap:
log.Println("⚠ 尚未配置 ADMIN_SECRET(环境变量与数据库均为空)。")
log.Printf(" 请使用浏览器访问管理台 http://%s:%d/admin/ 完成首次初始化,", bannerDisplayHost(bind), cfg.Port)
log.Println(" 设置一个强随机的管理密钥;该密钥也将作为登录密钥。")
log.Println(" 在初始化完成之前,所有 /api/admin/* 接口(除初始化端点外)均返回 503。")
case envSecret != "":
log.Println("✓ ADMIN_SECRET 来源:环境变量 (.env)")
default:
log.Println("✓ ADMIN_SECRET 来源:数据库(如需修改请进入「设置」页面)")
}
if apiKeyCount == 0 {
if cfg.AllowAnonymousV1 {
log.Println("⚠ /v1/* 当前处于【匿名访问】模式(CODEX_ALLOW_ANONYMOUS=true)。")
log.Println(" 任何能访问端口的人均可调用 /v1/* 消耗你的账号池配额,请仅在内网/测试环境使用!")
} else {
log.Println("⚠ 尚未创建任何对外 API Key。/v1/* 接口在创建第一把 Key 之前会返回 503。")
log.Println(" 请进入管理台「API 密钥」页面创建至少一把 Key 后再对外提供服务。")
}
} else {
log.Printf("✓ 已配置 %d 个对外 API Key,/v1/* 强制鉴权已生效。", apiKeyCount)
}
if publicBind {
log.Printf("ℹ 监听地址 = %s (所有网卡,兼容 Docker / 反代 / 公网)。", bind)
log.Println(" 生产环境请确认已部署反向代理 + HTTPS、配置防火墙白名单,并使用强 ADMIN_SECRET 与 API Key。")
log.Println(" 如希望服务只在本机回环可达,可设置 CODEX_BIND=127.0.0.1。")
} else {
log.Printf("✓ 监听地址 = %s (受限访问)。", bind)
}
log.Println(sep)
}
func bannerDisplayHost(bind string) string {
if bind == "" || bind == "0.0.0.0" || bind == "::" {
return "<your-host>"
}
return bind
}
func shouldSkipAccessLog(method string, path string, status int) bool {
if status >= http.StatusBadRequest {
return false
}
if method == http.MethodGet && path == "/api/admin/health" {
return true
}
if method == http.MethodGet && (path == "/api/admin/images/jobs" || strings.HasPrefix(path, "/api/admin/images/jobs/")) {
return true
}
return false
}