Files
AskaEth 4ae714883d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,runtimeconfig): 渠道配置写路径改为 patch 语义,修凭据静默丢失(P0 级数据安全)
- 新问题(本轮前端组发现):config 是整条替换,而面板只发送用户本次碰过的平台字段 ⇒
  ① 保存微信(iLink)账号卡(其平台字段只有 cdn_base_url,甚至可为空)会把
  bot_token/bot_id/owner_id/get_updates_buf **全部抹掉**(iLink token 是长期凭据);
  ② Telegram 填 webhook_url 而不重填 token 会因 cleanAccounts「token 空即丢账号」把**整个账号**删掉
- 修法:新增 runtimeconfig.PatchChannel + mergeChannelConfig + isEmptyConfigValue(三态,与 personaId 同构)
  · 键省略 / config 整体缺席 / null = **保留已存值**(未给出的键绝不被抹掉)
  · 给出非空值 = 覆盖 · 显式 null 或 "" = **清除该键**(0/false 不算空)
  · 写路径 UpsertChannel → PatchChannel;非 config 字段(名单/限流/personaId)仍整值替换(不变)
- 回归(真断言落盘内容):微信卡片只带 cdn_base_url / 空卡片三种形态 → 四个凭据键全在;
  Telegram 只带 webhook_url → 账号存活且 token 不变;显式 null/"" → 键真被清除;新账号正常写入;
  既有 TestAdminSaveChannelConfigPersistsAndReloads 保持绿
- 变异反证 3 条(改回 UpsertChannel → 精确复现两缺陷;去掉清除分支;"" 不再算空)均已还原
- 边界未改:token 要求型平台**显式**清 token 仍按 cleanAccounts 丢账号(主人明确清除凭据的语义);
  「本次一个 config 键都没给出」在非 weixin-ilink 平台仍回 400(既有行为,不在授权内放宽)
2026-10-04 13:42:37 +08:00

358 lines
13 KiB
Go
Raw Permalink 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.
// channels.go loads and persists the runtime-editable channel-adapter
// configuration: the per-platform account list in channels.json (design/04
// §3.1). The .env channel block (CHANNELS_JSON + CHANNEL_<PLATFORM>_*) is the
// final fallback; a present-and-valid channels.json takes precedence and is
// hot-reloaded at runtime without a restart.
//
// channels.json is gitignored (local runtime config) and holds bot tokens, so
// this package never logs a token and never exposes one un-masked to a caller
// that only wants a display snapshot (Masked).
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// channelsFileName is the channels.json basename stored under the data dir.
const channelsFileName = "channels.json"
// ErrInvalidChannels is returned when a channels.json fails schema validation.
var ErrInvalidChannels = errors.New("runtimeconfig: invalid channels config")
// tokenKeys are the config-map keys whose value is a secret and must never be
// emitted verbatim by Masked (or a log line). A key is treated as sensitive when
// its lower-cased form contains any of these substrings.
var tokenKeys = []string{"token", "secret", "password", "api_key", "apikey", "access_key", "key"}
// DefaultChannelsPath returns the channels.json path under dataDir.
func DefaultChannelsPath(dataDir string) string {
return filepath.Join(dataDir, channelsFileName)
}
// LoadChannels returns the effective per-platform account map. When path is a
// present-and-valid channels.json it is authoritative; otherwise it falls back
// to envFallback (the .env/CHANNELS_JSON channel block computed by the caller).
// A present-but-invalid file falls back to env and returns a non-nil warning so
// the caller can log it. The returned map is non-nil. A file-loaded account list
// is cleaned (bare empty-token placeholders dropped) so a stale channels.json
// never surfaces an account that cannot connect; the caller's envFallback is
// left untouched.
func LoadChannels(path string, envFallback map[string][]config.ChannelAccount) (map[string][]config.ChannelAccount, error) {
accounts, readErr := loadChannelsFile(path)
if readErr == nil {
return cleanAccounts(accounts), nil
}
var warn error
if !os.IsNotExist(readErr) {
warn = fmt.Errorf("runtimeconfig: %s: %w", path, readErr)
}
if envFallback == nil {
envFallback = map[string][]config.ChannelAccount{}
}
return envFallback, warn
}
// loadChannelsFile decodes and validates the channels.json map. A nil/blank
// file, a JSON decode error or an empty map all return an error so the caller
// falls back to env — an empty config file should never silently wipe the
// channels that env still provides.
func loadChannelsFile(path string) (map[string][]config.ChannelAccount, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, fmt.Errorf("%w: empty file", ErrInvalidChannels)
}
accounts, err := config.ParseChannelAccounts(string(raw))
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidChannels, err)
}
if len(accounts) == 0 {
return nil, fmt.Errorf("%w: no accounts", ErrInvalidChannels)
}
return accounts, nil
}
// SaveChannels writes accounts as a pretty-printed JSON map to path with mode
// 0600 (the file holds bot tokens). It writes to a temp file then renames so a
// concurrent reader never sees a partial file. Bare empty-token placeholders are
// dropped before writing, so an unusable account never lands in the file.
func SaveChannels(path string, accounts map[string][]config.ChannelAccount) error {
if accounts == nil {
accounts = map[string][]config.ChannelAccount{}
}
accounts = cleanAccounts(accounts)
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
}
}
raw, err := json.MarshalIndent(accounts, "", " ")
if err != nil {
return fmt.Errorf("runtimeconfig: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename: %w", err)
}
return nil
}
// UpsertChannel returns accounts with acct inserted or replaced in place within
// its platform by ID, preserving order. A blank ID is assigned a generated id so
// a panel save without an explicit id still produces a stable, addressable account.
func UpsertChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount {
if accounts == nil {
accounts = map[string][]config.ChannelAccount{}
}
if strings.TrimSpace(acct.ID) == "" {
acct.ID = NewID()
}
list := accounts[platform]
out := make([]config.ChannelAccount, 0, len(list)+1)
replaced := false
for _, cur := range list {
if cur.ID == acct.ID {
out = append(out, acct)
replaced = true
continue
}
out = append(out, cur)
}
if !replaced {
out = append(out, acct)
}
accounts[platform] = out
return accounts
}
// PatchChannel 以 **patch 语义** 写入一个账号,是 UpsertChannel 的补丁版:
// acct.Config 被当作「只含本次请求显式给出的键」的补丁,而不是一整套完整配置。
//
// - 补丁里**缺位的键** → 保留该账号已存值(未被触碰的键绝不因一次保存被抹掉);
// - 补丁里给出的非空值 → 覆盖该键;
// - 补丁里给出 nil 或空白字符串 → **删除**该键(保留「清空」能力,删掉后适配器回落到自己的零值)。
//
// 同平台同 id 的已存条目是补丁的基线;没有基线时(新账号,或 id 为空、由 UpsertChannel
// 生成 id)只写入补丁本身。acct 的其它字段(黑白名单 / 限流 / personaId)与 UpsertChannel
// 一样是整值替换 —— 它们在线上是值语义,不是三态。返回值与入参 accounts 的关系同 UpsertChannel。
func PatchChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount {
if accounts == nil {
accounts = map[string][]config.ChannelAccount{}
}
var stored config.ChannelAccount
if id := strings.TrimSpace(acct.ID); id != "" {
for _, cur := range accounts[platform] {
if cur.ID == id {
stored = cur
break
}
}
}
acct.Config = mergeChannelConfig(stored.Config, acct.Config)
return UpsertChannel(accounts, platform, acct)
}
// mergeChannelConfig 把补丁合并进已存 config,返回一个新 map(stored 与 patch 均不被改动)。
// 缺位的键保留已存值;nil/空白的补丁值删除该键;其余覆盖。空补丁是恒等变换。
func mergeChannelConfig(stored, patch map[string]any) map[string]any {
out := make(map[string]any, len(stored)+len(patch))
for k, v := range stored {
out[k] = v
}
for k, v := range patch {
if isEmptyConfigValue(v) {
delete(out, k)
continue
}
out[k] = v
}
return out
}
// isEmptyConfigValue 报告一个补丁值是否表示「清除该键」:显式 nil,或只含空白的字符串。
// 其它类型(数字/布尔/对象/数组)永远不算空 —— 0 与 false 是真实取值,不是「未设置」。
func isEmptyConfigValue(v any) bool {
switch t := v.(type) {
case nil:
return true
case string:
return strings.TrimSpace(t) == ""
default:
return false
}
}
// DeleteChannel returns accounts with the account of the given id removed from
// its platform. When the platform has no remaining accounts the platform key is
// dropped entirely, so a reload of that platform yields no adapters.
func DeleteChannel(accounts map[string][]config.ChannelAccount, platform, id string) map[string][]config.ChannelAccount {
if accounts == nil {
return map[string][]config.ChannelAccount{}
}
out := make([]config.ChannelAccount, 0, len(accounts[platform]))
for _, cur := range accounts[platform] {
if cur.ID == id {
continue
}
out = append(out, cur)
}
if len(out) == 0 {
delete(accounts, platform)
} else {
accounts[platform] = out
}
return accounts
}
// Masked returns a copy of acct whose Config has every secret value redacted,
// for display/logging. It never reveals more than the trailing 4 characters of a
// token, so a secret never lands verbatim in a wire response or a log line. The
// original account (and the channels.json on disk) is untouched.
func Masked(acct config.ChannelAccount) config.ChannelAccount {
out := acct
out.Config = maskConfig(acct.Config)
return out
}
// maskConfig redacts the values of sensitive keys in a config map. If a sensitive
// value is not a string it is passed through unchanged (never a token).
func maskConfig(m map[string]any) map[string]any {
if m == nil {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
if isSensitiveKey(k) {
if s, ok := v.(string); ok {
out[k] = maskToken(s)
continue
}
}
out[k] = v
}
return out
}
// isSensitiveKey reports whether a config key holds a secret. It matches the
// bot_token/access_token/secret/api_key family used across the adapters.
func isSensitiveKey(k string) bool {
lk := strings.ToLower(k)
for _, sub := range tokenKeys {
if strings.Contains(lk, sub) {
return true
}
}
return false
}
// maskToken redacts a token to the trailing 4 characters (or all-asterisks when
// it is 4 characters or fewer).
func maskToken(s string) string {
if len(s) <= 4 {
return "****"
}
return "****" + s[len(s)-4:]
}
// tokenRequiringPlatforms are the adapters whose connection needs a bot token
// (design/08 §4). An account for one of these with an empty bot_token is a bare
// placeholder: it can never connect and must not claim the bare platform name
// (design/16 §4.5), so it is dropped from the effective config and from
// channels.json. Other platforms (qq-obv11, webhook) are left alone so their
// channel list stays stable while the bridge's configured() gate decides whether
// to dial.
//
// weixin-ilink is deliberately NOT token-required here: an empty-token account is
// a legitimate pending scan-login (design/08 §4). The panel saves a bare weixin
// account, drives get_bot_qrcode/get_qrcode_status via the admin login endpoints,
// and the bridge's configured() gate keeps it disconnected until the scan
// confirms and the token is persisted — so such an account must survive loading
// and writing, not be dropped as a placeholder.
func tokenRequiringPlatforms(platform string) bool {
switch platform {
case "kook", "telegram":
return true
default:
return false
}
}
// cleanAccounts drops unusable bare accounts from an accounts map. For a
// token-requiring platform any account with an empty bot_token is dropped (a
// bare "kook"/"telegram"/"weixin-ilink" placeholder can never connect). It never
// logs or returns a token.
func cleanAccounts(accounts map[string][]config.ChannelAccount) map[string][]config.ChannelAccount {
if accounts == nil {
return accounts
}
out := make(map[string][]config.ChannelAccount, len(accounts))
for platform, list := range accounts {
if !tokenRequiringPlatforms(platform) {
out[platform] = list
continue
}
kept := make([]config.ChannelAccount, 0, len(list))
for _, a := range list {
if strings.TrimSpace(accountBotToken(a)) == "" {
continue
}
kept = append(kept, a)
}
if len(kept) > 0 {
out[platform] = kept
}
}
return out
}
// accountBotToken returns the account's bot_token value (the credential the
// token-requiring platforms need). It never logs the token.
func accountBotToken(a config.ChannelAccount) string {
if s, ok := a.Config["bot_token"].(string); ok {
return s
}
return ""
}
// AdminIDs 汇总 channels.json 里各账号声明的管理员 id(裁决 06.4.2;design/08 §1.1、
// design/11 §0),返回 platform → 去重后的管理员 id 列表(同一平台的多账号声明会合并去重、
// 空串被丢弃)。**结果覆盖 accounts 里的每个平台**:未声明 adminIds 的平台对应空切片,
// 这样 `for p, ids := range AdminIDs(accounts) { profile.SetPlatformAdmins(p, ids) }`
// 就是一次整体替换——热重载后删掉的管理员 id 会立即失效(不会有悬挂管理员)。返回的切片是
// 新建的,调用方改动它不影响 accounts。
func AdminIDs(accounts map[string][]config.ChannelAccount) map[string][]string {
out := make(map[string][]string, len(accounts))
for platform, list := range accounts {
seen := make(map[string]struct{})
var ids []string
for _, acct := range list {
for _, id := range acct.AdminIDs {
trimmed := strings.TrimSpace(id)
if trimmed == "" {
continue
}
if _, dup := seen[trimmed]; dup {
continue
}
seen[trimmed] = struct{}{}
ids = append(ids, trimmed)
}
}
out[platform] = ids
}
return out
}