Compare commits
2 Commits
1ac9aa1ba0
...
fac4e5fa0a
| Author | SHA1 | Date |
|---|---|---|
|
|
fac4e5fa0a | |
|
|
109d395356 |
|
|
@ -18,6 +18,10 @@ __pycache__/
|
||||||
coverage.json
|
coverage.json
|
||||||
htmlcov/
|
htmlcov/
|
||||||
|
|
||||||
|
# .NET Windows Node build outputs
|
||||||
|
windows-node/**/bin/
|
||||||
|
windows-node/**/obj/
|
||||||
|
|
||||||
# Virtualenv
|
# Virtualenv
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
|
||||||
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
> 工程口径的完整记录见 `PROGRESS.md` / git log。
|
||||||
|
|
||||||
|
## 0.65.0 — 2026-08-12
|
||||||
|
|
||||||
|
- 新增 Windows Node:可在 Windows 本机通过托盘程序注册并连接 zcbot,为后续受控调用本机科研软件提供节点基础;管理后台可生成 10 分钟有效、仅可使用一次的注册码。
|
||||||
|
|
||||||
## 0.64.3 — 2026-08-12
|
## 0.64.3 — 2026-08-12
|
||||||
|
|
||||||
- 粘贴、拖拽或上传同名文件时不再静默覆盖,系统会自动编号保留两份;需要覆盖时,可在文件菜单选择「替换文件」并确认。
|
- 粘贴、拖拽或上传同名文件时不再静默覆盖,系统会自动编号保留两份;需要覆盖时,可在文件菜单选择「替换文件」并确认。
|
||||||
|
|
|
||||||
10
DESIGN.md
10
DESIGN.md
|
|
@ -454,6 +454,16 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
||||||
|
|
||||||
**落地顺序/触发**:先 `ActionPolicy -> Attention Inbox(Web) -> 渠道响应 -> action audit`,四者作为一个完整外部写安全闭环;再按真实无人值守写需求增加 exact-target standing rule,按真实长计算中间步骤增加 self-wake;有明确第三方 MCP 接入对象后再做 adapter。任何阶段都不得先开放外部写、再用 prompt 要求模型“记得询问”补安全边界。实现时需回写 §3.1 loop、§7.5 sandbox/tool registry、§8.5 scheduler、§8.7 channel、§8.13 bg proc 与 §8.14 external systems 的最终契约,并以 migration 保持现有 API/数据兼容。
|
**落地顺序/触发**:先 `ActionPolicy -> Attention Inbox(Web) -> 渠道响应 -> action audit`,四者作为一个完整外部写安全闭环;再按真实无人值守写需求增加 exact-target standing rule,按真实长计算中间步骤增加 self-wake;有明确第三方 MCP 接入对象后再做 adapter。任何阶段都不得先开放外部写、再用 prompt 要求模型“记得询问”补安全边界。实现时需回写 §3.1 loop、§7.5 sandbox/tool registry、§8.5 scheduler、§8.7 channel、§8.13 bg proc 与 §8.14 external systems 的最终契约,并以 migration 保持现有 API/数据兼容。
|
||||||
|
|
||||||
|
### 8.16 Windows Node 内网 MVP(implementation,2026-08-12)
|
||||||
|
|
||||||
|
第一阶段以 `docs/windows-node-mvp-intranet.md` 为实现契约:Windows Node 只作为受控执行节点,通过出站 HTTP/WS 主动连接 zcbot;首批能力固定为 `origin.plot@v1`。长期方案中的 mTLS、Service/DesktopRunner 双进程、完整租约与多节点调度暂不进入 MVP,但 URL path、Node ID、Bearer Header 和任务协议保留原位升级空间。
|
||||||
|
|
||||||
|
云端控制面使用独立的 `compute_node_enrollments` 与 `compute_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和允许能力,成功后原子消费。每个节点获得独立高熵 Token,数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。
|
||||||
|
|
||||||
|
Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/compute/nodes/connect` WebSocket。进程内 Connection Manager 保证同一节点单活,新连接关闭旧连接;`hello`/`heartbeat` 更新版本、容量、软件健康与最后在线时间。管理员禁用节点时先持久化禁用态,再关闭现有连接;断线收尾不得覆盖禁用态。当前单活只覆盖单 Web 进程,生产启用多实例前必须增加 Redis/PG fencing 或将 Node API 固定路由到单一控制面实例。
|
||||||
|
|
||||||
|
首批只落注册、认证、心跳、状态与禁用基础链路。`compute_jobs`、任务 offer/accept、Origin Worker、输入输出传输、重连对账和 Token 轮换属于后续垂直闭环,不以任意命令或脚本接口临时代替。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
## 附录:DeepSeek V4 关键事实(2026-04-24)
|
||||||
|
|
|
||||||
14
PROGRESS.md
14
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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.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。
|
- **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/llm_transport.py 438 ← wire 层健壮性:畸形/吐空检测+留痕+非流式降级重试(07-23 自 loop 析出)
|
||||||
core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,factory);secret/host 工具按实际能力 gate)
|
core/tool_registry.py 264 ← 声明式工具注册表((组名,gate,factory);secret/host 工具按实际能力 gate)
|
||||||
core/context.py 95 ← LLM 调用前压缩旧 tool / load_skill 消息(带压力门槛),保 tool_call 协议字段
|
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/sinks.py 101
|
||||||
core/paths.py 50 ← task_dir db form 归一
|
core/paths.py 50 ← task_dir db form 归一
|
||||||
core/probe.py 243
|
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 容器池
|
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 外部系统元工具
|
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
|
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/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/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按域析出
|
||||||
web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全
|
web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全
|
||||||
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
|
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
|
||||||
web/broker.py / sinks.py / pptx_render.py / admin.py
|
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
|
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
|
||||||
tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
|
tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
|
||||||
─────────────────────────────────
|
─────────────────────────────────
|
||||||
|
|
|
||||||
29
RUN.md
29
RUN.md
|
|
@ -1039,6 +1039,35 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
|
||||||
|
|
||||||
## 关键路径与文件
|
## 关键路径与文件
|
||||||
|
|
||||||
|
### Windows Node 内网 MVP(开发中)
|
||||||
|
|
||||||
|
先执行 `alembic upgrade head` 创建 `compute_node_enrollments` 和 `compute_nodes`。不要在未确认目标数据库时运行迁移;本机 `.env` 的 `ZCBOT_DB_URL` 可能是生产隧道。
|
||||||
|
|
||||||
|
云端当前提供:
|
||||||
|
|
||||||
|
- 管理员 `POST /v1/admin/compute-node-enrollments` 创建一次性注册码;
|
||||||
|
- Node `POST /v1/compute/nodes/enroll` 注册并一次性取得 `node_id`、`node_token`;
|
||||||
|
- Node 携带 `Authorization: Bearer <node_token>` 和 `X-Node-Id` 连接 `WS /v1/compute/nodes/connect`;
|
||||||
|
- 管理员 `GET /v1/admin/compute-nodes` 查看节点,`PATCH /v1/admin/compute-nodes/{node_id}` 启停节点。
|
||||||
|
|
||||||
|
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`
|
- **入口**:`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/`
|
- **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/`
|
||||||
- **工具**:`tools/{fs, shell, run_python, skill_tool}.py`
|
- **工具**:`tools/{fs, shell, run_python, skill_tool}.py`
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.64.3"
|
__version__ = "0.65.0"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
"""Windows Node MVP 的注册、认证与状态持久化。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from hashlib import sha256
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from core.storage.engine import session_scope
|
||||||
|
from core.storage.models import ComputeNode, ComputeNodeEnrollment
|
||||||
|
|
||||||
|
SUPPORTED_CAPABILITIES = frozenset({"origin.plot@v1"})
|
||||||
|
MAX_ENROLLMENT_FAILURES = 5
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_secret(value: str) -> str:
|
||||||
|
return bcrypt.hashpw(value.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_secret(value: str, digest: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(value.encode("utf-8"), digest.encode("ascii"))
|
||||||
|
except (ValueError, TypeError, UnicodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _enrollment_digest(value: str) -> str:
|
||||||
|
"""注册码本身有 128 bit 熵;定长摘要允许数据库精确查找和行锁。"""
|
||||||
|
return sha256(value.encode("ascii", errors="ignore")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_enrollment(
|
||||||
|
created_by: UUID,
|
||||||
|
*,
|
||||||
|
expected_name: str = "",
|
||||||
|
capabilities: list[str] | None = None,
|
||||||
|
ttl_seconds: int = 600,
|
||||||
|
) -> dict:
|
||||||
|
allowed = list(dict.fromkeys(capabilities or ["origin.plot@v1"]))
|
||||||
|
if not allowed or any(item not in SUPPORTED_CAPABILITIES for item in allowed):
|
||||||
|
raise ComputeNodeError("unsupported capability")
|
||||||
|
if not 60 <= ttl_seconds <= 3600:
|
||||||
|
raise ComputeNodeError("ttl_seconds must be between 60 and 3600")
|
||||||
|
code = "ZCN-" + secrets.token_hex(16).upper()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||||
|
row = ComputeNodeEnrollment(
|
||||||
|
enrollment_id=uuid4(),
|
||||||
|
code_hash=_enrollment_digest(code),
|
||||||
|
expected_name=(expected_name or "").strip() or None,
|
||||||
|
allowed_capabilities=allowed,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
with session_scope() as session:
|
||||||
|
session.add(row)
|
||||||
|
return {
|
||||||
|
"enrollment_code": code,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"capabilities": allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_node(
|
||||||
|
*,
|
||||||
|
enrollment_code: str,
|
||||||
|
node_name: str,
|
||||||
|
install_id: UUID,
|
||||||
|
node_version: str,
|
||||||
|
os_version: str,
|
||||||
|
capabilities: list[str],
|
||||||
|
) -> dict:
|
||||||
|
name = node_name.strip()
|
||||||
|
requested = list(dict.fromkeys(capabilities))
|
||||||
|
if not name or not requested:
|
||||||
|
raise ComputeNodeError("node_name and capabilities are required")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
error: str | None = None
|
||||||
|
with session_scope() as session:
|
||||||
|
enrollment = session.execute(
|
||||||
|
select(ComputeNodeEnrollment)
|
||||||
|
.where(
|
||||||
|
ComputeNodeEnrollment.code_hash == _enrollment_digest(enrollment_code),
|
||||||
|
ComputeNodeEnrollment.consumed_at.is_(None),
|
||||||
|
ComputeNodeEnrollment.expires_at > now,
|
||||||
|
ComputeNodeEnrollment.failed_attempts < MAX_ENROLLMENT_FAILURES,
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if enrollment is None:
|
||||||
|
raise ComputeNodeError("invalid or expired enrollment code")
|
||||||
|
enrollment.failed_attempts += 1
|
||||||
|
if enrollment.expected_name and enrollment.expected_name != name:
|
||||||
|
error = "node name does not match enrollment"
|
||||||
|
elif any(item not in enrollment.allowed_capabilities for item in requested):
|
||||||
|
error = "capability is not allowed by enrollment"
|
||||||
|
else:
|
||||||
|
existing = session.execute(
|
||||||
|
select(ComputeNode.node_id).where(ComputeNode.install_id == install_id)
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
error = "install is already enrolled"
|
||||||
|
else:
|
||||||
|
token = secrets.token_urlsafe(48)
|
||||||
|
node_id = uuid4()
|
||||||
|
session.add(
|
||||||
|
ComputeNode(
|
||||||
|
node_id=node_id,
|
||||||
|
name=name,
|
||||||
|
install_id=install_id,
|
||||||
|
token_hash=_hash_secret(token),
|
||||||
|
status="offline",
|
||||||
|
capabilities=requested,
|
||||||
|
node_version=node_version.strip(),
|
||||||
|
os_version=os_version.strip(),
|
||||||
|
runtime={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
enrollment.consumed_at = now
|
||||||
|
if error is not None:
|
||||||
|
raise ComputeNodeError(error)
|
||||||
|
return {
|
||||||
|
"node_id": str(node_id),
|
||||||
|
"node_token": token,
|
||||||
|
"heartbeat_seconds": 15,
|
||||||
|
"max_concurrency": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_node(node_id: UUID, token: str) -> dict:
|
||||||
|
with session_scope() as session:
|
||||||
|
node = session.get(ComputeNode, node_id)
|
||||||
|
if (
|
||||||
|
node is None
|
||||||
|
or node.status == "disabled"
|
||||||
|
or not _verify_secret(token, node.token_hash)
|
||||||
|
):
|
||||||
|
raise ComputeNodeError("invalid node credentials")
|
||||||
|
return {
|
||||||
|
"node_id": node.node_id,
|
||||||
|
"install_id": node.install_id,
|
||||||
|
"status": node.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_node_runtime(node_id: UUID, *, status: str, runtime: dict) -> None:
|
||||||
|
with session_scope() as session:
|
||||||
|
node = session.get(ComputeNode, node_id)
|
||||||
|
if node is None or node.status == "disabled":
|
||||||
|
raise ComputeNodeError("node is disabled or missing")
|
||||||
|
node.status = status
|
||||||
|
node.runtime = runtime
|
||||||
|
node.last_seen_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_node_offline(node_id: UUID) -> None:
|
||||||
|
"""仅把活动节点转离线;管理员禁用态不可被断线收尾覆盖。"""
|
||||||
|
with session_scope() as session:
|
||||||
|
node = session.get(ComputeNode, node_id)
|
||||||
|
if node is not None and node.status != "disabled":
|
||||||
|
node.status = "offline"
|
||||||
|
|
||||||
|
|
||||||
|
def set_node_disabled(node_id: UUID, disabled: bool) -> bool:
|
||||||
|
with session_scope() as session:
|
||||||
|
node = session.get(ComputeNode, node_id)
|
||||||
|
if node is None:
|
||||||
|
return False
|
||||||
|
node.status = "disabled" if disabled else "offline"
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_nodes() -> list[dict]:
|
||||||
|
with session_scope() as session:
|
||||||
|
rows = (
|
||||||
|
session.execute(select(ComputeNode).order_by(ComputeNode.created_at))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"node_id": str(row.node_id),
|
||||||
|
"name": row.name,
|
||||||
|
"status": row.status,
|
||||||
|
"capabilities": row.capabilities,
|
||||||
|
"node_version": row.node_version,
|
||||||
|
"os_version": row.os_version,
|
||||||
|
"runtime": row.runtime,
|
||||||
|
"last_seen_at": row.last_seen_at.isoformat()
|
||||||
|
if row.last_seen_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
@ -420,6 +420,47 @@ class ChannelBinding(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeEnrollment(Base):
|
||||||
|
"""Windows Node 一次性注册码;数据库只保存不可逆摘要。"""
|
||||||
|
|
||||||
|
__tablename__ = "compute_node_enrollments"
|
||||||
|
enrollment_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||||
|
code_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
expected_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
allowed_capabilities: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
failed_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
consumed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_by: Mapped[Optional[UUID]] = mapped_column(
|
||||||
|
PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNode(Base):
|
||||||
|
"""平台托管的 Windows 执行节点身份与最后一次运行态。"""
|
||||||
|
|
||||||
|
__tablename__ = "compute_nodes"
|
||||||
|
node_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
install_id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), nullable=False, unique=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(Text, nullable=False, default="offline", server_default="offline")
|
||||||
|
capabilities: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
node_version: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
|
||||||
|
os_version: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
|
||||||
|
runtime: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExternalSystemDefinition(Base):
|
class ExternalSystemDefinition(Base):
|
||||||
"""管理员维护的可信外部系统目录;不含任何用户凭据。"""
|
"""管理员维护的可信外部系统目录;不含任何用户凭据。"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Add Windows compute node enrollment and registry tables.
|
||||||
|
|
||||||
|
Revision ID: 0030
|
||||||
|
Revises: 0029
|
||||||
|
Create Date: 2026-08-12
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0030"
|
||||||
|
down_revision: str | None = "0029"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"compute_node_enrollments",
|
||||||
|
sa.Column("enrollment_id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("code_hash", sa.Text(), nullable=False, unique=True),
|
||||||
|
sa.Column("expected_name", sa.Text(), nullable=True),
|
||||||
|
sa.Column("allowed_capabilities", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("failed_attempts", sa.Integer(), server_default="0", nullable=False),
|
||||||
|
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_by",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.user_id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"compute_nodes",
|
||||||
|
sa.Column("node_id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"install_id", postgresql.UUID(as_uuid=True), nullable=False, unique=True
|
||||||
|
),
|
||||||
|
sa.Column("token_hash", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), server_default="offline", nullable=False),
|
||||||
|
sa.Column("capabilities", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("node_version", sa.Text(), server_default="", nullable=False),
|
||||||
|
sa.Column("os_version", sa.Text(), server_default="", nullable=False),
|
||||||
|
sa.Column("runtime", postgresql.JSONB(), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_compute_nodes_status", "compute_nodes", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_compute_nodes_status", table_name="compute_nodes")
|
||||||
|
op.drop_table("compute_nodes")
|
||||||
|
op.drop_table("compute_node_enrollments")
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.303",
|
||||||
|
"rollForward": "latestPatch",
|
||||||
|
"allowPrerelease": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from sqlalchemy import create_mock_engine
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from core.compute_nodes import _enrollment_digest, _hash_secret, _verify_secret
|
||||||
|
from web.routers.compute_nodes import NodeConnectionManager, _bearer
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeSecurityTests(unittest.TestCase):
|
||||||
|
def test_secret_hash_is_salted_and_verifiable(self) -> None:
|
||||||
|
first = _hash_secret("node-secret")
|
||||||
|
second = _hash_secret("node-secret")
|
||||||
|
self.assertNotEqual(first, second)
|
||||||
|
self.assertNotIn("node-secret", first)
|
||||||
|
self.assertTrue(_verify_secret("node-secret", first))
|
||||||
|
self.assertFalse(_verify_secret("wrong", first))
|
||||||
|
|
||||||
|
def test_bearer_parser_rejects_query_style_or_missing_token(self) -> None:
|
||||||
|
self.assertEqual(_bearer("Bearer abc"), "abc")
|
||||||
|
with self.assertRaisesRegex(Exception, "missing node bearer token"):
|
||||||
|
_bearer(None)
|
||||||
|
|
||||||
|
def test_enrollment_digest_does_not_store_plaintext(self) -> None:
|
||||||
|
digest = _enrollment_digest("ZCN-ABC")
|
||||||
|
self.assertEqual(len(digest), 64)
|
||||||
|
self.assertNotIn("ZCN-ABC", digest)
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeConnectionTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_new_connection_replaces_old_without_removing_new(self) -> None:
|
||||||
|
manager = NodeConnectionManager()
|
||||||
|
node_id = uuid4()
|
||||||
|
old = AsyncMock()
|
||||||
|
new = AsyncMock()
|
||||||
|
|
||||||
|
await manager.activate(node_id, old)
|
||||||
|
await manager.activate(node_id, new)
|
||||||
|
|
||||||
|
old.close.assert_awaited_once_with(
|
||||||
|
code=4001, reason="replaced by a newer connection"
|
||||||
|
)
|
||||||
|
self.assertFalse(await manager.remove(node_id, old))
|
||||||
|
self.assertTrue(await manager.remove(node_id, new))
|
||||||
|
|
||||||
|
async def test_admin_close_removes_and_closes_connection(self) -> None:
|
||||||
|
manager = NodeConnectionManager()
|
||||||
|
node_id = uuid4()
|
||||||
|
websocket = AsyncMock()
|
||||||
|
await manager.activate(node_id, websocket)
|
||||||
|
await manager.close(node_id)
|
||||||
|
websocket.close.assert_awaited_once_with(code=4003, reason="node disabled")
|
||||||
|
self.assertFalse(await manager.remove(node_id, websocket))
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeMigrationTests(unittest.TestCase):
|
||||||
|
def test_0030_upgrade_compiles_as_postgresql_ddl(self) -> None:
|
||||||
|
statements: list[str] = []
|
||||||
|
|
||||||
|
def capture(sql, *multiparams, **params):
|
||||||
|
statements.append(str(sql.compile(dialect=postgresql.dialect())))
|
||||||
|
|
||||||
|
engine = create_mock_engine("postgresql+psycopg://", capture)
|
||||||
|
operations = Operations(MigrationContext.configure(engine.connect()))
|
||||||
|
migration = importlib.import_module(
|
||||||
|
"db.migrations.versions.20260812_2000_0030_compute_nodes"
|
||||||
|
)
|
||||||
|
with patch.object(migration, "op", operations):
|
||||||
|
migration.upgrade()
|
||||||
|
|
||||||
|
rendered = "\n".join(statements)
|
||||||
|
self.assertIn("compute_node_enrollments", rendered)
|
||||||
|
self.assertIn("compute_nodes", rendered)
|
||||||
|
self.assertIn("ix_compute_nodes_status", rendered)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -10,6 +10,7 @@ from web.static_files import NoCacheStaticFiles
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
STATIC_DIR = ROOT / "web" / "static"
|
STATIC_DIR = ROOT / "web" / "static"
|
||||||
DEV_HTML = STATIC_DIR / "dev.html"
|
DEV_HTML = STATIC_DIR / "dev.html"
|
||||||
|
ADMIN_HTML = STATIC_DIR / "admin.html"
|
||||||
UI_CSS = STATIC_DIR / "ui.css"
|
UI_CSS = STATIC_DIR / "ui.css"
|
||||||
JS_DIR = STATIC_DIR / "js"
|
JS_DIR = STATIC_DIR / "js"
|
||||||
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
|
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
|
||||||
|
|
@ -25,6 +26,18 @@ def _frontend_source() -> str:
|
||||||
|
|
||||||
|
|
||||||
class StaticVendorTests(unittest.TestCase):
|
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:
|
def test_dev_html_uses_local_markdown_vendor_assets(self) -> None:
|
||||||
html = DEV_HTML.read_text(encoding="utf-8")
|
html = DEV_HTML.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -28,6 +28,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from core import __version__
|
from core import __version__
|
||||||
|
|
||||||
|
from .admin import register_admin_routes
|
||||||
from .auth import (
|
from .auth import (
|
||||||
REFRESHED_TOKEN_HEADER,
|
REFRESHED_TOKEN_HEADER,
|
||||||
TOKEN_EXPIRES_HEADER,
|
TOKEN_EXPIRES_HEADER,
|
||||||
|
|
@ -35,7 +36,6 @@ from .auth import (
|
||||||
make_require_admin,
|
make_require_admin,
|
||||||
make_require_user,
|
make_require_user,
|
||||||
)
|
)
|
||||||
from .admin import register_admin_routes
|
|
||||||
from .background import (
|
from .background import (
|
||||||
cancel_and_wait,
|
cancel_and_wait,
|
||||||
drain_inflight,
|
drain_inflight,
|
||||||
|
|
@ -49,8 +49,9 @@ from .background import (
|
||||||
from .broker import broker
|
from .broker import broker
|
||||||
from .routers.asr import register_asr_routes
|
from .routers.asr import register_asr_routes
|
||||||
from .routers.authroutes import register_auth_routes
|
from .routers.authroutes import register_auth_routes
|
||||||
from .routers.files import register_file_routes
|
from .routers.compute_nodes import register_compute_node_routes
|
||||||
from .routers.external_systems import register_external_system_routes
|
from .routers.external_systems import register_external_system_routes
|
||||||
|
from .routers.files import register_file_routes
|
||||||
from .routers.kb import register_kb_routes
|
from .routers.kb import register_kb_routes
|
||||||
from .routers.messages import register_message_routes
|
from .routers.messages import register_message_routes
|
||||||
from .routers.misc import register_misc_routes
|
from .routers.misc import register_misc_routes
|
||||||
|
|
@ -205,6 +206,7 @@ def create_app() -> FastAPI:
|
||||||
register_asr_routes(app, require_user=require_user, auth_cfg=auth_cfg)
|
register_asr_routes(app, require_user=require_user, auth_cfg=auth_cfg)
|
||||||
register_task_routes(app, require_user=require_user)
|
register_task_routes(app, require_user=require_user)
|
||||||
register_message_routes(app, require_user=require_user)
|
register_message_routes(app, require_user=require_user)
|
||||||
|
register_compute_node_routes(app, require_admin=require_admin)
|
||||||
|
|
||||||
# ───────────── 管理后台(admin-only)─────────────
|
# ───────────── 管理后台(admin-only)─────────────
|
||||||
register_admin_routes(app, require_admin)
|
register_admin_routes(app, require_admin)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Windows Node MVP 的注册、管理与长连接端点。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, WebSocket, WebSocketDisconnect, status
|
||||||
|
|
||||||
|
from core.compute_nodes import (
|
||||||
|
ComputeNodeError,
|
||||||
|
authenticate_node,
|
||||||
|
create_enrollment,
|
||||||
|
enroll_node,
|
||||||
|
list_nodes,
|
||||||
|
mark_node_offline,
|
||||||
|
set_node_disabled,
|
||||||
|
update_node_runtime,
|
||||||
|
)
|
||||||
|
from web.schemas import (
|
||||||
|
ComputeEnrollmentCreateRequest,
|
||||||
|
ComputeNodeDisableRequest,
|
||||||
|
ComputeNodeEnrollRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeConnectionManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._connections: dict[UUID, WebSocket] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def activate(self, node_id: UUID, websocket: WebSocket) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
old = self._connections.get(node_id)
|
||||||
|
self._connections[node_id] = websocket
|
||||||
|
if old is not None and old is not websocket:
|
||||||
|
await old.close(code=4001, reason="replaced by a newer connection")
|
||||||
|
|
||||||
|
async def remove(self, node_id: UUID, websocket: WebSocket) -> bool:
|
||||||
|
async with self._lock:
|
||||||
|
if self._connections.get(node_id) is websocket:
|
||||||
|
self._connections.pop(node_id, None)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def close(self, node_id: UUID) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
websocket = self._connections.pop(node_id, None)
|
||||||
|
if websocket is not None:
|
||||||
|
await websocket.close(code=4003, reason="node disabled")
|
||||||
|
|
||||||
|
|
||||||
|
node_connections = NodeConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
def _bearer(authorization: str | None) -> str:
|
||||||
|
scheme, _, token = (authorization or "").partition(" ")
|
||||||
|
if scheme.lower() != "bearer" or not token:
|
||||||
|
raise ComputeNodeError("missing node bearer token")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def register_compute_node_routes(app, *, require_admin) -> None:
|
||||||
|
@app.post(
|
||||||
|
"/v1/compute/nodes/enroll",
|
||||||
|
tags=["compute-nodes"],
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def node_enroll(body: ComputeNodeEnrollRequest):
|
||||||
|
try:
|
||||||
|
return enroll_node(**body.model_dump())
|
||||||
|
except ComputeNodeError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
@app.websocket("/v1/compute/nodes/connect")
|
||||||
|
async def node_connect(websocket: WebSocket):
|
||||||
|
try:
|
||||||
|
node_id = UUID(websocket.headers.get("x-node-id", ""))
|
||||||
|
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")
|
||||||
|
return
|
||||||
|
await websocket.accept()
|
||||||
|
await node_connections.activate(node_id, websocket)
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "connected", "heartbeat_seconds": 15})
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_json()
|
||||||
|
message_type = message.get("type")
|
||||||
|
payload = message.get("payload") or {}
|
||||||
|
if message_type not in {"hello", "heartbeat"} or not isinstance(
|
||||||
|
payload, dict
|
||||||
|
):
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "code": "unsupported_message"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if payload.get("install_id") and payload["install_id"] != str(
|
||||||
|
identity["install_id"]
|
||||||
|
):
|
||||||
|
await websocket.close(code=1008, reason="install identity mismatch")
|
||||||
|
return
|
||||||
|
await asyncio.to_thread(
|
||||||
|
update_node_runtime,
|
||||||
|
node_id,
|
||||||
|
status="online",
|
||||||
|
runtime=payload,
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "ack", "message_id": message.get("message_id")}
|
||||||
|
)
|
||||||
|
except (ComputeNodeError, WebSocketDisconnect, RuntimeError, ValueError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if await node_connections.remove(node_id, websocket):
|
||||||
|
await asyncio.to_thread(mark_node_offline, node_id)
|
||||||
|
|
||||||
|
@app.post("/v1/admin/compute-node-enrollments", tags=["admin"])
|
||||||
|
def admin_create_compute_enrollment(
|
||||||
|
body: ComputeEnrollmentCreateRequest,
|
||||||
|
user_id: UUID = Depends(require_admin), # noqa: B008
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return create_enrollment(user_id, **body.model_dump())
|
||||||
|
except ComputeNodeError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
@app.get("/v1/admin/compute-nodes", tags=["admin"])
|
||||||
|
def admin_compute_nodes(user_id: UUID = Depends(require_admin)): # noqa: B008
|
||||||
|
return {"results": list_nodes()}
|
||||||
|
|
||||||
|
@app.patch("/v1/admin/compute-nodes/{node_id}", tags=["admin"])
|
||||||
|
async def admin_disable_compute_node(
|
||||||
|
node_id: UUID,
|
||||||
|
body: ComputeNodeDisableRequest,
|
||||||
|
user_id: UUID = Depends(require_admin), # noqa: B008
|
||||||
|
):
|
||||||
|
if not await asyncio.to_thread(set_node_disabled, node_id, body.disabled):
|
||||||
|
raise HTTPException(404, "compute node not found")
|
||||||
|
if body.disabled:
|
||||||
|
await node_connections.close(node_id)
|
||||||
|
return {
|
||||||
|
"node_id": str(node_id),
|
||||||
|
"status": "disabled" if body.disabled else "offline",
|
||||||
|
}
|
||||||
|
|
@ -107,3 +107,22 @@ class ExternalSystemCreateRequest(BaseModel):
|
||||||
|
|
||||||
class ExternalSystemCredentialsRequest(BaseModel):
|
class ExternalSystemCredentialsRequest(BaseModel):
|
||||||
credentials: dict[str, str] = Field(default_factory=dict)
|
credentials: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeEnrollmentCreateRequest(BaseModel):
|
||||||
|
expected_name: str = ""
|
||||||
|
capabilities: list[str] = Field(default_factory=lambda: ["origin.plot@v1"])
|
||||||
|
ttl_seconds: int = 600
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeEnrollRequest(BaseModel):
|
||||||
|
enrollment_code: str
|
||||||
|
node_name: str
|
||||||
|
install_id: UUID
|
||||||
|
node_version: str = ""
|
||||||
|
os_version: str = ""
|
||||||
|
capabilities: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNodeDisableRequest(BaseModel):
|
||||||
|
disabled: bool = True
|
||||||
|
|
|
||||||
|
|
@ -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 { 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); }
|
#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 { display: flex; align-items: center; gap: 12px; justify-content: flex-end; margin-top: 10px; }
|
||||||
.pager button {
|
.pager button {
|
||||||
font-size: 12px; padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--r-md);
|
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; }
|
.ctrl select { min-width: 0; }
|
||||||
.ext-form-grid { grid-template-columns: 1fr; }
|
.ext-form-grid { grid-template-columns: 1fr; }
|
||||||
.ext-form-grid > label, .ext-form-section { grid-column: 1 !important; }
|
.ext-form-grid > label, .ext-form-section { grid-column: 1 !important; }
|
||||||
|
.node-code-wrap { flex-direction: column; }
|
||||||
/* 窄屏:目录变顶部横向 chip 条 */
|
/* 窄屏:目录变顶部横向 chip 条 */
|
||||||
#layout { grid-template-columns: 1fr; gap: 10px; }
|
#layout { grid-template-columns: 1fr; gap: 10px; }
|
||||||
#toc {
|
#toc {
|
||||||
|
|
@ -230,6 +278,34 @@
|
||||||
<!-- 导出 PDF:屏幕隐藏,仅 @media print 显示;exportPdf() 现填充后 window.print() -->
|
<!-- 导出 PDF:屏幕隐藏,仅 @media print 显示;exportPdf() 现填充后 window.print() -->
|
||||||
<div id="print-report"></div>
|
<div id="print-report"></div>
|
||||||
<div id="app-dialog" class="modal"></div>
|
<div id="app-dialog" class="modal"></div>
|
||||||
|
<div id="node-enrollment-modal" class="modal" aria-hidden="true">
|
||||||
|
<div class="card" role="dialog" aria-modal="true" aria-labelledby="node-enrollment-title">
|
||||||
|
<div class="node-modal-head">
|
||||||
|
<h3 id="node-enrollment-title">生成 Windows Node 注册码</h3>
|
||||||
|
<button id="node-enrollment-close" type="button" aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="node-enrollment-form">
|
||||||
|
<div class="node-modal-body" id="node-enrollment-inputs">
|
||||||
|
<label for="node-expected-name">限定节点名称(可选)</label>
|
||||||
|
<input id="node-expected-name" maxlength="120" autocomplete="off" placeholder="例如:实验室绘图节点 01" />
|
||||||
|
<p class="node-help">填写后,客户端注册时必须使用完全相同的节点名称。注册码有效期为 10 分钟。</p>
|
||||||
|
</div>
|
||||||
|
<div class="node-modal-body" id="node-enrollment-result" hidden>
|
||||||
|
<p class="node-warning">该注册码只能成功使用一次,关闭窗口后无法再次查看。请立即复制并发送到目标电脑。</p>
|
||||||
|
<label>一次性注册码</label>
|
||||||
|
<div class="node-code-wrap">
|
||||||
|
<code class="node-code" id="node-enrollment-code"></code>
|
||||||
|
<button id="node-enrollment-copy" type="button">复制</button>
|
||||||
|
</div>
|
||||||
|
<p class="node-help" id="node-enrollment-expiry"></p>
|
||||||
|
</div>
|
||||||
|
<div class="node-modal-actions">
|
||||||
|
<button id="node-enrollment-cancel" type="button">取消</button>
|
||||||
|
<button id="node-enrollment-submit" class="primary" type="submit">生成注册码</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/static/js/admin.js"></script>
|
<script type="module" src="/static/js/admin.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
|
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
|
||||||
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
|
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
|
||||||
import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js";
|
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 LS_TOKEN = "zcbot.token";
|
||||||
const REFRESH_MS = 10000;
|
const REFRESH_MS = 10000;
|
||||||
|
|
@ -14,6 +14,7 @@ const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
|
["s-runtime", "运行态"], ["s-tasks", "任务"], ["s-usage", "用户与用量"],
|
||||||
["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
|
["s-models", "按模型"], ["s-users", "各用户用量"], ["s-storage", "存储"],
|
||||||
|
["s-windows-node", "Windows Node"],
|
||||||
["s-external", "外部系统"],
|
["s-external", "外部系统"],
|
||||||
["s-toolfail", "工具失败"],
|
["s-toolfail", "工具失败"],
|
||||||
];
|
];
|
||||||
|
|
@ -158,6 +159,87 @@ function renderByDay(rows) {
|
||||||
+ `<tbody>${body}</tbody>${foot}</table></div></div>`;
|
+ `<tbody>${body}</tbody>${foot}</table></div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderWindowsNodeEnrollment() {
|
||||||
|
$("s-windows-node").innerHTML = `<div class="card"><div class="card-head">`
|
||||||
|
+ `<div><h2>Windows Node</h2><div class="node-help">为新的 Windows 计算节点生成短时、一次性注册码。</div></div>`
|
||||||
|
+ `<button id="node-enrollment-open" class="primary" type="button">生成 Windows Node 注册码</button>`
|
||||||
|
+ `</div></div>`;
|
||||||
|
$("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() {
|
function renderExternalDefinitions() {
|
||||||
const rows = externalDefinitions.map(r => {
|
const rows = externalDefinitions.map(r => {
|
||||||
const cfg = r.config || {};
|
const cfg = r.config || {};
|
||||||
|
|
@ -735,6 +817,7 @@ function renderMetrics(d) {
|
||||||
$("s-usage").innerHTML =
|
$("s-usage").innerHTML =
|
||||||
renderUsersAndUsage(d.users || {}, d.usage || {})
|
renderUsersAndUsage(d.users || {}, d.usage || {})
|
||||||
+ renderByDay((d.usage || {}).by_day_7d);
|
+ renderByDay((d.usage || {}).by_day_7d);
|
||||||
|
renderWindowsNodeEnrollment();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMsg(html) {
|
function showMsg(html) {
|
||||||
|
|
@ -955,6 +1038,18 @@ function stopAuto() {
|
||||||
$("refresh").onclick = refresh;
|
$("refresh").onclick = refresh;
|
||||||
$("export").onclick = exportPdf;
|
$("export").onclick = exportPdf;
|
||||||
$("auto-refresh").onchange = startAuto;
|
$("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", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (document.hidden) stopAuto();
|
if (document.hidden) stopAuto();
|
||||||
|
|
|
||||||
|
|
@ -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 重装、运行账号变更或身份丢失时应由管理员禁用旧节点并重新注册。
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<Solution>
|
||||||
|
<Project Path="Zcbot.WindowsNode/Zcbot.WindowsNode.csproj" />
|
||||||
|
</Solution>
|
||||||
|
|
@ -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<EnrollOptions, Task>? 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string, string>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<EnrollResponse>(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];
|
||||||
|
}
|
||||||
|
|
@ -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<StoredNodeConfig>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<NodeStatus>? 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string> Capabilities);
|
||||||
|
|
||||||
|
internal sealed record StoredNodeConfig(
|
||||||
|
string ServerUrl,
|
||||||
|
Guid NodeId,
|
||||||
|
Guid InstallId,
|
||||||
|
string NodeName,
|
||||||
|
string ProtectedNodeToken,
|
||||||
|
int HeartbeatSeconds,
|
||||||
|
IReadOnlyList<string> 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<string> 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);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<int> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||||
|
<AssemblyName>Zcbot.WindowsNode</AssemblyName>
|
||||||
|
<RootNamespace>Zcbot.WindowsNode</RootNamespace>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0-windows7.0": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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."
|
||||||
Loading…
Reference in New Issue