803 lines
36 KiB
Rust
803 lines
36 KiB
Rust
// file: crates/common/game-realtime-webtransport-lib/src/webtransport.rs
|
|
// version: 4
|
|
|
|
const CERTIFICATE_HASH_SIZE: usize = 32;
|
|
const LOCAL_CERTIFICATE_CLOCK_SKEW: std::time::Duration = std::time::Duration::from_secs(60);
|
|
const LOCAL_CERTIFICATE_VALIDITY: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
|
|
const PRIMARY_FRAME_HEADER_SIZE: usize = 4;
|
|
const TRACING_TARGET: &str = "games::realtime::webtransport";
|
|
|
|
/// SHA-256 fingerprint of one certificate accepted by the native WebTransport client.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct WebTransportCertificateHash {
|
|
bytes: [u8; CERTIFICATE_HASH_SIZE],
|
|
}
|
|
|
|
impl WebTransportCertificateHash {
|
|
/// Creates a fingerprint from an already-computed SHA-256 digest.
|
|
#[must_use]
|
|
pub fn from_sha256(bytes: [u8; CERTIFICATE_HASH_SIZE]) -> Self {
|
|
return Self { bytes };
|
|
}
|
|
|
|
/// Returns the exact 32-byte SHA-256 digest.
|
|
#[must_use]
|
|
pub fn as_bytes(&self) -> &[u8; CERTIFICATE_HASH_SIZE] {
|
|
return &self.bytes;
|
|
}
|
|
}
|
|
|
|
/// Self-contained certificate/private-key identity used by a native WebTransport server.
|
|
pub struct WebTransportServerIdentity {
|
|
certificate_der: Vec<u8>,
|
|
private_key_pkcs8_der: Vec<u8>,
|
|
certificate_hash: WebTransportCertificateHash,
|
|
}
|
|
|
|
impl WebTransportServerIdentity {
|
|
/// Generates a short-lived self-signed ECDSA P-256 identity for localhost and loopback addresses.
|
|
pub fn generate_loopback() -> Result<Self, game_realtime_transport_lib::TransportError> {
|
|
let now = std::time::SystemTime::now();
|
|
let not_before = match now.checked_sub(LOCAL_CERTIFICATE_CLOCK_SKEW) {
|
|
Some(value) => value,
|
|
None => return Err(invalid_configuration("failed to compute local certificate not-before time")),
|
|
};
|
|
let not_after = match now.checked_add(LOCAL_CERTIFICATE_VALIDITY) {
|
|
Some(value) => value,
|
|
None => return Err(invalid_configuration("failed to compute local certificate not-after time")),
|
|
};
|
|
let subject_alt_names = vec!["localhost".to_owned(), "127.0.0.1".to_owned(), "::1".to_owned()];
|
|
let mut params = match rcgen::CertificateParams::new(subject_alt_names) {
|
|
Ok(value) => value,
|
|
Err(error) => return Err(invalid_configuration(error.to_string())),
|
|
};
|
|
params.not_before = not_before.into();
|
|
params.not_after = not_after.into();
|
|
let key_pair = match rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) {
|
|
Ok(value) => value,
|
|
Err(error) => return Err(invalid_configuration(error.to_string())),
|
|
};
|
|
let certificate = match params.self_signed(&key_pair) {
|
|
Ok(value) => value,
|
|
Err(error) => return Err(invalid_configuration(error.to_string())),
|
|
};
|
|
let certificate_der = certificate.der().to_vec();
|
|
let private_key_pkcs8_der = key_pair.serialize_der();
|
|
return Self::from_pkcs8_der(certificate_der, private_key_pkcs8_der);
|
|
}
|
|
|
|
/// Builds an identity from an X.509 certificate DER blob and its PKCS#8 private key DER blob.
|
|
///
|
|
/// Certificate/key compatibility is validated by the native TLS server builder when the listener is bound.
|
|
pub fn from_pkcs8_der(certificate_der: Vec<u8>, private_key_pkcs8_der: Vec<u8>) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
|
if certificate_der.is_empty() {
|
|
return Err(invalid_configuration("certificate DER must not be empty"));
|
|
}
|
|
if private_key_pkcs8_der.is_empty() {
|
|
return Err(invalid_configuration("PKCS#8 private-key DER must not be empty"));
|
|
}
|
|
let certificate_hash = match certificate_hash(certificate_der.as_slice()) {
|
|
Ok(value) => value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
return Ok(Self { certificate_der, private_key_pkcs8_der, certificate_hash });
|
|
}
|
|
|
|
/// Returns the SHA-256 certificate fingerprint used for native hash pinning.
|
|
#[must_use]
|
|
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
|
return &self.certificate_hash;
|
|
}
|
|
}
|
|
|
|
/// Native WebTransport client endpoint, certificate pin and reliable-path configuration.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct WebTransportClientConfig {
|
|
endpoint: url::Url,
|
|
certificate_hash: WebTransportCertificateHash,
|
|
transport: crate::WebTransportConfig,
|
|
}
|
|
|
|
impl WebTransportClientConfig {
|
|
/// Parses and validates a secure WebTransport endpoint with one pinned SHA-256 certificate fingerprint.
|
|
pub fn new(endpoint: &str, certificate_hash: WebTransportCertificateHash) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
|
let parsed = match url::Url::parse(endpoint) {
|
|
Ok(value) => value,
|
|
Err(error) => return Err(invalid_configuration(error.to_string())),
|
|
};
|
|
if parsed.scheme() != "https" {
|
|
return Err(invalid_configuration("WebTransport endpoint scheme must be https"));
|
|
}
|
|
if parsed.host().is_none() {
|
|
return Err(invalid_configuration("WebTransport endpoint must contain a host"));
|
|
}
|
|
return Ok(Self { endpoint: parsed, certificate_hash, transport: crate::WebTransportConfig::default() });
|
|
}
|
|
|
|
/// Returns a copy with explicit reliable-path limits and deadlines.
|
|
#[must_use]
|
|
pub fn with_transport_config(mut self, transport: crate::WebTransportConfig) -> Self {
|
|
self.transport = transport;
|
|
return self;
|
|
}
|
|
|
|
/// Returns the validated WebTransport endpoint URL.
|
|
#[must_use]
|
|
pub fn endpoint(&self) -> &str {
|
|
return self.endpoint.as_str();
|
|
}
|
|
|
|
/// Returns the pinned SHA-256 server-certificate fingerprint.
|
|
#[must_use]
|
|
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
|
return &self.certificate_hash;
|
|
}
|
|
|
|
/// Returns the reliable-path limits and deadlines.
|
|
#[must_use]
|
|
pub fn transport_config(&self) -> crate::WebTransportConfig {
|
|
return self.transport;
|
|
}
|
|
}
|
|
|
|
/// Native WebTransport server bind address, TLS identity and reliable-path configuration.
|
|
pub struct WebTransportServerConfig {
|
|
bind_address: std::net::SocketAddr,
|
|
identity: WebTransportServerIdentity,
|
|
transport: crate::WebTransportConfig,
|
|
}
|
|
|
|
impl WebTransportServerConfig {
|
|
/// Creates native server configuration for the requested bind address and TLS identity.
|
|
#[must_use]
|
|
pub fn new(bind_address: std::net::SocketAddr, identity: WebTransportServerIdentity) -> Self {
|
|
return Self { bind_address, identity, transport: crate::WebTransportConfig::default() };
|
|
}
|
|
|
|
/// Returns a copy with explicit reliable-path limits and deadlines.
|
|
#[must_use]
|
|
pub fn with_transport_config(mut self, transport: crate::WebTransportConfig) -> Self {
|
|
self.transport = transport;
|
|
return self;
|
|
}
|
|
|
|
/// Returns the requested UDP bind address.
|
|
#[must_use]
|
|
pub fn bind_address(&self) -> std::net::SocketAddr {
|
|
return self.bind_address;
|
|
}
|
|
|
|
/// Returns the server certificate fingerprint that clients must pin for this identity.
|
|
#[must_use]
|
|
pub fn certificate_hash(&self) -> &WebTransportCertificateHash {
|
|
return self.identity.certificate_hash();
|
|
}
|
|
|
|
/// Returns the reliable-path limits and deadlines.
|
|
#[must_use]
|
|
pub fn transport_config(&self) -> crate::WebTransportConfig {
|
|
return self.transport;
|
|
}
|
|
}
|
|
|
|
/// Established native WebTransport session before or while the single primary application stream is selected.
|
|
pub struct WebTransportSession {
|
|
inner: web_transport_quinn::Session,
|
|
transport: crate::WebTransportConfig,
|
|
}
|
|
|
|
impl WebTransportSession {
|
|
fn new(inner: web_transport_quinn::Session, transport: crate::WebTransportConfig) -> Self {
|
|
return Self { inner, transport };
|
|
}
|
|
|
|
/// Accepts the peer-created primary bidirectional stream and adapts it to the transport-neutral realtime contract.
|
|
///
|
|
/// The native WebTransport wrapper writes the required stream/session header while opening the stream, so the peer can
|
|
/// accept it before the first application frame is sent.
|
|
pub async fn accept_primary_connection(self) -> Result<WebTransportConnection, game_realtime_transport_lib::TransportError> {
|
|
let timeout = self.transport.primary_stream_timeout();
|
|
let accepted = tokio::time::timeout(timeout, self.inner.accept_bi()).await;
|
|
let (sender, receiver) = match accepted {
|
|
Ok(Ok(value)) => value,
|
|
Ok(Err(error)) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary bidirectional stream accept failed");
|
|
return Err(mapped);
|
|
},
|
|
Err(_) => {
|
|
let mapped = timeout_error("WebTransport primary bidirectional stream accept", timeout);
|
|
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(timeout), "WebTransport primary bidirectional stream accept timed out");
|
|
return Err(mapped);
|
|
},
|
|
};
|
|
tracing::debug!(target: TRACING_TARGET, peer = %self.inner.remote_address(), "WebTransport primary bidirectional stream accepted");
|
|
return Ok(WebTransportConnection::new(self.inner, sender, receiver, self.transport));
|
|
}
|
|
|
|
/// Opens the single primary bidirectional stream and adapts it to the transport-neutral realtime contract.
|
|
pub async fn open_primary_connection(self) -> Result<WebTransportConnection, game_realtime_transport_lib::TransportError> {
|
|
let timeout = self.transport.primary_stream_timeout();
|
|
let opened = tokio::time::timeout(timeout, self.inner.open_bi()).await;
|
|
let (sender, receiver) = match opened {
|
|
Ok(Ok(value)) => value,
|
|
Ok(Err(error)) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary bidirectional stream open failed");
|
|
return Err(mapped);
|
|
},
|
|
Err(_) => {
|
|
let mapped = timeout_error("WebTransport primary bidirectional stream open", timeout);
|
|
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(timeout), "WebTransport primary bidirectional stream open timed out");
|
|
return Err(mapped);
|
|
},
|
|
};
|
|
tracing::debug!(target: TRACING_TARGET, peer = %self.inner.remote_address(), "WebTransport primary bidirectional stream opened");
|
|
return Ok(WebTransportConnection::new(self.inner, sender, receiver, self.transport));
|
|
}
|
|
|
|
/// Returns the remote UDP socket address backing the established QUIC connection.
|
|
#[must_use]
|
|
pub fn remote_addr(&self) -> std::net::SocketAddr {
|
|
return self.inner.remote_address();
|
|
}
|
|
|
|
/// Returns the HTTP/3 CONNECT URL used to establish this session when available.
|
|
#[must_use]
|
|
pub fn request_url(&self) -> Option<&str> {
|
|
return match self.inner.request() {
|
|
Some(request) => Some(request.url.as_str()),
|
|
None => None,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Established WebTransport realtime connection carried by one primary reliable bidirectional stream.
|
|
pub struct WebTransportConnection {
|
|
receiver: web_transport_quinn::RecvStream,
|
|
sender: web_transport_quinn::SendStream,
|
|
session: web_transport_quinn::Session,
|
|
transport: crate::WebTransportConfig,
|
|
}
|
|
|
|
impl WebTransportConnection {
|
|
fn new(
|
|
session: web_transport_quinn::Session,
|
|
sender: web_transport_quinn::SendStream,
|
|
receiver: web_transport_quinn::RecvStream,
|
|
transport: crate::WebTransportConfig,
|
|
) -> Self {
|
|
return Self { receiver, sender, session, transport };
|
|
}
|
|
}
|
|
|
|
impl game_realtime_transport_lib::RealtimeConnection for WebTransportConnection {
|
|
type Receiver = crate::WebTransportReceiver;
|
|
type Sender = crate::WebTransportSender;
|
|
|
|
fn split(self) -> (Self::Sender, Self::Receiver) {
|
|
let receiver_session = self.session.clone();
|
|
return (
|
|
crate::WebTransportSender {
|
|
inner: self.sender,
|
|
_session: self.session,
|
|
max_message_size: self.transport.max_message_size(),
|
|
send_timeout: self.transport.send_timeout(),
|
|
terminal: false,
|
|
},
|
|
crate::WebTransportReceiver {
|
|
inner: self.receiver,
|
|
_session: receiver_session,
|
|
max_message_size: self.transport.max_message_size(),
|
|
header: [0_u8; PRIMARY_FRAME_HEADER_SIZE],
|
|
header_read: 0,
|
|
payload: Vec::new(),
|
|
payload_read: 0,
|
|
clean_closed: false,
|
|
terminal: false,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Receive half of the primary reliable WebTransport stream.
|
|
pub struct WebTransportReceiver {
|
|
inner: web_transport_quinn::RecvStream,
|
|
_session: web_transport_quinn::Session,
|
|
max_message_size: usize,
|
|
header: [u8; PRIMARY_FRAME_HEADER_SIZE],
|
|
header_read: usize,
|
|
payload: Vec<u8>,
|
|
payload_read: usize,
|
|
clean_closed: bool,
|
|
terminal: bool,
|
|
}
|
|
|
|
impl WebTransportReceiver {
|
|
/// Abruptly stops the reliable receive direction with one WebTransport application error code.
|
|
pub fn abort(&mut self, code: u32) -> Result<(), game_realtime_transport_lib::TransportError> {
|
|
if self.clean_closed || self.terminal {
|
|
return Err(transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, "WebTransport receiver is already terminal"));
|
|
}
|
|
return match self.inner.stop(code) {
|
|
Ok(()) => {
|
|
self.terminal = true;
|
|
tracing::debug!(target: TRACING_TARGET, code = code, "WebTransport primary receive stream aborted");
|
|
Ok(())
|
|
},
|
|
Err(error) => {
|
|
self.terminal = true;
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, code = code, detail = mapped.detail(), "WebTransport primary receive stream abort failed");
|
|
Err(mapped)
|
|
},
|
|
};
|
|
}
|
|
|
|
async fn receive_frame(&mut self) -> Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError> {
|
|
if self.clean_closed {
|
|
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
|
}
|
|
if self.terminal {
|
|
return Err(transport_error(
|
|
game_realtime_transport_lib::TransportErrorKind::Aborted,
|
|
"WebTransport receiver is unavailable after a terminal stream failure or abort",
|
|
));
|
|
}
|
|
loop {
|
|
if self.header_read < PRIMARY_FRAME_HEADER_SIZE {
|
|
let read = self.inner.read(&mut self.header[self.header_read..]).await;
|
|
match read {
|
|
Ok(Some(0)) => return self.fail_protocol("WebTransport primary stream returned an empty read in the middle of a frame header"),
|
|
Ok(Some(value)) => {
|
|
self.header_read += value;
|
|
continue;
|
|
},
|
|
Ok(None) => {
|
|
if self.header_read == 0 {
|
|
self.clean_closed = true;
|
|
tracing::debug!(target: TRACING_TARGET, "remote WebTransport primary stream closed cleanly");
|
|
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
|
|
}
|
|
return self.fail_protocol("WebTransport primary stream closed in the middle of a frame header");
|
|
},
|
|
Err(error) => return self.fail_read(error),
|
|
}
|
|
}
|
|
if self.payload.is_empty() && self.payload_read == 0 {
|
|
let payload_len = u32::from_be_bytes(self.header) as usize;
|
|
if payload_len > self.max_message_size {
|
|
let error = message_too_large(payload_len, self.max_message_size);
|
|
self.stop_after_failure(FRAME_TOO_LARGE_ERROR_CODE);
|
|
return Err(error);
|
|
}
|
|
if payload_len == 0 {
|
|
self.reset_frame_state();
|
|
tracing::trace!(target: TRACING_TARGET, payload_len = 0, "framed WebTransport payload received");
|
|
return Ok(game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(Vec::new())));
|
|
}
|
|
self.payload = vec![0_u8; payload_len];
|
|
}
|
|
if self.payload_read < self.payload.len() {
|
|
let read = self.inner.read(&mut self.payload[self.payload_read..]).await;
|
|
match read {
|
|
Ok(Some(0)) => return self.fail_protocol("WebTransport primary stream returned an empty read in the middle of a frame payload"),
|
|
Ok(Some(value)) => {
|
|
self.payload_read += value;
|
|
if self.payload_read < self.payload.len() {
|
|
continue;
|
|
}
|
|
},
|
|
Ok(None) => return self.fail_protocol("WebTransport primary stream closed in the middle of a frame payload"),
|
|
Err(error) => return self.fail_read(error),
|
|
}
|
|
}
|
|
let payload = core::mem::take(&mut self.payload);
|
|
self.reset_frame_state();
|
|
tracing::trace!(target: TRACING_TARGET, payload_len = payload.len(), "framed WebTransport payload received");
|
|
return Ok(game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(payload)));
|
|
}
|
|
}
|
|
|
|
fn fail_protocol(&mut self, detail: &str) -> Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError> {
|
|
let error = protocol_error(detail);
|
|
self.stop_after_failure(FRAME_PROTOCOL_ERROR_CODE);
|
|
return Err(error);
|
|
}
|
|
|
|
fn fail_read(
|
|
&mut self,
|
|
error: web_transport_quinn::ReadError,
|
|
) -> Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError> {
|
|
self.terminal = true;
|
|
let mapped = map_read_error(error);
|
|
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebTransport primary stream receive failed");
|
|
return Err(mapped);
|
|
}
|
|
|
|
fn reset_frame_state(&mut self) {
|
|
self.header = [0_u8; PRIMARY_FRAME_HEADER_SIZE];
|
|
self.header_read = 0;
|
|
self.payload.clear();
|
|
self.payload_read = 0;
|
|
}
|
|
|
|
fn stop_after_failure(&mut self, code: u32) {
|
|
let _ = self.inner.stop(code);
|
|
self.terminal = true;
|
|
}
|
|
}
|
|
|
|
impl Drop for WebTransportReceiver {
|
|
fn drop(&mut self) {
|
|
if !self.clean_closed && !self.terminal {
|
|
let _ = self.inner.stop(STREAM_CANCELLED_ERROR_CODE);
|
|
self.terminal = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl game_realtime_transport_lib::RealtimeReceiver for WebTransportReceiver {
|
|
type ReceiveFuture<'a>
|
|
= std::pin::Pin<
|
|
Box<dyn core::future::Future<Output = Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError>> + 'a>,
|
|
>
|
|
where
|
|
Self: 'a;
|
|
|
|
fn receive(&mut self) -> Self::ReceiveFuture<'_> {
|
|
return Box::pin(async move { return self.receive_frame().await });
|
|
}
|
|
}
|
|
|
|
/// Send half of the primary reliable WebTransport stream.
|
|
pub struct WebTransportSender {
|
|
inner: web_transport_quinn::SendStream,
|
|
_session: web_transport_quinn::Session,
|
|
max_message_size: usize,
|
|
send_timeout: std::time::Duration,
|
|
terminal: bool,
|
|
}
|
|
|
|
impl WebTransportSender {
|
|
/// Abruptly resets the reliable send direction with one WebTransport application error code.
|
|
pub fn abort(&mut self, code: u32) -> Result<(), game_realtime_transport_lib::TransportError> {
|
|
if self.terminal {
|
|
return Err(transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, "WebTransport sender is already terminal"));
|
|
}
|
|
self.terminal = true;
|
|
return match self.inner.reset(code) {
|
|
Ok(()) => {
|
|
tracing::debug!(target: TRACING_TARGET, code = code, "WebTransport primary send stream aborted");
|
|
Ok(())
|
|
},
|
|
Err(error) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, code = code, detail = mapped.detail(), "WebTransport primary send stream abort failed");
|
|
Err(mapped)
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
impl Drop for WebTransportSender {
|
|
fn drop(&mut self) {
|
|
if !self.terminal {
|
|
let _ = self.inner.reset(STREAM_CANCELLED_ERROR_CODE);
|
|
self.terminal = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl game_realtime_transport_lib::RealtimeSender for WebTransportSender {
|
|
type CloseFuture<'a>
|
|
= std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), game_realtime_transport_lib::TransportError>> + 'a>>
|
|
where
|
|
Self: 'a;
|
|
type SendFuture<'a>
|
|
= std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), game_realtime_transport_lib::TransportError>> + 'a>>
|
|
where
|
|
Self: 'a;
|
|
|
|
fn close(&mut self) -> Self::CloseFuture<'_> {
|
|
return Box::pin(async move {
|
|
if self.terminal {
|
|
return Err(transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, "WebTransport sender is already terminal"));
|
|
}
|
|
self.terminal = true;
|
|
return match self.inner.finish() {
|
|
Ok(()) => {
|
|
tracing::debug!(target: TRACING_TARGET, "local WebTransport primary stream close initiated");
|
|
Ok(())
|
|
},
|
|
Err(error) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Closed, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebTransport primary stream close failed");
|
|
Err(mapped)
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> {
|
|
return Box::pin(async move {
|
|
if self.terminal {
|
|
return Err(transport_error(
|
|
game_realtime_transport_lib::TransportErrorKind::Aborted,
|
|
"WebTransport sender is unavailable after close, abort, cancellation or terminal send failure",
|
|
));
|
|
}
|
|
let payload_len = message.len();
|
|
let frame_header = match frame_header(payload_len, self.max_message_size) {
|
|
Ok(value) => value,
|
|
Err(error) => return Err(error),
|
|
};
|
|
let send_timeout = self.send_timeout;
|
|
let mut guard = SendOperationGuard::new(&mut self.inner, &mut self.terminal);
|
|
let operation = guard.write_frame(&frame_header, message.as_bytes());
|
|
let result = tokio::time::timeout(send_timeout, operation).await;
|
|
return match result {
|
|
Ok(Ok(())) => {
|
|
guard.complete();
|
|
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "framed WebTransport payload sent");
|
|
Ok(())
|
|
},
|
|
Ok(Err(error)) => {
|
|
guard.abort(SEND_FAILURE_ERROR_CODE);
|
|
let mapped = map_write_error(error);
|
|
tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, kind = %mapped.kind(), detail = mapped.detail(), "WebTransport framed send failed");
|
|
Err(mapped)
|
|
},
|
|
Err(_) => {
|
|
guard.abort(SEND_TIMEOUT_ERROR_CODE);
|
|
let mapped = timeout_error("WebTransport framed send", send_timeout);
|
|
tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, timeout_ms = duration_millis(send_timeout), "WebTransport framed send timed out under flow control/backpressure");
|
|
Err(mapped)
|
|
},
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
struct SendOperationGuard<'a> {
|
|
stream: &'a mut web_transport_quinn::SendStream,
|
|
terminal: &'a mut bool,
|
|
armed: bool,
|
|
}
|
|
|
|
impl<'a> SendOperationGuard<'a> {
|
|
fn new(stream: &'a mut web_transport_quinn::SendStream, terminal: &'a mut bool) -> Self {
|
|
return Self { stream, terminal, armed: true };
|
|
}
|
|
|
|
async fn write_frame(&mut self, header: &[u8; PRIMARY_FRAME_HEADER_SIZE], payload: &[u8]) -> Result<(), web_transport_quinn::WriteError> {
|
|
if let Err(error) = self.stream.write_all(header).await {
|
|
return Err(error);
|
|
}
|
|
if let Err(error) = self.stream.write_all(payload).await {
|
|
return Err(error);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
fn abort(&mut self, code: u32) {
|
|
if self.armed {
|
|
let _ = self.stream.reset(code);
|
|
*self.terminal = true;
|
|
self.armed = false;
|
|
}
|
|
}
|
|
|
|
fn complete(&mut self) {
|
|
self.armed = false;
|
|
}
|
|
}
|
|
|
|
impl Drop for SendOperationGuard<'_> {
|
|
fn drop(&mut self) {
|
|
if self.armed {
|
|
let _ = self.stream.reset(STREAM_CANCELLED_ERROR_CODE);
|
|
*self.terminal = true;
|
|
self.armed = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bound native WebTransport server endpoint that accepts HTTP/3 WebTransport sessions.
|
|
pub struct WebTransportListener {
|
|
server: web_transport_quinn::Server,
|
|
local_addr: std::net::SocketAddr,
|
|
transport: crate::WebTransportConfig,
|
|
}
|
|
|
|
impl WebTransportListener {
|
|
/// Binds a native WebTransport server using TLS 1.3 and the configured certificate identity.
|
|
pub fn bind(config: WebTransportServerConfig) -> Result<Self, game_realtime_transport_lib::TransportError> {
|
|
if let Err(error) = config.transport.validate() {
|
|
return Err(error);
|
|
}
|
|
let transport = config.transport;
|
|
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(config.identity.certificate_der);
|
|
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivatePkcs8KeyDer::from(config.identity.private_key_pkcs8_der);
|
|
let private_key = web_transport_quinn::quinn::rustls::pki_types::PrivateKeyDer::Pkcs8(private_key);
|
|
let server = match web_transport_quinn::ServerBuilder::new().with_addr(config.bind_address).with_certificate(vec![certificate], private_key) {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, address = %config.bind_address, detail = mapped.detail(), "WebTransport listener bind failed");
|
|
return Err(mapped);
|
|
},
|
|
};
|
|
let local_addr = match server.local_addr() {
|
|
Ok(value) => value,
|
|
Err(error) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebTransport listener address lookup failed");
|
|
return Err(mapped);
|
|
},
|
|
};
|
|
tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebTransport listener bound");
|
|
return Ok(Self { server, local_addr, transport });
|
|
}
|
|
|
|
/// Returns the concrete UDP socket address, including an ephemeral port selected by the OS.
|
|
#[must_use]
|
|
pub fn local_addr(&self) -> std::net::SocketAddr {
|
|
return self.local_addr;
|
|
}
|
|
|
|
/// Accepts one native WebTransport CONNECT request and returns the established session.
|
|
///
|
|
/// Waiting for the next peer remains intentionally unbounded. Once a WebTransport request is surfaced, the final server
|
|
/// response is bounded by the configured connection deadline.
|
|
pub async fn accept(&mut self) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
|
|
let request = match self.server.accept().await {
|
|
Some(value) => value,
|
|
None => {
|
|
let error = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, "WebTransport server stopped accepting sessions");
|
|
tracing::warn!(target: TRACING_TARGET, detail = error.detail(), "WebTransport accept ended");
|
|
return Err(error);
|
|
},
|
|
};
|
|
let peer = request.conn().remote_address();
|
|
let timeout = self.transport.connect_timeout();
|
|
let accepted = tokio::time::timeout(timeout, request.ok()).await;
|
|
let session = match accepted {
|
|
Ok(Ok(value)) => value,
|
|
Ok(Err(error)) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, peer = %peer, detail = mapped.detail(), "WebTransport server handshake failed");
|
|
return Err(mapped);
|
|
},
|
|
Err(_) => {
|
|
let mapped = timeout_error("WebTransport server handshake response", timeout);
|
|
tracing::warn!(target: TRACING_TARGET, peer = %peer, timeout_ms = duration_millis(timeout), "WebTransport server handshake response timed out");
|
|
return Err(mapped);
|
|
},
|
|
};
|
|
tracing::info!(target: TRACING_TARGET, peer = %peer, "WebTransport peer accepted");
|
|
return Ok(WebTransportSession::new(session, self.transport));
|
|
}
|
|
}
|
|
|
|
/// Establishes one native WebTransport session using an exact SHA-256 certificate pin.
|
|
pub async fn connect(config: &WebTransportClientConfig) -> Result<WebTransportSession, game_realtime_transport_lib::TransportError> {
|
|
if let Err(error) = config.transport.validate() {
|
|
return Err(error);
|
|
}
|
|
let client = match web_transport_quinn::ClientBuilder::new().with_server_certificate_hashes(vec![config.certificate_hash.as_bytes().to_vec()]) {
|
|
Ok(value) => value,
|
|
Err(error) => return Err(invalid_configuration(error.to_string())),
|
|
};
|
|
let timeout = config.transport.connect_timeout();
|
|
let connected = tokio::time::timeout(timeout, client.connect(config.endpoint.clone())).await;
|
|
let session = match connected {
|
|
Ok(Ok(value)) => value,
|
|
Ok(Err(error)) => {
|
|
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Connect, error.to_string());
|
|
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), detail = mapped.detail(), "WebTransport client connection failed");
|
|
return Err(mapped);
|
|
},
|
|
Err(_) => {
|
|
let mapped = timeout_error("WebTransport client connect", timeout);
|
|
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), timeout_ms = duration_millis(timeout), "WebTransport client connection timed out");
|
|
return Err(mapped);
|
|
},
|
|
};
|
|
tracing::info!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), peer = %session.remote_address(), "WebTransport client connected");
|
|
return Ok(WebTransportSession::new(session, config.transport));
|
|
}
|
|
|
|
const FRAME_PROTOCOL_ERROR_CODE: u32 = 0x10;
|
|
const FRAME_TOO_LARGE_ERROR_CODE: u32 = 0x11;
|
|
const SEND_FAILURE_ERROR_CODE: u32 = 0x12;
|
|
const SEND_TIMEOUT_ERROR_CODE: u32 = 0x13;
|
|
const STREAM_CANCELLED_ERROR_CODE: u32 = 0x14;
|
|
|
|
fn certificate_hash(certificate_der: &[u8]) -> Result<WebTransportCertificateHash, game_realtime_transport_lib::TransportError> {
|
|
let certificate = web_transport_quinn::quinn::rustls::pki_types::CertificateDer::from(certificate_der.to_vec());
|
|
let provider = web_transport_quinn::crypto::default_provider();
|
|
let digest = web_transport_quinn::crypto::sha256(&provider, &certificate);
|
|
let digest_bytes = digest.as_ref();
|
|
if digest_bytes.len() != CERTIFICATE_HASH_SIZE {
|
|
return Err(invalid_configuration("WebTransport certificate SHA-256 digest has an unexpected length"));
|
|
}
|
|
let mut bytes = [0_u8; CERTIFICATE_HASH_SIZE];
|
|
bytes.copy_from_slice(digest_bytes);
|
|
return Ok(WebTransportCertificateHash::from_sha256(bytes));
|
|
}
|
|
|
|
fn frame_header(payload_len: usize, max_message_size: usize) -> Result<[u8; PRIMARY_FRAME_HEADER_SIZE], game_realtime_transport_lib::TransportError> {
|
|
if payload_len > max_message_size {
|
|
return Err(message_too_large(payload_len, max_message_size));
|
|
}
|
|
let payload_len = match u32::try_from(payload_len) {
|
|
Ok(value) => value,
|
|
Err(_) => return Err(message_too_large(payload_len, max_message_size)),
|
|
};
|
|
return Ok(payload_len.to_be_bytes());
|
|
}
|
|
|
|
fn invalid_configuration(detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
|
return transport_error(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
|
|
}
|
|
|
|
fn map_read_error(error: web_transport_quinn::ReadError) -> game_realtime_transport_lib::TransportError {
|
|
let kind = match &error {
|
|
web_transport_quinn::ReadError::Reset(_) => game_realtime_transport_lib::TransportErrorKind::Aborted,
|
|
web_transport_quinn::ReadError::ClosedStream => game_realtime_transport_lib::TransportErrorKind::Closed,
|
|
web_transport_quinn::ReadError::SessionError(session) => map_session_error_kind(session),
|
|
web_transport_quinn::ReadError::InvalidReset(_) | web_transport_quinn::ReadError::IllegalOrderedRead => {
|
|
game_realtime_transport_lib::TransportErrorKind::Protocol
|
|
},
|
|
};
|
|
return transport_error(kind, error.to_string());
|
|
}
|
|
|
|
fn map_session_error_kind(error: &web_transport_quinn::SessionError) -> game_realtime_transport_lib::TransportErrorKind {
|
|
if matches!(error, web_transport_quinn::SessionError::WebTransportError(web_transport_quinn::WebTransportError::Closed(_, _))) {
|
|
return game_realtime_transport_lib::TransportErrorKind::Closed;
|
|
}
|
|
return game_realtime_transport_lib::TransportErrorKind::Io;
|
|
}
|
|
|
|
fn map_write_error(error: web_transport_quinn::WriteError) -> game_realtime_transport_lib::TransportError {
|
|
let kind = match &error {
|
|
web_transport_quinn::WriteError::Stopped(_) => game_realtime_transport_lib::TransportErrorKind::Aborted,
|
|
web_transport_quinn::WriteError::ClosedStream => game_realtime_transport_lib::TransportErrorKind::Closed,
|
|
web_transport_quinn::WriteError::SessionError(session) => map_session_error_kind(session),
|
|
web_transport_quinn::WriteError::InvalidStopped(_) => game_realtime_transport_lib::TransportErrorKind::Protocol,
|
|
};
|
|
return transport_error(kind, error.to_string());
|
|
}
|
|
|
|
fn message_too_large(payload_len: usize, max_message_size: usize) -> game_realtime_transport_lib::TransportError {
|
|
return transport_error(
|
|
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
|
|
format!("WebTransport primary frame payload size {payload_len} exceeds configured maximum {max_message_size} bytes"),
|
|
);
|
|
}
|
|
|
|
fn protocol_error(detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
|
return transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, detail);
|
|
}
|
|
|
|
fn timeout_error(operation: &str, timeout: std::time::Duration) -> game_realtime_transport_lib::TransportError {
|
|
return transport_error(
|
|
game_realtime_transport_lib::TransportErrorKind::Timeout,
|
|
format!("{operation} exceeded configured deadline of {} ms", duration_millis(timeout)),
|
|
);
|
|
}
|
|
|
|
fn duration_millis(duration: std::time::Duration) -> u128 {
|
|
return duration.as_millis();
|
|
}
|
|
|
|
fn transport_error(kind: game_realtime_transport_lib::TransportErrorKind, detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
|
|
return game_realtime_transport_lib::TransportError::new(kind, detail);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../unit_tests/webtransport.rs"]
|
|
mod tests;
|