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 回显带走。 // // 保险库把凭据隔离成独立文件: // // /.secrets/.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. 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) } 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) b, err := json.MarshalIndent(m, "", " ") 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())) } }