feat: add remote control plane and whitelist reads
Build web service image / build (push) Successful in 1m53s
Build web service image / build (push) Successful in 1m53s
This commit is contained in:
@@ -47,6 +47,11 @@ try
|
||||
|
||||
switch (args[0])
|
||||
{
|
||||
case "remote":
|
||||
{
|
||||
WriteJson(await RemoteCliCommands.RunAsync(args, cancellationToken));
|
||||
return 0;
|
||||
}
|
||||
case "doctor":
|
||||
{
|
||||
var report = WechatDoctor.Run(cancellationToken);
|
||||
@@ -713,6 +718,9 @@ static string GetRuntimeLogPath(string[] values)
|
||||
|
||||
static int ValidateCommandLine(string[] values)
|
||||
{
|
||||
if (values[0] == "remote")
|
||||
return 60;
|
||||
|
||||
if (values[0] == "doctor")
|
||||
{
|
||||
ValidateOptions(values, 1, ["--timeout"], []);
|
||||
@@ -1024,6 +1032,10 @@ static int CountNodes(UiNodeSnapshot node) => 1 + node.Children.Sum(CountNodes);
|
||||
static void PrintHelp() => Console.WriteLine("""
|
||||
WxAgent.Host commands:
|
||||
serve --config <service.json>
|
||||
remote auth show|set|clear --config <remote.json>
|
||||
remote status show --config <remote.json> [--data-dir <dir>]
|
||||
remote probe run --config <remote.json> [--timeout 60]
|
||||
remote reporting show|enable|disable|account-add|account-enable|account-disable|allow|deny --config <remote.json>
|
||||
doctor [--timeout 30]
|
||||
diagnose --output <dir> [--baseline <ui-tree.json>] [--timeout 60]
|
||||
inspect-ui --output <path> [--window-title <title>] [--timeout 30]
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Net.Http;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Host;
|
||||
|
||||
internal static class RemoteCliCommands
|
||||
{
|
||||
public static async Task<object> RunAsync(string[] args, CancellationToken cancellationToken)
|
||||
{
|
||||
if (args.Length < 3)
|
||||
throw Invalid("Remote commands require a group and action.");
|
||||
var configPath = Path.GetFullPath(Required(args, "--config"));
|
||||
var group = args[1];
|
||||
var action = args[2];
|
||||
return (group, action) switch
|
||||
{
|
||||
("auth", "show") => await AuthShowAsync(configPath, cancellationToken),
|
||||
("auth", "set") => await AuthSetAsync(args, configPath, cancellationToken),
|
||||
("auth", "clear") => await AuthClearAsync(configPath, cancellationToken),
|
||||
("probe", "run") => await ProbeAsync(configPath, cancellationToken),
|
||||
("status", "show") => await StatusShowAsync(args, configPath, cancellationToken),
|
||||
("reporting", "show") => await ReportingShowAsync(configPath, cancellationToken),
|
||||
("reporting", "enable") => await ReportingToggleAsync(configPath, true, cancellationToken),
|
||||
("reporting", "disable") => await ReportingToggleAsync(configPath, false, cancellationToken),
|
||||
("reporting", "account-add") => await AccountToggleAsync(args, configPath, false, cancellationToken),
|
||||
("reporting", "account-enable") => await AccountToggleAsync(args, configPath, true, cancellationToken),
|
||||
("reporting", "account-disable") => await AccountToggleAsync(args, configPath, false, cancellationToken),
|
||||
("reporting", "allow") => await AllowChatAsync(args, configPath, cancellationToken),
|
||||
("reporting", "deny") => await DenyChatAsync(args, configPath, cancellationToken),
|
||||
_ => throw Invalid("Unknown remote command. Use: auth show|set|clear, status show, probe run, reporting show|enable|disable|account-add|account-enable|account-disable|allow|deny.")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> AuthShowAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
return new
|
||||
{
|
||||
config = path,
|
||||
remote = configuration.Remote.Redacted(),
|
||||
configured = configuration.Remote.IsConfigured,
|
||||
reportingConfigVersion = configuration.Reporting.ConfigVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> AuthSetAsync(string[] args, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
var remote = new RemoteAgentOptions
|
||||
{
|
||||
AuthAddress = Required(args, "--address"),
|
||||
Token = Required(args, "--token"),
|
||||
NodeId = Required(args, "--node"),
|
||||
ActiveAccountId = Option(args, "--active-account"),
|
||||
AllowInsecureHttp = Has(args, "--allow-insecure-http")
|
||||
};
|
||||
remote.Validate();
|
||||
var next = (current with { Remote = remote }).WithAudit("remote.auth.set");
|
||||
await RemoteNodeConfigurationStore.SaveAsync(path, next, cancellationToken);
|
||||
return new { saved = path, remote = remote.Redacted() };
|
||||
}
|
||||
|
||||
private static async Task<object> AuthClearAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Remote = new RemoteAgentOptions() }).WithAudit("remote.auth.clear"), cancellationToken);
|
||||
return new { saved = path, configured = false };
|
||||
}
|
||||
|
||||
private static async Task<object> ProbeAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
configuration.Remote.Validate();
|
||||
using var client = new RemoteControlClient(configuration.Remote, new HttpClient { Timeout = TimeSpan.FromSeconds(15) });
|
||||
var accounts = configuration.Reporting.Accounts.Select(account => new RemoteAccountSummary(
|
||||
account.AccountId,
|
||||
string.Equals(account.AccountId, configuration.Remote.ActiveAccountId, StringComparison.Ordinal),
|
||||
account.Enabled,
|
||||
account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Group && chat.Enabled && chat.IdentityVerified),
|
||||
account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Private && chat.Enabled && chat.IdentityVerified))).ToArray();
|
||||
var registration = await client.RegisterAsync(new RemoteNodeRegistration(configuration.Remote.NodeId!, "cli", RemoteProtocol.Version,
|
||||
["heartbeat", "poll-tasks", "send-text", "report-message"], configuration.Reporting.ConfigVersion, accounts), cancellationToken);
|
||||
var heartbeat = await client.HeartbeatAsync(new RemoteHeartbeat(configuration.Remote.NodeId!, "cli", RemoteProtocol.Version,
|
||||
RemoteNodeStatus.Online, false, false, false, configuration.Remote.ActiveAccountId, 0,
|
||||
configuration.Reporting.ConfigVersion, Guid.NewGuid().ToString("N")), cancellationToken);
|
||||
return new { status = "ok", node = registration.NodeId, authState = client.AuthState, heartbeat = heartbeat.Status };
|
||||
}
|
||||
|
||||
private static async Task<object> StatusShowAsync(string[] args, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
var dataDirectory = Path.GetFullPath(Option(args, "--data-dir") ?? Path.GetDirectoryName(path) ?? AppContext.BaseDirectory);
|
||||
var eventQueue = new RemoteEventQueue(Path.Combine(dataDirectory, "remote-event-queue.json"));
|
||||
var ledger = new RemoteTaskLedger(Path.Combine(dataDirectory, "remote-task-ledger.json"));
|
||||
return new
|
||||
{
|
||||
config = path,
|
||||
remote = configuration.Remote.Redacted(),
|
||||
configured = configuration.Remote.IsConfigured,
|
||||
reporting = new { configuration.Reporting.Enabled, configuration.Reporting.ConfigVersion, pendingEvents = eventQueue.PendingCount },
|
||||
unreportedTaskResults = ledger.UnreportedResults().Count,
|
||||
audit = configuration.Audit.TakeLast(20)
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ReportingShowAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
return new
|
||||
{
|
||||
config = path,
|
||||
enabled = configuration.Reporting.Enabled,
|
||||
configVersion = configuration.Reporting.ConfigVersion,
|
||||
accounts = configuration.Reporting.Accounts.Select(account => new
|
||||
{
|
||||
accountId = Mask(account.AccountId),
|
||||
account.Enabled,
|
||||
allowedGroupCount = account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Group && chat.Enabled && chat.IdentityVerified),
|
||||
allowedPrivateCount = account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Private && chat.Enabled && chat.IdentityVerified),
|
||||
unverifiedCount = account.AllowedChats.Count(chat => !chat.IdentityVerified)
|
||||
}),
|
||||
audit = configuration.Audit.TakeLast(20)
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ReportingToggleAsync(string path, bool enabled, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Enabled = enabled });
|
||||
await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit(enabled ? "reporting.enable" : "reporting.disable"), cancellationToken);
|
||||
return new { saved = path, reporting.Enabled, reporting.ConfigVersion };
|
||||
}
|
||||
|
||||
private static async Task<object> AccountToggleAsync(string[] args, string path, bool enabled, CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = Required(args, "--account");
|
||||
var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
var accounts = current.Reporting.Accounts.ToList();
|
||||
var index = accounts.FindIndex(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal));
|
||||
if (index < 0)
|
||||
{
|
||||
if (args[2] != "account-add") throw Invalid("The account does not exist; run reporting account-add first.");
|
||||
accounts.Add(new AccountReportingConfig { AccountId = accountId, Enabled = enabled });
|
||||
}
|
||||
else
|
||||
{
|
||||
accounts[index] = accounts[index] with { Enabled = enabled };
|
||||
}
|
||||
var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Accounts = accounts });
|
||||
await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit($"reporting.account.{(enabled ? "enable" : "disable")}"), cancellationToken);
|
||||
return new { saved = path, accountId = Mask(accountId), enabled, reporting.ConfigVersion };
|
||||
}
|
||||
|
||||
private static async Task<object> AllowChatAsync(string[] args, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Has(args, "--identity-verified"))
|
||||
throw Invalid("Allowing a chat requires --identity-verified after the stable identity was confirmed.");
|
||||
var accountId = Required(args, "--account");
|
||||
var chatId = Required(args, "--chat-id");
|
||||
var type = ParseChatType(Required(args, "--type"));
|
||||
var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
var accounts = current.Reporting.Accounts.ToList();
|
||||
var index = accounts.FindIndex(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal));
|
||||
if (index < 0 || !accounts[index].Enabled)
|
||||
throw Invalid("The account must exist and be enabled before a chat can be allowed.");
|
||||
var chats = accounts[index].AllowedChats.Where(chat => chat.Type != type || chat.ChatId != chatId).ToList();
|
||||
chats.Add(new AllowedChat { Type = type, ChatId = chatId, Enabled = true, IdentityVerified = true });
|
||||
accounts[index] = accounts[index] with { AllowedChats = chats };
|
||||
var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Accounts = accounts });
|
||||
await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit("reporting.chat.allow"), cancellationToken);
|
||||
return new { saved = path, accountId = Mask(accountId), chatId = Mask(chatId), type, reporting.ConfigVersion };
|
||||
}
|
||||
|
||||
private static async Task<object> DenyChatAsync(string[] args, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var accountId = Required(args, "--account");
|
||||
var chatId = Required(args, "--chat-id");
|
||||
var type = ParseChatType(Required(args, "--type"));
|
||||
var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
var accounts = current.Reporting.Accounts.ToList();
|
||||
var index = accounts.FindIndex(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal));
|
||||
if (index < 0) throw Invalid("The account does not exist.");
|
||||
accounts[index] = accounts[index] with
|
||||
{
|
||||
AllowedChats = accounts[index].AllowedChats.Where(chat => chat.Type != type || chat.ChatId != chatId).ToArray()
|
||||
};
|
||||
var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Accounts = accounts });
|
||||
await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit("reporting.chat.deny"), cancellationToken);
|
||||
return new { saved = path, accountId = Mask(accountId), chatId = Mask(chatId), type, reporting.ConfigVersion };
|
||||
}
|
||||
|
||||
private static ReportingChatType ParseChatType(string value) => value.ToLowerInvariant() switch
|
||||
{
|
||||
"group" => ReportingChatType.Group,
|
||||
"private" => ReportingChatType.Private,
|
||||
_ => throw Invalid("--type must be group or private.")
|
||||
};
|
||||
|
||||
private static string Required(string[] args, string name) => Option(args, name) switch
|
||||
{
|
||||
{ Length: > 0 } value => value,
|
||||
_ => throw Invalid($"Missing {name}.")
|
||||
};
|
||||
|
||||
private static string? Option(string[] args, string name)
|
||||
{
|
||||
var index = Array.IndexOf(args, name);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
private static bool Has(string[] args, string name) => args.Contains(name, StringComparer.Ordinal);
|
||||
|
||||
private static string Mask(string value) => value.Length <= 8 ? "<masked>" : value[..4] + "…" + value[^4..];
|
||||
|
||||
private static WxAgentException Invalid(string message) => new(WxAgentErrorCode.InvalidArgument, message);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace WxAgent.Host;
|
||||
|
||||
public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBackend, IAgentEventSource
|
||||
{
|
||||
private const bool ListenerEventsEnabled = false;
|
||||
private const bool ListenerEventsEnabled = true;
|
||||
private readonly SemaphoreSlim bindingGate = new(1, 1);
|
||||
|
||||
public IReadOnlyList<AgentCapability> Capabilities { get; } =
|
||||
@@ -48,6 +48,13 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa
|
||||
var current = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
||||
if (!BindingMatches(binding, current))
|
||||
throw new ServiceException("AccountBindingStale", 409, "The listener binding is stale; bind the account again.");
|
||||
var sessions = (await WechatChatClient.ListVisibleSessionsAsync(cancellationToken))
|
||||
.Where(session => string.Equals(session.Name, WechatLocators.FileTransferAssistant, StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(session.AutomationId))
|
||||
.ToArray();
|
||||
if (sessions.Length != 1)
|
||||
throw new ServiceException("ChatIdentityUnconfirmed", 409, "The listener chat identity could not be uniquely confirmed.");
|
||||
var chatId = sessions[0].AutomationId;
|
||||
var accountTag = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(binding.AccountId)))[..16].ToLowerInvariant();
|
||||
var checkpoint = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", $"listener-{accountTag}.json");
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
@@ -55,8 +62,13 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa
|
||||
await foreach (var item in WechatChatClient.ListenEventsAsync(TimeSpan.FromMinutes(5), checkpoint, cancellationToken,
|
||||
session: WechatLocators.FileTransferAssistant))
|
||||
{
|
||||
var observedIdentity = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
||||
if (!BindingMatches(binding, observedIdentity))
|
||||
throw new ServiceException("AccountBindingStale", 409, "The listener binding changed; reporting is paused until the account is rebound.");
|
||||
var eventType = item.Kind == MessageEventKind.MessageReceived ? "message" : "listener.reconnected";
|
||||
yield return new AgentEvent(item.EventId, binding.AccountId, item.Session,
|
||||
item.Kind.ToString(), item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt);
|
||||
eventType, item.Message is null ? "listener state" : item.Message.Type.ToString(), item.ObservedAt,
|
||||
chatId, ReportingChatType.Private, item.Message?.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +458,7 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings) : IAgentBa
|
||||
serviceOnline = true,
|
||||
wechatAvailable = report.Errors.Count == 0,
|
||||
sessionAvailable = report.UserInteractive && report.InputDesktopAvailable,
|
||||
sessionLocked = report.Errors.Contains(WxAgentErrorCode.SessionLocked),
|
||||
report.WindowFound,
|
||||
errors = report.Errors.Select(e => e.ToString()),
|
||||
wechatVersions = report.Processes.Select(p => p.Version).Where(v => v is not null).Distinct(),
|
||||
|
||||
Reference in New Issue
Block a user