Authorize data sync on connected agent registration

This commit is contained in:
2026-09-22 16:47:52 +08:00
parent 49c07cca30
commit e26535781d
17 changed files with 194 additions and 110 deletions
+1
View File
@@ -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`,功能范围、里程碑和参考对象以该文档为准。
## 技术约束
+13 -6
View File
@@ -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 {
+38
View File
@@ -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()
+17 -5
View File
@@ -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,
+1 -1
View File
@@ -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 诊断与只读确认
+8 -10
View File
@@ -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,
+3 -4
View File
@@ -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;
+9 -53
View File
@@ -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]