diff --git a/crates/net/src/handshake.rs b/crates/net/src/handshake.rs index 09fe275..cbc35b0 100644 --- a/crates/net/src/handshake.rs +++ b/crates/net/src/handshake.rs @@ -2,4 +2,206 @@ //! 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 { + 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::(&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 { + 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::(&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()); +} diff --git a/crates/net/tests/handshake.rs b/crates/net/tests/handshake.rs new file mode 100644 index 0000000..866778c --- /dev/null +++ b/crates/net/tests/handshake.rs @@ -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> { + 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>(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> { + 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>(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(()) +}