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
@@ -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;