checkpoint: implement wgcloak v1 foundations

This commit is contained in:
h3cx committed 2026-09-14 22:55:07 +02:00
commit 313ab00765
45 files changed
+12058

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
#[path = "../src/control.rs"]
mod control;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use tokio::time::{sleep, Duration};
#[tokio::test]
async fn control_commands_and_socket_permissions() {
let dir = tempdir().unwrap();
let path = dir.path().join("control.sock");
let state = control::ControlState::default();
state.set_status("ready").await;
state.set_stats_json("{\"packets\":3}").await;
let server = control::ControlServer::bind(&path, state.clone())
.await
.unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
let task = tokio::spawn(server.run());
sleep(Duration::from_millis(10)).await;
assert!(control::send_command(&path, "status")
.await
.unwrap()
.contains("ready"));
assert!(control::send_command(&path, "stats")
.await
.unwrap()
.contains("packets"));
assert!(control::send_command(&path, "reconnect")
.await
.unwrap()
.contains("true"));
assert!(control::send_command(&path, "reload")
.await
.unwrap()
.contains("true"));
assert!(control::send_command(&path, "shutdown")
.await
.unwrap()
.contains("true"));
task.await.unwrap().unwrap();
}
+295
View File
@@ -0,0 +1,295 @@
#[path = "../src/config.rs"]
mod config;
#[path = "../src/datagram.rs"]
mod datagram;
#[path = "../src/protocol/mod.rs"]
mod protocol;
#[path = "../src/stats.rs"]
mod stats;
#[path = "../src/transport/mod.rs"]
mod transport;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine;
use bytes::Bytes;
use config::Config;
use tokio::{net::UdpSocket, time::timeout};
use transport::{
direct_udp::{DirectUdpServer, DirectUdpSession},
TransportError, TransportSession,
};
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
fn configs(port: u16, backend: u16) -> (config::ServerConfig, config::ClientConfig) {
let server = Config::from_str(&format!(
"[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:{backend}\"\n[transports.direct_udp]\nport = {port}\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n"
)).unwrap().into_server().unwrap();
let client = Config::from_str(&format!(
"[client]\nserver = \"127.0.0.1\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport.direct_udp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n"
)).unwrap().into_client().unwrap();
(server, client)
}
fn multi_configs(
port: u16,
backend: u16,
) -> (
config::ServerConfig,
config::ClientConfig,
config::ClientConfig,
) {
let server = Config::from_str(&format!(
"[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:{backend}\"\n[transports.direct_udp]\nport = {port}\n[[auth.clients]]\nid = \"one\"\nsecret = \"{SECRET}\"\n[[auth.clients]]\nid = \"two\"\nsecret = \"{SECRET}\"\n"
)).unwrap().into_server().unwrap();
let client = |id: &str| {
Config::from_str(&format!(
"[client]\nserver = \"127.0.0.1\"\nid = \"{id}\"\nsecret = \"{SECRET}\"\n[transport.direct_udp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n"
)).unwrap().into_client().unwrap()
};
(server, client("one"), client("two"))
}
#[tokio::test]
async fn direct_udp_round_trip_and_auth_rejection() {
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let backend_addr = backend.local_addr().unwrap();
let port = 45781;
let (server_config, client_config) = configs(port, backend_addr.port());
let server = DirectUdpServer::bind(server_config).await.unwrap();
let stats = server.stats();
let server_task = tokio::spawn(server.run());
let backend_task = tokio::spawn(async move {
let mut buf = [0u8; 2048];
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
backend.send_to(&buf[..len], peer).await.unwrap();
});
let session = DirectUdpSession::connect(&client_config).await.unwrap();
session
.send_datagram(Bytes::from_static(b"opaque"))
.await
.unwrap();
assert_eq!(
timeout(Duration::from_secs(2), session.recv_datagram())
.await
.unwrap()
.unwrap(),
Bytes::from_static(b"opaque")
);
let mut wrong = client_config.clone();
wrong.client.secret.secret =
Some("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned());
let result = DirectUdpSession::connect(&wrong).await;
assert!(matches!(
result,
Err(TransportError::AuthenticationRejected(_))
));
session.close().await.unwrap();
backend_task.await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
assert_eq!(stats.snapshot().expired_sessions, 0);
server_task.abort();
}
#[tokio::test]
async fn direct_udp_enforces_payload_limit_and_expires_idle_session() {
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let backend_addr = backend.local_addr().unwrap();
let (mut server_config, client_config) = configs(45782, backend_addr.port());
server_config.session.idle_timeout_secs = 1;
let server = DirectUdpServer::bind(server_config).await.unwrap();
let stats = server.stats();
let server_task = tokio::spawn(server.run());
let session = DirectUdpSession::connect(&client_config).await.unwrap();
assert!(matches!(
session.send_datagram(Bytes::from(vec![0u8; 1201])).await,
Err(TransportError::Oversized { maximum: 1200 })
));
tokio::time::sleep(Duration::from_secs(3)).await;
assert_eq!(stats.snapshot().expired_sessions, 1);
session.close().await.unwrap();
server_task.abort();
}
#[tokio::test]
async fn concurrent_clients_use_independent_backend_sockets() {
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let backend_addr = backend.local_addr().unwrap();
let (server_config, mut client_one, mut client_two) = multi_configs(45783, backend_addr.port());
let server = DirectUdpServer::bind(server_config).await.unwrap();
let stats = server.stats();
let server_task = tokio::spawn(server.run());
client_one.transport.direct_udp.port = 45783;
client_two.transport.direct_udp.port = 45783;
let backend_task = tokio::spawn(async move {
let mut peers = Vec::new();
let mut buf = [0u8; 2048];
for _ in 0..2 {
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
peers.push(peer);
backend.send_to(&buf[..len], peer).await.unwrap();
}
peers
});
let first = DirectUdpSession::connect(&client_one).await.unwrap();
let second = DirectUdpSession::connect(&client_two).await.unwrap();
assert!(stats.snapshot().handshake_attempts >= 2);
first
.send_datagram(Bytes::from_static(b"first"))
.await
.unwrap();
second
.send_datagram(Bytes::from_static(b"second"))
.await
.unwrap();
assert_eq!(
timeout(Duration::from_secs(2), first.recv_datagram())
.await
.unwrap()
.unwrap(),
Bytes::from_static(b"first")
);
assert_eq!(
timeout(Duration::from_secs(2), second.recv_datagram())
.await
.unwrap()
.unwrap(),
Bytes::from_static(b"second")
);
let peers = backend_task.await.unwrap();
assert_ne!(
peers[0], peers[1],
"each authenticated client must have its own backend socket"
);
first.close().await.unwrap();
second.close().await.unwrap();
server_task.abort();
}
#[tokio::test]
async fn malformed_and_unauthenticated_packets_do_not_kill_server() {
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let backend_addr = backend.local_addr().unwrap();
let (server_config, client_config) = configs(45784, backend_addr.port());
let server = DirectUdpServer::bind(server_config).await.unwrap();
let stats = server.stats();
let server_task = tokio::spawn(server.run());
let raw = UdpSocket::bind("127.0.0.1:0").await.unwrap();
raw.send_to(&[1, 2, 3], "127.0.0.1:45784").await.unwrap();
raw.send_to(&[0u8; 64], "127.0.0.1:45784").await.unwrap();
let mut wrong = client_config.clone();
wrong.client.secret.secret = Some("B".repeat(64));
assert!(matches!(
DirectUdpSession::connect(&wrong).await,
Err(TransportError::AuthenticationRejected(_))
));
let backend_task = tokio::spawn(async move {
let mut buf = [0u8; 2048];
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
backend.send_to(&buf[..len], peer).await.unwrap();
});
let session = DirectUdpSession::connect(&client_config).await.unwrap();
session
.send_datagram(Bytes::from_static(b"still-alive"))
.await
.unwrap();
assert_eq!(
timeout(Duration::from_secs(2), session.recv_datagram())
.await
.unwrap()
.unwrap(),
Bytes::from_static(b"still-alive")
);
assert!(stats.snapshot().datagrams_dropped >= 3);
session.close().await.unwrap();
backend_task.await.unwrap();
server_task.abort();
}
#[tokio::test]
async fn authenticated_source_port_change_updates_return_peer() {
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let backend_addr = backend.local_addr().unwrap();
let (server_config, _) = configs(45785, backend_addr.port());
let server = DirectUdpServer::bind(server_config).await.unwrap();
let server_task = tokio::spawn(server.run());
let first = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let second = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let secret = base64::engine::general_purpose::STANDARD
.decode(SECRET)
.unwrap();
let mut nonce = [0u8; 16];
nonce[0] = 1;
let hello = protocol::HelloPacket::authenticated(
&secret,
Bytes::from_static(b"test"),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
nonce,
)
.unwrap();
first
.send_to(&hello.encode().unwrap(), "127.0.0.1:45785")
.await
.unwrap();
let mut wire = [0u8; 2048];
let (len, _) = timeout(Duration::from_secs(2), first.recv_from(&mut wire))
.await
.unwrap()
.unwrap();
let accept = match protocol::parse_packet(&wire[..len]).unwrap() {
protocol::Packet::Accept(packet) => packet,
other => panic!("unexpected handshake response: {other:?}"),
};
let key =
protocol::derive_session_key(&secret, &nonce, &accept.server_nonce, accept.session_id);
let data = protocol::DataPacket::authenticated(
&key,
accept.session_id,
1,
Bytes::from_static(b"rebound"),
);
second
.send_to(&data.encode().unwrap(), "127.0.0.1:45785")
.await
.unwrap();
let backend_task = tokio::spawn(async move {
let mut buf = [0u8; 2048];
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
backend.send_to(&buf[..len], peer).await.unwrap();
});
let (len, _) = timeout(Duration::from_secs(2), second.recv_from(&mut wire))
.await
.unwrap()
.unwrap();
match protocol::parse_packet(&wire[..len]).unwrap() {
protocol::Packet::Data(packet) => {
assert_eq!(packet.payload, Bytes::from_static(b"rebound"))
}
other => panic!("unexpected rebound response: {other:?}"),
}
backend_task.await.unwrap();
server_task.abort();
}
#[tokio::test]
async fn unavailable_backend_does_not_terminate_listener() {
let (server_config, client_config) = configs(45786, 9);
let server = DirectUdpServer::bind(server_config).await.unwrap();
let server_task = tokio::spawn(server.run());
let session = DirectUdpSession::connect(&client_config).await.unwrap();
session
.send_datagram(Bytes::from_static(b"backend-may-be-down"))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
!server_task.is_finished(),
"backend failure must not terminate the public listener"
);
session.close().await.unwrap();
server_task.abort();
}
+96
View File
@@ -0,0 +1,96 @@
#[path = "../src/transport/http3.rs"]
mod http3;
use std::collections::HashMap;
use http3::{
decode_capsule, decode_http_datagram, encode_auth_challenge, encode_auth_response,
encode_capsule, encode_http_datagram, validate_connect_udp_request, AuthChallenge,
ConnectUdpRequest, Http3Error, CONNECT_UDP_CONTEXT_ID,
};
#[test]
fn validates_extended_connect_request() {
validate_connect_udp_request("CONNECT", "connect-udp", "/tunnel", "/tunnel").unwrap();
ConnectUdpRequest::new("/tunnel")
.validate("/tunnel")
.unwrap();
assert!(matches!(
validate_connect_udp_request("POST", "connect-udp", "/tunnel", "/tunnel"),
Err(Http3Error::InvalidRequest("method must be CONNECT"))
));
assert!(matches!(
validate_connect_udp_request("CONNECT", "webtransport", "/tunnel", "/tunnel"),
Err(Http3Error::InvalidRequest(":protocol must be connect-udp"))
));
assert!(validate_connect_udp_request("CONNECT", "connect-udp", "/other", "/tunnel").is_err());
assert!(validate_connect_udp_request("CONNECT", "connect-udp", "tunnel", "tunnel").is_err());
}
#[test]
fn http_datagram_uses_context_id_and_preserves_opaque_payload() {
for context in [0, 63, 64, 16_383, 16_384, (1 << 30) - 1, 1 << 30] {
let encoded = encode_http_datagram(context, b"opaque", 1200).unwrap();
assert_eq!(
decode_http_datagram(&encoded, context, 1200).unwrap(),
b"opaque"[..]
);
}
assert!(matches!(
decode_http_datagram(
&encode_http_datagram(7, b"x", 1200).unwrap(),
CONNECT_UDP_CONTEXT_ID,
1200
),
Err(Http3Error::ContextMismatch { .. })
));
assert!(matches!(
encode_http_datagram(CONNECT_UDP_CONTEXT_ID, &[0; 1201], 1200),
Err(Http3Error::Oversized {
length: 1201,
maximum: 1200
})
));
}
#[test]
fn capsule_round_trip_and_truncation_are_checked() {
let encoded = encode_capsule(0x2a, b"challenge").unwrap();
assert_eq!(
decode_capsule(&encoded).unwrap(),
(0x2a, bytes::Bytes::from_static(b"challenge"))
);
assert!(matches!(
decode_capsule(&encoded[..encoded.len() - 1]),
Err(Http3Error::Truncated)
));
let mut extra = encoded.to_vec();
extra.push(0);
assert!(matches!(
decode_capsule(&extra),
Err(Http3Error::InvalidCapsule)
));
}
#[test]
fn authentication_is_completed_before_session_activation() {
let challenge = AuthChallenge::random();
let nonce = [5u8; 16];
let secret = [7u8; 32];
let challenge_capsule = encode_auth_challenge(&challenge).unwrap();
assert_eq!(
decode_capsule(&challenge_capsule).unwrap().0,
http3::AUTH_CHALLENGE_CAPSULE
);
let response = encode_auth_response(&challenge, "laptop", &nonce, &secret).unwrap();
let mut clients = HashMap::new();
clients.insert("laptop".to_owned(), secret.to_vec());
let client = http3::verify_auth_response(&challenge, &response, &clients).unwrap();
assert_eq!(client.id, "laptop");
assert_eq!(client.nonce, nonce);
let bad = encode_auth_response(&challenge, "laptop", &nonce, &[8u8; 32]).unwrap();
assert!(http3::verify_auth_response(&challenge, &bad, &clients).is_err());
assert!(http3::is_auth_accept(&http3::encode_auth_accept().unwrap()).unwrap());
assert!(!http3::is_auth_accept(&http3::encode_auth_reject().unwrap()).unwrap());
}
+102
View File
@@ -0,0 +1,102 @@
#[path = "../src/config.rs"]
mod config;
#[path = "../src/datagram.rs"]
mod datagram;
#[path = "../src/protocol/mod.rs"]
mod protocol;
#[path = "../src/stats.rs"]
mod stats;
#[path = "../src/transport/mod.rs"]
mod transport;
use std::{process::Command, sync::Arc, time::Duration};
use bytes::Bytes;
use config::Config;
use rustls::RootCertStore;
use tempfile::tempdir;
use tokio::time::timeout;
use transport::{TlsTcpConnector, TlsTcpServer, TransportConnector, TransportSession};
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
#[tokio::test]
async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
if Command::new("openssl").arg("version").output().is_err() {
return;
}
let dir = tempdir().unwrap();
let cert = dir.path().join("cert.pem");
let key = dir.path().join("key.pem");
let generated = Command::new("openssl")
.args([
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-subj",
"/CN=localhost",
"-days",
"1",
"-keyout",
])
.arg(&key)
.args(["-out"])
.arg(&cert)
.output()
.unwrap();
assert!(
generated.status.success(),
"openssl failed: {:?}",
generated
);
let port = free_port();
let server_config = Config::from_str(&format!(
"[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:51820\"\n[transports.direct_udp]\nenabled = false\n[transports.quic]\nenabled = false\n[transports.http3]\nenabled = false\n[transports.tls_tcp]\nenabled = true\nport = {port}\n[tls]\ncertificate = \"{}\"\nprivate_key = \"{}\"\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n",
cert.display(),
key.display()
))
.unwrap()
.into_server()
.unwrap();
let client_config = Config::from_str(&format!(
"[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport]\nmode = \"tls_tcp\"\n[transport.tls_tcp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n"
))
.unwrap()
.into_client()
.unwrap();
let certs = transport::tls_tcp::load_certificates(&cert).unwrap();
let mut roots = RootCertStore::empty();
roots.add(certs[0].clone()).unwrap();
let client_tls = (*transport::tls_tcp::client_tls_config(roots)).clone();
let server = Arc::new(TlsTcpServer::bind(&server_config).await.unwrap());
let accept = {
let server = server.clone();
tokio::spawn(async move { server.accept_session().await })
};
let client = TlsTcpConnector::with_client_config(client_config, client_tls);
let client_session = client.connect().await.unwrap();
let server_session = accept.await.unwrap().unwrap();
client_session
.send_datagram(Bytes::from_static(b"tls-opaque"))
.await
.unwrap();
assert_eq!(
timeout(Duration::from_secs(2), server_session.recv_datagram())
.await
.unwrap()
.unwrap(),
Bytes::from_static(b"tls-opaque")
);
client_session.close().await.unwrap();
server_session.close().await.unwrap();
}
+148
View File
@@ -0,0 +1,148 @@
#[path = "../src/config.rs"]
mod config;
#[path = "../src/datagram.rs"]
mod datagram;
#[path = "../src/protocol/mod.rs"]
mod protocol;
#[path = "../src/stats.rs"]
mod stats;
#[path = "../src/transport/mod.rs"]
mod transport;
use std::{collections::HashMap, sync::Arc, time::Duration};
use base64::Engine;
use bytes::Bytes;
use quinn::{ClientConfig as QuinnClientConfig, Endpoint, ServerConfig as QuinnServerConfig};
use rustls::pki_types::{CertificateDer, PrivatePkcs8KeyDer};
use tokio::time::timeout;
use transport::{
quic::{QuicClientConnector, QuicServer},
TransportError, TransportSession,
};
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
// A test-only localhost certificate, valid for ten years. Keeping the DER
// here avoids adding a certificate-generation crate to the application.
const CERT_DER_B64: &str = "MIIDHDCCAgSgAwIBAgIUJnI/VlHMWu6j0dXr/t1OmMQrLgowDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkxNDIwNDEyMFoXDTM2MDkxMTIwNDEyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4d4r5Py4GdfQrLit2oytLv20L+y7oM/VTDzzJfFMPmewOh0XyS0BI4lyRbCgOuUh686UPvF/BU8uJq24OO7wt/2CrE3FfwYX7BDjIWMuFevHuv896imabbX4IJ7lzVugI3862QSW09QSEQk3ggYs3z/F16cnSRZm9aIURgdPQsLmMzOwcPtDGwxp2jf0Rky0kFRxhUnqfIWko4hny7GjWL8VG0WzYJY6aUevjPlN/Oq/akr9xUAr2QJenEesP77do4X31LIZmsvB6GqKwaSQ0AbwNHW7rDiaE4dkZMIk5l95YgYnrzyOC8IhUwaSZt0wODxk/euXPYr+RFJv2FuOQIDAQABo2YwZDAdBgNVHQ4EFgQUhViBEXLkp7YUVZaWzLi2SfRmfmYwHwYDVR0jBBgwFoAUhViBEXLkp7YUVZaWzLi2SfRmfmYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBAEFC7da0bK78IUL0k6lZ82pqFImX2bNteMh/r1IJG0xqB/O2dOLUDHybpF7HToommALgRu6zhlT0d1eHg0cMYEgrKyq4BAAXGHqOt3OdKKL5nAWmI04dlWsrG9aTU3hTQhe0++hnTbVm+YJnYAp+ufOxx858FeothjLWu1Lr81Nm01FnmZbXcFno3nosyl1GPxSRVZl2XH9NsfMSZJUbSU6mHXTO1pEH6mjYmgD362CeJc+0DOzevdLsDozxUoWmdEuqlldauESW6XVK0WxRSJ1i2/B/mtYViHr5R/j5NYgG5STnNnwaNwjYK+0AhxvTtH3jAp0Bsluw4d+xKdsV/YI=";
const KEY_DER_B64: &str = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCbh3ivk/LgZ19CsuK3ajK0u/bQv7Lugz9VMPPMl8Uw+Z7A6HRfJLQEjiXJFsKA65SHrzpQ+8X8FTy4mrbg47vC3/YKsTcV/BhfsEOMhYy4V68e6/z3qKZpttfggnuXNW6AjfzrZBJbT1BIRCTeCBizfP8XXpydJFmb1ohRGB09CwuYzM7Bw+0MbDGnaN/RGTLSQVHGFSep8haSjiGfLsaNYvxUbRbNgljppR6+M+U386r9qSv3FQCvZAl6cR6w/vt2jhffUshmay8HoaorBpJDQBvA0dbusOJoTh2RkwiTmX3liBievPI4LwiFTBpJm3TA4PGT965c9iv5EUm/YW45AgMBAAECggEAMs0EtCD4t6i8Ol6Q5EUZa+RrgZLkILkZEjwLnjvCqrUWUD99Z2hnLoe5ut2myZJpcQpg23vpluXe8BOFAZ8RQN2DkcmByzoNQhAieD9VEA+AAXMmrURWmvggulIUiXbn8bCvOLn2rwGQoRI1EpsFanc9IUaDlGZ/qIHiA88DGeFDuchbcgClpXAX4lBzqhLKv/CCIhhGxmyWc1RPlZUef7uTO1Zh7q2HxnO5iBKobvPfMq5VfvzE4rXeSQIHtW+yJFLU2sNjaYry2JTLEqGIZhWMROmzlkW8x/cj0GvjLbY4ltkIpLNSb+WRbY3zZNhQ81kosBZtiYRj3vgr/FzvSwKBgQDQ/dxghryGDqIIOfyZ59+2tpPrrLX4vy7tmAQg6zJIiQVHMZ/Av60QvY7lA0dvDfQmfJuNzTOXtwhgnYkHbg6jlFqpT7P1CZQOzbF0UuFCNvwzE/jgCEKUExckDa+Du7g/VOglB9B4XKqJnQLpZkzz4Hwmuvz4oUtZJCl796HTMwKBgQC+gyPKrNp6AthxZ1gAu+aIjf18vgGZjANQx3cRFQl5BqSyTwZpxIfuiKrT+sU65N2VCWpGH6oTzGruaXBXzG0rlNCMAL5XaJqJehvLAnejUqmXhyi8ouH174Q4azDngPOCFaSjuYJwpX4nUXwMExqs9hwkH9pV3tn6qzEpKJA44wKBgBa+SalHgGSmWwPCK7ckALiFjftrxDHZvGWm0WwzrDkwkB57TANR2KeluVM61B9FyXgWv2bjGeY8yPCatDrWw0kR7/vU7KSR8kbaI6GhE1p9WVzFo6PdwkETAsWtp5gSaa6asti0zP8MNpLbP2He/CjeGSSt3LHB+80rnkgbXsT5AoGAVaTytCxc0zjwQJrqzvmpu33JlQh+A58T5n6lIkLIzWWVKKuqAGZfjXZXBZDYlY9JpbRj2D5n4Rqmf6Na47183ni6ABZde0IFJwSD9hGX4w5IO1yBjbvTxRH16L8XbxtjXPmJ2CiPYUCko9xX3b3A4jyJjhvbtXrHl47aPNyFaW0CgYEAxDOcW5r5pXXkCF/mIqSklGa1jDO9TeMavLo1AguHaUlJEQU8DgGM9Flx3senFASN2EjwwTB3aScVuxe3X29LeZnCNsuTIzCVGpSNyFLUjZVU0fRaf6R9JIcI7dVoflPAPCdrqBVkezK5Eyu8f3NCwnVD+zv+iWA+RE1zUyKy4sM=";
fn endpoint_and_client(
max_datagram: usize,
) -> (Endpoint, QuinnClientConfig, CertificateDer<'static>) {
let cert_bytes = base64::engine::general_purpose::STANDARD
.decode(CERT_DER_B64)
.unwrap();
let key_bytes = base64::engine::general_purpose::STANDARD
.decode(KEY_DER_B64)
.unwrap();
let cert = CertificateDer::from(cert_bytes);
let key = PrivatePkcs8KeyDer::from(key_bytes);
let mut server_config =
QuinnServerConfig::with_single_cert(vec![cert.clone()], key.into()).unwrap();
let mut transport_config = quinn::TransportConfig::default();
transport_config.keep_alive_interval(Some(Duration::from_secs(1)));
transport_config.datagram_send_buffer_size(max_datagram * 8);
server_config.transport_config(Arc::new(transport_config));
let endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
let mut roots = rustls::RootCertStore::empty();
roots.add(cert.clone()).unwrap();
let client = QuinnClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
(endpoint, client, cert)
}
fn client_config(port: u16, secret: &str) -> config::ClientConfig {
config::Config::from_str(&format!(
"[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{secret}\"\n[transport.quic]\nport = {port}\n[transport.auto]\nprobe_timeout_ms = 2000\n[network]\nmax_datagram_size = 1200\n"
))
.unwrap()
.into_client()
.unwrap()
}
#[tokio::test]
async fn authenticated_datagram_round_trip_and_limit() {
let (endpoint, client_tls, _cert) = endpoint_and_client(1200);
let mut clients = HashMap::new();
clients.insert(
"test".to_owned(),
base64::engine::general_purpose::STANDARD
.decode(SECRET)
.unwrap(),
);
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
let port = server.local_addr().unwrap().port();
let client = QuicClientConnector::with_client_config(client_config(port, SECRET), client_tls);
let server_task = tokio::spawn(async move { server.accept().await });
let client_session = client.connect_session().await.unwrap();
let server_session = server_task.await.unwrap().unwrap();
client_session
.send_datagram(Bytes::from_static(b"opaque"))
.await
.unwrap();
assert_eq!(
timeout(Duration::from_secs(2), server_session.recv_datagram())
.await
.unwrap()
.unwrap(),
Bytes::from_static(b"opaque")
);
assert!(matches!(
client_session
.send_datagram(Bytes::from(vec![0; 1201]))
.await,
Err(TransportError::Oversized { maximum: 1200 })
));
client_session.close().await.unwrap();
server_session.close().await.unwrap();
}
#[tokio::test]
async fn bad_secret_is_rejected_as_authentication_error() {
let (endpoint, client_tls, _cert) = endpoint_and_client(1200);
let mut clients = HashMap::new();
clients.insert(
"test".to_owned(),
base64::engine::general_purpose::STANDARD
.decode(SECRET)
.unwrap(),
);
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
let port = server.local_addr().unwrap().port();
let wrong = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
let client = QuicClientConnector::with_client_config(client_config(port, wrong), client_tls);
let server_task = tokio::spawn(async move { server.accept().await });
let result = client.connect_session().await;
assert!(matches!(
result,
Err(TransportError::AuthenticationRejected(_))
));
assert!(matches!(
server_task.await.unwrap(),
Err(TransportError::AuthenticationRejected(_))
));
}
#[tokio::test]
async fn certificate_hostname_mismatch_is_not_accepted() {
let (endpoint, client_tls, _cert) = endpoint_and_client(1200);
let mut clients = HashMap::new();
clients.insert(
"test".to_owned(),
base64::engine::general_purpose::STANDARD
.decode(SECRET)
.unwrap(),
);
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
let port = server.local_addr().unwrap().port();
let server_task = tokio::spawn(async move { server.accept().await });
// The connector's configured hostname is localhost, which matches. This
// test changes it to an unrelated name while retaining the localhost root.
let mut mismatch = client_config(port, SECRET);
mismatch.client.server = "not-localhost".to_owned();
let mismatch = QuicClientConnector::with_client_config(mismatch, client_tls);
let result = mismatch.connect_session().await;
assert!(result.is_err());
server_task.abort();
}
+166
View File
@@ -0,0 +1,166 @@
#[path = "../src/config.rs"]
mod config;
#[path = "../src/datagram.rs"]
mod datagram;
#[path = "../src/protocol/mod.rs"]
mod protocol;
#[path = "../src/stats.rs"]
mod stats;
#[path = "../src/client/supervisor.rs"]
mod supervisor;
#[path = "../src/transport/mod.rs"]
mod transport;
use bytes::Bytes;
use config::{Config, TransportMode};
use std::{
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::Duration,
};
use supervisor::{ProbeFuture, TransportProbe, TransportSupervisor};
use tokio::{sync::Mutex, time::timeout};
use transport::{TransportError, TransportFuture, TransportSession};
struct Session;
impl TransportSession for Session {
fn send_datagram<'a>(&'a self, _: Bytes) -> TransportFuture<'a, ()> {
Box::pin(async { Ok(()) })
}
fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> {
Box::pin(async { Err(TransportError::Closed) })
}
fn close<'a>(&'a self) -> TransportFuture<'a, ()> {
Box::pin(async { Ok(()) })
}
fn max_datagram_size(&self) -> usize {
1200
}
}
struct Probe {
calls: Arc<Mutex<Vec<TransportMode>>>,
auth: bool,
}
impl TransportProbe for Probe {
fn connect(&self, mode: TransportMode) -> ProbeFuture {
let calls = self.calls.clone();
let auth = self.auth;
Box::pin(async move {
calls.lock().await.push(mode);
if auth {
Err(TransportError::AuthenticationRejected(
protocol::RejectReason::AuthenticationFailed,
))
} else {
Err(TransportError::HandshakeTimeout)
}
})
}
}
fn config(mode: &str) -> config::ClientConfig {
Config::from_str(&format!("[client]\nserver=\"127.0.0.1\"\nid=\"c\"\nsecret=\"{}\"\n[transport]\nmode=\"{}\"\n[transport.auto]\norder=[\"direct_udp\",\"quic\"]\nretries=2\nprobe_timeout_ms=20\n", "A".repeat(64), mode)).unwrap().into_client().unwrap()
}
#[tokio::test]
async fn selection_order_retries_and_auth_stop() {
let calls = Arc::new(Mutex::new(Vec::new()));
let supervisor = TransportSupervisor::new(
config("auto"),
Probe {
calls: calls.clone(),
auth: false,
},
);
assert!(matches!(
supervisor.connect_once().await,
Err(TransportError::HandshakeTimeout)
));
assert_eq!(
*calls.lock().await,
vec![
TransportMode::DirectUdp,
TransportMode::DirectUdp,
TransportMode::Quic,
TransportMode::Quic
]
);
let calls = Arc::new(Mutex::new(Vec::new()));
let supervisor = TransportSupervisor::new(
config("auto"),
Probe {
calls: calls.clone(),
auth: true,
},
);
assert!(matches!(
supervisor.connect_once().await,
Err(TransportError::AuthenticationRejected(_))
));
assert_eq!(*calls.lock().await, vec![TransportMode::DirectUdp]);
}
#[test]
fn backoff_is_bounded_and_resettable() {
let mut supervisor = TransportSupervisor::new(
config("direct_udp"),
Probe {
calls: Arc::new(Mutex::new(Vec::new())),
auth: false,
},
);
for _ in 0..30 {
let delay = supervisor.backoff_delay();
assert!((250..=30_000).contains(&delay.as_millis()));
}
supervisor.reset_backoff();
assert_eq!(supervisor.backoff_delay(), Duration::from_millis(250));
}
struct LifecycleProbe {
calls: Arc<AtomicUsize>,
}
impl TransportProbe for LifecycleProbe {
fn connect(&self, _mode: TransportMode) -> ProbeFuture {
let calls = self.calls.clone();
Box::pin(async move {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
Ok(Box::new(Session) as Box<dyn TransportSession>)
} else {
Err(TransportError::HandshakeTimeout)
}
})
}
fn wait_for_session_failure<'a>(
&'a self,
_session: &'a dyn TransportSession,
) -> supervisor::SessionWatchFuture<'a> {
Box::pin(async { Err(TransportError::Closed) })
}
}
#[tokio::test]
async fn established_failure_reprobes_without_reading_datagrams() {
let calls = Arc::new(AtomicUsize::new(0));
let supervisor = TransportSupervisor::new(
config("direct_udp"),
LifecycleProbe {
calls: calls.clone(),
},
);
let shutdown = supervisor.shutdown_token();
let task = tokio::spawn(supervisor.run());
// The failed established session uses the normal minimum reconnect
// backoff before the second probe.
tokio::time::sleep(Duration::from_millis(320)).await;
shutdown.cancel();
let result = timeout(Duration::from_secs(2), task)
.await
.expect("supervisor should stop")
.unwrap();
assert!(result.is_ok());
assert!(calls.load(Ordering::SeqCst) >= 2);
}
+159
View File
@@ -0,0 +1,159 @@
#[path = "../src/transport/tls_tcp.rs"]
mod tls_tcp;
use std::{collections::HashMap, time::Duration};
use tls_tcp::{
client_authenticate, server_authenticate, AuthChallenge, FrameCodec, FrameError, FramedReader,
FramedWriter,
};
use tokio::io::{duplex, split};
#[test]
fn codec_handles_split_and_coalesced_frames() {
let codec = FrameCodec::new(32).expect("valid maximum");
let mut wire = codec.encode(b"first").unwrap();
wire.extend_from_slice(&codec.encode(b"second").unwrap());
let mut decoder = FrameCodec::new(32).unwrap();
assert!(decoder.feed(&wire[..1]).unwrap().is_empty());
assert!(decoder.feed(&wire[1..4]).unwrap().is_empty());
assert_eq!(
decoder.feed(&wire[4..]).unwrap(),
vec![b"first".to_vec(), b"second".to_vec()]
);
decoder.finish().unwrap();
}
#[test]
fn codec_rejects_oversized_before_payload() {
let mut decoder = FrameCodec::new(4).unwrap();
let error = decoder.feed(&[0, 5]).unwrap_err();
assert_eq!(
error,
FrameError::Oversized {
length: 5,
maximum: 4
}
);
assert!(decoder.finish().is_ok());
}
#[test]
fn codec_reports_truncated_header_or_payload() {
let mut decoder = FrameCodec::new(32).unwrap();
decoder.feed(&[0]).unwrap();
assert_eq!(decoder.finish(), Err(FrameError::Truncated));
let mut decoder = FrameCodec::new(32).unwrap();
decoder.feed(&[0, 3, b'x']).unwrap();
assert_eq!(decoder.finish(), Err(FrameError::Truncated));
}
#[test]
fn auth_response_is_bound_to_challenge_and_secret() {
let challenge = AuthChallenge::random();
let nonce = [9u8; 16];
let message = tls_tcp::encode_auth_response(&challenge, "laptop", &nonce, &[7u8; 32]).unwrap();
let mut clients = HashMap::new();
clients.insert("laptop".to_owned(), vec![7u8; 32]);
// Exercise the full asynchronous exchange below; this assertion also
// ensures the generated challenge is not the all-zero sentinel.
assert_ne!(challenge.bytes, [0u8; 32]);
assert_eq!(message[0], 0x11);
assert_eq!(clients["laptop"].len(), 32);
}
#[tokio::test]
async fn tls_stream_authentication_exchange_uses_challenge_response() {
let (left, right) = duplex(4096);
let (mut server_reader, mut server_writer) = split(left);
let (mut client_reader, mut client_writer) = split(right);
let mut clients = HashMap::new();
clients.insert("laptop".to_owned(), vec![7u8; 32]);
let server_clients = clients.clone();
let server = tokio::spawn(async move {
server_authenticate(
&mut server_reader,
&mut server_writer,
&server_clients,
1024,
Duration::from_secs(1),
)
.await
});
let client = tokio::spawn(async move {
client_authenticate(
&mut client_reader,
&mut client_writer,
"laptop",
&[7u8; 32],
1024,
Duration::from_secs(1),
)
.await
});
assert!(client.await.unwrap().is_ok());
let authenticated = server.await.unwrap().unwrap();
assert_eq!(authenticated.id, "laptop");
}
#[tokio::test]
async fn bad_secret_is_rejected_without_authenticating() {
let (left, right) = duplex(4096);
let (mut server_reader, mut server_writer) = split(left);
let (mut client_reader, mut client_writer) = split(right);
let mut clients = HashMap::new();
clients.insert("laptop".to_owned(), vec![7u8; 32]);
let server_clients = clients.clone();
let server = tokio::spawn(async move {
server_authenticate(
&mut server_reader,
&mut server_writer,
&server_clients,
1024,
Duration::from_secs(1),
)
.await
});
let client = tokio::spawn(async move {
client_authenticate(
&mut client_reader,
&mut client_writer,
"laptop",
&[8u8; 32],
1024,
Duration::from_secs(1),
)
.await
});
assert!(matches!(
client.await.unwrap(),
Err(tls_tcp::AuthError::Rejected)
));
assert!(matches!(
server.await.unwrap(),
Err(tls_tcp::AuthError::InvalidCredentials)
));
}
#[tokio::test]
async fn framed_session_preserves_empty_datagrams_and_heartbeats() {
let (left, right) = duplex(4096);
let (mut reader, _unused_writer) = split(left);
let (unused_reader, writer) = split(right);
drop(unused_reader);
let mut writer = FramedWriter::new(writer, 1200);
let mut reader = FramedReader::new(&mut reader, 1200, Some(Duration::from_secs(1)));
// Write frames using the writer half and read them from the other side.
// The opposite halves are intentionally dropped only after construction;
// duplex keeps the active directions independent.
let send = async {
writer.send_keepalive().await.unwrap();
writer.send_datagram(b"").await.unwrap();
writer.send_datagram(b"payload").await.unwrap();
};
tokio::pin!(send);
send.await;
assert_eq!(reader.recv_datagram().await.unwrap(), b"");
assert_eq!(reader.recv_datagram().await.unwrap(), b"payload");
}