feat: add tray remote connection settings
This commit is contained in:
@@ -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 诊断与只读确认
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user