渠道上游 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 全部正常」,明文长度与前后缀保持一致。
184 lines
5.7 KiB
Go
184 lines
5.7 KiB
Go
// 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
|
||
}
|