From 1d5c472e88aaeb6a9da6e7da2535fcf8357fe87f Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 20 Sep 2026 22:40:42 +0800 Subject: [PATCH] feat: add tray remote connection settings --- docs/WebUI-MCP-使用说明.md | 2 +- docs/WxAgent-远程控制面架构与部署说明-v1.0.md | 2 +- node-agent/WxAgent.Service/ServiceOptions.cs | 6 +- node-agent/WxAgent.Tray/Program.cs | 137 ++++++++++++++++-- .../ServiceBoundaryTests.cs | 35 +++++ 5 files changed, 165 insertions(+), 17 deletions(-) diff --git a/docs/WebUI-MCP-使用说明.md b/docs/WebUI-MCP-使用说明.md index 2be3e96..059a034 100644 --- a/docs/WebUI-MCP-使用说明.md +++ b/docs/WebUI-MCP-使用说明.md @@ -16,7 +16,7 @@ `AccountIds` 仅为旧凭据格式保留,不再作为账号级数据授权边界;`[]` 可以保留。凭据文件应使用当前用户 ACL,禁止提交仓库。调用方仍必须显式携带 account fingerprint 选择数据和目标,Agent 会验证绑定、窗口和目标一致性。托盘模式的 `service.json` 会保存 `AccessToken` 明文,以便服务设置窗口随时展示;该文件同样只应保存在本机。 -服务运行后通过托盘菜单或双击托盘图标打开“服务设置...”配置监听地址、端口、远程访问、唯一 Token、验证模式、后台消息监听和自动锁屏选项,不需要手工编辑 `service.json`/`credentials.json`。不提供命令行配置入口。队列容量、监听会话和监听事件开关属于内部实现/验收策略,不作为用户配置项。自动锁屏设置由服务设置窗口保存;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口;外部监听使用具体 IP,不能使用 `0.0.0.0`/`::`。服务校验可信 Host/Origin;远程浏览器写请求需要 Cookie、CSRF 和可信 Origin,本机浏览器写请求还需要 `X-WxAgent-Local: 1`。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 +服务运行后通过托盘菜单或双击托盘图标打开“服务设置...”配置监听地址、端口、唯一 Token、验证模式、后台消息监听和自动锁屏选项;“远程连接”页可管理控制面地址、节点 ID、控制面令牌、活动账号及 TLS 文件。不需要手工编辑 `service.json`/`credentials.json`,也不提供命令行配置入口。队列容量、监听会话和监听事件开关属于内部实现/验收策略,不作为用户配置项。自动锁屏设置由服务设置窗口保存;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口;外部监听使用具体 IP,不能使用 `0.0.0.0`/`::`。服务校验可信 Host/Origin;远程浏览器写请求需要 Cookie、CSRF 和可信 Origin,本机浏览器写请求还需要 `X-WxAgent-Local: 1`。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。 远程浏览器访问 `/` 后输入 Token 登录。HTTP/MCP 客户端使用: diff --git a/docs/WxAgent-远程控制面架构与部署说明-v1.0.md b/docs/WxAgent-远程控制面架构与部署说明-v1.0.md index 4ddb489..a0d9040 100644 --- a/docs/WxAgent-远程控制面架构与部署说明-v1.0.md +++ b/docs/WxAgent-远程控制面架构与部署说明-v1.0.md @@ -578,7 +578,7 @@ scp -r node-agent/WxAgent.Tray/bin/Release/net8.0-windows10.0.19041.0/win-x64/pu } ``` -当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改 `remote.json`、Token、节点 ID 或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作、后台消息监听和自动锁屏在“服务设置...”配置窗口中维护。后台消息监听默认关闭,启用后会操作微信界面;远程上报配置尚未提供用户配置容器时保持已有部署配置或禁用,不得用命令行绕过该边界。 +当前版本不提供任何命令行配置入口,也不接受通过 CLI 修改远程凭据或上报白名单。Windows Agent 只通过双击 `WxAgent.Tray.exe` 启动;本机监听、访问凭据、验证写操作、后台消息监听、自动锁屏以及控制面连接(地址、节点 ID、Token、活动账号和 TLS 文件)均在“服务设置...”的“远程连接”页维护。保存远程连接时可将旧 `remote.json` 配置迁移到托盘管理的 `service.json`;上报白名单仍遵循本地配置和默认关闭边界,不得用命令行绕过该边界。 ### 9.3 诊断与只读确认 diff --git a/node-agent/WxAgent.Service/ServiceOptions.cs b/node-agent/WxAgent.Service/ServiceOptions.cs index a4b01fc..89c39bc 100644 --- a/node-agent/WxAgent.Service/ServiceOptions.cs +++ b/node-agent/WxAgent.Service/ServiceOptions.cs @@ -48,7 +48,11 @@ public sealed class ServiceOptions throw new ArgumentException("External HTTP requires a concrete listen IP; do not use 0.0.0.0 or ::."); if (AccessToken is not null && !IsValidAccessToken(AccessToken)) throw new ArgumentException("AccessToken must be non-empty and contain no whitespace."); - try { (Reporting ?? new ReportingConfig()).NormalizeAndValidate(); } + try + { + Remote?.Validate(); + (Reporting ?? new ReportingConfig()).NormalizeAndValidate(); + } catch (WxAgentException exception) { throw new ArgumentException(exception.Message, exception); } _ = ReadCredentials(); } diff --git a/node-agent/WxAgent.Tray/Program.cs b/node-agent/WxAgent.Tray/Program.cs index b71765d..913c2da 100644 --- a/node-agent/WxAgent.Tray/Program.cs +++ b/node-agent/WxAgent.Tray/Program.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Windows.Forms; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Logging; +using WxAgent.Core; using WxAgent.Host; using WxAgent.Service; @@ -279,7 +280,14 @@ internal sealed class TrayApplicationContext : ApplicationContext try { EnsureConfiguration(); - if (ServiceSettingsEditor.Show(options!) is not { } edited) return; + var settings = options!; + if (!string.IsNullOrWhiteSpace(settings.RemoteConfigurationFile)) + { + var remotePath = ResolvePath(settings.RemoteConfigurationFile, Path.GetDirectoryName(configPath)!, "remote.json"); + var remoteConfiguration = RemoteNodeConfigurationStore.LoadAsync(remotePath).GetAwaiter().GetResult(); + settings = WithRemoteForSettings(settings, remoteConfiguration.Remote, remoteConfiguration.Reporting); + } + if (ServiceSettingsEditor.Show(settings) is not { } edited) return; WriteCredentials(edited.CredentialFile, edited.AccessToken!, edited.EnableValidationOperations); WriteJson(configPath, edited); _ = ReloadAsync(); @@ -291,6 +299,23 @@ internal sealed class TrayApplicationContext : ApplicationContext } } + private static ServiceOptions WithRemoteForSettings(ServiceOptions source, RemoteAgentOptions? remote, ReportingConfig reporting) => new() + { + ListenUrl = source.ListenUrl, + AllowExternal = source.AllowExternal, + AllowedHosts = source.AllowedHosts ?? [], + AllowedOrigins = source.AllowedOrigins ?? [], + AccessToken = source.AccessToken, + CredentialFile = source.CredentialFile, + DataDirectory = source.DataDirectory, + Remote = remote, + Reporting = reporting, + RemoteConfigurationFile = null, + EnableValidationOperations = source.EnableValidationOperations, + EnableListenerEvents = source.EnableListenerEvents, + PreventAutoLock = source.PreventAutoLock + }; + private void SetStatus(string message) { StatusItem.Text = message.Length > 60 ? message[..60] : message; @@ -352,57 +377,124 @@ internal static class ServiceSettingsEditor { if (!Uri.TryCreate(current.ListenUrl, UriKind.Absolute, out var uri)) throw new InvalidDataException("ListenUrl is invalid."); + + static string? Optional(TextBox box) => string.IsNullOrWhiteSpace(box.Text) ? null : box.Text.Trim(); + var host = uri.Host.Trim('[', ']'); var token = current.AccessToken ?? ServiceOptions.GenerateToken(); + var remote = current.Remote; ServiceOptions? result = null; using var form = new Form { Text = "WxAgent 服务设置", - Width = 620, - Height = 435, + Width = 720, + Height = 535, StartPosition = FormStartPosition.CenterScreen, MinimizeBox = false, MaximizeBox = false, FormBorderStyle = FormBorderStyle.FixedDialog }; + + var tabs = new TabControl { Left = 12, Top = 12, Width = 680, Height = 450 }; + var localTab = new TabPage("本地服务"); + var remoteTab = new TabPage("远程连接"); + tabs.TabPages.Add(localTab); + tabs.TabPages.Add(remoteTab); + var listenLabel = new Label { Left = 18, Top = 18, Width = 120, Text = "监听地址" }; var hostBox = new TextBox { Left = 145, Top = 14, Width = 245, Text = host }; var portBox = new NumericUpDown { Left = 400, Top = 14, Width = 90, Minimum = 1, Maximum = 65535, Value = uri.Port }; - var external = new CheckBox { Left = 145, Top = 52, Width = 430, Text = "允许远程机器连接(仅在可信内网启用)", Checked = current.AllowExternal }; + var external = new CheckBox { Left = 145, Top = 52, Width = 500, Text = "允许远程机器连接(仅在可信内网启用)", Checked = current.AllowExternal }; var tokenLabel = new Label { Left = 18, Top = 91, Width = 120, Text = "访问凭据" }; var tokenBox = new TextBox { Left = 145, Top = 87, Width = 345, Text = token, MaxLength = 0 }; var copy = new Button { Left = 500, Top = 85, Width = 88, Text = "复制" }; var regenerate = new Button { Left = 145, Top = 123, Width = 105, Text = "重新生成" }; var validation = new CheckBox { - Left = 145, Top = 158, Width = 430, + Left = 145, Top = 158, Width = 500, Text = "启用本机验证写操作(仅授权测试机)", Checked = current.EnableValidationOperations }; var preventAutoLock = new CheckBox { - Left = 145, Top = 187, Width = 430, + Left = 145, Top = 187, Width = 500, Text = "防止自动息屏、睡眠和锁屏", Checked = current.PreventAutoLock }; var listenerEvents = new CheckBox { - Left = 145, Top = 216, Width = 430, + Left = 145, Top = 216, Width = 500, Text = "启用后台消息监听(会操作微信界面,默认关闭)", Checked = current.EnableListenerEvents }; var note = new Label { - Left = 18, Top = 250, Width = 570, Height = 48, + Left = 18, Top = 250, Width = 620, Height = 48, Text = "仅保留一个访问凭据,可直接输入自定义内容(不能为空或包含空白字符)。\n验证写操作只应在明确授权的测试机启用;本机回环访问不需要凭据。" }; var data = new Label { - Left = 18, Top = 308, Width = 570, Height = 24, + Left = 18, Top = 308, Width = 620, Height = 24, Text = $"数据目录:{current.DataDirectory}", AutoEllipsis = true }; - var save = new Button { Left = 370, Top = 360, Width = 105, Text = "保存" }; - var cancel = new Button { Left = 485, Top = 360, Width = 105, Text = "取消" }; + localTab.Controls.AddRange([listenLabel, hostBox, portBox, external, tokenLabel, tokenBox, copy, regenerate, validation, preventAutoLock, listenerEvents, note, data]); + + var remoteEnabled = new CheckBox + { + Left = 18, Top = 16, Width = 620, + Text = "启用远程连接(连接控制面)", + Checked = remote is not null + }; + var remoteAddressLabel = new Label { Left = 18, Top = 54, Width = 125, Text = "控制面地址" }; + var remoteAddressBox = new TextBox { Left = 150, Top = 50, Width = 485, Text = remote?.AuthAddress ?? "" }; + var remoteNodeLabel = new Label { Left = 18, Top = 90, Width = 125, Text = "节点 ID" }; + var remoteNodeBox = new TextBox { Left = 150, Top = 86, Width = 485, Text = remote?.NodeId ?? "" }; + var remoteAccountLabel = new Label { Left = 18, Top = 126, Width = 125, Text = "活动账号 ID" }; + var remoteAccountBox = new TextBox { Left = 150, Top = 122, Width = 485, Text = remote?.ActiveAccountId ?? "" }; + var remoteTokenLabel = new Label { Left = 18, Top = 162, Width = 125, Text = "控制面令牌" }; + var remoteTokenBox = new TextBox + { + Left = 150, Top = 158, Width = 485, + Text = remote?.Token ?? "", + UseSystemPasswordChar = true + }; + var remoteTokenFileLabel = new Label { Left = 18, Top = 198, Width = 125, Text = "令牌文件(可选)" }; + var remoteTokenFileBox = new TextBox { Left = 150, Top = 194, Width = 485, Text = remote?.TokenFile ?? "" }; + var allowInsecureHttp = new CheckBox + { + Left = 150, Top = 230, Width = 485, + Text = "允许私有网络 HTTP(仅 10/172.16-31/192.168 网段)", + Checked = remote?.AllowInsecureHttp == true + }; + var remoteServerCaLabel = new Label { Left = 18, Top = 266, Width = 125, Text = "服务端 CA(可选)" }; + var remoteServerCaBox = new TextBox { Left = 150, Top = 262, Width = 485, Text = remote?.ServerCaFile ?? "" }; + var remoteClientCertLabel = new Label { Left = 18, Top = 302, Width = 125, Text = "客户端证书(可选)" }; + var remoteClientCertBox = new TextBox { Left = 150, Top = 298, Width = 485, Text = remote?.ClientCertificateFile ?? "" }; + var remoteClientKeyLabel = new Label { Left = 18, Top = 338, Width = 125, Text = "客户端密钥(可选)" }; + var remoteClientKeyBox = new TextBox { Left = 150, Top = 334, Width = 485, Text = remote?.ClientCertificateKeyFile ?? "" }; + var remoteNote = new Label + { + Left = 18, Top = 372, Width = 620, Height = 40, + Text = "控制面令牌与控制面 WXAGENT_NODE_TOKEN 一致;HTTP 仅允许私有 IP,公网或域名请使用 HTTPS。" + }; + remoteTab.Controls.AddRange([ + remoteEnabled, remoteAddressLabel, remoteAddressBox, remoteNodeLabel, remoteNodeBox, + remoteAccountLabel, remoteAccountBox, remoteTokenLabel, remoteTokenBox, + remoteTokenFileLabel, remoteTokenFileBox, allowInsecureHttp, remoteServerCaLabel, + remoteServerCaBox, remoteClientCertLabel, remoteClientCertBox, remoteClientKeyLabel, + remoteClientKeyBox, remoteNote + ]); + var remoteInputs = new Control[] + { + remoteAddressBox, remoteNodeBox, remoteAccountBox, remoteTokenBox, remoteTokenFileBox, + allowInsecureHttp, remoteServerCaBox, remoteClientCertBox, remoteClientKeyBox + }; + void SetRemoteEnabled() { foreach (var control in remoteInputs) control.Enabled = remoteEnabled.Checked; } + remoteEnabled.CheckedChanged += (_, _) => SetRemoteEnabled(); + SetRemoteEnabled(); + + var save = new Button { Left = 470, Top = 470, Width = 105, Text = "保存" }; + var cancel = new Button { Left = 585, Top = 470, Width = 105, Text = "取消" }; copy.Click += (_, _) => { Clipboard.SetText(tokenBox.Text); copy.Text = "已复制"; }; regenerate.Click += (_, _) => { @@ -418,6 +510,21 @@ internal static class ServiceSettingsEditor } var formattedHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString(); + var remoteToken = Optional(remoteTokenBox); + var remoteOptions = remoteEnabled.Checked + ? new RemoteAgentOptions + { + AuthAddress = Optional(remoteAddressBox), + Token = remoteToken, + TokenFile = remoteToken is null ? Optional(remoteTokenFileBox) : null, + ServerCaFile = Optional(remoteServerCaBox), + ClientCertificateFile = Optional(remoteClientCertBox), + ClientCertificateKeyFile = Optional(remoteClientKeyBox), + NodeId = Optional(remoteNodeBox), + ActiveAccountId = Optional(remoteAccountBox), + AllowInsecureHttp = allowInsecureHttp.Checked + } + : null; var edited = new ServiceOptions { ListenUrl = $"http://{formattedHost}:{portBox.Value}", @@ -427,9 +534,9 @@ internal static class ServiceSettingsEditor AccessToken = tokenBox.Text, CredentialFile = current.CredentialFile, DataDirectory = current.DataDirectory, - Remote = current.Remote, + Remote = remoteOptions, Reporting = current.Reporting, - RemoteConfigurationFile = current.RemoteConfigurationFile, + RemoteConfigurationFile = null, EnableValidationOperations = validation.Checked, EnableListenerEvents = listenerEvents.Checked, PreventAutoLock = preventAutoLock.Checked @@ -444,7 +551,9 @@ internal static class ServiceSettingsEditor form.Close(); }; cancel.Click += (_, _) => form.Close(); - form.Controls.AddRange([listenLabel, hostBox, portBox, external, tokenLabel, tokenBox, copy, regenerate, validation, preventAutoLock, listenerEvents, note, data, save, cancel]); + form.Controls.Add(tabs); + form.Controls.Add(save); + form.Controls.Add(cancel); form.AcceptButton = save; form.CancelButton = cancel; form.ShowDialog(); diff --git a/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs b/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs index 83c7154..06d9042 100644 --- a/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs +++ b/tests/node-agent/WxAgent.Service.Tests/ServiceBoundaryTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using WxAgent.Core; using WxAgent.Service; using Xunit; @@ -92,6 +93,40 @@ public sealed class ServiceBoundaryTests finally { Directory.Delete(dir, true); } } + [Fact] + public void EmbeddedRemoteConfigurationIsValidatedWithTheService() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); + try + { + var options = new ServiceOptions + { + CredentialFile = Path.Combine(dir, "credentials.json"), + DataDirectory = dir, + Remote = new RemoteAgentOptions + { + AuthAddress = "http://10.1.1.104:8090", + Token = "node-token", + NodeId = "node-1", + AllowInsecureHttp = true + } + }; + File.WriteAllText(options.CredentialFile, JsonSerializer.Serialize(new[] + { + new ServiceCredential("tray", ServiceOptions.HashToken("local-token"), ["read"], []) + })); + options.Validate(); + var invalid = new ServiceOptions + { + CredentialFile = options.CredentialFile, + DataDirectory = options.DataDirectory, + Remote = options.Remote! with { AllowInsecureHttp = false } + }; + Assert.Throws(() => invalid.Validate()); + } + finally { Directory.Delete(dir, true); } + } + [Fact] public void RuntimeLogWritesFullOperationalMessagesToTheRequestedPath() {