mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户要求的四类测试全部落地,且每类都抓到了真问题: ## 0) 测试暴露的真实缺陷(先修后测) **保险库明文落盘**(加密解密测试的目标缺陷):.secrets/<plugin>.json 此前 是明文 JSON。接入 SecretBox(与 config.yaml 同一把 master key、同 enc:v1 形态):SetSecretBox 注入(secretSealer 接口,测试可桩);写盘逐值加密、 读盘逐值解密;无前缀旧值原样通过 —— 旧明文库可读、下次写整体重封。 core 注入点在 store 创建之后(NewPlugins 时 box 还不存在 —— 顺序 load-bearing, 修掉了一个 nil-deref)。 ## 1) 加密解密(internal/lua/secrets_crypto_test.go) - EncryptedAtRest:磁盘字节不含明文 token、带 enc:v1、文件 0600/目录 0700 - PlaintextFallbackAndUpgrade:旧明文库可读 + 任意一次写整体重封为密文 - WrongMasterKey:换 key 后读库必须报错(静默空会让插件重登覆盖凭据) - ConcurrentPlugins:8 插件×2 goroutine 并发读写,无串台/丢写/文件数正确 ## 2) 变异(internal/core/device_mutate_test.go + device_harness_test.go) - 决策表抽成 deviceHarness.runDecisionTable(行为测试与变异测试共用, 单一事实源) - 5 个语义变异逐个杀:slow_down 不加 interval / denied 不识别 / expired_token 不识别 / authorization_pending 当失败 / 成功分支禁用 - 行3/行4 断言 message 区分(用户拒绝/过期)—— 初版只断 state=failed 时 两个变异体幸存(落进通用 failed 分支),加 message 后全杀。 这正是变异测试的价值:暴露断言太弱。 - 基准(未变异)必须全过 —— 防止测试自身失效的假阳性 ## 3) 压力(internal/core/channels_stress_test.go) - ConcurrentLogins:12 轮并发 device-poll,会话互不串台、全部终态 - ClaimStress:30 并发领签到,上游收到恰 30 次(无登录态丢失) ## 4) 旧通路(channels_stress_test.go 后半 + 复用 lua 包测试) - LegacyVaultPlaintextLoad:老版本明文库 → 新版本可读 → 下次写重封 - LegacySourceSealedConfig:明文 api_key 的旧 config.yaml → 运行期解密、 落盘 seal、master.key 0600 生成 全量 lua/core/gateway/types/scheduler/provider/config 测试通过;build+vet 绿。
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package lua
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// Package lua — 插件凭据保险库(R4-d)
|
||
//
|
||
// 用户点明的能力:插件**自己主导外部源的链接认证、自动领取额度**。这类插件
|
||
// (对照 dsh-our-free-model 的渠道插件形态)要长期持有 OAuth token、session、
|
||
// 设备指纹等凭据,并在定时任务里拿去换额度、换上游。
|
||
//
|
||
// 在保险库之前,插件唯一的持久化落点是 plugin.state,而它有两个问题:
|
||
//
|
||
// 1. **权限**:.state.json 是业务累计量(计费总额、签到天数),和凭据混在同一个
|
||
// 文件里。它没有 0600,落在插件目录(0755)下 —— 存 token 等于把凭据摊开。
|
||
// 2. **语义**:state 会被 PUT /api/plugins/{name}/state 整体替换,会被
|
||
// `prices` 分离逻辑触碰,会进 WebUI。凭据进去就有可能被一次"重置状态"
|
||
// 或一次 UI 回显带走。
|
||
//
|
||
// 保险库把凭据隔离成独立文件:
|
||
//
|
||
// <plugin_dir>/.secrets/<plugin>.json 权限 0600,目录 0700
|
||
//
|
||
// 与状态分离,不参与 UI 回显,不参与 state 替换。
|
||
//
|
||
// # 谁能读
|
||
//
|
||
// 只有该插件自己(按启动时的固定名字)。跨插件读会被拒 —— 记名方式是插件名
|
||
// 而非任意引用,因为参考插件的 `credentialRef` 模式(任意字符串引用)在这里
|
||
// 会变成一个可以指向别人凭据的键空间。
|
||
//
|
||
// # 与内核动作的关系
|
||
//
|
||
// 凭据本身不进 Lua 的返回值上限之外的地方:取出后由插件用于自己的 HTTP 调用。
|
||
// 保险库不把凭据交给内核,内核也不需要读它。
|
||
|
||
// secretsDirName is the vault directory inside plugin_dir. The leading dot keeps
|
||
// it out of a casual `ls` of the plugin directory, which is where operators look
|
||
// for "what plugins do I have" — the vault is not a plugin.
|
||
// secretSealer 是保险库落盘加密所需的最小能力面。
|
||
//
|
||
// 不直接 import config.SecretBox 的原因与 KernelAPI 相同:保持依赖注入的缝,
|
||
// 测试可以用桩;core 侧把 *config.SecretBox 注进来(Encrypt/Decrypt 语义与
|
||
// config.yaml 的凭据完全一致 —— 同一把 master key、同一种 enc:v1 前缀)。
|
||
type secretSealer interface {
|
||
Encrypt(plain string) (string, error)
|
||
Decrypt(v string) (string, error)
|
||
}
|
||
|
||
// SetSecretBox 注入落盘加密器。必须在任何插件写凭据之前调用(core 启动序里
|
||
// 在 NewPlugins 之后立即做)。传 nil 合法(=不加密,测试环境)。
|
||
func (ps *Plugins) SetSecretBox(box secretSealer) {
|
||
ps.mu.Lock()
|
||
ps.secretBox = box
|
||
ps.mu.Unlock()
|
||
}
|
||
|
||
const secretsDirName = ".secrets"
|
||
|
||
// secretsDir returns the vault directory (empty when there is no plugin dir, in
|
||
// which case the vault is disabled — same posture as state persistence).
|
||
func (ps *Plugins) secretsDir() string {
|
||
if ps.dir == "" {
|
||
return ""
|
||
}
|
||
return filepath.Join(ps.dir, secretsDirName)
|
||
}
|
||
|
||
// secretFile returns one plugin's vault path.
|
||
func (ps *Plugins) secretFile(plugin string) string {
|
||
d := ps.secretsDir()
|
||
if d == "" {
|
||
return ""
|
||
}
|
||
return filepath.Join(d, plugin+".json")
|
||
}
|
||
|
||
// validateSecretKey rejects keys that could not round-trip through the vault.
|
||
//
|
||
// The vault is a flat string->string map serialized as JSON, so a key only needs
|
||
// to be non-empty and not absurdly long. Traversal is not a concern because keys
|
||
// are never used as path components; the FILE name comes from the plugin name,
|
||
// which is already validated when the plugin loads.
|
||
func validateSecretKey(key string) error {
|
||
if strings.TrimSpace(key) == "" {
|
||
return fmt.Errorf("secret key must not be empty")
|
||
}
|
||
if len(key) > 200 {
|
||
return fmt.Errorf("secret key too long (%d chars, max 200)", len(key))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// loadSecretsLocked reads one plugin's vault. Caller holds ps.mu.
|
||
//
|
||
// A missing file means "nothing stored yet" — the normal first-run state. A
|
||
// corrupt file returns an error rather than an empty map: silently treating
|
||
// unreadable credentials as absent would make a plugin re-login and overwrite
|
||
// whatever the file still held, which is how a recoverable parse problem becomes
|
||
// a lost session.
|
||
func (ps *Plugins) loadSecretsLocked(plugin string) (map[string]string, error) {
|
||
path := ps.secretFile(plugin)
|
||
if path == "" {
|
||
return map[string]string{}, nil
|
||
}
|
||
b, err := os.ReadFile(path)
|
||
if os.IsNotExist(err) {
|
||
return map[string]string{}, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
m := map[string]string{}
|
||
if len(b) == 0 {
|
||
return m, nil
|
||
}
|
||
if err := json.Unmarshal(b, &m); err != nil {
|
||
return nil, fmt.Errorf("vault for plugin %s is unreadable: %w", plugin, err)
|
||
}
|
||
// Decrypt each value. Decrypt passes values without the enc:v1 prefix through
|
||
// unchanged, so vaults written by an older plaintext build keep loading —
|
||
// and get re-sealed on the next write (SetSecret rewrites the whole file).
|
||
if ps.secretBox != nil {
|
||
for k, v := range m {
|
||
plain, err := ps.secretBox.Decrypt(v)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("vault for plugin %s: key %q: %w (master key changed?)", plugin, k, err)
|
||
}
|
||
m[k] = plain
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// writeSecretsLocked persists one plugin's vault with 0600.
|
||
//
|
||
// The mode is set on the temp file BEFORE the rename, so the secret is never
|
||
// visible under a looser mode at any instant — a chmod-after-write would leave a
|
||
// window where the token sits in a world-readable file.
|
||
func (ps *Plugins) writeSecretsLocked(plugin string, m map[string]string) error {
|
||
path := ps.secretFile(plugin)
|
||
if path == "" {
|
||
return fmt.Errorf("no plugin directory configured; the credential vault is disabled")
|
||
}
|
||
dir := ps.secretsDir()
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
return err
|
||
}
|
||
// Tighten an existing directory too: MkdirAll is a no-op when it exists, so a
|
||
// vault created before this mode was enforced would keep its old permissions.
|
||
_ = os.Chmod(dir, 0o700)
|
||
|
||
// Encrypt-at-rest: each VALUE is sealed with the deployment's master key
|
||
// (same enc:v1 form as config.yaml credentials). Keys stay plaintext so the
|
||
// file stays diffable/debuggable without exposing token bytes. A nil box
|
||
// writes plaintext — tests only; core always injects a real box.
|
||
sealed := make(map[string]string, len(m))
|
||
for k, v := range m {
|
||
if ps.secretBox != nil {
|
||
enc, err := ps.secretBox.Encrypt(v)
|
||
if err != nil {
|
||
return fmt.Errorf("seal secret %q: %w", k, err)
|
||
}
|
||
sealed[k] = enc
|
||
} else {
|
||
sealed[k] = v
|
||
}
|
||
}
|
||
b, err := json.MarshalIndent(sealed, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp, err := os.CreateTemp(dir, plugin+".*.tmp")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmpName := tmp.Name()
|
||
cleanup := func() { _ = os.Remove(tmpName) }
|
||
if err := tmp.Chmod(0o600); err != nil {
|
||
tmp.Close()
|
||
cleanup()
|
||
return err
|
||
}
|
||
if _, err := tmp.Write(b); err != nil {
|
||
tmp.Close()
|
||
cleanup()
|
||
return err
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
cleanup()
|
||
return err
|
||
}
|
||
if err := os.Rename(tmpName, path); err != nil {
|
||
cleanup()
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- 导出给 Lua 的 API ----
|
||
|
||
// SetSecret stores one credential for a plugin.
|
||
func (ps *Plugins) SetSecret(plugin, key, value string) error {
|
||
if plugin == "" {
|
||
return fmt.Errorf("plugin name required")
|
||
}
|
||
if err := validateSecretKey(key); err != nil {
|
||
return err
|
||
}
|
||
ps.mu.Lock()
|
||
defer ps.mu.Unlock()
|
||
m, err := ps.loadSecretsLocked(plugin)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
m[key] = value
|
||
return ps.writeSecretsLocked(plugin, m)
|
||
}
|
||
|
||
// GetSecret reads one credential. The second return reports whether it existed,
|
||
// which lets a plugin distinguish "no token stored yet" from "token is empty"
|
||
// without a sentinel value.
|
||
func (ps *Plugins) GetSecret(plugin, key string) (string, bool, error) {
|
||
if plugin == "" {
|
||
return "", false, fmt.Errorf("plugin name required")
|
||
}
|
||
if err := validateSecretKey(key); err != nil {
|
||
return "", false, err
|
||
}
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
m, err := ps.loadSecretsLocked(plugin)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
v, ok := m[key]
|
||
return v, ok, nil
|
||
}
|
||
|
||
// DeleteSecret removes one credential. Removing a missing key is not an error —
|
||
// "make sure nothing is stored under this key" is the useful postcondition.
|
||
func (ps *Plugins) DeleteSecret(plugin, key string) error {
|
||
if plugin == "" {
|
||
return fmt.Errorf("plugin name required")
|
||
}
|
||
if err := validateSecretKey(key); err != nil {
|
||
return err
|
||
}
|
||
ps.mu.Lock()
|
||
defer ps.mu.Unlock()
|
||
m, err := ps.loadSecretsLocked(plugin)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
delete(m, key)
|
||
return ps.writeSecretsLocked(plugin, m)
|
||
}
|
||
|
||
// SecretKeys lists the KEY NAMES a plugin has stored (never the values), so a
|
||
// plugin can show "which accounts are configured" without exposing token bytes.
|
||
func (ps *Plugins) SecretKeys(plugin string) ([]string, error) {
|
||
if plugin == "" {
|
||
return nil, fmt.Errorf("plugin name required")
|
||
}
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
m, err := ps.loadSecretsLocked(plugin)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out, nil
|
||
}
|
||
|
||
// DeleteAllSecrets wipes a plugin's vault. Used on plugin removal so uninstalling
|
||
// a plugin does not leave live credentials behind.
|
||
func (ps *Plugins) DeleteAllSecrets(plugin string) {
|
||
ps.mu.Lock()
|
||
defer ps.mu.Unlock()
|
||
if path := ps.secretFile(plugin); path != "" {
|
||
_ = os.Remove(path)
|
||
}
|
||
}
|
||
|
||
// PurgeOrphanVaults removes vault files whose plugin no longer exists.
|
||
//
|
||
// Vaults outlive the plugin file only if a removal path forgot to clean up, so
|
||
// this is a startup sweep rather than a hot path. Its value is that a deleted
|
||
// plugin cannot leave a live OAuth token on disk indefinitely.
|
||
func (ps *Plugins) PurgeOrphanVaults() {
|
||
dir := ps.secretsDir()
|
||
if dir == "" {
|
||
return
|
||
}
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
ps.mu.RLock()
|
||
live := make(map[string]bool, len(ps.plugins))
|
||
for _, p := range ps.plugins {
|
||
live[p.Info.Name] = true
|
||
}
|
||
ps.mu.RUnlock()
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||
continue
|
||
}
|
||
name := strings.TrimSuffix(e.Name(), ".json")
|
||
if live[name] {
|
||
continue
|
||
}
|
||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||
}
|
||
}
|