use std::sync::Arc;
use std::time::Instant;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::{lookup_host, TcpStream};
use tokio_rustls::rustls::pki_types::ServerName;
use tokio_rustls::rustls::{ClientConfig, RootCertStore};
use tokio_rustls::TlsConnector;
use url::Url;
use webpki_roots::TLS_SERVER_ROOTS;
use crate::engine::timing::{build_report_from_phases, TimingPhases};
use crate::engine::tls::{report_for_http, report_for_https_failure, report_for_https_success};
use crate::error::{Result, TloxError};
use crate::models::{CertificateInfo, NewProbeResult, ProbeResult, ProbeStatus, TimingReport, TlsReport};
use crate::storage::SqliteStore;
#[derive(Debug, Clone, Default)]
pub struct ProxyMeasurement {
phases: TimingPhases,
}
impl ProxyMeasurement {
pub fn new() -> Self {
Self::default()
}
pub fn record_dns_ms(&mut self, dns_ms: u64) {
self.phases.dns_ms = dns_ms;
}
pub fn record_tcp_ms(&mut self, tcp_ms: u64) {
self.phases.tcp_ms = tcp_ms;
}
pub fn record_tls_ms(&mut self, tls_ms: u64) {
self.phases.tls_ms = tls_ms;
}
pub fn record_ttfb_ms(&mut self, ttfb_ms: u64) {
self.phases.ttfb_ms = ttfb_ms;
}
pub fn record_download_ms(&mut self, download_ms: u64) {
self.phases.download_ms = download_ms;
}
pub fn finish_report(&self) -> TimingReport {
build_report_from_phases(self.phases)
}
}
#[derive(Debug, Clone, Default)]
pub struct SimpleProbe;
#[derive(Debug, Clone, PartialEq, Eq)]
struct HttpResponseMetadata {
total_ms: u64,
status_code: Option<u16>,
status_text: Option<String>,
bytes: u64,
}
impl SimpleProbe {
pub fn new() -> Self {
Self
}
pub async fn probe(&self, target: &str) -> Result<NewProbeResult> {
let url = Url::parse(target).map_err(|_| TloxError::InvalidTarget(target.to_string()))?;
let is_https = url.scheme() == "https";
let host = url
.host_str()
.ok_or_else(|| TloxError::InvalidTarget(target.to_string()))?
.to_string();
let port = url
.port_or_known_default()
.ok_or_else(|| TloxError::InvalidTarget(target.to_string()))?;
let request_target = build_request_target(&url);
let mut measurement = ProxyMeasurement::new();
let total_start = Instant::now();
let dns_start = Instant::now();
let mut addresses = match lookup_host((host.as_str(), port)).await {
Ok(addresses) => addresses,
Err(error) => {
return Ok(failed_probe_result(
target,
&request_target,
finish_report(&measurement, total_start.elapsed().as_millis() as u64),
tls_report_for_failure(is_https, error.to_string()),
error.to_string(),
));
}
};
measurement.record_dns_ms(dns_start.elapsed().as_millis() as u64);
let Some(address) = addresses.next() else {
let error_message = "no addresses resolved".to_string();
return Ok(failed_probe_result(
target,
&request_target,
finish_report(&measurement, total_start.elapsed().as_millis() as u64),
tls_report_for_failure(is_https, error_message.clone()),
error_message,
));
};
let tcp_start = Instant::now();
let stream = match TcpStream::connect(address).await {
Ok(stream) => stream,
Err(error) => {
return Ok(failed_probe_result(
target,
&request_target,
finish_report(&measurement, total_start.elapsed().as_millis() as u64),
tls_report_for_failure(is_https, error.to_string()),
error.to_string(),
));
}
};
measurement.record_tcp_ms(tcp_start.elapsed().as_millis() as u64);
if is_https {
self.probe_https(target, &host, &request_target, stream, measurement, total_start)
.await
} else {
self.probe_http(target, &host, &request_target, stream, measurement, total_start)
.await
}
}
pub async fn probe_and_store(&self, store: &SqliteStore, target: &str) -> Result<ProbeResult> {
let result = self.probe(target).await?;
store.save_result(&result)
}
async fn probe_http(
&self,
target: &str,
host: &str,
request_target: &str,
mut stream: TcpStream,
mut measurement: ProxyMeasurement,
total_start: Instant,
) -> Result<NewProbeResult> {
match execute_request(
&mut stream,
host,
request_target,
&mut measurement,
total_start,
)
.await
{
Ok(response) => Ok(NewProbeResult {
target: target.to_string(),
request_path: request_target.to_string(),
report: finish_report(&measurement, response.total_ms),
status: ProbeStatus::Succeeded,
response_status_code: response.status_code,
response_status_text: response.status_text,
response_bytes: Some(response.bytes),
error_message: None,
tls: Some(report_for_http()),
}),
Err(error) => Ok(failed_probe_result(
target,
request_target,
finish_report(&measurement, total_start.elapsed().as_millis() as u64),
Some(report_for_http()),
error.to_string(),
)),
}
}
async fn probe_https(
&self,
target: &str,
host: &str,
request_target: &str,
stream: TcpStream,
mut measurement: ProxyMeasurement,
total_start: Instant,
) -> Result<NewProbeResult> {
let tls_start = Instant::now();
let connector = build_tls_connector();
let server_name = ServerName::try_from(host.to_string())
.map_err(|_| TloxError::InvalidTarget(target.to_string()))?;
let mut tls_stream = match connector.connect(server_name, stream).await {
Ok(stream) => stream,
Err(error) => {
return Ok(failed_probe_result(
target,
request_target,
finish_report(&measurement, total_start.elapsed().as_millis() as u64),
Some(report_for_https_failure(error.to_string())),
error.to_string(),
));
}
};
measurement.record_tls_ms(tls_start.elapsed().as_millis() as u64);
match execute_request(
&mut tls_stream,
host,
request_target,
&mut measurement,
total_start,
)
.await
{
Ok(response) => {
let (_, connection) = tls_stream.get_ref();
let tls = Some(report_for_https_success(
connection.protocol_version().map(|value| format!("{value:?}")),
connection
.negotiated_cipher_suite()
.map(|value| format!("{:?}", value.suite())),
extract_certificate_info(connection.peer_certificates()),
));
Ok(NewProbeResult {
target: target.to_string(),
request_path: request_target.to_string(),
report: finish_report(&measurement, response.total_ms),
status: ProbeStatus::Succeeded,
response_status_code: response.status_code,
response_status_text: response.status_text,
response_bytes: Some(response.bytes),
error_message: None,
tls,
})
}
Err(error) => Ok(failed_probe_result(
target,
request_target,
finish_report(&measurement, total_start.elapsed().as_millis() as u64),
Some(report_for_https_failure(error.to_string())),
error.to_string(),
)),
}
}
}
fn failed_probe_result(
target: &str,
request_path: &str,
report: TimingReport,
tls: Option<TlsReport>,
error_message: String,
) -> NewProbeResult {
NewProbeResult {
target: target.to_string(),
request_path: request_path.to_string(),
report,
status: ProbeStatus::Failed,
response_status_code: None,
response_status_text: None,
response_bytes: None,
error_message: Some(error_message),
tls,
}
}
fn finish_report(measurement: &ProxyMeasurement, total_ms: u64) -> TimingReport {
let mut report = measurement.finish_report();
report.total_ms = total_ms;
report
}
fn tls_report_for_failure(is_https: bool, error_message: String) -> Option<TlsReport> {
if is_https {
Some(report_for_https_failure(error_message))
} else {
Some(report_for_http())
}
}
fn build_request_target(url: &Url) -> String {
let mut target = url.path().to_string();
if target.is_empty() {
target.push('/');
}
if let Some(query) = url.query() {
target.push('?');
target.push_str(query);
}
target
}
fn build_tls_connector() -> TlsConnector {
let roots = RootCertStore::from_iter(TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
TlsConnector::from(Arc::new(config))
}
async fn execute_request<S>(
stream: &mut S,
host: &str,
request_target: &str,
measurement: &mut ProxyMeasurement,
total_start: Instant,
) -> std::io::Result<HttpResponseMetadata>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let request = format!(
"GET {request_target} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: lobe/0.1\r\nAccept: */*\r\nConnection: close\r\n\r\n"
);
let ttfb_start = Instant::now();
stream.write_all(request.as_bytes()).await?;
stream.flush().await?;
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut download_start: Option<Instant> = None;
loop {
let bytes_read = stream.read(&mut chunk).await?;
if bytes_read == 0 {
break;
}
if download_start.is_none() {
measurement.record_ttfb_ms(ttfb_start.elapsed().as_millis() as u64);
download_start = Some(Instant::now());
}
buffer.extend_from_slice(&chunk[..bytes_read]);
}
if let Some(start) = download_start {
measurement.record_download_ms(start.elapsed().as_millis() as u64);
}
let metadata = parse_http_response(&buffer);
Ok(HttpResponseMetadata {
total_ms: total_start.elapsed().as_millis() as u64,
status_code: metadata.status_code,
status_text: metadata.status_text,
bytes: metadata.bytes,
})
}
fn parse_http_response(buffer: &[u8]) -> HttpResponseMetadata {
let Some(header_end) = find_header_end(buffer) else {
return HttpResponseMetadata {
total_ms: 0,
status_code: None,
status_text: None,
bytes: buffer.len() as u64,
};
};
let header_bytes = &buffer[..header_end];
let body_bytes = &buffer[header_end + 4..];
let status_line = header_bytes
.split(|byte| *byte == b'\n')
.next()
.and_then(|line| std::str::from_utf8(line).ok())
.map(str::trim)
.unwrap_or_default();
let mut parts = status_line.splitn(3, ' ');
let _http_version = parts.next();
let status_code = parts.next().and_then(|part| part.parse::<u16>().ok());
let status_text = parts.next().map(|part| part.trim().to_string());
HttpResponseMetadata {
total_ms: 0,
status_code,
status_text,
bytes: body_bytes.len() as u64,
}
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
fn extract_certificate_info(
certificates: Option<&[tokio_rustls::rustls::pki_types::CertificateDer<'_>]>,
) -> Option<CertificateInfo> {
let certificate = certificates?.first()?;
let (_, parsed) = x509_parser::parse_x509_certificate(certificate.as_ref()).ok()?;
Some(CertificateInfo {
subject: parsed.subject().to_string(),
issuer: parsed.issuer().to_string(),
not_before: Some(parsed.validity().not_before.to_string()),
not_after: Some(parsed.validity().not_after.to_string()),
})
}
#[cfg(test)]
mod tests {
use url::Url;
use crate::models::ProbeStatus;
use crate::models::TlsStatus;
use crate::storage::SqliteStore;
use super::{build_request_target, ProxyMeasurement, SimpleProbe};
#[test]
fn finish_report_builds_report_from_recorded_phases() {
let mut measurement = ProxyMeasurement::new();
measurement.record_dns_ms(12);
measurement.record_tcp_ms(18);
measurement.record_tls_ms(24);
measurement.record_ttfb_ms(30);
measurement.record_download_ms(6);
let report = measurement.finish_report();
assert_eq!(report.dns_ms, 12);
assert_eq!(report.tcp_ms, 18);
assert_eq!(report.tls_ms, 24);
assert_eq!(report.ttfb_ms, 30);
assert_eq!(report.download_ms, 6);
assert_eq!(report.total_ms, 90);
}
#[test]
fn build_request_target_includes_query_string() {
let url = Url::parse("https://example.com/path?hello=world").expect("url should parse");
let target = build_request_target(&url);
assert_eq!(target, "/path?hello=world");
}
#[tokio::test]
async fn simple_probe_returns_failed_result_for_unreachable_target() {
let probe = SimpleProbe::new();
let result = probe
.probe("http://127.0.0.1:1")
.await
.expect("probe should return a result");
assert_eq!(result.status, ProbeStatus::Failed);
assert_eq!(result.report.tls_ms, 0);
assert_eq!(result.report.ttfb_ms, 0);
assert!(result.report.dns_ms <= result.report.total_ms);
assert!(result.error_message.is_some());
assert_eq!(
result.tls.expect("tls report should exist").status,
TlsStatus::NotUsed
);
}
#[tokio::test]
async fn simple_probe_rejects_invalid_targets() {
let probe = SimpleProbe::new();
let result = probe.probe("not-a-url").await;
assert!(matches!(
result,
Err(crate::error::TloxError::InvalidTarget(_))
));
}
#[tokio::test]
async fn probe_and_store_persists_probe_result() {
let probe = SimpleProbe::new();
let store = SqliteStore::in_memory().expect("store should initialize");
let saved = probe
.probe_and_store(&store, "http://127.0.0.1:1")
.await
.expect("probe result should save");
let loaded = store
.latest_result_for_target("http://127.0.0.1:1")
.expect("latest result should load")
.expect("target should have a saved result");
assert_eq!(loaded.id, saved.id);
assert_eq!(loaded.target, "http://127.0.0.1:1");
assert_eq!(loaded.status, ProbeStatus::Failed);
assert!(loaded.error_message.is_some());
}
}