Fix nested XHTTP SNI and Vision TLS framing (#254)

* fix(xhttp): suppress inherited SNI inside the carrier

Retain the hop identity for certificate verification while restoring the absent default wire SNI for inner protocols. Extend the TLS identity matrix through the XHTTP builder to cover omitted, null, and explicit SNI.

* fix(vision): keep the initial prefix in one TLS record

Floor configured Vision client TLS buffers at 21 bytes so peers receive the UUID and first frame header together. Preserve ordinary TLS limits and cover tiny buffers with record-level assertions, UDP exchanges, and sing-box/Xray peer tests.

* fix(tls): drain deferred KeyUpdate replies before Vision writes

Materialize pending KeyUpdate replies before draining TLS output so they cannot fragment the initial Vision prefix. Preserve queued application data and fatal alerts after session failure, with framing and backpressure regressions.

* refactor(tls): clarify identity and Vision regression tests

Replace positional identity-path flags with a test-local enum, use the existing message shutdown helper, and document the XHTTP inner SNI boundary. Preserve test coverage and production behavior.

* ci: pin Xray test peers to 26.10.31

Replace the unavailable release download with the latest release and its verified checksum. Align source-built peers and fixture version checks with the same release.
This commit is contained in:
cfal
2026-11-01 00:32:17 +08:00
committed by GitHub
parent 652320af58
commit 8ab0f49ec4
15 changed files with 260 additions and 33 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
xray:
runs-on: ubuntu-latest
timeout-minutes: 30
name: Xray v26.10.29
name: Xray v26.10.31
env:
CARGO_BUILD_JOBS: 1
GOMAXPROCS: 1
@@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@v7
with:
repository: XTLS/Xray-core
ref: 0afec66085a85191e9dd2a4a6646c7c9a94688d1
ref: a4a3dff5b20c2faae567b742c172c7b95e75c083
path: reality-peer
- name: Cache Rust artifacts
uses: actions/cache@v6
+4 -4
View File
@@ -15,11 +15,11 @@ env:
ANYTLS_SING_BOX_SHA256: bfc39f643a5ecc1b702c5723ff662e61dde813f12b3af0d0cfce4f629ff74047
MIHOMO_VERSION: 1.19.31
MIHOMO_SHA256: 04cf9f09671704f839ddbee2e93069dc831a4123a75281e725d1d96ab9ac1afc
XRAY_VERSION: 26.10.29
XRAY_SHA256: 8a5f3e6a1057e4bd0f8a7c57c9d91204b4be0b74222a6e210d070603101163c2
XRAY_VERSION: 26.10.31
XRAY_SHA256: e4bd9119f50ae7c41658b1abd0679ae06f98bd864a2907ff33f2f2cf7081b214
SSSERVER_VERSION: 1.25.0
SSSERVER_SHA256: 874f817fcf3e6d7681ec715a1c13c686c6eaae936524d102639b38364f3966ae
XRAY_COMMIT: 0afec66085a85191e9dd2a4a6646c7c9a94688d1
XRAY_COMMIT: a4a3dff5b20c2faae567b742c172c7b95e75c083
MIHOMO_ALPHA_COMMIT: 94b13aed49b8dc41973e4bb3f721c5fa2c5c5c1f
NAIVE_CADDY_VERSION: 2.11.7-naive
NAIVE_CADDY_SHA256: ac97057c925bb098eb575e7b0cb8bd547d4c5f845b09a3dedfbe34444aa7ae88
@@ -49,7 +49,7 @@ jobs:
env:
GOMAXPROCS: 1
WG_SING_BOX_COMMIT: 7054cac5124465ffdb145870d7c01885566711b1
WG_XRAY_COMMIT: 0afec66085a85191e9dd2a4a6646c7c9a94688d1
WG_XRAY_COMMIT: a4a3dff5b20c2faae567b742c172c7b95e75c083
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
@@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v7
with:
repository: XTLS/Xray-core
ref: 0afec66085a85191e9dd2a4a6646c7c9a94688d1
ref: a4a3dff5b20c2faae567b742c172c7b95e75c083
path: peers/xray
- uses: actions/checkout@v7
with:
+5
View File
@@ -326,6 +326,11 @@ impl<IO: AsyncStream> CryptoTlsStream<IO> {
pub fn poll_drain_tls(&mut self, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.register_writer(cx);
if !self.write_raw && self.state.writeable() {
// Deferred KeyUpdate replies must not consume the next plaintext record's budget.
// Failed sessions still need to drain accepted data and fatal alerts.
let _ = self.session.writer().write(&[]);
}
self.poll_drain_tls_inner(cx)
}
+90 -5
View File
@@ -552,9 +552,11 @@ fn raw_flush_retains_prefix_until_transport_flush_completes() {
fn key_update_response_precedes_the_next_application_write() {
use std::io::Read;
for (session, mut peer) in [
completed_ordinary_connections(),
crate::reality::established_pair(),
for (drain_first, (session, mut peer)) in [
(false, completed_ordinary_connections()),
(false, crate::reality::established_pair()),
(true, completed_ordinary_connections()),
(true, crate::reality::established_pair()),
] {
let state = Arc::new(Mutex::new(WriteReadiness {
allowance: usize::MAX,
@@ -583,6 +585,17 @@ fn key_update_response_precedes_the_next_application_write() {
assert_eq!((state.write_polls, state.flush_polls), (0, 0));
}
if drain_first {
state.lock().unwrap().allowance = 0;
assert!(stream.poll_drain_tls(&mut cx).is_pending());
assert!(stream.session.wants_write());
state.lock().unwrap().allowance = usize::MAX;
assert!(matches!(
stream.poll_drain_tls(&mut cx),
Poll::Ready(Ok(()))
));
}
assert!(matches!(
Pin::new(&mut stream).poll_write(&mut cx, b"reply"),
Poll::Ready(Ok(5))
@@ -607,6 +620,74 @@ fn key_update_response_precedes_the_next_application_write() {
}
}
#[tokio::test]
async fn vision_prefix_stays_in_one_record_after_peer_key_update() {
use crate::address::NetLocation;
use crate::tcp::tcp_client_handler_factory::create_tcp_client_handler;
use futures::future::poll_fn;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const UUID: [u8; 16] = [0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0];
tokio::time::timeout(std::time::Duration::from_secs(10), async {
for size in [32, 8, 21, 27, 28, 47, 48, 64] {
let hop = NetLocation::from_str("localhost:443", None).unwrap();
let handler = create_tcp_client_handler(
serde_json::from_value(serde_json::json!({
"type": "tls", "verify": false, "vision": true,
"tls_buffer_size": size,
"protocol": {
"type": "vless", "udp_enabled": true,
"user_id": "00000000-0000-4000-8000-000000000000"
}
}))
.unwrap(),
&hop,
Arc::new(crate::resolver::NativeResolver::new()),
)
.unwrap();
let (_, server) = ordinary_connections();
let (left, right) = tokio::io::duplex(65536);
let target = NetLocation::from_str("192.0.2.1:53", None).unwrap();
let (client, server) = tokio::join!(
handler.setup_client_udp_bidirectional(Box::new(left), target.into()),
CryptoTlsStream::handshake(right, server, TlsReadMode::PreserveRecords, &[]),
);
let mut client = client.unwrap();
let mut server = server.unwrap();
let mut request = [0; 37];
server.read_exact(&mut request).await.unwrap();
assert_eq!(request[0], 0);
assert_eq!(&request[1..17], &UUID);
assert_eq!(&request[17..], b"\x12\x0a\x10xtls-rprx-vision\x03");
server.session.refresh_traffic_keys().unwrap();
server.flush().await.unwrap();
let mut cx = Context::from_waker(noop_waker_ref());
assert!(
Pin::new(&mut *client)
.poll_read_message(&mut cx, &mut ReadBuf::new(&mut [0; 1024]))
.is_pending()
);
poll_fn(|cx| Pin::new(&mut *client).poll_write_message(cx, b"key-update-datagram"))
.await
.unwrap();
poll_fn(|cx| Pin::new(&mut *client).poll_flush_message(cx))
.await
.unwrap();
let mut record = [0; 8192];
let n = server.read(&mut record).await.unwrap();
assert!(n >= 21, "buffer {size}: first Vision record has {n} bytes");
assert!(n <= size.max(21));
assert_eq!(&record[..16], &UUID);
assert_eq!(record[16], 0);
}
})
.await
.unwrap();
}
struct PendingWriteIo;
impl AsyncRead for PendingWriteIo {
@@ -879,11 +960,12 @@ fn completed_ordinary_connections() -> (TlsConnection, TlsConnection) {
}
#[tokio::test]
async fn ordinary_tls_shutdown_drains_data_and_fatal_alert_after_backpressure() {
async fn ordinary_tls_drain_and_shutdown_preserve_data_and_fatal_alert_after_backpressure() {
use futures::future::poll_fn;
use std::io::Read;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
for fail_server in [false, true] {
for (fail_server, drain_first) in [(false, false), (true, false), (false, true), (true, true)] {
let (mut client, mut server) = ordinary_connections();
for _ in 0..8 {
let mut wire = Vec::new();
@@ -919,6 +1001,9 @@ async fn ordinary_tls_shutdown_drains_data_and_fatal_alert_after_backpressure()
let (shutdown, read) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(
async move {
if drain_first {
poll_fn(|cx| stream.poll_drain_tls(cx)).await.unwrap();
}
let result = stream.shutdown().await;
drop(stream);
result
+1 -1
View File
@@ -63,7 +63,7 @@ pub(crate) fn tls_verification_name(
rustls::pki_types::ServerName::try_from(identity).map_err(std::io::Error::other)
}
fn create_with_hop_context(
pub(crate) fn create_with_hop_context(
client_proxy_config: ClientProxyConfig,
hop_location: &NetLocation,
default_sni_hostname: Option<String>,
+38 -10
View File
@@ -27,6 +27,11 @@ impl TlsClientHandler {
let mut connection =
rustls::ClientConnection::new(self.client_config.clone(), self.server_name.clone())?;
if let Some(size) = self.tls_buffer_size {
// Vision peers require the UUID and first frame header in one TLS record.
let size = match self.handler {
TlsInnerClientHandler::VisionVless { .. } => size.max(21),
TlsInnerClientHandler::Default(_) => size,
};
connection.set_buffer_limit(Some(size));
}
Ok(connection)
@@ -218,17 +223,28 @@ impl TcpClientHandler for TlsClientHandler {
mod tests {
use super::*;
enum IdentityPath {
Tcp,
Udp,
Xhttp,
}
#[tokio::test]
async fn verification_uses_hop_identity_even_without_wire_sni() {
check_hop_identity(false).await;
check_hop_identity(IdentityPath::Tcp).await;
}
#[tokio::test]
async fn udp_verification_uses_hop_identity_even_without_wire_sni() {
check_hop_identity(true).await;
check_hop_identity(IdentityPath::Udp).await;
}
async fn check_hop_identity(udp: bool) {
#[tokio::test]
async fn xhttp_inner_tls_keeps_hop_identity_without_default_sni() {
check_hop_identity(IdentityPath::Xhttp).await;
}
async fn check_hop_identity(path: IdentityPath) {
use crate::address::NetLocation;
use crate::async_stream::{AsyncFlushMessage, AsyncReadMessage, AsyncWriteMessage};
use crate::tcp::tcp_client_handler_factory::create_tcp_client_handler;
@@ -254,6 +270,8 @@ mod tests {
}
}
let udp = matches!(path, IdentityPath::Udp);
let xhttp = matches!(path, IdentityPath::Xhttp);
tokio::time::timeout(std::time::Duration::from_secs(10), async {
for nested in [false, true] {
for (hop, sni, expected, wire_sni) in [
@@ -261,7 +279,7 @@ mod tests {
"localhost:443",
"",
"localhost",
if nested { None } else { Some("localhost") },
if nested || xhttp { None } else { Some("localhost") },
),
("localhost:443", "sni_hostname: null", "localhost", None),
("127.0.0.1:443", "", "127.0.0.1", None),
@@ -322,12 +340,22 @@ mod tests {
yaml
};
let handler = crate::rustls_config_util::with_test_roots(roots, || {
create_tcp_client_handler(
serde_yaml::from_str(&yaml).unwrap(),
&hop,
Arc::new(crate::resolver::NativeResolver::new()),
)
.unwrap()
let protocol: crate::config::ClientProxyConfig =
serde_yaml::from_str(&yaml).unwrap();
let resolver = Arc::new(crate::resolver::NativeResolver::new());
if xhttp {
let config = serde_json::from_value(serde_json::json!({
"address": hop.to_string(),
"protocol": {"type": "xhttp", "protocol": protocol}
}))
.unwrap();
crate::xhttp::connector::build(&config, resolver)
.unwrap()
.unwrap()
.1
} else {
create_tcp_client_handler(protocol, &hop, resolver).unwrap()
}
});
let (io, peer) = tokio::io::duplex(65536);
let server_task = tokio::spawn(async move {
+106
View File
@@ -699,6 +699,112 @@ async fn vision_udp_client_uses_mux_and_exchanges_arbitrary_datagrams() {
.unwrap();
}
#[tokio::test]
async fn vision_udp_small_tls_buffers_keep_the_initial_prefix_in_one_record() {
use crate::async_stream::{
AsyncFlushMessage, AsyncReadSessionMessage, AsyncShutdownMessageExt,
AsyncWriteSessionMessage,
};
use crate::tcp::tcp_client_handler_factory::create_tcp_client_handler;
use crate::xudp::message_stream::XudpMessageStream;
use futures::future::poll_fn;
use tokio::io::AsyncReadExt;
const UUID: [u8; 16] = [0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0];
tokio::time::timeout(std::time::Duration::from_secs(10), async {
for backend in [Backend::Tls12, Backend::Tls13] {
for size in [8, 1, 16, 20, 21, 22, 64, 1024] {
let hop = NetLocation::from_str("localhost:443", None).unwrap();
let target = NetLocation::from_str("192.0.2.1:53", None).unwrap();
let handler = create_tcp_client_handler(
serde_json::from_value(serde_json::json!({
"type": "tls", "verify": false, "vision": true,
"tls_buffer_size": size,
"protocol": {
"type": "vless", "udp_enabled": true,
"user_id": "00000000-0000-4000-8000-000000000000"
}
}))
.unwrap(),
&hop,
Arc::new(crate::resolver::NativeResolver::new()),
)
.unwrap();
let (_, server) = new_connections(backend);
let (left, right) = tokio::io::duplex(65536);
let (client, server) = tokio::join!(
handler.setup_client_udp_bidirectional(Box::new(left), target.clone().into()),
CryptoTlsStream::handshake(right, server, TlsReadMode::PreserveRecords, &[]),
);
let mut client = client.unwrap();
let mut server = server.unwrap();
let mut request = [0; 37];
server.read_exact(&mut request).await.unwrap();
assert_eq!(request[0], 0);
assert_eq!(&request[1..17], &UUID);
assert_eq!(&request[17..], b"\x12\x0a\x10xtls-rprx-vision\x03");
let payload = b"small-buffer-datagram";
poll_fn(|cx| Pin::new(&mut *client).poll_write_message(cx, payload))
.await
.unwrap();
poll_fn(|cx| Pin::new(&mut *client).poll_flush_message(cx))
.await
.unwrap();
let mut first_record = [0; 8192];
let n = server.read(&mut first_record).await.unwrap();
assert!(
n >= 21,
"{backend:?}, buffer {size}: first record has {n} bytes"
);
assert!(n <= size.max(21));
assert_eq!(&first_record[..16], &UUID);
assert_eq!(first_record[16], 0);
let vision =
VisionStream::new_xudp_server(server, UUID, &first_record[..n]).unwrap();
let mut server = XudpMessageStream::new(Box::new(vision));
for exchange in 0..2 {
if exchange > 0 {
poll_fn(|cx| Pin::new(&mut *client).poll_write_message(cx, payload))
.await
.unwrap();
poll_fn(|cx| Pin::new(&mut *client).poll_flush_message(cx))
.await
.unwrap();
}
let mut storage = [0; 1024];
let mut buf = ReadBuf::new(&mut storage);
let (id, received_target) =
poll_fn(|cx| Pin::new(&mut server).poll_read_session_message(cx, &mut buf))
.await
.unwrap();
assert_eq!(received_target, target);
assert_eq!(buf.filled(), payload);
poll_fn(|cx| {
Pin::new(&mut server).poll_write_session_message(cx, id, payload, &target)
})
.await
.unwrap();
poll_fn(|cx| Pin::new(&mut server).poll_flush_message(cx))
.await
.unwrap();
buf.clear();
poll_fn(|cx| Pin::new(&mut *client).poll_read_message(cx, &mut buf))
.await
.unwrap();
assert_eq!(buf.filled(), payload);
}
server.shutdown_message().await.unwrap();
}
}
})
.await
.unwrap();
}
#[tokio::test]
async fn vision_target_auth_fallback_preserves_all_plaintext() {
use crate::client_proxy_selector::ClientProxySelector;
+3 -1
View File
@@ -264,9 +264,11 @@ pub(crate) fn build_with_limits(
"XHTTP split modes require at least two request lanes",
));
}
let inner = crate::tcp::tcp_client_handler_factory::create_tcp_client_handler(
// Inner protocols keep the hop identity for verification, not default wire SNI.
let inner = crate::tcp::tcp_client_handler_factory::create_with_hop_context(
*xhttp.protocol.clone(),
&config.address,
None,
resolver,
)?;
let client = Client::new(
+1 -1
View File
@@ -108,7 +108,7 @@ selection. The 1.14 releases require authentication padding to fit in their firs
read and cannot handle this fixture's 40,000-byte auth padding. Other sing-box
peer tests retain their existing pins.
The SS2022 identity tests use Xray 26.10.29, shadowsocks-rust `ssserver` 1.25.0,
The SS2022 identity tests use Xray 26.10.31, shadowsocks-rust `ssserver` 1.25.0,
and sing-box 1.14.1 with multi-user Shadowsocks and SagerNet UoT. They cover
both AES key sizes, concurrent users, ordered relays, wrong/missing/reordered
identity rejection, large bidirectional transfers, half-close, server-first data,
+1 -1
View File
@@ -3,7 +3,7 @@
The `xhttp_interop` integration target runs real Shoes processes against Shoes,
Xray-core, and Mihomo Alpha. CI builds and caches these peers from pinned source:
- Xray-core: `0afec66085a85191e9dd2a4a6646c7c9a94688d1` (26.10.29).
- Xray-core: `a4a3dff5b20c2faae567b742c172c7b95e75c083` (26.10.31).
- Mihomo Alpha: `94b13aed49b8dc41973e4bb3f721c5fa2c5c5c1f`.
Use Go 1.27.3, `GOMAXPROCS=1`, and `go build -p 1`. Build Xray's `./main` package.
+4 -3
View File
@@ -27,7 +27,7 @@ async fn exercise(xray: bool) -> Result<(), Box<dyn std::error::Error>> {
let private = [42; 32];
let public = aws_lc_rs::agreement::PrivateKey::from_private_key(&aws_lc_rs::agreement::X25519, &private)
.unwrap().compute_public_key().unwrap();
for reality in [false, true] {
for (reality, buffer_size) in [(false, None), (false, Some(8)), (true, None)] {
let mut ports = PortHelper::new();
let (_, peer_port) = ports.get_localhost_listener_port();
let (_, shoes_port) = ports.get_localhost_listener_port();
@@ -37,7 +37,8 @@ async fn exercise(xray: bool) -> Result<(), Box<dyn std::error::Error>> {
json!({"type": "reality", "public_key": URL_SAFE_NO_PAD.encode(public.as_ref()),
"short_id": "0123456789abcdef", "sni_hostname": "test.local", "vision": true, "protocol": inner})
} else {
json!({"type": "tls", "verify": false, "sni_hostname": "test.local", "vision": true, "protocol": inner})
json!({"type": "tls", "verify": false, "sni_hostname": "test.local",
"tls_buffer_size": buffer_size, "vision": true, "protocol": inner})
};
let mut shoes = json!([{
"address": format!("0.0.0.0:{shoes_port}"),
@@ -95,7 +96,7 @@ async fn exercise(xray: bool) -> Result<(), Box<dyn std::error::Error>> {
let mut data = data.clone();
data.extend_from_slice(b"\x16\x03\x03");
assert_eq!(client.recv_packet(Duration::from_secs(5)).await?, data,
"xray={xray}, reality={reality}");
"xray={xray}, reality={reality}, tls_buffer_size={buffer_size:?}");
}
super::tls_hybrid::echo_through_http(http_port, tcp_echo.local_addr().port()).await?;
}
@@ -29,7 +29,7 @@ fn singbox_server(port: u16) -> Value {
}
#[tokio::test]
#[ignore = "Requires Xray 26.10.29; set SHOES_TEST_XRAY_BIN"]
#[ignore = "Requires Xray 26.10.31; set SHOES_TEST_XRAY_BIN"]
async fn wireguard_xray_both_roles_tcp_udp_ipv4_ipv6() -> io::Result<()> {
tokio::time::timeout(Duration::from_secs(90), async {
for shoes_is_server in [false, true] {
+2 -2
View File
@@ -38,8 +38,8 @@ GOMAXPROCS=1 go run -p 1 /path/to/shoes/tests/fixtures/vless_encryption/generate
```
The peer suite uses `XRAY_BINARY`, `MIHOMO_BINARY`, `SHOES_TEST_SING_BOX_BIN`, and
optionally `SHOES_TEST_SHOES_BIN`. CI pins Xray 26.10.29
(`0afec66085a85191e9dd2a4a6646c7c9a94688d1`), including its Vision TLS-buffer
optionally `SHOES_TEST_SHOES_BIN`. CI pins Xray 26.10.31
(`a4a3dff5b20c2faae567b742c172c7b95e75c083`), including its Vision TLS-buffer
compatibility fix. The committed vectors retain their original provenance above.
Missing requested peers fail rather than skip. The wire tap distinguishes fresh
from resumed handshakes and checks raw DIRECT lengths. Successful transfers plus
+1 -1
View File
@@ -11,7 +11,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::process::ProcessGuard;
pub const VERSION: &str = "26.10.29";
pub const VERSION: &str = "26.10.31";
pub struct Peer {
_process: ProcessGuard,