Files
openteam/server/internal/config/config.go
T
Sakurasan 5d6e5bdb04 feat: 主密钥诊断与迁移命令;空/占位主密钥启动警告
渠道上游 key 用 OT_MASTER_KEY 经 AES-GCM 加密落库,主密钥一旦变化,全部渠道在运行时
报 "failed to decrypt channel key"。最常见的成因是历史实例先用了空主密钥(退化为
sha256("")),后来才补上 OT_MASTER_KEY——旧密文从此解不开,看起来像渠道全挂了。

- 新增 `check-channels`:打印主密钥派生路径与指纹(SHA-256 前 4 字节,不泄露密钥)
  + 逐渠道解密结果,区分「全部失败=主密钥不对」与「个别失败=该渠道密文异常」;
  存在失败时退出码为 1,便于脚本告警
- 新增 `rotate-master-key`:旧密钥解密、新密钥重新加密,全部渠道单事务更新,
  任一渠道解不开就整体回滚(--force 可跳过),避免库里新旧密钥混杂;
  写入前对新密文做回读校验;--dry-run 只验证不改库。空主密钥作为默认 --old,
  正是「先空后补」的历史路径;配套迁移无需重新录入任何渠道 key
- crypto 新增 KeyFingerprint / KeyMode:指纹用于比对两个实例是否同一把密钥,
  KeyMode 显式暴露「16/24/32 字节用原始字节、其他长度走 SHA-256 派生」这条
  容易踩的长度陷阱(改长度等于换密钥)
- 启动警告:开发环境不拦空/占位主密钥,但明确提示它等于公开密钥,并给出
  rotate-master-key 指引;生产环境仍由 config.validate 直接拒绝启动
- README 补主密钥与渠道密钥运维小节
- 测试:换密钥必解密失败、长度切换 32↔33 字节必失败、指纹一致性

验证:go vet、go test ./... 全绿;端到端复现「空主密钥建渠道 → 配新主密钥全部解密失败
→ rotate-master-key 迁移 → check-channels 全部正常」,明文长度与前后缀保持一致。
2026-11-08 23:12:17 +08:00

318 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package config 加载服务配置:.env / 环境变量 / 默认值(viper)。
// 所有项均可用环境变量 OT_<KEY> 覆盖(点号转下划线,如 db.driver → OT_DB_DRIVER)。
package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Env string // development | production
Port int
DB DBConfig
JWT JWTConfig
Auth AuthConfig
Proxy ProxyConfig
Usage UsageConfig
RateLimit RateLimitConfig
WebAuthn WebAuthnConfig
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
// 管理员初始密码:仅用于生产启动断言,Seed 仍按原样直接读环境变量
AdminPassword string
// 跨域白名单(逗号分隔 origin)。为空 = 不输出任何 CORS 头:
// 网关自己托管 web/dist 时前后端同源,本就不需要 CORS;开发用 vite proxy 也不需要。
CORSAllowOrigins []string
// 反向代理列表(CIDR 或 IP)。只有配置它,gin 才信任 X-Forwarded-For 取客户端 IP,
// 认证限速依赖这个 IP;直连部署留空即可。
TrustedProxies []string
}
// UsageConfig 用量数据的保留策略。
type UsageConfig struct {
// RetentionDays 用量明细保留天数;0(默认)= 永久保留。
// 只清 usage_logs,不动余额流水。
RetentionDays int
}
// WebAuthnConfig Passkey(WebAuthn)配置。
type WebAuthnConfig struct {
RPID string // Relying Party ID(域名,如 localhost)
RPOrigin string // 前端来源,如 http://localhost:5173
RPName string // 展示名
}
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
type RateLimitConfig struct {
UserRPS int // 用户级每秒请求数上限(0=不限制)
// 认证端点限速(每分钟):登录是密码爆破面,注册是批量建号面。0=不限制。
LoginIPPerMin int // 单 IP 登录尝试数
LoginUserPerMin int // 单 IP 对同一账号的登录尝试数(挡住换 IP 定向爆破)
RegisterIPPerMin int // 单 IP 注册次数
}
type DBConfig struct {
Driver string // sqlite | postgres
DSN string
}
type JWTConfig struct {
Secret string
AccessTTL time.Duration
RefreshTTL time.Duration
Issuer string
CookieName string
CookieSecure bool
CookieDomain string
}
type AuthConfig struct {
RegistrationMode string // open | invite
Argon2Time uint32
Argon2Memory uint32
Argon2Threads uint8
Argon2KeyLen uint32
SaltLen int
}
type ProxyConfig struct {
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
UpstreamBaseURL string // 渠道 base_url 默认值
UpstreamKey string // 渠道上游 key 默认值
DefaultModel string // 渠道模型导入时使用的模型名
Timeout time.Duration
MaxBodyMB int // /v1 请求体上限(MB),超限按客户端协议返回 413
FailoverBudget time.Duration // 故障转移给"第二个及以后的渠道"留的时间预算,0=不限
StreamHeartbeat time.Duration // 流式空闲多久发一次 SSE 注释心跳,0=关闭
FirstTokenWait time.Duration // 流式放行前等内容的最长时间,0=不限;超时未出内容换下一个渠道
AffinityTTL time.Duration // 会话亲和 TTL,0=关闭;同一会话粘住上次应答的渠道以命中上游缓存
ChannelAcquireWait time.Duration // 渠道并发满载时每个候选的排队时长,0=立刻跳过
AuthCacheTTL time.Duration // 鉴权缓存 TTL,0=关闭(每次请求都查库)
HealthInterval time.Duration // 渠道健康检查周期
HealthFailThreshold int // 连续失败 N 次进 cooldown
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
}
// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。
// 已存在的环境变量优先,不覆盖。
func loadDotEnv() {
data, err := os.ReadFile(".env")
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.Trim(strings.TrimSpace(v), `"'`)
if k != "" && os.Getenv(k) == "" {
_ = os.Setenv(k, v)
}
}
}
func Load() (*Config, error) {
loadDotEnv()
v := viper.New()
v.SetEnvPrefix("OT")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// 默认值(与 .env.example 对应)
v.SetDefault("env", "development")
v.SetDefault("port", 8080)
v.SetDefault("db.driver", "sqlite")
v.SetDefault("db.dsn", "data/openteam.db")
v.SetDefault("jwt.secret", "dev-only-secret-change-me")
v.SetDefault("jwt.access_ttl", "2h")
v.SetDefault("jwt.refresh_ttl", "168h")
v.SetDefault("jwt.issuer", "openteam")
v.SetDefault("jwt.cookie_name", "ot_refresh")
v.SetDefault("jwt.cookie_secure", false)
v.SetDefault("jwt.cookie_domain", "")
v.SetDefault("auth.registration_mode", "open")
v.SetDefault("auth.argon2_time", 3)
v.SetDefault("auth.argon2_memory", 64*1024) // 64 MiB
v.SetDefault("auth.argon2_threads", 2)
v.SetDefault("auth.argon2_keylen", 32)
v.SetDefault("auth.salt_len", 16)
v.SetDefault("proxy.default_channel_name", "openai")
v.SetDefault("proxy.upstream_base_url", "https://api.openai.com")
v.SetDefault("proxy.upstream_key", "")
v.SetDefault("proxy.default_model", "gpt-4o-mini")
v.SetDefault("proxy.timeout", "300s")
v.SetDefault("proxy.max_body_mb", 32)
v.SetDefault("proxy.failover_budget", "30s")
v.SetDefault("proxy.stream_heartbeat", "15s")
v.SetDefault("proxy.first_token_wait", "0s")
v.SetDefault("proxy.affinity_ttl", "5m")
v.SetDefault("proxy.channel_acquire_wait", "150ms")
v.SetDefault("proxy.auth_cache_ttl", "2s")
v.SetDefault("usage.retention_days", 0)
v.SetDefault("proxy.health_interval", "60s")
v.SetDefault("proxy.health_fail_threshold", 2)
v.SetDefault("proxy.log_raw", false)
v.SetDefault("ratelimit.user_rps", 20)
v.SetDefault("ratelimit.login_ip_per_min", 30)
v.SetDefault("ratelimit.login_user_per_min", 10)
v.SetDefault("ratelimit.register_ip_per_min", 5)
v.SetDefault("cors.allow_origins", "")
v.SetDefault("server.trusted_proxies", "")
v.SetDefault("webauthn.rp_id", "localhost")
v.SetDefault("webauthn.rp_origin", "http://localhost:5173")
v.SetDefault("webauthn.rp_name", "openteam")
cfg := &Config{
Env: v.GetString("env"),
Port: v.GetInt("port"),
DB: DBConfig{
Driver: v.GetString("db.driver"),
DSN: v.GetString("db.dsn"),
},
JWT: JWTConfig{
Secret: v.GetString("jwt.secret"),
AccessTTL: v.GetDuration("jwt.access_ttl"),
RefreshTTL: v.GetDuration("jwt.refresh_ttl"),
Issuer: v.GetString("jwt.issuer"),
CookieName: v.GetString("jwt.cookie_name"),
CookieSecure: v.GetBool("jwt.cookie_secure"),
CookieDomain: v.GetString("jwt.cookie_domain"),
},
Auth: AuthConfig{
RegistrationMode: v.GetString("auth.registration_mode"),
Argon2Time: v.GetUint32("auth.argon2_time"),
Argon2Memory: v.GetUint32("auth.argon2_memory"),
Argon2Threads: v.GetUint8("auth.argon2_threads"),
Argon2KeyLen: v.GetUint32("auth.argon2_keylen"),
SaltLen: v.GetInt("auth.salt_len"),
},
Proxy: ProxyConfig{
DefaultChannelName: v.GetString("proxy.default_channel_name"),
UpstreamBaseURL: v.GetString("proxy.upstream_base_url"),
UpstreamKey: v.GetString("proxy.upstream_key"),
DefaultModel: v.GetString("proxy.default_model"),
Timeout: v.GetDuration("proxy.timeout"),
MaxBodyMB: v.GetInt("proxy.max_body_mb"),
FailoverBudget: v.GetDuration("proxy.failover_budget"),
StreamHeartbeat: v.GetDuration("proxy.stream_heartbeat"),
FirstTokenWait: v.GetDuration("proxy.first_token_wait"),
AffinityTTL: v.GetDuration("proxy.affinity_ttl"),
ChannelAcquireWait: v.GetDuration("proxy.channel_acquire_wait"),
AuthCacheTTL: v.GetDuration("proxy.auth_cache_ttl"),
HealthInterval: v.GetDuration("proxy.health_interval"),
HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"),
LogRaw: v.GetBool("proxy.log_raw"),
},
Usage: UsageConfig{
RetentionDays: v.GetInt("usage.retention_days"),
},
RateLimit: RateLimitConfig{
UserRPS: v.GetInt("ratelimit.user_rps"),
LoginIPPerMin: v.GetInt("ratelimit.login_ip_per_min"),
LoginUserPerMin: v.GetInt("ratelimit.login_user_per_min"),
RegisterIPPerMin: v.GetInt("ratelimit.register_ip_per_min"),
},
WebAuthn: WebAuthnConfig{
RPID: v.GetString("webauthn.rp_id"),
RPOrigin: v.GetString("webauthn.rp_origin"),
RPName: v.GetString("webauthn.rp_name"),
},
Master: v.GetString("master_key"),
AdminPassword: v.GetString("admin_password"),
CORSAllowOrigins: splitList(v.GetString("cors.allow_origins")),
TrustedProxies: splitList(v.GetString("server.trusted_proxies")),
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// splitList 解析逗号分隔列表(去空白、忽略空项)。
func splitList(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
if len(out) == 0 {
return nil
}
return out
}
// Load 之后、使用之前统一校验。env 值枚举化:OT_ENV 拼错(如 "prod")会静默落入
// 开发配置,让下面所有生产断言形同虚设。
func (c *Config) validate() error {
if c.Env != "development" && c.Env != "production" {
return fmt.Errorf(`OT_ENV 只能是 "development" 或 "production",当前 %q(拼错会静默跳过生产密钥断言)`, c.Env)
}
if c.Env != "production" {
return nil
}
var problems []string
if c.JWT.Secret == "" || knownSecret(c.JWT.Secret) {
problems = append(problems, "OT_JWT_SECRET 未设置或仍是仓库里的默认值(可伪造会话 token)")
}
if len(c.Master) < 16 || knownSecret(c.Master) {
problems = append(problems, "OT_MASTER_KEY 未设置、少于 16 字节或仍是示例占位值(缺失/公开时渠道密文可被公开还原)")
}
if c.AdminPassword == "" || c.AdminPassword == "admin123" {
problems = append(problems, "OT_ADMIN_PASSWORD 未设置或仍是 admin123")
}
if !c.JWT.CookieSecure {
problems = append(problems, "OT_JWT_COOKIE_SECURE 必须为 true(生产环境 refresh cookie 走 HTTP 明文可被中间人截取;自签证书或反代终止 TLS 后也要置 true)")
}
if len(problems) > 0 {
return fmt.Errorf("生产环境启动被拒绝:%s", strings.Join(problems, ";"))
}
return nil
}
// knownSecret 仓库/示例文件里公开出现过的密钥占位值:复制 .env.example 不改密钥
// 是最常见的部署路径,必须当作默认值拦下。
func knownSecret(s string) bool {
switch s {
case "dev-only-secret-change-me", "change-me-to-a-long-random-string", "change-me-master-key":
return true
}
return false
}
// InsecureMasterKey 返回主密钥不可用的原因,安全时返回空串。
// 生产环境由 validate 直接拒绝启动;开发环境不拦,但启动时要打警告——
// 空主密钥会退化成 sha256(""),任何能读到数据库的人都能解出全部上游 key。
func (c *Config) InsecureMasterKey() string {
if c.Master == "" {
return `未设置,渠道密钥用公开可还原的 sha256("") 加密`
}
if knownSecret(c.Master) {
return "仍是仓库示例文件里的占位值,等同于公开密钥"
}
return ""
}