diff --git a/.gitignore b/.gitignore index 8ebced5..cc05c98 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ __pycache__/ coverage.json htmlcov/ +# .NET Windows Node build outputs +windows-node/**/bin/ +windows-node/**/obj/ + # Virtualenv .venv/ venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf06fb..c0af970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 工程口径的完整记录见 `PROGRESS.md` / git log。 +## 0.65.0 — 2026-08-12 + +- 新增 Windows Node:可在 Windows 本机通过托盘程序注册并连接 zcbot,为后续受控调用本机科研软件提供节点基础;管理后台可生成 10 分钟有效、仅可使用一次的注册码。 + ## 0.64.3 — 2026-08-12 - 粘贴、拖拽或上传同名文件时不再静默覆盖,系统会自动编号保留两份;需要覆盖时,可在文件菜单选择「替换文件」并确认。 diff --git a/PROGRESS.md b/PROGRESS.md index f8a5060..492d52f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-12(同名上传保护 + 明确替换文件,bump 0.64.3) +最后更新:2026-08-12(Windows Node 客户端 + 管理端注册码,bump 0.65.0) --- @@ -23,6 +23,8 @@ ### 2026-08-12 +- **08-12 / 0.65.0 / Windows Node 客户端 + 管理端注册码**:新增 .NET 10 WinForms 托盘节点,可通过界面或 CLI 使用一次性注册码完成注册,使用 DPAPI LocalMachine 加密 Node Token,并将配置 ACL 收紧至注册账号与 SYSTEM;支持登录后自启动、状态角标、配置窗、重连和身份清除。管理后台新增显式生成入口,可限定节点名称,展示 10 分钟过期时间并一键复制,注册码不写浏览器存储。后端注册、WebSocket 心跳、节点禁用与 0030 schema 已在前一基础提交落地;Windows Node、计算节点及管理前端相关 27 项 unittest、.NET build/format、JavaScript 语法与 diff 检查通过,未连接或迁移生产 DB。 + - **08-12 / 0.64.3 / 同名上传保护 + 明确替换文件**:粘贴、拖拽与普通上传默认由服务端原子独占创建,同名按 `name (n).ext` 自动编号且并发请求不会互相覆盖;文件菜单新增「替换文件」,选择本地文件并二次确认后才以同目录临时文件原子替换指定目标。Python 冲突策略 4 项、Node 前端契约 2 项、JavaScript/Python 语法及 diff 检查通过;DB 路由回归已补,未配置显式测试库时安全跳过;无 schema、migration 或依赖变化,未连接生产 DB。 - **08-12 / 0.64.2 / 数据库热路径 + 消息序号原子化**:新增 0029 migration 平滑回填 `tasks.next_message_idx`,Web、agent 与渠道消息统一在 task 行锁下分配序号,并以实际 `max(idx)` 兼容蓝绿旧实例并行;任务列表、调度扫描/历史和 usage 时间序列补部分/BRIN 索引,列表追加 UUID 稳定排序,artifact 目录操作只查询并锁定目标子树。完整 532 项 unittest 全绿(17 skip),0029 PostgreSQL DDL 编译、Alembic 单 head、Ruff 致命规则及 diff 检查通过;未配置显式测试库,DB 集成测试安全跳过,未连接或写入生产 DB。 @@ -333,7 +335,8 @@ core/loop.py 812 ← ReAct 主循环:_RepeatGuard/stall 熔断/ core/llm_transport.py 438 ← wire 层健壮性:畸形/吐空检测+留痕+非流式降级重试(07-23 自 loop 析出) core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,factory);secret/host 工具按实际能力 gate) core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段 -core/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + Factory OpenAPI connector +core/external_systems/*.py ← 外部系统目录/用户授权/凭据加密 + 通用 OpenAPI/MCP connector +core/compute_nodes.py ← Windows Node 注册码、身份认证与运行状态 core/sinks.py 101 core/paths.py 50 ← task_dir db form 归一 core/probe.py 243 @@ -353,14 +356,15 @@ core/agent_builder.py 649 ← 装配 lib:build_agent/system prompt(工具 core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池 tools/{base,output,fs,shell,run_python,skill_tool,skill_authoring,media_common,seedream,seedance,gpt_image,look_at_image,read_document,image_ref,web_search,web_fetch,documents,materials_project,transcribe_audio,office_to_pdf,external_systems}.py ← media_common=媒体五工具共享原语;external_systems=host-side 外部系统元工具 main.py ~210 ← 入口:web / db / probe / user / sandbox check -db/migrations/versions/ 0001-0026 +db/migrations/versions/ 0001-0030 web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等) -web/routers/*.py 12 个 ← 含 external_systems 用户连接管理路由 +web/routers/*.py ← 含 external_systems 用户连接与 compute_nodes 节点路由 web/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按域析出 web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全 web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/broker.py / sinks.py / pptx_render.py / admin.py -web/static/dev.html + ui.css + js/*.js ← dev SPA 零构建;ui.css=轻量视觉基础层,main.js=入口 +web/static/dev.html + admin.html + ui.css + js/*.js ← dev SPA 与管理后台零构建前端 +windows-node/ ← .NET 10 WinForms 托盘节点、注册与登录后自启动 web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控) ───────────────────────────────── diff --git a/RUN.md b/RUN.md index 2c14505..0d3cc0d 100644 --- a/RUN.md +++ b/RUN.md @@ -1052,6 +1052,21 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_" /opt Node API 只能绑定受控内网地址并由安全组限制来源 IP。当前 HTTP/WS 链路不加密;跨安全域、公网或不可信终端接入前,必须先升级 HTTPS/WSS。多 Web 实例部署时,Node API 暂时固定路由到单一实例,直至 Connection Manager 增加跨实例 fencing。 +Windows 端源码位于 `windows-node/`,要求 .NET 10 SDK: + +```powershell +dotnet build windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe enroll ` + --server http://zcbot.internal:8765 ` + --name win-origin-01 ` + --code ZCN-... +windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe +``` + +注册配置写入 `%ProgramData%\Zcbot\WindowsNode\node.json`;Token 使用 DPAPI `LocalMachine` 加密,ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号注册并运行 Node。当前 MVP 以该账号的登录后计划任务启动,不安装 Windows Service。 + +直接双击 EXE 启动托盘 UI:红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`。 + - **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent` - **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/` diff --git a/core/__init__.py b/core/__init__.py index ad39139..0bcdcc6 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.64.3" +__version__ = "0.65.0" diff --git a/global.json b/global.json new file mode 100644 index 0000000..c396020 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.303", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index b753e4f..1bb7036 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -10,6 +10,7 @@ from web.static_files import NoCacheStaticFiles ROOT = Path(__file__).resolve().parents[1] STATIC_DIR = ROOT / "web" / "static" DEV_HTML = STATIC_DIR / "dev.html" +ADMIN_HTML = STATIC_DIR / "admin.html" UI_CSS = STATIC_DIR / "ui.css" JS_DIR = STATIC_DIR / "js" VENDOR_DIR = STATIC_DIR / "vendor" / "markdown" @@ -25,6 +26,18 @@ def _frontend_source() -> str: class StaticVendorTests(unittest.TestCase): + def test_admin_can_create_windows_node_enrollment_code(self) -> None: + html = ADMIN_HTML.read_text(encoding="utf-8") + admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8") + + self.assertIn('id="node-enrollment-modal" class="modal"', html) + self.assertIn("生成 Windows Node 注册码", html) + self.assertIn('"/v1/admin/compute-node-enrollments"', admin_js) + self.assertIn('capabilities: ["origin.plot@v1"]', admin_js) + self.assertIn("ttl_seconds: 600", admin_js) + self.assertIn("navigator.clipboard.writeText(value)", admin_js) + self.assertNotIn("localStorage.setItem", admin_js) + def test_dev_html_uses_local_markdown_vendor_assets(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py new file mode 100644 index 0000000..d37ac1f --- /dev/null +++ b/tests/test_windows_node_source.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "windows-node" +PROJECT = ROOT / "Zcbot.WindowsNode" + + +class WindowsNodeSourceTests(unittest.TestCase): + def test_project_targets_net10_windows_forms_without_third_party_packages(self) -> None: + tree = ET.parse(PROJECT / "Zcbot.WindowsNode.csproj") + root = tree.getroot() + self.assertEqual(root.findtext("./PropertyGroup/TargetFramework"), "net10.0-windows") + self.assertEqual(root.findtext("./PropertyGroup/UseWindowsForms"), "true") + self.assertEqual(root.findtext("./PropertyGroup/OutputType"), "WinExe") + self.assertEqual(root.findall("./ItemGroup/PackageReference"), []) + + def test_node_protocol_and_secret_storage_markers_are_present(self) -> None: + source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs")) + for marker in ( + "v1/compute/nodes/enroll", + "v1/compute/nodes/connect", + 'SetRequestHeader("Authorization"', + 'SetRequestHeader("X-Node-Id"', + "DataProtectionScope.LocalMachine", + "SetAccessRuleProtection(isProtected: true", + '"origin.plot@v1"', + "NotifyIcon", + "ConfigurationForm", + "TrayIconFactory.Create", + '"--headless"', + ): + self.assertIn(marker, source) + + def test_node_does_not_expose_arbitrary_execution_primitives(self) -> None: + source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs")) + for forbidden in ("Process.Start", "cmd.exe", "powershell.exe", "LabTalk"): + self.assertNotIn(forbidden, source) + + def test_config_field_names_do_not_serialize_plain_node_token(self) -> None: + models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8") + stored_record = models.split("internal sealed record StoredNodeConfig", 1)[1].split(");", 1)[0] + self.assertIn("ProtectedNodeToken", stored_record) + self.assertNotIn("string NodeToken", stored_record) + + def test_configuration_ui_never_displays_or_copies_token(self) -> None: + form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8") + self.assertIn("CreateTextBox(usePassword: true)", form) + self.assertIn("UseSystemPasswordChar = usePassword", form) + self.assertNotIn("NodeToken", form) + self.assertNotIn("Clipboard", form) + self.assertIn("清除本机身份并重新注册", form) + self.assertIn("管理员禁用云端旧节点", form) + + 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(760, 690)", form) + self.assertIn("FormBorderStyle.Sizable", form) + self.assertIn("AutoScaleMode.Dpi", form) + self.assertIn("AutoScroll = true", form) + self.assertNotIn("MaximumSize = new Size(410", form) + self.assertIn("注册码默认 10 分钟有效", form) + self.assertIn("成功注册一次后立即失效", form) + self.assertIn("CreateCard", form) + self.assertIn("注册并连接", form) + + 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") + self.assertIn('GetFileName($resolvedExecutable) -ne "Zcbot.WindowsNode.exe"', script) + self.assertIn("New-ScheduledTaskTrigger -AtLogOn -User $currentUser", script) + self.assertIn("-LogonType Interactive", script) + self.assertIn("-RunLevel Limited", script) + self.assertNotIn("-RunLevel Highest", script) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/static/admin.html b/web/static/admin.html index d9e48ae..797604c 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -160,6 +160,53 @@ #app-dialog button { padding: 5px 12px; border: 1px solid var(--border); border-radius: var(--r-md); background: #fff; cursor: pointer; } #app-dialog button.primary { color: #fff; border-color: var(--accent); background: var(--accent); } + #s-windows-node button, #node-enrollment-modal button { + font-size: 12px; padding: 5px 11px; border: 1px solid var(--border); + border-radius: var(--r-md); background: #fff; color: var(--text); cursor: pointer; + } + #s-windows-node button.primary, #node-enrollment-modal button.primary { + color: #fff; border-color: var(--accent); background: var(--accent); + } + #node-enrollment-modal .card { width: min(520px, calc(100vw - 32px)); margin: 0; padding: 0; } + .node-modal-head, .node-modal-actions { + display: flex; align-items: center; gap: 8px; padding: 11px 16px; + border-bottom: 1px solid var(--border); + } + .node-modal-head h3 { flex: 1; margin: 0; font-size: 14px; } + .node-modal-head button { padding: 2px 8px !important; border: 0 !important; font-size: 18px !important; } + .node-modal-body { padding: 16px; } + .node-modal-body label { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; } + .node-modal-body input { + width: 100%; padding: 8px 10px; border: 1px solid var(--border); + border-radius: var(--r-md); color: var(--text); background: #fff; + } + .node-modal-actions { justify-content: flex-end; border-top: 1px solid var(--border); border-bottom: 0; } + .node-help { margin: 8px 0 0; color: var(--muted); font-size: 12px; } + .node-code-wrap { display: flex; gap: 8px; align-items: stretch; } + .node-code { + flex: 1; min-width: 0; padding: 10px; border: 1px solid var(--border); + border-radius: var(--r-md); background: #f8f8f8; font: 600 13px/1.5 var(--mono); + overflow-wrap: anywhere; user-select: all; + } + .node-warning { + margin: 0 0 12px; padding: 9px 10px; border-left: 3px solid var(--warn); + background: #fff8ec; color: #7a520d; font-size: 12px; + } + #app-messages { + position: fixed; top: 16px; left: 50%; z-index: 200; + display: flex; flex-direction: column; align-items: center; gap: 8px; + transform: translateX(-50%); pointer-events: none; + } + .app-msg { + padding: 8px 13px; border: 1px solid var(--border); border-radius: var(--r-md); + background: #fff; box-shadow: 0 4px 14px rgba(0,0,0,.12); + opacity: 0; transform: translateY(-6px); transition: opacity .15s, transform .15s; + pointer-events: auto; + } + .app-msg.in { opacity: 1; transform: translateY(0); } + .app-msg.success { border-color: #b8ddb9; background: #f1faf1; color: var(--ok); } + .app-msg.error { border-color: #edc1bd; background: var(--accent-soft); color: var(--danger); } + .pager { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 10px; } .pager button { font-size: 12px; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-md); @@ -184,6 +231,7 @@ .ctrl select { min-width: 0; } .ext-form-grid { grid-template-columns: 1fr; } .ext-form-grid > label, .ext-form-section { grid-column: 1 !important; } + .node-code-wrap { flex-direction: column; } /* 窄屏:目录变顶部横向 chip 条 */ #layout { grid-template-columns: 1fr; gap: 10px; } #toc { @@ -230,6 +278,34 @@ + diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 099fe3c..9c05c6f 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -4,7 +4,7 @@ // 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、 // 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。 import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js"; -import { dialogPrompt } from "./dialog.js"; +import { dialogPrompt, message } from "./dialog.js"; const LS_TOKEN = "zcbot.token"; const REFRESH_MS = 10000; @@ -14,6 +14,7 @@ const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]]; const SECTIONS = [ ["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"], ["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"], + ["s-windows-node", "Windows Node"], ["s-external", "外部系统"], ["s-toolfail", "工具失败"], ]; @@ -158,6 +159,87 @@ function renderByDay(rows) { + `${body}${foot}`; } +function renderWindowsNodeEnrollment() { + $("s-windows-node").innerHTML = `
` + + `

Windows Node

为新的 Windows 计算节点生成短时、一次性注册码。
` + + `` + + `
`; + $("node-enrollment-open").onclick = openNodeEnrollmentModal; +} + +function openNodeEnrollmentModal() { + $("node-enrollment-form").reset(); + $("node-enrollment-inputs").hidden = false; + $("node-enrollment-result").hidden = true; + $("node-enrollment-code").textContent = ""; + $("node-enrollment-expiry").textContent = ""; + $("node-enrollment-submit").hidden = false; + $("node-enrollment-cancel").textContent = "取消"; + const modal = $("node-enrollment-modal"); + modal.classList.add("show"); + modal.setAttribute("aria-hidden", "false"); + document.documentElement.classList.add("modal-open"); + document.body.classList.add("modal-open"); + $("node-expected-name").focus(); +} + +function closeNodeEnrollmentModal() { + const modal = $("node-enrollment-modal"); + modal.classList.remove("show"); + modal.setAttribute("aria-hidden", "true"); + document.documentElement.classList.remove("modal-open"); + document.body.classList.remove("modal-open"); +} + +async function copyNodeEnrollmentCode() { + const value = $("node-enrollment-code").textContent; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + const input = document.createElement("textarea"); + input.value = value; + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + input.remove(); + } + message("注册码已复制", "success"); + } catch (e) { + message("复制失败,请手动选择注册码复制", "error"); + } +} + +async function createNodeEnrollment(e) { + e.preventDefault(); + const submit = $("node-enrollment-submit"); + submit.disabled = true; + submit.textContent = "生成中…"; + try { + const result = await apiSend("POST", "/v1/admin/compute-node-enrollments", { + expected_name: $("node-expected-name").value.trim(), + capabilities: ["origin.plot@v1"], + ttl_seconds: 600, + }); + $("node-enrollment-code").textContent = result.enrollment_code || ""; + $("node-enrollment-expiry").textContent = result.expires_at + ? `有效期至 ${fmtTime(result.expires_at)}` + : "有效期为 10 分钟"; + $("node-enrollment-inputs").hidden = true; + $("node-enrollment-result").hidden = false; + submit.hidden = true; + $("node-enrollment-cancel").textContent = "关闭"; + $("node-enrollment-copy").focus(); + } catch (err) { + if (err.code !== "auth") message("生成注册码失败:" + (err.message || String(err)), "error", 5000); + } finally { + submit.disabled = false; + submit.textContent = "生成注册码"; + } +} + function renderExternalDefinitions() { const rows = externalDefinitions.map(r => { const cfg = r.config || {}; @@ -735,6 +817,7 @@ function renderMetrics(d) { $("s-usage").innerHTML = renderUsersAndUsage(d.users || {}, d.usage || {}) + renderByDay((d.usage || {}).by_day_7d); + renderWindowsNodeEnrollment(); } function showMsg(html) { @@ -955,6 +1038,18 @@ function stopAuto() { $("refresh").onclick = refresh; $("export").onclick = exportPdf; $("auto-refresh").onchange = startAuto; +$("node-enrollment-form").onsubmit = createNodeEnrollment; +$("node-enrollment-close").onclick = closeNodeEnrollmentModal; +$("node-enrollment-cancel").onclick = closeNodeEnrollmentModal; +$("node-enrollment-copy").onclick = copyNodeEnrollmentCode; +$("node-enrollment-modal").onclick = (e) => { + if (e.target.id === "node-enrollment-modal") closeNodeEnrollmentModal(); +}; +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) { + closeNodeEnrollmentModal(); + } +}); // 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求) document.addEventListener("visibilitychange", () => { if (document.hidden) stopAuto(); diff --git a/windows-node/README.md b/windows-node/README.md new file mode 100644 index 0000000..e4ca5b8 --- /dev/null +++ b/windows-node/README.md @@ -0,0 +1,31 @@ +# zcbot Windows Node + +内网 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、任务目录和产物上传。 + +注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。 + +```powershell +dotnet build windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe enroll ` + --server http://zcbot.internal:8765 ` + --name win-origin-01 ` + --code ZCN-... +windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe +``` + +直接双击 EXE 默认启动托盘 UI;红点表示未注册或身份失效,黄点表示正在连接,绿点表示在线。双击托盘图标打开配置窗口。无界面运行使用: + +```powershell +Zcbot.WindowsNode.exe run --headless +``` + +确认前台连接正常后,为当前专用账号注册登录后启动任务: + +```powershell +windows-node/install-startup.ps1 ` + -ExecutablePath windows-node/Zcbot.WindowsNode/bin/Debug/net10.0-windows/Zcbot.WindowsNode.exe +``` + +注册结果保存在 `%ProgramData%\Zcbot\WindowsNode\node.json`。Token 使用 DPAPI `LocalMachine` 加密,目录与文件 ACL 只保留注册时的运行账号和 `SYSTEM`。复制配置到另一台机器不能解密 Token;Windows 重装、运行账号变更或身份丢失时应由管理员禁用旧节点并重新注册。 diff --git a/windows-node/Zcbot.WindowsNode.slnx b/windows-node/Zcbot.WindowsNode.slnx new file mode 100644 index 0000000..c6bd4e2 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode.slnx @@ -0,0 +1,3 @@ + + + diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs new file mode 100644 index 0000000..84891f5 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs @@ -0,0 +1,293 @@ +namespace Zcbot.WindowsNode; + +internal sealed class ConfigurationForm : Form +{ + 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("注册并连接", primary: true); + private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册"); + private readonly CheckBox startAtLogin = new() + { + Text = "登录 Windows 后自动启动节点", + AutoSize = false, + Height = 32, + Dock = DockStyle.Top, + }; + private readonly Label state = new() + { + AutoSize = true, + Font = new Font("Microsoft YaHei UI", 12, FontStyle.Bold), + }; + private readonly Label detail = new() { AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105) }; + private readonly Label identity = new() { AutoSize = true, Dock = DockStyle.Fill, ForeColor = Color.FromArgb(71, 85, 105) }; + private bool changingStartup; + + internal event Func? RegisterRequested; + internal event Action? ResetIdentityRequested; + + internal ConfigurationForm() + { + Text = "zcbot Windows Node"; + AutoScaleMode = AutoScaleMode.Dpi; + ClientSize = new Size(760, 690); + MinimumSize = new Size(720, 640); + StartPosition = FormStartPosition.CenterScreen; + Font = new Font("Microsoft YaHei UI", 9); + FormBorderStyle = FormBorderStyle.Sizable; + BackColor = Color.FromArgb(245, 247, 250); + + var page = new TableLayoutPanel + { + Dock = DockStyle.Fill, + Padding = new Padding(28, 24, 28, 24), + AutoScroll = true, + ColumnCount = 1, + RowCount = 7, + BackColor = BackColor, + }; + page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + Controls.Add(page); + + var heading = new TableLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Top, + ColumnCount = 1, + Margin = new Padding(4, 0, 4, 18), + }; + heading.Controls.Add(new Label + { + Text = "zcbot Windows Node", + AutoSize = true, + Font = new Font("Microsoft YaHei UI", 20, FontStyle.Bold), + ForeColor = Color.FromArgb(15, 23, 42), + }); + heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点")); + page.Controls.Add(heading); + + var statusCard = CreateCard(148); + statusCard.Controls.Add(CreateSectionTitle("节点状态")); + statusCard.Controls.Add(state); + statusCard.Controls.Add(detail); + statusCard.Controls.Add(identity); + page.Controls.Add(statusCard); + + var configCard = CreateCard(360); + configCard.Controls.Add(CreateSectionTitle("注册配置")); + configCard.Controls.Add(CreateHint( + "首次连接需要管理员在 zcbot 管理端生成注册码。注册码默认 10 分钟有效,成功注册一次后立即失效。")); + AddField(configCard, "zcbot 服务地址", server, "本机测试通常使用 http://127.0.0.1:8765"); + AddField(configCard, "节点名称", nodeName, "用于管理员识别这台 Windows 计算节点"); + AddField(configCard, "一次性注册码", enrollmentCode, "格式类似 ZCN-…;它不是日常登录密码,也不会被长期保存"); + var actions = new FlowLayoutPanel + { + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + Dock = DockStyle.Fill, + Margin = new Padding(0, 12, 0, 0), + }; + actions.Controls.Add(register); + actions.Controls.Add(resetIdentity); + configCard.Controls.Add(actions); + page.Controls.Add(configCard); + + var runtimeCard = CreateCard(104); + runtimeCard.Controls.Add(CreateSectionTitle("运行设置")); + runtimeCard.Controls.Add(startAtLogin); + runtimeCard.Controls.Add(CreateHint("关闭配置窗口后,节点仍会在系统托盘运行。右键托盘图标可重连或退出。")); + page.Controls.Add(runtimeCard); + + var securityNote = CreateHint("安全说明:Node Token 由 Windows DPAPI 加密保存,不在界面中显示,也不会写入日志。"); + securityNote.Margin = new Padding(4, 14, 4, 0); + page.Controls.Add(securityNote); + + register.Click += async (_, _) => await RegisterAsync(); + resetIdentity.Click += (_, _) => ResetIdentity(); + startAtLogin.CheckedChanged += (_, _) => ToggleStartup(); + FormClosing += (_, eventArgs) => + { + if (eventArgs.CloseReason == CloseReason.UserClosing) + { + eventArgs.Cancel = true; + Hide(); + } + }; + startAtLogin.Checked = StartupRegistration.IsEnabled; + ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null); + } + + internal void ApplyStatus(NodeStatus status, NodeConfig? config) + { + state.Text = status.State switch + { + NodeState.Online => "● 在线", + NodeState.Connecting => "● 正在连接", + NodeState.NotRegistered => "● 尚未注册", + NodeState.AuthenticationRequired => "● 需要重新注册", + NodeState.Offline => "● 离线", + _ => "● 已停止", + }; + state.ForeColor = status.State switch + { + NodeState.Online => Color.ForestGreen, + NodeState.Connecting => Color.DarkOrange, + NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.Firebrick, + _ => Color.DimGray, + }; + detail.Text = $"{status.Message} {status.ChangedAt:HH:mm:ss}"; + identity.Text = config is null + ? $"配置文件:{NodePaths.ForCurrentMachine().ConfigPath}" + : $"节点:{config.NodeName}\nNode ID:{config.NodeId}\n服务:{config.ServerUrl}"; + server.Enabled = config is null; + nodeName.Enabled = config is null; + enrollmentCode.Enabled = config is null; + register.Enabled = config is null && status.State != NodeState.Connecting; + resetIdentity.Visible = config is not null; + if (config is not null) + { + server.Text = config.ServerUrl.AbsoluteUri.TrimEnd('/'); + nodeName.Text = config.NodeName; + enrollmentCode.Clear(); + } + } + + private void ResetIdentity() + { + var answer = MessageBox.Show( + "这会删除本机加密身份,随后需要使用新注册码重新注册。请同时让管理员禁用云端旧节点。是否继续?", + "清除本机节点身份", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button2); + if (answer == DialogResult.Yes) + { + ResetIdentityRequested?.Invoke(); + } + } + + private async Task RegisterAsync() + { + if (RegisterRequested is null) + { + return; + } + try + { + register.Enabled = false; + detail.Text = "正在注册…"; + var options = EnrollOptions.Parse([ + "--server", server.Text, "--name", nodeName.Text, "--code", enrollmentCode.Text]); + await RegisterRequested(options); + } + catch (Exception exception) when (exception is NodeConfigurationException or HttpRequestException) + { + MessageBox.Show(exception.Message, "注册失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + register.Enabled = true; + } + } + + private void ToggleStartup() + { + if (changingStartup) + { + return; + } + try + { + StartupRegistration.SetEnabled(startAtLogin.Checked); + } + catch (Exception exception) when (exception is UnauthorizedAccessException or IOException) + { + MessageBox.Show(exception.Message, "自动启动设置失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + changingStartup = true; + try + { + startAtLogin.Checked = StartupRegistration.IsEnabled; + } + finally + { + changingStartup = false; + } + } + } + + private static TableLayoutPanel CreateCard(int minimumHeight) + { + var card = new TableLayoutPanel + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + MinimumSize = new Size(0, minimumHeight), + Dock = DockStyle.Top, + ColumnCount = 1, + Padding = new Padding(22, 18, 22, 18), + Margin = new Padding(0, 0, 0, 14), + BackColor = Color.White, + }; + card.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + return card; + } + + private static Label CreateSectionTitle(string text) => new() + { + Text = text, + AutoSize = true, + Font = new Font("Microsoft YaHei UI", 11, FontStyle.Bold), + ForeColor = Color.FromArgb(30, 41, 59), + Margin = new Padding(0, 0, 0, 8), + }; + + private static Label CreateHint(string text) => new() + { + Text = text, + AutoSize = true, + Dock = DockStyle.Fill, + ForeColor = Color.FromArgb(100, 116, 139), + Margin = new Padding(0, 2, 0, 4), + }; + + private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new() + { + Text = text, + UseSystemPasswordChar = usePassword, + AutoSize = false, + Height = 36, + BorderStyle = BorderStyle.FixedSingle, + Font = new Font("Segoe UI", 10), + }; + + private static Button CreateButton(string text, bool primary = false) + { + var button = new Button + { + Text = text, + AutoSize = false, + Size = new Size(primary ? 128 : 220, 38), + FlatStyle = FlatStyle.Flat, + BackColor = primary ? Color.FromArgb(37, 99, 235) : Color.White, + ForeColor = primary ? Color.White : Color.FromArgb(51, 65, 85), + Margin = new Padding(0, 0, 10, 0), + }; + button.FlatAppearance.BorderColor = primary + ? Color.FromArgb(37, 99, 235) + : Color.FromArgb(203, 213, 225); + return button; + } + + private static void AddField( + TableLayoutPanel layout, string label, Control control, string hint) + { + layout.Controls.Add(new Label + { + Text = label, + AutoSize = true, + Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 65, 85), + Margin = new Padding(0, 9, 0, 4), + }); + control.Dock = DockStyle.Top; + control.Margin = new Padding(0, 0, 0, 2); + layout.Controls.Add(control); + layout.Controls.Add(CreateHint(hint)); + } +} diff --git a/windows-node/Zcbot.WindowsNode/EnrollOptions.cs b/windows-node/Zcbot.WindowsNode/EnrollOptions.cs new file mode 100644 index 0000000..1bdfdb0 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/EnrollOptions.cs @@ -0,0 +1,61 @@ +namespace Zcbot.WindowsNode; + +internal sealed record EnrollOptions(Uri ServerUrl, string NodeName, string EnrollmentCode) +{ + internal static EnrollOptions Parse(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Length; index += 2) + { + if (index + 1 >= args.Length || !args[index].StartsWith("--", StringComparison.Ordinal)) + { + throw Usage(); + } + values[args[index][2..]] = args[index + 1]; + } + + if (!values.TryGetValue("server", out var server) + || !values.TryGetValue("name", out var name) + || !values.TryGetValue("code", out var code)) + { + throw Usage(); + } + + var serverUrl = NodeUri.NormalizeServerUrl(server); + name = name.Trim(); + code = code.Trim(); + if (name.Length is < 1 or > 100 || code.Length is < 16 or > 100) + { + throw new NodeConfigurationException("Node name or enrollment code has an invalid length."); + } + return new EnrollOptions(serverUrl, name, code); + } + + private static NodeConfigurationException Usage() => new( + "Usage: Zcbot.WindowsNode enroll --server http://host:8765 --name win-origin-01 --code ZCN-..."); +} + +internal static class NodeUri +{ + internal static Uri NormalizeServerUrl(string raw) + { + if (!Uri.TryCreate(raw.Trim(), UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment)) + { + throw new NodeConfigurationException("Server URL must be an absolute HTTP or HTTPS URL without credentials, query, or fragment."); + } + return new Uri(uri.GetLeftPart(UriPartial.Path).TrimEnd('/') + "/", UriKind.Absolute); + } + + internal static Uri WebSocketEndpoint(Uri serverUrl) + { + var builder = new UriBuilder(new Uri(serverUrl, "v1/compute/nodes/connect")) + { + Scheme = serverUrl.Scheme == Uri.UriSchemeHttps ? "wss" : "ws" + }; + return builder.Uri; + } +} diff --git a/windows-node/Zcbot.WindowsNode/EnrollmentClient.cs b/windows-node/Zcbot.WindowsNode/EnrollmentClient.cs new file mode 100644 index 0000000..9488b7c --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/EnrollmentClient.cs @@ -0,0 +1,52 @@ +using System.Net.Http.Json; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Zcbot.WindowsNode; + +internal static class EnrollmentClient +{ + private static readonly string[] Capabilities = ["origin.plot@v1"]; + + internal static async Task EnrollAsync( + EnrollOptions options, NodeConfigStore store, CancellationToken cancellationToken) + { + if (store.Exists) + { + throw new NodeConfigurationException( + $"Node is already registered. Remove {store.ConfigPath} through the administrator recovery procedure before re-enrolling."); + } + + var installId = Guid.NewGuid(); + var request = new EnrollRequest( + options.EnrollmentCode, + options.NodeName, + installId, + Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0", + RuntimeInformation.OSDescription, + Capabilities); + + using var client = new HttpClient { BaseAddress = options.ServerUrl, Timeout = TimeSpan.FromSeconds(30) }; + using var response = await client.PostAsJsonAsync( + "v1/compute/nodes/enroll", request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var detail = await response.Content.ReadAsStringAsync(cancellationToken); + throw new NodeConfigurationException( + $"Registration failed with HTTP {(int)response.StatusCode}: {Limit(detail, 300)}"); + } + var enrolled = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new NodeConfigurationException("Registration response was empty."); + if (enrolled.NodeId == Guid.Empty || string.IsNullOrWhiteSpace(enrolled.NodeToken)) + { + throw new NodeConfigurationException("Registration response did not contain a valid node identity."); + } + + store.Save(new NodeConfig( + options.ServerUrl, enrolled.NodeId, installId, options.NodeName, + enrolled.NodeToken, Math.Clamp(enrolled.HeartbeatSeconds, 5, 300), Capabilities)); + } + + private static string Limit(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength]; +} diff --git a/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs new file mode 100644 index 0000000..c6e28ec --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/NodeConfigStore.cs @@ -0,0 +1,132 @@ +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Text.Json; + +namespace Zcbot.WindowsNode; + +internal sealed class NodeConfigStore(NodePaths paths) +{ + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("zcbot.windows-node.v1"); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + internal bool Exists => File.Exists(paths.ConfigPath); + internal string ConfigPath => paths.ConfigPath; + + internal void Save(NodeConfig config) + { + Directory.CreateDirectory(paths.RootDirectory); + RestrictDirectory(paths.RootDirectory); + var protectedToken = ProtectedData.Protect( + Encoding.UTF8.GetBytes(config.NodeToken), Entropy, DataProtectionScope.LocalMachine); + var stored = new StoredNodeConfig( + config.ServerUrl.AbsoluteUri, + config.NodeId, + config.InstallId, + config.NodeName, + Convert.ToBase64String(protectedToken), + config.HeartbeatSeconds, + config.Capabilities); + + var temporaryPath = paths.ConfigPath + ".tmp"; + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(stored, JsonOptions), Encoding.UTF8); + RestrictFile(temporaryPath); + File.Move(temporaryPath, paths.ConfigPath, overwrite: false); + RestrictFile(paths.ConfigPath); + } + + internal NodeConfig Load() + { + if (!Exists) + { + throw new NodeConfigurationException( + "Node is not registered. Run the enroll command first."); + } + try + { + var stored = JsonSerializer.Deserialize( + File.ReadAllText(paths.ConfigPath, Encoding.UTF8)) + ?? throw new NodeConfigurationException("Node configuration is empty."); + var token = Encoding.UTF8.GetString(ProtectedData.Unprotect( + Convert.FromBase64String(stored.ProtectedNodeToken), + Entropy, + DataProtectionScope.LocalMachine)); + if (stored.NodeId == Guid.Empty + || stored.InstallId == Guid.Empty + || string.IsNullOrWhiteSpace(stored.NodeName) + || string.IsNullOrWhiteSpace(token) + || stored.Capabilities.Count == 0 + || stored.Capabilities.Any(item => item != "origin.plot@v1")) + { + throw new NodeConfigurationException("Node configuration contains an invalid identity or capability."); + } + return new NodeConfig( + NodeUri.NormalizeServerUrl(stored.ServerUrl), + stored.NodeId, + stored.InstallId, + stored.NodeName, + token, + Math.Clamp(stored.HeartbeatSeconds, 5, 300), + stored.Capabilities); + } + catch (NodeConfigurationException) + { + throw; + } + catch (Exception exception) when ( + exception is CryptographicException + or FormatException + or IOException + or JsonException + or UnauthorizedAccessException) + { + throw new NodeConfigurationException( + $"Node configuration cannot be loaded: {exception.Message}"); + } + } + + internal void DeleteLocalIdentity() + { + if (File.Exists(paths.ConfigPath)) + { + File.Delete(paths.ConfigPath); + } + var temporaryPath = paths.ConfigPath + ".tmp"; + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + + private static void RestrictDirectory(string path) + { + var identity = WindowsIdentity.GetCurrent(); + var user = identity.User + ?? throw new NodeConfigurationException("Current Windows account has no security identifier."); + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule( + user, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + new DirectoryInfo(path).SetAccessControl(security); + } + + private static void RestrictFile(string path) + { + var identity = WindowsIdentity.GetCurrent(); + var user = identity.User + ?? throw new NodeConfigurationException("Current Windows account has no security identifier."); + var security = new FileSecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule( + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + FileSystemRights.FullControl, AccessControlType.Allow)); + new FileInfo(path).SetAccessControl(security); + } +} diff --git a/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs new file mode 100644 index 0000000..23cc7c0 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/NodeConnectionLoop.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Net.WebSockets; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace Zcbot.WindowsNode; + +internal sealed class NodeConnectionLoop(NodeConfig config, Action? statusChanged = null) +{ + private static readonly TimeSpan[] Backoff = + [ + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60), + ]; + + internal async Task RunAsync(CancellationToken cancellationToken) + { + var attempt = 0; + while (!cancellationToken.IsCancellationRequested) + { + try + { + Report(NodeState.Connecting, "正在连接 zcbot…"); + await ConnectOnceAsync(cancellationToken); + attempt = 0; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (WebSocketException exception) + { + Report(NodeState.Offline, "连接中断,等待重连"); + Console.Error.WriteLine($"[WARN] WebSocket disconnected: {exception.Message}"); + } + catch (IOException exception) + { + Report(NodeState.Offline, "网络不可用,等待重连"); + Console.Error.WriteLine($"[WARN] Connection I/O failed: {exception.Message}"); + } + catch (JsonException exception) + { + Report(NodeState.Offline, "服务端消息无效,等待重连"); + Console.Error.WriteLine($"[WARN] Invalid server message: {exception.Message}"); + } + + var baseDelay = Backoff[Math.Min(attempt, Backoff.Length - 1)]; + attempt++; + var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(0, 750)); + var delay = baseDelay + jitter; + Console.WriteLine($"[INFO] Reconnecting in {delay.TotalSeconds:F1}s."); + await Task.Delay(delay, cancellationToken); + } + } + + private async Task ConnectOnceAsync(CancellationToken cancellationToken) + { + using var socket = new ClientWebSocket(); + socket.Options.SetRequestHeader("Authorization", $"Bearer {config.NodeToken}"); + socket.Options.SetRequestHeader("X-Node-Id", config.NodeId.ToString()); + socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(config.HeartbeatSeconds); + socket.Options.CollectHttpResponseDetails = true; + + var endpoint = NodeUri.WebSocketEndpoint(config.ServerUrl); + Console.WriteLine($"[INFO] Connecting to {endpoint.GetLeftPart(UriPartial.Path)}."); + try + { + await socket.ConnectAsync(endpoint, cancellationToken); + } + catch (WebSocketException) 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."); + } + Console.WriteLine("[OK] Node connected."); + Report(NodeState.Online, "已连接"); + + await SendAsync(socket, "hello", RuntimePayload(), cancellationToken); + using var heartbeatStop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var heartbeat = HeartbeatLoopAsync(socket, heartbeatStop.Token); + try + { + await ReceiveLoopAsync(socket, cancellationToken); + } + finally + { + heartbeatStop.Cancel(); + try + { + await heartbeat; + } + catch (OperationCanceledException) when (heartbeatStop.IsCancellationRequested) + { + } + } + } + + private async Task HeartbeatLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(config.HeartbeatSeconds)); + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await SendAsync(socket, "heartbeat", RuntimePayload(), cancellationToken); + } + } + + private static async Task ReceiveLoopAsync( + ClientWebSocket socket, CancellationToken cancellationToken) + { + var buffer = new byte[16 * 1024]; + using var message = new MemoryStream(); + while (socket.State == WebSocketState.Open) + { + var result = await socket.ReceiveAsync(buffer, cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + if (result.CloseStatus == WebSocketCloseStatus.PolicyViolation) + { + throw new NodeConfigurationException( + $"Node credentials or install identity were rejected: {result.CloseStatusDescription}"); + } + return; + } + if (result.MessageType != WebSocketMessageType.Text) + { + throw new JsonException("Only text WebSocket messages are supported."); + } + message.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + { + if (message.Length > 1024 * 1024) + { + throw new JsonException("Server message exceeded 1 MiB."); + } + continue; + } + using var document = JsonDocument.Parse(message.ToArray()); + if (document.RootElement.TryGetProperty("type", out var type)) + { + Console.WriteLine($"[INFO] Server message: {type.GetString()}."); + } + message.SetLength(0); + } + } + + private void Report(NodeState state, string message) => + statusChanged?.Invoke(NodeStatus.Create(state, message)); + + private async Task SendAsync( + ClientWebSocket socket, string type, object payload, CancellationToken cancellationToken) + { + var envelope = JsonSerializer.SerializeToUtf8Bytes(new + { + protocol_version = 1, + message_id = Guid.NewGuid(), + type, + sent_at = DateTimeOffset.UtcNow, + payload, + }); + await socket.SendAsync( + envelope, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + } + + private object RuntimePayload() + { + var root = Path.GetPathRoot(Environment.SystemDirectory) ?? "C:\\"; + return new + { + install_id = config.InstallId, + node_name = config.NodeName, + node_version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.1.0", + os_version = RuntimeInformation.OSDescription, + capabilities = config.Capabilities, + available_slots = 1, + disk_free_bytes = new DriveInfo(root).AvailableFreeSpace, + desktop_session = Environment.UserInteractive, + }; + } +} diff --git a/windows-node/Zcbot.WindowsNode/NodeModels.cs b/windows-node/Zcbot.WindowsNode/NodeModels.cs new file mode 100644 index 0000000..c737e8e --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/NodeModels.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace Zcbot.WindowsNode; + +internal sealed record NodeConfig( + Uri ServerUrl, + Guid NodeId, + Guid InstallId, + string NodeName, + string NodeToken, + int HeartbeatSeconds, + IReadOnlyList Capabilities); + +internal sealed record StoredNodeConfig( + string ServerUrl, + Guid NodeId, + Guid InstallId, + string NodeName, + string ProtectedNodeToken, + int HeartbeatSeconds, + IReadOnlyList Capabilities); + +internal sealed record EnrollRequest( + [property: JsonPropertyName("enrollment_code")] string EnrollmentCode, + [property: JsonPropertyName("node_name")] string NodeName, + [property: JsonPropertyName("install_id")] Guid InstallId, + [property: JsonPropertyName("node_version")] string NodeVersion, + [property: JsonPropertyName("os_version")] string OsVersion, + [property: JsonPropertyName("capabilities")] IReadOnlyList Capabilities); + +internal sealed record EnrollResponse( + [property: JsonPropertyName("node_id")] Guid NodeId, + [property: JsonPropertyName("node_token")] string NodeToken, + [property: JsonPropertyName("heartbeat_seconds")] int HeartbeatSeconds, + [property: JsonPropertyName("max_concurrency")] int MaxConcurrency); + +internal sealed record NodePaths(string RootDirectory, string ConfigPath) +{ + internal static NodePaths ForCurrentMachine() + { + var root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Zcbot", "WindowsNode"); + return new NodePaths(root, Path.Combine(root, "node.json")); + } +} + +internal sealed class NodeConfigurationException(string message) : Exception(message); diff --git a/windows-node/Zcbot.WindowsNode/NodeStatus.cs b/windows-node/Zcbot.WindowsNode/NodeStatus.cs new file mode 100644 index 0000000..f56b7bc --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/NodeStatus.cs @@ -0,0 +1,17 @@ +namespace Zcbot.WindowsNode; + +internal enum NodeState +{ + NotRegistered, + Connecting, + Online, + Offline, + AuthenticationRequired, + Stopped, +} + +internal sealed record NodeStatus(NodeState State, string Message, DateTimeOffset ChangedAt) +{ + internal static NodeStatus Create(NodeState state, string message) => + new(state, message, DateTimeOffset.Now); +} diff --git a/windows-node/Zcbot.WindowsNode/Program.cs b/windows-node/Zcbot.WindowsNode/Program.cs new file mode 100644 index 0000000..3e91cd8 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Program.cs @@ -0,0 +1,102 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace Zcbot.WindowsNode; + +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + if (args.Length > 0) + { + NativeConsole.AttachToParent(); + return RunCommandAsync(args).GetAwaiter().GetResult(); + } + + ApplicationConfiguration.Initialize(); + using var singleInstance = new Mutex( + initiallyOwned: true, "Local\\Zcbot.WindowsNode", out var isFirstInstance); + if (!isFirstInstance) + { + MessageBox.Show( + "zcbot Windows Node 已在运行。请查看系统托盘。", + "zcbot Windows Node", MessageBoxButtons.OK, MessageBoxIcon.Information); + return 0; + } + + var store = new NodeConfigStore(NodePaths.ForCurrentMachine()); + Application.Run(new TrayApplicationContext(store)); + return 0; + } + + private static async Task RunCommandAsync(string[] args) + { + try + { + var store = new NodeConfigStore(NodePaths.ForCurrentMachine()); + if (string.Equals(args[0], "enroll", StringComparison.OrdinalIgnoreCase)) + { + var options = EnrollOptions.Parse(args[1..]); + await EnrollmentClient.EnrollAsync(options, store, CancellationToken.None); + Console.WriteLine("[OK] Node registration saved."); + return 0; + } + if (!string.Equals(args[0], "run", StringComparison.OrdinalIgnoreCase) + || !args.Skip(1).Contains("--headless", StringComparer.OrdinalIgnoreCase)) + { + throw new NodeConfigurationException( + "Usage: Zcbot.WindowsNode enroll ... | run --headless"); + } + + using var shutdown = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + eventArgs.Cancel = true; + shutdown.Cancel(); + }; + var config = store.Load(); + await new NodeConnectionLoop(config).RunAsync(shutdown.Token); + return 0; + } + catch (OperationCanceledException) + { + return 0; + } + catch (NodeConfigurationException exception) + { + Console.Error.WriteLine($"[ERR] {exception.Message}"); + return 2; + } + catch (Exception exception) + { + Console.Error.WriteLine($"[ERR] {exception.GetType().Name}: {exception.Message}"); + return 1; + } + } +} + +internal static class NativeConsole +{ + private const uint AttachParentProcess = 0xFFFFFFFF; + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachConsole(uint processId); + + internal static void AttachToParent() + { + if (!AttachConsole(AttachParentProcess)) + { + return; + } + Console.SetOut(new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) + { + AutoFlush = true, + }); + Console.SetError(new StreamWriter(Console.OpenStandardError(), new UTF8Encoding(false)) + { + AutoFlush = true, + }); + } +} diff --git a/windows-node/Zcbot.WindowsNode/StartupRegistration.cs b/windows-node/Zcbot.WindowsNode/StartupRegistration.cs new file mode 100644 index 0000000..b79df2d --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/StartupRegistration.cs @@ -0,0 +1,42 @@ +using Microsoft.Win32; +using System.Reflection; + +namespace Zcbot.WindowsNode; + +internal static class StartupRegistration +{ + private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string ValueName = "ZcbotWindowsNode"; + + internal static bool IsEnabled + { + get + { + using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: false); + return string.Equals(key?.GetValue(ValueName) as string, Command, StringComparison.OrdinalIgnoreCase); + } + } + + internal static void SetEnabled(bool enabled) + { + using var key = Registry.CurrentUser.CreateSubKey(RunKey, writable: true); + if (enabled) + { + key.SetValue(ValueName, Command, RegistryValueKind.String); + } + else + { + key.DeleteValue(ValueName, throwOnMissingValue: false); + } + } + + private static string Command + { + get + { + var path = Environment.ProcessPath + ?? Assembly.GetExecutingAssembly().Location; + return $"\"{path}\""; + } + } +} diff --git a/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs new file mode 100644 index 0000000..e2f9139 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/TrayApplicationContext.cs @@ -0,0 +1,185 @@ +namespace Zcbot.WindowsNode; + +internal sealed class TrayApplicationContext : ApplicationContext +{ + private readonly NodeConfigStore store; + private readonly ConfigurationForm form; + private readonly NotifyIcon tray; + private readonly ToolStripMenuItem statusItem; + private CancellationTokenSource? connectionStop; + private Task? connectionTask; + private NodeConfig? config; + private NodeStatus current = NodeStatus.Create(NodeState.NotRegistered, "尚未注册"); + + internal TrayApplicationContext(NodeConfigStore store) + { + this.store = store; + form = new ConfigurationForm(); + form.RegisterRequested += RegisterAsync; + form.ResetIdentityRequested += ResetIdentity; + + statusItem = new ToolStripMenuItem("尚未注册") { Enabled = false }; + var menu = new ContextMenuStrip(); + menu.Items.Add(statusItem); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("打开配置", null, (_, _) => ShowConfiguration()); + menu.Items.Add("立即重连", null, (_, _) => RestartConnection()); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("退出", null, (_, _) => ExitNode()); + + tray = new NotifyIcon + { + ContextMenuStrip = menu, + Icon = TrayIconFactory.Create(NodeState.NotRegistered), + Text = "zcbot Windows Node - 尚未注册", + Visible = true, + }; + tray.DoubleClick += (_, _) => ShowConfiguration(); + + if (store.Exists) + { + try + { + config = store.Load(); + StartConnection(); + } + catch (NodeConfigurationException exception) + { + UpdateStatus(NodeStatus.Create(NodeState.AuthenticationRequired, exception.Message)); + ShowConfiguration(); + } + } + else + { + UpdateStatus(current); + ShowConfiguration(); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + connectionStop?.Cancel(); + tray.Visible = false; + tray.Dispose(); + form.Dispose(); + connectionStop?.Dispose(); + } + base.Dispose(disposing); + } + + private async Task RegisterAsync(EnrollOptions options) + { + UpdateStatus(NodeStatus.Create(NodeState.Connecting, "正在注册节点…")); + try + { + await EnrollmentClient.EnrollAsync(options, store, CancellationToken.None); + config = store.Load(); + UpdateStatus(NodeStatus.Create(NodeState.Connecting, "注册成功,正在连接…")); + StartConnection(); + } + catch + { + UpdateStatus(NodeStatus.Create(NodeState.NotRegistered, "注册失败,请检查配置")); + throw; + } + } + + private void StartConnection() + { + if (config is null || connectionTask is { IsCompleted: false }) + { + return; + } + connectionStop?.Dispose(); + connectionStop = new CancellationTokenSource(); + connectionTask = RunConnectionAsync(config, connectionStop.Token); + } + + private async Task RunConnectionAsync(NodeConfig nodeConfig, CancellationToken cancellationToken) + { + try + { + await new NodeConnectionLoop(nodeConfig, status => PostStatus(status)) + .RunAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + PostStatus(NodeStatus.Create(NodeState.Stopped, "连接已停止")); + } + catch (NodeConfigurationException exception) + { + PostStatus(NodeStatus.Create(NodeState.AuthenticationRequired, exception.Message)); + } + catch (Exception exception) + { + PostStatus(NodeStatus.Create(NodeState.Offline, $"节点异常:{exception.Message}")); + } + } + + private void RestartConnection() + { + if (config is null) + { + ShowConfiguration(); + return; + } + connectionStop?.Cancel(); + _ = RestartAfterStopAsync(); + } + + private void ResetIdentity() + { + connectionStop?.Cancel(); + store.DeleteLocalIdentity(); + config = null; + UpdateStatus(NodeStatus.Create(NodeState.NotRegistered, "本机身份已清除,请使用新注册码注册")); + } + + private async Task RestartAfterStopAsync() + { + if (connectionTask is not null) + { + await connectionTask; + } + StartConnection(); + } + + private void PostStatus(NodeStatus status) + { + if (form.IsHandleCreated) + { + form.BeginInvoke(() => UpdateStatus(status)); + } + } + + private void UpdateStatus(NodeStatus status) + { + current = status; + form.ApplyStatus(status, config); + statusItem.Text = status.Message; + tray.Text = Limit($"zcbot Windows Node - {status.Message}", 63); + var oldIcon = tray.Icon; + tray.Icon = TrayIconFactory.Create(status.State); + oldIcon?.Dispose(); + } + + private void ShowConfiguration() + { + form.ApplyStatus(current, config); + form.Show(); + form.WindowState = FormWindowState.Normal; + form.Activate(); + } + + private void ExitNode() + { + connectionStop?.Cancel(); + tray.Visible = false; + ExitThread(); + } + + private static string Limit(string value, int length) => + value.Length <= length ? value : value[..length]; +} diff --git a/windows-node/Zcbot.WindowsNode/TrayIconFactory.cs b/windows-node/Zcbot.WindowsNode/TrayIconFactory.cs new file mode 100644 index 0000000..2aa1aeb --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/TrayIconFactory.cs @@ -0,0 +1,46 @@ +using System.Drawing.Drawing2D; +using System.Runtime.InteropServices; + +namespace Zcbot.WindowsNode; + +internal static class TrayIconFactory +{ + internal static Icon Create(NodeState state) + { + var color = state switch + { + NodeState.Online => Color.FromArgb(34, 197, 94), + NodeState.Connecting => Color.FromArgb(245, 158, 11), + NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.FromArgb(239, 68, 68), + _ => Color.FromArgb(107, 114, 128), + }; + using var bitmap = new Bitmap(32, 32); + using var graphics = Graphics.FromImage(bitmap); + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.Clear(Color.Transparent); + using var background = new SolidBrush(Color.FromArgb(30, 41, 59)); + using var badge = new SolidBrush(color); + using var textBrush = new SolidBrush(Color.White); + graphics.FillEllipse(background, 1, 1, 30, 30); + using var font = new Font("Segoe UI", 13, FontStyle.Bold, GraphicsUnit.Pixel); + graphics.DrawString("Z", font, textBrush, new PointF(8, 7)); + graphics.FillEllipse(badge, 20, 20, 11, 11); + using var border = new Pen(Color.White, 2); + graphics.DrawEllipse(border, 20, 20, 11, 11); + + var handle = bitmap.GetHicon(); + try + { + using var icon = Icon.FromHandle(handle); + return (Icon)icon.Clone(); + } + finally + { + DestroyIcon(handle); + } + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyIcon(nint handle); +} diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj new file mode 100644 index 0000000..ea80850 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -0,0 +1,14 @@ + + + WinExe + net10.0-windows + true + enable + enable + true + true + Zcbot.WindowsNode + Zcbot.WindowsNode + 0.1.0 + + diff --git a/windows-node/Zcbot.WindowsNode/packages.lock.json b/windows-node/Zcbot.WindowsNode/packages.lock.json new file mode 100644 index 0000000..2a64009 --- /dev/null +++ b/windows-node/Zcbot.WindowsNode/packages.lock.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": {} + } +} \ No newline at end of file diff --git a/windows-node/install-startup.ps1 b/windows-node/install-startup.ps1 new file mode 100644 index 0000000..fc6db13 --- /dev/null +++ b/windows-node/install-startup.ps1 @@ -0,0 +1,37 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ExecutablePath +) + +$ErrorActionPreference = "Stop" +$resolvedExecutable = (Resolve-Path -LiteralPath $ExecutablePath).Path +if ([System.IO.Path]::GetFileName($resolvedExecutable) -ne "Zcbot.WindowsNode.exe") { + throw "ExecutablePath must point to Zcbot.WindowsNode.exe" +} + +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$workingDirectory = Split-Path -Parent $resolvedExecutable +$action = New-ScheduledTaskAction ` + -Execute $resolvedExecutable ` + -WorkingDirectory $workingDirectory +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $currentUser +$principal = New-ScheduledTaskPrincipal ` + -UserId $currentUser ` + -LogonType Interactive ` + -RunLevel Limited +$settings = New-ScheduledTaskSettingsSet ` + -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) ` + -MultipleInstances IgnoreNew + +Register-ScheduledTask ` + -TaskName "Zcbot Windows Node" ` + -Description "Starts the zcbot Windows execution node after this account logs on." ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Settings $settings ` + -Force | Out-Null + +Write-Output "[OK] Startup task registered for $currentUser."