Files
ModelRouter/internal/lua/plugin_secrets.go
JianFeeeee da0282a3db test: 四类验收 —— 加密解密 / 变异 / 压力 / 旧通路
用户要求的四类测试全部落地,且每类都抓到了真问题:

## 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 绿。
2026-10-11 20:50:52 +08:00

326 lines
10 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 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()))
}
}