Authorize data sync on connected agent registration
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user