#![deny(
clippy::all,
clippy::cargo,
clippy::nursery,
)]
#![allow(
clippy::suboptimal_flops,
clippy::redundant_pub_crate,
clippy::fallible_impl_from
)]
#![allow(clippy::multiple_crate_versions)]
#![allow(clippy::use_self)]
#![deny(missing_docs)]
#![deny(missing_debug_implementations)]
#![deny(rustdoc::all)]
pub use error::{InvalidUrlError, ResolveDnsError, TtfbError};
pub use outcome::{DurationPair, TtfbOutcome};
use hickory_resolver::Resolver as DnsResolver;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, Error, SignatureScheme};
use rustls_connector::RustlsConnector;
use std::io::{Read as IoRead, Write as IoWrite};
use std::net::{IpAddr, TcpStream};
use std::str::FromStr;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use url::Url;
mod error;
mod outcome;
const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
trait IoReadAndWrite: IoWrite + IoRead {}
impl<T: IoRead + IoWrite> IoReadAndWrite for T {}
pub fn ttfb(
input: impl AsRef<str>,
allow_insecure_certificates: bool,
) -> Result<TtfbOutcome, TtfbError> {
let input = input.as_ref();
if input.is_empty() {
return Err(TtfbError::InvalidUrl(InvalidUrlError::MissingInput));
}
let input = input.to_string();
let input = prepend_default_scheme_if_necessary(input);
let url = parse_input_as_url(&input)?;
check_scheme_is_allowed(&url)?;
let (addr, dns_duration) = resolve_dns_if_necessary(&url)?;
let port = url.port_or_known_default().unwrap();
let (tcp, tcp_connect_duration) = tcp_connect(addr, port)?;
let (mut tcp, tls_handshake_duration) =
tls_handshake_if_necessary(tcp, &url, allow_insecure_certificates)?;
let (http_get_send_duration, http_ttfb_duration) = execute_http_get(&mut tcp, &url)?;
Ok(TtfbOutcome::new(
input,
addr,
port,
dns_duration,
tcp_connect_duration,
tls_handshake_duration,
http_get_send_duration,
http_ttfb_duration,
))
}
fn tcp_connect(addr: IpAddr, port: u16) -> Result<(TcpStream, Duration), TtfbError> {
let addr_w_port = (addr, port);
let now = Instant::now();
let mut tcp = TcpStream::connect(addr_w_port).map_err(TtfbError::CantConnectTcp)?;
tcp.flush().map_err(TtfbError::OtherStreamError)?;
let tcp_connect_duration = now.elapsed();
Ok((tcp, tcp_connect_duration))
}
fn tls_handshake_if_necessary(
tcp: TcpStream,
url: &Url,
allow_insecure_certificates: bool,
) -> Result<(Box<dyn IoReadAndWrite>, Option<Duration>), TtfbError> {
if url.scheme() == "https" {
let connector: RustlsConnector = if allow_insecure_certificates {
ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AllowInvalidCertsVerifier))
.with_no_client_auth()
.into()
} else {
RustlsConnector::new_with_native_certs()
.unwrap_or_else(|_| RustlsConnector::new_with_webpki_roots_certs())
};
let now = Instant::now();
let certificate_host = url.host_str().unwrap_or("");
let mut stream = connector
.connect(certificate_host, tcp)
.map_err(TtfbError::CantVerifyTls)?;
stream.flush().map_err(TtfbError::OtherStreamError)?;
let tls_handshake_duration = now.elapsed();
Ok((Box::new(stream), Some(tls_handshake_duration)))
} else {
Ok((Box::new(tcp), None))
}
}
#[derive(Debug)]
pub struct AllowInvalidCertsVerifier;
impl ServerCertVerifier for AllowInvalidCertsVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
vec![
SignatureScheme::RSA_PKCS1_SHA1,
SignatureScheme::ECDSA_SHA1_Legacy,
SignatureScheme::RSA_PKCS1_SHA256,
SignatureScheme::ECDSA_NISTP256_SHA256,
SignatureScheme::RSA_PKCS1_SHA384,
SignatureScheme::ECDSA_NISTP384_SHA384,
SignatureScheme::RSA_PKCS1_SHA512,
SignatureScheme::ECDSA_NISTP521_SHA512,
SignatureScheme::RSA_PSS_SHA256,
SignatureScheme::RSA_PSS_SHA384,
SignatureScheme::RSA_PSS_SHA512,
SignatureScheme::ED25519,
SignatureScheme::ED448,
]
}
}
fn execute_http_get(
tcp: &mut Box<dyn IoReadAndWrite>,
url: &Url,
) -> Result<(Duration, Duration), TtfbError> {
let header = build_http11_header(url);
let now = Instant::now();
tcp.write_all(header.as_bytes())
.map_err(TtfbError::CantConnectHttp)?;
tcp.flush().map_err(TtfbError::OtherStreamError)?;
let get_request_send_duration = now.elapsed();
let mut one_byte_buf = [0_u8];
let now = Instant::now();
tcp.read_exact(&mut one_byte_buf)
.map_err(|_e| TtfbError::NoHttpResponse)?;
let http_ttfb_duration = now.elapsed();
Ok((
get_request_send_duration,
http_ttfb_duration,
))
}
fn build_http11_header(url: &Url) -> String {
format!(
"GET {path} HTTP/1.1\r\n\
Host: {host}\r\n\
User-Agent: ttfb/{version}\r\n\
Accept: */*\r\n\
Accept-Encoding: gzip, deflate, br, zstd\r\n\
\r\n",
path = url.path(),
host = url.host_str().unwrap(),
version = CRATE_VERSION
)
}
fn parse_input_as_url(input: &str) -> Result<Url, TtfbError> {
Url::parse(input)
.map_err(|e| TtfbError::InvalidUrl(InvalidUrlError::WrongFormat(e.to_string())))
}
fn prepend_default_scheme_if_necessary(url: String) -> String {
const SCHEME_SEPARATOR: &str = "://";
const DEFAULT_SCHEME: &str = "http";
(!url.contains(SCHEME_SEPARATOR))
.then(|| format!("{DEFAULT_SCHEME}://{url}"))
.unwrap_or(url)
}
fn check_scheme_is_allowed(url: &Url) -> Result<(), TtfbError> {
let actual_scheme = url.scheme();
let allowed_scheme = actual_scheme == "http" || actual_scheme == "https";
if allowed_scheme {
Ok(())
} else {
Err(TtfbError::InvalidUrl(InvalidUrlError::WrongScheme(
actual_scheme.to_string(),
)))
}
}
fn resolve_dns_if_necessary(url: &Url) -> Result<(IpAddr, Option<Duration>), TtfbError> {
match url.domain() {
Some(domain) => {
if domain.eq("localhost") {
Ok((
IpAddr::from_str("127.0.0.1").unwrap(),
Some(Duration::default()),
))
} else {
resolve_dns(url).map(|(addr, dur)| (addr, Some(dur)))
}
}
None => {
let mut ip_str = url.host_str().unwrap();
let is_ipv6_addr = ip_str.starts_with('[');
if is_ipv6_addr {
ip_str = &ip_str[1..ip_str.len() - 1];
}
let addr = IpAddr::from_str(ip_str)
.map_err(|e| TtfbError::InvalidUrl(InvalidUrlError::WrongFormat(e.to_string())))?;
Ok((addr, None))
}
}
}
fn resolve_dns(url: &Url) -> Result<(IpAddr, Duration), TtfbError> {
let resolver = DnsResolver::from_system_conf()
.or_else(|_| DnsResolver::default())
.map_err(TtfbError::CantConfigureDNSError)?;
let begin = Instant::now();
let response = {
let url = url.clone();
thread::spawn(move || {
resolver
.lookup_ip(url.host_str().unwrap())
.map_err(|err| TtfbError::CantResolveDns(ResolveDnsError::Other(Box::new(err))))
})
.join()
.unwrap()?
};
let duration = begin.elapsed();
let ipv4_addrs = response
.iter()
.filter(|addr| addr.is_ipv4())
.collect::<Vec<_>>();
let ipv6_addrs = response
.iter()
.filter(|addr| addr.is_ipv6())
.collect::<Vec<_>>();
if !ipv4_addrs.is_empty() {
Ok((ipv4_addrs[0], duration))
} else if !ipv6_addrs.is_empty() {
Ok((ipv6_addrs[0], duration))
} else {
Err(TtfbError::CantResolveDns(ResolveDnsError::NoResults))
}
}
#[cfg(all(test, not(network_tests)))]
mod tests {
use super::*;
#[test]
fn test_parse_input_as_url() {
parse_input_as_url("http://google.com").expect("to be valid");
parse_input_as_url("https://google.com:443").expect("to be valid");
parse_input_as_url("http://google.com:80").expect("to be valid");
parse_input_as_url("google.com:80").expect("to be valid");
parse_input_as_url("http://google.com/foobar").expect("to be valid");
parse_input_as_url("https://google.com:443/foobar").expect("to be valid");
parse_input_as_url("https://goo-gle.com:443/foobar").expect("to be valid");
parse_input_as_url("https://goo-gle.com:443/foobar?124141").expect("to be valid");
parse_input_as_url("https://subdomain.goo-gle.com:443/foobar?124141").expect("to be valid");
parse_input_as_url("https://192.168.1.102:443/foobar?124141").expect("to be valid");
parse_input_as_url("http://localhost").expect("to be valid");
}
#[test]
fn test_append_scheme_if_necessary() {
assert_eq!(
prepend_default_scheme_if_necessary("phip1611.de".to_owned()),
"http://phip1611.de"
);
assert_eq!(
prepend_default_scheme_if_necessary("https://phip1611.de".to_owned()),
"https://phip1611.de"
);
assert_eq!(
prepend_default_scheme_if_necessary("192.168.1.102:443/foobar?124141".to_owned()),
"http://192.168.1.102:443/foobar?124141"
);
assert_eq!(
prepend_default_scheme_if_necessary(
"https://192.168.1.102:443/foobar?124141".to_owned()
),
"https://192.168.1.102:443/foobar?124141"
);
assert_eq!(
prepend_default_scheme_if_necessary("ftp://192.168.1.102:443/foobar?124141".to_owned()),
"ftp://192.168.1.102:443/foobar?124141"
);
}
#[test]
fn test_dns_if_necessary_localhost_shortcut() {
let url = url::Url::from_str("http://localhost").unwrap();
assert_eq!(
resolve_dns_if_necessary(&url),
Ok((
IpAddr::from_str("127.0.0.1").unwrap(),
Some(Duration::from_secs(0))
))
);
}
#[test]
fn test_check_scheme() {
check_scheme_is_allowed(
&Url::from_str(&prepend_default_scheme_if_necessary(
"phip1611.de".to_owned(),
))
.unwrap(),
)
.expect("must accept http");
check_scheme_is_allowed(
&Url::from_str(&prepend_default_scheme_if_necessary(
"https://phip1611.de".to_owned(),
))
.unwrap(),
)
.expect("must accept http");
check_scheme_is_allowed(
&Url::from_str(&prepend_default_scheme_if_necessary(
"ftp://phip1611.de".to_owned(),
))
.unwrap(),
)
.expect_err("must not accept ftp");
}
}
#[cfg(all(test, network_tests))]
mod network_tests {
use super::*;
#[test]
fn test_resolve_dns_if_necessary() {
let url1 = Url::from_str("http://phip1611.de").expect("must be valid");
let url2 = Url::from_str("https://phip1611.de").expect("must be valid");
let url3 = Url::from_str("http://192.168.1.102").expect("must be valid");
let url4 = Url::from_str("http://[2001:0db8:3c4d:0015::1a2f:1a2b]").expect("must be valid");
let url5 = Url::from_str("http://[2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b]")
.expect("must be valid");
resolve_dns_if_necessary(&url1).expect("must be valid");
resolve_dns_if_necessary(&url2).expect("must be valid");
resolve_dns_if_necessary(&url3).expect("must be valid");
resolve_dns_if_necessary(&url4).expect("must be valid");
resolve_dns_if_necessary(&url5).expect("must be valid");
}
#[test]
fn test_http_dns_lookup_duration() {
let r = ttfb("http://phip1611.de".to_string(), false).unwrap();
assert!(r.dns_lookup_duration().is_some());
}
#[test]
fn test_http_no_tls_handshake() {
let r = ttfb("http://phip1611.de".to_string(), false).unwrap();
assert!(r.tls_handshake_duration().is_none());
}
#[test]
fn test_https_dns_lookup_duration() {
let r = ttfb("https://phip1611.de".to_string(), false).unwrap();
assert!(r.dns_lookup_duration().is_some());
}
#[test]
fn test_https_tls_handshake_duration() {
let r = ttfb("https://phip1611.de".to_string(), false).unwrap();
assert!(r.tls_handshake_duration().is_some());
}
#[test]
fn test_https_expired_certificate_error() {
let r = ttfb("https://expired.badssl.com".to_string(), false);
assert!(r.is_err());
}
#[test]
fn test_https_expired_certificate_ignore_error() {
let r = ttfb("https://expired.badssl.com".to_string(), true).unwrap();
assert!(r.dns_lookup_duration().is_some());
}
#[test]
fn test_https_self_signed_certificate_error() {
let r = ttfb("https://self-signed.badssl.com".to_string(), false);
assert!(r.is_err());
}
#[test]
fn test_https_self_signed_certificate_ignore_error() {
let r = ttfb("https://self-signed.badssl.com".to_string(), true).unwrap();
assert!(r.dns_lookup_duration().is_some());
}
#[test]
fn test_https_wrong_host_certificate_error() {
let r = ttfb("https://wrong.host.badssl.com".to_string(), false);
assert!(r.is_err());
}
#[test]
fn test_https_wrong_host_certificate_ignore_error() {
let r = ttfb("https://wrong.host.badssl.com".to_string(), true).unwrap();
assert!(r.dns_lookup_duration().is_some());
assert!(r.tls_handshake_duration().is_some());
}
#[test]
fn test_https_ip_address_tls_handshake() {
let r = ttfb("https://1.1.1.1".to_string(), false).unwrap();
assert!(
r.tls_handshake_duration().is_some(),
"must execute TLS handshake"
);
}
}