优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user