feat: add tray settings and local console access

This commit is contained in:
2026-09-08 10:38:18 +08:00
parent c5314350bf
commit 945d36eb31
15 changed files with 254 additions and 55 deletions
+12 -13
View File
@@ -2,8 +2,15 @@
## 启动
1. 在已登录、未锁定的 Windows 用户会话中准备目录和凭据文件;不要通过 HTTP 生成 Token。
2. `credentials.json` 只保存 SHA-256 大写十六进制摘要,例如:
1. 在已登录、未锁定的 Windows 用户会话中运行发布包中的 `WxAgent-Setup.exe`。安装器会创建桌面/开始菜单快捷方式和可选的登录启动项,不需要 PowerShell 或管理员权限。
2. 启动托盘程序后,首次运行会生成远程访问 Token,并在一次性窗口中提供复制按钮。后续通过托盘菜单“访问凭据...”新建或撤销 Token,通过“服务设置...”配置监听和服务参数;不需要手工编辑配置文件。
3. 如需无托盘运行,仍可复制 `docs/webui-mcp-config.example.json` 后使用:
```powershell
WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json
```
`credentials.json` 只保存 SHA-256 大写十六进制摘要,例如:
```json
[{"PrincipalId":"local-read","TokenSha256":"<64-hex-sha256>","Permissions":["read"],"AccountIds":[]}]
@@ -11,17 +18,9 @@
`AccountIds: []` 不授予任何显式数据库账号范围;需要联系人/群成员等账号范围调用时,必须填入已验证的 account fingerprint。凭据文件应使用当前用户 ACL,禁止提交仓库。
3. 复制 `docs/webui-mcp-config.example.json`,修改本机路径后启动:
服务运行后使用托盘菜单中的“服务设置...”配置监听地址、端口、远程访问、队列和监听事件;使用“访问凭据...”新建或撤销 Token,不需要手工编辑 `service.json`/`credentials.json`。若需要保持微信 UI 会话不自动锁屏,在安装器的可选项中启用“防止自动锁屏”;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`,本机访问控制台不需要 Token,会自动进入。外部监听必须启用远程访问并自行配置防火墙,服务不会自动开放端口。Host/Origin(CORS)限制不再校验;HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。
```powershell
WxAgent.Host.exe serve --config C:\Users\USERNAME\wx-agent\service.json
```
需要正常安装、登录后自动启动、创建桌面/开始菜单快捷方式并常驻托盘时,运行发布包中的 `WxAgent-Setup.exe`。安装器会创建当前用户的登录启动项,不需要 PowerShell 或管理员权限;首次启动托盘程序时生成本地管理 Token,并在一次性窗口中提供复制按钮。
服务运行后可在托盘菜单中打开控制台、打开 `service.json`、停止/启动服务或重新加载配置。若需要保持微信 UI 会话不自动锁屏,在安装器的可选项中启用“防止自动锁屏”;该设置不阻止用户手动锁定。默认只监听 `127.0.0.1:5088`。外部监听必须同时设置 `AllowExternal=true`、明确 IP/端口和精确 `AllowedHosts`/`AllowedOrigins`;自行配置防火墙,服务不会自动开放端口。HTTP 不加密 Token、Cookie、消息或附件,不直接暴露公网。
浏览器访问 `/`,输入 Token 登录。HTTP/MCP 客户端使用:
远程浏览器访问 `/` 后输入 Token 登录。HTTP/MCP 客户端使用:
```text
Authorization: Bearer <TOKEN>
@@ -31,7 +30,7 @@ MCP Streamable HTTP 地址为 `/mcp`。不要把 Token 放在 URL、MCP session
## 凭据更换与撤销
原子替换凭据文件并保留相同 `PrincipalId` 可保留幂等记录;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。
在托盘“访问凭据...”中创建新 Token 或撤销旧 Token。明文 Token 只在创建时显示一次;服务在每个请求、任务执行和事件批次重新读取凭据。旧 Token、Cookie、SSE/MCP 授权立即失效,不存在重叠窗口。变更后删除旧浏览器会话并重新登录。
## 多账号显式绑定
+3 -3
View File
@@ -19,7 +19,7 @@
## 运行边界
- 通过 `WxAgent.Host serve --config <file>` 启动;默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。
- HTTP API、Streamable HTTP MCP (`/mcp`) 和浏览器登录共用 Bearer Token;Token 只放 `Authorization`,不放 URL。
- 浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。
- 可通过 `WxAgent.Host serve --config <file>` 启动;安装版使用托盘程序,服务设置和 Token 管理均通过系统 UI 完成。默认监听 `127.0.0.1`,外部 IP 必须显式 `allowExternal: true`。
- 本机回环访问 Web UI、HTTP API 和 Streamable HTTP MCP (`/mcp`) 不需要 Token;远程访问仍使用 Bearer Token,Token 只放 `Authorization`,不放 URL。
- 远程浏览器登录后仅保留短期 HttpOnly SameSite Cookie,写请求需 CSRF;普通日志不记录正文、Token、Cookie、密钥或完整 UI 树。
- 明文 HTTP 不提供传输保密性,只适合可信隔离网络;不应直接暴露公网。
+6 -6
View File
@@ -164,10 +164,10 @@ Queued → Running → Succeeded / Failed / Cancelled / Unconfirmed
### 5.1 本机与外部入口鉴权
1. 默认绑定 `127.0.0.1`/`::1`;允许显式配置非回环 IP、`0.0.0.0`/`::`、端口和允许的访问域名。外部 HTTP 监听必须先配置有效 Token,否则启动失败;不自动开放防火墙。不要求 HTTPS,不开发证书管理及反向代理适配。
2. HTTP API 与 MCP 共用高熵 Token、principalId、权限和撤销机制,同一 Token 可以调用两种协议。直接客户端使用 `Authorization: Bearer <TOKEN>`,不得将 Token 放入 URL、查询参数、日志或 MCP session ID。Token 首次生成/配置及授权在 Windows 主机本地完成,文件由当前用户 ACL 保护;不通过匿名网络接口生成或提权。
3. 本机和外部 Web UI 统一输入 Token 登录,不实现配对码。只匿名提供无业务数据的登录页及静态资源;浏览器通过 POST 换取短期 `HttpOnly`、`SameSite=Strict` 会话 Cookie,随后清空输入,不把 Token 写入 localStorage/sessionStorage 或持久缓存。明文 HTTP 下 Cookie 不设置 Secure,因此不具备传输保密性。会话继承 Token 身份和权限;SSE 使用该 Cookie,写请求另校验 CSRF。API/MCP 无 Token 或有效派生会话时拒绝,错误/状态接口不得匿名泄露环境信息。
4. Token 登录失败限流;校验配置的 Host 与精确 Origin 白名单,不设置通配 CORS。原生 MCP/HTTP 客户端可无 Origin,但仍须 Bearer 认证;有 Origin 则必须通过检查。本机专用入口仅接受直接回环连接,不信任客户端自报 Host、Origin 或 X-Forwarded-For 作为来源证明。
1. 默认绑定 `127.0.0.1`/`::1`;通过托盘“服务设置...”配置非回环 IP、端口和远程访问,不自动开放防火墙。不要求 HTTPS,不开发证书管理及反向代理适配。
2. HTTP API 与 MCP 共用高熵 Token、principalId、权限和撤销机制,同一 Token 可以调用两种协议。直接客户端使用 `Authorization: Bearer <TOKEN>`,不得将 Token 放入 URL、查询参数、日志或 MCP session ID。Token 的生成、撤销和服务设置均通过 Windows 托盘系统 UI 完成;凭据文件由当前用户 ACL 保护。
3. 本机回环 Web UI/API/MCP 不需要 Token,服务仅信任操作系统提供的直接回环地址;远程 Web UI 仍输入 Token 登录。远程浏览器通过 POST 换取短期 `HttpOnly`、`SameSite=Strict` 会话 Cookie,随后清空输入,不把 Token 写入 localStorage/sessionStorage 或持久缓存。明文 HTTP 下 Cookie 不设置 Secure,因此不具备传输保密性。会话继承 Token 身份和权限;SSE 使用该 Cookie,写请求另校验 CSRF。
4. Token 登录失败限流;不校验 Host/Origin,客户端可不提供 Origin。远程请求仍必须通过 Bearer 或有效派生会话;本机专用入口仅接受直接回环连接,不信任客户端自报 X-Forwarded-For 作为来源证明。
5. 首版权限区分只读、内容读取、普通写、管理写和本机诊断管理;凭据默认只读。共享 Token 即共享身份,不能隔离共享者;需要隔离时配置不同身份的 Token。密钥扫描、保存等本机专用动作不能因外部 Token 有管理权限而开放。
6. 更换或撤销 Token 立即使旧 Token、派生 Cookie 和 MCP 会话授权失效,关闭相关 SSE/事件流,不实现新旧 Token 重叠窗口;稳定身份和幂等记录保留。每次资源访问及每个事件输出批次检查授权;执行及实际提交副作用前重新检查。未执行任务失败且不执行;已提交任务保留真实/不确定结果,不承诺撤销微信动作。
@@ -229,7 +229,7 @@ Queued → Running → Succeeded / Failed / Cancelled / Unconfirmed
1. 增加 serve 分支和最小 HTTP Host,CLI 行为保持兼容。
2. 提取必要共享处理逻辑,使 REST、MCP 不复制参数校验和微信操作实现。
3. 接入有界队列、现有门禁、超时取消和任务记录,核查库 API 未受门禁保护的路径。
4. 实现默认回环及显式外部 HTTP 监听、HTTP/MCP 共用 Token、统一浏览器 Token 登录、权限、CSRF、Host/Origin 检查和脱敏日志。
4. 实现默认回环及显式外部 HTTP 监听、HTTP/MCP 共用 Token、本机免 Token、远程 Token 登录、权限、CSRF 和脱敏日志。
5. 先接 status、capabilities、operations 查询和取消。
验收:
@@ -340,7 +340,7 @@ Queued → Running → Succeeded / Failed / Cancelled / Unconfirmed
2. Linux restore/test/build/publish 后上传 Windows,同一已登录用户会话启动 serve。
3. 执行 doctor/inspect-ui/smoke 及本期实际接入功能回归,记录 Windows/微信/浏览器/MCP 客户端版本。
4. 验证关闭、崩溃重启、凭据撤销、端口冲突、锁屏、微信退出及版本不支持路径。
5. 从另一主机验证外部 HTTP Web UI、HTTP API 和 MCP:同一 Token 可访问,缺失/错误/撤销 Token 被拒绝,本机专用入口不可访问;验证 Host/Origin、限流和防火墙部署要求,不以 localhost 自测代替。
5. 从另一主机验证外部 HTTP Web UI、HTTP API 和 MCP:同一 Token 可访问,缺失/错误/撤销 Token 被拒绝,本机专用入口不可访问;验证限流和防火墙部署要求,不以 localhost 自测代替。
6. 输出使用说明、HTTP/MCP 共用 Token 配置样例(只用占位值)、显式监听配置、明文风险提示、凭据更换、回滚步骤及本轮验收报告。
验收:
+2 -2
View File
@@ -27,8 +27,8 @@
## 安全/失败路径
- 无 Bearer Token:401。
- 非 allowlist Host/Origin:403。
- 回环本机无 Bearer Token:允许直接访问;远程无 Bearer Token:401。
- Host/Origin 不再作为认证条件;远程仍必须通过 Bearer Token,URL 查询中的 token 仍拒绝。
- URL 查询中出现 token 参数:400,拒绝凭据泄漏路径。
- 旧 Token 轮换后:旧 Bearer 与浏览器 Cookie 均 401;身份与幂等记录保留。
- 写能力:保持 disabled;当前服务不模拟成功、不执行微信写入。
-2
View File
@@ -1,8 +1,6 @@
{
"ListenUrl": "http://127.0.0.1:5088",
"AllowExternal": false,
"AllowedHosts": ["127.0.0.1:5088", "localhost:5088", "[::1]:5088"],
"AllowedOrigins": ["http://127.0.0.1:5088", "http://localhost:5088", "http://[::1]:5088"],
"CredentialFile": "C:/Users/USERNAME/wx-agent/credentials.json",
"DataDirectory": "C:/Users/USERNAME/wx-agent/data",
"QueueCapacity": 100,
+2
View File
@@ -6,6 +6,7 @@ AppId={{8B2EAF78-2B11-4E55-9C39-7A6E4E7A4B7D}
AppName=WxAgent
AppVersion={#AppVersion}
AppPublisher=WxAgent
SetupIconFile={#PublishRoot}\wxagent.ico
DefaultDirName={localappdata}\WxAgent
DefaultGroupName=WxAgent
OutputDir=..\artifacts\setup
@@ -25,6 +26,7 @@ Uninstallable=yes
Source: "{#PublishRoot}\WxAgent.Tray.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#PublishRoot}\WxAgent.Host.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#PublishRoot}\wwwroot\*"; DestDir: "{app}\wwwroot"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#PublishRoot}\wxagent.ico"; DestDir: "{app}"; Flags: ignoreversion
[Tasks]
Name: "autostart"; Description: "登录 Windows 后自动启动 WxAgent"; GroupDescription: "启动选项:"; Flags: checkedonce
+2 -7
View File
@@ -26,11 +26,6 @@ public sealed class ServiceOptions
throw new ArgumentException("ListenUrl must be an explicit HTTP IP address and port.");
if (!IPAddress.IsLoopback(address) && !AllowExternal)
throw new ArgumentException("External HTTP requires AllowExternal; use a trusted isolated network.");
if (AllowedHosts.Length == 0 || AllowedOrigins.Length == 0 ||
AllowedHosts.Any(h => h.Contains('*') || h.Contains('/')) ||
AllowedOrigins.Any(o => !Uri.TryCreate(o, UriKind.Absolute, out var origin) ||
origin.Scheme != "http" || origin.GetLeftPart(UriPartial.Authority) != o))
throw new ArgumentException("Explicit Host and exact Origin allowlists are required.");
if (QueueCapacity is < 1 or > 100) throw new ArgumentOutOfRangeException(nameof(QueueCapacity));
if (string.IsNullOrWhiteSpace(ListenerSession) || ListenerSession.Length > 200) throw new ArgumentException("ListenerSession must be a bounded nonempty name.");
_ = ReadCredentials();
@@ -55,10 +50,10 @@ public sealed class ServiceOptions
public static string HashToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}
public sealed record ServiceIdentity(string PrincipalId, string CredentialHash, string[] Permissions, string[] AccountIds)
public sealed record ServiceIdentity(string PrincipalId, string CredentialHash, string[] Permissions, string[] AccountIds, bool LocalOnly = false)
{
public bool Allows(string permission) => Permissions.Contains(permission, StringComparer.Ordinal);
public bool AllowsAccount(string accountId) => AccountIds.Contains(accountId, StringComparer.Ordinal);
public bool AllowsAccount(string accountId) => LocalOnly || AccountIds.Contains(accountId, StringComparer.Ordinal);
}
public sealed class ServiceException(string code, int statusCode, string message) : Exception(message)
+20 -10
View File
@@ -7,6 +7,7 @@ namespace WxAgent.Service;
public sealed class ServiceSecurity(ServiceOptions options)
{
private static readonly ServiceIdentity LocalIdentity = new("local", "local", ["read", "content", "manage", "local-admin"], [], true);
private sealed record BrowserSession(ServiceIdentity Identity, string Csrf, DateTimeOffset Expires);
private readonly ConcurrentDictionary<string, BrowserSession> sessions = new();
private readonly object loginGate = new();
@@ -24,6 +25,14 @@ public sealed class ServiceSecurity(ServiceOptions options)
public ServiceIdentity RequireCurrent(ServiceIdentity original, string? permission = null, string? accountId = null)
{
if (original.LocalOnly)
{
if (permission is not null && !original.Allows(permission))
throw new ServiceException("Forbidden", 403, "Permission required.");
if (accountId is not null && !original.AllowsAccount(accountId))
throw new ServiceException("Forbidden", 403, "Account access denied.");
return original;
}
var credential = ReadCredentialsSafely().SingleOrDefault(c => c.PrincipalId == original.PrincipalId &&
EqualHash(c.TokenSha256, original.CredentialHash));
if (credential is null) throw new ServiceException("AuthorizationRevoked", 401, "Credential expired or revoked.");
@@ -47,11 +56,7 @@ public sealed class ServiceSecurity(ServiceOptions options)
public void ValidateSource(HttpContext context)
{
if (!options.AllowedHosts.Contains(context.Request.Host.Value, StringComparer.OrdinalIgnoreCase))
throw new ServiceException("InvalidHost", 403, "Host not allowed.");
var origin = context.Request.Headers.Origin;
if (origin.Count != 0 && (origin.Count != 1 || !options.AllowedOrigins.Contains(origin[0], StringComparer.Ordinal)))
throw new ServiceException("InvalidOrigin", 403, "Origin not allowed.");
// Host/Origin filtering is intentionally disabled; authentication and the optional loopback-only mode are the boundary.
}
public static void RequireLocal(HttpContext context)
@@ -70,7 +75,12 @@ public sealed class ServiceSecurity(ServiceOptions options)
throw new ServiceException("Unauthorized", 401, "Valid Bearer credential required.");
}
if (!context.Request.Cookies.TryGetValue(CookieName, out var id) || !sessions.TryGetValue(id, out var session))
{
if (IsLoopback(context)) return LocalIdentity;
throw new ServiceException("Unauthorized", 401, "Login required.");
}
if (session.Identity.LocalOnly && !IsLoopback(context))
throw new ServiceException("Unauthorized", 401, "Local session cannot be used remotely.");
if (session.Expires <= DateTimeOffset.UtcNow)
{
sessions.TryRemove(id, out _);
@@ -78,9 +88,8 @@ public sealed class ServiceSecurity(ServiceOptions options)
}
var current = RequireCurrent(session.Identity);
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
(context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf ||
!options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal)))
throw new ServiceException("CsrfRejected", 403, "CSRF token and same-origin request required.");
context.Request.Headers["X-CSRF-Token"].ToString() != session.Csrf)
throw new ServiceException("CsrfRejected", 403, "CSRF token required.");
return current;
}
@@ -93,8 +102,6 @@ public sealed class ServiceSecurity(ServiceOptions options)
{ loginWindow = DateTimeOffset.UtcNow; loginAttempts = 0; }
if (++loginAttempts > 10) throw new ServiceException("RateLimited", 429, "Wait before attempting login again.");
}
if (!options.AllowedOrigins.Contains(context.Request.Headers.Origin.ToString(), StringComparer.Ordinal))
throw new ServiceException("InvalidOrigin", 403, "Login requires an allowed Origin.");
var identity = AuthenticateToken(token) ?? throw new ServiceException("Unauthorized", 401, "Invalid credential.");
foreach (var entry in sessions.Where(s => s.Value.Expires <= DateTimeOffset.UtcNow)) sessions.TryRemove(entry.Key, out _);
if (sessions.Count >= 100) throw new ServiceException("RateLimited", 429, "Browser session limit reached.");
@@ -107,6 +114,9 @@ public sealed class ServiceSecurity(ServiceOptions options)
return new { identity.PrincipalId, identity.Permissions, identity.AccountIds, csrfToken = csrf, expires };
}
private static bool IsLoopback(HttpContext context) =>
context.Connection.RemoteIpAddress is { } address && IPAddress.IsLoopback(address);
public void Logout(HttpContext context)
{
if (context.Request.Cookies.TryGetValue(CookieName, out var id)) sessions.TryRemove(id, out _);
+12 -3
View File
@@ -38,7 +38,7 @@ async function get(path) {
async function mutate(path, body) {
return api(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, Origin: location.origin },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify(body)
});
}
@@ -226,7 +226,7 @@ $('loginForm').addEventListener('submit', async event => {
button.textContent = '登录中…';
clearError();
try {
const response = await api('/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: location.origin }, body: JSON.stringify({ token: $('token').value }) });
const response = await api('/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: $('token').value }) });
csrf = response.csrfToken;
$('token').value = '';
$('login').hidden = true;
@@ -248,6 +248,15 @@ $('lookup').onclick = async () => {
catch (error) { showError(error); }
};
$('logout').onclick = async () => {
try { await api('/api/v1/logout', { method: 'POST', headers: { 'X-CSRF-Token': csrf, Origin: location.origin } }); }
try { await api('/api/v1/logout', { method: 'POST', headers: { 'X-CSRF-Token': csrf } }); }
finally { if (refreshTimer) window.clearInterval(refreshTimer); $('app').hidden = true; $('login').hidden = false; $('connectionBadge').textContent = '未连接'; $('connectionBadge').className = 'badge neutral'; }
};
(async () => {
const local = await get('/api/v1/status');
if (local.ok) {
$('login').hidden = true;
$('app').hidden = false;
await refresh();
}
})();
+2 -2
View File
@@ -15,8 +15,8 @@
<p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
<section id="login" class="panel login-panel">
<h2>登录控制台</h2>
<p class="muted">Token 只在当前浏览器会话使用,不会保存到页面或 URL。</p>
<h2>远程登录控制台</h2>
<p class="muted">本机打开会自动进入;远程访问请输入 Token。Token 不会保存到页面或 URL。</p>
<form id="loginForm" class="login-form">
<label for="token">访问 Token</label>
<div class="input-row"><input id="token" type="password" autocomplete="off" required placeholder="粘贴 Token"><button id="loginButton">登录</button></div>
+171 -5
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@@ -91,13 +92,14 @@ internal sealed class TrayApplicationContext : ApplicationContext
menu.Items.Add(new ToolStripMenuItem("打开控制台", null, (_, _) => OpenConsole()));
menu.Items.Add(serviceToggle);
menu.Items.Add(reload);
menu.Items.Add(new ToolStripMenuItem("打开服务配置", null, (_, _) => OpenConfig()));
menu.Items.Add(new ToolStripMenuItem("服务设置...", null, (_, _) => OpenConfig()));
menu.Items.Add(new ToolStripMenuItem("访问凭据...", null, (_, _) => ConfigureCredentials()));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add(new ToolStripMenuItem("退出", null, async (_, _) => await ExitAsync()));
trayIcon = new NotifyIcon
{
Icon = SystemIcons.Application,
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath) ?? SystemIcons.Application,
Text = "WxAgent",
Visible = true,
ContextMenuStrip = menu
@@ -215,8 +217,31 @@ internal sealed class TrayApplicationContext : ApplicationContext
private void OpenConfig()
{
EnsureConfiguration();
Process.Start(new ProcessStartInfo("notepad.exe", $"\"{configPath}\"") { UseShellExecute = true });
try
{
EnsureConfiguration();
if (ServiceSettingsEditor.Show(options!) is not { } edited) return;
WriteJson(configPath, edited);
_ = ReloadAsync();
}
catch (Exception exception)
{
trayIcon.ShowBalloonTip(5000, "WxAgent 服务设置失败", exception.Message, ToolTipIcon.Error);
}
}
private void ConfigureCredentials()
{
try
{
EnsureConfiguration();
CredentialEditor.Show(options!.CredentialFile);
trayIcon.ShowBalloonTip(2500, "WxAgent", "访问凭据已更新。", ToolTipIcon.Info);
}
catch (Exception exception)
{
trayIcon.ShowBalloonTip(5000, "WxAgent 凭据配置失败", exception.Message, ToolTipIcon.Error);
}
}
private void SetStatus(string message)
@@ -271,7 +296,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
File.WriteAllText(path, JsonSerializer.Serialize(value, ServiceHost.Json), new UTF8Encoding(false));
}
private static void ShowToken(string token)
internal static void ShowToken(string token)
{
using var dialog = new Form
{
@@ -294,3 +319,144 @@ internal sealed class TrayApplicationContext : ApplicationContext
dialog.ShowDialog();
}
}
internal static class ServiceSettingsEditor
{
public static ServiceOptions? Show(ServiceOptions current)
{
if (!Uri.TryCreate(current.ListenUrl, UriKind.Absolute, out var uri))
throw new InvalidDataException("ListenUrl is invalid.");
var host = uri.Host.Trim('[', ']');
ServiceOptions? result = null;
using var form = new Form
{
Text = "WxAgent 服务设置",
Width = 620,
Height = 430,
StartPosition = FormStartPosition.CenterScreen,
MinimizeBox = false,
MaximizeBox = false,
FormBorderStyle = FormBorderStyle.FixedDialog
};
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 sessionLabel = new Label { Left = 18, Top = 91, Width = 120, Text = "监听会话" };
var sessionBox = new TextBox { Left = 145, Top = 87, Width = 345, Text = current.ListenerSession, MaxLength = 200 };
var queueLabel = new Label { Left = 18, Top = 129, Width = 120, Text = "队列容量" };
var queueBox = new NumericUpDown { Left = 145, Top = 125, Width = 90, Minimum = 1, Maximum = 100, Value = current.QueueCapacity };
var events = new CheckBox { Left = 145, Top = 163, Width = 430, Text = "启用监听事件流(仅受控测试使用)", Checked = current.EnableListenerEvents };
var files = new Label
{
Left = 18, Top = 205, Width = 560, Height = 58,
Text = $"凭据文件:{current.CredentialFile}{Environment.NewLine}数据目录:{current.DataDirectory}", AutoEllipsis = true
};
var note = new Label
{
Left = 18, Top = 274, Width = 560, Height = 42,
Text = "本机回环访问不需要 Token。远程访问仍需要 Token。Host/CORS 限制已移除,请用防火墙限制端口。"
};
var save = new Button { Left = 370, Top = 335, Width = 105, Text = "保存" };
var cancel = new Button { Left = 485, Top = 335, Width = 105, Text = "取消" };
save.Click += (_, _) =>
{
if (!IPAddress.TryParse(hostBox.Text.Trim().Trim('[', ']'), out var address))
{
MessageBox.Show("监听地址必须是 IP 地址。", "WxAgent", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var formattedHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
? $"[{address}]" : address.ToString();
var edited = new ServiceOptions
{
ListenUrl = $"http://{formattedHost}:{portBox.Value}",
AllowExternal = external.Checked,
AllowedHosts = current.AllowedHosts,
AllowedOrigins = current.AllowedOrigins,
CredentialFile = current.CredentialFile,
DataDirectory = current.DataDirectory,
QueueCapacity = (int)queueBox.Value,
ListenerSession = sessionBox.Text.Trim(),
EnableListenerEvents = events.Checked
};
try { edited.Validate(); }
catch (Exception exception)
{
MessageBox.Show(exception.Message, "WxAgent 设置无效", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
result = edited;
form.Close();
};
cancel.Click += (_, _) => form.Close();
form.Controls.AddRange([listenLabel, hostBox, portBox, external, sessionLabel, sessionBox, queueLabel, queueBox, events, files, note, save, cancel]);
form.AcceptButton = save;
form.CancelButton = cancel;
form.ShowDialog();
return result;
}
}
internal static class CredentialEditor
{
public static void Show(string path)
{
var credentials = Read(path).ToList();
using var form = new Form
{
Text = "WxAgent 访问凭据",
Width = 620,
Height = 390,
StartPosition = FormStartPosition.CenterScreen,
MinimizeBox = false,
MaximizeBox = false,
FormBorderStyle = FormBorderStyle.FixedDialog
};
var label = new Label { Left = 18, Top = 14, Width = 570, Height = 38, Text = "Token 只显示一次。撤销后立即失效;凭据文件只保存摘要,不保存明文 Token。" };
var list = new ListBox { Left = 18, Top = 58, Width = 570, Height = 210 };
var add = new Button { Left = 18, Top = 286, Width = 120, Text = "新建 Token" };
var revoke = new Button { Left = 148, Top = 286, Width = 120, Text = "撤销选中" };
var close = new Button { Left = 470, Top = 286, Width = 118, Text = "关闭" };
void RefreshList()
{
list.Items.Clear();
foreach (var credential in credentials)
list.Items.Add($"{credential.PrincipalId} · {string.Join(", ", credential.Permissions)}");
revoke.Enabled = list.SelectedIndex >= 0 && credentials.Count > 1;
}
list.SelectedIndexChanged += (_, _) => revoke.Enabled = list.SelectedIndex >= 0 && credentials.Count > 1;
add.Click += (_, _) =>
{
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
credentials.Add(new ServiceCredential($"local-{Guid.NewGuid():N}"[..14], ServiceOptions.HashToken(token), ["read", "content", "manage"], []));
Write(path, credentials);
RefreshList();
TrayApplicationContext.ShowToken(token);
};
revoke.Click += (_, _) =>
{
if (list.SelectedIndex < 0 || credentials.Count <= 1) return;
if (MessageBox.Show("撤销选中的 Token?现有连接会立即失效。", "WxAgent", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) return;
credentials.RemoveAt(list.SelectedIndex);
Write(path, credentials);
RefreshList();
};
close.Click += (_, _) => form.Close();
form.Controls.AddRange([label, list, add, revoke, close]);
form.AcceptButton = close;
RefreshList();
form.ShowDialog();
}
private static IReadOnlyList<ServiceCredential> Read(string path) =>
JsonSerializer.Deserialize<ServiceCredential[]>(File.ReadAllText(path), ServiceHost.Json)
?? throw new InvalidDataException("credentials.json is empty.");
private static void Write(string path, IReadOnlyList<ServiceCredential> credentials)
{
var temporary = path + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(credentials, ServiceHost.Json), new UTF8Encoding(false));
File.Move(temporary, path, true);
}
}
+4
View File
@@ -13,7 +13,11 @@
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<PublishTrimmed>false</PublishTrimmed>
<AssemblyName>WxAgent.Tray</AssemblyName>
<ApplicationIcon>wxagent.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Content Include="wxagent.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Always" ExcludeFromSingleFile="true" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WxAgent.Host\WxAgent.Host.csproj" />
<ProjectReference Include="..\WxAgent.Service\WxAgent.Service.csproj" />
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using WxAgent.Service;
using Xunit;
@@ -47,6 +48,21 @@ public sealed class ServiceBoundaryTests
finally { await app.StopAsync(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); Directory.Delete(dir, true); }
}
[Fact]
public void LoopbackRequestsCanUseLocalIdentityWithoutToken()
{
var options = new ServiceOptions { CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")), DataDirectory = Path.GetTempPath() };
var security = new ServiceSecurity(options);
var local = new DefaultHttpContext();
local.Connection.RemoteIpAddress = IPAddress.Loopback;
var identity = security.AuthenticateRequest(local);
Assert.True(identity.LocalOnly);
Assert.True(identity.Allows("manage"));
var remote = new DefaultHttpContext();
remote.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.1");
Assert.Throws<ServiceException>(() => security.AuthenticateRequest(remote));
}
[Fact]
public void ExternalBindingRequiresExplicitOptIn()
{
+2 -2
View File
@@ -45,10 +45,10 @@ public sealed class ServiceTests
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Add("Origin", "http://evil.invalid");
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Remove("Origin");
client.DefaultRequestHeaders.Host = "evil.invalid";
Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync("/api/v1/status")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/v1/status")).StatusCode);
client.DefaultRequestHeaders.Host = null;
client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream");