use crate::client::ClientConfig;
use crate::crypto;
use crate::quic::CongestionControl;
use crate::quic::Resolved;
use crate::server::ServerConfig;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::net;
use std::path::Path;
use std::sync::{Arc, RwLock};
use url::Url;
use web_transport_quiche::proto::ConnectRequest;
pub use web_transport_quiche;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("invalid DNS name")]
InvalidDnsName,
#[doc(hidden)]
#[deprecated(note = "fingerprint verification over http:// is now supported; this is never returned")]
#[error("fingerprint verification (http:// scheme) is not supported with the quiche backend")]
FingerprintUnsupported,
#[error("failed to fetch certificate fingerprint")]
FetchFingerprint(#[source] reqwest::Error),
#[error("certificate fingerprint request failed")]
FingerprintStatus(#[source] reqwest::Error),
#[error("failed to read certificate fingerprint")]
ReadFingerprint(#[source] reqwest::Error),
#[error("invalid certificate fingerprint")]
InvalidFingerprint(#[source] hex::FromHexError),
#[error("certificate fingerprint must be 32 bytes (SHA-256), got {0}")]
FingerprintLength(usize),
#[error("url scheme must be 'https', 'moqt', or 'moql'")]
InvalidScheme,
#[error("client tls host_name override is not supported with the quiche backend")]
HostNameUnsupported,
#[error("the quiche backend cannot disable GSO; drop --*-quic-gso=false or use the quinn backend")]
GsoUnsupported,
#[error("missing ALPN")]
MissingAlpn,
#[error("failed to decode ALPN")]
DecodeAlpn(#[from] std::str::Utf8Error),
#[error("unsupported ALPN: {0}")]
UnsupportedAlpn(String),
#[error("failed to resolve bind address")]
ResolveBind(#[source] std::io::Error),
#[error("failed to get local address")]
NoLocalAddr,
#[error("--tls-cert and --tls-key are required with the quiche backend")]
CertRequired,
#[error("must provide matching --tls-cert and --tls-key pairs")]
CertPairMismatch,
#[error("failed to connect to quiche server")]
Connect(#[source] std::io::Error),
#[error(transparent)]
Connection(#[from] web_transport_quiche::ez::ConnectionError),
#[error("failed to establish quiche connection")]
Establish(#[source] web_transport_quiche::ez::ConnectionError),
#[error("failed to connect to quiche server")]
ClientConnect(#[from] web_transport_quiche::ClientError),
#[error(transparent)]
ConnectRejected(#[from] crate::ConnectError),
#[error("failed to create quiche server")]
ServerBuild(#[source] std::io::Error),
#[error("failed to accept WebTransport request")]
AcceptRequest(#[source] web_transport_quiche::ServerError),
#[error("failed to accept quiche WebTransport")]
Accept(#[source] web_transport_quiche::ServerError),
#[error("failed to close quiche WebTransport request")]
Reject(#[source] web_transport_quiche::ServerError),
#[error(transparent)]
Tls(#[from] crate::tls::Error),
}
type Result<T> = std::result::Result<T, Error>;
fn apply_settings(settings: &mut web_transport_quiche::Settings, quic: &Resolved) -> Result<()> {
settings.initial_max_streams_bidi = quic.max_streams;
settings.initial_max_streams_uni = quic.max_streams;
settings.max_idle_timeout = Some(quic.idle_timeout);
settings.discover_path_mtu = quic.mtu_discovery;
if let Some(family) = quic.congestion_control {
settings.cc_algorithm = cc_algorithm(family).to_owned();
}
if let Some(dir) = quic.qlog_dir() {
settings.qlog_dir = Some(dir.to_string_lossy().into_owned());
tracing::info!(dir = %dir.display(), "writing qlog");
}
Ok(())
}
fn cc_algorithm(family: CongestionControl) -> &'static str {
match family {
CongestionControl::Loss => "cubic",
CongestionControl::Delay => "bbr2_gcongestion",
}
}
#[derive(Clone)]
pub(crate) struct QuicheClient {
pub bind: net::SocketAddr,
pub verification: crate::tls::Verification,
pub http_bootstrap: bool,
pub quic: Resolved,
}
impl QuicheClient {
pub fn new(config: &ClientConfig) -> Result<Self> {
if config.tls.host_name.is_some() {
return Err(Error::HostNameUnsupported);
}
let quic = config.quic.resolve();
if quic.gso_disabled() {
return Err(Error::GsoUnsupported);
}
Ok(Self {
bind: config.bind,
verification: config.tls.verification()?,
http_bootstrap: config.tls.allows_http_bootstrap(),
quic,
})
}
pub async fn connect(&self, url: Url, versions: &moq_net::Versions) -> Result<web_transport_quiche::Connection> {
use crate::tls::Verification;
let host = url.host().ok_or(Error::InvalidDnsName)?.to_string();
let port = url.port().unwrap_or(443);
let (url, verification) = if url.scheme() == "http" {
let mut https = url.clone();
https.set_scheme("https").expect("https is a valid scheme");
if self.http_bootstrap {
let pin = fetch_fingerprint(&url).await?;
(https, Verification::Fingerprints(vec![pin]))
} else {
tracing::warn!(
"ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification"
);
(https, self.verification.clone())
}
} else {
(url, self.verification.clone())
};
let alpns: Vec<Vec<u8>> = match url.scheme() {
"https" => vec![web_transport_quiche::ALPN.as_bytes().to_vec()],
"moqt" | "moql" => versions.alpns().iter().map(|alpn| alpn.as_bytes().to_vec()).collect(),
_ => return Err(Error::InvalidScheme),
};
let mut settings = web_transport_quiche::Settings::default();
settings.verify_peer = !matches!(verification, Verification::Disabled);
settings.alpn = alpns;
apply_settings(&mut settings, &self.quic)?;
let mut builder = web_transport_quiche::ez::ClientBuilder::default()
.with_settings(settings)
.with_bind(self.bind)?;
match verification {
Verification::Disabled => {}
Verification::Fingerprints(hashes) => {
builder = builder.with_server_certificate_hashes(hashes);
}
Verification::Roots { custom, system } => {
let mut roots = custom;
if system {
let native = rustls_native_certs::load_native_certs();
for err in native.errors {
tracing::warn!(%err, "failed to load native root cert");
}
roots.extend(native.certs);
}
if !roots.is_empty() {
builder = builder.with_root_certificates(roots);
}
}
}
tracing::debug!(%url, "connecting via quiche");
let mut request = web_transport_quiche::proto::ConnectRequest::new(url.clone());
for alpn in versions.alpns() {
request = request.with_protocol(alpn.to_string());
}
match url.scheme() {
"https" => {
let conn = builder
.connect(&host, port)
.await
.map_err(Error::Connect)?
.established()
.await
.map_err(Error::Establish)?;
let session = web_transport_quiche::Connection::connect(conn, request)
.await
.map_err(map_client_error)?;
Ok(session)
}
"moqt" | "moql" => {
let conn = builder
.connect(&host, port)
.await
.map_err(Error::Connect)?
.established()
.await
.map_err(Error::Establish)?;
let alpn = conn.alpn().ok_or(Error::MissingAlpn)?;
let alpn = std::str::from_utf8(&alpn)?;
let response = web_transport_quiche::proto::ConnectResponse::OK.with_protocol(alpn);
Ok(web_transport_quiche::Connection::raw(conn, request, response))
}
_ => unreachable!("unsupported URL scheme: {}", url.scheme()),
}
}
}
async fn fetch_fingerprint(url: &Url) -> Result<[u8; 32]> {
let mut fp = url.clone();
fp.set_path("/certificate.sha256");
fp.set_query(None);
fp.set_fragment(None);
tracing::warn!(url = %fp, "performing insecure HTTP request for certificate fingerprint");
let resp = reqwest::get(fp.as_str())
.await
.map_err(Error::FetchFingerprint)?
.error_for_status()
.map_err(Error::FingerprintStatus)?;
let text = resp.text().await.map_err(Error::ReadFingerprint)?;
let bytes = hex::decode(text.trim()).map_err(Error::InvalidFingerprint)?;
bytes.try_into().map_err(|v: Vec<u8>| Error::FingerprintLength(v.len()))
}
impl Error {
pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
match self {
Self::ConnectRejected(err) => Some(*err),
Self::ClientConnect(err) => classify_client_error(err),
_ => None,
}
}
}
fn map_client_error(err: web_transport_quiche::ClientError) -> Error {
if let Some(err) = classify_client_error(&err) {
return err.into();
}
err.into()
}
fn classify_client_error(err: &web_transport_quiche::ClientError) -> Option<crate::ConnectError> {
match err {
web_transport_quiche::ClientError::Connect(err) => classify_connect_error(err),
_ => None,
}
}
fn classify_connect_error(err: &web_transport_quiche::h3::ConnectError) -> Option<crate::ConnectError> {
match err {
web_transport_quiche::h3::ConnectError::Status(status) => crate::ConnectError::from_status_u16(status.as_u16()),
web_transport_quiche::h3::ConnectError::Proto(err) => classify_proto_error(err),
_ => None,
}
}
fn classify_proto_error(err: &web_transport_quiche::proto::ConnectError) -> Option<crate::ConnectError> {
match err {
web_transport_quiche::proto::ConnectError::ErrorStatus(status)
| web_transport_quiche::proto::ConnectError::WrongStatus(Some(status)) => {
crate::ConnectError::from_status_u16(status.as_u16())
}
_ => None,
}
}
pub(crate) struct QuicheServer {
pub server: web_transport_quiche::ez::Server,
pub certs: crate::tls::Certificates,
}
impl QuicheServer {
pub fn new(config: ServerConfig) -> Result<Self> {
if config.quic.quic_lb_id.is_some() {
tracing::warn!("QUIC-LB is not supported with the quiche backend; ignoring server ID");
}
let quic = config.quic.resolve();
if quic.gso_disabled() {
return Err(Error::GsoUnsupported);
}
let listen =
crate::util::resolve(config.bind.as_deref(), crate::server::DEFAULT_BIND).map_err(Error::ResolveBind)?;
let (chain, key) = if !config.tls.generate.is_empty() {
generate_quiche_cert(&config.tls.generate)?
} else {
if config.tls.cert.is_empty() || config.tls.key.is_empty() {
return Err(Error::CertRequired);
}
if config.tls.cert.len() != config.tls.key.len() {
return Err(Error::CertPairMismatch);
}
load_quiche_cert(&config.tls.cert[0], &config.tls.key[0])?
};
let provider = crypto::provider();
let fingerprints: Vec<String> = chain
.iter()
.map(|cert| hex::encode(crypto::sha256(&provider, cert.as_ref())))
.collect();
let certs = crate::tls::Certificates::new(Arc::new(RwLock::new(crate::tls::Info {
#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
certs: Vec::new(),
fingerprints,
})));
let mut alpns: Vec<Vec<u8>> = config
.versions()
.alpns()
.iter()
.map(|alpn| alpn.as_bytes().to_vec())
.collect();
alpns.push(b"h3".to_vec());
let mut settings = web_transport_quiche::Settings::default();
settings.alpn = alpns;
apply_settings(&mut settings, &quic)?;
let server = web_transport_quiche::ez::ServerBuilder::default()
.with_settings(settings)
.with_bind(listen)?
.with_single_cert(chain, key)
.map_err(Error::ServerBuild)?;
Ok(Self { server, certs })
}
pub fn accept(&mut self) -> impl std::future::Future<Output = Option<web_transport_quiche::ez::Incoming>> + '_ {
self.server.accept()
}
pub fn certificates(&self) -> crate::tls::Certificates {
self.certs.clone()
}
pub fn local_addr(&self) -> Result<net::SocketAddr> {
self.server.local_addrs().first().copied().ok_or(Error::NoLocalAddr)
}
pub fn close(&mut self) {
}
}
fn load_quiche_cert(
cert_path: &Path,
key_path: &Path,
) -> crate::tls::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let chain = crate::tls::read_certs(cert_path)?;
if chain.is_empty() {
return Err(crate::tls::Error::Empty);
}
let key = PrivateKeyDer::from_pem_file(key_path).map_err(crate::tls::Error::Key)?;
Ok((chain, key))
}
#[cfg(any(feature = "aws-lc-rs", feature = "ring"))]
fn generate_quiche_cert(
hostnames: &[String],
) -> crate::tls::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let key_pair = rcgen::KeyPair::generate()?;
let mut params = rcgen::CertificateParams::new(hostnames)?;
params.not_before = ::time::OffsetDateTime::now_utc() - ::time::Duration::days(1);
params.not_after = params.not_before + ::time::Duration::days(14);
let cert = params.self_signed(&key_pair)?;
let key_der = key_pair.serialized_der().to_vec();
let key = PrivateKeyDer::Pkcs8(key_der.into());
Ok((vec![cert.into()], key))
}
#[cfg(not(any(feature = "aws-lc-rs", feature = "ring")))]
fn generate_quiche_cert(
hostnames: &[String],
) -> crate::tls::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
Err(crate::tls::Error::NoCryptoProvider)
}
pub(crate) async fn accept(
incoming: web_transport_quiche::ez::Incoming,
alpns: Vec<&'static str>,
) -> Result<(
web_transport_quiche::Connection,
Option<Url>,
Option<crate::tls::PeerIdentity>,
)> {
tracing::debug!(ip = %incoming.peer_addr(), "accepting via quiche");
let conn = incoming.accept().await?;
let alpn = conn.alpn().ok_or(Error::MissingAlpn)?;
let alpn = std::str::from_utf8(&alpn)?;
tracing::debug!(ip = %conn.peer_addr(), ?alpn, "accepted via quiche");
match alpn {
web_transport_quiche::ALPN => {
let request = web_transport_quiche::h3::Request::accept(conn)
.await
.map_err(Error::AcceptRequest)?;
let url = Some(request.url.clone());
let mut response = web_transport_quiche::proto::ConnectResponse::OK;
if let Some(protocol) = request.protocols.iter().find(|p| alpns.contains(&p.as_str())) {
response = response.with_protocol(protocol);
}
let session = request.respond(response).await.map_err(Error::Accept)?;
Ok((session, url, None))
}
alpn if alpns.contains(&alpn) => {
let request = ConnectRequest::new("moqt://".to_string().parse::<Url>().unwrap());
let response = web_transport_quiche::proto::ConnectResponse::OK.with_protocol(alpn);
let session = web_transport_quiche::Connection::raw(conn, request, response);
Ok((session, None, None))
}
_ => Err(Error::UnsupportedAlpn(alpn.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cc_algorithm_names_are_valid() {
assert_eq!(cc_algorithm(CongestionControl::Loss), "cubic");
assert_eq!(cc_algorithm(CongestionControl::Delay), "bbr2_gcongestion");
}
#[test]
fn apply_settings_writes_cc_algorithm() {
let mut quic = crate::quic::Client::default();
let mut settings = web_transport_quiche::Settings::default();
let default = settings.cc_algorithm.clone();
apply_settings(&mut settings, &quic.resolve()).unwrap();
assert_eq!(settings.cc_algorithm, default);
quic.congestion_control = Some(CongestionControl::Delay);
apply_settings(&mut settings, &quic.resolve()).unwrap();
assert_eq!(settings.cc_algorithm, "bbr2_gcongestion");
}
}