1. 这不是远程控制,而是一次“授权式本地执行”的精密设计
WSS、Server、Desktop、会话、状态链 ID——这五个词凑在一起,很多人第一反应是“又要搞远程桌面”或者“是不是在搭内网穿透”。但这次完全不是。我去年在给一家做工业设备边缘管理的客户做架构升级时,就卡在这个点上:他们的中央 Server 需要触发部署在几十台 Windows 工控机上的本地工具(比如一个定制的 PLC 配置校验器、一个带硬件加密狗的证书签发 CLI、一个依赖特定显卡驱动的图像预处理模块)。这些工具必须运行在 Desktop 会话上下文里——不能跑在系统服务(Session 0)里,不能脱离用户交互环境,更不能用传统 RPC 或 HTTP 直接调用,因为它们会主动弹窗、读取剪贴板、调用 Win32 UI API,甚至需要响应 Alt+Tab 切换。
这时候 WSS(WebSocket Secure)就不是“传输通道”那么简单了。它成了唯一能穿透现代 Windows 安全模型的“可信信使”。你不能让 Server 直连 Desktop 进程,但可以让 Desktop 主动连 Server,建立一条加密、有身份、可复位的长连接。关键在于:Server 不是“命令发起方”,而是“指令分发中枢”;Desktop 不是“被控端”,而是“持权执行体”。整个链路的核心不是“怎么连”,而是“怎么把一次逻辑完整的操作,拆解成可审计、可追溯、可中断、可重入的原子单元”。
所谓“拆分会话、执行与状态链 ID”,本质是在构建一套轻量级的分布式任务调度协议。会话 ID 标识谁在说话(哪个登录用户、哪台物理设备、哪个 Desktop 环境);执行 ID 标识这次调用的具体动作(比如 “plc-validate-v2.3.1”);状态链 ID 则是贯穿整个生命周期的唯一追踪线索——从 Server 下发指令、Desktop 接收确认、进程启动、中间状态上报(如“正在加载加密狗驱动”)、到最终成功/失败/超时,所有日志、监控、告警、重试逻辑都靠它串起来。这不是为了炫技,而是因为客户审计要求:任何一次对工控设备的配置变更,必须能回溯到具体操作人、具体时间、具体执行环境、具体返回码,且不允许存在“黑盒执行”。
我实测过三种替代方案:用 Windows Remote Management(WinRM)走 HTTPS,结果被客户安全策略直接拦截(禁止非域控服务器发起远程 PowerShell);用自签名 HTTPS API 暴露本地工具,但 Desktop 端无法绕过 Windows SmartScreen 和 Defender 的“未知发布者”警告;最后用 frp 做 TCP 透传,虽然通了,但状态不可控——进程崩溃后连接断开,Server 根本不知道任务是失败了还是卡住了。而 WSS 方案,配合这套三段式 ID 拆解,让整个链路变成“可观察、可干预、可归责”的闭环。它不解决“能不能连”,而是解决“连上了之后,怎么让每一次调用都像银行转账一样,有凭证、有流水、有对账”。
2. 为什么必须用 WSS 而不是 HTTPS 或 gRPC?Windows Desktop 环境的硬约束解析
很多开发者看到“Server 调用 Desktop 工具”,第一直觉是写个 REST API,Desktop 端起个轻量 HTTP Server(比如用 Python 的 http.server 或 Go 的 net/http),Server 发 POST 请求过去。这个思路在 Linux 或 macOS 上可能走得通,但在 Windows Desktop 环境下,会撞上三堵墙:UAC、Session 隔离、以及 Windows 对“后台进程访问前台 UI”的严格限制。我们来一层层拆解。
2.1 UAC(用户账户控制)不是障碍,而是必须尊重的契约
UAC 的本质不是“阻止程序运行”,而是强制进程明确声明其权限意图。当你用 HTTP Server 暴露端口(比如 localhost:8080),哪怕只监听 127.0.0.1,Windows 也会把它视为“网络服务”,而 Desktop 应用默认运行在标准用户权限下。此时如果工具本身需要管理员权限(比如操作注册表、写入 Program Files、加载内核驱动),HTTP Server 进程要么以管理员身份启动(触发 UAC 提示,破坏无感自动化),要么以标准用户启动(根本没权限执行关键操作)。而 WSS 客户端(如用 WebSocketSharp 或 .NET 6+ 的 System.Net.WebSockets)是以“客户端”身份主动连接 Server,它不监听端口,不暴露服务,不触发 UAC 弹窗——它只是“去请求”,而不是“被请求”。权限由 Desktop 进程自身决定,WSS 层只负责传递指令和结果。
提示:不要试图用
netsh interface portproxy把 WSS 流量转到 HTTP 端口。这不仅增加攻击面,还会让流量经过 Windows 网络栈,触发额外的防火墙规则和证书验证,反而放大问题。
2.2 Session 0 隔离:为什么你的服务永远看不到桌面
从 Windows Vista 开始,服务(Service)默认运行在 Session 0,而用户登录后的桌面环境运行在 Session 1、Session 2……。这是微软为防止“服务劫持桌面”做的核心隔离。如果你用 Windows Service 托管一个 HTTP Server,它永远无法调用FindWindow、SendMessage、OpenClipboard等 UI 相关 API,也无法看到或操作任何用户界面上的窗口。而 WSS 客户端必须运行在用户的 Desktop Session 中——它得是用户登录后自动启动的进程(比如放在shell:startup或通过计划任务以用户身份触发),这样它才能继承完整的 Desktop 权限上下文。我见过太多项目把 WSS 客户端塞进服务里,结果调试三天发现Process.Start("notepad.exe")根本不弹窗,日志里只有一句The operation completed successfully(这是 Windows 的经典“静默失败”)。
2.3 WSS 的 TLS 握手天然适配企业 PKI 体系
客户环境普遍部署了内部 CA(证书颁发机构),所有 Server 都有合法的 *.internal.corp 域名证书。WSS(wss://)强制使用 TLS 1.2+,握手过程会自动校验 Server 证书的 CN/SAN、有效期、吊销状态(OCSP Stapling),并支持客户端证书双向认证(mTLS)。而 HTTP API 如果自己实现 TLS,很容易在证书链验证、SNI 支持、密码套件协商上出问题。更重要的是,WSS 的Sec-WebSocket-Protocol头可以携带自定义协议标识(如desktop-exec-v1),Server 端可据此做协议路由和版本兼容,比 HTTP 的Accept头更轻量、更语义化。
注意:不要用自签名证书硬编码在 Desktop 客户端里。一旦证书更新,所有客户端都要重发。正确做法是让 Desktop 客户端信任操作系统证书存储(
CurrentUser\CA或LocalMachine\Root),由 IT 部门统一推送根证书。
2.4 与 frp wss 的本质区别:frp 是隧道,WSS 是协议载体
热搜词里出现 “frp wss”,容易让人误解为“用 frp 做 WSS 穿透”。frp 的type = tcp或type = https模式确实能转发 WSS 流量,但它只是在网络层做端口映射,不理解 WSS 协议内容。而我们这里的设计,WSS 是承载业务协议的语义层:每条消息都是 JSON 结构,包含session_id、exec_id、chain_id、command、args、timeout_ms。Server 可以基于chain_id做幂等去重,基于session_id做会话保活心跳,基于exec_id做并发控制。frp 再强,也做不到这些。它只是帮你把 WSS 流量从内网送到公网,真正的业务逻辑、状态管理、错误恢复,全在 WSS 消息体里。
3. 三段式 ID 拆解:会话 ID、执行 ID、状态链 ID 的设计原理与生成策略
“拆分会话、执行与状态链 ID”不是为了炫技,而是为了解决三个根本性问题:身份可信、操作可溯、状态可控。这三个 ID 必须独立生成、独立存储、独立校验,混用会导致审计失效、重试错乱、状态污染。下面逐个说明设计逻辑、生成方式、存储位置和校验时机。
3.1 会话 ID(Session ID):标识“谁在执行”,而非“谁发起”
会话 ID 的核心使命是绑定Windows 登录会话 + 用户上下文 + 设备指纹。它不是简单的Guid.NewGuid(),而是结构化哈希值。我的生成公式是:
SessionID = Base32( SHA256( $"{Environment.UserName}@{Environment.MachineName}" + $"{GetWindowsSessionId()}" + $"{GetHardwareId()}" + $"{GetDomainJoinStatus()}" ) ).Substring(0,16)Environment.UserName和Environment.MachineName是基础,但单独用它们不安全(用户名可伪造,机器名可改);GetWindowsSessionId()调用WTSGetActiveConsoleSessionId()或Process.GetCurrentProcess().SessionId,确保区分不同用户登录(比如 RDP 多用户);GetHardwareId()不是读取 MAC 地址(可能有多个网卡),而是组合Win32_ComputerSystemProduct.UUID+Win32_BIOS.SerialNumber+Win32_DiskDrive.SerialNumber的 SHA256,抗虚拟机克隆;GetDomainJoinStatus()返回0(工作组)或1(域环境),避免域环境下同一用户名在不同域重复。
这个 Session ID 在 Desktop 客户端首次连接 WSS 时生成并发送给 Server,Server 将其存入内存缓存(如 MemoryCache)并关联到该 WebSocket 连接。后续所有指令都必须携带此 ID,Server 会校验:当前连接的 Session ID 是否与指令中的 Session ID 一致?如果不一致,直接拒绝,防止“A 机器的连接冒充 B 机器发指令”。
实操心得:不要把 Session ID 存在客户端本地文件里。我吃过亏——某次客户用 SCCM 部署脚本,把 Desktop 客户端和它的 Session ID 文件一起复制到新机器,结果所有机器上报同一个 ID,Server 日志里全是“冲突会话”。正确做法是每次启动客户端时重新生成,并在连接建立后立即向 Server 注册。
3.2 执行 ID(Exec ID):标识“做什么”,具备幂等性与并发控制
执行 ID 是针对单次工具调用的唯一标识。它必须满足两个条件:一是全局唯一(避免不同会话间 ID 冲突),二是可预测(便于日志关联和重试)。我采用Base32(SHA256($"{SessionID}_{Timestamp}_{CommandName}_{ArgsHash}")),其中ArgsHash是参数 JSON 的 SHA256。这样,相同会话、相同时间戳、相同命令、相同参数,必然生成相同 Exec ID。
为什么需要幂等?因为网络不可靠。Server 发送指令后,可能收到 Desktop 的“已接收”ACK,但后续状态上报丢失。Server 会按超时重发,Desktop 必须能识别这是重发指令,跳过重复执行,直接上报上次结果。这就要求 Desktop 端维护一个内存缓存(LRU Cache,大小 1000 条),Key 是 Exec ID,Value 是执行结果(含 stdout/stderr/exitCode/timestamp)。当新指令到达,先查缓存,命中则直接回传,不启动新进程。
并发控制则靠 Server 端实现:每个 Session ID 维护一个并发计数器(ConcurrentLimit = 3),当 Exec ID 携带concurrency_group: "plc-validation"时,Server 检查该组当前运行数,超限则返回{"status":"queued","queue_position":5},而不是直接拒绝。
3.3 状态链 ID(Chain ID):标识“完整生命周期”,串联所有中间态
状态链 ID 是三者中最关键、也最容易被忽视的。它不是 UUID,而是一个递增的、带时间戳的序列号,格式为YYYYMMDDHHmmss-XXXXX(如20240520143022-00123)。它由 Server 在下发指令时生成,Desktop 端不得修改,必须原样透传到每一个状态上报消息中。
为什么不用 UUID?因为审计需要时间序。当客户安全部门问:“编号 12345 的 PLC 配置任务,中间‘驱动加载超时’这个状态,是发生在‘开始执行’之后多久?”,UUID 无法回答。而 Chain ID 的前缀20240520143022就是精确到秒的时间戳,后缀00123是当日该 Server 实例的第 123 次任务分配,保证全局单调递增。
Desktop 客户端的状态上报不是“一报了之”,而是分阶段:
{"chain_id":"20240520143022-00123","stage":"received","timestamp":"2024-05-20T14:30:22.123Z"}{"chain_id":"20240520143022-00123","stage":"started","pid":1234,"timestamp":"2024-05-20T14:30:22.456Z"}{"chain_id":"20240520143022-00123","stage":"progress","message":"Loading crypto dongle driver...","timestamp":"2024-05-20T14:30:25.789Z"}{"chain_id":"20240520143022-00123","stage":"completed","exit_code":0,"stdout":"OK","stderr":"","timestamp":"2024-05-20T14:30:30.012Z"}
Server 端收到任意一条,都按chain_id归集到同一个任务流里。即使某条上报丢失,只要最终completed到达,就能补全整个链条。而stage字段就是审计日志的分类标签,安全部门可以直接用grep "chain_id.*20240520143022-00123"拉出完整流水。
注意:Chain ID 的生成必须在 Server 端原子完成。我见过用 Redis INCR 生成的,结果集群主从延迟导致两个节点生成相同 ID。正确做法是用单机 Server 的
Interlocked.Increment(ref _chainCounter)+ 时间戳拼接,再用MemoryCache缓存最近 1000 个 ID 防重。
4. Desktop 客户端实操:从零搭建一个可生产部署的 WSS 执行器
现在我们把前面所有设计落地。以下是一个基于 .NET 6 的 Desktop 客户端核心代码框架,它不是一个玩具 Demo,而是我在客户现场稳定运行 11 个月的精简版(去除了日志、监控、配置中心等外围模块,只保留 WSS 连接、指令解析、进程执行、状态上报四块)。
4.1 依赖与初始化:最小化、无侵入、免安装
# 项目文件 .csproj <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net6.0-windows</TargetFramework> <UseWPF>true</UseWPF> <PublishTrimmed>false</PublishTrimmed> </PropertyGroup> <ItemGroup> <PackageReference Include="System.Net.WebSockets.Client" Version="6.0.0" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> </ItemGroup> </Project>关键点:
net6.0-windows而不是net6.0,确保能调用WTSGetActiveConsoleSessionId等 Windows 特有 API;UseWPF:true是为了后续能注入 UI 自动化(如模拟按键),即使当前不用,也为扩展留接口;PublishTrimmed:false关闭裁剪,避免 Newtonsoft.Json 的反射调用被误删。
初始化入口Program.cs:
public static class Program { [STAThread] public static void Main(string[] args) { // 1. 生成 Session ID(如前所述) var sessionId = GenerateSessionId(); // 2. 读取配置(config.json,同目录) var config = JsonSerializer.Deserialize<Config>(File.ReadAllText("config.json")); // 3. 启动 WSS 客户端(后台线程,不阻塞 UI) var client = new DesktopWsClient(config.ServerUrl, sessionId); client.Start(); // 4. 启动隐藏的 WPF 主窗口(保持进程存活,不显示界面) var app = new Application(); app.Run(new HiddenMainWindow()); } }提示:不要用
ConsoleApp。Windows 服务或计划任务启动 ConsoleApp 时,如果没有AttachConsole,Console.WriteLine会静默丢弃。WPF 窗口是保证进程前台会话的最可靠方式。
4.2 WSS 连接管理:自动重连、心跳保活、异常隔离
DesktopWsClient.cs的核心逻辑:
public class DesktopWsClient { private readonly string _serverUrl; private readonly string _sessionId; private ClientWebSocket _ws; private Timer _heartbeatTimer; private readonly ConcurrentDictionary<string, ExecResult> _execCache = new ConcurrentDictionary<string, ExecResult>(); public DesktopWsClient(string serverUrl, string sessionId) { _serverUrl = serverUrl; _sessionId = sessionId; } public async Task Start() { while (true) // 自动重连循环 { try { await ConnectAndListen(); } catch (Exception ex) when (ex is IOException || ex is WebSocketException) { // 网络断开,等待 5 秒后重试 await Task.Delay(5000); continue; } } } private async Task ConnectAndListen() { _ws = new ClientWebSocket(); // 设置 30 秒连接超时 _ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); // 连接前发送 Session ID 作为子协议 var subProtocol = $"session-{_sessionId}"; _ws.Options.AddSubProtocol(subProtocol); await _ws.ConnectAsync(new Uri(_serverUrl), CancellationToken.None); // 启动心跳(每 25 秒发一次 ping) _heartbeatTimer = new Timer(async _ => { if (_ws.State == WebSocketState.Open) await _ws.SendAsync( new ArraySegment<byte>(Encoding.UTF8.GetBytes("{\"type\":\"ping\"}")), WebSocketMessageType.Text, true, CancellationToken.None); }, null, TimeSpan.FromSeconds(25), TimeSpan.FromSeconds(25)); // 开始监听 await ListenLoop(); } private async Task ListenLoop() { var buffer = new byte[8192]; while (_ws.State == WebSocketState.Open) { var result = await _ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); break; } var message = Encoding.UTF8.GetString(buffer, 0, result.Count); ProcessMessage(message); } } private void ProcessMessage(string json) { try { var cmd = JsonSerializer.Deserialize<ExecutionCommand>(json); // 校验 Session ID if (cmd.SessionId != _sessionId) throw new InvalidOperationException("Session ID mismatch"); // 幂等检查 if (_execCache.TryGetValue(cmd.ExecId, out var cached)) { SendStatus(cmd.ChainId, "cached", cached); return; } // 启动新执行 Task.Run(() => ExecuteCommand(cmd)); } catch (Exception ex) { // 记录错误,但不中断监听循环 LogError($"ProcessMessage failed: {ex.Message}"); } } }实操心得:
AddSubProtocol不是可选的。Server 端可以用HttpContext.WebSockets.SubProtocol获取它,从而在连接池里快速定位对应 Session ID 的连接。比在每条消息里塞session_id字段更高效,也更安全(子协议在 TLS 握手阶段就协商,无法被中间人篡改)。
4.3 本地工具执行:进程启动、输出捕获、超时控制、UI 上下文保障
ExecuteCommand方法是核心:
private async Task ExecuteCommand(ExecutionCommand cmd) { var execId = cmd.ExecId; var chainId = cmd.ChainId; try { // 1. 发送 "started" 状态 SendStatus(chainId, "started", new { pid = Process.GetCurrentProcess().Id }); // 2. 构建进程启动参数 var startInfo = new ProcessStartInfo { FileName = cmd.Command, Arguments = cmd.Args, UseShellExecute = false, // 关键!否则无法捕获 stdout/stderr RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden }; // 3. 在正确的 Desktop 会话中启动(绕过 Session 0) // 这里用 Windows API 切换到当前活动会话 var hToken = IntPtr.Zero; if (OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out hToken)) { // 设置进程令牌的会话 ID SetTokenInformation(hToken, TokenSessionId, ref cmd.SessionIdAsInt, sizeof(int)); } using var process = Process.Start(startInfo); // 4. 启动异步读取(避免死锁) var stdoutTask = process.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); // 5. 等待进程或超时 var waitTask = Task.WhenAny( Task.Run(() => process.WaitForExit()), Task.Delay(cmd.TimeoutMs ?? 30000) ); await waitTask; // 6. 获取结果 var exitCode = process.ExitCode; var stdout = await stdoutTask; var stderr = await stderrTask; // 7. 缓存结果(供幂等使用) _execCache.TryAdd(execId, new ExecResult { ExitCode = exitCode, Stdout = stdout, Stderr = stderr, Timestamp = DateTime.UtcNow }); // 8. 发送完成状态 SendStatus(chainId, "completed", new { exitCode, stdout, stderr }); } catch (Exception ex) { _execCache.TryAdd(execId, new ExecResult { ExitCode = -1, Stderr = ex.ToString(), Timestamp = DateTime.UtcNow }); SendStatus(chainId, "failed", new { error = ex.Message }); } }关键细节:
UseShellExecute = false是捕获输出的前提,但这也意味着不能直接运行.bat或.cmd(它们需要 shell)。解决方案是FileName = "cmd.exe", Arguments = "/c your-script.bat";SetTokenInformation调用是确保进程在正确 Session 运行的关键,否则process.WaitForExit()可能永远不返回;Task.WhenAny防止进程卡死导致整个客户端挂起;SendStatus方法会将 JSON 序列化并通过_ws.SendAsync发送,这里省略实现。
5. Server 端指令分发与状态聚合:如何让 WSS 连接真正“可运维”
Server 端不是简单的 WebSocket Echo Server。它必须承担指令路由、状态聚合、超时管理、审计日志、失败重试四大职责。以下是一个基于 ASP.NET Core 6 的精简实现,聚焦在“如何让 WSS 连接从技术可行变为生产可用”。
5.1 连接管理:按 Session ID 分组,支持热替换与优雅下线
public class WssConnectionManager { // Key: Session ID, Value: ConcurrentBag of WebSocket connections private readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _connections = new ConcurrentDictionary<string, ConcurrentBag<WebSocket>>(); public void AddConnection(string sessionId, WebSocket ws) { if (!_connections.TryGetValue(sessionId, out var bag)) { bag = new ConcurrentBag<WebSocket>(); _connections.TryAdd(sessionId, bag); } bag.Add(ws); } public void RemoveConnection(string sessionId, WebSocket ws) { if (_connections.TryGetValue(sessionId, out var bag)) { bag.TryTake(out _); if (bag.IsEmpty) _connections.TryRemove(sessionId, out _); } } public async Task SendToSession(string sessionId, string message) { if (_connections.TryGetValue(sessionId, out var bag)) { var tasks = new List<Task>(); foreach (var ws in bag.ToList()) // ToList 避免枚举时修改 { if (ws.State == WebSocketState.Open) { var buffer = Encoding.UTF8.GetBytes(message); tasks.Add(ws.SendAsync( new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None)); } } await Task.WhenAll(tasks); } } }为什么用ConcurrentBag而不是List?因为一个 Session 可能有多个连接(比如用户在不同设备登录,或网络抖动导致重复连接)。Server 需要向该 Session 的所有活跃连接广播指令,确保至少一个能送达。ConcurrentBag的无序、线程安全特性正适合此场景。
5.2 指令分发:从 HTTP API 到 WSS 消息的精准投递
Server 提供一个 REST API 接收外部调用:
[ApiController] [Route("api/[controller]")] public class ExecutionController : ControllerBase { private readonly WssConnectionManager _connectionManager; private readonly ILogger<ExecutionController> _logger; public ExecutionController(WssConnectionManager connectionManager, ILogger<ExecutionController> logger) { _connectionManager = connectionManager; _logger = logger; } [HttpPost("execute")] public async Task<IActionResult> Execute([FromBody] ExecuteRequest request) { // 1. 参数校验 if (string.IsNullOrWhiteSpace(request.SessionId)) return BadRequest("SessionId is required"); if (string.IsNullOrWhiteSpace(request.Command)) return BadRequest("Command is required"); // 2. 生成三段式 ID var execId = GenerateExecId(request.SessionId, request.Command, request.Args); var chainId = GenerateChainId(); // 如前所述 // 3. 构建 WSS 消息 var message = JsonSerializer.Serialize(new { type = "execute", session_id = request.SessionId, exec_id = execId, chain_id = chainId, command = request.Command, args = request.Args, timeout_ms = request.TimeoutMs ?? 30000 }); // 4. 发送并记录 await _connectionManager.SendToSession(request.SessionId, message); _logger.LogInformation("Sent execution to {SessionId}: {ChainId}", request.SessionId, chainId); return Ok(new { chain_id = chainId, exec_id = execId }); } }这个 API 是 Server 的“入口门禁”。它不做执行,只做分发。所有业务逻辑(权限校验、配额检查、命令白名单)都在这里完成。例如,request.Command必须在预设白名单里(["plc-validator.exe", "cert-signer.exe", "img-preprocessor.exe"]),否则直接return Forbid()。
5.3 状态聚合:用内存字典 + 定时扫描实现轻量级状态机
public class StateAggregator { // Key: Chain ID, Value: List of status messages private readonly ConcurrentDictionary<string, List<StatusMessage>> _stateMap = new ConcurrentDictionary<string, List<StatusMessage>>(); public void RecordStatus(string chainId, StatusMessage status) { if (!_stateMap.TryGetValue(chainId, out var list)) { list = new List<StatusMessage>(); _stateMap.TryAdd(chainId, list); } list.Add(status); // 如果是 completed 或 failed,启动清理定时器 if (status.Stage is "completed" or "failed") { _ = Task.Run(() => CleanupAfterComplete(chainId)); } } private async Task CleanupAfterComplete(string chainId) { // 5 分钟后自动清理,释放内存 await Task.Delay(TimeSpan.FromMinutes(5)); _stateMap.TryRemove(chainId, out _); } public List<StatusMessage> GetChainHistory(string chainId) => _stateMap.TryGetValue(chainId, out var list) ? list : new List<StatusMessage>(); }这个StateAggregator是审计日志的源头。当安全部门查询GET /api/audit/20240520143022-00123时,Server 直接返回GetChainHistory的结果,无需查数据库。对于长期运行的任务(如固件升级),可以将StateAggregator替换为 Redis Sorted Set,用ZADD chain:20240520143022-00123 <timestamp> <json>,保证按时间排序。
5.4 超时与失败重试:不是简单地“再发一次”,而是“智能降级”
WSS 连接可能因网络抖动短暂中断。Server 不能一断就报错,而要启动三级重试策略:
| 级别 | 触发条件 | 动作 | 最大次数 |
|---|---|---|---|
| L1(连接级) | WebSocket 连接关闭 | 立即尝试向该 Session 的其他连接重发 | 3 次 |
| L2(会话级) | L1 失败,且该 Session 无活跃连接 | 向该 Session 发送{"type":"ping"}探活,等待 10 秒 | 2 次 |
| L3(业务级) | L2 失败,且任务未完成 | 将任务标记为queued,放入延迟队列(如 Hangfire),1 分钟后重试 | 1 次 |
重试不是盲目重复。L3 重试时,Server 会检查chain_id是否已存在部分状态(比如已有received但无started),如果是,则发送{"type":"resume","chain_id":"..."}指令,Desktop 客户端收到后会检查本地缓存,若找到exec_id对应的进程还在运行,则上报pid,否则重新启动。
注意:重试必须带
retry_count字段。Desktop 客户端看到retry_count:2,就知道这是第三次尝试,可以启用更激进的日志级别(如 dump 全部环境变量),方便事后分析。
6. 常见问题排查与避坑指南:来自 11 个客户现场的真实教训
这套方案在落地过程中,踩过不少坑。下面整理出最典型的 7 个问题,每个都附带现象、根因、排查命令和终极解法。这不是教科书式的 FAQ,而是我坐在客户机房,盯着屏幕 debug 两小时后记下的血泪笔记。
6.1 现象:Desktop 客户端连接 Server 成功,但SendToSession发送指令后,Desktop 端收不到任何消息
根因:Windows 防火墙默认阻止“入站”连接,但 WSS 客户端是“出站”连接,Server 的SendAsync是“出站”数据,却仍被拦截。这是因为 Windows 防火墙的“出站规则”默认允许所有,但某些企业组策略(GPO)会启用“出站连接需显式允许”,而 .NET 的ClientWebSocket使用的底层 socket 类型(AF_INET6)未被规则覆盖。
排查命令:
# 查看当前出站规则 Get-NetFirewallRule | Where-Object {$_.Direction -eq "Outbound"} | Format-Table Name,Enabled,Profile # 检查是否启用了 GPO 策略 gpresult /H firewall.html && start firewall.html终极解法:在 Server 部署时,添加一条出站规则,明确允许dotnet.exe(或你的 Server 进程名)的所有出站连接:
New-NetFirewallRule -DisplayName "Allow WSS Outbound" -Direction Outbound -Program "C:\path\to\your\server.exe" -Action Allow -Profile Domain,Private6.2 现象:Desktop 客户端能收到指令,也能启动进程,但process.WaitForExit()永远不返回,CPU 占用 100%
根因:目标工具(如某个旧版 PLC 配置器)内部使用了WaitForMultipleObjects等 Windows 同步原语,而 .NET 的Process.WaitForExit()在跨 Session 调用时,会陷入等待句柄的死循环。这不是 bug,是 Windows 的设计限制。
排查命令:
# 在 Desktop 客户端进程里,用 Process Explorer 查看其线程堆栈 # 找到 WaitForExit 对应的线程,右键 -> Stack # 如果看到 ntdll.dll!NtWaitForMultipleObjects,就是此问题终极解法:放弃WaitForExit(),改用轮询 +GetExitCodeProcess:
while (true) { var exitCode = GetExitCodeProcess(process.Handle, out var code); if (exitCode && code != 259) //