remote/linux-agent/tests/smoke.rs

448 lines
15 KiB
Rust
Raw Normal View History

feat(agent): 新增 agent-core 与 Linux 被控端(X11 + XTest,纯新增不动 agent/) 本期只在新增文件里做,**未改动 Windows agent/ 一个字节**(与 NVENC 线并行,避免争抢)。 - 新增 agent-core:平台无关会话层 —— QUIC/TLS 握手、传输调优(沿用 512KB 发送窗口 结论)、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码 视频管线(openh264)。同步跟进协议新增的 Pong{lost,sent} 下行丢包统计字段 - 新增 linux-agent:X11 GetImage 采集 + 整帧变更检测;XTest 注入 + 扫描码→X11 keycode 映射(主键区 = scancode+8,E0 扩展键必须查表,含单元测试) - 协议零改动:复用 Set-1 扫描码与虚拟桌面归一化坐标,viewer 无需任何修改 - 新增端到端冒烟测试 tests/smoke.rs 与 scripts/linux-smoke.sh(自建 Xvfb,无需 图形桌面):采集→编码→QUIC 解帧、认证(含错误码拒绝)、Ping/Pong(含丢包字段)、 静止桌面关键帧恢复、输入注入实证(注入后从另一条 X11 连接读回指针真实坐标) - 该关键帧用例做过变异测试:把修复退回会失败、恢复后通过,确认断言有效 - .gitattributes:shell 脚本强制 LF(Windows checkout 成 CRLF 会导致容器内无法执行) - 手册:修正已失效的工具链路径;补充 cargo 镜像;新增第 8 节(Linux)与 6.1 节 (三条待修问题,含两条 Windows 侧仍存在的关键帧缺陷) 验证: - Linux(容器, USTC 源 + Xvfb): cargo test -p linux-agent 通过,2 passed - Windows: 本期未改 agent/,default-members 未变,`cargo build --release` 行为不变
2026-10-23 01:31:07 -04:00
//! Linux 被控端端到端冒烟测试。
//!
//! 需要 X server(Xvfb 即可),通过 `DISPLAY` 指定;推荐用 `scripts/linux-smoke.sh` 一键跑。
//!
//! 覆盖链路:X11 采集 → openh264 编码 → QUIC/TLS 下行 → 主控端解帧;
//! 外加验证码认证、Ping/Pong、关键帧请求,以及**输入注入的实证断言**
//! (注入鼠标移动后,从另一条 X11 连接查询指针真实落点,而不是只看进程没崩)。
use anyhow::{bail, Context, Result};
use common::{read_control, read_video_frame, write_control, ControlMsg, InputEvent};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, SignatureScheme};
use std::io::{BufRead, BufReader};
use std::net::{TcpListener, ToSocketAddrs};
use std::process::{Child, Command, Stdio};
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ConnectionExt as _, Window};
use x11rb::rust_connection::RustConnection;
/// 与 viewer 一致:MVP 阶段跳过自签名证书校验
#[derive(Debug)]
struct SkipVerify;
impl ServerCertVerifier for SkipVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_certificate: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::ED25519,
SignatureScheme::RSA_PSS_SHA256,
]
}
}
/// 被测进程的 RAII 句柄:测试无论成功失败都会收尸
struct AgentProc {
child: Child,
}
impl Drop for AgentProc {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn expected_screen() -> (u32, u32) {
let s = std::env::var("SMOKE_SCREEN").unwrap_or_else(|_| "1280x720".to_string());
let (w, h) = s
.split_once('x')
.unwrap_or_else(|| panic!("SMOKE_SCREEN 需形如 1280x720,实际 {s:?}"));
(w.parse().expect("宽"), h.parse().expect("高"))
}
fn free_port() -> Result<u16> {
let l = TcpListener::bind("127.0.0.1:0")?;
Ok(l.local_addr()?.port())
}
fn spawn_agent(port: u16, code: &str) -> Result<AgentProc> {
let mut child = Command::new(env!("CARGO_BIN_EXE_linux-agent"))
.args(["--port", &port.to_string(), "--code", code])
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("启动 linux-agent 失败")?;
let stdout = child.stdout.take().expect("stdout 已 piped");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines().map_while(|l| l.ok()) {
println!("[agent] {line}");
let _ = tx.send(line);
}
});
// 等 QUIC 端口就绪
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
bail!("等待 agent 监听超时(DISPLAY 是否已设置?)");
}
match rx.recv_timeout(left) {
Ok(line) if line.contains("listening") => return Ok(AgentProc { child }),
Ok(_) => continue,
Err(_) => bail!("等待 agent 监听超时,进程可能已退出"),
}
}
}
async fn connect(port: u16) -> Result<(quinn::Endpoint, quinn::Connection)> {
let tls = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipVerify))
.with_no_client_auth();
let qc: quinn::crypto::rustls::QuicClientConfig = Arc::new(tls).try_into()?;
let mut transport = quinn::TransportConfig::default();
transport.receive_window(quinn::VarInt::from_u32(16 * 1024 * 1024));
transport.stream_receive_window(quinn::VarInt::from_u32(8 * 1024 * 1024));
let mut client_config = quinn::ClientConfig::new(Arc::new(qc));
client_config.transport_config(Arc::new(transport));
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
endpoint.set_default_client_config(client_config);
let addr = ("127.0.0.1", port)
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow::anyhow!("地址解析失败"))?;
let conn = endpoint.connect(addr, "localhost")?.await?;
// endpoint 必须与 conn 同生命周期,故一并返回
Ok((endpoint, conn))
}
fn has_start_code(payload: &[u8]) -> bool {
let head = &payload[..payload.len().min(64)];
head.windows(4).any(|w| w == [0, 0, 0, 1]) || head.windows(3).any(|w| w == [0, 0, 1])
}
/// H.264 NAL 类型:5 = IDR 片(关键帧)
const NAL_IDR: u8 = 5;
/// 按 Annex-B 起始码扫描,判断载荷里是否含指定类型的 NAL
fn contains_nal(payload: &[u8], nal_type: u8) -> bool {
let mut i = 0usize;
while i + 3 <= payload.len() {
let sc_len = if payload[i..].starts_with(&[0, 0, 0, 1]) {
4
} else if payload[i..].starts_with(&[0, 0, 1]) {
3
} else {
i += 1;
continue;
};
if i + sc_len >= payload.len() {
return false;
}
if payload[i + sc_len] & 0x1F == nal_type {
return true;
}
i += sc_len + 1;
}
false
}
/// 在根窗口左上角画一个**小**矩形,制造一次细微画面变化。
///
/// 刻意不用「整屏刷白」那类大变化:大变化会触发编码器的场景切换自动 IDR,
/// 于是「重发旧帧」和「真重编关键帧」拿到同样的字节,用例就失去区分度了。
fn perturb_screen(conn: &RustConnection, root: Window) -> Result<()> {
use x11rb::protocol::xproto::{CreateGCAux, Rectangle};
let gc = conn.generate_id()?;
conn.create_gc(
gc,
root,
&CreateGCAux::new().foreground(0x00FF_FFFF).graphics_exposures(0),
)?;
conn.poly_fill_rectangle(
root,
gc,
&[Rectangle {
x: 32,
y: 32,
width: 64,
height: 64,
}],
)?;
conn.free_gc(gc)?;
conn.flush()?;
println!("[smoke] 已扰动屏幕(根窗口画 64x64 白块)");
Ok(())
}
/// 读帧直到拿到一个非关键帧(跳过静止桌面的低频重发),返回该帧载荷
async fn read_until_p_frame(recv: &mut quinn::RecvStream, timeout: Duration) -> Result<Vec<u8>> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let (_h, p) = tokio::time::timeout(Duration::from_secs(5), read_video_frame(recv))
.await
.context("等待扰动帧超时")??;
if !contains_nal(&p, NAL_IDR) {
return Ok(p);
}
}
bail!("扰动后未拿到非关键帧,用例前置条件不成立(扰动量是否太大?)")
}
/// 请求关键帧后,容错地等待一个 IDR:期间允许出现静止桌面的 P 帧重发。
/// 返回是否在超时内拿到 IDR。
async fn wait_for_idr(recv: &mut quinn::RecvStream, timeout: Duration) -> Result<bool> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match tokio::time::timeout(Duration::from_secs(2), read_video_frame(recv)).await {
Ok(Ok((_h, p))) => {
if contains_nal(&p, NAL_IDR) {
return Ok(true);
}
}
_ => break,
}
}
Ok(false)
}
/// 归一化坐标 → 根窗口像素(与 linux-agent/src/input.rs 的 scale 完全一致)
fn scale(v: u16, extent: u32) -> i16 {
if extent <= 1 {
return 0;
}
((v as i64 * (extent as i64 - 1)) / 65535) as i16
}
fn open_display() -> Result<(RustConnection, Window)> {
let (conn, screen_num) = x11rb::connect(None)
.map_err(|e| anyhow::anyhow!("连接 X11 失败(DISPLAY 是否已设置?): {e}"))?;
let root = conn.setup().roots[screen_num].root;
Ok((conn, root))
}
fn pointer_position(conn: &RustConnection, root: Window) -> Result<(i16, i16)> {
let r = conn.query_pointer(root)?.reply()?;
Ok((r.root_x, r.root_y))
}
fn wait_pointer(
conn: &RustConnection,
root: Window,
want: (i16, i16),
timeout: Duration,
) -> Result<(i16, i16)> {
let deadline = Instant::now() + timeout;
let mut last = pointer_position(conn, root)?;
while Instant::now() < deadline {
last = pointer_position(conn, root)?;
if (last.0 - want.0).abs() <= 2 && (last.1 - want.1).abs() <= 2 {
return Ok(last);
}
std::thread::sleep(Duration::from_millis(50));
}
Ok(last)
}
#[tokio::test]
async fn end_to_end_capture_encode_transport_input() -> Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
let (sw, sh) = expected_screen();
let port = free_port()?;
let _agent = spawn_agent(port, "smoke-code")?;
let (_endpoint, conn) = connect(port).await?;
// 流 1:认证,并核对被控端上报的桌面尺寸就是 Xvfb 的尺寸
let (mut send, mut recv) = conn.open_bi().await?;
write_control(
&mut send,
&ControlMsg::Auth {
code: "smoke-code".into(),
feat(common): 协议 v2——版本号 + 帧头 seq/keyframe + 长度下界校验 为接入安卓主控做准备,先把三端共享契约改到能承载多客户端。改动集中在 common/src/lib.rs,三端适配在同一提交内完成以保证可编译。 协议版本(PROTO_VERSION=2) - ControlMsg::Auth/AuthOk 增加 proto_version 字段;认证顺序改为「先版本后验证码」: 版本不一致时字段布局可能不同,验证码的解析结果不可信 - 任一端版本不符即 AuthFail 并明确报错,把「连上了但行为诡异」变成「连不上且提示升级」 视频帧头(VideoFrameHeader) - 新增 seq: u64(每次实际发送递增,含静止桌面重发)与 keyframe: bool - keyframe 由新增的 common::h264_contains_idr() 从实际 Annex-B 码流判定, 不依赖编码器 API 推断——NVENC 与 openh264 规则不同,实际输出才是唯一可信来源 - 该字段是 MediaCodec 判断解码器重置时机的必要条件,避免客户端自己写 NAL 扫描 不可信输入防护 - read_video_frame 补齐长度下界:total 必须 >= 4,hlen 必须落在 [1, total-4] - 旧实现只校验上界,构造 total<4 的帧会让接收端在 buf[0..4] 处 panic(进程级DoS) - 测试从 0 增至 11 个,覆盖序列化往返与 6 类越界/损坏输入 顺带修复(与上述契约直接相关) - 新增 common::split_video_frame():静止桌面重发时复用载荷但重建帧头, 否则 seq/ts_ms 会停在上次发送时刻,破坏 seq 单调递增契约 - agent-core 与 agent 两端的静止重发路径均改用该函数 验证:cargo test -p common → 20 passed(9 signaling + 11 新增)
2026-10-28 04:01:09 -04:00
proto_version: common::PROTO_VERSION,
feat(agent): 新增 agent-core 与 Linux 被控端(X11 + XTest,纯新增不动 agent/) 本期只在新增文件里做,**未改动 Windows agent/ 一个字节**(与 NVENC 线并行,避免争抢)。 - 新增 agent-core:平台无关会话层 —— QUIC/TLS 握手、传输调优(沿用 512KB 发送窗口 结论)、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码 视频管线(openh264)。同步跟进协议新增的 Pong{lost,sent} 下行丢包统计字段 - 新增 linux-agent:X11 GetImage 采集 + 整帧变更检测;XTest 注入 + 扫描码→X11 keycode 映射(主键区 = scancode+8,E0 扩展键必须查表,含单元测试) - 协议零改动:复用 Set-1 扫描码与虚拟桌面归一化坐标,viewer 无需任何修改 - 新增端到端冒烟测试 tests/smoke.rs 与 scripts/linux-smoke.sh(自建 Xvfb,无需 图形桌面):采集→编码→QUIC 解帧、认证(含错误码拒绝)、Ping/Pong(含丢包字段)、 静止桌面关键帧恢复、输入注入实证(注入后从另一条 X11 连接读回指针真实坐标) - 该关键帧用例做过变异测试:把修复退回会失败、恢复后通过,确认断言有效 - .gitattributes:shell 脚本强制 LF(Windows checkout 成 CRLF 会导致容器内无法执行) - 手册:修正已失效的工具链路径;补充 cargo 镜像;新增第 8 节(Linux)与 6.1 节 (三条待修问题,含两条 Windows 侧仍存在的关键帧缺陷) 验证: - Linux(容器, USTC 源 + Xvfb): cargo test -p linux-agent 通过,2 passed - Windows: 本期未改 agent/,default-members 未变,`cargo build --release` 行为不变
2026-10-23 01:31:07 -04:00
},
)
.await?;
match read_control(&mut recv).await? {
feat(common): 协议 v2——版本号 + 帧头 seq/keyframe + 长度下界校验 为接入安卓主控做准备,先把三端共享契约改到能承载多客户端。改动集中在 common/src/lib.rs,三端适配在同一提交内完成以保证可编译。 协议版本(PROTO_VERSION=2) - ControlMsg::Auth/AuthOk 增加 proto_version 字段;认证顺序改为「先版本后验证码」: 版本不一致时字段布局可能不同,验证码的解析结果不可信 - 任一端版本不符即 AuthFail 并明确报错,把「连上了但行为诡异」变成「连不上且提示升级」 视频帧头(VideoFrameHeader) - 新增 seq: u64(每次实际发送递增,含静止桌面重发)与 keyframe: bool - keyframe 由新增的 common::h264_contains_idr() 从实际 Annex-B 码流判定, 不依赖编码器 API 推断——NVENC 与 openh264 规则不同,实际输出才是唯一可信来源 - 该字段是 MediaCodec 判断解码器重置时机的必要条件,避免客户端自己写 NAL 扫描 不可信输入防护 - read_video_frame 补齐长度下界:total 必须 >= 4,hlen 必须落在 [1, total-4] - 旧实现只校验上界,构造 total<4 的帧会让接收端在 buf[0..4] 处 panic(进程级DoS) - 测试从 0 增至 11 个,覆盖序列化往返与 6 类越界/损坏输入 顺带修复(与上述契约直接相关) - 新增 common::split_video_frame():静止桌面重发时复用载荷但重建帧头, 否则 seq/ts_ms 会停在上次发送时刻,破坏 seq 单调递增契约 - agent-core 与 agent 两端的静止重发路径均改用该函数 验证:cargo test -p common → 20 passed(9 signaling + 11 新增)
2026-10-28 04:01:09 -04:00
ControlMsg::AuthOk {
width,
height,
proto_version,
} => {
assert_eq!(proto_version, common::PROTO_VERSION, "被控端协议版本不符");
feat(agent): 新增 agent-core 与 Linux 被控端(X11 + XTest,纯新增不动 agent/) 本期只在新增文件里做,**未改动 Windows agent/ 一个字节**(与 NVENC 线并行,避免争抢)。 - 新增 agent-core:平台无关会话层 —— QUIC/TLS 握手、传输调优(沿用 512KB 发送窗口 结论)、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码 视频管线(openh264)。同步跟进协议新增的 Pong{lost,sent} 下行丢包统计字段 - 新增 linux-agent:X11 GetImage 采集 + 整帧变更检测;XTest 注入 + 扫描码→X11 keycode 映射(主键区 = scancode+8,E0 扩展键必须查表,含单元测试) - 协议零改动:复用 Set-1 扫描码与虚拟桌面归一化坐标,viewer 无需任何修改 - 新增端到端冒烟测试 tests/smoke.rs 与 scripts/linux-smoke.sh(自建 Xvfb,无需 图形桌面):采集→编码→QUIC 解帧、认证(含错误码拒绝)、Ping/Pong(含丢包字段)、 静止桌面关键帧恢复、输入注入实证(注入后从另一条 X11 连接读回指针真实坐标) - 该关键帧用例做过变异测试:把修复退回会失败、恢复后通过,确认断言有效 - .gitattributes:shell 脚本强制 LF(Windows checkout 成 CRLF 会导致容器内无法执行) - 手册:修正已失效的工具链路径;补充 cargo 镜像;新增第 8 节(Linux)与 6.1 节 (三条待修问题,含两条 Windows 侧仍存在的关键帧缺陷) 验证: - Linux(容器, USTC 源 + Xvfb): cargo test -p linux-agent 通过,2 passed - Windows: 本期未改 agent/,default-members 未变,`cargo build --release` 行为不变
2026-10-23 01:31:07 -04:00
assert_eq!(
(width, height),
(sw, sh),
"agent 上报桌面尺寸与实际屏幕不符"
);
}
other => bail!("认证应成功,却收到 {other:?}"),
}
// 流 2:输入 + 控制
let (mut isend, mut irecv) = conn.open_bi().await?;
// Ping/Pong(延迟测量通道 + 下行丢包统计)
write_control(&mut isend, &ControlMsg::Ping(4242)).await?;
match read_control(&mut irecv).await? {
ControlMsg::Pong {
t,
agent_ts,
lost,
sent,
} => {
assert_eq!(t, 4242, "Pong 回显的时间戳不一致");
assert!(sent > 0, "下行发包计数应大于 0(视频流已在发)");
println!("[smoke] Pong 收到,agent_ts={agent_ts},下行 sent={sent} lost={lost}");
}
other => bail!("期望 Pong,收到 {other:?}"),
}
// 首帧:证明采集 + 编码 + 线格式整条链路通了
let (h1, p1) = tokio::time::timeout(Duration::from_secs(15), read_video_frame(&mut recv))
.await
.context("等待首个视频帧超时")??;
assert_eq!((h1.width, h1.height), (sw, sh), "视频帧头尺寸不符");
assert!(!p1.is_empty(), "视频载荷为空");
println!(
"[smoke] 首帧 {}x{} ts={} 编码后 {} 字节,头部 {:02X?}",
h1.width,
h1.height,
h1.ts_ms,
p1.len(),
&p1[..p1.len().min(12)]
);
assert!(
has_start_code(&p1),
"载荷不像 H.264(前 16 字节 {:02X?})",
&p1[..p1.len().min(16)]
);
// 关键帧恢复:先在**静止**桌面上请求关键帧。
// 为了有区分度,先扰动一下屏幕,让缓存里落入一个非关键帧(P 帧),否则
// 「重发旧帧」和「真重编关键帧」两种行为拿到的字节是一样的,测不出来。
let (probe, root) = open_display()?;
perturb_screen(&probe, root)?;
let pp = read_until_p_frame(&mut recv, Duration::from_secs(10)).await?;
println!(
"[smoke] 扰动帧 {} 字节,头部 {:02X?}(应为 P 帧,无 IDR)",
pp.len(),
&pp[..pp.len().min(12)]
);
// 前置条件自检:没有这一步,用例可能因为「扰动本身产生了 IDR」而虚假通过
assert!(
!contains_nal(&pp, NAL_IDR),
"扰动帧仍是 IDR,本用例无法区分重发旧帧与真重编关键帧"
);
tokio::time::sleep(Duration::from_millis(800)).await; // 等画面重新静止
write_control(&mut isend, &ControlMsg::KeyFrameReq).await?;
assert!(
wait_for_idr(&mut recv, Duration::from_secs(3)).await?,
"静止桌面请求关键帧后 3s 内未收到 IDR:解码端一旦出错将永远无法恢复"
);
println!("[smoke] 静止桌面请求关键帧 → 收到 IDR");
// 输入注入实证:注入到屏幕中心,再从独立 X11 连接查询指针真实落点
let before = pointer_position(&probe, root)?;
write_control(
&mut isend,
&ControlMsg::Input(InputEvent::MouseMove {
x: 32768,
y: 32768,
}),
)
.await?;
let want = (scale(32768, sw), scale(32768, sh));
let got = wait_pointer(&probe, root, want, Duration::from_secs(5))?;
println!("[smoke] 指针 {before:?} → {got:?}(期望 ≈{want:?})");
assert!(
(got.0 - want.0).abs() <= 2 && (got.1 - want.1).abs() <= 2,
"XTest 注入未生效:期望指针 ≈{want:?},实际 {got:?}"
);
// 键盘:链路不得因此崩溃(无窗口可断言落点)
write_control(
&mut isend,
&ControlMsg::Input(InputEvent::KeyDown {
scancode: 0x1E,
extended: false,
}),
)
.await?;
write_control(
&mut isend,
&ControlMsg::Input(InputEvent::KeyUp {
scancode: 0x1E,
extended: false,
}),
)
.await?;
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
conn.close_reason().is_none(),
"会话被意外关闭:{:?}",
conn.close_reason()
);
println!("[smoke] 全部断言通过");
Ok(())
}
#[tokio::test]
async fn rejects_wrong_verification_code() -> Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.ok();
let port = free_port()?;
let _agent = spawn_agent(port, "right-code")?;
let (_endpoint, conn) = connect(port).await?;
let (mut send, mut recv) = conn.open_bi().await?;
write_control(
&mut send,
&ControlMsg::Auth {
code: "wrong-code".into(),
feat(common): 协议 v2——版本号 + 帧头 seq/keyframe + 长度下界校验 为接入安卓主控做准备,先把三端共享契约改到能承载多客户端。改动集中在 common/src/lib.rs,三端适配在同一提交内完成以保证可编译。 协议版本(PROTO_VERSION=2) - ControlMsg::Auth/AuthOk 增加 proto_version 字段;认证顺序改为「先版本后验证码」: 版本不一致时字段布局可能不同,验证码的解析结果不可信 - 任一端版本不符即 AuthFail 并明确报错,把「连上了但行为诡异」变成「连不上且提示升级」 视频帧头(VideoFrameHeader) - 新增 seq: u64(每次实际发送递增,含静止桌面重发)与 keyframe: bool - keyframe 由新增的 common::h264_contains_idr() 从实际 Annex-B 码流判定, 不依赖编码器 API 推断——NVENC 与 openh264 规则不同,实际输出才是唯一可信来源 - 该字段是 MediaCodec 判断解码器重置时机的必要条件,避免客户端自己写 NAL 扫描 不可信输入防护 - read_video_frame 补齐长度下界:total 必须 >= 4,hlen 必须落在 [1, total-4] - 旧实现只校验上界,构造 total<4 的帧会让接收端在 buf[0..4] 处 panic(进程级DoS) - 测试从 0 增至 11 个,覆盖序列化往返与 6 类越界/损坏输入 顺带修复(与上述契约直接相关) - 新增 common::split_video_frame():静止桌面重发时复用载荷但重建帧头, 否则 seq/ts_ms 会停在上次发送时刻,破坏 seq 单调递增契约 - agent-core 与 agent 两端的静止重发路径均改用该函数 验证:cargo test -p common → 20 passed(9 signaling + 11 新增)
2026-10-28 04:01:09 -04:00
proto_version: common::PROTO_VERSION,
feat(agent): 新增 agent-core 与 Linux 被控端(X11 + XTest,纯新增不动 agent/) 本期只在新增文件里做,**未改动 Windows agent/ 一个字节**(与 NVENC 线并行,避免争抢)。 - 新增 agent-core:平台无关会话层 —— QUIC/TLS 握手、传输调优(沿用 512KB 发送窗口 结论)、验证码认证、会话/select 循环、latest-wins 单槽、输入分发,以及软件编码 视频管线(openh264)。同步跟进协议新增的 Pong{lost,sent} 下行丢包统计字段 - 新增 linux-agent:X11 GetImage 采集 + 整帧变更检测;XTest 注入 + 扫描码→X11 keycode 映射(主键区 = scancode+8,E0 扩展键必须查表,含单元测试) - 协议零改动:复用 Set-1 扫描码与虚拟桌面归一化坐标,viewer 无需任何修改 - 新增端到端冒烟测试 tests/smoke.rs 与 scripts/linux-smoke.sh(自建 Xvfb,无需 图形桌面):采集→编码→QUIC 解帧、认证(含错误码拒绝)、Ping/Pong(含丢包字段)、 静止桌面关键帧恢复、输入注入实证(注入后从另一条 X11 连接读回指针真实坐标) - 该关键帧用例做过变异测试:把修复退回会失败、恢复后通过,确认断言有效 - .gitattributes:shell 脚本强制 LF(Windows checkout 成 CRLF 会导致容器内无法执行) - 手册:修正已失效的工具链路径;补充 cargo 镜像;新增第 8 节(Linux)与 6.1 节 (三条待修问题,含两条 Windows 侧仍存在的关键帧缺陷) 验证: - Linux(容器, USTC 源 + Xvfb): cargo test -p linux-agent 通过,2 passed - Windows: 本期未改 agent/,default-members 未变,`cargo build --release` 行为不变
2026-10-23 01:31:07 -04:00
},
)
.await?;
match read_control(&mut recv).await? {
ControlMsg::AuthFail(_) => Ok(()),
other => bail!("错误验证码竟被接受:{other:?}"),
}
}