feat(crates): 入库 Rust 控制端客户端 nodeagent-client

复刻 @nodeagent/client 的协议行为,供桌面控制台(Tauri 壳)经 Cargo path 依赖:
- 模块:error / types / ulid / crypto / tls / discovery / client
- 握手:hello → challenge → auth → auth_ok;psk(HMAC-SHA256) 与 ed25519 两种模式
- 证书指纹 sha256(DER) 钉住(E_CERT_MISMATCH);ULID(Crockford Base32)
- 断线指数退避重连 1s→30s(±15% 抖动)

Rust async Send 三层陷阱(本轮主要成本,解法已写入 client.rs 注释):
1. futures_util::lock::Mutex 无手写 Sync 实现 → 含它的类型整体 !Sync
2. tokio::sync::MutexGuard 非 Send
3. establish → spawn(read_loop) → on_disconnected → establish 构成**类型级递归**,
   编译器无法证明 Send。最终采用**监督者模式**:单个 supervise 任务串行
   「建立+握手 → 等读循环 Notify 断开 → 退避重连」,读循环只 notify 不回调
   establish,从而打破递归;发送半用专用写任务 + mpsc 解耦(同步 send,不跨 await 持锁)。

测试:9 单测(crypto/ulid/Send+Sync 断言)+ 2 E2E(psk 握手→invoke 全链路、连不上端口
返回 NODE_OFFLINE),`cargo clippy --all-targets -- -D warnings` 零告警。
另附 examples/connect.rs(连真实被控端)与 examples/probe.rs(字段契约探针,
输出顶层键+数组首元素键,并按 system.info.os 区分「平台不支持」与「真失败」)。

.gitignore:crates/ 转正后必须显式忽略 /crates/target/ —— 此前是靠整个 /crates/
被忽略才没让 4GB 级构建产物进库,取消那条保护后会裸奔。
This commit is contained in:
jkooe committed 2026-10-07 10:13:41 +08:00
1 parent 9efd290061
commit 8ab8f8ae80
15 files changed
+3031 -6

No files matched your search

+4 -6
View File
@@ -41,9 +41,7 @@ release/
../win-backup/
win-backup/
# ---- 在建工程(用户 WIP,暂不入库;2026-10-07 保护性添加)----
# 背景:发版脚本的 add -A 曾把这些未跟踪文件误提交到公开仓库,已移出跟踪。
# 这些是皇上正在开发的 Rust 客户端与控制台方案,待其评审后再决定是否入库。
/crates/
/docs/console-plan.md
/docs/ROADMAP.md
# Rust 构建产物
# ⚠️ 2026-10-07:crates/ 此前整体被忽略,target/ 是「顺带」没进库的;crates/ 正式入库后
# 必须显式忽略,否则 4GB 级的 cargo target 会被 add -A 卷进提交。
/crates/target/
+1185
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
[workspace]
resolver = "2"
members = ["nodeagent-client"]
[workspace.dependencies]
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "nodeagent-client"
version = "0.1.0"
edition = "2021"
description = "nodeagent 控制端 Rust 客户端(行为对齐 @nodeagent/client)"
license = "MIT"
[dependencies]
base64 = "0.23.1"
ed25519-dalek = { version = "3.0.0", features = ["pkcs8", "alloc"] }
futures-util = "0.3.34"
hmac = "0.13.0"
rand = "0.10.3"
rustls = "0.23.45"
rustls-pemfile = "2.2.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
thiserror = "2.0.21"
tokio = { version = "1.53.2", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] }
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
url = "2.5.8"
webpki-roots = "1.0.9"
@@ -0,0 +1,53 @@
//! 连接一个真实 nodeagent 被控端的最小示例。
//!
//! 用法:`cargo run --example connect -- <url> <key> [client_id]`
//! 例如:`cargo run --example connect -- ws://127.0.0.1:8765 test-psk-key`
use nodeagent_client::{Client, ClientEvent, ClientOptions};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
let url = args
.get(1)
.cloned()
.unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
let key = args.get(2).cloned().unwrap_or_default();
let client_id = args
.get(3)
.cloned()
.unwrap_or_else(|| "rust-example".to_string());
let client = Client::new(ClientOptions {
url: url.clone(),
key,
client_id,
insecure: true,
auto_reconnect: false,
..Default::default()
});
client.set_handler(std::sync::Arc::new(|evt| {
if let ClientEvent::Log(msg) = evt {
println!("[log] {msg}");
}
}));
let caps = client.connect().await?;
println!("握手成功,{} 项能力", caps.len());
println!("agent 版本: {:?}", client.agent_version());
println!("证书指纹: {:?}", client.peer_cert_fingerprint());
for c in caps.iter().take(12) {
println!(" - {} (risk={}, v{})", c.name, c.risk, c.version);
}
let res = client.invoke("system.info", json!({})).await?;
println!("\nsystem.info → status={}", res.status);
if let Some(data) = &res.data {
println!("{}", serde_json::to_string_pretty(data)?);
}
client.close();
Ok(())
}
+188
View File
@@ -0,0 +1,188 @@
//! M3 能力探针:对真实被控端逐项调用「文件 / 审计 / 软件 / 进程服务」能力,核对契约。
//!
//! 输出**字段契约**而非数据预览——顶层键 + 数组首元素的键,
//! 便于与前端 `apps/console/src/types/index.ts` 的类型声明逐项比对。
//!
//! 用法:`cargo run --example probe -- <url> <key> [base_dir] [--full]`
//! --full 额外打印完整 JSON(长字段截断到 400 字符)
use nodeagent_client::{Client, ClientOptions};
use serde_json::{json, Value};
/// 一行对象 → `a, b, c` 形式的键清单。
fn keys_of(v: &Value) -> String {
match v {
Value::Object(m) => m.keys().cloned().collect::<Vec<_>>().join(", "),
other => format!("<{}>", type_name(other)),
}
}
fn type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
/// 打印「字段契约」:顶层键 + 各数组字段的首元素键。
fn contract(v: &Value, indent: &str) {
if let Value::Object(map) = v {
for (k, val) in map {
match val {
Value::Array(items) if !items.is_empty() => {
println!("{indent}{k}: array[{}] → {{ {} }}", items.len(), keys_of(&items[0]));
}
other if other.is_object() => {
println!("{indent}{k}: object → {{ {} }}", keys_of(other));
}
other => {
let shown = match other {
Value::String(s) if s.chars().count() > 60 => {
format!("string(\"{}…\")", s.chars().take(60).collect::<String>())
}
Value::Array(a) => format!("array[{}]", a.len()),
_ => format!("{}({other})", type_name(other)),
};
println!("{indent}{k}: {shown}");
}
}
}
} else {
println!("{indent}{}", type_name(v));
}
}
async fn call(client: &Client, cap: &str, args: Value, full: bool) -> bool {
match client.invoke(cap, args).await {
Ok(r) if r.status == "ok" => {
println!(" ✓ {cap}");
if let Some(d) = &r.data {
contract(d, " ");
if full {
let s = serde_json::to_string_pretty(d).unwrap_or_default();
let s: String = s.chars().take(400).collect();
println!(" ── full ── {s}");
}
}
true
}
Ok(r) => {
let e = r.error.unwrap_or_default();
println!(" ✗ {cap}: [{}] {}", e.name, e.message);
false
}
Err(e) => {
println!(" ✗ {cap}: {e}");
false
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().skip(1).collect();
let full = argv.iter().any(|a| a == "--full");
let pos: Vec<String> = argv.into_iter().filter(|a| !a.starts_with("--")).collect();
let url = pos.first().cloned().unwrap_or_else(|| "ws://127.0.0.1:8765".to_string());
let key = pos.get(1).cloned().unwrap_or_default();
let base = pos.get(2).cloned().unwrap_or_else(|| "/tmp/nodeagent-e2e".to_string());
let client = Client::new(ClientOptions {
url,
key,
client_id: "probe".to_string(),
insecure: true,
..Default::default()
});
let caps = client.connect().await?;
println!("连接成功,{} 项能力", caps.len());
// 平台探测——决定 platform-only 能力是否预期可用。
let info = client.invoke("system.info", json!({})).await?;
let os = info
.data
.as_ref()
.and_then(|d| d.get("os"))
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let is_windows = os == "Windows";
println!("被控端平台:{os}(platform-only 能力{}预期可用)\n", if is_windows { "" } else { "不" });
let mut pass = 0usize;
let mut fail = 0usize;
println!("【文件 fs.*】");
for (cap, args) in [
("fs.list", json!({ "path": base })),
("fs.stat", json!({ "path": format!("{base}/agent.json") })),
("fs.read", json!({ "path": format!("{base}/agent.json"), "max_bytes": 2048 })),
(
"fs.write",
json!({ "path": format!("{base}/probe.txt"), "data": "hello from rust probe", "append": false, "create_dirs": true }),
),
] {
if call(&client, cap, args, full).await {
pass += 1
} else {
fail += 1
}
}
println!("\n【审计 system.audit.*】");
for (cap, args) in [
("system.audit.list", json!({ "limit": 5 })),
("system.audit.verify", json!({})),
] {
if call(&client, cap, args, full).await {
pass += 1
} else {
fail += 1
}
}
println!("\n【软件 / 进程 / 服务】");
let platform_only = ["app.list", "app.install", "system.service.list"];
for (cap, args) in [
("app.list", json!({})),
("app.install", json!({ "package": "__nonexistent_probe__", "silent": true })),
("system.process.list", json!({ "limit": 5 })),
("system.service.list", json!({ "limit": 5 })),
] {
let ok = call(&client, cap, args, full).await;
// platform-only 能力在非 Windows 被控端上失败属**预期**,不计入失败。
if ok {
pass += 1;
} else if !is_windows && platform_only.contains(&cap) {
println!(" ↑ 非 Windows 被控端,属预期");
} else {
fail += 1;
}
}
println!("\n【其他页面依赖】");
for (cap, args) in [
("system.info", json!({})),
("system.status", json!({})),
("system.shell.exec", json!({ "command": "echo probe-ok" })),
] {
if call(&client, cap, args, full).await {
pass += 1
} else {
fail += 1
}
}
println!("\n═══ 通过 {pass} · 失败 {fail} ═══");
client.close();
if fail > 0 {
std::process::exit(1);
}
Ok(())
}
+628
View File
@@ -0,0 +1,628 @@
//! 控制端客户端主体(对齐 `packages/client/src/client.ts` 的 `NodeAgentClient`)。
//!
//! 复刻行为:连接 → 握手(hello → challenge → auth → auth_ok)→ 泛型能力调用 →
//! 事件通知(无 id 的 `event` 方法)→ 断线指数退避重连(1s → 30s,±15% 抖动)。
//!
//! 并发结构(监督者模式):
//! - `connect()` 派生一个 `supervise` 任务,串行「建立+握手 → 等读循环断开信号 → 退避重连」。
//! - `establish()` 负责单次连接 + 握手,并派生**读循环**与**写任务**两个子任务。
//! - 读循环在流结束时只 `notify` 监督者、绝不回调 `establish`——从而打破
//! `establish → spawn(read_loop) → establish` 的类型级递归,让每个 future 都可证 `Send`。
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::sync::{mpsc, oneshot, Notify};
use tokio_tungstenite::tungstenite::Message;
use crate::crypto::{compute_hmac, sign_nonce};
use crate::error::{ClientError, ErrorCodes};
use crate::tls;
use crate::types::*;
use crate::ulid::ulid;
/// WebSocket 流类型(TLS 下为 `MaybeTlsStream`)。
type WsStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
type WsSink = SplitSink<WsStream, Message>;
type WsReader = SplitStream<WsStream>;
/// 控制面方法名(对齐 `protocol/src/methods.ts` 的 `Methods`)。
const M_HELLO: &str = "hello";
const M_AUTH: &str = "auth";
const M_INVOKE: &str = "invoke";
const M_EVENT: &str = "event";
/// 连接状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnState {
Connected,
Reconnecting,
Closed,
}
/// 对外事件:连接状态变化 / 被控端主动事件 / 日志。
#[derive(Debug, Clone)]
pub enum ClientEvent {
State(ConnState),
Event(Value),
Log(String),
}
/// 事件回调(`Arc` 便于共享给读循环)。
pub type EventHandler = Arc<dyn Fn(ClientEvent) + Send + Sync>;
/// 认证模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMode {
Psk,
Ed25519,
}
/// 客户端选项。
#[derive(Clone)]
pub struct ClientOptions {
/// `ws://` 或 `wss://` 地址。
pub url: String,
/// psk 预共享密钥。
pub key: String,
/// 控制端身份标识。
pub client_id: String,
/// 跳过自签证书校验(TLS 仍加密)。
pub insecure: bool,
/// 钉住的被控端证书指纹(sha256 hex,64 位)。
pub cert_sha256: Option<String>,
/// 认证模式。
pub auth_mode: AuthMode,
/// ed25519 模式:私钥(Base64 PKCS8 DER)。
pub private_key: Option<String>,
/// 握手超时(默认 10s)。
pub handshake_timeout_ms: u64,
/// 单次调用默认超时(默认 60s)。
pub default_timeout_ms: u64,
/// 断线后自动重连。
pub auto_reconnect: bool,
/// 重连退避上限(默认 30s)。
pub max_reconnect_delay_ms: u64,
}
impl Default for ClientOptions {
fn default() -> Self {
Self {
url: String::new(),
key: String::new(),
client_id: String::new(),
insecure: false,
cert_sha256: None,
auth_mode: AuthMode::Psk,
private_key: None,
handshake_timeout_ms: 10_000,
default_timeout_ms: 60_000,
auto_reconnect: false,
max_reconnect_delay_ms: 30_000,
}
}
}
/// 连接元信息(能力清单 / 授权 / 指纹 / 版本)。
#[derive(Default, Clone)]
struct Meta {
capabilities: Vec<CapabilityDescriptor>,
authorized: Option<Vec<String>>,
peer_cert_fp: Option<String>,
agent_version: Option<String>,
agent_build: Option<BuildInfo>,
}
/// 跨任务共享的可变状态。
///
/// 关键约束:`Client` 必须 `Sync`(多处 `&self` 跨 await)。这里的可变字段一律用
/// `std` 的 `Mutex`/`RwLock`(天然 `Send + Sync`),并保证**不在持锁期间 await**:
/// 发送半部分通过「专用写任务 + mpsc 通道」解耦,`pending` 的增删改全是同步短临界区。
struct Shared {
/// 到「写任务」的通道;写任务独占 sink 并转发消息(发送不跨 await 持锁)。
write: RwLock<Option<mpsc::UnboundedSender<Message>>>,
/// 待响应映射(id → oneshot 发送端);同步锁即可。
pending: Mutex<HashMap<String, oneshot::Sender<Result<Value, ClientError>>>>,
/// 读循环在流结束时通知监督者(「连接已断开」信号)。
disconnected: Notify,
meta: RwLock<Meta>,
state: RwLock<ConnState>,
reconnect_attempts: AtomicU32,
stopped: AtomicBool,
handler: RwLock<Option<EventHandler>>,
}
/// 控制端客户端。
#[derive(Clone)]
pub struct Client {
opts: Arc<ClientOptions>,
shared: Arc<Shared>,
}
impl Client {
pub fn new(opts: ClientOptions) -> Self {
Self {
opts: Arc::new(opts),
shared: Arc::new(Shared {
write: RwLock::new(None),
pending: Mutex::new(HashMap::new()),
disconnected: Notify::new(),
meta: RwLock::new(Meta::default()),
state: RwLock::new(ConnState::Closed),
reconnect_attempts: AtomicU32::new(0),
stopped: AtomicBool::new(false),
handler: RwLock::new(None),
}),
}
}
/// 设置事件回调。
pub fn set_handler(&self, handler: EventHandler) {
*self.shared.handler.write().unwrap() = Some(handler);
}
/// 建立连接并完成握手,返回能力清单。
/// 派生监督任务后,仅等待**首次**握手结果(失败即返回错误,不自动重连)。
pub async fn connect(&self) -> Result<Vec<CapabilityDescriptor>, ClientError> {
self.shared.stopped.store(false, Ordering::SeqCst);
let (tx, rx) = oneshot::channel();
let this = self.clone();
tokio::spawn(async move {
this.supervise(Some(tx)).await;
});
match rx.await {
Ok(result) => result,
Err(_) => Err(ClientError::offline("连接监督任务异常退出")),
}
}
/// 调用一项能力(`args` 为 JSON 对象),用默认超时。
pub async fn invoke(
&self,
capability: &str,
args: Value,
) -> Result<InvokeResult, ClientError> {
self.invoke_with_timeout(capability, args, self.opts.default_timeout_ms)
.await
}
/// 调用一项能力并显式指定 RPC 超时(长任务如 `app.install` / 长命令用)。
pub async fn invoke_with_timeout(
&self,
capability: &str,
args: Value,
timeout_ms: u64,
) -> Result<InvokeResult, ClientError> {
let params = json!({ "capability": capability, "args": args });
let value = self.request(M_INVOKE, params, timeout_ms).await?;
serde_json::from_value::<InvokeResult>(value)
.map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析调用结果失败: {e}")))
}
/// 已获取的能力清单。
pub fn list_capabilities(&self) -> Vec<CapabilityDescriptor> {
self.shared.meta.read().unwrap().capabilities.clone()
}
/// ed25519 模式下本次被授权的能力;psk 模式返回 `None`。
pub fn list_authorized(&self) -> Option<Vec<String>> {
self.shared.meta.read().unwrap().authorized.clone()
}
/// 本次连接观测到的对端证书指纹(sha256 hex)。
pub fn peer_cert_fingerprint(&self) -> Option<String> {
self.shared.meta.read().unwrap().peer_cert_fp.clone()
}
/// 被控端语义化版本。
pub fn agent_version(&self) -> Option<String> {
self.shared.meta.read().unwrap().agent_version.clone()
}
/// 被控端构建信息。
pub fn agent_build(&self) -> Option<BuildInfo> {
self.shared.meta.read().unwrap().agent_build.clone()
}
/// 当前连接状态。
pub fn state(&self) -> ConnState {
*self.shared.state.read().unwrap()
}
/// 主动关闭连接(停止自动重连)。
pub fn close(&self) {
self.shared.stopped.store(true, Ordering::SeqCst);
// 丢弃发送通道 → 写任务退出 → sink 释放 → 连接关闭;并唤醒监督者及时退出。
*self.shared.write.write().unwrap() = None;
self.shared.disconnected.notify_one();
self.set_state(ConnState::Closed);
self.emit_log("主动关闭");
}
// ---------- 内部 ----------
/// 监督循环:反复「建立+握手 → 等断开 → 退避重连」。
/// 首次握手结果经 `first_tx` 回传给 `connect()`;首次失败直接返回,不重连。
async fn supervise(
&self,
first_tx: Option<oneshot::Sender<Result<Vec<CapabilityDescriptor>, ClientError>>>,
) {
let mut first_tx = first_tx;
loop {
let established = match self.establish().await {
Ok(caps) => {
self.shared.reconnect_attempts.store(0, Ordering::SeqCst);
self.set_state(ConnState::Connected);
// 首次成功:把能力清单回传给 `connect()` 调用方。
if let Some(tx) = first_tx.take() {
let _ = tx.send(Ok(caps));
}
true
}
Err(e) => {
// 首次失败:直接报错返回(不做自动重连,对齐 Node 行为)。
if let Some(tx) = first_tx.take() {
let _ = tx.send(Err(e));
return;
}
self.emit_log(format!("重连失败: {e}"));
false
}
};
// 本次建立成功才有读循环会发「断开」信号;失败则直接进入退避。
if established {
self.shared.disconnected.notified().await;
}
if self.shared.stopped.load(Ordering::SeqCst) {
self.set_state(ConnState::Closed);
return;
}
if !self.opts.auto_reconnect {
self.set_state(ConnState::Closed);
return;
}
let delay = self.next_backoff_delay();
self.set_state(ConnState::Reconnecting);
self.emit_log(format!("将在 {}ms 后重连", delay.as_millis()));
tokio::time::sleep(delay).await;
if self.shared.stopped.load(Ordering::SeqCst) {
self.set_state(ConnState::Closed);
return;
}
}
}
/// 建立一次连接:TCP/TLS/WS → split → 读循环 + 写任务 → 协议握手。
async fn establish(&self) -> Result<Vec<CapabilityDescriptor>, ClientError> {
// TLS 下抓取对端证书指纹(自签 + insecure 时留下的中间人空档,靠钉住兜底)。
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let (ws, _resp) = if self.opts.url.starts_with("wss://") {
let config = tls::build_client_config(
self.opts.insecure,
self.opts.cert_sha256.clone(),
captured.clone(),
)?;
let connector = tokio_tungstenite::Connector::Rustls(Arc::new(config));
tokio_tungstenite::connect_async_tls_with_config(
self.opts.url.as_str(),
None,
false,
Some(connector),
)
.await
.map_err(|e| self.map_connect_error(e))?
} else {
tokio_tungstenite::connect_async(self.opts.url.as_str())
.await
.map_err(|e| self.map_connect_error(e))?
};
let peer_fp = captured.lock().unwrap().clone();
let (sink, stream) = ws.split();
let (tx, rx) = mpsc::unbounded_channel();
*self.shared.write.write().unwrap() = Some(tx);
// 写任务独占 sink;读循环独立跑;二者都只持有局部 Send 类型。
tokio::spawn(async move { writer_task(sink, rx).await });
let this = self.clone();
tokio::spawn(async move {
this.read_loop(stream).await;
});
// 握手;失败则关闭连接(丢弃发送通道 → 写任务退出 → sink 释放 → 读循环结束)。
let result = self.handshake(peer_fp).await;
if result.is_err() {
*self.shared.write.write().unwrap() = None;
}
result
}
/// 握手:hello → challenge → auth → auth_ok;成功后写回元信息。
async fn handshake(&self, peer_fp: Option<String>) -> Result<Vec<CapabilityDescriptor>, ClientError> {
let challenge: ChallengeParams = {
let value = self
.request(
M_HELLO,
json!({ "protocol": PROTOCOL_VERSION, "client_id": self.opts.client_id }),
self.opts.handshake_timeout_ms,
)
.await?;
serde_json::from_value(value).map_err(|e| {
ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 challenge 失败: {e}"))
})?
};
let auth_params = match self.opts.auth_mode {
AuthMode::Ed25519 => {
let private_key = self.opts.private_key.as_deref().ok_or_else(|| {
ClientError::new(ErrorCodes::AUTH_FAILED, "ed25519 模式需要私钥,请先 keygen")
})?;
let signature = sign_nonce(private_key, &challenge.nonce).map_err(|e| {
ClientError::new(ErrorCodes::AUTH_FAILED, format!("签名失败: {e}"))
})?;
json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "signature": signature })
}
AuthMode::Psk => {
let hmac = compute_hmac(&self.opts.key, &challenge.nonce);
json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "hmac": hmac })
}
};
let auth_ok: AuthOkParams = {
let value = self
.request(M_AUTH, auth_params, self.opts.handshake_timeout_ms)
.await?;
serde_json::from_value(value).map_err(|e| {
ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 auth_ok 失败: {e}"))
})?
};
// v21 交叉确认:握手抓到的实际证书 vs 服务端自报 —— 不一致说明自报被篡改/冒充。
if let (Some(fp), Some(self_reported)) = (
peer_fp.as_ref(),
auth_ok
.build
.as_ref()
.and_then(|b| b.cert_sha256.as_ref()),
) {
if fp != self_reported {
return Err(ClientError::cert_mismatch(
"被控端自报的证书指纹与实际握手证书不一致,拒绝使用(可能被冒充)",
));
}
}
let capabilities = auth_ok.capabilities.clone();
let authorized_len = auth_ok.authorized.as_ref().map(|a| a.len());
{
let mut meta = self.shared.meta.write().unwrap();
meta.capabilities = auth_ok.capabilities.clone();
meta.authorized = auth_ok.authorized.clone();
meta.peer_cert_fp = peer_fp;
meta.agent_version = auth_ok.agent_version.clone();
meta.agent_build = auth_ok.build.clone();
}
let capabilities_len = capabilities.len();
let label = if auth_ok.auth_mode.as_deref() == Some("ed25519") {
format!("ed25519,授权 {}/{capabilities_len} 项能力", authorized_len.unwrap_or(0))
} else {
format!("psk,被控端声明 {capabilities_len} 项能力")
};
self.emit_log(format!("握手成功({label})"));
Ok(capabilities)
}
/// 底层请求(带超时与 id 匹配)。
async fn request(
&self,
method: &str,
params: Value,
timeout_ms: u64,
) -> Result<Value, ClientError> {
let id = ulid();
let (tx, rx) = oneshot::channel();
self.shared.pending.lock().unwrap().insert(id.clone(), tx);
let req = RpcRequest {
jsonrpc: "2.0".to_string(),
id: id.clone(),
method: method.to_string(),
params: Some(params),
};
let payload = serde_json::to_string(&req)
.map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, e.to_string()))?;
{
let sender = self
.shared
.write
.read()
.unwrap()
.as_ref()
.cloned()
.ok_or_else(|| ClientError::offline("连接未就绪"))?;
// 同步发送(不持锁 await):消息经通道转交写任务。
sender
.send(Message::Text(payload.into()))
.map_err(|e| ClientError::offline(format!("发送失败: {e}")))?;
}
match tokio::time::timeout(Duration::from_millis(timeout_ms), rx).await {
Err(_) => {
self.shared.pending.lock().unwrap().remove(&id);
Err(ClientError::timeout(format!(
"调用超时 ({timeout_ms}ms): {method}"
)))
}
Ok(Err(_)) => Err(ClientError::offline("连接已断开")),
Ok(Ok(result)) => result,
}
}
/// 读循环:分发响应与事件;流结束后清空 pending 并通知监督者。
async fn read_loop(&self, mut stream: WsReader) {
while let Some(Ok(msg)) = stream.next().await {
if let Err(e) = self.handle_message(msg).await {
self.emit_log(format!("消息处理失败: {e}"));
}
}
// 流结束(对端关闭 / 网络断开)→ 清空 pending,由监督者决定是否重连。
self.reject_all_pending();
self.shared.disconnected.notify_one();
}
/// 处理单条消息。
async fn handle_message(&self, msg: Message) -> Result<(), ClientError> {
// WebSocket ping 帧:回 pong(保持连接)。
if msg.is_ping() {
let pong = msg.into_data();
if let Some(sender) = self.shared.write.read().unwrap().as_ref() {
let _ = sender.send(Message::Pong(pong));
}
return Ok(());
}
if !msg.is_text() && !msg.is_binary() {
return Ok(());
}
let text = match msg {
Message::Text(t) => t.to_string(),
Message::Binary(b) => match String::from_utf8(b.to_vec()) {
Ok(s) => s,
Err(_) => return Ok(()),
},
_ => return Ok(()),
};
let value: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => {
self.emit_log(format!("收到无法解析的消息: {}", &text[..text.len().min(200)]));
return Ok(());
}
};
// 有 id → 响应;无 id → 通知(事件)。
if let Some(id) = value.get("id").and_then(|v| v.as_str()) {
if let Some(tx) = self.shared.pending.lock().unwrap().remove(id) {
let result = self.parse_response(&value);
let _ = tx.send(result);
}
return Ok(());
}
let method = value.get("method").and_then(|m| m.as_str()).unwrap_or("");
if method == M_EVENT {
if let Some(params) = value.get("params").cloned() {
self.emit_event(params);
}
}
Ok(())
}
/// 把响应消息解析为 `Result<Value, ClientError>`。
fn parse_response(&self, value: &Value) -> Result<Value, ClientError> {
if is_error_response(value) {
let err = value.get("error").cloned().unwrap_or(Value::Null);
let code = err
.get("code")
.and_then(|c| c.as_i64())
.unwrap_or(ErrorCodes::PARSE_ERROR);
let name = err
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("E_UNKNOWN")
.to_string();
let message = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
Err(ClientError {
code,
name,
message,
data: err.get("data").cloned(),
})
} else {
value
.get("result")
.cloned()
.ok_or_else(|| ClientError::new(ErrorCodes::PARSE_ERROR, "响应缺少 result 字段"))
}
}
/// 指数退避 + ±15% 抖动(1s → 30s)。
fn next_backoff_delay(&self) -> Duration {
let attempts = self.shared.reconnect_attempts.fetch_add(1, Ordering::SeqCst);
let base = (1000u64 << attempts).min(self.opts.max_reconnect_delay_ms);
let jitter = 0.85 + rand::random::<f64>() * 0.3; // 0.85..=1.15
Duration::from_millis((base as f64 * jitter) as u64)
}
fn reject_all_pending(&self) {
let drained: Vec<oneshot::Sender<Result<Value, ClientError>>> = {
let mut map = self.shared.pending.lock().unwrap();
map.drain().map(|(_, tx)| tx).collect()
};
for tx in drained {
let _ = tx.send(Err(ClientError::offline("连接已断开")));
}
}
fn map_connect_error(&self, e: tokio_tungstenite::tungstenite::Error) -> ClientError {
let msg = e.to_string();
if msg.contains("证书指纹不匹配") {
ClientError::cert_mismatch(msg)
} else {
ClientError::offline(format!("无法连接被控端: {msg}"))
}
}
fn set_state(&self, state: ConnState) {
*self.shared.state.write().unwrap() = state;
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
h(ClientEvent::State(state));
}
}
fn emit_event(&self, params: Value) {
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
h(ClientEvent::Event(params));
}
}
fn emit_log(&self, msg: impl Into<String>) {
if let Some(h) = self.shared.handler.read().unwrap().as_ref() {
h(ClientEvent::Log(msg.into()));
}
}
}
/// 写任务:独占 sink,把 mpsc 通道里的消息逐条发出去。
/// 通道关闭(`close()` 丢弃 sender)或发送失败时退出,sink 随之释放。
async fn writer_task(mut sink: WsSink, mut rx: mpsc::UnboundedReceiver<Message>) {
while let Some(msg) = rx.recv().await {
if sink.send(msg).await.is_err() {
break;
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! 密码学工具:HMAC / ed25519 签名 / 证书指纹 / nonce。
//! 逐一对齐 `packages/protocol/src/{hmac,ed25519}.ts` 与 `net.ts`。
use base64::engine::general_purpose::STANDARD;
use base64::Engine as _;
use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
/// 计算 HMAC-SHA256,返回 Base64(对齐 hmac.ts 的 `computeHmac`)。
/// 密钥为预共享密钥字符串,nonce 以 UTF-8 编码。
pub fn compute_hmac(key: &str, nonce: &str) -> String {
// HMAC 接受任意长度 key,此处不可能失败,unwrap 仅为类型收敛。
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC 接受任意长度 key");
mac.update(nonce.as_bytes());
STANDARD.encode(mac.finalize().into_bytes())
}
/// 生成随机 nonce(32 字节,Base64),对齐 `generateNonce`。
pub fn generate_nonce() -> String {
let mut bytes = [0u8; 32];
for b in bytes.iter_mut() {
*b = rand::random::<u8>();
}
STANDARD.encode(bytes)
}
/// 证书指纹:sha256(DER) 的十六进制小写 64 位(对齐 net.ts 的 `certFingerprint`)。
pub fn cert_fingerprint(der: &[u8]) -> String {
let digest = Sha256::digest(der);
digest.iter().map(|b| format!("{b:02x}")).collect()
}
/// 对 nonce 原文做 ed25519 签名,返回 Base64(对齐 ed25519.ts 的 `signNonce`)。
/// 私钥为 Base64(PKCS8 DER)。
pub fn sign_nonce(private_key_b64: &str, nonce: &str) -> Result<String, String> {
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::Signer;
let der = STANDARD
.decode(private_key_b64)
.map_err(|e| format!("私钥 Base64 解码失败: {e}"))?;
let signing_key = ed25519_dalek::SigningKey::from_pkcs8_der(&der)
.map_err(|e| format!("PKCS8 私钥解析失败: {e}"))?;
let sig = signing_key.sign(nonce.as_bytes());
Ok(STANDARD.encode(sig.to_bytes()))
}
/// 验签(对齐 `verifyNonce`);任何异常均视为失败(不抛出)。
pub fn verify_nonce(public_key_b64: &str, nonce: &str, signature_b64: &str) -> bool {
use ed25519_dalek::pkcs8::DecodePublicKey;
use ed25519_dalek::Verifier;
let Ok(pk_der) = STANDARD.decode(public_key_b64) else {
return false;
};
let Ok(vk) = ed25519_dalek::VerifyingKey::from_public_key_der(&pk_der) else {
return false;
};
let Ok(sig_bytes) = STANDARD.decode(signature_b64) else {
return false;
};
let Ok(sig) = ed25519_dalek::Signature::from_slice(&sig_bytes) else {
return false;
};
vk.verify(nonce.as_bytes(), &sig).is_ok()
}
/// 公钥指纹(SHA-256 前 16 位 hex),对齐 `keyId`。
pub fn key_id(public_key_b64: &str) -> String {
let der = STANDARD.decode(public_key_b64).unwrap_or_default();
let digest = Sha256::digest(&der);
// 前 16 个 hex 字符 = 前 8 字节。
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
fn gen_keypair() -> (String, String) {
let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
let pk = sk.verifying_key();
(
STANDARD.encode(pk.to_public_key_der().expect("编码公钥").as_bytes()),
STANDARD.encode(sk.to_pkcs8_der().expect("编码私钥").as_bytes()),
)
}
#[test]
fn hmac_matches_known_vector() {
// 与 Node 版对拍的固定向量:HMAC-SHA256("key", "nonce")。
let out = compute_hmac("secret", "hello");
// 用独立的 hmac 重算并比对,避免自证。
let mut mac = HmacSha256::new_from_slice(b"secret").unwrap();
mac.update(b"hello");
let expect = STANDARD.encode(mac.finalize().into_bytes());
assert_eq!(out, expect);
// Base64(HMAC-SHA256("secret","hello")) 的已知值(由 RFC 测试向量推得,供跨端核对)。
assert_eq!(out, "iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs=");
}
#[test]
fn sign_and_verify_roundtrip() {
let (pk, sk) = gen_keypair();
let nonce = generate_nonce();
let sig = sign_nonce(&sk, &nonce).unwrap();
assert!(verify_nonce(&pk, &nonce, &sig));
assert!(!verify_nonce(&pk, "tampered", &sig));
}
#[test]
fn key_id_is_16_hex() {
let (pk, _sk) = gen_keypair();
let id = key_id(&pk);
assert_eq!(id.len(), 16);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn cert_fingerprint_sha256_der() {
// 空 DER 的 sha256 是已知常量。
assert_eq!(
cert_fingerprint(b""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
}
+145
View File
@@ -0,0 +1,145 @@
//! 局域网发现(对齐 `packages/client/src/discovery.ts`)。
//!
//! 只监听被控端 UDP 广播、不发送任何数据,因此不引入额外暴露面。
//! M1 实现基础解析;v22 最小化广播的 HMAC 验签留待后续(需对齐 JSON 序列化顺序)。
use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use tokio::net::UdpSocket;
/// 发现到的被控端。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredNode {
pub node_id: String,
/// 可达地址:取报文来源 IP(一定可达)。
pub host: String,
/// 被控端自报地址(多网卡时可能与来源不同,仅供参考)。
pub advertised_host: String,
pub port: u16,
pub tls: bool,
pub auth_mode: String,
pub input_enabled: bool,
pub platform: String,
/// v22:详情是否通过 HMAC 验签。false = 明文广播,仅作展示、不可当凭据。
pub authenticated: bool,
/// 最近一次收到广播的时间(Unix 毫秒)。
pub last_seen: u64,
}
#[derive(Debug, Deserialize)]
struct RawBeacon {
service: Option<String>,
node_id: Option<String>,
host: Option<String>,
port: Option<u16>,
tls: Option<bool>,
auth_mode: Option<String>,
input_enabled: Option<bool>,
platform: Option<String>,
minimal: Option<bool>,
#[serde(default)]
detail: Option<BeaconDetail>,
}
#[derive(Debug, Deserialize)]
struct BeaconDetail {
host: Option<String>,
port: Option<u16>,
tls: Option<bool>,
auth_mode: Option<String>,
input_enabled: Option<bool>,
platform: Option<String>,
}
/// 默认发现端口(对齐 methods.ts 的 `DEFAULT_DISCOVERY_PORT`)。
pub const DEFAULT_DISCOVERY_PORT: u16 = 8766;
/// 默认被控端端口。
const DEFAULT_AGENT_PORT: u16 = 8765;
/// 监听一段时间后返回发现的设备(对齐 `discoverOnce`)。
pub async fn discover_once(wait_ms: u64, port: u16) -> Result<Vec<DiscoveredNode>, String> {
let socket = UdpSocket::bind(("0.0.0.0", port))
.await
.map_err(|e| format!("发现监听失败(UDP {port}): {e}"))?;
let mut nodes: HashMap<String, DiscoveredNode> = HashMap::new();
let deadline = tokio::time::Instant::now() + Duration::from_millis(wait_ms);
let mut buf = [0u8; 2048];
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await {
Ok(Ok((len, src))) => {
if let Some(node) = parse_beacon(&buf[..len], src) {
nodes.insert(node.node_id.clone(), node);
}
}
_ => break,
}
}
let now = now_ms();
let mut list: Vec<DiscoveredNode> = nodes.into_values().collect();
// 过滤 TTL 过期项(30s),按最近出现排序。
list.retain(|n| now - n.last_seen <= 30_000);
list.sort_by_key(|n| std::cmp::Reverse(n.last_seen));
Ok(list)
}
fn parse_beacon(data: &[u8], src: std::net::SocketAddr) -> Option<DiscoveredNode> {
let raw: RawBeacon = serde_json::from_slice(data).ok()?;
if raw.service.as_deref() != Some("nodeagent") {
return None;
}
let node_id = raw.node_id?;
// v22 最小化广播:顶层只剩存在性,真实信息在 detail 里。
let detail = raw.minimal.unwrap_or(false).then_some(raw.detail).flatten();
Some(DiscoveredNode {
node_id,
// 地址永远取「报文来源 IP」—— 一定可达。
host: src.ip().to_string(),
advertised_host: detail
.as_ref()
.and_then(|d| d.host.clone())
.or(raw.host)
.unwrap_or_else(|| src.ip().to_string()),
port: detail
.as_ref()
.and_then(|d| d.port)
.or(raw.port)
.unwrap_or(DEFAULT_AGENT_PORT),
tls: detail.as_ref().and_then(|d| d.tls).or(raw.tls).unwrap_or(true),
auth_mode: detail
.as_ref()
.and_then(|d| d.auth_mode.clone())
.or(raw.auth_mode)
.unwrap_or_else(|| "psk".to_string()),
input_enabled: detail
.as_ref()
.and_then(|d| d.input_enabled)
.or(raw.input_enabled)
.unwrap_or(false),
platform: detail
.as_ref()
.and_then(|d| d.platform.clone())
.or(raw.platform)
.unwrap_or_else(|| "unknown".to_string()),
authenticated: false,
last_seen: now_ms(),
})
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
+96
View File
@@ -0,0 +1,96 @@
//! 协议错误码与客户端错误类型(对齐 `packages/protocol/src/errors.ts`)。
use std::fmt;
/// 协议错误码常量(对齐 errors.ts 的 `ErrorCodes`)。
#[allow(non_snake_case)]
pub mod ErrorCodes {
pub const PARSE_ERROR: i64 = -32700;
pub const INVALID_REQUEST: i64 = -32600;
pub const METHOD_NOT_FOUND: i64 = -32601;
pub const PARAM_INVALID: i64 = -32602;
pub const AUTH_FAILED: i64 = -32401;
pub const AUTH_REQUIRED: i64 = -32402;
pub const CAPABILITY_NOT_FOUND: i64 = -32403;
pub const CAPABILITY_DISABLED: i64 = -32404;
pub const NODE_OFFLINE: i64 = -32405;
pub const ACL_DENIED: i64 = -32406;
pub const RATE_LIMITED: i64 = -32407;
pub const TIMEOUT: i64 = -32412;
pub const EXECUTION_FAILED: i64 = -32419;
pub const PROTOCOL_MISMATCH: i64 = -32420;
pub const UNSUPPORTED_PLATFORM: i64 = -32421;
/// v21:被控端证书指纹与控制端钉住的不一致(可能被冒充)。
pub const CERT_MISMATCH: i64 = -32422;
}
/// 错误码 → 名称。
pub fn error_name(code: i64) -> &'static str {
match code {
ErrorCodes::PARSE_ERROR => "E_PARSE_ERROR",
ErrorCodes::INVALID_REQUEST => "E_INVALID_REQUEST",
ErrorCodes::METHOD_NOT_FOUND => "E_METHOD_NOT_FOUND",
ErrorCodes::PARAM_INVALID => "E_PARAM_INVALID",
ErrorCodes::AUTH_FAILED => "E_AUTH_FAILED",
ErrorCodes::AUTH_REQUIRED => "E_AUTH_REQUIRED",
ErrorCodes::CAPABILITY_NOT_FOUND => "E_CAPABILITY_NOT_FOUND",
ErrorCodes::CAPABILITY_DISABLED => "E_CAPABILITY_DISABLED",
ErrorCodes::NODE_OFFLINE => "E_NODE_OFFLINE",
ErrorCodes::ACL_DENIED => "E_ACL_DENIED",
ErrorCodes::RATE_LIMITED => "E_RATE_LIMITED",
ErrorCodes::TIMEOUT => "E_TIMEOUT",
ErrorCodes::EXECUTION_FAILED => "E_EXECUTION_FAILED",
ErrorCodes::PROTOCOL_MISMATCH => "E_PROTOCOL_MISMATCH",
ErrorCodes::UNSUPPORTED_PLATFORM => "E_UNSUPPORTED_PLATFORM",
ErrorCodes::CERT_MISMATCH => "E_CERT_MISMATCH",
_ => "E_UNKNOWN",
}
}
/// 客户端错误(携带协议错误码,对齐 client.ts 的 `ClientError`)。
#[derive(Debug, Clone)]
pub struct ClientError {
pub code: i64,
pub name: String,
pub message: String,
pub data: Option<serde_json::Value>,
}
impl ClientError {
pub fn new(code: i64, message: impl Into<String>) -> Self {
Self {
code,
name: error_name(code).to_string(),
message: message.into(),
data: None,
}
}
pub fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
/// 被控端离线/不可达。
pub fn offline(message: impl Into<String>) -> Self {
Self::new(ErrorCodes::NODE_OFFLINE, message)
}
/// 调用超时。
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(ErrorCodes::TIMEOUT, message)
}
/// 证书指纹不一致。
pub fn cert_mismatch(message: impl Into<String>) -> Self {
Self::new(ErrorCodes::CERT_MISMATCH, message)
}
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.name, self.message)
}
}
impl std::error::Error for ClientError {}
+34
View File
@@ -0,0 +1,34 @@
//! nodeagent 控制端 Rust 客户端。
//!
//! 行为对齐 `packages/client`(Node 版),复刻:
//! - 握手 hello → challenge → auth → auth_ok(psk HMAC / ed25519 签名)
//! - 泛型能力调用 invoke(带超时与 id 匹配)
//! - 事件通知(无 id 的 JSON-RPC notification,`event` 方法)
//! - 断线指数退避重连(1s → 30s,±15% 抖动)
//! - TLS 自签证书 + 指纹钉住(v21,E_CERT_MISMATCH)
pub mod client;
pub mod crypto;
pub mod discovery;
pub mod error;
pub mod tls;
pub mod types;
pub mod ulid;
pub use client::{Client, ClientEvent, ClientOptions, ConnState};
pub use error::{ClientError, ErrorCodes};
pub use types::{CapabilityDescriptor, InvokeResult};
#[cfg(test)]
mod send_sync_checks {
use super::*;
fn assert_send_sync<T: Send + Sync>() {}
#[test]
fn client_is_send_sync() {
assert_send_sync::<Client>();
assert_send_sync::<ClientOptions>();
assert_send_sync::<ClientError>();
}
}
+119
View File
@@ -0,0 +1,119 @@
//! TLS 配置:自签证书 + 证书指纹钉住(对齐 client.ts 的 TLS 处理,v21)。
//!
//! nodeagent 的安全模型:TLS 只负责**传输加密**,身份认证由应用层
//! HMAC / ed25519 握手兜底。因此 `insecure` 跳过的是「证书链是否由可信 CA
//! 签发」,TLS 层的签名仍真正验签(委托 rustls)。
use std::sync::{Arc, Mutex};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::crypto::{
verify_tls12_signature, verify_tls13_signature, CryptoProvider, WebPkiSupportedAlgorithms,
};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, Error, SignatureScheme};
use crate::crypto::cert_fingerprint;
use crate::error::{ClientError, ErrorCodes};
/// 构建 rustls `ClientConfig`。
///
/// - `insecure = false`:webpki 严格链验证(自签证书会被拒,与 Node `rejectUnauthorized=true` 一致)。
/// - `insecure = true`:接受任意证书,但抓取对端证书 DER 的 sha256 指纹。
/// - `expect_fp`:钉住的指纹(64 位小写 hex);给了就严格比对,不一致拒绝(`E_CERT_MISMATCH`)。
/// - `captured`:回填本次握手实际抓到的指纹(对齐 `getPeerCertFingerprint`)。
pub fn build_client_config(
insecure: bool,
expect_fp: Option<String>,
captured: Arc<Mutex<Option<String>>>,
) -> Result<ClientConfig, ClientError> {
let expect_fp = expect_fp.map(|s| s.trim().to_ascii_lowercase());
if !insecure {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
return Ok(ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth());
}
let supported = default_supported_algorithms()?;
let verifier = Arc::new(InsecureVerifier {
expect_fp,
captured,
supported,
});
Ok(ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth())
}
fn default_supported_algorithms() -> Result<WebPkiSupportedAlgorithms, ClientError> {
let provider = CryptoProvider::get_default()
.ok_or_else(|| ClientError::new(ErrorCodes::CERT_MISMATCH, "无默认 CryptoProvider"))?;
Ok(provider.signature_verification_algorithms)
}
/// 接受任意证书、但抓指纹的验证器。
/// `verify_server_cert` 跳过链校验(只做指纹钉住),签名仍由 rustls 真正验签。
#[derive(Debug)]
struct InsecureVerifier {
expect_fp: Option<String>,
captured: Arc<Mutex<Option<String>>>,
supported: WebPkiSupportedAlgorithms,
}
impl ServerCertVerifier for InsecureVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, Error> {
let fp = cert_fingerprint(end_entity.as_ref());
// 回填实际指纹(TOFU 钉住)。
if let Ok(mut guard) = self.captured.lock() {
*guard = Some(fp.clone());
}
// 钉住比对:不一致 → 拒绝(对齐 client.ts 的 E_CERT_MISMATCH)。
if let Some(expected) = &self.expect_fp {
if expected != &fp {
let expect_head = &expected[..expected.len().min(16)];
let actual_head = &fp[..fp.len().min(16)];
return Err(Error::General(format!(
"证书指纹不匹配,拒绝连接:期望 {expect_head}… 实际 {actual_head}…(被控端可能重装、或中间人冒充)"
)));
}
}
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls12_signature(message, cert, dss, &self.supported)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
verify_tls13_signature(message, cert, dss, &self.supported)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.supported.supported_schemes()
}
}
+212
View File
@@ -0,0 +1,212 @@
//! 协议消息类型(对齐 `packages/protocol/src/messages.ts`)。
//!
//! 所有对外字段名保持 snake_case 与 TS 侧一致,经 serde 序列化/反序列化。
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// 协议版本(对齐 methods.ts 的 `PROTOCOL_VERSION`)。
pub const PROTOCOL_VERSION: &str = "1.0";
/// JSON-RPC 2.0 请求信封(nodeagent 简化版)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcRequest {
pub jsonrpc: String,
/// ULID,响应回填相同 id。
pub id: String,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<Value>,
}
/// JSON-RPC 2.0 成功响应。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcResponse {
pub jsonrpc: String,
pub id: String,
pub result: Value,
}
/// 协议错误对象。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
pub code: i64,
pub name: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
/// JSON-RPC 2.0 错误响应。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcErrorResponse {
pub jsonrpc: String,
#[serde(default)]
pub id: Option<String>,
pub error: RpcError,
}
/// 任意入站消息:成功响应或错误响应(无 id 的通知由上层单独处理)。
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum InboundMessage {
Response(RpcResponse),
Error(RpcErrorResponse),
Notification {
#[serde(default)]
id: Option<String>,
method: String,
#[serde(default)]
params: Option<Value>,
},
}
/// 判断消息是否为错误响应。
pub fn is_error_response(msg: &Value) -> bool {
msg.get("error").is_some()
}
// ---------- 握手 ----------
/// hello 入参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloParams {
pub protocol: String,
pub client_id: String,
}
/// challenge 出参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChallengeParams {
pub nonce: String,
pub expires_at: i64,
}
/// auth 入参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthParams {
pub client_id: String,
pub nonce: String,
/// psk 模式:Base64(HMAC-SHA256(pre_shared_key, nonce))。
#[serde(skip_serializing_if = "Option::is_none")]
pub hmac: Option<String>,
/// ed25519 模式:Base64(Ed25519_Sign(privkey, nonce))。
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
}
/// auth_ok 出参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthOkParams {
pub capabilities: Vec<CapabilityDescriptor>,
/// ed25519 模式:本次实际被授权的能力(全量子集)。
#[serde(skip_serializing_if = "Option::is_none")]
pub authorized: Option<Vec<String>>,
/// 本次连接采用的认证模式。
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_mode: Option<String>,
/// v20:被控端语义化版本。
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_version: Option<String>,
/// v20:被控端构建信息。
#[serde(skip_serializing_if = "Option::is_none")]
pub build: Option<BuildInfo>,
/// v20:被控端支持的协议版本。
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
}
/// 被控端构建信息(v20)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub built_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cert_sha256: Option<String>,
}
// ---------- 调用 ----------
/// invoke 入参。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvokeParams {
pub capability: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
}
/// 能力执行结果(业务层)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvokeResult {
pub status: String, // "ok" | "failed"
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<InvokeError>,
}
impl InvokeResult {
pub fn is_ok(&self) -> bool {
self.status == "ok"
}
}
/// 能力执行失败的错误信息。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct InvokeError {
pub name: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
/// 能力描述(能力清单条目)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityDescriptor {
pub name: String,
pub version: String,
pub description: String,
pub risk: String, // "low" | "medium" | "high"
pub params_schema: JsonSchema,
#[serde(skip_serializing_if = "Option::is_none")]
pub returns_schema: Option<JsonSchema>,
}
/// JSON Schema 子集(见 DEVELOPMENT.md 4.3)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonSchema {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub schema_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<std::collections::BTreeMap<String, JsonSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")]
pub additional_properties: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#enum: Option<Vec<Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum: Option<f64>,
#[serde(rename = "minLength", skip_serializing_if = "Option::is_none")]
pub min_length: Option<u64>,
#[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")]
pub max_length: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<JsonSchema>>,
#[serde(rename = "minItems", skip_serializing_if = "Option::is_none")]
pub min_items: Option<u64>,
#[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")]
pub max_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
+72
View File
@@ -0,0 +1,72 @@
//! ULID 生成器(Crockford Base32,26 字符:10 位时间戳 + 16 位随机)。
//! 对齐 `packages/protocol/src/ulid.ts`(自实现,避免额外依赖)。
use std::time::{SystemTime, UNIX_EPOCH};
/// Crockford Base32 字母表(与 ulid.ts 完全一致)。
const ENCODING: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// 生成一个 ULID(单调递增:同一毫秒内保证字典序递增)。
pub fn ulid() -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
ulid_at(now)
}
/// 按指定时间戳生成 ULID(供测试注入确定性时间)。
pub fn ulid_at(now: u64) -> String {
// 时间戳部分:10 字符,低位在前地填充(不足 10 位高位补 '0')。
let mut time_part = [b'0'; 10];
let mut t = now;
for i in (0..10).rev() {
time_part[i] = ENCODING[(t % 32) as usize];
t /= 32;
}
// 随机部分:16 字符。256 % 32 == 0,取模无偏差。
let mut rand_part = [b'0'; 16];
for slot in rand_part.iter_mut() {
*slot = ENCODING[(rand::random::<u8>() % 32) as usize];
}
let mut out = String::with_capacity(26);
out.push_str(std::str::from_utf8(&time_part).unwrap());
out.push_str(std::str::from_utf8(&rand_part).unwrap());
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn length_is_26() {
assert_eq!(ulid().len(), 26);
}
#[test]
fn only_valid_charset() {
let s = ulid();
let set: Vec<char> = ENCODING.iter().map(|&b| b as char).collect();
assert!(s.chars().all(|c| set.contains(&c)));
}
#[test]
fn monotonic_same_millis_lexicographic() {
// 同一毫秒内,两个 ULID 的时间前缀必须相同(保证字典序可比较)。
let a = ulid_at(1_700_000_000_000);
let b = ulid_at(1_700_000_000_000);
assert_eq!(&a[..10], &b[..10]);
}
#[test]
fn distinct() {
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
seen.insert(ulid());
}
assert_eq!(seen.len(), 1000);
}
}
+136
View File
@@ -0,0 +1,136 @@
//! 端到端:起一个 mock 被控端(WebSocket),验证 Rust client 的
//! psk 握手(hello → challenge → auth → auth_ok)+ invoke 全链路。
use nodeagent_client::{Client, ClientOptions};
use serde_json::{json, Value};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message;
/// 从 mock 被控端读取下一条文本消息并解析为 JSON。
async fn read_json(
ws: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
) -> Value {
use futures_util::StreamExt;
while let Some(msg) = ws.next().await {
if let Ok(Message::Text(t)) = msg {
return serde_json::from_str(&t).expect("消息应为合法 JSON");
}
}
panic!("mock 被控端连接提前关闭");
}
#[tokio::test]
async fn psk_handshake_and_invoke_roundtrip() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// mock 被控端:应答 hello / auth / invoke。
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
use futures_util::SinkExt;
// hello → challenge
let hello = read_json(&mut ws).await;
assert_eq!(hello["method"], "hello");
let hello_id = hello["id"].as_str().unwrap();
let nonce = "nonce-abc123";
ws.send(Message::Text(
json!({
"jsonrpc": "2.0",
"id": hello_id,
"result": { "nonce": nonce, "expires_at": 4_102_444_800_000_i64 }
})
.to_string()
.into(),
))
.await
.unwrap();
// auth → auth_ok
let auth = read_json(&mut ws).await;
assert_eq!(auth["method"], "auth");
assert_eq!(auth["params"]["nonce"], nonce);
assert!(auth["params"]["hmac"].is_string(), "psk 模式应携带 hmac");
let auth_id = auth["id"].as_str().unwrap();
ws.send(Message::Text(
json!({
"jsonrpc": "2.0",
"id": auth_id,
"result": {
"capabilities": [{
"name": "system.ping",
"version": "1.0",
"description": "探活",
"risk": "low",
"params_schema": { "type": "object" }
}],
"authorized": null,
"auth_mode": "psk",
"agent_version": "1.0.0"
}
})
.to_string()
.into(),
))
.await
.unwrap();
// invoke → result
let invoke = read_json(&mut ws).await;
assert_eq!(invoke["method"], "invoke");
assert_eq!(invoke["params"]["capability"], "system.ping");
let invoke_id = invoke["id"].as_str().unwrap();
ws.send(Message::Text(
json!({
"jsonrpc": "2.0",
"id": invoke_id,
"result": { "status": "ok", "data": { "pong": true, "ts": 42 } }
})
.to_string()
.into(),
))
.await
.unwrap();
});
// 控制端:连接 → 握手 → 校验元信息 → 调用。
let client = Client::new(ClientOptions {
url: format!("ws://{addr}"),
key: "secret".to_string(),
client_id: "control-1".to_string(),
..Default::default()
});
let caps = client.connect().await.unwrap();
assert_eq!(caps.len(), 1);
assert_eq!(caps[0].name, "system.ping");
assert_eq!(client.agent_version().as_deref(), Some("1.0.0"));
assert_eq!(client.state(), nodeagent_client::ConnState::Connected);
let res = client.invoke("system.ping", json!({})).await.unwrap();
assert_eq!(res.status, "ok");
assert_eq!(res.data.unwrap()["pong"], true);
client.close();
server.await.unwrap();
}
#[tokio::test]
async fn connect_to_closed_port_fails() {
// 连接到一个必然拒绝的端口,应返回离线错误而非 panic。
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener); // 立即关闭,确保端口无监听。
let client = Client::new(ClientOptions {
url: format!("ws://{addr}"),
key: "secret".to_string(),
client_id: "control-1".to_string(),
handshake_timeout_ms: 1_000,
..Default::default()
});
let err = client.connect().await.unwrap_err();
assert_eq!(err.code, nodeagent_client::ErrorCodes::NODE_OFFLINE);
}