feat(net): perform synvael handshake and graceful disconnect

This commit is contained in:
Serkyo 2026-07-13 23:40:53 +02:00
parent 092d546c52
commit 8e7cd15085
2 changed files with 304 additions and 1 deletions

View file

@ -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());
}

View 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(())
}