feat: add tray remote connection settings

This commit is contained in:
2026-09-20 22:40:42 +08:00
parent baf7a47418
commit 1d5c472e88
5 changed files with 165 additions and 17 deletions
+1 -1
View File
@@ -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 客户端使用:
@@ -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 诊断与只读确认
+5 -1
View File
@@ -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();
}
+123 -14
View File
@@ -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();
@@ -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<ArgumentException>(() => invalid.Validate());
}
finally { Directory.Delete(dir, true); }
}
[Fact]
public void RuntimeLogWritesFullOperationalMessagesToTheRequestedPath()
{