feat(net): perform synvael handshake and graceful disconnect
This commit is contained in:
parent
092d546c52
commit
8e7cd15085
|
|
@ -2,4 +2,206 @@
|
||||||
|
|
||||||
//! Synvael application handshake over an established QUIC connection.
|
//! Synvael application handshake over an established QUIC connection.
|
||||||
//!
|
//!
|
||||||
//! Drives the `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange and protocol-version verification on the control stream. Populated in a later concept; currently a placeholder.
|
//! Drives the `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange and protocol-version verification on the control stream (stream 0). The QUIC/TLS handshake is completed by the transport layer before any of these functions run; the exchange here is the Synvael application handshake layered on top of it.
|
||||||
|
//!
|
||||||
|
//! The control stream `(SendStream, RecvStream)` is retained inside the returned handles ([`Connected`], [`ServerConnection`]) and is never finished after the handshake, so later concepts can reuse stream 0 for chat, commands, and disconnect.
|
||||||
|
|
||||||
|
use quinn::{Connection, Incoming, RecvStream, SendStream, VarInt};
|
||||||
|
use shared::protocol::{
|
||||||
|
ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION,
|
||||||
|
RejectReason, StreamLayout,
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::codec::{MAX_CONTROL_FRAME_LEN, read_frame, write_frame};
|
||||||
|
use crate::error::NetError;
|
||||||
|
|
||||||
|
/// Application close code used when a peer is rejected during the handshake.
|
||||||
|
const CLOSE_CODE_REJECTED: u32 = 1;
|
||||||
|
|
||||||
|
/// Application close code used for an orderly, graceful disconnect.
|
||||||
|
const CLOSE_CODE_GRACEFUL: u32 = 0;
|
||||||
|
|
||||||
|
/// Upper bound on the wait for a rejected client to read the `HandshakeReject` and close, before the server tears the connection down anyway.
|
||||||
|
const REJECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Errors produced while performing the Synvael application handshake.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum HandshakeError {
|
||||||
|
/// The synchronous `quinn` connect call failed before the connection attempt began.
|
||||||
|
#[error("quic connect error: {0}")]
|
||||||
|
Connect(#[from] quinn::ConnectError),
|
||||||
|
/// The QUIC connection failed to establish or was lost during the handshake.
|
||||||
|
#[error("quic connection error: {0}")]
|
||||||
|
Connection(#[from] quinn::ConnectionError),
|
||||||
|
/// A control-stream frame failed to encode, decode, or transfer.
|
||||||
|
#[error("control frame codec error: {0}")]
|
||||||
|
Codec(#[from] NetError),
|
||||||
|
/// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire.
|
||||||
|
#[error("handshake rejected: {0:?}")]
|
||||||
|
Rejected(HandshakeReject),
|
||||||
|
/// A control message other than the one expected for this handshake step arrived.
|
||||||
|
#[error("unexpected control message during handshake")]
|
||||||
|
UnexpectedMessage,
|
||||||
|
/// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`HandshakeReject`] to the client.
|
||||||
|
#[error("protocol version mismatch: client {client}, server {server}")]
|
||||||
|
VersionMismatch {
|
||||||
|
/// Protocol version advertised by the client in its `ClientHello`.
|
||||||
|
client: u32,
|
||||||
|
/// Protocol version the server was built against (`PROTOCOL_VERSION`).
|
||||||
|
server: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A completed client-side handshake: an established connection, the retained control stream, and the server's acceptance parameters.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Connected {
|
||||||
|
/// The established QUIC connection. Additional streams are opened from it.
|
||||||
|
pub connection: Connection,
|
||||||
|
/// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake.
|
||||||
|
pub control: (SendStream, RecvStream),
|
||||||
|
/// The negotiated session parameters returned by the server.
|
||||||
|
pub ack: HandshakeAck,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A completed server-side handshake for one connection: the established connection, the retained control stream, and the client's presented identity.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ServerConnection {
|
||||||
|
/// The established QUIC connection. Additional streams are accepted from it.
|
||||||
|
pub connection: Connection,
|
||||||
|
/// The control stream (stream 0), retained so it can carry chat, commands, and disconnect. Never finished after the handshake.
|
||||||
|
pub control: (SendStream, RecvStream),
|
||||||
|
/// The `ClientHello` the accepted client presented.
|
||||||
|
pub hello: ClientHello,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects to a Synvael server: establishes the QUIC connection, opens the control stream, sends `ClientHello`, and awaits the server's response.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`HandshakeError::Connect`] if the connection attempt cannot be initiated, [`HandshakeError::Connection`] if the QUIC connection fails to establish, [`HandshakeError::Codec`] if the `ClientHello` cannot be written or the reply cannot be read, [`HandshakeError::Rejected`] if the server refuses the handshake, and [`HandshakeError::UnexpectedMessage`] if the server replies with a control message other than `HandshakeAck` or `HandshakeReject`.
|
||||||
|
pub async fn connect(
|
||||||
|
endpoint: &quinn::Endpoint,
|
||||||
|
server_addr: std::net::SocketAddr,
|
||||||
|
server_name: &str,
|
||||||
|
hello: ClientHello,
|
||||||
|
) -> Result<Connected, HandshakeError> {
|
||||||
|
let connection = endpoint.connect(server_addr, server_name)?.await?;
|
||||||
|
|
||||||
|
// The client opens the control stream; the server accepts it. The stream first appears on the server once the `ClientHello` bytes are written below.
|
||||||
|
let (mut send, mut recv) = connection.open_bi().await?;
|
||||||
|
|
||||||
|
write_frame(&mut send, &ControlMessage::ClientHello(hello)).await?;
|
||||||
|
|
||||||
|
match read_frame::<ControlMessage>(&mut recv, MAX_CONTROL_FRAME_LEN).await? {
|
||||||
|
ControlMessage::HandshakeAck(ack) => {
|
||||||
|
info!(
|
||||||
|
protocol_version = ack.protocol_version,
|
||||||
|
server_build = %ack.server_build,
|
||||||
|
"handshake accepted by server"
|
||||||
|
);
|
||||||
|
Ok(Connected {
|
||||||
|
connection,
|
||||||
|
control: (send, recv),
|
||||||
|
ack,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ControlMessage::HandshakeReject(rej) => {
|
||||||
|
warn!(reason = ?rej.reason, detail = %rej.detail, "handshake rejected by server");
|
||||||
|
Err(HandshakeError::Rejected(rej))
|
||||||
|
}
|
||||||
|
_ => Err(HandshakeError::UnexpectedMessage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepts one incoming connection: completes the QUIC handshake, reads the client's `ClientHello`, validates it, and replies with `HandshakeAck` or `HandshakeReject`.
|
||||||
|
///
|
||||||
|
/// On a protocol-version mismatch a [`HandshakeReject`] is sent to the client, the connection is closed with [`CLOSE_CODE_REJECTED`], and [`HandshakeError::VersionMismatch`] is returned locally.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`HandshakeError::Connection`] if the QUIC connection fails to establish or the control stream cannot be accepted, [`HandshakeError::Codec`] if the `ClientHello` cannot be read or a reply cannot be written, [`HandshakeError::UnexpectedMessage`] if the first control message is not a `ClientHello`, and [`HandshakeError::VersionMismatch`] if the client's protocol version does not match the server's.
|
||||||
|
pub async fn accept_connection(
|
||||||
|
incoming: Incoming,
|
||||||
|
server_build: String,
|
||||||
|
tick_rate_hint: u16,
|
||||||
|
) -> Result<ServerConnection, HandshakeError> {
|
||||||
|
let connection = incoming.await?;
|
||||||
|
|
||||||
|
// The client opened the control stream; the server accepts it here.
|
||||||
|
let (mut send, mut recv) = connection.accept_bi().await?;
|
||||||
|
|
||||||
|
let ControlMessage::ClientHello(hello) =
|
||||||
|
read_frame::<ControlMessage>(&mut recv, MAX_CONTROL_FRAME_LEN).await?
|
||||||
|
else {
|
||||||
|
return Err(HandshakeError::UnexpectedMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
if hello.protocol_version != PROTOCOL_VERSION {
|
||||||
|
let reject = HandshakeReject {
|
||||||
|
reason: RejectReason::ProtocolMismatch,
|
||||||
|
detail: format!(
|
||||||
|
"client protocol version {}, server protocol version {PROTOCOL_VERSION}",
|
||||||
|
hello.protocol_version
|
||||||
|
),
|
||||||
|
upgrade_url: None,
|
||||||
|
};
|
||||||
|
warn!(
|
||||||
|
client = hello.protocol_version,
|
||||||
|
server = PROTOCOL_VERSION,
|
||||||
|
"rejecting client on protocol mismatch"
|
||||||
|
);
|
||||||
|
// Send the rejection, then keep the connection alive until the client has read it and closed. `Connection::close` (and dropping the connection) discards buffered stream data, so an immediate close would race the client's read and lose the reject frame. The wait is bounded so a misbehaving client cannot park the accept task indefinitely.
|
||||||
|
write_frame(&mut send, &ControlMessage::HandshakeReject(reject)).await?;
|
||||||
|
let _ = send.finish();
|
||||||
|
let _ = tokio::time::timeout(REJECT_DELIVERY_TIMEOUT, connection.closed()).await;
|
||||||
|
connection.close(
|
||||||
|
VarInt::from_u32(CLOSE_CODE_REJECTED),
|
||||||
|
b"protocol version mismatch",
|
||||||
|
);
|
||||||
|
return Err(HandshakeError::VersionMismatch {
|
||||||
|
client: hello.protocol_version,
|
||||||
|
server: PROTOCOL_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: validate installed_packs against the world's required packs once the modlist-matching concept lands. M1 accepts any pack set and advertises none.
|
||||||
|
let ack = HandshakeAck {
|
||||||
|
protocol_version: PROTOCOL_VERSION,
|
||||||
|
server_build,
|
||||||
|
world_packs: vec![],
|
||||||
|
missing_packs: vec![],
|
||||||
|
stream_layout: StreamLayout::default(),
|
||||||
|
tick_rate_hint,
|
||||||
|
};
|
||||||
|
write_frame(&mut send, &ControlMessage::HandshakeAck(ack)).await?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
display_name = %hello.player_identity.display_name,
|
||||||
|
"handshake accepted"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ServerConnection {
|
||||||
|
connection,
|
||||||
|
control: (send, recv),
|
||||||
|
hello,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a `Disconnect` control message and then closes the connection cleanly.
|
||||||
|
///
|
||||||
|
/// The control stream's send half is borrowed mutably to write the final frame; the connection is then closed with [`CLOSE_CODE_GRACEFUL`]. A failure to write the `Disconnect` frame is logged rather than propagated, since the connection is closed unconditionally afterwards. Both [`Connected`] and [`ServerConnection`] expose their parts as `connection` and `control`, so either can call this with `(&conn.connection, &mut conn.control.0, reason)`.
|
||||||
|
pub async fn graceful_disconnect(connection: &Connection, send: &mut SendStream, reason: &str) {
|
||||||
|
if let Err(error) = write_frame(
|
||||||
|
send,
|
||||||
|
&ControlMessage::Disconnect(Disconnect {
|
||||||
|
reason: reason.to_owned(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!(%error, "failed to send disconnect frame; closing connection regardless");
|
||||||
|
}
|
||||||
|
connection.close(VarInt::from_u32(CLOSE_CODE_GRACEFUL), reason.as_bytes());
|
||||||
|
}
|
||||||
|
|
|
||||||
101
crates/net/tests/handshake.rs
Normal file
101
crates/net/tests/handshake.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
//! Loopback integration tests for the Synvael application handshake.
|
||||||
|
//!
|
||||||
|
//! Each test binds a real QUIC server endpoint on `127.0.0.1:0`, reads the OS-assigned port, and drives a client through the full `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange, exercising the async `read_frame`/`write_frame` path end-to-end.
|
||||||
|
|
||||||
|
use net::endpoint::{client_endpoint, server_endpoint};
|
||||||
|
use net::handshake::{HandshakeError, accept_connection, connect};
|
||||||
|
use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason};
|
||||||
|
|
||||||
|
/// Builds a `ClientHello` for `display_name` advertising `protocol_version`.
|
||||||
|
fn hello(display_name: &str, protocol_version: u32) -> ClientHello {
|
||||||
|
ClientHello {
|
||||||
|
protocol_version,
|
||||||
|
client_build: "synvael-client-test".to_owned(),
|
||||||
|
player_identity: PlayerIdentity {
|
||||||
|
display_name: display_name.to_owned(),
|
||||||
|
},
|
||||||
|
installed_packs: vec![],
|
||||||
|
requested_features: FeatureFlags(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn happy_path_completes_handshake() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let server = server_endpoint("127.0.0.1:0".parse()?)?;
|
||||||
|
let server_addr = server.local_addr()?;
|
||||||
|
|
||||||
|
// Accept exactly one connection on the server, returning the observed identity. The connection is held open until the client closes it, so the ack frame is reliably delivered before teardown.
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let incoming = server.accept().await.ok_or("server endpoint closed")?;
|
||||||
|
let conn = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await?;
|
||||||
|
let name = conn.hello.player_identity.display_name.clone();
|
||||||
|
conn.connection.closed().await;
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(name)
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = client_endpoint()?;
|
||||||
|
let connected = connect(
|
||||||
|
&client,
|
||||||
|
server_addr,
|
||||||
|
"localhost",
|
||||||
|
hello("Tester", PROTOCOL_VERSION),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
connected.ack.protocol_version, PROTOCOL_VERSION,
|
||||||
|
"server must ack with the matching protocol version"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close the client connection so the server's `closed()` wait resolves.
|
||||||
|
drop(connected);
|
||||||
|
let observed_name = server_task.await??;
|
||||||
|
assert_eq!(
|
||||||
|
observed_name, "Tester",
|
||||||
|
"server must observe the client's display name"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn version_mismatch_is_rejected() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let server = server_endpoint("127.0.0.1:0".parse()?)?;
|
||||||
|
let server_addr = server.local_addr()?;
|
||||||
|
|
||||||
|
let server_task = tokio::spawn(async move {
|
||||||
|
let incoming = server.accept().await.ok_or("server endpoint closed")?;
|
||||||
|
// The server is expected to return VersionMismatch after sending the reject.
|
||||||
|
let result = accept_connection(incoming, "synvael-server-test".to_owned(), 20).await;
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(result.is_err())
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = client_endpoint()?;
|
||||||
|
let result = connect(
|
||||||
|
&client,
|
||||||
|
server_addr,
|
||||||
|
"localhost",
|
||||||
|
hello("Tester", PROTOCOL_VERSION + 1),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(HandshakeError::Rejected(rej)) => {
|
||||||
|
assert_eq!(
|
||||||
|
rej.reason,
|
||||||
|
RejectReason::ProtocolMismatch,
|
||||||
|
"rejection must cite a protocol mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => return Err(format!("expected a rejection, got {other:?}").into()),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
server_task.await??,
|
||||||
|
"server must return an error on mismatch"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue