fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)
- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner - session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝 - 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效) - 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测) - 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
This commit is contained in:
1 parent
7e7f53bb41
commit
af8591b78a
30 files changed
+1289
-144
No files matched your search
@@ -296,6 +296,12 @@ type ChannelAccount struct {
|
||||
// DenyUsers is a black list of user uids: a direct message from a user in this
|
||||
// list is dropped at ingress.
|
||||
DenyUsers []string `json:"denyUsers,omitempty"`
|
||||
// AdminIDs 是该账号声明的管理员 id 列表(裁决 06.4.2;design/08 §1.1、design/11 §0):
|
||||
// 命中的 uid 才可能进 owner 车道,可声明多个账户。**适配器上报的角色字段不参与判主**
|
||||
// ——群主/群管理员一律是 RoleMember,伪造 role=owner 无效。只有需要的渠道才声明;
|
||||
// weixin-ilink 只允许与绑定微信号私聊,不需要该项。这些 id 会与 owner.yaml 的账号
|
||||
// 白名单取并集,共同构成唯一的判主依据(见 runtimeconfig.AdminIDs)。
|
||||
AdminIDs []string `json:"adminIds,omitempty"`
|
||||
// GroupMsgPerMin caps group-chat inbound messages per minute for this account
|
||||
// (design/12 §6 策略三). Zero follows the protective default (6); a negative
|
||||
// value disables the group rate limit; a positive value pins it.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package runtimeconfig
|
||||
|
||||
// 本文件覆盖裁决 06.4.2 的配置侧形状:channels.json / CHANNELS_JSON 的账号级
|
||||
// `adminIds[]`(现有 allowUsers 之外)能被解析,并被 AdminIDs 汇总成
|
||||
// platform → 管理员 id 列表。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// TestAdminIDsAggregatesPerPlatform 覆盖多账号、重复与空白条目的合并去重。
|
||||
func TestAdminIDsAggregatesPerPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounts := map[string][]config.ChannelAccount{
|
||||
"qq-obv11": {
|
||||
{ID: "conf0", AdminIDs: []string{" 10001 ", "10002"}},
|
||||
{ID: "conf1", AdminIDs: []string{"10002", "", "10003"}},
|
||||
},
|
||||
"telegram": {{ID: "conf0"}},
|
||||
}
|
||||
got := AdminIDs(accounts)
|
||||
|
||||
ids := got["qq-obv11"]
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("qq-obv11 admin ids = %v, want 3 个去重后的 id", ids)
|
||||
}
|
||||
for i, want := range []string{"10001", "10002", "10003"} {
|
||||
if ids[i] != want {
|
||||
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want)
|
||||
}
|
||||
}
|
||||
// 未声明 adminIds 的平台仍出现在结果里(空切片),这样调用方的一次循环就是整体替换:
|
||||
// 该平台上一轮的管理员层会被 SetPlatformAdmins(p, nil) 清掉,热重载不留悬挂管理员。
|
||||
tg, ok := got["telegram"]
|
||||
if !ok {
|
||||
t.Error("未声明 adminIds 的平台也应出现在结果里(空切片),否则热重载无法清掉旧管理员层")
|
||||
}
|
||||
if len(tg) != 0 {
|
||||
t.Errorf("telegram admin ids = %v, want 空", tg)
|
||||
}
|
||||
if len(AdminIDs(nil)) != 0 {
|
||||
t.Error("nil accounts 应返回空映射")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadChannelsParsesAdminIDs 覆盖 channels.json 的账号级 adminIds[] 形状:文件优先
|
||||
// 加载后能被 AdminIDs 读到(证明 schema 真的落到了配置结构上,而不是被静默丢弃)。
|
||||
func TestLoadChannelsParsesAdminIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
raw := map[string][]config.ChannelAccount{
|
||||
"qq-obv11": {{
|
||||
ID: "conf0",
|
||||
Config: map[string]any{"remote_url": "ws://127.0.0.1:3001"},
|
||||
AdminIDs: []string{"10001", "10002"},
|
||||
}},
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
accounts, warn := LoadChannels(path, nil)
|
||||
if warn != nil {
|
||||
t.Fatalf("LoadChannels warn = %v, want nil", warn)
|
||||
}
|
||||
ids := AdminIDs(accounts)["qq-obv11"]
|
||||
if len(ids) != 2 || ids[0] != "10001" || ids[1] != "10002" {
|
||||
t.Fatalf("channels.json 的 adminIds[] 未落到配置结构: %v", ids)
|
||||
}
|
||||
}
|
||||
@@ -269,3 +269,32 @@ func accountBotToken(a config.ChannelAccount) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user