mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 23:17:24 +00:00
feat: 密钥静态加密 + /api/v1 agent 管理 API
密钥加密(写侧封存 / 读侧解封)
- config.yaml 的 sources[].api_key、sources[].headers、keys[].key 落盘即
AES-256-GCM 密文(enc:v1: 前缀),master.key 复用 runtime store 那把
- 内存里永远是明文:鉴权比对、API 返回新建 key、WebUI 编辑回填都不受影响
- 启动时一次性封存现存明文(幂等,已封存则不写盘);-check 不写文件
- UpsertSourceInYAML 增加 box 参数,新加的源不再以明文落盘
- 解密失败改为硬错误:原先 MustDecrypt 返回密文会被下次 Save 二次封存
(实测:源 key 18→20、静默损坏),现在启动即失败且配置分毫不动
/api/v1:面向 agent 的管理 API(WebUI 零影响)
- GET /api/v1 机器可读索引,列出每个端点的方法/权限/用途
- GET /api/v1/overview 一次调用看全貌:源 + AUTO 链 + 密钥数 + 健康度
- GET /api/v1/health 仅健康快照
- GET /api/v1/models 按源分组的可路由模型清单
- GET /api/v1/sources[/{name}] 凭据遮蔽后的源
- GET /api/v1/auto 调度链与实时槽位状态
- GET /api/v1/keys admin only,密钥元数据,绝不回显密钥本身
- 沿用同一套网关 key 鉴权;读端点任意角色,写仍需 admin
测试:15 个新用例(含负向:泄密、越权、写操作必须被拒)
变异验证:maskKey 不遮蔽→红、去掉 admin 校验→红
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit ad28a924a5)
This commit is contained in:
227
internal/config/secret_config.go
Normal file
227
internal/config/secret_config.go
Normal file
@ -0,0 +1,227 @@
|
||||
package config
|
||||
|
||||
// Secret handling for config.yaml.
|
||||
//
|
||||
// config.yaml is 0644 world-readable by design (ops need to inspect it), so any
|
||||
// credential in it must not sit there in plaintext. Sources' api_key / headers
|
||||
// and gateway keys are therefore sealed at rest with the same SecretBox used by
|
||||
// the runtime store, and unsealed in memory at load time.
|
||||
//
|
||||
// The invariant that makes this safe: **in-memory values are always plaintext**,
|
||||
// and the enc:v1: prefix is what marks a file value as sealed. Read paths that
|
||||
// predate this (core.resolveSourceKey) already unseal, so only the write side and
|
||||
// the load-time normalize step are new.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sealSource seals one source's credentials in place (used by the YAML upsert
|
||||
// path, which is a package function and therefore has no Config to borrow a box
|
||||
// from).
|
||||
func sealSource(s *Source, box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Encrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
for k, v := range s.Headers {
|
||||
if v == "" || strings.HasPrefix(v, encPrefix) {
|
||||
continue
|
||||
}
|
||||
e, err := box.Encrypt(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Headers[k] = e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeSecrets unseals every credential in the freshly parsed config so the
|
||||
// rest of the program only ever sees plaintext. A value without the enc:v1:
|
||||
// prefix is left untouched, which keeps hand-written plaintext configs working
|
||||
// (and is what a pre-encryption config file looks like).
|
||||
//
|
||||
// A value that carries the prefix but fails to decrypt is a hard error, not
|
||||
// something to paper over: returning the ciphertext (MustDecrypt's behavior)
|
||||
// would let the next Save re-seal it and turn one bad value into permanent,
|
||||
// compounding corruption. Losing the master key must be loud.
|
||||
func (c *Config) normalizeSecrets(box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Sources {
|
||||
s := &c.Sources[i]
|
||||
if strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Decrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source %q api_key: %w", s.Name, err)
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
for k, v := range s.Headers {
|
||||
if strings.HasPrefix(v, encPrefix) {
|
||||
d, err := box.Decrypt(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source %q header %q: %w", s.Name, k, err)
|
||||
}
|
||||
s.Headers[k] = d
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range c.Keys {
|
||||
if strings.HasPrefix(c.Keys[i].Key, encPrefix) {
|
||||
v, err := box.Decrypt(c.Keys[i].Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway key %q: %w", c.Keys[i].Name, err)
|
||||
}
|
||||
c.Keys[i].Key = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sealInPlace replaces plaintext credentials with ciphertext for writing. It is
|
||||
// deliberately a separate step from Marshal: callers that need the plaintext
|
||||
// (auth comparisons, log output, returning a key to the operator who just
|
||||
// created it) must not be handed a sealed config by accident.
|
||||
func (c *Config) sealInPlace(box *SecretBox) error {
|
||||
if box == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Sources {
|
||||
s := &c.Sources[i]
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
v, err := box.Encrypt(s.APIKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.APIKey = v
|
||||
}
|
||||
if len(s.Headers) > 0 {
|
||||
sealed := make(map[string]string, len(s.Headers))
|
||||
for k, v := range s.Headers {
|
||||
if v == "" || strings.HasPrefix(v, encPrefix) {
|
||||
sealed[k] = v
|
||||
continue
|
||||
}
|
||||
e, err := box.Encrypt(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sealed[k] = e
|
||||
}
|
||||
s.Headers = sealed
|
||||
}
|
||||
}
|
||||
for i := range c.Keys {
|
||||
k := &c.Keys[i]
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
v, err := box.Encrypt(k.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k.Key = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsealAfterWrite restores plaintext after a sealed marshal so the live process
|
||||
// keeps working on plaintext values (mirrors Store.persistLocked's dance).
|
||||
func (c *Config) unsealAfterWrite(box *SecretBox) {
|
||||
// Save just encrypted every value it can see, so a failure here is
|
||||
// impossible; ignore the error rather than panic in a write path.
|
||||
_ = c.normalizeSecrets(box)
|
||||
}
|
||||
|
||||
// hasPlaintextSecrets reports whether any credential in the config is still in
|
||||
// the clear. Used to decide whether a startup migration write is needed, and to
|
||||
// warn (without leaking values) when no master key is available.
|
||||
func (c *Config) hasPlaintextSecrets() bool {
|
||||
for _, s := range c.Sources {
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
return true
|
||||
}
|
||||
for _, v := range s.Headers {
|
||||
if v != "" && !strings.HasPrefix(v, encPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range c.Keys {
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// countPlaintextSecrets returns how many credentials are still in the clear, for
|
||||
// an operator-facing migration log line that must not print the values.
|
||||
func (c *Config) countPlaintextSecrets() int {
|
||||
n := 0
|
||||
for _, s := range c.Sources {
|
||||
if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) {
|
||||
n++
|
||||
}
|
||||
for _, v := range s.Headers {
|
||||
if v != "" && !strings.HasPrefix(v, encPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range c.Keys {
|
||||
if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// NormalizeSecretsForRun unseals the loaded config and then seals it back on
|
||||
// disk if anything was still in the clear. Order matters: Load() read the file
|
||||
// with ciphertext still in place, so the unseal has to happen before the
|
||||
// registry (and any Save the startup path performs) sees the values.
|
||||
func (c *Config) NormalizeSecretsForRun(box *SecretBox) error {
|
||||
c.AttachSecretBox(box)
|
||||
if err := c.normalizeSecrets(box); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.migratePlaintextSecrets()
|
||||
}
|
||||
|
||||
// AttachSecretBox wires the encryption box into the config so Save can seal
|
||||
// credentials. Kept as an explicit call (rather than a constructor argument) so
|
||||
// config.Load stays usable in contexts that have no filesystem secrets (tests,
|
||||
// `-check`).
|
||||
func (c *Config) AttachSecretBox(box *SecretBox) { c.box = box }
|
||||
|
||||
// SecretBox returns the wired encryption box, or nil when none is attached.
|
||||
func (c *Config) SecretBox() *SecretBox { return c.box }
|
||||
|
||||
// migratePlaintextSecrets seals any credential still in the clear and writes the
|
||||
// file once. It is idempotent: a config that is already sealed (or has no
|
||||
// secrets) is left alone and nothing is written.
|
||||
func (c *Config) migratePlaintextSecrets() error {
|
||||
if c.box == nil || c.Path == "" {
|
||||
return nil
|
||||
}
|
||||
if !c.hasPlaintextSecrets() {
|
||||
return nil
|
||||
}
|
||||
n := c.countPlaintextSecrets()
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user