checkpoint: integrate authenticated transports
This commit is contained in:
1 parent
313ab00765
commit
586ff8f223
42 files changed
+6277
-771
No files matched your search
+64
-9
@@ -1,26 +1,38 @@
|
||||
#[path = "../src/control.rs"]
|
||||
mod control;
|
||||
use wgcloak::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 runtime_dir = dir.path().join("runtime");
|
||||
let path = runtime_dir.join("control.sock");
|
||||
let state = control::ControlState::default();
|
||||
state.enable_reconnect();
|
||||
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(&runtime_dir)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let task = tokio::spawn(server.run());
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
let second_bind = control::ControlServer::bind(&path, control::ControlState::default()).await;
|
||||
assert!(matches!(
|
||||
second_bind,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse
|
||||
));
|
||||
assert!(control::send_command(&path, "status")
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -33,13 +45,56 @@ async fn control_commands_and_socket_permissions() {
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
assert!(control::send_command(&path, "reload")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
let reload = control::send_command(&path, "reload").await.unwrap();
|
||||
assert!(reload.contains("\"ok\":false"));
|
||||
assert!(reload.contains("not supported"));
|
||||
assert!(control::send_command(&path, "shutdown")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaces_only_a_verified_stale_socket() {
|
||||
let dir = tempdir().unwrap();
|
||||
let private_dir = dir.path().join("runtime");
|
||||
std::fs::create_dir(&private_dir).unwrap();
|
||||
std::fs::set_permissions(&private_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let path = private_dir.join("stale.sock");
|
||||
let stale = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
drop(stale);
|
||||
|
||||
let server = control::ControlServer::bind(&path, control::ControlState::default())
|
||||
.await
|
||||
.expect("verified stale socket should be replaced");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
drop(server);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_to_replace_a_non_socket_path() {
|
||||
let dir = tempdir().unwrap();
|
||||
let private_dir = dir.path().join("runtime");
|
||||
std::fs::create_dir(&private_dir).unwrap();
|
||||
std::fs::set_permissions(&private_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let path = private_dir.join("control.sock");
|
||||
std::fs::write(&path, b"keep this file").unwrap();
|
||||
let result = control::ControlServer::bind(&path, control::ControlState::default()).await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists
|
||||
));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"keep this file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_socket_path_never_targets_the_shared_tmp_socket() {
|
||||
let path = control::default_socket_path();
|
||||
assert_eq!(path.file_name().unwrap(), "wgcloak.sock");
|
||||
assert_ne!(path.parent().unwrap(), std::path::Path::new("/tmp"));
|
||||
}
|
||||
+322
-13
@@ -1,13 +1,4 @@
|
||||
#[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 wgcloak::{config, protocol, transport};
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -16,7 +7,7 @@ use bytes::Bytes;
|
||||
use config::Config;
|
||||
use tokio::{net::UdpSocket, time::timeout};
|
||||
use transport::{
|
||||
direct_udp::{DirectUdpServer, DirectUdpSession},
|
||||
direct_udp::{DirectUdpServer, DirectUdpServerHandle, DirectUdpSession},
|
||||
TransportError, TransportSession,
|
||||
};
|
||||
|
||||
@@ -51,6 +42,19 @@ fn multi_configs(
|
||||
(server, client("one"), client("two"))
|
||||
}
|
||||
|
||||
async fn wait_for_active_sessions(server: &DirectUdpServerHandle, expected: usize) {
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if server.active_sessions().await == expected {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("active session count did not become {expected}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_round_trip_and_auth_rejection() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -66,6 +70,11 @@ async fn direct_udp_round_trip_and_auth_rejection() {
|
||||
backend.send_to(&buf[..len], peer).await.unwrap();
|
||||
});
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
assert_eq!(
|
||||
session.authenticated_keepalive_interval(),
|
||||
Some(Duration::from_secs(15))
|
||||
);
|
||||
session.send_keepalive().await.unwrap();
|
||||
session
|
||||
.send_datagram(Bytes::from_static(b"opaque"))
|
||||
.await
|
||||
@@ -99,19 +108,42 @@ async fn direct_udp_enforces_payload_limit_and_expires_idle_session() {
|
||||
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 observer = server.handle();
|
||||
let stats = observer.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
wait_for_active_sessions(&observer, 1).await;
|
||||
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);
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
session.close().await.unwrap();
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_observer_tracks_authenticated_session_close() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, client_config) = configs(45787, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let observer = server.handle();
|
||||
let stats = observer.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
wait_for_active_sessions(&observer, 1).await;
|
||||
assert_eq!(stats.snapshot().expired_sessions, 0);
|
||||
|
||||
session.close().await.unwrap();
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
assert_eq!(stats.snapshot().expired_sessions, 0);
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_clients_use_independent_backend_sockets() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -178,6 +210,14 @@ async fn malformed_and_unauthenticated_packets_do_not_kill_server() {
|
||||
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 oversized = protocol::DataPacket::new(1, 1, vec![0u8; 1201], [0; 16])
|
||||
.encode()
|
||||
.unwrap();
|
||||
raw.send_to(&oversized, "127.0.0.1:45784").await.unwrap();
|
||||
let unexpected = protocol::RejectPacket::new(protocol::RejectReason::AuthenticationFailed)
|
||||
.encode()
|
||||
.unwrap();
|
||||
raw.send_to(&unexpected, "127.0.0.1:45784").await.unwrap();
|
||||
let mut wrong = client_config.clone();
|
||||
wrong.client.secret.secret = Some("B".repeat(64));
|
||||
assert!(matches!(
|
||||
@@ -201,7 +241,8 @@ async fn malformed_and_unauthenticated_packets_do_not_kill_server() {
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"still-alive")
|
||||
);
|
||||
assert!(stats.snapshot().datagrams_dropped >= 3);
|
||||
assert!(stats.snapshot().datagrams_dropped >= 5);
|
||||
assert!(stats.snapshot().oversized_datagrams >= 1);
|
||||
session.close().await.unwrap();
|
||||
backend_task.await.unwrap();
|
||||
server_task.abort();
|
||||
@@ -275,6 +316,274 @@ async fn authenticated_source_port_change_updates_return_peer() {
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identical_hello_retransmission_recovers_original_accept_and_session() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, _) = configs(45788, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let observer = server.handle();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let other_source = 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] = 44;
|
||||
let hello = protocol::HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::from_static(b"test"),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
nonce,
|
||||
)
|
||||
.unwrap();
|
||||
let hello_bytes = hello.encode().unwrap();
|
||||
client
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = [0u8; 2048];
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let first_accept = match protocol::parse_packet(&response[..len]).unwrap() {
|
||||
protocol::Packet::Accept(accept) => accept,
|
||||
other => panic!("unexpected handshake response: {other:?}"),
|
||||
};
|
||||
|
||||
client
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let retry_accept = match protocol::parse_packet(&response[..len]).unwrap() {
|
||||
protocol::Packet::Accept(accept) => accept,
|
||||
other => panic!("identical HELLO should recover ACCEPT, got {other:?}"),
|
||||
};
|
||||
assert_eq!(retry_accept, first_accept);
|
||||
|
||||
wait_for_active_sessions(&observer, 1).await;
|
||||
other_source
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(
|
||||
Duration::from_secs(2),
|
||||
other_source.recv_from(&mut response),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Reject(protocol::RejectPacket {
|
||||
reason: protocol::RejectReason::ReplayDetected,
|
||||
..
|
||||
}))
|
||||
));
|
||||
|
||||
let incompatible = protocol::HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::from_static(b"test"),
|
||||
hello.timestamp + 1,
|
||||
nonce,
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.send_to(&incompatible.encode().unwrap(), "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Reject(protocol::RejectPacket {
|
||||
reason: protocol::RejectReason::ReplayDetected,
|
||||
..
|
||||
}))
|
||||
));
|
||||
|
||||
let session_key = protocol::derive_session_key(
|
||||
&secret,
|
||||
&nonce,
|
||||
&first_accept.server_nonce,
|
||||
first_accept.session_id,
|
||||
);
|
||||
let (peer_tx, mut peer_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut buffer = [0u8; 2048];
|
||||
for _ in 0..2 {
|
||||
let (len, peer) = backend.recv_from(&mut buffer).await.unwrap();
|
||||
peer_tx.send(peer).unwrap();
|
||||
backend.send_to(&buffer[..len], peer).await.unwrap();
|
||||
}
|
||||
});
|
||||
for (sequence, payload) in [(1, b"one".as_slice()), (2, b"two".as_slice())] {
|
||||
let packet = protocol::DataPacket::authenticated(
|
||||
&session_key,
|
||||
first_accept.session_id,
|
||||
sequence,
|
||||
Bytes::copy_from_slice(payload),
|
||||
);
|
||||
client
|
||||
.send_to(&packet.encode().unwrap(), "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Data(_))
|
||||
));
|
||||
}
|
||||
backend_task.await.unwrap();
|
||||
assert_eq!(peer_rx.recv().await, peer_rx.recv().await);
|
||||
assert_eq!(observer.active_sessions().await, 1);
|
||||
|
||||
let close = protocol::SessionPacket::authenticated(
|
||||
protocol::PacketType::Close,
|
||||
&session_key,
|
||||
first_accept.session_id,
|
||||
3,
|
||||
);
|
||||
client
|
||||
.send_to(&close.encode().unwrap(), "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
client
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Reject(protocol::RejectPacket {
|
||||
reason: protocol::RejectReason::ReplayDetected,
|
||||
..
|
||||
}))
|
||||
));
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flags_are_authenticated_and_invalid_close_cannot_remove_session() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, _) = configs(45789, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let observer = server.handle();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let client = 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] = 45;
|
||||
let hello = protocol::HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::from_static(b"test"),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
nonce,
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.send_to(&hello.encode().unwrap(), "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = [0u8; 2048];
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let accept = match protocol::parse_packet(&response[..len]).unwrap() {
|
||||
protocol::Packet::Accept(accept) => accept,
|
||||
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, b"valid".as_slice());
|
||||
let mut mutated_flags = data.encode().unwrap();
|
||||
mutated_flags[2] = 1;
|
||||
client
|
||||
.send_to(&mutated_flags, "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(timeout(
|
||||
Duration::from_millis(100),
|
||||
backend.recv_from(&mut [0u8; 32])
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let invalid_close = protocol::SessionPacket::authenticated(
|
||||
protocol::PacketType::Close,
|
||||
&key,
|
||||
accept.session_id,
|
||||
1,
|
||||
);
|
||||
let mut mutated_close_flags = invalid_close.encode().unwrap();
|
||||
mutated_close_flags[2] = 1;
|
||||
client
|
||||
.send_to(&mutated_close_flags, "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(observer.active_sessions().await, 1);
|
||||
|
||||
let valid_data = protocol::DataPacket::authenticated(
|
||||
&key,
|
||||
accept.session_id,
|
||||
1,
|
||||
Bytes::from_static(b"still-open"),
|
||||
);
|
||||
client
|
||||
.send_to(&valid_data.encode().unwrap(), "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut backend_buffer = [0u8; 64];
|
||||
let (len, _) = timeout(
|
||||
Duration::from_secs(2),
|
||||
backend.recv_from(&mut backend_buffer),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&backend_buffer[..len], b"still-open");
|
||||
|
||||
let valid_close = protocol::SessionPacket::authenticated(
|
||||
protocol::PacketType::Close,
|
||||
&key,
|
||||
accept.session_id,
|
||||
2,
|
||||
);
|
||||
client
|
||||
.send_to(&valid_close.encode().unwrap(), "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_backend_does_not_terminate_listener() {
|
||||
let (server_config, client_config) = configs(45786, 9);
|
||||
|
||||
+32
-7
@@ -1,12 +1,10 @@
|
||||
#[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,
|
||||
use wgcloak::transport::http3;
|
||||
use wgcloak::transport::http3::{
|
||||
decode_auth_challenge, 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]
|
||||
@@ -78,6 +76,10 @@ fn authentication_is_completed_before_session_activation() {
|
||||
let nonce = [5u8; 16];
|
||||
let secret = [7u8; 32];
|
||||
let challenge_capsule = encode_auth_challenge(&challenge).unwrap();
|
||||
assert_eq!(
|
||||
decode_auth_challenge(&challenge_capsule).unwrap(),
|
||||
challenge
|
||||
);
|
||||
assert_eq!(
|
||||
decode_capsule(&challenge_capsule).unwrap().0,
|
||||
http3::AUTH_CHALLENGE_CAPSULE
|
||||
@@ -94,3 +96,26 @@ fn authentication_is_completed_before_session_activation() {
|
||||
assert!(http3::is_auth_accept(&http3::encode_auth_accept().unwrap()).unwrap());
|
||||
assert!(!http3::is_auth_accept(&http3::encode_auth_reject().unwrap()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_limit_includes_http_context_and_request_stream_overhead() {
|
||||
use h3::quic::StreamId;
|
||||
|
||||
let limit = http3::maximum_application_datagram(
|
||||
1200,
|
||||
StreamId::try_from(0).unwrap(),
|
||||
CONNECT_UDP_CONTEXT_ID,
|
||||
1200,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(limit, 1198);
|
||||
assert!(matches!(
|
||||
http3::maximum_application_datagram(
|
||||
2,
|
||||
StreamId::try_from(0).unwrap(),
|
||||
CONNECT_UDP_CONTEXT_ID,
|
||||
1200
|
||||
),
|
||||
Ok(0)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use std::{collections::HashMap, fs, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use quinn::{ClientConfig, Endpoint, ServerConfig};
|
||||
use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
|
||||
use tokio::time::timeout;
|
||||
use wgcloak::config;
|
||||
use wgcloak::transport::{
|
||||
http3::{Http3ClientConnection, Http3Error, Http3Server, Http3ServerConnection},
|
||||
AdmissionController, TransportSession,
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const WRONG_SECRET: &str = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
|
||||
fn test_endpoints() -> (
|
||||
tempfile::TempDir,
|
||||
Endpoint,
|
||||
ClientConfig,
|
||||
std::net::SocketAddr,
|
||||
) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let certificate = directory.path().join("localhost-cert.pem");
|
||||
let private_key = directory.path().join("localhost-key.pem");
|
||||
let result = Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
private_key.to_str().unwrap(),
|
||||
"-out",
|
||||
certificate.to_str().unwrap(),
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-addext",
|
||||
"keyUsage=critical,digitalSignature,keyEncipherment",
|
||||
"-addext",
|
||||
"extendedKeyUsage=serverAuth",
|
||||
"-days",
|
||||
"1",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"openssl failed: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
|
||||
let cert_pem = fs::read(&certificate).unwrap();
|
||||
let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&cert_pem)
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
let key_pem = fs::read(&private_key).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_slice(&key_pem).unwrap();
|
||||
let mut server_config = ServerConfig::with_single_cert(certs.clone(), key).unwrap();
|
||||
let mut server_transport = quinn::TransportConfig::default();
|
||||
server_transport.datagram_send_buffer_size(1024 * 1024);
|
||||
server_config.transport_config(Arc::new(server_transport));
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let server_address = server.local_addr().unwrap();
|
||||
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(certs[0].clone()).unwrap();
|
||||
let mut client_config = ClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
|
||||
let mut client_transport = quinn::TransportConfig::default();
|
||||
client_transport.datagram_send_buffer_size(1024 * 1024);
|
||||
client_config.transport_config(Arc::new(client_transport));
|
||||
(directory, server, client_config, server_address)
|
||||
}
|
||||
|
||||
async fn connect_client(address: std::net::SocketAddr, config: ClientConfig) -> quinn::Connection {
|
||||
let mut endpoint = Endpoint::client("[::]:0".parse().unwrap()).unwrap();
|
||||
endpoint.set_default_client_config(config);
|
||||
endpoint
|
||||
.connect(address, "localhost")
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_udp_authenticates_then_exchanges_contextual_http_datagrams() {
|
||||
let (_certificate_dir, server_endpoint, client_tls, address) = test_endpoints();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"laptop".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let incoming = server_endpoint.accept().await.unwrap();
|
||||
let quic_connection = incoming.await.unwrap();
|
||||
Http3ServerConnection::new(quic_connection)
|
||||
.await
|
||||
.unwrap()
|
||||
.accept_connect_udp("/tunnel", &clients, 1200, Duration::from_secs(3))
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let quic_connection = connect_client(address, client_tls).await;
|
||||
let client = Http3ClientConnection::new(quic_connection).await.unwrap();
|
||||
let client_session = client
|
||||
.connect_udp(
|
||||
&format!("localhost:{}", address.port()),
|
||||
"/tunnel",
|
||||
"laptop",
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let server_session = timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client_session.max_datagram_size(),
|
||||
server_session.max_datagram_size()
|
||||
);
|
||||
assert!(client_session.max_datagram_size() < 1200);
|
||||
client_session
|
||||
.send_datagram(b"opaque-upstream")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), server_session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque-upstream")
|
||||
);
|
||||
server_session
|
||||
.send_datagram(b"opaque-downstream")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), client_session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque-downstream")
|
||||
);
|
||||
let too_large = vec![0; client_session.max_datagram_size() + 1];
|
||||
assert!(matches!(
|
||||
client_session.send_datagram(&too_large).await,
|
||||
Err(Http3Error::Oversized { .. })
|
||||
));
|
||||
client_session.close(Duration::from_secs(2)).await.unwrap();
|
||||
assert!(matches!(
|
||||
client_session.send_datagram(b"after-close").await,
|
||||
Err(Http3Error::Closed)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_udp_rejects_bad_secret_before_returning_session() {
|
||||
let (_certificate_dir, server_endpoint, client_tls, address) = test_endpoints();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"laptop".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let incoming = server_endpoint.accept().await.unwrap();
|
||||
let quic_connection = incoming.await.unwrap();
|
||||
Http3ServerConnection::new(quic_connection)
|
||||
.await
|
||||
.unwrap()
|
||||
.accept_connect_udp("/tunnel", &clients, 1200, Duration::from_secs(3))
|
||||
.await
|
||||
});
|
||||
|
||||
let quic_connection = connect_client(address, client_tls).await;
|
||||
let client = Http3ClientConnection::new(quic_connection).await.unwrap();
|
||||
let client_result = client
|
||||
.connect_udp(
|
||||
&format!("localhost:{}", address.port()),
|
||||
"/tunnel",
|
||||
"laptop",
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(WRONG_SECRET)
|
||||
.unwrap(),
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(client_result, Err(Http3Error::AuthenticationRejected)),
|
||||
"unexpected client error: {:?}",
|
||||
client_result.as_ref().err()
|
||||
);
|
||||
let server_result = timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(server_result, Err(Http3Error::AuthenticationRejected)),
|
||||
"unexpected server error: {:?}",
|
||||
server_result.as_ref().err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http3_server_admission_permit_tracks_authenticated_session_lifetime() {
|
||||
let (_certificate_dir, endpoint, client_tls, address) = test_endpoints();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"laptop".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let limits = config::LimitsConfig {
|
||||
max_sessions: 1,
|
||||
max_sessions_per_ip: 1,
|
||||
..config::LimitsConfig::default()
|
||||
};
|
||||
let admission = AdmissionController::new(&limits);
|
||||
let server = Arc::new(Http3Server::from_endpoint_with_admission(
|
||||
endpoint,
|
||||
"/tunnel",
|
||||
clients,
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
admission.clone(),
|
||||
));
|
||||
let accept_server = server.clone();
|
||||
let server_task = tokio::spawn(async move { accept_server.accept_session().await.unwrap() });
|
||||
|
||||
let quic_connection = connect_client(address, client_tls).await;
|
||||
let client = Http3ClientConnection::new(quic_connection).await.unwrap();
|
||||
let client_session = client
|
||||
.connect_udp(
|
||||
&format!("localhost:{}", address.port()),
|
||||
"/tunnel",
|
||||
"laptop",
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let server_session = timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(admission.active_sessions(), 1);
|
||||
assert_eq!(admission.pending_handshakes(), 0);
|
||||
server_session.close().await.unwrap();
|
||||
assert_eq!(admission.active_sessions(), 0);
|
||||
client_session.close(Duration::from_secs(2)).await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::{
|
||||
fs, net::SocketAddr, os::unix::fs::PermissionsExt, path::Path, process::Command, sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use tempfile::TempDir;
|
||||
use tokio::{
|
||||
net::UdpSocket,
|
||||
sync::mpsc,
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use wgcloak::{
|
||||
client,
|
||||
config::{self, Config},
|
||||
control, server, stats, transport,
|
||||
transport::{Http3ClientConnector, TransportConnector, TransportSession},
|
||||
};
|
||||
|
||||
const SECRET_ONE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const SECRET_TWO: &str = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
const WRONG_SECRET: &str = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC";
|
||||
|
||||
fn free_udp_port() -> u16 {
|
||||
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
fn private_runtime_dir(root: &Path) -> std::path::PathBuf {
|
||||
let path = root.join("runtime");
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn server_config(
|
||||
directory: &TempDir,
|
||||
backend: SocketAddr,
|
||||
direct_port: u16,
|
||||
http3_port: u16,
|
||||
) -> config::ServerConfig {
|
||||
let certificate = directory.path().join("server-cert.pem");
|
||||
let private_key = directory.path().join("server-key.pem");
|
||||
let generated = Command::new("openssl")
|
||||
.args(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout"])
|
||||
.arg(&private_key)
|
||||
.args(["-out"])
|
||||
.arg(&certificate)
|
||||
.args([
|
||||
"-subj",
|
||||
"/CN=127.0.0.1",
|
||||
"-addext",
|
||||
"subjectAltName=IP:127.0.0.1",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-addext",
|
||||
"keyUsage=critical,digitalSignature,keyEncipherment",
|
||||
"-addext",
|
||||
"extendedKeyUsage=serverAuth",
|
||||
"-days",
|
||||
"1",
|
||||
])
|
||||
.output()
|
||||
.expect("openssl is required for the HTTP/3 runtime integration test");
|
||||
assert!(
|
||||
generated.status.success(),
|
||||
"openssl certificate generation failed: {}",
|
||||
String::from_utf8_lossy(&generated.stderr)
|
||||
);
|
||||
|
||||
Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='{backend}'\n[transports.direct_udp]\nenabled=true\nport={direct_port}\n[transports.quic]\nenabled=false\nport={}\n[transports.http3]\nenabled=true\nport={http3_port}\npath='/tunnel'\n[transports.tls_tcp]\nenabled=true\nport={direct_port}\n[tls]\ncertificate='{}'\nprivate_key='{}'\n[[auth.clients]]\nid='one'\nsecret='{SECRET_ONE}'\n[[auth.clients]]\nid='two'\nsecret='{SECRET_TWO}'\n[limits]\nhandshake_timeout_ms=3000\n",
|
||||
free_udp_port(),
|
||||
certificate.display(),
|
||||
private_key.display()
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn client_config(
|
||||
id: &str,
|
||||
secret: &str,
|
||||
http3_port: u16,
|
||||
direct_probe_port: u16,
|
||||
local_port: u16,
|
||||
) -> config::ClientConfig {
|
||||
Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='{id}'\nsecret='{secret}'\n[local]\nlisten='127.0.0.1:{local_port}'\n[transport]\nmode='auto'\n[transport.auto]\norder=['direct_udp','http3']\nprobe_timeout_ms=700\nretries=1\n[transport.direct_udp]\nport={direct_probe_port}\n[transport.http3]\nport={http3_port}\npath='/tunnel'\n[network]\nmax_datagram_size=1200\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn test_client_tls(certificate: &[u8]) -> quinn::ClientConfig {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(certificate)
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(certs[0].clone()).unwrap();
|
||||
let mut tls = rustls::ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.unwrap()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
tls.alpn_protocols = vec![b"h3".to_vec()];
|
||||
let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls).unwrap();
|
||||
quinn::ClientConfig::new(Arc::new(crypto))
|
||||
}
|
||||
|
||||
async fn wait_control_contains(path: &std::path::Path, command: &str, needle: &str) {
|
||||
let mut last_response = None;
|
||||
let result = timeout(Duration::from_secs(6), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, command).await {
|
||||
if response.contains(needle) {
|
||||
return;
|
||||
}
|
||||
last_response = Some(response);
|
||||
}
|
||||
sleep(Duration::from_millis(40)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"control {command} did not contain {needle:?}; latest response: {last_response:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_http3_runtime_relays_isolated_clients_and_rejects_bad_secret() {
|
||||
if Command::new("openssl").arg("version").output().is_err() {
|
||||
return;
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let runtime_dir = private_runtime_dir(directory.path());
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (peer_tx, mut peer_rx) = mpsc::unbounded_channel();
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut packet = vec![0u8; u16::MAX as usize];
|
||||
while let Ok((len, peer)) = backend.recv_from(&mut packet).await {
|
||||
let _ = peer_tx.send(peer);
|
||||
let _ = backend.send_to(&packet[..len], peer).await;
|
||||
}
|
||||
});
|
||||
|
||||
let certificate = directory.path().join("server-cert.pem");
|
||||
let direct_server_port = free_udp_port();
|
||||
let http3_port = free_udp_port();
|
||||
let server = server_config(&directory, backend_addr, direct_server_port, http3_port);
|
||||
let server_control = runtime_dir.join("server.sock");
|
||||
let server_control_for_task = server_control.clone();
|
||||
let server_task = tokio::spawn(server::run_with_control_path(
|
||||
server,
|
||||
server_control_for_task,
|
||||
));
|
||||
wait_control_contains(
|
||||
&server_control,
|
||||
"status",
|
||||
"transports=direct_udp,http3,tls_tcp",
|
||||
)
|
||||
.await;
|
||||
|
||||
let certificate_bytes = fs::read(certificate).unwrap();
|
||||
let client_tls = test_client_tls(&certificate_bytes);
|
||||
let direct_probe_port = free_udp_port();
|
||||
assert_ne!(direct_probe_port, direct_server_port);
|
||||
|
||||
let local_one = free_udp_port();
|
||||
let local_two = free_udp_port();
|
||||
let app_one = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let app_two = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let client_one_config =
|
||||
client_config("one", SECRET_ONE, http3_port, direct_probe_port, local_one);
|
||||
let client_two_config =
|
||||
client_config("two", SECRET_TWO, http3_port, direct_probe_port, local_two);
|
||||
let client_one_control = runtime_dir.join("client-one.sock");
|
||||
let client_two_control = runtime_dir.join("client-two.sock");
|
||||
let client_one_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
client_one_config.clone(),
|
||||
client_one_control.clone(),
|
||||
Some(Arc::new(Http3ClientConnector::with_client_config(
|
||||
client_one_config,
|
||||
client_tls.clone(),
|
||||
)) as Arc<dyn TransportConnector>),
|
||||
));
|
||||
let client_two_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
client_two_config.clone(),
|
||||
client_two_control.clone(),
|
||||
Some(Arc::new(Http3ClientConnector::with_client_config(
|
||||
client_two_config,
|
||||
client_tls.clone(),
|
||||
)) as Arc<dyn TransportConnector>),
|
||||
));
|
||||
|
||||
wait_control_contains(&server_control, "status", "active_sessions=2").await;
|
||||
app_one
|
||||
.send_to(b"client-one", ("127.0.0.1", local_one))
|
||||
.await
|
||||
.unwrap();
|
||||
app_two
|
||||
.send_to(b"client-two", ("127.0.0.1", local_two))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = [0u8; 64];
|
||||
let (len_one, _) = timeout(Duration::from_secs(4), app_one.recv_from(&mut response))
|
||||
.await
|
||||
.expect("first HTTP/3 echo timed out")
|
||||
.unwrap();
|
||||
assert_eq!(&response[..len_one], b"client-one");
|
||||
let (len_two, _) = timeout(Duration::from_secs(4), app_two.recv_from(&mut response))
|
||||
.await
|
||||
.expect("second HTTP/3 echo timed out")
|
||||
.unwrap();
|
||||
assert_eq!(&response[..len_two], b"client-two");
|
||||
let peer_one = timeout(Duration::from_secs(2), peer_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let peer_two = timeout(Duration::from_secs(2), peer_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
peer_one, peer_two,
|
||||
"each tunnel must own its backend UDP socket"
|
||||
);
|
||||
|
||||
let bad_local = free_udp_port();
|
||||
let bad_client_config = client_config(
|
||||
"one",
|
||||
WRONG_SECRET,
|
||||
http3_port,
|
||||
direct_probe_port,
|
||||
bad_local,
|
||||
);
|
||||
let bad_control = runtime_dir.join("client-bad.sock");
|
||||
let bad_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
bad_client_config.clone(),
|
||||
bad_control.clone(),
|
||||
Some(Arc::new(Http3ClientConnector::with_client_config(
|
||||
bad_client_config,
|
||||
client_tls,
|
||||
)) as Arc<dyn TransportConnector>),
|
||||
));
|
||||
let bad_result = timeout(Duration::from_secs(6), bad_task)
|
||||
.await
|
||||
.expect("bad-secret client should finish its rejected startup")
|
||||
.unwrap();
|
||||
assert!(bad_result.is_err(), "wrong project secret must be rejected");
|
||||
wait_control_contains(&server_control, "stats", "\"authentication_rejections\":1").await;
|
||||
let aggregate = control::send_command(&server_control, "stats")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(aggregate.contains("\"transport\":\"multi\""));
|
||||
assert!(aggregate.contains("\"direct_udp\":{"));
|
||||
assert!(aggregate.contains("\"http3\":{"));
|
||||
assert!(aggregate.contains("\"tls_tcp\":{"));
|
||||
|
||||
for path in [&client_one_control, &client_two_control] {
|
||||
control::send_command(path, "shutdown").await.unwrap();
|
||||
}
|
||||
timeout(Duration::from_secs(4), client_one_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(4), client_two_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
control::send_command(&server_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(5), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
backend_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conflicting_udp_listeners_are_rejected() {
|
||||
let parsed = Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:9'\n[transports.direct_udp]\nenabled=true\nport=54001\n[transports.quic]\nenabled=true\nport=54001\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled=true\nport=54001\n[tls]\ncertificate='placeholder-cert'\nprivate_key='placeholder-key'\n[[auth.clients]]\nid='one'\nsecret='{SECRET_ONE}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let runtime_dir = private_runtime_dir(directory.path());
|
||||
// Validation happens before binding any listener and reports only the
|
||||
// incompatible same-protocol UDP bind; TCP is allowed to reuse the number.
|
||||
let result = server::run_with_control_path(parsed, runtime_dir.join("conflict.sock")).await;
|
||||
assert!(result.unwrap_err().to_string().contains("UDP listeners"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_stats_and_status_merge_multiple_transport_snapshots() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = private_runtime_dir(directory.path()).join("aggregate.sock");
|
||||
let state = control::ControlState::default();
|
||||
state
|
||||
.set_status("listening; transports=direct_udp,http3")
|
||||
.await;
|
||||
let direct = stats::StatsSnapshot {
|
||||
datagrams_sent: 2,
|
||||
bytes_sent: 40,
|
||||
..stats::StatsSnapshot::default()
|
||||
};
|
||||
let http3 = stats::StatsSnapshot {
|
||||
datagrams_received: 3,
|
||||
authentication_rejections: 1,
|
||||
..stats::StatsSnapshot::default()
|
||||
};
|
||||
state.set_transport_stats("direct_udp", 1, direct).await;
|
||||
state.set_transport_stats("http3", 2, http3).await;
|
||||
let server = control::ControlServer::bind(&path, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let task = tokio::spawn(server.run());
|
||||
|
||||
let status = control::send_command(&path, "status").await.unwrap();
|
||||
assert!(status.contains("active_sessions=3"));
|
||||
assert!(status.contains("active_sessions_by_transport=direct_udp:1,http3:2"));
|
||||
let response = control::send_command(&path, "stats").await.unwrap();
|
||||
assert!(response.contains("\"datagrams_sent\":2"));
|
||||
assert!(response.contains("\"bytes_sent\":40"));
|
||||
assert!(response.contains("\"datagrams_received\":3"));
|
||||
assert!(response.contains("\"authentication_rejections\":1"));
|
||||
assert!(response.contains("\"direct_udp\":{"));
|
||||
assert!(response.contains("\"http3\":{"));
|
||||
|
||||
state.shutdown.cancel();
|
||||
timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_runtime_cancellation_runs_session_cleanup() {
|
||||
let port = free_udp_port();
|
||||
let server = Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:9'\n[transports.direct_udp]\nenabled=true\nport={port}\n[transports.quic]\nenabled=false\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled=false\n[[auth.clients]]\nid='lifecycle'\nsecret='{SECRET_ONE}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap();
|
||||
let client = Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='lifecycle'\nsecret='{SECRET_ONE}'\n[transport]\nmode='direct_udp'\n[transport.direct_udp]\nport={port}\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap();
|
||||
|
||||
let listener = transport::DirectUdpServer::bind(server).await.unwrap();
|
||||
let observer = listener.handle();
|
||||
let shutdown = tokio_util::sync::CancellationToken::new();
|
||||
let run = tokio::spawn(listener.run_with_shutdown(shutdown.clone()));
|
||||
let session = timeout(
|
||||
Duration::from_secs(3),
|
||||
transport::direct_udp::DirectUdpSession::connect(&client),
|
||||
)
|
||||
.await
|
||||
.expect("direct UDP client authentication timed out")
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(2), async {
|
||||
while observer.active_sessions().await != 1 {
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("server did not register the authenticated direct session");
|
||||
|
||||
shutdown.cancel();
|
||||
timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("direct listener did not complete graceful cleanup")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observer.active_sessions().await, 0);
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
#[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};
|
||||
use wgcloak::{
|
||||
config::Config,
|
||||
protocol, transport,
|
||||
transport::{
|
||||
AdmissionController, TlsTcpConnector, TlsTcpServer, TransportConnector, TransportError,
|
||||
},
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
@@ -29,7 +23,7 @@ fn free_port() -> u16 {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
async fn tls_tcp_authenticates_round_trips_and_classifies_failures() {
|
||||
if Command::new("openssl").arg("version").output().is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -45,6 +39,10 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
"-nodes",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-days",
|
||||
"1",
|
||||
"-keyout",
|
||||
@@ -61,7 +59,7 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
);
|
||||
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",
|
||||
"[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[limits]\nhandshake_timeout_ms = 500\n[tls]\ncertificate = \"{}\"\nprivate_key = \"{}\"\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n",
|
||||
cert.display(),
|
||||
key.display()
|
||||
))
|
||||
@@ -69,23 +67,47 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
.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"
|
||||
"[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport]\nmode = \"tls_tcp\"\n[transport.tls_tcp]\nport = {port}\nkeepalive_secs = 1\n[network]\nmax_datagram_size = 1200\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap();
|
||||
let mismatch_config = client_config.clone();
|
||||
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 admission = AdmissionController::new(&server_config.limits);
|
||||
let server = Arc::new(
|
||||
TlsTcpServer::bind_with_admission(&server_config, admission.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let slow_server = server.clone();
|
||||
let slow_handshake = tokio::spawn(async move { slow_server.accept_session().await });
|
||||
let slow_peer = tokio::net::TcpStream::connect(server.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
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 = TlsTcpConnector::with_client_config(client_config, client_tls.clone());
|
||||
let client_session = client.connect().await.unwrap();
|
||||
let server_session = accept.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
client_session.authenticated_keepalive_interval(),
|
||||
Some(Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(client_session.rtt(), None);
|
||||
assert_eq!(
|
||||
client_session.remote_addr().unwrap().ip(),
|
||||
"127.0.0.1".parse::<std::net::IpAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(admission.active_sessions(), 1);
|
||||
assert_eq!(admission.pending_handshakes(), 1);
|
||||
client_session.send_keepalive().await.unwrap();
|
||||
client_session
|
||||
.send_datagram(Bytes::from_static(b"tls-opaque"))
|
||||
.await
|
||||
@@ -99,4 +121,59 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
);
|
||||
client_session.close().await.unwrap();
|
||||
server_session.close().await.unwrap();
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(1), slow_handshake)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Err(TransportError::HandshakeTimeout)
|
||||
));
|
||||
drop(slow_peer);
|
||||
assert_eq!(admission.active_sessions(), 0);
|
||||
assert_eq!(admission.pending_handshakes(), 0);
|
||||
|
||||
let mut wrong_credentials = mismatch_config.clone();
|
||||
wrong_credentials.client.secret.secret =
|
||||
Some("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned());
|
||||
let server_accept = {
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move { server.accept_session().await })
|
||||
};
|
||||
let wrong_client = TlsTcpConnector::with_client_config(wrong_credentials, client_tls.clone());
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(2), wrong_client.connect())
|
||||
.await
|
||||
.unwrap(),
|
||||
Err(TransportError::AuthenticationRejected(
|
||||
protocol::RejectReason::AuthenticationFailed
|
||||
))
|
||||
));
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(2), server_accept)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Err(TransportError::AuthenticationRejected(
|
||||
protocol::RejectReason::AuthenticationFailed
|
||||
))
|
||||
));
|
||||
|
||||
let server_accept = {
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move { server.accept_session().await })
|
||||
};
|
||||
let mut mismatch_config = mismatch_config;
|
||||
mismatch_config.client.server = "127.0.0.1".to_owned();
|
||||
let mismatch_client = TlsTcpConnector::with_client_config(mismatch_config, client_tls);
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(2), mismatch_client.connect())
|
||||
.await
|
||||
.unwrap(),
|
||||
Err(TransportError::Io(_))
|
||||
));
|
||||
assert!(timeout(Duration::from_secs(2), server_accept)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.is_err());
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
use std::{net::SocketAddr, sync::Arc, thread, time::Duration};
|
||||
|
||||
use wgcloak::{
|
||||
config::{self, LimitsConfig},
|
||||
transport::{AdmissionController, AdmissionError},
|
||||
};
|
||||
|
||||
fn limits(
|
||||
max_sessions: usize,
|
||||
max_sessions_per_ip: usize,
|
||||
max_pending_handshakes: usize,
|
||||
max_auth_attempts_per_ip: usize,
|
||||
auth_attempt_window_secs: u64,
|
||||
) -> LimitsConfig {
|
||||
LimitsConfig {
|
||||
max_sessions,
|
||||
max_sessions_per_ip,
|
||||
max_pending_handshakes,
|
||||
max_auth_attempts_per_ip,
|
||||
auth_attempt_window_secs,
|
||||
handshake_timeout_ms: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn address(ip: &str) -> SocketAddr {
|
||||
format!("{ip}:40000").parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_limit_and_failed_handshake_release_reservation() {
|
||||
let controller = AdmissionController::new(&limits(8, 4, 1, 100, 60));
|
||||
let first = controller.begin(address("192.0.2.1")).unwrap();
|
||||
assert_eq!(controller.pending_handshakes(), 1);
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.2")),
|
||||
Err(AdmissionError::Busy)
|
||||
));
|
||||
|
||||
// Dropping the pending guard models any failed or timed-out handshake.
|
||||
drop(first);
|
||||
assert_eq!(controller.pending_handshakes(), 0);
|
||||
let second = controller.begin(address("192.0.2.2")).unwrap();
|
||||
drop(second);
|
||||
assert_eq!(controller.pending_handshakes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_and_per_ip_limits_release_on_close_or_drop() {
|
||||
let controller = AdmissionController::new(&limits(2, 1, 2, 100, 60));
|
||||
let first = controller.begin(address("192.0.2.1")).unwrap().promote();
|
||||
assert_eq!(controller.active_sessions(), 1);
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.1")),
|
||||
Err(AdmissionError::Busy)
|
||||
));
|
||||
|
||||
let second = controller.begin(address("192.0.2.2")).unwrap().promote();
|
||||
assert_eq!(controller.active_sessions(), 2);
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.3")),
|
||||
Err(AdmissionError::Busy)
|
||||
));
|
||||
|
||||
drop(first);
|
||||
assert_eq!(controller.active_sessions(), 1);
|
||||
drop(second);
|
||||
assert_eq!(controller.active_sessions(), 0);
|
||||
assert!(controller.begin(address("192.0.2.3")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_admissions_never_exceed_global_or_per_ip_capacity() {
|
||||
let controller = AdmissionController::new(&limits(3, 3, 32, 100, 60));
|
||||
let barrier = Arc::new(std::sync::Barrier::new(33));
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..32 {
|
||||
let controller = controller.clone();
|
||||
let barrier = barrier.clone();
|
||||
let sender = sender.clone();
|
||||
workers.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
let permit = controller
|
||||
.begin(address("192.0.2.10"))
|
||||
.ok()
|
||||
.map(|pending| pending.promote());
|
||||
barrier.wait();
|
||||
sender.send(permit).unwrap();
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
barrier.wait();
|
||||
drop(sender);
|
||||
let permits: Vec<_> = receiver.into_iter().flatten().collect();
|
||||
for worker in workers {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
assert_eq!(permits.len(), 3);
|
||||
assert_eq!(controller.active_sessions(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authentication_attempt_limit_is_per_ip_and_expires() {
|
||||
let controller = AdmissionController::new(&limits(8, 4, 8, 2, 1));
|
||||
for _ in 0..2 {
|
||||
drop(controller.begin(address("192.0.2.1")).unwrap());
|
||||
}
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.1")),
|
||||
Err(AdmissionError::RateLimited)
|
||||
));
|
||||
assert!(controller.begin(address("192.0.2.2")).is_ok());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1_050)).await;
|
||||
assert!(controller.begin(address("192.0.2.1")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_limits_have_safe_defaults_and_reject_out_of_bounds_values() {
|
||||
let defaults = LimitsConfig::default();
|
||||
assert_eq!(defaults.max_sessions, 1024);
|
||||
assert_eq!(defaults.max_sessions_per_ip, 32);
|
||||
assert_eq!(defaults.max_pending_handshakes, 256);
|
||||
assert_eq!(defaults.max_auth_attempts_per_ip, 20);
|
||||
assert_eq!(defaults.auth_attempt_window_secs, 60);
|
||||
assert_eq!(defaults.handshake_timeout_ms, 3_000);
|
||||
|
||||
let valid = "[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[[auth.clients]]\nid='one'\nsecret='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\n";
|
||||
assert!(config::Config::from_str(valid).unwrap().validate().is_ok());
|
||||
|
||||
let invalid = format!("{valid}\n[limits]\nmax_auth_attempts_per_ip=0\n");
|
||||
assert!(config::Config::from_str(&invalid)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.is_err());
|
||||
let too_long = format!("{valid}\n[limits]\nhandshake_timeout_ms=300001\n");
|
||||
assert!(config::Config::from_str(&too_long)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
+32
-16
@@ -1,14 +1,3 @@
|
||||
#[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;
|
||||
@@ -16,9 +5,12 @@ 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,
|
||||
use wgcloak::{
|
||||
config,
|
||||
transport::{
|
||||
quic::{QuicClientConnector, QuicServer},
|
||||
AdmissionController, TransportError, TransportSession,
|
||||
},
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
@@ -71,12 +63,34 @@ async fn authenticated_datagram_round_trip_and_limit() {
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
|
||||
let port = server.local_addr().unwrap().port();
|
||||
let admission = AdmissionController::new(&config::LimitsConfig::default());
|
||||
let server = QuicServer::from_endpoint_with_admission(
|
||||
endpoint,
|
||||
clients,
|
||||
1200,
|
||||
Duration::from_secs(2),
|
||||
admission.clone(),
|
||||
);
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
let port = server_addr.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();
|
||||
let remote_addr = client_session.remote_addr().unwrap();
|
||||
assert_eq!(remote_addr.port(), server_addr.port());
|
||||
assert!(match remote_addr.ip() {
|
||||
std::net::IpAddr::V4(address) => address.is_loopback(),
|
||||
std::net::IpAddr::V6(address) => {
|
||||
address.is_loopback()
|
||||
|| address
|
||||
.to_ipv4_mapped()
|
||||
.map(|mapped| mapped.is_loopback())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
});
|
||||
assert!(client_session.rtt().is_some());
|
||||
assert_eq!(client_session.authenticated_keepalive_interval(), None);
|
||||
client_session
|
||||
.send_datagram(Bytes::from_static(b"opaque"))
|
||||
.await
|
||||
@@ -88,6 +102,7 @@ async fn authenticated_datagram_round_trip_and_limit() {
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque")
|
||||
);
|
||||
assert_eq!(admission.active_sessions(), 1);
|
||||
assert!(matches!(
|
||||
client_session
|
||||
.send_datagram(Bytes::from(vec![0; 1201]))
|
||||
@@ -96,6 +111,7 @@ async fn authenticated_datagram_round_trip_and_limit() {
|
||||
));
|
||||
client_session.close().await.unwrap();
|
||||
server_session.close().await.unwrap();
|
||||
assert_eq!(admission.active_sessions(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use tempfile::tempdir;
|
||||
use tokio::{
|
||||
net::UdpSocket,
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use wgcloak::{
|
||||
client,
|
||||
config::{self, Config},
|
||||
control, server,
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
fn free_udp_port() -> u16 {
|
||||
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
fn server_config(port: u16, backend: std::net::SocketAddr) -> config::ServerConfig {
|
||||
Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='{backend}'\n[transports.direct_udp]\nenabled=true\nport={port}\n[transports.quic]\nenabled=false\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled=false\n[[auth.clients]]\nid='reconnect-test'\nsecret='{SECRET}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn client_config(port: u16, local_port: u16) -> config::ClientConfig {
|
||||
Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='reconnect-test'\nsecret='{SECRET}'\n[local]\nlisten='127.0.0.1:{local_port}'\n[transport]\nmode='direct_udp'\n[transport.direct_udp]\nport={port}\n[transport.auto]\nprobe_timeout_ms=150\nretries=1\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for_contains(path: &Path, command: &str, needle: &str) -> String {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, command).await {
|
||||
if response.contains(needle) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("control {command} did not contain {needle:?}"))
|
||||
}
|
||||
|
||||
async fn wait_for_reconnected_stats(path: &Path) -> String {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, "stats").await {
|
||||
if response.contains("\"state\":\"connected\"")
|
||||
&& response.contains("\"reconnections\":1")
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("client did not publish its connected reconnect state")
|
||||
}
|
||||
|
||||
async fn wait_for_echo(app: &UdpSocket, local_port: u16, payload: &[u8]) {
|
||||
let mut response = [0u8; 128];
|
||||
app.send_to(payload, ("127.0.0.1", local_port))
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(5), app.recv_from(&mut response))
|
||||
.await
|
||||
.expect("application echo timed out")
|
||||
.unwrap();
|
||||
assert_eq!(&response[..len], payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_recovers_through_backoff_after_server_restart() {
|
||||
let directory = tempdir().unwrap();
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut packet = vec![0u8; 2048];
|
||||
while let Ok((len, peer)) = backend.recv_from(&mut packet).await {
|
||||
let _ = backend.send_to(&packet[..len], peer).await;
|
||||
}
|
||||
});
|
||||
|
||||
let port = free_udp_port();
|
||||
let local_port = free_udp_port();
|
||||
let runtime_dir = directory.path().join("runtime");
|
||||
let server_control = runtime_dir.join("server.sock");
|
||||
let client_control = runtime_dir.join("client.sock");
|
||||
let server_task = tokio::spawn(server::run_with_control_path(
|
||||
server_config(port, backend_addr),
|
||||
server_control.clone(),
|
||||
));
|
||||
wait_for_contains(&server_control, "status", "transports=direct_udp").await;
|
||||
|
||||
let client_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
client_config(port, local_port),
|
||||
client_control.clone(),
|
||||
None,
|
||||
));
|
||||
wait_for_contains(&client_control, "status", "connected; server=127.0.0.1").await;
|
||||
let app = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
wait_for_echo(&app, local_port, b"before-restart").await;
|
||||
|
||||
let stopped = control::send_command(&server_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stopped.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(5), server_task)
|
||||
.await
|
||||
.expect("first server instance should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let requested = control::send_command(&client_control, "reconnect")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(requested.contains("\"ok\":true"));
|
||||
// Wait until the client has made a failed connection attempt and entered
|
||||
// jittered backoff before starting the replacement server. This exercises
|
||||
// the daemon loop without relying on a guessed sleep duration.
|
||||
wait_for_contains(&client_control, "status", "error=").await;
|
||||
|
||||
let replacement = tokio::spawn(server::run_with_control_path(
|
||||
server_config(port, backend_addr),
|
||||
server_control.clone(),
|
||||
));
|
||||
wait_for_contains(&server_control, "status", "transports=direct_udp").await;
|
||||
let _stats = wait_for_reconnected_stats(&client_control).await;
|
||||
wait_for_echo(&app, local_port, b"after-restart").await;
|
||||
|
||||
let stopped = control::send_command(&server_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stopped.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(5), replacement)
|
||||
.await
|
||||
.expect("replacement server should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let stopped = control::send_command(&client_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stopped.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(5), client_task)
|
||||
.await
|
||||
.expect("client should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
backend_task.abort();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::{os::unix::fs::PermissionsExt, path::Path};
|
||||
use tempfile::tempdir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::{net::UdpSocket, time::timeout};
|
||||
use wgcloak::{
|
||||
config::Config,
|
||||
control, server,
|
||||
transport::{direct_udp::DirectUdpSession, TransportSession},
|
||||
};
|
||||
|
||||
const DIRECT_SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
fn private_runtime_dir(root: &Path) -> std::path::PathBuf {
|
||||
let path = root.join("runtime");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_commands_drive_only_supported_runtime_actions() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = private_runtime_dir(dir.path()).join("runtime.sock");
|
||||
let state = control::ControlState::default();
|
||||
state.enable_reconnect();
|
||||
let server = control::ControlServer::bind(&path, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let task = tokio::spawn(server.run());
|
||||
|
||||
state.set_status("connected; transport=direct_udp").await;
|
||||
state
|
||||
.set_stats_json("{\"datagrams_sent\":7,\"datagrams_dropped\":1}")
|
||||
.await;
|
||||
let status = control::send_command(&path, "status").await.unwrap();
|
||||
assert!(status.contains("connected; transport=direct_udp"));
|
||||
let stats = control::send_command(&path, "stats").await.unwrap();
|
||||
assert!(stats.contains("\"datagrams_sent\":7"));
|
||||
|
||||
let reconnect = state.reconnect.notified();
|
||||
tokio::pin!(reconnect);
|
||||
let response = control::send_command(&path, "reconnect").await.unwrap();
|
||||
assert!(response.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(1), &mut reconnect)
|
||||
.await
|
||||
.expect("reconnect should reach the running client loop");
|
||||
|
||||
let response = control::send_command(&path, "reload").await.unwrap();
|
||||
assert!(response.contains("\"ok\":false"));
|
||||
assert!(response.contains("not supported"));
|
||||
|
||||
let response = control::send_command(&path, "shutdown").await.unwrap();
|
||||
assert!(response.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("control server should stop on shutdown")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_control_rejects_client_only_reconnect() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = private_runtime_dir(dir.path()).join("server.sock");
|
||||
let state = control::ControlState::default();
|
||||
let server = control::ControlServer::bind(&path, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let task = tokio::spawn(server.run());
|
||||
|
||||
let response = control::send_command(&path, "reconnect").await.unwrap();
|
||||
assert!(response.contains("\"ok\":false"));
|
||||
assert!(response.contains("not supported"));
|
||||
|
||||
state.shutdown.cancel();
|
||||
timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("control server should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_server_control_stats_track_authenticated_session_lifecycle() {
|
||||
let probe = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
|
||||
let config = Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:9'\n[transports.direct_udp]\nport={port}\n[[auth.clients]]\nid='runtime-test'\nsecret='{DIRECT_SECRET}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap();
|
||||
let client_config = Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='runtime-test'\nsecret='{DIRECT_SECRET}'\n[transport.direct_udp]\nport={port}\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap();
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let control_path = private_runtime_dir(dir.path()).join("direct-runtime.sock");
|
||||
let server_task = tokio::spawn(server::run_with_control_path(config, control_path.clone()));
|
||||
|
||||
let session = timeout(
|
||||
Duration::from_secs(3),
|
||||
DirectUdpSession::connect(&client_config),
|
||||
)
|
||||
.await
|
||||
.expect("direct handshake should complete")
|
||||
.unwrap();
|
||||
|
||||
wait_for_active_count(&control_path, 1).await;
|
||||
let status = control::send_command(&control_path, "status")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(status.contains("active_sessions=1"));
|
||||
session.close().await.unwrap();
|
||||
wait_for_active_count(&control_path, 0).await;
|
||||
|
||||
let response = control::send_command(&control_path, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.expect("server runtime should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn wait_for_active_count(path: &std::path::Path, expected: usize) {
|
||||
timeout(Duration::from_secs(4), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, "stats").await {
|
||||
if response.contains(&format!("\"active_sessions\":{expected}")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("server control stats did not report {expected} active sessions"));
|
||||
}
|
||||
+6
-16
@@ -1,18 +1,4 @@
|
||||
#[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},
|
||||
@@ -20,9 +6,13 @@ use std::{
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use supervisor::{ProbeFuture, TransportProbe, TransportSupervisor};
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
use transport::{TransportError, TransportFuture, TransportSession};
|
||||
use wgcloak::{
|
||||
client::supervisor::{self as supervisor, ProbeFuture, TransportProbe, TransportSupervisor},
|
||||
config::{self, Config, TransportMode},
|
||||
protocol,
|
||||
transport::{TransportError, TransportFuture, TransportSession},
|
||||
};
|
||||
|
||||
struct Session;
|
||||
impl TransportSession for Session {
|
||||
|
||||
+3
-5
@@ -1,13 +1,11 @@
|
||||
#[path = "../src/transport/tls_tcp.rs"]
|
||||
mod tls_tcp;
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use tls_tcp::{
|
||||
use tokio::io::{duplex, split};
|
||||
use wgcloak::transport::tls_tcp;
|
||||
use wgcloak::transport::tls_tcp::{
|
||||
client_authenticate, server_authenticate, AuthChallenge, FrameCodec, FrameError, FramedReader,
|
||||
FramedWriter,
|
||||
};
|
||||
use tokio::io::{duplex, split};
|
||||
|
||||
#[test]
|
||||
fn codec_handles_split_and_coalesced_frames() {
|
||||
|
||||
Reference in new issue
Block a user