mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 19:12:03 +03:00
add TLS handshake RTT / TTFB probe
This commit is contained in:
parent
c63c179b57
commit
e34d8f27f4
2 changed files with 179 additions and 0 deletions
99
src/netsleuth/probes/tls_rtt.py
Normal file
99
src/netsleuth/probes/tls_rtt.py
Normal file
|
|
@ -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)))
|
||||||
80
tests/test_tls_rtt.py
Normal file
80
tests/test_tls_rtt.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue