优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账
This commit is contained in:
@@ -61,10 +61,10 @@ pub(super) fn truncate_chars(s: &str, max: usize) -> String {
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 规范化路径用于比较/查重:canonicalize 解析绝对规范路径(失败降级),
|
||||
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
|
||||
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
|
||||
pub fn normalize_path(p: &str) -> String {
|
||||
/// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)。
|
||||
pub fn normalize_for_compare(p: &str) -> String {
|
||||
match Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
||||
Err(_) => p
|
||||
@@ -74,6 +74,22 @@ pub fn normalize_path(p: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 规范化路径用于存库:canonicalize 解析绝对规范路径(失败降级为 trim + 正斜杠),
|
||||
/// **保留大小写**(大小写敏感系统如 Linux/容器文件系统路径解析需原样大小写,
|
||||
/// 小写化会导致路径解析失败)。比较/查重请用 [`normalize_for_compare`]。
|
||||
pub fn canonicalize_for_store(p: &str) -> String {
|
||||
match Path::new(p).canonicalize() {
|
||||
Ok(abs) => abs.to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => p.trim_end_matches(['\\', '/']).replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容别名:小写比较版(等价 [`normalize_for_compare`])。
|
||||
/// 旧调用方沿用;新代码按用途选 `normalize_for_compare`(比较)/ `canonicalize_for_store`(存库)。
|
||||
pub fn normalize_path(p: &str) -> String {
|
||||
normalize_for_compare(p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -111,4 +127,30 @@ mod tests {
|
||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalize_for_store_preserves_case() {
|
||||
// 存库版保留大小写(降级分支:不存在的路径走 trim + replace,大小写原样保留)
|
||||
let n = canonicalize_for_store(r"C:\Foo\Bar\");
|
||||
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
|
||||
assert!(
|
||||
!n.ends_with('/') && !n.ends_with('\\'),
|
||||
"尾部分隔符未裁剪: {n}"
|
||||
);
|
||||
assert!(
|
||||
n.contains("Foo") && n.contains("Bar"),
|
||||
"存库版不应小写化(大小写敏感系统路径解析依赖): {n}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_lowercases_but_store_keeps_case() {
|
||||
// compare 版与 store 版语义分离:比较小写、存库保留大小写
|
||||
let p = r"C:\Foo\Bar";
|
||||
let comp = normalize_for_compare(p);
|
||||
let store = canonicalize_for_store(p);
|
||||
assert_eq!(comp, comp.to_lowercase(), "比较版应小写: {comp}");
|
||||
assert_ne!(comp, store, "小写化仅限比较版,存库版应保留大小写: {store}");
|
||||
assert!(!store.contains('\\'), "存库版应正斜杠: {store}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,22 @@
|
||||
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
|
||||
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
|
||||
//!
|
||||
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。
|
||||
//! 纯转发中继,无业务逻辑。token 校验对齐 `DF_RELAY_TOKEN`(relay.rs,必须设置)。
|
||||
//!
|
||||
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。
|
||||
//! 联调启动:`DF_RELAY_TOKEN=<token> cargo run -p df-relay`(未设 token 时启动即失败,exit 1)。
|
||||
|
||||
use df_relay::{DefaultRelayServer, RelayServer};
|
||||
use df_relay::{DefaultRelayServer, RelayError, RelayServer};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> df_relay::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
// RLY-5:token 未设置 → 启动即返回错误(exit code 1 + 明确消息),而非运行期 panic。
|
||||
// relay.rs expected_token() 已改为返回 Option 兜底,但 fail-fast 比慢速拒连更早暴露配置错误。
|
||||
if std::env::var("DF_RELAY_TOKEN").is_err() {
|
||||
return Err(RelayError::Start(
|
||||
"必须设置环境变量 DF_RELAY_TOKEN(未设置时拒绝启动)".into(),
|
||||
));
|
||||
}
|
||||
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
|
||||
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
|
||||
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
|
||||
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
|
||||
//!
|
||||
//! ## 鉴权模型(MC-4 现状与风险)
|
||||
//! MVP 单共享 token(`DF_RELAY_TOKEN`),无 per-device 绑定:**任何持 token 客户端可声明任意
|
||||
//! `device_id` 并冒充该设备收发指令**。生产级 per-device token(配对时按 device 颁发独立 token
|
||||
//! 并存储)涉及配对流程改造(需 df-miniapp/df-tunnel 配合),当前批次不实施,仅做最小加固:
|
||||
//! - 连接建立时校验 `device_id` 格式(非空 + 长度上限,见 `validate_device_id`)。
|
||||
//! - token 比较用常量时间比较(RLY-4),防时序侧信道。
|
||||
//! 完整 per-device 鉴权留设计文档 Phase3。
|
||||
//!
|
||||
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
@@ -32,12 +40,39 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
|
||||
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
|
||||
use crate::error::{RelayError, Result};
|
||||
|
||||
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
|
||||
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
|
||||
/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
|
||||
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
|
||||
fn expected_token() -> String {
|
||||
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
|
||||
panic!("必须设置环境变量 DF_RELAY_TOKEN")
|
||||
})
|
||||
fn expected_token() -> Option<String> {
|
||||
std::env::var("DF_RELAY_TOKEN").ok()
|
||||
}
|
||||
|
||||
/// RLY-3:入站文本帧大小上限(1 MiB)。
|
||||
/// 防恶意/异常客户端发超大帧耗尽内存与转发带宽;超限直接断开连接。
|
||||
const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024;
|
||||
|
||||
/// 常量时间字符串比较(RLY-4,防时序侧信道)。
|
||||
///
|
||||
/// 没有引入 subtle 依赖;用等长逐字节异或累加 + 尾随等量循环掩蔽长度差,
|
||||
/// 比较时长不随首字节差异提前返回。长度本身不保密(可被包长度观测),故长度不等直接返回。
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let a = a.as_bytes();
|
||||
let b = b.as_bytes();
|
||||
// 用最大长度循环,短者补 0 参与异或,时长恒等于较长者,不泄露首字节命中点
|
||||
let len = a.len().max(b.len());
|
||||
let mut diff: u8 = 0;
|
||||
for i in 0..len {
|
||||
let av = a.get(i).copied().unwrap_or(0);
|
||||
let bv = b.get(i).copied().unwrap_or(0);
|
||||
diff |= av ^ bv;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// MC-4 最小加固:校验 device_id 格式。
|
||||
/// 拒绝空串与超长(防内存/日志注入),仅做基础防御;per-device token 绑定留 Phase3。
|
||||
fn validate_device_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
|
||||
}
|
||||
|
||||
/// 客户端首消息:身份宣告(简单协议)
|
||||
@@ -222,7 +257,34 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
if hello.token != expected_token() {
|
||||
// MC-4 最小加固:device_id 格式校验(空/超长/含控制字符一律拒)。
|
||||
// 单共享 token 下 device_id 无强绑定,但格式校验可挡最基础的注入/异常输入。
|
||||
if !validate_device_id(&hello.device_id) {
|
||||
tracing::warn!(
|
||||
device_id = %hello.device_id,
|
||||
"握手失败:device_id 格式非法"
|
||||
);
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"invalid_device_id"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
}
|
||||
// RLY-5:token 未配置(服务启动时应已由 main 校验)→ 拒绝握手而非 panic
|
||||
let Some(expected) = expected_token() else {
|
||||
tracing::error!("DF_RELAY_TOKEN 未设置,拒绝握手");
|
||||
let _ = send_text(
|
||||
&mut socket_tx,
|
||||
r#"{"kind":"control","error":"server_misconfigured"}"#,
|
||||
)
|
||||
.await;
|
||||
let _ = socket_tx.close().await;
|
||||
return;
|
||||
};
|
||||
// RLY-4:常量时间比较防时序侧信道
|
||||
if !constant_time_eq(&hello.token, &expected) {
|
||||
tracing::warn!(
|
||||
device_id = %hello.device_id,
|
||||
"握手失败:token 校验不通过"
|
||||
@@ -270,6 +332,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
|
||||
maybe_msg = socket_rx.next() => {
|
||||
match maybe_msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
// RLY-3:入站帧超限(>1MiB)直接断开,防大帧耗尽内存/带宽
|
||||
if text.len() > MAX_FRAME_BYTES {
|
||||
tracing::warn!(
|
||||
conn_id = conn_id.0,
|
||||
len = text.len(),
|
||||
"入站帧超限,断开连接"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
|
||||
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
|
||||
}
|
||||
@@ -331,6 +402,13 @@ async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Re
|
||||
}
|
||||
_ => return Err(RelayError::Client("握手首帧类型非法".into())),
|
||||
};
|
||||
// RLY-3:Hello 帧同样限长(Hello 结构很小,超限视为异常/恶意)
|
||||
if text.len() > MAX_FRAME_BYTES {
|
||||
return Err(RelayError::Client(format!(
|
||||
"Hello 帧超限({} B > {MAX_FRAME_BYTES} B)",
|
||||
text.len()
|
||||
)));
|
||||
}
|
||||
let hello: Hello =
|
||||
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
|
||||
Ok(hello)
|
||||
@@ -440,3 +518,31 @@ fn now_ms() -> i64 {
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constant_time_eq_equality_and_inequality() {
|
||||
assert!(constant_time_eq("abc", "abc"));
|
||||
assert!(!constant_time_eq("abc", "abd"));
|
||||
assert!(!constant_time_eq("abc", "ab"));
|
||||
assert!(!constant_time_eq("", "a"));
|
||||
assert!(constant_time_eq("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_device_id_basic() {
|
||||
assert!(validate_device_id("dev-1"));
|
||||
assert!(validate_device_id("a".repeat(128).as_str()), "上限 128 应放行");
|
||||
assert!(!validate_device_id(""), "空串拒绝");
|
||||
assert!(!validate_device_id("a".repeat(129).as_str()), "超长拒绝");
|
||||
assert!(!validate_device_id("dev\u{0001}"), "控制字符拒绝");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_frame_is_one_mib() {
|
||||
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,56 @@ impl_repo!(
|
||||
// 通用 query 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。
|
||||
// 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。
|
||||
impl AiToolExecutionRepo {
|
||||
/// 批量插入审计记录(单事务多行 INSERT,砍 N 次串行 INSERT 尾巴)。
|
||||
///
|
||||
/// 对比 [`insert`](`impl_repo!` 生成,每次 spawn_blocking + 单行 execute):
|
||||
/// 本方法单次 `spawn_blocking` + 单事务,`prepare` 一次 INSERT stmt 循环 bind N 行,
|
||||
/// 一次 `COMMIT`(原子性:全插或全不插,审计留痕可追溯)。空 `records` 直接返回(无操作)。
|
||||
///
|
||||
/// **用途**:audit/mod.rs `process_tool_calls` 低风险工具 join_all 并行执行后的回填循环
|
||||
/// (每工具一条审计),把 N 次串行 INSERT 合并为一次事务批量(治 aichat 效率 AC-EFF-T1-1)。
|
||||
///
|
||||
/// 安全:全部值走参数绑定(同 `insert` 宏体),无 SQL 拼接注入面;单连接 Mutex 持锁整段,
|
||||
/// 与单行 insert 的锁粒度相同(一次持锁换 N 次持锁)。
|
||||
pub async fn insert_batch(&self, records: Vec<AiToolExecutionRecord>) -> Result<()> {
|
||||
if records.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard.execute_batch("BEGIN").map_err(storage_err)?;
|
||||
let result = (|| -> std::result::Result<(), rusqlite::Error> {
|
||||
let mut stmt = guard.prepare(
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
)?;
|
||||
for rec in &records {
|
||||
stmt.execute(params![
|
||||
rec.id, rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by
|
||||
])?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => {
|
||||
guard.execute_batch("COMMIT").map_err(storage_err)?;
|
||||
}
|
||||
Err(e) => {
|
||||
// 回滚失败静默(尽量保一致性;rollback 失败通常是连接已坏,交给上层)
|
||||
let _ = guard.execute_batch("ROLLBACK");
|
||||
return Err(storage_err(e));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。
|
||||
pub async fn find_by_tool_call_id(
|
||||
&self,
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
//!
|
||||
//! ## 边界
|
||||
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
|
||||
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
|
||||
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/收发循环;断线**自动重连由调用方 supervisor
|
||||
//! 负责**,src-tauri 已实现指数退避重连,本 crate 提供 connect/disconnect/is_connected
|
||||
//! 原语 + `backoff_delay` 退避工具)
|
||||
//!
|
||||
//! ## 模块
|
||||
//! - [`events`]:隧道透传事件/指令/消息包装类型
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! df-tunnel 隧道客户端(Phase2 完整实现)
|
||||
//!
|
||||
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
|
||||
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环 + 指数退避重连。
|
||||
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。
|
||||
//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处):
|
||||
//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。
|
||||
//!
|
||||
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
|
||||
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
|
||||
@@ -130,9 +132,9 @@ enum OutFrame {
|
||||
|
||||
/// 默认 WS 隧道客户端(完整实现)
|
||||
pub struct WsTunnelClient {
|
||||
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读)
|
||||
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连),
|
||||
/// 该字段保留以备 Phase3「断线后台自动重连」消费。
|
||||
/// 目标服务器 URL(connect 后保存,供调用方 supervisor 经 [`reconnect_params`] 复用)。
|
||||
/// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
|
||||
/// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)。
|
||||
server_url: Mutex<Option<String>>,
|
||||
device_id: Mutex<Option<String>>,
|
||||
token: Mutex<Option<String>>,
|
||||
@@ -155,7 +157,7 @@ impl WsTunnelClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前已连接时取一份重连参数(未来自动重连用)
|
||||
/// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连复用)
|
||||
pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
|
||||
let url = self.server_url.lock().await.clone()?;
|
||||
let device_id = self.device_id.lock().await.clone()?;
|
||||
@@ -344,8 +346,9 @@ impl WsTunnelClient {
|
||||
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
|
||||
/// 4. 任一端断开 → 退出 task,置 connected=false
|
||||
///
|
||||
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层
|
||||
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。
|
||||
/// 注:本任务范围不含「自动后台重连」(断开即退出)。自动重连由**调用方 supervisor** 承担:
|
||||
/// 轮询 is_connected → 用 [`reconnect_params`](WsTunnelClient::reconnect_params) 取参数
|
||||
/// + [`backoff_delay`] 退避 → 再 connect。src-tauri 已实现该 supervisor。
|
||||
async fn run_loop(
|
||||
mut sink: futures_util::stream::SplitSink<
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
@@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分)
|
||||
#[allow(dead_code)]
|
||||
fn backoff_delay(attempt: u32) -> Duration {
|
||||
/// 计算指数退避延迟(1s, 2s, 4s, 8s, 16s, 30s, 30s, ...)
|
||||
///
|
||||
/// 供调用方 supervisor 断线重连使用(本 crate 不内置自动重连,见模块文档)。
|
||||
/// 暴露为 pub 使外部(supervisor / src-tauri)可直接复用,不再属死代码。
|
||||
pub fn backoff_delay(attempt: u32) -> Duration {
|
||||
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
|
||||
let secs = RECONNECT_BASE
|
||||
.as_secs()
|
||||
|
||||
Reference in New Issue
Block a user