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:
1 parent
9efd290061
commit
8ab8f8ae80
15 files changed
+3031
-6
No files matched your search
+4
-6
@@ -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/
|
||||
Generated
+1185
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["nodeagent-client"]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user