use std::io::{self, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use quinn::crypto::rustls::QuicClientConfig;
use quinn::{ClientConfig as QuinnClientConfig, Endpoint};
use rustls::{ClientConfig, RootCertStore};
use crate::pinger::Pinger;
use crate::uri::get_uri;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_PORT: u16 = 443;
const DEFAULT_ALPN: &[u8] = b"h3";
pub struct QuicPinger {
pub endpoint: String,
pub timeout: Duration,
pub alpn: Vec<Vec<u8>>,
tls_config: Option<Arc<ClientConfig>>,
}
impl QuicPinger {
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
timeout: DEFAULT_TIMEOUT,
alpn: vec![DEFAULT_ALPN.to_vec()],
tls_config: None,
}
}
pub fn with_timeout(mut self, t: Duration) -> Self {
self.timeout = t;
self
}
pub fn with_alpn(mut self, alpn: Vec<Vec<u8>>) -> Self {
self.alpn = alpn;
self
}
pub fn with_tls_config(mut self, config: Arc<ClientConfig>) -> Self {
self.tls_config = Some(config);
self
}
}
#[async_trait]
impl Pinger for QuicPinger {
async fn ping(&self) -> Result<()> {
let (host, port) = parse_endpoint(&self.endpoint)?;
let server_addr = resolve_first(&host, port).await?;
let crypto = build_rustls_config(self.tls_config.as_deref(), &self.alpn)?;
let quic_crypto = QuicClientConfig::try_from(crypto)
.map_err(|e| io::Error::other(format!("quinn rustls config: {e}")))?;
let client_config = QuinnClientConfig::new(Arc::new(quic_crypto));
let mut endpoint = Endpoint::client(unspecified_for(server_addr))
.map_err(|e| io::Error::other(format!("quinn Endpoint::client: {e}")))?;
endpoint.set_default_client_config(client_config);
let connecting = endpoint
.connect(server_addr, &host)
.map_err(|e| io::Error::other(format!("quinn connect: {e}")))?;
match tokio::time::timeout(self.timeout, connecting).await {
Ok(Ok(connection)) => {
connection.close(0u32.into(), b"ping done");
endpoint.wait_idle().await;
Ok(())
}
Ok(Err(e)) => Err(io::Error::other(format!("quinn handshake: {e}"))),
Err(_) => Err(io::Error::new(
io::ErrorKind::TimedOut,
"QUIC handshake timed out",
)),
}
}
}
fn parse_endpoint(endpoint: &str) -> Result<(String, u16)> {
let trimmed = endpoint.trim();
if trimmed.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"QUIC endpoint is empty",
));
}
let uri = get_uri(trimmed);
let scheme = uri.scheme.to_ascii_lowercase();
match scheme.as_str() {
"" | "quic" | "https" | "h3" => {}
other => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"scheme '{other}' is not supported by QuicPinger \
(use quic://, https://, or host:port)"
),
));
}
}
if uri.domain.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"QUIC endpoint is missing a host",
));
}
let port = if uri.port > 0 {
uri.port as u16
} else {
DEFAULT_PORT
};
Ok((uri.domain, port))
}
async fn resolve_first(host: &str, port: u16) -> Result<SocketAddr> {
let target = format!("{host}:{port}");
let addrs: Vec<SocketAddr> = tokio::net::lookup_host(&target).await?.collect();
if let Some(v4) = addrs.iter().find(|a| a.is_ipv4()) {
return Ok(*v4);
}
addrs
.into_iter()
.next()
.ok_or_else(|| io::Error::other(format!("DNS lookup returned no addresses for {target}")))
}
fn unspecified_for(remote: SocketAddr) -> SocketAddr {
if remote.is_ipv6() {
"[::]:0".parse().unwrap()
} else {
"0.0.0.0:0".parse().unwrap()
}
}
fn build_rustls_config(inject: Option<&ClientConfig>, alpn: &[Vec<u8>]) -> Result<ClientConfig> {
let mut config = match inject {
Some(c) => c.clone(),
None => default_quic_config()?,
};
config.alpn_protocols = alpn.to_vec();
Ok(config)
}
fn default_quic_config() -> Result<ClientConfig> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder_with_provider(provider)
.with_protocol_versions(&[&rustls::version::TLS13])
.map_err(|e| io::Error::other(format!("rustls protocol: {e}")))?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_endpoint_handles_schemes() {
assert_eq!(
parse_endpoint("quic://example.com:8443").unwrap(),
("example.com".to_string(), 8443)
);
assert_eq!(
parse_endpoint("https://example.com").unwrap(),
("example.com".to_string(), 443)
);
assert_eq!(
parse_endpoint("example.com:443").unwrap(),
("example.com".to_string(), 443)
);
assert_eq!(
parse_endpoint("example.com").unwrap(),
("example.com".to_string(), 443)
);
}
#[test]
fn parse_endpoint_rejects_unsupported_scheme() {
let err = parse_endpoint("ws://example.com").unwrap_err();
assert!(err.to_string().contains("scheme 'ws'"));
}
#[test]
fn parse_endpoint_rejects_empty() {
assert!(parse_endpoint("").is_err());
assert!(parse_endpoint(" ").is_err());
}
#[test]
fn unspecified_for_picks_v4_or_v6() {
let v4: SocketAddr = "1.2.3.4:443".parse().unwrap();
assert_eq!(unspecified_for(v4), "0.0.0.0:0".parse().unwrap());
let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
assert_eq!(unspecified_for(v6), "[::]:0".parse().unwrap());
}
}