#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Transport {
Tls,
Quic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum AppProtocol {
Http1,
Http2,
Http3,
DnsOverTls,
DnsOverQuic,
DnsOverHttps,
#[default]
Unknown,
}
impl AppProtocol {
pub fn as_str(&self) -> &'static str {
match self {
AppProtocol::Http1 => "http/1.1",
AppProtocol::Http2 => "h2",
AppProtocol::Http3 => "h3",
AppProtocol::DnsOverTls => "dot",
AppProtocol::DnsOverQuic => "doq",
AppProtocol::DnsOverHttps => "doh",
AppProtocol::Unknown => "unknown",
}
}
pub fn is_encrypted_dns(&self) -> bool {
matches!(
self,
AppProtocol::DnsOverTls | AppProtocol::DnsOverQuic | AppProtocol::DnsOverHttps
)
}
pub fn is_http(&self) -> bool {
matches!(
self,
AppProtocol::Http1 | AppProtocol::Http2 | AppProtocol::Http3
)
}
}
pub fn classify_alpn_token(token: &str) -> Option<AppProtocol> {
if token == "h2" {
Some(AppProtocol::Http2)
} else if token == "h3" || token.starts_with("h3-") {
Some(AppProtocol::Http3)
} else if token == "http/1.1" || token == "http/1.0" {
Some(AppProtocol::Http1)
} else if token == "dot" {
Some(AppProtocol::DnsOverTls)
} else if token == "doq" || token.starts_with("doq-") {
Some(AppProtocol::DnsOverQuic)
} else {
None
}
}
pub fn classify<S: AsRef<str>>(
alpn: &[S],
sni: Option<&str>,
transport: Transport,
port: u16,
) -> AppProtocol {
let mut proto = alpn
.iter()
.find_map(|t| classify_alpn_token(t.as_ref()))
.unwrap_or(AppProtocol::Unknown);
if port == 853 && !proto.is_encrypted_dns() {
proto = match transport {
Transport::Tls => AppProtocol::DnsOverTls,
Transport::Quic => AppProtocol::DnsOverQuic,
};
}
if proto.is_http()
&& let Some(host) = sni
&& is_known_doh_host(host)
{
proto = AppProtocol::DnsOverHttps;
}
proto
}
pub fn is_known_doh_host(sni: &str) -> bool {
let host = sni.trim_end_matches('.').to_ascii_lowercase();
KNOWN_DOH_HOSTS.contains(&host.as_str())
}
const KNOWN_DOH_HOSTS: &[&str] = &[
"dns.google",
"dns.google.com",
"cloudflare-dns.com",
"mozilla.cloudflare-dns.com",
"security.cloudflare-dns.com",
"family.cloudflare-dns.com",
"one.one.one.one",
"dns.quad9.net",
"dns9.quad9.net",
"dns10.quad9.net",
"dns11.quad9.net",
"doh.opendns.com",
"dns.nextdns.io",
"doh.cleanbrowsing.org",
"dns.adguard-dns.com",
"dns.adguard.com",
"doh.dns.sb",
"dns.controld.com",
];
impl AppProtocol {
#[cfg(feature = "tls")]
pub fn from_tls_handshake(hs: &crate::tls::TlsHandshake, port: u16) -> AppProtocol {
let alpn: Vec<&str> = match hs.server_alpn.as_deref() {
Some(s) => vec![s],
None => hs.client_alpn.iter().map(String::as_str).collect(),
};
classify(&alpn, hs.sni.as_deref(), Transport::Tls, port)
}
#[cfg(feature = "quic")]
pub fn from_quic_initial(qi: &crate::quic::QuicInitial, port: u16) -> AppProtocol {
classify(&qi.alpn, qi.sni.as_deref(), Transport::Quic, port)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alpn_h2_h3_http1() {
assert_eq!(classify_alpn_token("h2"), Some(AppProtocol::Http2));
assert_eq!(classify_alpn_token("h3"), Some(AppProtocol::Http3));
assert_eq!(classify_alpn_token("h3-29"), Some(AppProtocol::Http3));
assert_eq!(classify_alpn_token("http/1.1"), Some(AppProtocol::Http1));
assert_eq!(classify_alpn_token("spdy/3"), None);
}
#[test]
fn alpn_encrypted_dns_tokens() {
assert_eq!(classify_alpn_token("dot"), Some(AppProtocol::DnsOverTls));
assert_eq!(classify_alpn_token("doq"), Some(AppProtocol::DnsOverQuic));
assert_eq!(
classify_alpn_token("doq-i03"),
Some(AppProtocol::DnsOverQuic)
);
}
#[test]
fn port_853_fallback_without_alpn() {
let none: &[&str] = &[];
assert_eq!(
classify(none, None, Transport::Tls, 853),
AppProtocol::DnsOverTls
);
assert_eq!(
classify(none, None, Transport::Quic, 853),
AppProtocol::DnsOverQuic
);
}
#[test]
fn doh_reclassifies_http_to_known_resolver() {
assert_eq!(
classify(&["h2"], Some("example.com"), Transport::Tls, 443),
AppProtocol::Http2
);
assert_eq!(
classify(&["h2"], Some("cloudflare-dns.com"), Transport::Tls, 443),
AppProtocol::DnsOverHttps
);
assert_eq!(
classify(&["h3"], Some("dns.google"), Transport::Quic, 443),
AppProtocol::DnsOverHttps
);
}
#[test]
fn alpn_beats_port_for_dot() {
assert_eq!(
classify(&["dot"], None, Transport::Tls, 853),
AppProtocol::DnsOverTls
);
}
#[test]
fn doh_host_canonicalisation() {
assert!(is_known_doh_host("Cloudflare-DNS.com."));
assert!(is_known_doh_host("dns.google"));
assert!(!is_known_doh_host("example.com"));
}
#[test]
fn slugs_and_predicates() {
assert_eq!(AppProtocol::DnsOverHttps.as_str(), "doh");
assert!(AppProtocol::DnsOverQuic.is_encrypted_dns());
assert!(AppProtocol::Http3.is_http());
assert!(!AppProtocol::Http2.is_encrypted_dns());
}
}