use core::{net::SocketAddr, time::Duration};
use std::{io, time::Instant};
use tokio::io::{AsyncRead, AsyncWrite};
use ts_http_util::{ClientExt, EmptyBody, Http1};
use ts_transport_derp::ServerConnInfo;
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("io error occurred")]
Io,
#[error("bad http status")]
HttpStatus,
#[error("invalid parameter")]
InvalidParam,
}
impl From<io::Error> for Error {
fn from(_: io::Error) -> Self {
Error::Io
}
}
impl From<ts_http_util::Error> for Error {
fn from(err: ts_http_util::Error) -> Self {
match err {
ts_http_util::Error::InvalidParam => Error::InvalidParam,
ts_http_util::Error::Io | ts_http_util::Error::Timeout => Error::Io,
}
}
}
impl From<ts_transport_derp::dial::Error> for Error {
fn from(value: ts_transport_derp::dial::Error) -> Self {
use ts_transport_derp::dial;
match value {
dial::Error::Io => Error::Io,
dial::Error::InvalidParam => Error::InvalidParam,
}
}
}
#[derive(Debug, Copy, Clone)]
pub struct Config {
pub n_warmup: usize,
pub n_samples: usize,
pub fail_on_status: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
n_warmup: 1,
n_samples: 1,
fail_on_status: true,
}
}
}
pub async fn measure_https_latency<'c>(
servers: impl IntoIterator<Item = &'c ServerConnInfo>,
config: Config,
) -> Option<(Duration, &'c ServerConnInfo, SocketAddr)> {
if config.n_samples == 0 {
tracing::warn!("requested to measure https latency with 0 samples");
return None;
}
let mut servers = servers.into_iter();
loop {
let (conn, server, remote) =
match ts_transport_derp::dial::dial_region_tls(&mut servers).await {
Ok(Some(x)) => x,
Ok(None) => {
tracing::warn!("ran out of servers to dial");
return None;
}
Err(e) => {
tracing::error!(error = %e, "dialing tls");
continue;
}
};
match measure_server_latency(conn, server, &config).await {
Ok(dur) => return Some((dur, server, remote)),
Err(e) => {
tracing::error!(error = %e, %remote, %server.hostname, "measuring server latency failed, try next server");
}
}
}
}
pub async fn measure_server_latency(
conn: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
server: &ServerConnInfo,
config: &Config,
) -> Result<Duration, Error> {
let client: Http1<EmptyBody> = ts_http_util::http1::connect(conn).await?;
for _ in 0..config.n_warmup {
if let Err(e) = measure_http_request(server, &client, config.fail_on_status).await {
tracing::error!(error = %e, "error during https warmup");
}
}
let mut sum = Duration::ZERO;
for _ in 0..config.n_samples {
sum += measure_http_request(server, &client, config.fail_on_status).await?;
}
Ok(sum / config.n_samples as u32)
}
pub async fn measure_http_request(
server: &ServerConnInfo,
http_client: impl ts_http_util::Client<EmptyBody>,
fail_on_status: bool,
) -> Result<Duration, Error> {
let url: Url = format!("https://{}/derp/latency-check", server.hostname)
.parse()
.map_err(|_| Error::InvalidParam)?;
let start = Instant::now();
let resp = http_client.get(&url, None).await?;
let dur = start.elapsed();
if fail_on_status && !resp.status().is_success() {
tracing::error!(status = %resp.status());
return Err(Error::HttpStatus);
}
Ok(dur)
}
#[cfg(test)]
mod test {
use super::*;
fn info() -> ServerConnInfo {
ServerConnInfo::default_from_url(&"https://derp1f.tailscale.com".parse().unwrap()).unwrap()
}
#[tracing_test::traced_test]
#[tokio::test]
async fn basic_test() {
if !ts_test_util::run_net_tests() {
return;
}
let info = info();
let (latency, _info, remote) = measure_https_latency([&info], Default::default())
.await
.unwrap();
tracing::info!(?latency, %remote);
}
#[tracing_test::traced_test]
#[tokio::test]
async fn repeated() {
if !ts_test_util::run_net_tests() {
return;
}
let info = info();
let (conn, server, remote) = ts_transport_derp::dial::dial_region_tls([&info])
.await
.unwrap()
.unwrap();
let client: Http1<EmptyBody> = ts_http_util::http1::connect(conn).await.unwrap();
for _ in 0..10 {
let latency = measure_http_request(server, &client, true).await.unwrap();
tracing::info!(?latency, %remote);
}
}
}