From e34d8f27f4652bf4cc8e380737ae0987d20ff850 Mon Sep 17 00:00:00 2001 From: Andrew Pimenov Date: Sun, 9 Aug 2026 17:42:02 +0300 Subject: [PATCH] add TLS handshake RTT / TTFB probe --- src/netsleuth/probes/tls_rtt.py | 99 +++++++++++++++++++++++++++++++++ tests/test_tls_rtt.py | 80 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/netsleuth/probes/tls_rtt.py create mode 100644 tests/test_tls_rtt.py diff --git a/src/netsleuth/probes/tls_rtt.py b/src/netsleuth/probes/tls_rtt.py new file mode 100644 index 0000000..4bd1cc5 --- /dev/null +++ b/src/netsleuth/probes/tls_rtt.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +import ssl +import time + +from netsleuth.models import TlsResult +from netsleuth.probes.latency import tcp_connect_rtt + + +def split_timings( + tcp_rtt_ms: float | None, tls_total_ms: float | None, ttfb_ms: float | None +) -> tuple[float | None, float | None, float | None]: + handshake_ms = None + if tcp_rtt_ms is not None and tls_total_ms is not None: + handshake_ms = max(0.0, tls_total_ms - tcp_rtt_ms) + return tcp_rtt_ms, handshake_ms, ttfb_ms + + +def cpu_bound_ratio(tls_handshake_ms: float | None, tcp_rtt_ms: float | None) -> float | None: + if tls_handshake_ms is None or not tcp_rtt_ms: + return None + return tls_handshake_ms / tcp_rtt_ms + + +def tls_context() -> ssl.SSLContext: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.set_alpn_protocols(["h2", "http/1.1"]) + return ctx + + +async def measure_tls( + label: str, + host: str, + *, + port: int = 443, + timeout: float = 3.0, + source_ip: str | None = None, +) -> TlsResult: + result = TlsResult(label=label, host=host, port=port) + try: + tcp_rtt_ms = await tcp_connect_rtt(host, port=port, timeout=timeout, source_ip=source_ip) + result.tcp_rtt_ms = tcp_rtt_ms + + local_addr = (source_ip, 0) if source_ip else None + began = time.perf_counter() + reader, writer = await asyncio.wait_for( + asyncio.open_connection( + host, port, ssl=tls_context(), server_hostname=host, local_addr=local_addr + ), + timeout=timeout, + ) + tls_total_ms = (time.perf_counter() - began) * 1000.0 + + ssl_object = writer.get_extra_info("ssl_object") + if ssl_object is not None: + result.tls_version = ssl_object.version() + cipher = ssl_object.cipher() + result.cipher = cipher[0] if cipher else None + result.alpn = ssl_object.selected_alpn_protocol() + + request = f"HEAD / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode() + began_ttfb = time.perf_counter() + writer.write(request) + await writer.drain() + await asyncio.wait_for(reader.read(1), timeout=timeout) + ttfb_ms = (time.perf_counter() - began_ttfb) * 1000.0 + + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + + _tcp_rtt_ms, handshake_ms, ttfb_ms = split_timings(tcp_rtt_ms, tls_total_ms, ttfb_ms) + result.tls_handshake_ms = handshake_ms + result.ttfb_ms = ttfb_ms + except (OSError, TimeoutError, ssl.SSLError) as exc: + result.error = str(exc) or exc.__class__.__name__ + return result + + +async def tls_fanout( + targets: list[tuple[str, str]], + *, + port: int, + timeout: float, + concurrency: int, + source_ip: str | None = None, +) -> list[TlsResult]: + semaphore = asyncio.Semaphore(concurrency) + + async def _bounded(label: str, host: str) -> TlsResult: + async with semaphore: + return await measure_tls(label, host, port=port, timeout=timeout, source_ip=source_ip) + + return list(await asyncio.gather(*(_bounded(label, host) for label, host in targets))) diff --git a/tests/test_tls_rtt.py b/tests/test_tls_rtt.py new file mode 100644 index 0000000..c63e2e1 --- /dev/null +++ b/tests/test_tls_rtt.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import socket +import ssl + +from netsleuth.probes.tls_rtt import ( + cpu_bound_ratio, + measure_tls, + split_timings, + tls_context, + tls_fanout, +) + + +def _closed_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +def test_split_timings_derives_handshake_from_total_minus_tcp(): + tcp_rtt, handshake, ttfb = split_timings(20.0, 50.0, 100.0) + assert tcp_rtt == 20.0 + assert handshake == 30.0 + assert ttfb == 100.0 + + +def test_split_timings_handshake_is_none_when_total_is_missing(): + tcp_rtt, handshake, ttfb = split_timings(20.0, None, 100.0) + assert tcp_rtt == 20.0 + assert handshake is None + assert ttfb == 100.0 + + +def test_split_timings_clamps_a_negative_delta_to_zero(): + _tcp_rtt, handshake, _ttfb = split_timings(50.0, 40.0, None) + assert handshake == 0.0 + + +def test_cpu_bound_ratio_divides_handshake_by_tcp_rtt(): + assert cpu_bound_ratio(30.0, 15.0) == 2.0 + + +def test_cpu_bound_ratio_is_none_when_tcp_rtt_is_missing(): + assert cpu_bound_ratio(30.0, None) is None + + +def test_cpu_bound_ratio_is_none_when_tcp_rtt_is_zero(): + assert cpu_bound_ratio(30.0, 0.0) is None + + +def test_tls_context_disables_hostname_checking_for_diagnostics(): + ctx = tls_context() + assert isinstance(ctx, ssl.SSLContext) + assert ctx.check_hostname is False + assert ctx.verify_mode == ssl.CERT_NONE + + +async def test_measure_tls_reports_an_error_result_for_a_closed_port_without_raising(): + port = _closed_port() + result = await measure_tls("dead", "127.0.0.1", port=port, timeout=1.0) + assert result.label == "dead" + assert result.host == "127.0.0.1" + assert result.error + assert result.tls_handshake_ms is None + assert result.ttfb_ms is None + + +async def test_tls_fanout_returns_a_result_per_target_all_with_errors(): + port = _closed_port() + results = await tls_fanout( + [("a", "127.0.0.1"), ("b", "127.0.0.1")], + port=port, + timeout=1.0, + concurrency=2, + ) + assert len(results) == 2 + assert all(r.error for r in results)