mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 22:42:03 +03:00
Every probe used to follow whatever the OS default route picked, with no way to point the tool at a specific adapter for comparison (VPN vs raw) or to confirm which one actually carries traffic on a multi-adapter machine. New iface.py resolves an adapter name or IP to a BindTarget (pure, tested): exact/case-insensitive/unique-substring name match, rejects APIPA-only, link-local-only and down adapters. Wired through every httpx provider client, the Ookla binary tier, ndt7, and the whole ping/traceroute cascade (icmplib, mtr, unix traceroute, TCP connect, scapy) plus --watch. Windows icmp_win now uses IcmpSendEcho2Ex (adds SourceAddress) only when a bind is forced, so the unforced path is unchanged. Windows tracert has no IPv4 source-address option (verified from its own usage text) so that tier is dropped when forced, noted in the report rather than silently measuring the wrong path. An unresolvable/down adapter aborts with every adapter listed; a resolvable adapter with no route to the internet degrades gracefully instead of aborting - that's a real diagnostic answer, not an error. netinfo's observation of the OS's actual default route stays untouched, so the report can show the forced adapter and the OS default side by side. Verified live: forcing Tailscale (no internet route on this box) triggered the preflight warning and degraded correctly; forcing Ethernet ran clean end-to-end including a full speedtest+MTR cycle. Also noted, not fixed: probes/traceroute.py's icmplib tier imports async_traceroute, which doesn't exist in icmplib 3.0.4 - that tier has always raised ImportError and silently fallen through. Found while verifying the icmplib source-binding wiring live.
269 lines
9 KiB
Python
269 lines
9 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from netsleuth.models import Capabilities, TraceHop, TraceResult
|
|
from netsleuth.probes.icmp_win import IP_REQ_TIMED_OUT, IP_SUCCESS, IP_TTL_EXPIRED_TRANSIT, IcmpReply
|
|
from netsleuth.probes.traceroute import (
|
|
_run_in_daemon_thread,
|
|
filter_trace_tiers,
|
|
hops_from_win_replies,
|
|
run_cascade,
|
|
tier_order,
|
|
trace_argv,
|
|
)
|
|
|
|
|
|
def caps(**kw) -> Capabilities:
|
|
base = dict(os_name="Linux", icmp_dgram=False, icmp_raw=False, icmp_win_api=False)
|
|
base.update(kw)
|
|
return Capabilities(**base)
|
|
|
|
|
|
def test_tier_order_puts_mtr_first_when_available():
|
|
order = tier_order(caps(mtr_binary="/usr/bin/mtr", icmp_dgram=True, traceroute_binary="/usr/bin/traceroute"))
|
|
assert order == ["mtr_json", "icmplib", "system_traceroute"]
|
|
|
|
|
|
def test_tier_order_on_windows_uses_the_win_api_before_the_binary():
|
|
order = tier_order(caps(os_name="Windows", icmp_win_api=True, traceroute_binary="C:\\tracert.exe"))
|
|
assert order == ["icmp_win", "system_traceroute"]
|
|
|
|
|
|
def test_tier_order_on_windows_still_prefers_mtr_if_someone_installed_it():
|
|
order = tier_order(caps(os_name="Windows", mtr_binary="C:\\mtr.exe", icmp_win_api=True))
|
|
assert order[0] == "mtr_json"
|
|
assert order[1] == "icmp_win"
|
|
|
|
|
|
def test_tier_order_without_any_icmp_falls_back_to_the_system_binary():
|
|
assert tier_order(caps(traceroute_binary="/usr/bin/traceroute")) == ["system_traceroute"]
|
|
|
|
|
|
def test_tier_order_with_nothing_available_is_empty():
|
|
assert tier_order(caps()) == []
|
|
|
|
|
|
def good(name: str) -> TraceResult:
|
|
return TraceResult(target="1.1.1.1", backend=name, hops=[TraceHop(ttl=1, ip="192.168.1.1")], completed=True)
|
|
|
|
|
|
async def test_cascade_uses_the_first_tier_that_works():
|
|
calls: list[str] = []
|
|
|
|
async def tier1() -> TraceResult:
|
|
calls.append("t1")
|
|
return good("mtr_json")
|
|
|
|
async def tier2() -> TraceResult:
|
|
calls.append("t2")
|
|
return good("icmplib")
|
|
|
|
result = await run_cascade([("mtr_json", tier1), ("icmplib", tier2)])
|
|
assert result.backend == "mtr_json"
|
|
assert calls == ["t1"]
|
|
|
|
|
|
async def test_cascade_falls_through_when_a_tier_raises():
|
|
calls: list[str] = []
|
|
|
|
async def tier1() -> TraceResult:
|
|
calls.append("t1")
|
|
raise FileNotFoundError("mtr not on PATH")
|
|
|
|
async def tier2() -> TraceResult:
|
|
calls.append("t2")
|
|
return good("icmplib")
|
|
|
|
result = await run_cascade([("mtr_json", tier1), ("icmplib", tier2)])
|
|
assert result.backend == "icmplib"
|
|
assert calls == ["t1", "t2"]
|
|
|
|
|
|
async def test_cascade_falls_through_when_a_tier_returns_no_hops():
|
|
async def tier1() -> TraceResult:
|
|
return TraceResult(target="1.1.1.1", backend="mtr_json", hops=[])
|
|
|
|
async def tier2() -> TraceResult:
|
|
return good("system_traceroute")
|
|
|
|
result = await run_cascade([("mtr_json", tier1), ("system_traceroute", tier2)])
|
|
assert result.backend == "system_traceroute"
|
|
|
|
|
|
async def test_cascade_tries_every_tier_in_order():
|
|
calls: list[str] = []
|
|
|
|
def failing(name: str):
|
|
async def tier() -> TraceResult:
|
|
calls.append(name)
|
|
raise OSError(name)
|
|
|
|
return tier
|
|
|
|
async def last() -> TraceResult:
|
|
calls.append("system")
|
|
return good("system_traceroute")
|
|
|
|
result = await run_cascade(
|
|
[("mtr_json", failing("mtr")), ("icmp_win", failing("win")), ("icmplib", failing("lib")), ("system_traceroute", last)]
|
|
)
|
|
assert calls == ["mtr", "win", "lib", "system"]
|
|
assert result.backend == "system_traceroute"
|
|
|
|
|
|
async def test_cascade_exhaustion_yields_a_none_backend_not_an_exception():
|
|
async def boom() -> TraceResult:
|
|
raise OSError("nope")
|
|
|
|
result = await run_cascade([("mtr_json", boom), ("icmplib", boom)])
|
|
assert isinstance(result, TraceResult)
|
|
assert result.backend == "none"
|
|
assert result.hops == []
|
|
assert result.completed is False
|
|
|
|
|
|
async def test_cascade_with_no_tiers_at_all_yields_a_none_backend():
|
|
result = await run_cascade([])
|
|
assert result.backend == "none"
|
|
|
|
|
|
async def test_cascade_never_swallows_cancellation():
|
|
async def cancelled() -> TraceResult:
|
|
raise asyncio.CancelledError()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await run_cascade([("mtr_json", cancelled)])
|
|
|
|
|
|
def test_tcp_trace_is_absent_unless_it_is_asked_for():
|
|
assert "tcp_trace" not in tier_order(caps(icmp_dgram=True, traceroute_binary="/usr/bin/traceroute"))
|
|
assert "tcp_trace" not in tier_order(caps(os_name="Windows", icmp_win_api=True))
|
|
|
|
|
|
def test_tcp_trace_leads_the_cascade_when_requested():
|
|
order = tier_order(caps(mtr_binary="/usr/bin/mtr", icmp_dgram=True), tcp_trace=True)
|
|
assert order[0] == "tcp_trace"
|
|
assert order[1:] == ["mtr_json", "icmplib"]
|
|
|
|
|
|
def test_tcp_trace_can_be_the_only_tier():
|
|
assert tier_order(caps(), tcp_trace=True) == ["tcp_trace"]
|
|
|
|
|
|
def test_win_replies_become_hops_with_ttl_expiry_handled():
|
|
replies = [
|
|
(1, IcmpReply(address="192.168.1.1", status=IP_TTL_EXPIRED_TRANSIT, rtt_ms=1.0, ttl=64)),
|
|
(2, IcmpReply(address=None, status=IP_REQ_TIMED_OUT, rtt_ms=None, ttl=0)),
|
|
(3, IcmpReply(address="1.1.1.1", status=IP_SUCCESS, rtt_ms=12.0, ttl=57)),
|
|
]
|
|
hops = hops_from_win_replies(replies)
|
|
assert [h.ttl for h in hops] == [1, 2, 3]
|
|
assert hops[0].ip == "192.168.1.1"
|
|
assert hops[0].probes == [1.0]
|
|
assert hops[1].ip is None
|
|
assert hops[1].probes == [None]
|
|
assert hops[1].loss_pct == 100.0
|
|
assert hops[2].ip == "1.1.1.1"
|
|
assert hops[2].avg_ms == 12.0
|
|
|
|
|
|
async def test_run_in_daemon_thread_returns_the_function_result():
|
|
result = await _run_in_daemon_thread(lambda a, b: a + b, 2, 3)
|
|
assert result == 5
|
|
|
|
|
|
async def test_run_in_daemon_thread_propagates_exceptions():
|
|
def boom():
|
|
raise ValueError("nope")
|
|
|
|
with pytest.raises(ValueError, match="nope"):
|
|
await _run_in_daemon_thread(boom)
|
|
|
|
|
|
def test_run_in_daemon_thread_never_creates_a_non_daemon_worker_thread():
|
|
# This is the crux of the process-exit hang fix: asyncio.to_thread()/the
|
|
# default executor spawn non-daemon ThreadPoolExecutor workers that get
|
|
# joined by concurrent.futures' atexit handler, which is exactly what let
|
|
# an orphaned, uncancellable IcmpSendEcho2 call keep the whole process
|
|
# alive. We can't unit-test "process exit is never blocked" directly (that
|
|
# needs a real subprocess), but we can pin the one property that makes it
|
|
# true: every thread this helper spawns is a plain daemon thread, never
|
|
# routed through a ThreadPoolExecutor.
|
|
seen: list[threading.Thread] = []
|
|
original_init = threading.Thread.__init__
|
|
|
|
def spy_init(self, *args, **kwargs):
|
|
original_init(self, *args, **kwargs)
|
|
seen.append(self)
|
|
|
|
async def run():
|
|
await _run_in_daemon_thread(lambda: None)
|
|
|
|
threading.Thread.__init__ = spy_init
|
|
try:
|
|
asyncio.run(run())
|
|
finally:
|
|
threading.Thread.__init__ = original_init
|
|
|
|
assert seen
|
|
assert all(t.daemon for t in seen)
|
|
|
|
|
|
def test_filter_trace_tiers_drops_windows_tracert_when_a_v4_bind_is_forced():
|
|
kept, dropped = filter_trace_tiers(["icmp_win", "system_traceroute"], "Windows", forced_v4=True)
|
|
assert kept == ["icmp_win"]
|
|
assert dropped == ["system_traceroute"]
|
|
|
|
|
|
def test_filter_trace_tiers_keeps_everything_when_nothing_is_forced():
|
|
order = ["icmp_win", "system_traceroute"]
|
|
kept, dropped = filter_trace_tiers(order, "Windows", forced_v4=False)
|
|
assert kept == order
|
|
assert dropped == []
|
|
|
|
|
|
def test_filter_trace_tiers_leaves_unix_traceroute_alone_even_when_forced():
|
|
order = ["mtr_json", "icmplib", "system_traceroute"]
|
|
kept, dropped = filter_trace_tiers(order, "Linux", forced_v4=True)
|
|
assert kept == order
|
|
assert dropped == []
|
|
|
|
|
|
def test_filter_trace_tiers_is_a_noop_when_system_traceroute_is_absent():
|
|
kept, dropped = filter_trace_tiers(["icmp_win"], "Windows", forced_v4=True)
|
|
assert kept == ["icmp_win"]
|
|
assert dropped == []
|
|
|
|
|
|
def test_trace_argv_mtr_adds_address_flag_when_source_given():
|
|
args = trace_argv("mtr_json", "/usr/bin/mtr", "1.1.1.1", max_hops=30, cycles=10, os_name="Linux", source_ip="192.168.1.34")
|
|
assert args == [
|
|
"/usr/bin/mtr", "--json", "--report-cycles", "10", "--max-ttl", "30",
|
|
"--address", "192.168.1.34", "1.1.1.1",
|
|
]
|
|
|
|
|
|
def test_trace_argv_mtr_omits_address_flag_without_a_source():
|
|
args = trace_argv("mtr_json", "/usr/bin/mtr", "1.1.1.1", max_hops=30, cycles=10, os_name="Linux")
|
|
assert "--address" not in args
|
|
|
|
|
|
def test_trace_argv_unix_traceroute_adds_source_flag():
|
|
args = trace_argv(
|
|
"system_traceroute", "/usr/bin/traceroute", "1.1.1.1", max_hops=30, cycles=1,
|
|
os_name="Linux", source_ip="192.168.1.34",
|
|
)
|
|
assert args == ["/usr/bin/traceroute", "-m", "30", "-w", "2", "-s", "192.168.1.34", "1.1.1.1"]
|
|
|
|
|
|
def test_trace_argv_windows_tracert_never_gets_a_source_flag():
|
|
args = trace_argv(
|
|
"system_traceroute", "tracert", "1.1.1.1", max_hops=30, cycles=1,
|
|
os_name="Windows", source_ip="192.168.1.34",
|
|
)
|
|
assert args == ["tracert", "-h", "30", "-w", "2", "1.1.1.1"]
|
|
assert "-s" not in args and "-S" not in args
|