remote/linux-agent/tests/smoke.rs
yu 9c340813a8 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 13:31:08 +08:00

441 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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(),
},
)
.await?;
match read_control(&mut recv).await? {
ControlMsg::AuthOk { width, height } => {
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(),
},
)
.await?;
match read_control(&mut recv).await? {
ControlMsg::AuthFail(_) => Ok(()),
other => bail!("错误验证码竟被接受:{other:?}"),
}
}