|
| 1 | +package admin |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "net/http" |
| 6 | + "strings" |
| 7 | + "sync" |
| 8 | + "sync/atomic" |
| 9 | + "time" |
| 10 | + "unicode/utf8" |
| 11 | + |
| 12 | + "github.com/codex2api/database" |
| 13 | + "github.com/codex2api/security" |
| 14 | + "github.com/gin-gonic/gin" |
| 15 | +) |
| 16 | + |
| 17 | +// bootstrapState 跟踪初始化端点的运行状态,主要用于: |
| 18 | +// 1. 防止并发条件下重复写入; |
| 19 | +// 2. 简单的全局限频,避免被扫描器穷举攻击。 |
| 20 | +var bootstrapState struct { |
| 21 | + mu sync.Mutex |
| 22 | + |
| 23 | + // rateBucket: 简单的固定窗口限频,单位 = 每 windowSec 内最多 maxPerWindow 次 |
| 24 | + windowStart atomic.Int64 // unix seconds |
| 25 | + count atomic.Int64 |
| 26 | +} |
| 27 | + |
| 28 | +const ( |
| 29 | + bootstrapWindowSec = 60 |
| 30 | + bootstrapMaxPerWin = 20 |
| 31 | + bootstrapMinSecret = 8 |
| 32 | + bootstrapMaxSecret = 256 |
| 33 | +) |
| 34 | + |
| 35 | +// bootstrapAllowRate 使用 CAS 实现固定窗口限频: |
| 36 | +// - 任意时刻只有一个 goroutine 能成功翻新窗口起点,其它失败者读到的就是 |
| 37 | +// 翻新后的最新值,避免多个 goroutine 同时把 count 重置为 0。 |
| 38 | +// - 在并发下,最坏情况只是有一个 reset 与若干 Add 交错,但所有"翻窗" |
| 39 | +// 操作都是原子的,不会出现窗口被重复清零导致超额放行的情况。 |
| 40 | +func bootstrapAllowRate() bool { |
| 41 | + now := time.Now().Unix() |
| 42 | + for { |
| 43 | + winStart := bootstrapState.windowStart.Load() |
| 44 | + if now-winStart < bootstrapWindowSec { |
| 45 | + break |
| 46 | + } |
| 47 | + // 仅当 windowStart 仍是我们读到的旧值时才推进;其它 goroutine 已经 |
| 48 | + // 推进过的话直接退出循环,复用最新窗口。 |
| 49 | + if bootstrapState.windowStart.CompareAndSwap(winStart, now) { |
| 50 | + bootstrapState.count.Store(0) |
| 51 | + break |
| 52 | + } |
| 53 | + } |
| 54 | + return bootstrapState.count.Add(1) <= bootstrapMaxPerWin |
| 55 | +} |
| 56 | + |
| 57 | +// GetBootstrapStatus 返回当前是否需要执行初始化(GET /api/admin/bootstrap-status)。 |
| 58 | +// |
| 59 | +// 该端点不要求鉴权,前端 AuthGate 在拿到登录界面前会先轮询此端点: |
| 60 | +// - 已通过 .env 设置 ADMIN_SECRET => needs_bootstrap=false, source="env" |
| 61 | +// - 已写入数据库 => needs_bootstrap=false, source="database" |
| 62 | +// - 两端均空 => needs_bootstrap=true, source="empty" |
| 63 | +func (h *Handler) GetBootstrapStatus(c *gin.Context) { |
| 64 | + envSecret := strings.TrimSpace(h.adminSecretEnv) |
| 65 | + if envSecret != "" { |
| 66 | + c.JSON(http.StatusOK, gin.H{ |
| 67 | + "needs_bootstrap": false, |
| 68 | + "source": "env", |
| 69 | + }) |
| 70 | + return |
| 71 | + } |
| 72 | + |
| 73 | + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) |
| 74 | + defer cancel() |
| 75 | + settings, err := h.db.GetSystemSettings(ctx) |
| 76 | + if err != nil { |
| 77 | + // 数据库异常时倾向 fail-closed:不允许 bootstrap,让运维先排查 DB |
| 78 | + c.JSON(http.StatusServiceUnavailable, gin.H{ |
| 79 | + "needs_bootstrap": false, |
| 80 | + "source": "error", |
| 81 | + "error": "读取系统设置失败,请检查数据库连接", |
| 82 | + }) |
| 83 | + return |
| 84 | + } |
| 85 | + if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" { |
| 86 | + c.JSON(http.StatusOK, gin.H{ |
| 87 | + "needs_bootstrap": false, |
| 88 | + "source": "database", |
| 89 | + }) |
| 90 | + return |
| 91 | + } |
| 92 | + c.JSON(http.StatusOK, gin.H{ |
| 93 | + "needs_bootstrap": true, |
| 94 | + "source": "empty", |
| 95 | + }) |
| 96 | +} |
| 97 | + |
| 98 | +// PostBootstrap 接收用户在浏览器中输入的初始管理密钥并写入数据库。 |
| 99 | +// |
| 100 | +// 安全约束: |
| 101 | +// 1. 仅在系统未配置 ADMIN_SECRET 时可用,否则一律 409; |
| 102 | +// 2. 通过互斥锁 + 双重检查避免并发写入; |
| 103 | +// 3. 简单全局限频,防止扫描器穷举; |
| 104 | +// 4. 校验最小长度(12 个 rune),避免过弱密钥; |
| 105 | +// 5. 全程审计日志。 |
| 106 | +func (h *Handler) PostBootstrap(c *gin.Context) { |
| 107 | + if !bootstrapAllowRate() { |
| 108 | + security.SecurityAuditLog("BOOTSTRAP_RATE_LIMITED", "ip="+c.ClientIP()) |
| 109 | + c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"}) |
| 110 | + return |
| 111 | + } |
| 112 | + |
| 113 | + envSecret := strings.TrimSpace(h.adminSecretEnv) |
| 114 | + if envSecret != "" { |
| 115 | + security.SecurityAuditLog("BOOTSTRAP_REJECTED_ENV", "ip="+c.ClientIP()) |
| 116 | + c.JSON(http.StatusConflict, gin.H{ |
| 117 | + "error": "ADMIN_SECRET 已通过环境变量配置,无需在页面初始化", |
| 118 | + }) |
| 119 | + return |
| 120 | + } |
| 121 | + |
| 122 | + var body struct { |
| 123 | + AdminSecret string `json:"admin_secret"` |
| 124 | + } |
| 125 | + if err := c.ShouldBindJSON(&body); err != nil { |
| 126 | + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"}) |
| 127 | + return |
| 128 | + } |
| 129 | + secret := strings.TrimSpace(body.AdminSecret) |
| 130 | + if secret == "" { |
| 131 | + c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥不能为空"}) |
| 132 | + return |
| 133 | + } |
| 134 | + if utf8.RuneCountInString(secret) < bootstrapMinSecret { |
| 135 | + c.JSON(http.StatusBadRequest, gin.H{ |
| 136 | + "error": "管理密钥至少 8 位", |
| 137 | + }) |
| 138 | + return |
| 139 | + } |
| 140 | + if len(secret) > bootstrapMaxSecret { |
| 141 | + c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥过长"}) |
| 142 | + return |
| 143 | + } |
| 144 | + |
| 145 | + bootstrapState.mu.Lock() |
| 146 | + defer bootstrapState.mu.Unlock() |
| 147 | + |
| 148 | + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) |
| 149 | + defer cancel() |
| 150 | + |
| 151 | + // 双重检查:进入临界区后再读一次,避免并发写入 |
| 152 | + settings, err := h.db.GetSystemSettings(ctx) |
| 153 | + if err != nil { |
| 154 | + security.SecurityAuditLog("BOOTSTRAP_DB_READ_ERROR", "ip="+c.ClientIP()+" err="+err.Error()) |
| 155 | + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取系统设置失败"}) |
| 156 | + return |
| 157 | + } |
| 158 | + if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" { |
| 159 | + security.SecurityAuditLog("BOOTSTRAP_REJECTED_ALREADY_INITIALIZED", "ip="+c.ClientIP()) |
| 160 | + c.JSON(http.StatusConflict, gin.H{ |
| 161 | + "error": "ADMIN_SECRET 已配置,无法重复初始化。如需重置,请进入「设置」页面使用现有密钥登录后修改。", |
| 162 | + }) |
| 163 | + return |
| 164 | + } |
| 165 | + if settings == nil { |
| 166 | + settings = defaultBootstrapSettings() |
| 167 | + } |
| 168 | + settings.AdminSecret = secret |
| 169 | + |
| 170 | + if err := h.db.UpdateSystemSettings(ctx, settings); err != nil { |
| 171 | + security.SecurityAuditLog("BOOTSTRAP_DB_WRITE_ERROR", "ip="+c.ClientIP()+" err="+err.Error()) |
| 172 | + c.JSON(http.StatusInternalServerError, gin.H{"error": "写入系统设置失败"}) |
| 173 | + return |
| 174 | + } |
| 175 | + |
| 176 | + security.SecurityAuditLog("BOOTSTRAP_SUCCESS", "ip="+c.ClientIP()) |
| 177 | + c.JSON(http.StatusOK, gin.H{"ok": true}) |
| 178 | +} |
| 179 | + |
| 180 | +// defaultBootstrapSettings 返回 settings 表初次记录的安全默认值。 |
| 181 | +// 与 main.go 中 step 3 保持一致,避免 PostBootstrap 在数据库尚无任何记录时 |
| 182 | +// 写入空值导致后续业务设置缺失。 |
| 183 | +func defaultBootstrapSettings() *database.SystemSettings { |
| 184 | + return &database.SystemSettings{ |
| 185 | + MaxConcurrency: 2, |
| 186 | + GlobalRPM: 0, |
| 187 | + TestModel: "gpt-5.4", |
| 188 | + TestConcurrency: 50, |
| 189 | + BackgroundRefreshIntervalMinutes: 2, |
| 190 | + UsageProbeMaxAgeMinutes: 10, |
| 191 | + RecoveryProbeIntervalMinutes: 30, |
| 192 | + PgMaxConns: 50, |
| 193 | + RedisPoolSize: 30, |
| 194 | + PromptFilterMode: "monitor", |
| 195 | + PromptFilterThreshold: 50, |
| 196 | + PromptFilterStrictThreshold: 90, |
| 197 | + PromptFilterLogMatches: true, |
| 198 | + PromptFilterMaxTextLength: 81920, |
| 199 | + PromptFilterCustomPatterns: "[]", |
| 200 | + PromptFilterDisabledPatterns: "[]", |
| 201 | + } |
| 202 | +} |
0 commit comments