Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
# HTTP 服务端口
CODEX_PORT=8080

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

# 管理后台登录密钥
# - 不设置时,首次启动会自动生成随机密钥并写入数据库(启动日志会打印一次)
# - 强烈建议在生产环境显式设置一个强随机字符串,避免依赖自动生成
# 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
12 changes: 11 additions & 1 deletion .env.sqlite.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@
# HTTP 服务端口
CODEX_PORT=8080

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

# 管理后台登录密钥
# - 不设置时,首次启动会自动生成随机密钥并写入数据库(启动日志会打印一次)
# - 强烈建议在生产环境显式设置一个强随机字符串,避免依赖自动生成
# 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
190 changes: 190 additions & 0 deletions admin/bootstrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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
)

func bootstrapAllowRate() bool {
now := time.Now().Unix()
winStart := bootstrapState.windowStart.Load()
if now-winStart >= bootstrapWindowSec {
bootstrapState.windowStart.Store(now)
bootstrapState.count.Store(0)
}
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 // 监听地址,默认 127.0.0.1,公网监听需显式设置为 0.0.0.0
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 中间件 + 自动 ADMIN_SECRET + 启动 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