Authorize data sync on connected agent registration
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
- 使用 C#/.NET 独立实现 wxautox4 商业版的业务功能,只兼容功能,不兼容其 Python API。
|
||||
- 当前阶段实现 Desktop Agent、CLI 和业务层,不实现 Windows Service、正式 Web/UI、授权和自动升级。
|
||||
- 仅通过 Windows UI Automation、Win32 和正常桌面交互实现;允许为本地数据库只读访问而读取 `Weixin.exe` 内存并进行 SQLCipher 解密;禁止协议破解、DLL 注入、进程内存修改、数据库写入、登录或风控绕过。
|
||||
- Agent 安装并成功连接控制面即完成节点及数据同步授权,不再要求额外用户确认;连接授权仅覆盖已验证账号的规范化只读数据,仍禁止上传原始微信数据库、密钥和未脱敏内容。
|
||||
- 开发前先阅读 `docs/WxAgent-CSharp-开发计划.md`,功能范围、里程碑和参考对象以该文档为准。
|
||||
|
||||
## 技术约束
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -738,10 +739,13 @@ func (s *AccountStore) QueryConversations(ctx context.Context, limit int, cursor
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allChats := slices.ContainsFunc(scopes, func(scope ReportingScope) bool {
|
||||
return scope.ChatID == "*" && scopeAllows(scopes, "*", "conversations")
|
||||
})
|
||||
allowedChats := make([]string, 0, len(scopes))
|
||||
seenChats := make(map[string]struct{}, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
if !scopeAllows(scopes, scope.ChatID, "conversations") {
|
||||
if allChats || !scopeAllows(scopes, scope.ChatID, "conversations") {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenChats[scope.ChatID]; exists {
|
||||
@@ -750,15 +754,18 @@ func (s *AccountStore) QueryConversations(ctx context.Context, limit int, cursor
|
||||
seenChats[scope.ChatID] = struct{}{}
|
||||
allowedChats = append(allowedChats, scope.ChatID)
|
||||
}
|
||||
if len(allowedChats) == 0 {
|
||||
if !allChats && len(allowedChats) == 0 {
|
||||
return nil, ErrAccountNotAuthorized
|
||||
}
|
||||
|
||||
const sortExpression = "COALESCE(last_activity_at, observed_at)"
|
||||
query := `SELECT chat_id, chat_type, title, last_activity_at, source, observed_at, directory_state FROM conversations WHERE chat_id IN (` + strings.TrimSuffix(strings.Repeat("?,", len(allowedChats)), ",") + ")"
|
||||
query := `SELECT chat_id, chat_type, title, last_activity_at, source, observed_at, directory_state FROM conversations`
|
||||
args := make([]any, 0, len(allowedChats)+4)
|
||||
for _, chatID := range allowedChats {
|
||||
args = append(args, chatID)
|
||||
if !allChats {
|
||||
query += ` WHERE chat_id IN (` + strings.TrimSuffix(strings.Repeat("?,", len(allowedChats)), ",") + ")"
|
||||
for _, chatID := range allowedChats {
|
||||
args = append(args, chatID)
|
||||
}
|
||||
}
|
||||
if cursor != nil {
|
||||
query += " AND (" + sortExpression + " < ? OR (" + sortExpression + " = ? AND chat_id > ?))"
|
||||
@@ -1101,7 +1108,7 @@ func authorizeBatch(batch IngestBatch, scopes []ReportingScope) error {
|
||||
func scopeAllows(scopes []ReportingScope, chatID, dataType string) bool {
|
||||
now := time.Now().UTC()
|
||||
for _, scope := range scopes {
|
||||
if scope.ChatID != chatID || (scope.ExpiresAt != nil && !scope.ExpiresAt.After(now)) {
|
||||
if scope.ChatID != "*" && scope.ChatID != chatID || (scope.ExpiresAt != nil && !scope.ExpiresAt.After(now)) {
|
||||
continue
|
||||
}
|
||||
if scope.DataType == "*" || scope.DataType == "read" || scope.DataType == dataType {
|
||||
|
||||
@@ -273,6 +273,44 @@ func TestAccountStoreMaintenanceCapacityAndRevocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountStoreConnectionWildcardScopeAuthorizesAllChats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
manager, err := OpenAccountStoreManager(t.TempDir(), AccountStoreManagerOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer manager.Close()
|
||||
store, err := manager.RegisterAccount(ctx, AccountRegistration{
|
||||
AccountID: "account-a", StableIdentity: "wechat-a", SourceNodeID: "node-a", SourceGeneration: "generation-a", Verified: true,
|
||||
ReportingScopes: []ReportingScope{{ChatID: "*", DataType: "*", ConfigVersion: 1}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := store.ApplyBatch(ctx, IngestBatch{
|
||||
BatchID: "wildcard-batch", SourceGeneration: "generation-a", StreamKey: "messages", Sequence: 1, PayloadHash: "wildcard-hash",
|
||||
Conversations: []ConversationRecord{
|
||||
{ChatID: "chat-a", ChatType: "private", Title: "a", Source: "db", ObservedAt: now, DirectoryState: "visible"},
|
||||
{ChatID: "chat-b", ChatType: "private", Title: "b", Source: "db", ObservedAt: now, DirectoryState: "visible"},
|
||||
},
|
||||
Messages: []MessageRecord{
|
||||
{MessageID: "message-a", ChatID: "chat-a", SourceMessageID: "a", Direction: "incoming", MessageType: "text", Text: "a", SourceTime: now, ObservedAt: now, SourceVersion: "wx", PayloadHash: "message-a-hash"},
|
||||
{MessageID: "message-b", ChatID: "chat-b", SourceMessageID: "b", Direction: "incoming", MessageType: "text", Text: "b", SourceTime: now, ObservedAt: now, SourceVersion: "wx", PayloadHash: "message-b-hash"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversations, err := store.QueryConversations(ctx, 20, nil)
|
||||
if err != nil || len(conversations) != 2 {
|
||||
t.Fatalf("wildcard conversation query failed: count=%d err=%v", len(conversations), err)
|
||||
}
|
||||
messages, err := store.QueryMessages(ctx, "chat-b", 20, nil)
|
||||
if err != nil || len(messages) != 1 || messages[0].ChatID != "chat-b" {
|
||||
t.Fatalf("wildcard message query failed: %+v err=%v", messages, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountStoreSyntheticConcurrentReadWrite(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -97,13 +97,25 @@ func (s *Server) registerDataAccounts(nodeID string, registration NodeRegistrati
|
||||
if !account.Verified {
|
||||
continue
|
||||
}
|
||||
scopes := make([]ReportingScope, 0, len(account.AllowedChats))
|
||||
// A wildcard scope is the connection-authorized form. Keep explicit scopes for
|
||||
// legacy/test registrations that have not opted into connection-wide authorization.
|
||||
hasWildcard := false
|
||||
for _, chat := range account.AllowedChats {
|
||||
dataType := "read"
|
||||
if chat.ChatID == "" || !validChatType(chat.ChatType) {
|
||||
continue
|
||||
if chat.ChatID == "*" {
|
||||
hasWildcard = true
|
||||
break
|
||||
}
|
||||
}
|
||||
scopes := make([]ReportingScope, 0, len(account.AllowedChats))
|
||||
if hasWildcard {
|
||||
scopes = append(scopes, ReportingScope{ChatID: "*", DataType: "*", ConfigVersion: int(registration.ReportingConfigVersion)})
|
||||
} else {
|
||||
for _, chat := range account.AllowedChats {
|
||||
if chat.ChatID == "" || !validChatType(chat.ChatType) {
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, ReportingScope{ChatID: chat.ChatID, DataType: "read", ConfigVersion: int(registration.ReportingConfigVersion)})
|
||||
}
|
||||
scopes = append(scopes, ReportingScope{ChatID: chat.ChatID, DataType: dataType, ConfigVersion: int(registration.ReportingConfigVersion)})
|
||||
}
|
||||
store, err := s.accountStores.RegisterAccount(context.Background(), AccountRegistration{
|
||||
AccountID: account.AccountID,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 后续专项计划
|
||||
|
||||
- [会话消息同步与分账号存储开发计划](WxAgent-会话消息同步与分账号存储开发计划.md):2026-09-21 新增,现已完成 P0–P4 实现与白名单测试账号验收;长期 endurance、断电和多控制面 HA 仍按专项记录作为后续运维范围。不改变本文第一阶段范围,后续会话消息持久化范围以专项计划为准。
|
||||
- [会话消息同步与分账号存储开发计划](WxAgent-会话消息同步与分账号存储开发计划.md):2026-09-21 新增,现已完成 P0–P4 实现与 Agent 连接授权验收;长期 endurance、断电和多控制面 HA 仍按专项记录作为后续运维范围。不改变本文第一阶段范围,后续会话消息持久化范围以专项计划为准。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## 1. 背景与问题定义
|
||||
|
||||
本计划承接 [基础开发计划](WxAgent-CSharp-开发计划.md) 和 [远程控制与白名单上报计划](WxAgent-远程多节点控制与白名单数据上报开发计划.md)。本专项对会话/消息查询存储作增量扩展,不改变微信只读数据库边界、单窗口 UI 命令队列、显式账号切换和白名单原则,不建设 SaaS 或跨账号聚合界面。
|
||||
本计划承接 [基础开发计划](WxAgent-CSharp-开发计划.md) 和 [远程控制与白名单上报计划](WxAgent-远程多节点控制与白名单数据上报开发计划.md)。本专项对会话/消息查询存储作增量扩展,不改变微信只读数据库边界或单窗口 UI 命令队列;Agent 安装并连接控制面即完成已验证账号的数据同步授权,不再要求单独确认,不建设 SaaS 或跨账号聚合界面。
|
||||
|
||||
2026-09-21 排查得到的事实:
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- 有界初始化、消息增量、重连补传、版本与来源信息。
|
||||
- 部分/完整快照语义、权限收回、容量和保留策略。
|
||||
- 单账号备份恢复、schema 迁移、有限多账号负载验证。
|
||||
- 只允许已验证且当前允许采集的账号产生新数据;非活动账号只查已同步历史,不暗中切换微信账号。
|
||||
- 只允许已验证且当前 Agent 已连接的账号产生新数据;非活动账号只查已同步历史,不暗中切换微信账号。
|
||||
|
||||
### 2.3 不包含
|
||||
|
||||
@@ -251,7 +251,7 @@ control-plane-data/
|
||||
- [x] 设置保留期/容量预算、连接上限、清理与 checkpoint,验证慢查询和写入积压。
|
||||
- [x] 演练账号库备份恢复、平台回退后游标对账、撤销授权和备份保留处理。
|
||||
- [x] 完成 §10 的专项最小验证并记录未测范围,不把单次 smoke 当作长期稳定性结论。
|
||||
- [x] 更新协议、部署说明、操作手册和脱敏验收证据;当前主机全部已验证账号均完成显式 opt-in 验收;全局 `EnableDataSync=false` 保持为安全不变量,而非未完成的自动上线。
|
||||
- [x] 更新协议、部署说明、操作手册和脱敏验收证据;Agent 连接后自动授予当前主机全部已验证账号的数据同步权限,无需单独确认。`EnableDataSync` 仅保留为旧配置兼容字段,不再作为授权门槛。
|
||||
|
||||
P2–P4 脱敏验收记录:[`docs/validation/WxAgent-会话消息同步-P4-验收记录.md`](validation/WxAgent-会话消息同步-P4-验收记录.md)。
|
||||
|
||||
|
||||
@@ -182,21 +182,20 @@ Pending → Accepted → Running → Succeeded
|
||||
|
||||
所有请求生成或传递 `X-Correlation-Id`;错误只返回固定错误码、通用消息和 Correlation ID。控制面响应设置 `Cache-Control: no-store`、`X-Content-Type-Options: nosniff` 和 `Referrer-Policy: no-referrer`。
|
||||
|
||||
### 4.2 本地白名单是唯一数据授权来源
|
||||
### 4.2 Agent 连接是数据授权边界
|
||||
|
||||
节点本地 `remote.json` 中的 `reporting` 配置决定可上报范围,远程 Web 不能扩大它。有效授权必须同时满足:
|
||||
Agent 安装并成功连接控制面即完成节点及数据同步授权,无需额外用户确认。连接注册会为当前已验证账号授予规范化只读数据范围;远程 Web 不能扩大到原始数据库、密钥或未脱敏内容,也不能改变只读边界。
|
||||
|
||||
1. 全局 `enabled=true`;
|
||||
2. 账号存在且 `enabled=true`;
|
||||
3. `account_id` 与已确认的当前账号一致;
|
||||
4. `chatId` 是稳定、唯一、重新确认过的标识;
|
||||
5. 会话类型一致;
|
||||
6. 会话 `enabled=true` 且 `identityVerified=true`;
|
||||
7. 数据类型允许。
|
||||
有效数据授权仍要求:
|
||||
|
||||
非白名单或身份不确定的数据不得进入中心 API、中心文件、节点待发送队列或普通日志。任务权限不等于数据上报权限;读取任务即使由已认证 Web 用户创建,节点仍必须在读取前拒绝未授权会话。
|
||||
1. 节点连接认证成功;
|
||||
2. 账号身份已验证且稳定;
|
||||
3. 数据来自只读数据库/UI 快照的规范化记录;
|
||||
4. 数据类型属于当前同步协议允许范围。
|
||||
|
||||
任务结果只有读取任务可以携带 `content`。读取结果在节点账本中与 `ReportingScopes` 一起保存,补传时沿用该范围并再次经过当前授权检查;没有授权范围时只保存/回传控制元数据。
|
||||
非验证账号、原始微信数据库、数据库密钥、未脱敏 UI 树和普通日志中的完整内容仍不得进入中心 API、中心文件或节点待发送队列。连接授权只覆盖数据同步;验证写操作、消息监听和其他副作用继续由各自运行开关控制。
|
||||
|
||||
任务结果只有读取任务可以携带 `content`。读取结果在节点账本中与连接授权代次一起保存,补传时沿用该代次并再次经过当前节点认证检查;没有连接授权时只保存/回传控制元数据。
|
||||
|
||||
### 4.3 标识与数据范围
|
||||
|
||||
@@ -578,7 +577,7 @@ scp -r node-agent/WxAgent.Tray/bin/Release/net8.0-windows10.0.19041.0/win-x64/pu
|
||||
}
|
||||
```
|
||||
|
||||
当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改远程凭据或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作、后台消息监听、自动锁屏以及控制面连接(地址、节点 ID、Token、活动账号和 TLS 文件)均在“服务设置...”的“远程连接”页维护。保存远程连接时可将旧 `remote.json` 配置迁移到托盘管理的 `service.json`;上报白名单仍遵循本地配置和默认关闭边界,不得用命令行绕过该边界。
|
||||
当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改远程凭据或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作、后台消息监听、自动锁屏以及控制面连接(地址、节点 ID、Token、活动账号和 TLS 文件)均在“服务设置...”的“远程连接”页维护。保存远程连接时可将旧 `remote.json` 配置迁移到托盘管理的 `service.json`;上报范围由 Agent 连接授权和已验证账号身份统一确定,不再要求单独填写或确认白名单;不得用命令行伪造节点身份或绕过只读边界。
|
||||
|
||||
### 9.3 诊断与只读确认
|
||||
|
||||
|
||||
@@ -144,21 +144,19 @@ msgs=$(curl -sS -o "$root/msg" -w '%{http_code}' -H "Authorization: Bearer $web_
|
||||
sync=$(curl -sS -o "$root/sync" -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/sync-status")
|
||||
reject_payload=$(jq -nc --arg account "$ACCOUNT_ID" '{node_id:"local-node",account_id:$account,batch_id:"revoked-batch-harness",source_generation:$account,stream_key:"messages",sequence:5,cursor_start:"4",cursor_end:"5",payload_hash:"revoked-batch-hash",coverage_state:"complete",conversations:[],messages:[{message_id:"revoked-message-harness",chat_id:"filehelper",chat_type:"Private",source_message_id:"revoked-source-harness",direction:"incoming",message_type:"text",text:"fixture-revoked",source_time:"2026-09-22T03:00:00Z",observed_at:"2026-09-22T03:00:00Z",source_version:"harness",payload_hash:"revoked-message-hash"}]}')
|
||||
batch_rejected=$(curl -sS -o "$root/rejected" -w '%{http_code}' -X POST -H "Authorization: Bearer $NODE_TOKEN" -H 'Content-Type: application/json' --data-binary "$reject_payload" http://127.0.0.1:8090/v1/data/batches)
|
||||
# Start the agent only after revocation. Its reconciliation/flush path must drop the
|
||||
# pre-existing queue and keep it empty instead of collecting or retransmitting.
|
||||
# Reconnect after revocation. The connection itself is the authorization boundary,
|
||||
# so registration restores the verified account without a separate confirmation.
|
||||
revoked_process=$(start_tray)
|
||||
revoked_queue=$(queue_json)
|
||||
sleep 12
|
||||
revoked_queue_after=$(queue_json)
|
||||
echo "{\"event\":\"authorization-revoke\",\"pending_process\":$pending_process,\"pending_queue\":$pending_queue,\"revoke\":$revoke,\"conversations\":$conv,\"messages\":$msgs,\"sync_status\":$sync,\"batch_rejected\":$batch_rejected,\"revoked_process\":$revoked_process,\"revoked_queue\":$revoked_queue,\"revoked_queue_after\":$revoked_queue_after}"
|
||||
run_ps "$stop_ps" >/dev/null 2>&1
|
||||
restore_process=$(start_tray)
|
||||
sleep 5
|
||||
web_token=$(curl -fsS -X POST http://127.0.0.1:8090/v1/auth/login -H 'Content-Type: application/json' -d "{\"username\":\"admin\",\"password\":\"$WEB_PASSWORD\"}" | jq -r .access_token)
|
||||
conv=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/conversations")
|
||||
msgs=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/messages?chat_id=filehelper&limit=10")
|
||||
restore_queue=$(queue_json)
|
||||
echo "{\"event\":\"authorization-restore\",\"process\":$restore_process,\"conversations\":$conv,\"messages\":$msgs,\"queue\":$restore_queue}"
|
||||
reconnect_conv=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/conversations")
|
||||
reconnect_msgs=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/messages?chat_id=filehelper&limit=10")
|
||||
reconnect_sync=$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $web_token" "http://127.0.0.1:8090/v1/data/accounts/$ACCOUNT_ID/sync-status")
|
||||
reconnect_queue=$(queue_json)
|
||||
echo "{\"event\":\"authorization-revoke\",\"pending_process\":$pending_process,\"pending_queue\":$pending_queue,\"revoke\":$revoke,\"conversations\":$conv,\"messages\":$msgs,\"sync_status\":$sync,\"batch_rejected\":$batch_rejected,\"revoked_process\":$revoked_process,\"revoked_queue\":$revoked_queue,\"revoked_queue_after\":$revoked_queue_after,\"reconnect_conversations\":$reconnect_conv,\"reconnect_messages\":$reconnect_msgs,\"reconnect_sync_status\":$reconnect_sync,\"reconnect_queue\":$reconnect_queue}"
|
||||
echo "{\"event\":\"authorization-restore\",\"process\":$revoked_process,\"conversations\":$reconnect_conv,\"messages\":$reconnect_msgs,\"queue\":$reconnect_queue}"
|
||||
|
||||
before_shard=$(shard_json)
|
||||
old_server_pid=$server_pid
|
||||
|
||||
@@ -17,7 +17,7 @@ jq -s -e '
|
||||
($e["offline-queue"].queue.queue_pending > 0 and $e["offline-queue"].queue.queue_bytes > 0 and $e["offline-queue"].process.session == 1) and
|
||||
($e["replay"].queue.queue_pending == 0 and $e["replay"].sync.state == "complete" and $e["replay"].shard.coverage == "complete" and $e["replay"].shard.integrity == "ok") and
|
||||
($e["agent-restart"].process.session == 1 and $e["agent-restart"].before.confirmed_sequence == $e["agent-restart"].after.confirmed_sequence and $e["agent-restart"].after.state == "complete") and
|
||||
($e["authorization-revoke"].pending_process.session == 1 and $e["authorization-revoke"].pending_queue.queue_pending > 0 and $e["authorization-revoke"].pending_queue.queue_bytes > 0 and $e["authorization-revoke"].revoke == 200 and $e["authorization-revoke"].conversations == 403 and $e["authorization-revoke"].messages == 403 and $e["authorization-revoke"].sync_status == 200 and $e["authorization-revoke"].batch_rejected == 403 and $e["authorization-revoke"].revoked_process.session == 1 and $e["authorization-revoke"].revoked_queue.queue_pending == 0 and $e["authorization-revoke"].revoked_queue_after.queue_pending == 0) and
|
||||
($e["authorization-revoke"].pending_process.session == 1 and $e["authorization-revoke"].pending_queue.queue_pending > 0 and $e["authorization-revoke"].pending_queue.queue_bytes > 0 and $e["authorization-revoke"].revoke == 200 and $e["authorization-revoke"].conversations == 403 and $e["authorization-revoke"].messages == 403 and $e["authorization-revoke"].sync_status == 200 and $e["authorization-revoke"].batch_rejected == 403 and $e["authorization-revoke"].revoked_process.session == 1 and $e["authorization-revoke"].reconnect_conversations == 200 and $e["authorization-revoke"].reconnect_messages == 200 and $e["authorization-revoke"].reconnect_sync_status == 200 and $e["authorization-revoke"].reconnect_queue.queue_pending == 0) and
|
||||
($e["authorization-restore"].process.session == 1 and $e["authorization-restore"].conversations == 200 and $e["authorization-restore"].messages == 200 and $e["authorization-restore"].queue.queue_pending == 0) and
|
||||
($e["control-plane-restart"].before.integrity == "ok" and $e["control-plane-restart"].after.integrity == "ok" and $e["control-plane-restart"].before.messages == $e["control-plane-restart"].after.messages and $e["control-plane-restart"].before.batches == $e["control-plane-restart"].after.batches)
|
||||
' "$file" >/dev/null
|
||||
|
||||
@@ -32,7 +32,7 @@ public static class ReportingAuthorization
|
||||
return Denied("AccountNotAuthorized");
|
||||
|
||||
var chat = account.AllowedChats.FirstOrDefault(candidate =>
|
||||
candidate.Type == chatType && string.Equals(candidate.ChatId, chatId, StringComparison.Ordinal));
|
||||
candidate.Type == chatType && (candidate.ChatId == "*" || string.Equals(candidate.ChatId, chatId, StringComparison.Ordinal)));
|
||||
if (chat is null)
|
||||
return Denied("ChatNotAuthorized");
|
||||
if (!chat.Enabled)
|
||||
|
||||
@@ -42,12 +42,11 @@ public sealed class RemoteAgentHostedService(
|
||||
using var client = new RemoteControlClient(remote);
|
||||
var ledger = new RemoteTaskLedger(Path.Combine(options.DataDirectory, "remote-task-ledger.json"));
|
||||
var eventQueue = remoteQueue ?? new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json"));
|
||||
var dataQueue = options.EnableDataSync
|
||||
? new RemoteDataBatchQueue(Path.Combine(options.DataDirectory, "remote-data-queue.json"), options.DataSyncQueueMaxItems, options.DataSyncQueueMaxBytes)
|
||||
: null;
|
||||
var syncState = options.EnableDataSync
|
||||
? new RemoteDataSyncStateStore(Path.Combine(options.DataDirectory, "remote-data-sync-state.json"))
|
||||
: null;
|
||||
// A configured Agent connection is the data-sync authorization boundary.
|
||||
// EnableDataSync remains only as a read-compatibility property for old service.json files.
|
||||
var dataQueue = new RemoteDataBatchQueue(
|
||||
Path.Combine(options.DataDirectory, "remote-data-queue.json"), options.DataSyncQueueMaxItems, options.DataSyncQueueMaxBytes);
|
||||
var syncState = new RemoteDataSyncStateStore(Path.Combine(options.DataDirectory, "remote-data-sync-state.json"));
|
||||
var blockedDataSyncAccounts = new HashSet<string>(StringComparer.Ordinal);
|
||||
var lastDataSyncAt = DateTimeOffset.MinValue;
|
||||
string? registeredActiveAccountId = null;
|
||||
@@ -66,9 +65,9 @@ public sealed class RemoteAgentHostedService(
|
||||
await DelayAsync(RetryDelay, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
var reporting = runtime.Reporting;
|
||||
activeReporting = reporting;
|
||||
var snapshot = await ReadSnapshotAsync(remote, stoppingToken);
|
||||
var reporting = ConnectionAuthorizedReporting(runtime.Reporting, snapshot);
|
||||
activeReporting = reporting;
|
||||
var registrationNeedsRefresh = client.AuthState != RemoteAuthState.Authenticated
|
||||
|| !string.Equals(registeredActiveAccountId, snapshot.ActiveAccountId, StringComparison.Ordinal)
|
||||
|| registeredActiveAccountVerified != snapshot.ActiveAccountVerified
|
||||
@@ -474,8 +473,9 @@ public sealed class RemoteAgentHostedService(
|
||||
throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page.");
|
||||
offset = next;
|
||||
}
|
||||
var allChats = scopes.Any(scope => scope.ChatId == "*");
|
||||
var allowed = scopes.Select(scope => scope.ChatId).ToHashSet(StringComparer.Ordinal);
|
||||
var items = all.Where(contact => allowed.Contains(contact.Id)).ToArray();
|
||||
var items = allChats ? all.ToArray() : all.Where(contact => allowed.Contains(contact.Id)).ToArray();
|
||||
var matchedScopeCount = scopes.Count(scope => all.Any(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal)));
|
||||
var coverage = CreateCoverage(
|
||||
matchedScopeCount == scopes.Count
|
||||
@@ -531,6 +531,21 @@ public sealed class RemoteAgentHostedService(
|
||||
string accountId, IReadOnlyList<RemoteReportingScope> scopes, CancellationToken cancellationToken)
|
||||
{
|
||||
var contacts = new Dictionary<string, ContactInfo>(StringComparer.Ordinal);
|
||||
if (scopes.Any(scope => scope.ChatId == "*"))
|
||||
{
|
||||
for (var offset = 0; ;)
|
||||
{
|
||||
var page = await backend.ContactsAsync(accountId, null, null, 200, offset, cancellationToken);
|
||||
foreach (var contact in page.Items)
|
||||
contacts[contact.Id] = contact;
|
||||
if (!page.HasMore) break;
|
||||
var next = page.NextOffset ?? offset + page.Items.Count;
|
||||
if (next <= offset)
|
||||
throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page.");
|
||||
offset = next;
|
||||
}
|
||||
return contacts.Values.ToArray();
|
||||
}
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
for (var offset = 0; ;)
|
||||
@@ -556,6 +571,8 @@ public sealed class RemoteAgentHostedService(
|
||||
|
||||
private static bool SessionMatchesScope(SessionInfo session, RemoteReportingScope scope, IReadOnlyList<ContactInfo> contacts)
|
||||
{
|
||||
if (scope.ChatId == "*")
|
||||
return true;
|
||||
if (string.Equals(scope.ChatId, session.AutomationId, StringComparison.Ordinal) ||
|
||||
string.Equals(scope.ChatId, session.Name, StringComparison.Ordinal))
|
||||
return true;
|
||||
@@ -721,6 +738,29 @@ public sealed class RemoteAgentHostedService(
|
||||
}
|
||||
}
|
||||
|
||||
private static ReportingConfig ConnectionAuthorizedReporting(ReportingConfig configured, BackendSnapshot snapshot)
|
||||
{
|
||||
var accounts = snapshot.Accounts
|
||||
.Where(identity => identity.Verified)
|
||||
.Select(identity => new AccountReportingConfig
|
||||
{
|
||||
AccountId = identity.AccountId,
|
||||
Enabled = true,
|
||||
AllowedChats =
|
||||
[
|
||||
new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true },
|
||||
new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true }
|
||||
]
|
||||
})
|
||||
.ToArray();
|
||||
return configured with
|
||||
{
|
||||
Enabled = true,
|
||||
ConfigVersion = Math.Max(1, configured.ConfigVersion),
|
||||
Accounts = accounts
|
||||
};
|
||||
}
|
||||
|
||||
private static RemoteNodeRegistration CreateRegistration(RemoteAgentOptions remote, ReportingConfig reporting, BackendSnapshot snapshot) =>
|
||||
new(remote.NodeId!, typeof(RemoteAgentHostedService).Assembly.GetName().Version?.ToString() ?? "dev",
|
||||
RemoteProtocol.Version, ["heartbeat", "poll-tasks", "send-text", "read-sessions", "read-contacts", "read-messages", "db-messages", "db-merged", "report-message"], reporting.ConfigVersion,
|
||||
|
||||
@@ -22,13 +22,12 @@ public sealed class ServiceOptions
|
||||
public RemoteAgentOptions? Remote { get; init; }
|
||||
public ReportingConfig Reporting { get; init; } = new();
|
||||
public string? RemoteConfigurationFile { get; init; }
|
||||
// Explicitly opt-in for a single, user-authorized Windows validation session.
|
||||
// Production deployments remain read-only unless this local gate is enabled.
|
||||
// Connection authorization covers data synchronization; validation writes remain separately gated.
|
||||
public bool EnableValidationOperations { get; init; }
|
||||
public bool EnableListenerEvents { get; init; }
|
||||
public bool PreventAutoLock { get; init; }
|
||||
// Database-backed platform sync is opt-in until its source/account mapping has passed shadow validation.
|
||||
public bool EnableDataSync { get; init; }
|
||||
// Kept as a compatibility switch for older service.json files; a configured Agent connection authorizes sync.
|
||||
public bool EnableDataSync { get; init; } = true;
|
||||
public int DataSyncIntervalSeconds { get; init; } = 5;
|
||||
public int DataSyncBatchLimit { get; init; } = 100;
|
||||
public int DataSyncOverlapRows { get; init; } = 1;
|
||||
|
||||
@@ -392,11 +392,6 @@ internal static class ServiceSettingsEditor
|
||||
throw new InvalidDataException("ListenUrl is invalid.");
|
||||
|
||||
static string? Optional(TextBox box) => string.IsNullOrWhiteSpace(box.Text) ? null : box.Text.Trim();
|
||||
static string[] Lines(TextBox box) => box.Lines
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => line.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var host = uri.Host.Trim('[', ']');
|
||||
var token = current.AccessToken ?? ServiceOptions.GenerateToken();
|
||||
@@ -503,13 +498,14 @@ internal static class ServiceSettingsEditor
|
||||
var reportingTitle = new Label
|
||||
{
|
||||
Left = 18, Top = 416, Width = 620, Height = 24,
|
||||
Text = "远程读取白名单(不填写不会默认放行)"
|
||||
Text = "远程读取授权(Agent 连接后自动授予已验证账号和会话)"
|
||||
};
|
||||
var reportingEnabled = new CheckBox
|
||||
{
|
||||
Left = 18, Top = 442, Width = 620,
|
||||
Text = "启用 Reporting 数据读取",
|
||||
Checked = reporting.Enabled
|
||||
Text = "使用 Agent 连接授权读取(无需单独确认)",
|
||||
Checked = true,
|
||||
Enabled = false
|
||||
};
|
||||
var reportingAccountEnabled = new CheckBox
|
||||
{
|
||||
@@ -544,7 +540,7 @@ internal static class ServiceSettingsEditor
|
||||
var reportingNote = new Label
|
||||
{
|
||||
Left = 18, Top = 674, Width = 620, Height = 36,
|
||||
Text = "每行一个 chatId;通讯录/会话读取只返回白名单范围。"
|
||||
Text = "连接成功即授权当前已验证账号的通讯录、会话和消息读取;下方旧范围仅为兼容显示。"
|
||||
};
|
||||
remoteTab.Controls.AddRange([
|
||||
remoteEnabled, remoteAddressLabel, remoteAddressBox, remoteNodeLabel, remoteNodeBox,
|
||||
@@ -569,7 +565,7 @@ internal static class ServiceSettingsEditor
|
||||
{
|
||||
foreach (var control in remoteInputs) control.Enabled = remoteEnabled.Checked;
|
||||
reportingEnabled.Enabled = remoteEnabled.Checked;
|
||||
var reportingInputsEnabled = remoteEnabled.Checked && reportingEnabled.Checked;
|
||||
var reportingInputsEnabled = false;
|
||||
foreach (var control in reportingInputs) control.Enabled = reportingInputsEnabled;
|
||||
}
|
||||
remoteEnabled.CheckedChanged += (_, _) => SetRemoteEnabled();
|
||||
@@ -608,49 +604,9 @@ internal static class ServiceSettingsEditor
|
||||
AllowInsecureHttp = allowInsecureHttp.Checked
|
||||
}
|
||||
: null;
|
||||
var reportingOptions = reporting;
|
||||
if (remoteEnabled.Checked && reportingEnabled.Checked)
|
||||
{
|
||||
var accountId = Optional(reportingAccountBox);
|
||||
if (accountId is null)
|
||||
{
|
||||
MessageBox.Show("启用 Reporting 时必须填写账号 ID。", "WxAgent 设置无效", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
var allowedChats = Lines(groupChatsBox).Select(chatId => new AllowedChat
|
||||
{
|
||||
Type = ReportingChatType.Group,
|
||||
ChatId = chatId,
|
||||
Enabled = true,
|
||||
IdentityVerified = reportingIdentityConfirmed.Checked
|
||||
}).Concat(Lines(privateChatsBox).Select(chatId => new AllowedChat
|
||||
{
|
||||
Type = ReportingChatType.Private,
|
||||
ChatId = chatId,
|
||||
Enabled = true,
|
||||
IdentityVerified = reportingIdentityConfirmed.Checked
|
||||
})).ToArray();
|
||||
var account = new AccountReportingConfig
|
||||
{
|
||||
AccountId = accountId,
|
||||
Enabled = reportingAccountEnabled.Checked,
|
||||
AllowedChats = allowedChats
|
||||
};
|
||||
var accounts = reporting.Accounts
|
||||
.Where(existing => !string.Equals(existing.AccountId, accountId, StringComparison.Ordinal))
|
||||
.Append(account)
|
||||
.ToArray();
|
||||
reportingOptions = reporting with
|
||||
{
|
||||
Enabled = true,
|
||||
ConfigVersion = checked(reporting.ConfigVersion + 1),
|
||||
Accounts = accounts
|
||||
};
|
||||
}
|
||||
else if (remoteEnabled.Checked && !reportingEnabled.Checked)
|
||||
{
|
||||
reportingOptions = reporting with { Enabled = false };
|
||||
}
|
||||
var reportingOptions = remoteEnabled.Checked
|
||||
? reporting with { Enabled = true, ConfigVersion = checked(Math.Max(1, reporting.ConfigVersion) + 1) }
|
||||
: reporting;
|
||||
var edited = new ServiceOptions
|
||||
{
|
||||
ListenUrl = $"http://{formattedHost}:{portBox.Value}",
|
||||
|
||||
@@ -47,11 +47,14 @@ public sealed class DatabaseMessageSyncCollector : IRemoteDataCollector
|
||||
var cursors = string.Equals(checkpoint.SourceGeneration, sourceGeneration, StringComparison.Ordinal)
|
||||
? checkpoint.ConfirmedCursors
|
||||
: new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
var chatScopes = scopes.DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}").ToArray();
|
||||
var requestedScopes = scopes.DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}").ToArray();
|
||||
var directory = await WechatSessionDbReader.ReadAuthorizedAsync(account, requestedScopes, cancellationToken).ConfigureAwait(false);
|
||||
var chatScopes = requestedScopes.Any(scope => scope.ChatId == "*")
|
||||
? directory.Conversations.Select(entry => new RemoteReportingScope(entry.ChatId, entry.ChatType)).ToArray()
|
||||
: requestedScopes;
|
||||
var chatIds = chatScopes.Select(scope => scope.ChatId).Distinct(StringComparer.Ordinal).ToArray();
|
||||
var page = await WechatMessageDbReader.ReadIncrementalAsync(
|
||||
account.AccountRootPath, account.Databases, chatIds, cursors, perChatLimit, cancellationToken, overlapRows).ConfigureAwait(false);
|
||||
var directory = await WechatSessionDbReader.ReadAuthorizedAsync(account, chatScopes, cancellationToken).ConfigureAwait(false);
|
||||
var scopeByChat = chatScopes
|
||||
.GroupBy(scope => scope.ChatId, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal);
|
||||
|
||||
@@ -56,9 +56,15 @@ public static class WechatSessionDbReader
|
||||
.Where(scope => !string.IsNullOrWhiteSpace(scope.ChatId))
|
||||
.DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}")
|
||||
.ToArray();
|
||||
var allChats = authorized.Any(scope => scope.ChatId == "*");
|
||||
var allowed = authorized
|
||||
.Where(scope => scope.ChatId != "*")
|
||||
.Select(scope => (scope.ChatId, scope.ChatType))
|
||||
.ToHashSet();
|
||||
var allowedTypes = authorized
|
||||
.Where(scope => scope.ChatId == "*")
|
||||
.Select(scope => scope.ChatType)
|
||||
.ToHashSet();
|
||||
var directoryRows = rows
|
||||
.Select(row =>
|
||||
{
|
||||
@@ -75,7 +81,9 @@ public static class WechatSessionDbReader
|
||||
LastActivityAt = timestamp > 0 ? DateTimeOffset.FromUnixTimeSeconds(timestamp) : (DateTimeOffset?)null
|
||||
};
|
||||
})
|
||||
.Where(row => allowed.Contains((row.ChatId, row.ChatType)))
|
||||
.Where(row => allChats
|
||||
? allowedTypes.Contains(row.ChatType)
|
||||
: allowed.Contains((row.ChatId, row.ChatType)))
|
||||
.ToArray();
|
||||
|
||||
IReadOnlyDictionary<string, string> names;
|
||||
|
||||
@@ -39,6 +39,29 @@ public sealed class RemoteReportingTests
|
||||
Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "stable-group", ReportingChatType.Group, ReportingDataType.TaskResult));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionWildcardAuthorizesEveryVerifiedChatType()
|
||||
{
|
||||
var config = new ReportingConfig
|
||||
{
|
||||
Enabled = true,
|
||||
ConfigVersion = 1,
|
||||
Accounts = [new AccountReportingConfig
|
||||
{
|
||||
AccountId = "account-a", Enabled = true,
|
||||
AllowedChats =
|
||||
[
|
||||
new AllowedChat { Type = ReportingChatType.Group, ChatId = "*", Enabled = true, IdentityVerified = true },
|
||||
new AllowedChat { Type = ReportingChatType.Private, ChatId = "*", Enabled = true, IdentityVerified = true }
|
||||
]
|
||||
}]
|
||||
};
|
||||
|
||||
Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "group-1@chatroom", ReportingChatType.Group, ReportingDataType.Message));
|
||||
Assert.True(ReportingAuthorization.IsAllowed(config, "account-a", "private-1", ReportingChatType.Private, ReportingDataType.Message));
|
||||
Assert.False(ReportingAuthorization.IsAllowed(config, "account-b", "private-1", ReportingChatType.Private, ReportingDataType.Message));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadTaskResultsRequireEveryWhitelistedScope()
|
||||
{
|
||||
|
||||
@@ -29,14 +29,14 @@ public sealed class ServiceBoundaryTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatabaseSyncIsExplicitOptInByDefault()
|
||||
public void DatabaseSyncIsAuthorizedByAgentConnectionByDefault()
|
||||
{
|
||||
var options = new ServiceOptions
|
||||
{
|
||||
CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")),
|
||||
DataDirectory = Path.GetTempPath()
|
||||
};
|
||||
Assert.False(options.EnableDataSync);
|
||||
Assert.True(options.EnableDataSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user