Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@
# HTTP 服务端口
CODEX_PORT=8080

# 管理后台登录密钥(可选,设置后首次访问 /admin 会弹出密码输入框)
# 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署)
# 如希望仅本机访问,可设置为 127.0.0.1
# CODEX_BIND=0.0.0.0

# 管理后台登录密钥
# - 未设置时,首次访问 /admin 会进入“首次初始化”页面,请在浏览器内设置一个强随机密钥并妥善保存
# - 未初始化之前,所有 /api/admin/* 接口(除 bootstrap 端点外)均返回 503
# - 如需以环境变量方式下发(如 CI/CD),可取消下面设置后重启,环境变量优先于浏览器初始化
# ADMIN_SECRET=your-admin-password
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 下游 API 密钥鉴权(逗号分隔,留空则不鉴权)
# CODEX_API_KEYS=sk-your-key-1,sk-your-key-2
# /v1/* 匿名访问开关(默认 false,强烈建议保持默认)
# 设置为 true 时:未配置任何对外 API Key 也允许 /v1/* 直接调用——仅限内网测试场景!
# CODEX_ALLOW_ANONYMOUS=false

# 全局代理(可选)
# CODEX_PROXY_URL=http://host.docker.internal:7890
Expand Down
13 changes: 12 additions & 1 deletion .env.sqlite.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@
# HTTP 服务端口
CODEX_PORT=8080

# 管理后台登录密钥(可选,设置后首次访问 /admin 会弹出密码输入框)
# 监听地址(默认 0.0.0.0,兼容 Docker 端口映射 / 反向代理 / 公网部署)
# 如希望仅本机访问,可设置为 127.0.0.1
# CODEX_BIND=0.0.0.0

# 管理后台登录密钥
# - 未设置时,首次访问 /admin 会进入“首次初始化”页面,请在浏览器内设置一个强随机密钥并妥善保存
# - 未初始化之前,所有 /api/admin/* 接口(除 bootstrap 端点外)均返回 503
# - 如需以环境变量方式下发(如 CI/CD),可取消下面设置后重启,环境变量优先于浏览器初始化
# ADMIN_SECRET=your-admin-password
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# /v1/* 匿名访问开关(默认 false,强烈建议保持默认)
# 设置为 true 时:未配置任何对外 API Key 也允许 /v1/* 直接调用——仅限内网测试场景!
# CODEX_ALLOW_ANONYMOUS=false

# ---- 数据库 / 缓存驱动 ----
DATABASE_DRIVER=sqlite
CACHE_DRIVER=memory
Expand Down
202 changes: 202 additions & 0 deletions admin/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package admin

import (
"context"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"

"github.com/codex2api/database"
"github.com/codex2api/security"
"github.com/gin-gonic/gin"
)

// bootstrapState 跟踪初始化端点的运行状态,主要用于:
// 1. 防止并发条件下重复写入;
// 2. 简单的全局限频,避免被扫描器穷举攻击。
var bootstrapState struct {
mu sync.Mutex

// rateBucket: 简单的固定窗口限频,单位 = 每 windowSec 内最多 maxPerWindow 次
windowStart atomic.Int64 // unix seconds
count atomic.Int64
}

const (
bootstrapWindowSec = 60
bootstrapMaxPerWin = 20
bootstrapMinSecret = 8
bootstrapMaxSecret = 256
)

// bootstrapAllowRate 使用 CAS 实现固定窗口限频:
// - 任意时刻只有一个 goroutine 能成功翻新窗口起点,其它失败者读到的就是
// 翻新后的最新值,避免多个 goroutine 同时把 count 重置为 0。
// - 在并发下,最坏情况只是有一个 reset 与若干 Add 交错,但所有"翻窗"
// 操作都是原子的,不会出现窗口被重复清零导致超额放行的情况。
func bootstrapAllowRate() bool {
now := time.Now().Unix()
for {
winStart := bootstrapState.windowStart.Load()
if now-winStart < bootstrapWindowSec {
break
}
// 仅当 windowStart 仍是我们读到的旧值时才推进;其它 goroutine 已经
// 推进过的话直接退出循环,复用最新窗口。
if bootstrapState.windowStart.CompareAndSwap(winStart, now) {
bootstrapState.count.Store(0)
break
}
}
return bootstrapState.count.Add(1) <= bootstrapMaxPerWin
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// GetBootstrapStatus 返回当前是否需要执行初始化(GET /api/admin/bootstrap-status)。
//
// 该端点不要求鉴权,前端 AuthGate 在拿到登录界面前会先轮询此端点:
// - 已通过 .env 设置 ADMIN_SECRET => needs_bootstrap=false, source="env"
// - 已写入数据库 => needs_bootstrap=false, source="database"
// - 两端均空 => needs_bootstrap=true, source="empty"
func (h *Handler) GetBootstrapStatus(c *gin.Context) {
envSecret := strings.TrimSpace(h.adminSecretEnv)
if envSecret != "" {
c.JSON(http.StatusOK, gin.H{
"needs_bootstrap": false,
"source": "env",
})
return
}

ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
settings, err := h.db.GetSystemSettings(ctx)
if err != nil {
// 数据库异常时倾向 fail-closed:不允许 bootstrap,让运维先排查 DB
c.JSON(http.StatusServiceUnavailable, gin.H{
"needs_bootstrap": false,
"source": "error",
"error": "读取系统设置失败,请检查数据库连接",
})
return
}
if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" {
c.JSON(http.StatusOK, gin.H{
"needs_bootstrap": false,
"source": "database",
})
return
}
c.JSON(http.StatusOK, gin.H{
"needs_bootstrap": true,
"source": "empty",
})
}

// PostBootstrap 接收用户在浏览器中输入的初始管理密钥并写入数据库。
//
// 安全约束:
// 1. 仅在系统未配置 ADMIN_SECRET 时可用,否则一律 409;
// 2. 通过互斥锁 + 双重检查避免并发写入;
// 3. 简单全局限频,防止扫描器穷举;
// 4. 校验最小长度(12 个 rune),避免过弱密钥;
// 5. 全程审计日志。
func (h *Handler) PostBootstrap(c *gin.Context) {
Comment on lines +98 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Comment says 12 runes but constant is 8.

The doc comment on line 104 states "校验最小长度(12 个 rune)" but bootstrapMinSecret = 8 on line 31. Update the comment to match the actual constraint.

📝 Suggested fix
 // 安全约束:
 //  1. 仅在系统未配置 ADMIN_SECRET 时可用,否则一律 409;
 //  2. 通过互斥锁 + 双重检查避免并发写入;
 //  3. 简单全局限频,防止扫描器穷举;
-//  4. 校验最小长度(12 个 rune),避免过弱密钥;
+//  4. 校验最小长度(8 个 rune),避免过弱密钥;
 //  5. 全程审计日志。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@admin/bootstrap.go` around lines 98 - 106, The doc comment above
PostBootstrap currently says the minimum secret length is 12 runes but the code
defines bootstrapMinSecret = 8; update the comment to reflect the actual
constraint (8 runes) so the documentation matches the constant, e.g. change
"校验最小长度(12 个 rune)" to "校验最小长度(8 个 rune)"; verify references to PostBootstrap
and bootstrapMinSecret in the comment are consistent after the edit.

if !bootstrapAllowRate() {
security.SecurityAuditLog("BOOTSTRAP_RATE_LIMITED", "ip="+c.ClientIP())
c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
return
}

envSecret := strings.TrimSpace(h.adminSecretEnv)
if envSecret != "" {
security.SecurityAuditLog("BOOTSTRAP_REJECTED_ENV", "ip="+c.ClientIP())
c.JSON(http.StatusConflict, gin.H{
"error": "ADMIN_SECRET 已通过环境变量配置,无需在页面初始化",
})
return
}

var body struct {
AdminSecret string `json:"admin_secret"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体格式错误"})
return
}
secret := strings.TrimSpace(body.AdminSecret)
if secret == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥不能为空"})
return
}
if utf8.RuneCountInString(secret) < bootstrapMinSecret {
c.JSON(http.StatusBadRequest, gin.H{
"error": "管理密钥至少 8 位",
})
return
}
if len(secret) > bootstrapMaxSecret {
c.JSON(http.StatusBadRequest, gin.H{"error": "管理密钥过长"})
return
}

bootstrapState.mu.Lock()
defer bootstrapState.mu.Unlock()

ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()

// 双重检查:进入临界区后再读一次,避免并发写入
settings, err := h.db.GetSystemSettings(ctx)
if err != nil {
security.SecurityAuditLog("BOOTSTRAP_DB_READ_ERROR", "ip="+c.ClientIP()+" err="+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取系统设置失败"})
return
}
if settings != nil && strings.TrimSpace(settings.AdminSecret) != "" {
security.SecurityAuditLog("BOOTSTRAP_REJECTED_ALREADY_INITIALIZED", "ip="+c.ClientIP())
c.JSON(http.StatusConflict, gin.H{
"error": "ADMIN_SECRET 已配置,无法重复初始化。如需重置,请进入「设置」页面使用现有密钥登录后修改。",
})
return
}
if settings == nil {
settings = defaultBootstrapSettings()
}
settings.AdminSecret = secret

if err := h.db.UpdateSystemSettings(ctx, settings); err != nil {
security.SecurityAuditLog("BOOTSTRAP_DB_WRITE_ERROR", "ip="+c.ClientIP()+" err="+err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入系统设置失败"})
return
}

security.SecurityAuditLog("BOOTSTRAP_SUCCESS", "ip="+c.ClientIP())
c.JSON(http.StatusOK, gin.H{"ok": true})
}

// defaultBootstrapSettings 返回 settings 表初次记录的安全默认值。
// 与 main.go 中 step 3 保持一致,避免 PostBootstrap 在数据库尚无任何记录时
// 写入空值导致后续业务设置缺失。
func defaultBootstrapSettings() *database.SystemSettings {
return &database.SystemSettings{
MaxConcurrency: 2,
GlobalRPM: 0,
TestModel: "gpt-5.4",
TestConcurrency: 50,
BackgroundRefreshIntervalMinutes: 2,
UsageProbeMaxAgeMinutes: 10,
RecoveryProbeIntervalMinutes: 30,
PgMaxConns: 50,
RedisPoolSize: 30,
PromptFilterMode: "monitor",
PromptFilterThreshold: 50,
PromptFilterStrictThreshold: 90,
PromptFilterLogMatches: true,
PromptFilterMaxTextLength: 81920,
PromptFilterCustomPatterns: "[]",
PromptFilterDisabledPatterns: "[]",
}
}
19 changes: 17 additions & 2 deletions admin/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ func (h *Handler) SetPoolSizes(pgMaxConns, redisPoolSize int) {
func (h *Handler) RegisterRoutes(r *gin.Engine) {
r.GET("/p/img/:id", h.GetSignedImageAssetFile)

// 首次初始化端点(无需鉴权,仅在系统未配置 ADMIN_SECRET 时可用)
// 这两个端点必须注册在 adminAuthMiddleware 之外,否则会被 fail-closed 拦截。
r.GET("/api/admin/bootstrap-status", h.GetBootstrapStatus)
r.POST("/api/admin/bootstrap", h.PostBootstrap)

api := r.Group("/api/admin")
api.Use(h.adminAuthMiddleware())
api.GET("/stats", h.GetStats)
Expand Down Expand Up @@ -167,12 +172,22 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
}

// adminAuthMiddleware 管理接口鉴权中间件(增强版,增加安全审计日志)
//
// 安全策略(fail-closed):
// - 未配置 ADMIN_SECRET 时一律拒绝(503),防止 /api/admin/* 裸奔。
// - 用户应通过前端「首次初始化」页面(无鉴权的 /api/admin/bootstrap 端点)
// 设置初始密钥,或者在 .env 中显式设置 ADMIN_SECRET 后重启。
func (h *Handler) adminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
adminSecret, source := h.resolveAdminSecret(c.Request.Context())
if adminSecret == "" {
// 未配置管理密钥,跳过鉴权
c.Next()
// fail-closed:拒绝并提示用户配置 ADMIN_SECRET
security.SecurityAuditLog("ADMIN_BLOCKED_NO_SECRET", fmt.Sprintf("path=%s ip=%s", c.Request.URL.Path, c.ClientIP()))
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "管理接口未初始化:ADMIN_SECRET 尚未配置。请在浏览器访问 /admin/ 完成首次初始化,或在 .env 中设置 ADMIN_SECRET 后重启。",
"code": "bootstrap_required",
})
c.Abort()
return
}

Expand Down
10 changes: 10 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ func (c *CacheConfig) Label() string {
// 业务逻辑参数(如 ProxyURL,APIKeys,MaxConcurrency)已全部移至数据库 SystemSettings 进行化
type Config struct {
Port int
BindAddress string // 监听地址,默认 0.0.0.0(兼容 Docker / 反代 / 公网);如需仅本机访问可设为 127.0.0.1
AdminSecret string
AllowAnonymousV1 bool // 显式允许 /v1/* 在未配置 API Key 时无鉴权放行(默认禁止)
MaxRequestBodySize int
Database DatabaseConfig
Cache CacheConfig
Expand Down Expand Up @@ -98,6 +100,14 @@ func Load(envPath string) (*Config, error) {
fmt.Sscanf(port, "%d", &cfg.Port)
}
cfg.AdminSecret = strings.TrimSpace(os.Getenv("ADMIN_SECRET"))
cfg.AllowAnonymousV1 = parseBoolEnv(os.Getenv("CODEX_ALLOW_ANONYMOUS"))
// 默认绑 0.0.0.0 以兼容 Docker 端口映射、反向代理、生产服务器等常规部署。
// 安全防护由 fail-closed 中间件 + 首启自助初始化 (/api/admin/bootstrap) + 启动 banner 共同保证;
// 想要严格仅本机访问的用户可设 CODEX_BIND=127.0.0.1。
cfg.BindAddress = strings.TrimSpace(os.Getenv("CODEX_BIND"))
if cfg.BindAddress == "" {
cfg.BindAddress = "0.0.0.0"
}
if v := strings.TrimSpace(os.Getenv("CODEX_MAX_REQUEST_BODY_SIZE_MB")); v != "" {
if mb, err := strconv.Atoi(v); err == nil && mb > 0 {
cfg.MaxRequestBodySize = mb * 1024 * 1024
Expand Down
Loading
Loading