Files
Cyrene_v2/internal/services/gateway/admin_write_channel_config.go
T
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

367 lines
15 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 gateway
// 管理渠道配置写操作(仅 owner,带审计)。面板把某平台的
// 账号配置(token/地址/速率限制/黑或白名单)保存到
// channels.json 并热重载该平台的适配器 —— 无需重启
// (design/04 §3.1)。GET /admin/channels 读路径会对每个 token
// 脱敏,因此秘密绝不返回给客户端;这条写路径把真实
// token 持久化进被 gitignore 的文件并触发 bridge 重载。
//
// 请求携带一个通用 config map(平台特有键,如
// bot_token / base_url / remote_url / endpointPath),外加针对常见
// token/地址字段的顶层便捷字段,因此面板既可发送
// config 对象,也可发送快捷字段。
//
// config 的线上契约是 **patch 语义**(三态,与 personaId 同构),不是整条替换:
// 面板只携带用户本次碰过的平台字段,因此未被携带的键必须保留已存值 ——
// 否则打开一张账号卡直接保存,就会把面板不渲染的凭据(weixin-ilink 的
// bot_token/bot_id/owner_id)静默抹掉。判定见 runtimeconfig.PatchChannel:
// 键缺席 = 保留、给出非空值 = 覆盖、给出 null/"" = 清除。
import (
"encoding/json"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// AdminChannelAccount 是管理列表中一个已配置渠道账号的线上
// 结构。Config 的值已脱敏(不含原始 token),因此没有敏感
// 内容返回给客户端。
type AdminChannelAccount struct {
ID string `json:"id"`
Config map[string]any `json:"config,omitempty"`
AllowGroups []string `json:"allowGroups,omitempty"`
DenyGroups []string `json:"denyGroups,omitempty"`
AllowUsers []string `json:"allowUsers,omitempty"`
DenyUsers []string `json:"denyUsers,omitempty"`
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
MsgPerMin int `json:"msgPerMin,omitempty"`
MaxMessages int `json:"maxMessages,omitempty"`
// PersonaID 是该账号应答所用的角色(design/22 §2 方案 b),账号
// 未绑定任何角色时省略。它是人格 id,不是秘密。
PersonaID string `json:"personaId,omitempty"`
// BotID/BotUsername/BotNickname 是已连接机器人的实际解析身份
// (kook 在 connect 时从 /api/v3/user/me 获取)。它们来自活的
// 适配器,而不是持久化的 channels.json 配置,且非敏感(机器人
// 的公开 handle/显示名),因此面板可以展示它们。
BotID string `json:"botId,omitempty"`
BotUsername string `json:"botUsername,omitempty"`
BotNickname string `json:"botNickname,omitempty"`
// Capabilities 列出该平台适配器暴露的账号级管理操作
// (friends/channels/user-chats/message-test),使面板
// 渲染正确的每账号卡片。没有专用适配器表面的平台
// 会省略它。
Capabilities []string `json:"capabilities,omitempty"`
}
// channelConfigRequest 是 PUT /api/v1/admin/channels/{platform}/config 的请求体。
// Config 保存平台特有键(bot_token、base_url、remote_url、
// endpointPath……),**按 patch 语义解释**(见 PatchChannel):
// - 键缺席(本 map 里没有该键,或整个 config 缺席 / 为 null)→ 保留该账号已存值;
// - 键给出非空值 → 覆盖该键;
// - 键显式给出 null 或 "" → 清除该键。
//
// 顶层便捷字段(token/baseURL/remoteURL/listenAddr/mode/accessToken/selfID)会为
// 常见 token/地址字段合并进去,且只在**非空**时才合并 —— 它们是普通 string,
// 分不出「缺席」与「显式空串」,因此线上一律以「非空 = 给出」为准;要清除
// bot_token 之类的键请走 config 里的显式 null/""。
// AllowGroups/DenyGroups/AllowUsers/DenyUsers 是
// 黑或白名单(design/16 §4.6 §6);*_PerMin/MaxMessages 字段是
// 每账号的流量限制(design/12 §6 策略三/四)。
type channelConfigRequest struct {
ID string `json:"id"`
Token string `json:"token"`
BaseURL string `json:"baseURL"`
RemoteURL string `json:"remoteURL"`
ListenAddr string `json:"listenAddr"`
Mode string `json:"mode"`
AccessToken string `json:"accessToken"`
SelfID string `json:"selfID"`
Config map[string]any `json:"config"`
AllowGroups []string `json:"allowGroups"`
DenyGroups []string `json:"denyGroups"`
AllowUsers []string `json:"allowUsers"`
DenyUsers []string `json:"denyUsers"`
GroupMsgPerMin int `json:"groupMsgPerMin"`
MsgPerMin int `json:"msgPerMin"`
MaxMessages int `json:"maxMessages"`
// PersonaID 是该账号应答所用的角色(design/22 §2 方案 b)。它是
// 指针,因为线上契约是三态的,而普通 string 无法
// 区分键缺失与显式 "":缺失保留已存绑定,
// "" 清除它,非空 id 绑定它。未知 id 以 400 拒绝,因此
// 保存绝不可能绑定一个无法应答的角色。
PersonaID *string `json:"personaId"`
}
// adminSaveChannelConfig 把某平台一个账号的配置持久化进
// channels.json(按 id 打补丁)并热重载该平台的适配器。
// 响应携带 channels.json 路径与账号 id,使面板能
// 定位已保存的账号(供之后编辑/删除)。
//
// config 与 personaId 一样是三态的(见 runtimeconfig.PatchChannel 与
// channelPersonaBinding):面板只发送用户本次碰过的键,
// 因此键缺失必须保留已存值,而不是静默丢弃已存凭据或
// 解绑该账号。
func (s *Server) adminSaveChannelConfig(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
if platform == "" {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
var req channelConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if s.channels == nil {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
// 渠道级角色必须存在于人格库中,否则账号会被绑定到
// 一个无法应答的角色(design/22 §2 方案 b)。只校验面板确实
// 发送的值;键缺失(nil)继承已存绑定,该绑定在
// 写入时就已校验。显式的 "" 一律接受:
// 它表示「清除渠道绑定」。
if req.PersonaID != nil {
if !s.requireKnownPersona(w, strings.TrimSpace(*req.PersonaID)) {
s.auditClaims(r, "channel.save", "error")
return
}
}
cfg := channelConfigFromRequest(req)
// cfg 是**补丁**(只含本次请求显式给出的键),因此这道门禁读作
// 「本次请求一个平台特有配置键都没给出」。weixin-ilink 可以带空配置保存:
// 面板先创建账号,随后用户扫码(get_bot_qrcode),它经 login-status 填入
// bot_token/bot_id/owner_id(design/08 §4)。此处要求 token 会阻塞该
// 流程,因此把它从「至少一个字段」的门禁中豁免。
if len(cfg) == 0 && platform != "weixin-ilink" {
s.auditClaims(r, "channel.save", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "至少提供 token/baseUrl/address 等配置字段"))
return
}
accounts := s.channels.Load()
// patch 语义:未在 cfg 里出现的 config 键保留盘上已存值;cfg 里显式为
// null/"" 的键被清除。非 config 字段(黑白名单 / 限流 / personaId)仍是整值替换。
updated := runtimeconfig.PatchChannel(accounts, platform, config.ChannelAccount{
ID: strings.TrimSpace(req.ID),
Config: cfg,
AllowGroups: req.AllowGroups,
DenyGroups: req.DenyGroups,
AllowUsers: req.AllowUsers,
DenyUsers: req.DenyUsers,
GroupMsgPerMin: req.GroupMsgPerMin,
MsgPerMin: req.MsgPerMin,
MaxMessages: req.MaxMessages,
PersonaID: channelPersonaBinding(req.PersonaID, existingChannelAccount(accounts[platform], req.ID)),
})
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.save", "error")
s.log.Error("channel save channels.json", "path", s.channels.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
return
}
if err := s.channels.Reload(platform); err != nil {
s.auditClaims(r, "channel.save", "error")
s.log.Error("channel reload", "platform", platform, "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
return
}
s.auditClaims(r, "channel.save", "ok")
writeJSON(w, http.StatusCreated, map[string]any{
"persisted": true,
"path": s.channels.Path(),
"platform": platform,
"accountId": savedAccountID(updated[platform], req.ID),
})
}
// adminDeleteChannelAccount 按 id 查询参数从某平台在 channels.json
// 中移除一个账号,并热重载该平台使适配器停止。
func (s *Server) adminDeleteChannelAccount(w http.ResponseWriter, r *http.Request) {
platform := strings.TrimSpace(r.PathValue("platform"))
id := strings.TrimSpace(r.URL.Query().Get("id"))
if platform == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "platform is required"))
return
}
if id == "" {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id is required"))
return
}
if s.channels == nil {
s.auditClaims(r, "channel.delete", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "channels_unavailable", "渠道配置服务未注入"))
return
}
accounts := s.channels.Load()
updated := runtimeconfig.DeleteChannel(accounts, platform, id)
if err := runtimeconfig.SaveChannels(s.channels.Path(), updated); err != nil {
s.auditClaims(r, "channel.delete", "error")
s.log.Error("channel delete channels.json", "path", s.channels.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_persist_failed", "failed to save channels.json"))
return
}
if err := s.channels.Reload(platform); err != nil {
s.auditClaims(r, "channel.delete", "error")
s.log.Error("channel reload", "platform", platform, "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_reload_failed", "saved but failed to hot-reload"))
return
}
s.auditClaims(r, "channel.delete", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"persisted": true,
"path": s.channels.Path(),
"platform": platform,
"accountId": id,
})
}
// channelConfigFromRequest 把请求的便捷字段合并进平台特有 config
// map,优先采用面板已显式设置的键。它绝不注入面板
// 未给出的键(空 token 保持缺位,这样用的是
// 适配器的零值,而不是空字符串 token)。
//
// 返回的是**补丁**:只含本次请求给出的键,调用方必须
// 交给 runtimeconfig.PatchChannel,不得当作完整配置整条替换 ——
// 后者的三态(缺席 / 给出 / null 或 "")正是「未被面板携带的
// 键不会被抹掉」所依赖的。
func channelConfigFromRequest(req channelConfigRequest) map[string]any {
cfg := make(map[string]any, len(req.Config)+8)
for k, v := range req.Config {
cfg[k] = v
}
if req.Token != "" {
if _, ok := cfg["bot_token"]; !ok {
cfg["bot_token"] = req.Token
}
}
if req.BaseURL != "" {
if _, ok := cfg["base_url"]; !ok {
cfg["base_url"] = req.BaseURL
}
}
if req.RemoteURL != "" {
if _, ok := cfg["remote_url"]; !ok {
cfg["remote_url"] = req.RemoteURL
}
}
if req.ListenAddr != "" {
if _, ok := cfg["listen_addr"]; !ok {
cfg["listen_addr"] = req.ListenAddr
}
}
if req.Mode != "" {
if _, ok := cfg["mode"]; !ok {
cfg["mode"] = req.Mode
}
}
if req.AccessToken != "" {
if _, ok := cfg["access_token"]; !ok {
cfg["access_token"] = req.AccessToken
}
}
if req.SelfID != "" {
if _, ok := cfg["self_id"]; !ok {
cfg["self_id"] = req.SelfID
}
}
return cfg
}
// channelPersonaBinding 解析 PUT /admin/channels/{platform}/config 的
// 三态 personaId 契约:
//
// - 键缺失(nil) → 保留该账号已存的绑定;
// - 键是显式 "" → 清除绑定(显式解绑);
// - 键是非空 id → 绑定该角色。
//
// 除非主人触碰过角色选择,面板会省略该键,因此从已存
// 账号解析缺失键,正是让一次普通的配置编辑不会
// 静默丢弃绑定的原因。创建新账号的保存(没有已存
// 账号可继承)解析为「无绑定」。
func channelPersonaBinding(sent *string, stored config.ChannelAccount) string {
if sent == nil {
return strings.TrimSpace(stored.PersonaID)
}
return strings.TrimSpace(*sent)
}
// existingChannelAccount 返回一次保存将要替换的已存账号(同
// 平台、同 id),使写操作能继承面板未发送的字段。
// 空 id 意味着该保存创建新账号,因此没有
// 可继承的内容。
func existingChannelAccount(list []config.ChannelAccount, id string) config.ChannelAccount {
id = strings.TrimSpace(id)
if id == "" {
return config.ChannelAccount{}
}
for _, acct := range list {
if acct.ID == id {
return acct
}
}
return config.ChannelAccount{}
}
// savedAccountID 返回一次保存产出的账号 id —— 调用方提供时
// 用它的,否则用 runtimeconfig.Upsert 生成的 id(对全新账号
// 它会追加在末尾)。
func savedAccountID(list []config.ChannelAccount, supplied string) string {
if supplied != "" {
return supplied
}
if len(list) == 0 {
return ""
}
return list[len(list)-1].ID
}
// adminChannelAccountOf 把一个(已脱敏的)账号映射为其管理侧线上结构。
func adminChannelAccountOf(acct config.ChannelAccount) AdminChannelAccount {
return AdminChannelAccount{
ID: acct.ID,
Config: acct.Config,
AllowGroups: acct.AllowGroups,
DenyGroups: acct.DenyGroups,
AllowUsers: acct.AllowUsers,
DenyUsers: acct.DenyUsers,
GroupMsgPerMin: acct.GroupMsgPerMin,
MsgPerMin: acct.MsgPerMin,
MaxMessages: acct.MaxMessages,
PersonaID: acct.PersonaID,
}
}
// channelBasePlatform 返回形如 "telegram:conf0" → "telegram" 的
// (可能多账号的)适配器名的裸平台,使配置查找把某平台的
// 所有账号归到一组。
func channelBasePlatform(name string) string {
if base, _, ok := strings.Cut(name, ":"); ok {
return base
}
return name
}
// configuredAccount 报告某账号是否携带有效配置(面板创建的
// id 或真实 token/地址),使管理列表跳过环境变量回退
// 为未配置平台合成的空白占位账号。
func configuredAccount(a config.ChannelAccount) bool {
if strings.TrimSpace(a.ID) != "" {
return true
}
for _, v := range a.Config {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return true
}
}
return false
}