feat(compute): report Origin runtime health

This commit is contained in:
caoqianming 2026-08-13 12:34:27 +08:00
parent 054aaf8e8c
commit b016f8ee8c
11 changed files with 234 additions and 61 deletions

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from uuid import uuid4
@ -38,6 +39,18 @@ class ComputeNodeSecurityTests(unittest.TestCase):
self.assertEqual(len(digest), 64)
self.assertNotIn("ZCN-ABC", digest)
def test_websocket_auth_rejection_uses_explicit_application_close_code(self) -> None:
source = (
Path(__file__).resolve().parents[1] / "web" / "routers" / "compute_nodes.py"
).read_text(encoding="utf-8")
rejection = source.split("except (ValueError, ComputeNodeError):", 1)[1].split(
"await node_connections.activate", 1
)[0]
self.assertLess(
rejection.index("await websocket.accept()"), rejection.index("await websocket.close")
)
self.assertIn('code=4003, reason="invalid node credentials"', rejection)
class ComputeNodeConnectionTests(unittest.IsolatedAsyncioTestCase):
async def test_new_connection_replaces_old_without_removing_new(self) -> None:

View File

@ -34,6 +34,7 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("生成 Windows Node 注册码", html)
self.assertIn('"/v1/admin/compute-node-enrollments"', admin_js)
self.assertIn('capabilities: ["origin.plot@v1"]', admin_js)
self.assertIn('origin.health === "ready"', admin_js)
self.assertIn("ttl_seconds: 600", admin_js)
self.assertIn("navigator.clipboard.writeText(value)", admin_js)
self.assertIn('apiGet("/v1/admin/compute-nodes")', admin_js)

View File

@ -56,7 +56,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn("ClientSize = new Size(880, 720)", form)
self.assertIn("ClientSize = new Size(840, 680)", form)
self.assertIn("FormBorderStyle.Sizable", form)
self.assertIn("AutoScaleMode.Dpi", form)
self.assertIn("AutoScroll = true", form)
@ -65,12 +65,17 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("成功注册一次后立即失效", form)
self.assertIn("CreateCard", form)
self.assertIn("注册并连接", form)
self.assertIn("ContentWidth = 800", form)
self.assertIn("节点能力", form)
self.assertIn("ContentWidth = 760", form)
self.assertIn("Origin 绘图", form)
self.assertIn("当前 MVP 内置声明该协议,不需要手工配置", form)
self.assertIn('CreateCapabilityRow("Origin 绘图", "origin.plot@v1")', form)
self.assertIn('CreateButton("立即重连", 112, primary: true)', form)
self.assertIn("ReconnectRequested?.Invoke()", form)
self.assertIn("registrationCard.Visible = !registered", form)
self.assertIn("registeredActionsCard.Visible = registered", form)
self.assertIn("reconnect.Visible = registered", form)
self.assertIn("resetIdentity.Visible = registered", form)
tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
self.assertIn("form.ReconnectRequested += RestartConnection", tray)
def test_startup_task_is_login_scoped_and_runs_the_fixed_node_executable(self) -> None:
script = (ROOT / "install-startup.ps1").read_text(encoding="utf-8")
@ -80,6 +85,35 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("-RunLevel Limited", script)
self.assertNotIn("-RunLevel Highest", script)
def test_auth_rejection_is_distinct_from_http_websocket_handshake_failure(self) -> None:
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', connection)
self.assertIn('or HttpStatusCode.Forbidden', connection)
self.assertIn("WebSocket 握手被拒绝,请检查服务端或反向代理", connection)
self.assertIn("catch (NodeEndpointException exception)", connection)
self.assertIn("throw new NodeEndpointException", connection)
self.assertIn("(int?)result.CloseStatus == 4003", connection)
self.assertIn("节点身份已被服务端拒绝", connection)
self.assertNotIn("Node credentials were rejected", connection)
def test_origin_runtime_probe_is_read_only_and_reported(self) -> None:
probe = (PROJECT / "OriginRuntimeProbe.cs").read_text(encoding="utf-8")
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
self.assertIn('AutomationProgId = @"Origin.ApplicationSI\\CLSID"', probe)
self.assertIn("RegistryHive.LocalMachine", probe)
self.assertIn("RegistryHive.CurrentUser", probe)
self.assertIn('new("OriginPro", version, "0.1.0", health, detail)', probe)
self.assertIn('available_slots = origin.Health == "ready" ? 1 : 0', connection)
self.assertNotIn("CreateInstance", probe)
self.assertNotIn("Process.Start", probe)
for marker in (
"software_version = origin.SoftwareVersion",
"adapter_version = origin.AdapterVersion",
"health = origin.Health",
"detail = origin.Detail",
):
self.assertIn(marker, connection)
if __name__ == "__main__":
unittest.main()

View File

@ -80,7 +80,11 @@ def register_compute_node_routes(app, *, require_admin) -> None:
token = _bearer(websocket.headers.get("authorization"))
identity = await asyncio.to_thread(authenticate_node, node_id, token)
except (ValueError, ComputeNodeError):
await websocket.close(code=1008, reason="invalid node credentials")
# 握手前 close 会被 ASGI 统一表现为 HTTP 403客户端无法区分
# “凭据无效”和“代理/路由没有正确转发 WebSocket”。先升级再用
# 应用关闭码给已持有 Node ID/Token 的节点返回明确诊断。
await websocket.accept()
await websocket.close(code=4003, reason="invalid node credentials")
return
await websocket.accept()
await node_connections.activate(node_id, websocket)

View File

@ -169,10 +169,14 @@ function nodeStatusHTML(status) {
function renderWindowsNodes() {
const rows = computeNodes.map(node => {
const runtime = node.runtime || {};
const origin = runtime.origin || {};
const originState = origin.health === "ready" ? "Origin 可用" : "Origin 不可用";
const originVersion = origin.software_version ? ` ${origin.software_version}` : "";
const runtimeParts = [
node.os_version || "",
runtime.desktop_session === true ? "桌面会话" : "",
runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "",
runtime.origin ? `${originState}${originVersion}` : "",
].filter(Boolean);
const lastSeen = node.last_seen_at
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`

View File

@ -2,7 +2,7 @@
内网 MVP 的 Windows 执行节点,目标运行环境为 Windows 11 Enterprise + .NET 10 SDK 10.0.303。仓库根目录 `global.json` 固定 SDK patch客户端只使用 .NET Windows Desktop Framework不依赖第三方 NuGet 包。
当前实现托盘状态角标、小型配置窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连。尚未实现 `compute_jobs`、Origin Worker、任务目录和产物上传。
当前实现托盘状态角标、小型配置窗口、注册、DPAPI/ACL 配置保存、WebSocket `hello`/心跳和退避重连,并只读探测 Origin/OriginPro 安装版本、COM 自动化组件与桌面会话状态。尚未实现 `compute_jobs`、Origin Worker、任务目录和产物上传。
注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。

View File

@ -2,12 +2,13 @@ namespace Zcbot.WindowsNode;
internal sealed class ConfigurationForm : Form
{
private const int ContentWidth = 800;
private const int ContentWidth = 760;
private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765");
private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant());
private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true);
private readonly Button register = CreateButton("注册并连接", 128, primary: true);
private readonly Button reconnect = CreateButton("立即重连", 112, primary: true);
private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220);
private readonly CheckBox startAtLogin = new()
{
@ -25,18 +26,18 @@ internal sealed class ConfigurationForm : Form
private readonly Label identity = CreateBodyLabel();
private readonly Label capabilitySummary = CreateBodyLabel();
private readonly TableLayoutPanel registrationCard;
private readonly TableLayoutPanel registeredActionsCard;
private bool changingStartup;
internal event Func<EnrollOptions, Task>? RegisterRequested;
internal event Action? ReconnectRequested;
internal event Action? ResetIdentityRequested;
internal ConfigurationForm()
{
Text = "zcbot Windows Node";
AutoScaleMode = AutoScaleMode.Dpi;
ClientSize = new Size(880, 720);
MinimumSize = new Size(850, 680);
ClientSize = new Size(840, 680);
MinimumSize = new Size(820, 640);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9);
FormBorderStyle = FormBorderStyle.Sizable;
@ -62,7 +63,7 @@ internal sealed class ConfigurationForm : Form
AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top,
ColumnCount = 1,
RowCount = 6,
RowCount = 5,
BackColor = BackColor,
};
page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
@ -85,33 +86,19 @@ internal sealed class ConfigurationForm : Form
heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点"));
page.Controls.Add(heading);
var overview = new TableLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Top,
ColumnCount = 2,
Margin = new Padding(0, 0, 0, 14),
};
overview.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
overview.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
var statusCard = CreateCard();
statusCard.Margin = new Padding(0, 0, 7, 0);
statusCard.Controls.Add(CreateSectionTitle("节点状态"));
statusCard.Controls.Add(CreateSectionTitle("节点"));
statusCard.Controls.Add(state);
statusCard.Controls.Add(detail);
statusCard.Controls.Add(identity);
overview.Controls.Add(statusCard, 0, 0);
var capabilityCard = CreateCard();
capabilityCard.Margin = new Padding(7, 0, 0, 0);
capabilityCard.Controls.Add(CreateSectionTitle("节点能力"));
capabilityCard.Controls.Add(CreateCapabilityBadge("Origin 绘图", "origin.plot@v1"));
capabilityCard.Controls.Add(capabilitySummary);
capabilityCard.Controls.Add(CreateHint(
"当前 MVP 内置声明该协议,不需要手工配置;它不代表已完成 Origin 安装检测。"));
overview.Controls.Add(capabilityCard, 1, 0);
page.Controls.Add(overview);
statusCard.Controls.Add(CreateDivider());
statusCard.Controls.Add(CreateCapabilityRow("Origin 绘图", "origin.plot@v1"));
statusCard.Controls.Add(capabilitySummary);
var resetActions = CreateActions();
resetActions.Controls.Add(reconnect);
resetActions.Controls.Add(resetIdentity);
statusCard.Controls.Add(resetActions);
page.Controls.Add(statusCard);
registrationCard = CreateCard();
registrationCard.Controls.Add(CreateSectionTitle("首次注册"));
@ -125,15 +112,6 @@ internal sealed class ConfigurationForm : Form
registrationCard.Controls.Add(registerActions);
page.Controls.Add(registrationCard);
registeredActionsCard = CreateCard();
registeredActionsCard.Controls.Add(CreateSectionTitle("节点身份"));
registeredActionsCard.Controls.Add(CreateHint(
"服务地址、节点名称和能力在首次注册时确定。需要修改时,请先在管理后台删除旧节点,再清除本机身份并重新注册。"));
var identityActions = CreateActions();
identityActions.Controls.Add(resetIdentity);
registeredActionsCard.Controls.Add(identityActions);
page.Controls.Add(registeredActionsCard);
var runtimeCard = CreateCard();
runtimeCard.Controls.Add(CreateSectionTitle("运行设置"));
runtimeCard.Controls.Add(startAtLogin);
@ -147,6 +125,7 @@ internal sealed class ConfigurationForm : Form
page.Controls.Add(securityNote);
register.Click += async (_, _) => await RegisterAsync();
reconnect.Click += (_, _) => ReconnectRequested?.Invoke();
resetIdentity.Click += (_, _) => ResetIdentity();
startAtLogin.CheckedChanged += (_, _) => ToggleStartup();
FormClosing += (_, eventArgs) =>
@ -185,11 +164,12 @@ internal sealed class ConfigurationForm : Form
: $"节点:{config.NodeName}\nNode ID{config.NodeId}\n服务{config.ServerUrl}";
capabilitySummary.Text = config is null
? "注册后启用"
: $"已声明 {config.Capabilities.Count} 项能力";
: FormatOriginStatus(OriginRuntimeProbe.Detect());
var registered = config is not null;
registrationCard.Visible = !registered;
registeredActionsCard.Visible = registered;
reconnect.Visible = registered;
resetIdentity.Visible = registered;
register.Enabled = !registered && status.State != NodeState.Connecting;
if (registered)
{
@ -199,6 +179,15 @@ internal sealed class ConfigurationForm : Form
}
}
private static string FormatOriginStatus(OriginRuntimeStatus origin)
{
var version = string.IsNullOrWhiteSpace(origin.SoftwareVersion)
? "版本未知"
: $"版本 {origin.SoftwareVersion}";
var state = origin.Health == "ready" ? "可用" : "不可用";
return $"{state} · {version}\n{origin.Detail}";
}
private void ResetIdentity()
{
var answer = MessageBox.Show(
@ -287,7 +276,7 @@ internal sealed class ConfigurationForm : Form
AutoSize = true,
Dock = DockStyle.Fill,
ForeColor = Color.FromArgb(71, 85, 105),
MaximumSize = new Size(340, 0),
MaximumSize = new Size(ContentWidth - 40, 0),
Margin = new Padding(0, 2, 0, 5),
};
@ -300,25 +289,33 @@ internal sealed class ConfigurationForm : Form
Margin = new Padding(0, 2, 0, 4),
};
private static Panel CreateCapabilityBadge(string title, string protocol)
private static Panel CreateDivider() => new()
{
var badge = new Panel
Height = 1,
Dock = DockStyle.Top,
BackColor = Color.FromArgb(226, 232, 240),
Margin = new Padding(0, 10, 0, 12),
};
private static Panel CreateCapabilityRow(string title, string protocol)
{
var row = new Panel
{
AutoSize = false,
Height = 54,
Height = 36,
Dock = DockStyle.Top,
BackColor = Color.FromArgb(239, 246, 255),
Margin = new Padding(0, 2, 0, 8),
Padding = new Padding(12, 7, 12, 7),
Margin = new Padding(0, 0, 0, 5),
Padding = new Padding(11, 7, 11, 7),
};
badge.Controls.Add(new Label
row.Controls.Add(new Label
{
Text = $"{title}\n协议{protocol}",
Text = $"{title} · {protocol}",
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
ForeColor = Color.FromArgb(29, 78, 216),
});
return badge;
return row;
}
private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new()

View File

@ -49,6 +49,11 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
Report(NodeState.Offline, "服务端消息无效,等待重连");
Console.Error.WriteLine($"[WARN] Invalid server message: {exception.Message}");
}
catch (NodeEndpointException exception)
{
Report(NodeState.Offline, "WebSocket 握手被拒绝,请检查服务端或反向代理");
Console.Error.WriteLine($"[WARN] WebSocket handshake rejected: {exception.Message}");
}
var baseDelay = Backoff[Math.Min(attempt, Backoff.Length - 1)];
attempt++;
@ -73,12 +78,14 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
{
await socket.ConnectAsync(endpoint, cancellationToken);
}
catch (WebSocketException) when (
catch (WebSocketException exception) when (
socket.HttpStatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Report(NodeState.AuthenticationRequired, "身份失效,需要重新注册");
throw new NodeConfigurationException(
"Node credentials were rejected. Ask an administrator to re-register this node.");
throw new NodeEndpointException(
$"WebSocket handshake returned HTTP {(int?)socket.HttpStatusCode}. "
+ "Verify that the server includes the current Windows Node routes and that "
+ $"the reverse proxy forwards WebSocket Upgrade for {endpoint.AbsolutePath}. "
+ exception.Message);
}
Console.WriteLine("[OK] Node connected.");
Report(NodeState.Online, "已连接");
@ -122,10 +129,17 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
var result = await socket.ReceiveAsync(buffer, cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
if ((int?)result.CloseStatus == 4003)
{
throw new NodeConfigurationException(
"节点身份已被服务端拒绝。请在管理后台确认该 Node ID 未被禁用或删除;"
+ "若记录不存在或身份已撤销,请清除本机身份并使用新注册码重新注册。"
+ $" 服务端信息:{result.CloseStatusDescription}");
}
if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation)
{
throw new NodeConfigurationException(
$"Node credentials or install identity were rejected: {result.CloseStatusDescription}");
$"节点上报被服务端拒绝:{result.CloseStatusDescription}");
}
return;
}
@ -172,6 +186,7 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
private object RuntimePayload()
{
var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\";
var origin = OriginRuntimeProbe.Detect();
return new
{
install_id = config.InstallId,
@ -179,9 +194,17 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
node_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0",
os_version = RuntimeInformation.OSDescription,
capabilities = config.Capabilities,
available_slots = 1,
available_slots = origin.Health == "ready" ? 1 : 0,
disk_free_bytes = new DriveInfo(root).AvailableFreeSpace,
desktop_session = Environment.UserInteractive,
origin = new
{
software = origin.Software,
software_version = origin.SoftwareVersion,
adapter_version = origin.AdapterVersion,
health = origin.Health,
detail = origin.Detail,
},
};
}
}

View File

@ -46,3 +46,5 @@ internal sealed record NodePaths(string RootDirectory, string ConfigPath)
}
internal sealed class NodeConfigurationException(string message) : Exception(message);
internal sealed class NodeEndpointException(string message) : Exception(message);

View File

@ -0,0 +1,94 @@
using Microsoft.Win32;
using System.Security;
namespace Zcbot.WindowsNode;
internal sealed record OriginRuntimeStatus(
string Software,
string? SoftwareVersion,
string AdapterVersion,
string Health,
string Detail);
internal static class OriginRuntimeProbe
{
private static readonly Lazy<OriginRuntimeStatus> Current = new(DetectCore);
private const string AutomationProgId = @"Origin.ApplicationSI\CLSID";
internal static OriginRuntimeStatus Detect() => Current.Value;
private static OriginRuntimeStatus DetectCore()
{
try
{
var version = FindInstalledVersion();
using var automationKey = Registry.ClassesRoot.OpenSubKey(AutomationProgId);
var automationRegistered = automationKey is not null;
if (version is null && !automationRegistered)
{
return Status(null, "unavailable", "未检测到 Origin/OriginPro 安装");
}
if (!automationRegistered)
{
return Status(version, "unavailable", "已检测到 Origin但 COM 自动化组件未注册");
}
if (!Environment.UserInteractive)
{
return Status(version, "unavailable", "Origin 需要交互式 Windows 桌面会话");
}
return Status(version, "ready", "Origin COM 自动化组件可用");
}
catch (Exception exception) when (
exception is SecurityException or UnauthorizedAccessException or IOException)
{
return Status(null, "unavailable", $"Origin 运行时探测失败:{exception.Message}");
}
}
private static OriginRuntimeStatus Status(string? version, string health, string detail) =>
new("OriginPro", version, "0.1.0", health, detail);
private static string? FindInstalledVersion()
{
var candidates = new List<string>();
foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser })
{
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
using var uninstall = baseKey.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall");
if (uninstall is null)
{
continue;
}
foreach (var keyName in uninstall.GetSubKeyNames())
{
using var product = uninstall.OpenSubKey(keyName);
var name = product?.GetValue("DisplayName") as string;
var publisher = product?.GetValue("Publisher") as string;
if (!IsOriginProduct(name, publisher))
{
continue;
}
var version = product?.GetValue("DisplayVersion") as string;
if (!string.IsNullOrWhiteSpace(version))
{
candidates.Add(version.Trim());
}
}
}
}
return candidates.OrderByDescending(ParseVersion).ThenByDescending(x => x).FirstOrDefault();
}
private static bool IsOriginProduct(string? name, string? publisher) =>
!string.IsNullOrWhiteSpace(name)
&& (name.Equals("Origin", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Origin ", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("OriginPro", StringComparison.OrdinalIgnoreCase))
&& (publisher?.Contains("OriginLab", StringComparison.OrdinalIgnoreCase) ?? false);
private static Version ParseVersion(string value) =>
Version.TryParse(value, out var version) ? version : new Version(0, 0);
}

View File

@ -16,6 +16,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
this.store = store;
form = new ConfigurationForm();
form.RegisterRequested += RegisterAsync;
form.ReconnectRequested += RestartConnection;
form.ResetIdentityRequested += ResetIdentity;
statusItem = new ToolStripMenuItem("尚未注册") { Enabled = false };