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:
AskaEth committed 2026-10-04 12:07:40 +08:00
1 parent 7e7f53bb41
commit af8591b78a
30 files changed
+1289 -144

No files matched your search

+46 -3
View File
@@ -50,6 +50,12 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
return nil
}
// LiveOwnerProfile 是 ownerRegistrar 对 gateway.liveOwnerProfile 可选扩展的实现(裁决
// 10.4.5):管理面板 PUT /admin/owner-profile 保存后,gateway 需要拿到**同一个**活档案指针
// 做热替换——渠道 bridge 判定 actor 用的就是这个指针,所以热替换无需重启即生效。启动时档案
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
// wireChannels constructs the four IM channel adapters from environment
// configuration, registers them so the admin API can enumerate them, and wires
// them into the channel bridge so a configured adapter connects and routes
@@ -112,7 +118,19 @@ func (a *app) wireChannels() error {
}
bridge.Start()
a.channelBridge = bridge
a.channels = &runtimeChannels{path: channelsPath, fallback: factory.fallback, bridge: bridge, log: a.log}
// 保留具体类型:gateway 只看到 ChannelConfigStore 接口,而启动播种管理员白名单
// 需要 concrete runtimeChannels 的方法。
rc := &runtimeChannels{
path: channelsPath,
fallback: factory.fallback,
bridge: bridge,
owner: a.owner,
log: a.log,
}
// 启动即灌一次每渠道管理员白名单(裁决 06.4.2):不必等面板打开或热重载,
// channels.json 里声明的 adminIds 从第一帧入站消息起就参与判主。
rc.applyAdminIDs(rc.loadAccounts())
a.channels = rc
a.log.Info("channel adapters registered", "count", len(adapters))
return nil
}
@@ -125,12 +143,22 @@ type runtimeChannels struct {
path string
fallback map[string][]config.ChannelAccount
bridge *channelbridge.Bridge
log *slog.Logger
// owner 是渠道 bridge 判 actor 用的**同一个**活档案指针:读配置时把每渠道管理员
// 白名单灌进它的 adminIDs 层(裁决 06.4.2),面板保存后无需重启即生效。
owner *user.OwnerProfile
log *slog.Logger
}
func (r *runtimeChannels) Path() string { return r.path }
func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
accounts := r.loadAccounts()
r.applyAdminIDs(accounts)
return accounts
}
// loadAccounts 只负责「读配置」:文件优先、env 兜底(design/04 §3.1)。
func (r *runtimeChannels) loadAccounts() map[string][]config.ChannelAccount {
accounts, warn := runtimeconfig.LoadChannels(r.path, r.fallback)
if warn != nil {
r.log.Warn("channels.json load fell back to env", "error", warn)
@@ -138,7 +166,22 @@ func (r *runtimeChannels) Load() map[string][]config.ChannelAccount {
return accounts
}
func (r *runtimeChannels) Reload(platform string) error { return r.bridge.Reload(platform) }
// applyAdminIDs 把各平台声明的 adminIds 整体替换进活主人档案(裁决 06.4.2)。整体替换
// 语义保证热重载后已删除的管理员 id 立即失效;owner.yaml 的账号白名单不受影响。
func (r *runtimeChannels) applyAdminIDs(accounts map[string][]config.ChannelAccount) {
if r.owner == nil {
return
}
for platform, ids := range runtimeconfig.AdminIDs(accounts) {
r.owner.SetPlatformAdmins(platform, ids)
}
}
func (r *runtimeChannels) Reload(platform string) error {
// 先灌管理员白名单再重载适配器:面板一次保存(adminIds + 凭据)两条路径同时生效。
r.applyAdminIDs(r.loadAccounts())
return r.bridge.Reload(platform)
}
// reviewFor builds the channel-bridge review model (design/12 §6 策略四), or nil
// when no model adapter is available so the gate falls back to FIFO eviction — the
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// wireTestChatter / wireTestSessions 是构造真 *channelbridge.Bridge 用的最小桩:
// 本文件的用例只关心「白名单播种的顺序」,不跑消息流水线,所以两个接口各给一个空实现。
type wireTestChatter struct{}
func (wireTestChatter) Run(context.Context, string, []model.Message) (*loop.Result, error) {
return &loop.Result{}, nil
}
type wireTestSessions struct{}
func (wireTestSessions) GetOrCreate(context.Context, adapter.UnifiedMessage, *user.OwnerProfile) (session.Session, error) {
return session.Session{}, nil
}
// newTestRuntimeChannels 造一个指向临时 channels.json 的运行时渠道视图,外加一个 Accounts
// 白名单为空的活主人档案——这样断言命中的就只可能是「每渠道管理员层」这一条来源。
func newTestRuntimeChannels(t *testing.T, bridge *channelbridge.Bridge) (*runtimeChannels, *user.OwnerProfile, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "channels.json")
profile := &user.OwnerProfile{ID: "owner"}
rc := &runtimeChannels{path: path, bridge: bridge, owner: profile, log: testLog()}
return rc, profile, path
}
func writeTestChannels(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写 channels.json 失败: %v", err)
}
}
// 裁决 06.4.2:文件里声明的 adminIds 必须经 Load 灌进活主人档案并成为判主依据。
func TestRuntimeChannelsLoadSeedsPlatformAdmins(t *testing.T) {
rc, profile, path := newTestRuntimeChannels(t, nil)
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1"," admin-1 ",""]}]}`)
rc.Load()
if !profile.IsOwner("kook", "admin-1") {
t.Fatal("Load 后 kook:admin-1 应被判为主人(adminIds 未生效)")
}
if profile.IsOwner("kook", "someone-else") {
t.Fatal("未声明的账号不应被判为主人")
}
if profile.IsOwner("qq_obv11", "admin-1") {
t.Fatal("管理员层必须按平台隔离:kook 声明的 id 不应在别的平台生效")
}
}
// 裁决 06.4.2:整体替换语义——热重载后文件里删掉的管理员 id 必须立即失效,不残留悬挂管理员。
func TestRuntimeChannelsLoadReplacesRemovedAdmin(t *testing.T) {
rc, profile, path := newTestRuntimeChannels(t, nil)
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-1","admin-2"]}]}`)
rc.Load()
if !profile.IsOwner("kook", "admin-2") {
t.Fatal("首次 Load 后 kook:admin-2 应被判为主人")
}
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"}}]}`)
rc.Load()
if profile.IsOwner("kook", "admin-2") || profile.IsOwner("kook", "admin-1") {
t.Fatal("重载后已从配置移除的管理员 id 仍然是主人(整体替换语义失效)")
}
}
// 裁决 06.4.2:Reload 必须先把白名单灌进活档案、再委托 bridge 重载适配器——面板一次保存
// (adminIds + 凭据)两条路径同时生效;这里用未注入 factory 的 bridge 证明「顺序」而非「重载成功」。
func TestRuntimeChannelsReloadSeedsAdminsBeforeDelegating(t *testing.T) {
bridge, err := channelbridge.New(channelbridge.Options{
Log: testLog(),
Loop: wireTestChatter{},
Sessions: wireTestSessions{},
})
if err != nil {
t.Fatalf("channelbridge.New: %v", err)
}
rc, profile, path := newTestRuntimeChannels(t, bridge)
writeTestChannels(t, path, `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"adminIds":["admin-9"]}]}`)
if err := rc.Reload("kook"); err != channelbridge.ErrNilFactory {
t.Fatalf("Reload err = %v, 期望 ErrNilFactory(测试前提:bridge 无 factory)", err)
}
if !profile.IsOwner("kook", "admin-9") {
t.Fatal("Reload 应先把 adminIds 灌进活档案再委托 bridge,实际被跳过")
}
}
// 裁决 10.4.5:组合根必须交出渠道 bridge 判主用的**同一个**活档案指针,热替换才真的生效;
// 档案未加载时返回 nil,让 gateway 退回「真实落盘 + 提示重启」而不是假装成功。
func TestOwnerRegistrarLiveOwnerProfile(t *testing.T) {
profile := &user.OwnerProfile{ID: "owner"}
r := &ownerRegistrar{path: filepath.Join(t.TempDir(), "owner.yaml"), owner: profile, log: testLog()}
if got := r.LiveOwnerProfile(); got != profile {
t.Fatalf("LiveOwnerProfile 必须返回同一个指针: got %p want %p", got, profile)
}
var orphan ownerRegistrar
if orphan.LiveOwnerProfile() != nil {
t.Fatal("档案未加载时必须返回 nil")
}
}