mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-11 02:12:04 +03:00
traceroute: mtr to icmp_win to icmplib to system-binary cascade
This commit is contained in:
parent
c020936d7f
commit
b151a934b7
2 changed files with 312 additions and 0 deletions
164
src/netcheck/probes/traceroute.py
Normal file
164
src/netcheck/probes/traceroute.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from netcheck.models import Capabilities, TraceHop, TraceResult
|
||||
from netcheck.probes.icmp_win import IcmpReply, classify_status
|
||||
from netcheck.traceparse import build_trace_result, finalize_hop
|
||||
|
||||
|
||||
def tier_order(caps: Capabilities) -> list[str]:
|
||||
order: list[str] = []
|
||||
if caps.mtr_binary:
|
||||
order.append("mtr_json")
|
||||
if caps.icmp_win_api:
|
||||
order.append("icmp_win")
|
||||
elif caps.icmp_dgram or caps.icmp_raw:
|
||||
order.append("icmplib")
|
||||
if caps.traceroute_binary:
|
||||
order.append("system_traceroute")
|
||||
return order
|
||||
|
||||
|
||||
async def run_cascade(tiers: list[tuple[str, Callable[[], Awaitable[TraceResult]]]]) -> TraceResult:
|
||||
for _name, tier in tiers:
|
||||
try:
|
||||
result = await tier()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException: # noqa: BLE001 - a dead tier is data, the next tier gets its turn
|
||||
continue
|
||||
if result.hops:
|
||||
return result
|
||||
return TraceResult(backend="none", hops=[], completed=False)
|
||||
|
||||
|
||||
def hops_from_win_replies(replies: list[tuple[int, IcmpReply]]) -> list[TraceHop]:
|
||||
hops: list[TraceHop] = []
|
||||
for ttl, reply in replies:
|
||||
kind = classify_status(reply.status)
|
||||
rtt = reply.rtt_ms if kind in ("ok", "ttl_expired") else None
|
||||
hops.append(finalize_hop(TraceHop(ttl=ttl, ip=reply.address, probes=[rtt])))
|
||||
return hops
|
||||
|
||||
|
||||
async def _run(args: list[str], timeout: float) -> str:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
try:
|
||||
stdout, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
raise
|
||||
return stdout.decode(_console_encoding(), errors="replace")
|
||||
|
||||
|
||||
def _console_encoding() -> str:
|
||||
# Windows console tools emit the OEM code page (cp866 on Russian Windows),
|
||||
# which is why every parser here works on decoded text and never on bytes.
|
||||
if platform.system() != "Windows":
|
||||
return "utf-8"
|
||||
import ctypes
|
||||
|
||||
try:
|
||||
return f"cp{ctypes.windll.kernel32.GetOEMCP()}"
|
||||
except Exception:
|
||||
return "utf-8"
|
||||
|
||||
|
||||
async def _tier_mtr(target: str, binary: str, cycles: int, max_hops: int, timeout: float) -> TraceResult:
|
||||
text = await _run(
|
||||
[binary, "--json", "--report-cycles", str(cycles), "--max-ttl", str(max_hops), target], timeout
|
||||
)
|
||||
payload = json.loads(text)
|
||||
report = payload.get("report") or {}
|
||||
hops = []
|
||||
for entry in report.get("hubs") or []:
|
||||
hop = TraceHop(
|
||||
ttl=entry.get("count", 0),
|
||||
ip=None if entry.get("host") in ("???", None) else entry.get("host"),
|
||||
probes=[],
|
||||
loss_pct=float(entry.get("Loss%", 0.0)),
|
||||
min_ms=entry.get("Best"),
|
||||
avg_ms=entry.get("Avg"),
|
||||
max_ms=entry.get("Wrst"),
|
||||
jitter_ms=entry.get("StDev"),
|
||||
)
|
||||
hops.append(hop)
|
||||
return TraceResult(
|
||||
target=target,
|
||||
backend="mtr_json",
|
||||
hops=hops,
|
||||
cycles=cycles,
|
||||
completed=bool(hops) and hops[-1].ip is not None,
|
||||
)
|
||||
|
||||
|
||||
async def _tier_icmp_win(target: str, max_hops: int, timeout: float) -> TraceResult:
|
||||
from netcheck.probes.icmp_win import trace_hops_win
|
||||
|
||||
replies = await asyncio.to_thread(trace_hops_win, target, max_hops, int(timeout * 1000))
|
||||
hops = hops_from_win_replies(replies)
|
||||
return TraceResult(
|
||||
target=target,
|
||||
backend="icmp_win",
|
||||
hops=hops,
|
||||
cycles=1,
|
||||
completed=bool(hops) and classify_status(replies[-1][1].status) == "ok",
|
||||
max_hops_reached=bool(hops) and hops[-1].ttl >= max_hops,
|
||||
)
|
||||
|
||||
|
||||
async def _tier_icmplib(target: str, max_hops: int, timeout: float, privileged: bool) -> TraceResult:
|
||||
from icmplib import async_traceroute
|
||||
|
||||
raw = await async_traceroute(target, max_hops=max_hops, timeout=timeout, privileged=privileged)
|
||||
hops = [
|
||||
finalize_hop(TraceHop(ttl=h.distance, ip=h.address, probes=list(h.rtts)))
|
||||
for h in raw
|
||||
]
|
||||
return TraceResult(
|
||||
target=target,
|
||||
backend="icmplib",
|
||||
hops=hops,
|
||||
cycles=1,
|
||||
completed=bool(hops) and hops[-1].ip is not None,
|
||||
max_hops_reached=bool(hops) and hops[-1].ttl >= max_hops,
|
||||
)
|
||||
|
||||
|
||||
async def _tier_system(target: str, binary: str, max_hops: int, timeout: float) -> TraceResult:
|
||||
os_name = platform.system()
|
||||
args = (
|
||||
[binary, "-h", str(max_hops), "-w", "2", target]
|
||||
if os_name == "Windows"
|
||||
else [binary, "-m", str(max_hops), "-w", "2", target]
|
||||
)
|
||||
text = await _run(args, timeout)
|
||||
return build_trace_result(text, os_name, target=target, resolved_ip=None, max_hops=max_hops)
|
||||
|
||||
|
||||
async def traceroute(
|
||||
target: str,
|
||||
caps: Capabilities,
|
||||
max_hops: int,
|
||||
cycles: int,
|
||||
timeout: float,
|
||||
semaphore: asyncio.Semaphore | None = None,
|
||||
) -> TraceResult:
|
||||
builders = {
|
||||
"mtr_json": lambda: _tier_mtr(target, caps.mtr_binary or shutil.which("mtr") or "mtr", cycles, max_hops, timeout),
|
||||
"icmp_win": lambda: _tier_icmp_win(target, max_hops, timeout),
|
||||
"icmplib": lambda: _tier_icmplib(target, max_hops, timeout, privileged=not caps.icmp_dgram),
|
||||
"system_traceroute": lambda: _tier_system(target, caps.traceroute_binary or "traceroute", max_hops, timeout),
|
||||
}
|
||||
tiers = [(name, builders[name]) for name in tier_order(caps)]
|
||||
if semaphore is None:
|
||||
return await run_cascade(tiers)
|
||||
async with semaphore:
|
||||
return await run_cascade(tiers)
|
||||
148
tests/test_traceroute_cascade.py
Normal file
148
tests/test_traceroute_cascade.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from netcheck.models import Capabilities, TraceHop, TraceResult
|
||||
from netcheck.probes.icmp_win import IP_REQ_TIMED_OUT, IP_SUCCESS, IP_TTL_EXPIRED_TRANSIT, IcmpReply
|
||||
from netcheck.probes.traceroute import hops_from_win_replies, run_cascade, tier_order
|
||||
|
||||
|
||||
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_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
|
||||
Loading…
Add table
Reference in a new issue