Files
openteam/server/internal/app/app.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

184 lines
5.7 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 app 应用容器:装配配置、数据库、密码/加密/JWT 与记账器。
package app
import (
"context"
"fmt"
"log"
"time"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/passkey"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/jwt"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"gorm.io/gorm"
)
type App struct {
Cfg *config.Config
DB *gorm.DB
Hasher *crypto.PasswordHasher
Enc *crypto.Encryptor
JWT *jwt.Manager
Usage *usage.Recorder
Health *channel.HealthMonitor
Limit *ratelimit.Limiter
Passkeys *passkey.Service
// usageRetention 用量明细保留期任务(未开启时为 no-op)
usageRetention func()
startedAt time.Time
ctx context.Context
cancel context.CancelFunc
}
func New(cfg *config.Config) (*App, error) {
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
if err != nil {
return nil, err
}
a := &App{
Cfg: cfg,
DB: db,
Hasher: crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen),
Enc: crypto.NewEncryptor(cfg.Master),
JWT: jwt.NewManager(cfg.JWT.Secret, cfg.JWT.Issuer, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL),
startedAt: time.Now(),
}
a.ctx, a.cancel = context.WithCancel(context.Background())
a.Usage = usage.NewRecorder(db)
a.Limit = ratelimit.New()
// 开发环境不拦空/占位主密钥,但必须让人看见:空主密钥退化为 sha256(""),
// 任何能读到数据库的人都能解出全部上游 key。这类实例后续补上 OT_MASTER_KEY
// 会直接变成「全部渠道解密失败」,所以一并给出迁移命令。
if reason := cfg.InsecureMasterKey(); reason != "" {
log.Printf("WARN: OT_MASTER_KEY %s;任何能读到数据库的人都能解出上游 key。"+
"配置真实主密钥后执行 `openteam rotate-master-key` 迁移已有渠道(无需重填 key)", reason)
}
if err := a.Seed(); err != nil {
return nil, err
}
a.Health = channel.NewHealthMonitor(db, a.Enc, channel.HealthConfig{
Interval: cfg.Proxy.HealthInterval,
FailThreshold: cfg.Proxy.HealthFailThreshold,
})
a.Health.Start(a.ctx)
// 用量明细保留期(默认 0=永久保留,只清明细不动余额流水)
a.usageRetention = usage.StartRetention(a.ctx, db, cfg.Usage.RetentionDays)
a.Passkeys, err = passkey.New(db, passkey.Config{
RPID: cfg.WebAuthn.RPID, Origin: cfg.WebAuthn.RPOrigin, Name: cfg.WebAuthn.RPName,
})
if err != nil {
return nil, err
}
return a, nil
}
func (a *App) Close() {
if a.usageRetention != nil {
a.usageRetention()
}
a.Usage.Close()
}
// Seed 首次启动初始化:管理员账号 + 默认渠道 + 默认模型。
func (a *App) Seed() error {
// 1. 管理员(从环境变量读取,默认 admin/admin123,生产必须改)
var count int64
a.DB.Model(&store.User{}).Where("role = ?", store.RoleAdmin).Count(&count)
if count == 0 {
password := a.Cfg.AdminPassword
if password == "" {
// config.validate 已拒绝缺 OT_ADMIN_PASSWORD 的生产配置;这里再拦一层,
// 防止绕过 Load 的调用方(测试/工具)带着公开默认密码进非开发环境。
if a.Cfg.Env != "development" {
return fmt.Errorf("非 development 环境必须显式设置 OT_ADMIN_PASSWORD,拒绝用默认密码创建管理员")
}
password = "admin123"
log.Printf("seed: using default admin password admin123 — DO NOT expose this instance to the public internet")
}
hash, err := a.Hasher.HashPassword(password)
if err != nil {
return err
}
admin := store.User{
Username: envOr("OT_ADMIN_USERNAME", "admin"),
Email: envOr("OT_ADMIN_EMAIL", "admin@localhost"),
PasswordHash: hash,
Role: store.RoleAdmin,
Balance: 1000, // 初始余额,便于联调;生产由充值/调整决定
Status: store.UserStatusActive,
}
if err := a.DB.Create(&admin).Error; err != nil {
return err
}
log.Printf("seed: created admin user %q (change the default password!)", admin.Username)
}
// 2. 默认渠道(配置了上游 key 时创建)
if a.Cfg.Proxy.UpstreamKey != "" {
var chCount int64
a.DB.Model(&store.Channel{}).Count(&chCount)
if chCount == 0 {
enc, err := a.Enc.Encrypt(a.Cfg.Proxy.UpstreamKey)
if err != nil {
return err
}
ch := store.Channel{
Name: a.Cfg.Proxy.DefaultChannelName,
Provider: store.ChannelProviderOpenAI,
Formats: []string{store.FormatChat, store.FormatResponses},
BaseURL: a.Cfg.Proxy.UpstreamBaseURL,
APIKeyEnc: enc,
Weight: 1,
Priority: 0,
TimeoutMS: int(a.Cfg.Proxy.Timeout / time.Millisecond),
MaxConcurrency: 16,
HealthStatus: store.ChannelHealthHealthy,
Enabled: true,
}
if err := a.DB.Create(&ch).Error; err != nil {
return err
}
// 默认模型 + 绑定
m := store.Model{
Name: a.Cfg.Proxy.DefaultModel,
InputPrice: 0.15, // 每百万 token,示例价
OutputPrice: 0.60,
Enabled: true,
}
if err := a.DB.Create(&m).Error; err == nil {
a.DB.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: m.Name})
}
log.Printf("seed: created default channel %q (%s)", ch.Name, ch.BaseURL)
}
}
return nil
}
func (a *App) Shutdown(ctx context.Context) {
a.cancel()
a.Usage.Close()
if sqlDB, err := a.DB.DB(); err == nil {
_ = sqlDB.Close()
}
_ = ctx
}
func envOr(key, fallback string) string {
v := envLookup(key)
if v == "" {
return fallback
}
return v
}