mirror of
https://github.com/thatmattlove/hyperglass.git
synced 2026-04-17 21:38:27 +00:00
MAJOR NEW ARCHITECTURE - STRUCTURED TRACEROUTE: - Complete rewrite of traceroute data processing with structured output - Dedicated TracerouteResult and TracerouteHop data models - Platform-specific parsers with unified output format - Rich metadata including ASN, organization, country, and prefix information - AS path visualization with organization names in React Flow charts SUPPORTED PLATFORMS: - TraceroutePluginMikrotik: Handles MikroTik's complex multi-table format * Progressive statistics parsing with deduplication * Timeout hop handling and continuation line processing * Loss percentage and RTT statistics extraction - TraceroutePluginHuawei: Unix-style traceroute format parser * Standard hop_number ip_address rtt format support * Timeout hop detection with * notation * Automatic cleanup of excessive trailing timeouts COMPREHENSIVE IP ENRICHMENT SYSTEM: - Offline enrichment using BGP.tools bulk data (1.3M+ CIDR entries) - PeeringDB integration for IXP detection and ASN organization data - Ultra-fast pickle cache system with combined data files - Integer-based bitwise IP matching for maximum performance - Bulk ASN organization lookup capabilities - Private/reserved IP handling with AS0 fallbacks - Country code mapping from ASN database - Graceful fallbacks for missing enrichment data FRONTEND ENHANCEMENTS: - New traceroute table components with consistent formatting - Enhanced AS path visualization with organization names - Improved copy-to-clipboard functionality with structured data - Unified table styling across BGP and traceroute results - Better error handling and loading states CONCURRENT PROCESSING INFRASTRUCTURE: - Thread executor implementation for blocking I/O operations - Query deduplication system to prevent resource conflicts - Non-blocking Redis cache operations using asyncio executors - Event coordination for waiting requests - Background cleanup for completed operations - Prevents website hangs during long-running queries PLUGIN ARCHITECTURE IMPROVEMENTS: - Platform-aware plugin system with proper execution restrictions - Enhanced MikroTik garbage output cleaning - IP enrichment plugins for both BGP routes and traceroute - Conditional plugin execution based on platform detection - Proper async/sync plugin method handling CRITICAL BUG FIXES: - Fixed double AS prefix bug (ASAS123456 → AS123456) - Resolved TracerouteHop avg_rtt field/property conflicts - Corrected Huawei traceroute source field validation - Fixed plugin platform restriction enforcement - Eliminated blocking I/O causing UI freezes - Proper timeout and empty response caching prevention - Enhanced private IP range detection and handling PERFORMANCE OPTIMIZATIONS: - Pickle cache system reduces startup time from seconds to milliseconds - Bulk processing for ASN organization lookups - Simplified IXP detection using single PeeringDB API call - Efficient CIDR network sorting and integer-based lookups - Reduced external API calls by 90%+ - Optimized memory usage for large datasets API & ROUTING ENHANCEMENTS: - Enhanced API routes with proper error handling - Improved middleware for concurrent request processing - Better state management and event handling - Enhanced task processing with thread pool execution This represents a complete transformation of hyperglass traceroute capabilities, moving from basic text output to rich, structured data with comprehensive network intelligence and concurrent processing support.
251 lines
9 KiB
Python
251 lines
9 KiB
Python
"""Traceroute Data Models."""
|
|
|
|
# Standard Library
|
|
import typing as t
|
|
from ipaddress import ip_address, AddressValueError
|
|
|
|
# Third Party
|
|
from pydantic import field_validator
|
|
|
|
# Project
|
|
from hyperglass.external.ip_enrichment import TargetDetail
|
|
|
|
# Local
|
|
from ..main import HyperglassModel
|
|
|
|
|
|
class TracerouteHop(HyperglassModel):
|
|
"""Individual hop in a traceroute."""
|
|
|
|
hop_number: int
|
|
ip_address: t.Optional[str] = None
|
|
display_ip: t.Optional[str] = None # For truncated IPs that can't be validated
|
|
hostname: t.Optional[str] = None
|
|
rtt1: t.Optional[float] = None
|
|
rtt2: t.Optional[float] = None
|
|
rtt3: t.Optional[float] = None
|
|
|
|
# MikroTik-specific statistics
|
|
loss_pct: t.Optional[int] = None
|
|
sent_count: t.Optional[int] = None
|
|
last_rtt: t.Optional[float] = None
|
|
best_rtt: t.Optional[float] = None
|
|
worst_rtt: t.Optional[float] = None
|
|
|
|
# IP enrichment data
|
|
asn: t.Optional[str] = None
|
|
org: t.Optional[str] = None
|
|
prefix: t.Optional[str] = None
|
|
country: t.Optional[str] = None
|
|
rir: t.Optional[str] = None
|
|
allocated: t.Optional[str] = None
|
|
|
|
@field_validator("ip_address")
|
|
def validate_ip_address(cls, value):
|
|
"""Validate IP address format."""
|
|
if value is not None:
|
|
# Handle truncated addresses (MikroTik sometimes truncates long IPv6 addresses with ...)
|
|
if value.endswith("...") or value.endswith(".."):
|
|
return None # Invalid for BGP enrichment but kept in display_ip
|
|
try:
|
|
ip_address(value)
|
|
except AddressValueError:
|
|
return None
|
|
return value
|
|
|
|
@property
|
|
def ip_display(self) -> t.Optional[str]:
|
|
"""Get the IP address for display purposes (may be truncated)."""
|
|
return self.display_ip or self.ip_address
|
|
|
|
@property
|
|
def avg_rtt(self) -> t.Optional[float]:
|
|
"""Calculate average RTT from available measurements."""
|
|
rtts = [rtt for rtt in [self.rtt1, self.rtt2, self.rtt3] if rtt is not None]
|
|
return sum(rtts) / len(rtts) if rtts else None
|
|
|
|
@property
|
|
def is_timeout(self) -> bool:
|
|
"""Check if this hop is a timeout (no IP and no RTTs)."""
|
|
return self.ip_address is None and all(
|
|
rtt is None for rtt in [self.rtt1, self.rtt2, self.rtt3]
|
|
)
|
|
|
|
@property
|
|
def asn_display(self) -> str:
|
|
"""Display ASN - just the number, no AS prefix."""
|
|
if self.asn and self.asn != "None":
|
|
if self.asn == "IXP":
|
|
# For IXPs, show "IXP" with org if available
|
|
if self.org and self.org != "None":
|
|
return f"IXP ({self.org})"
|
|
return "IXP"
|
|
else:
|
|
# For ASNs, show just the number with org if available
|
|
if self.org and self.org != "None":
|
|
return f"{self.asn} ({self.org})"
|
|
return self.asn
|
|
return "Unknown"
|
|
|
|
|
|
class TracerouteResult(HyperglassModel):
|
|
"""Complete traceroute result."""
|
|
|
|
target: str
|
|
source: str
|
|
hops: t.List[TracerouteHop]
|
|
max_hops: int = 30
|
|
packet_size: int = 60
|
|
raw_output: t.Optional[str] = (
|
|
None # Store cleaned/processed output for "Copy Raw" functionality (not original raw router output)
|
|
)
|
|
asn_organizations: t.Dict[str, t.Dict[str, str]] = {} # ASN -> {name, country}
|
|
|
|
@property
|
|
def hop_count(self) -> int:
|
|
"""Total number of hops."""
|
|
return len(self.hops)
|
|
|
|
@property
|
|
def unique_asns(self) -> t.List[str]:
|
|
"""List of unique ASNs encountered in the path."""
|
|
asns = set()
|
|
for hop in self.hops:
|
|
if hop.asn and hop.asn != "None":
|
|
asns.add(hop.asn)
|
|
return sorted(list(asns))
|
|
|
|
@property
|
|
def as_path_summary(self) -> str:
|
|
"""Summary of AS path traversed."""
|
|
as_path = []
|
|
current_asn = None
|
|
|
|
for hop in self.hops:
|
|
if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn:
|
|
current_asn = hop.asn
|
|
# hop.asn is now just number ("12345") or "IXP" - display as-is
|
|
as_path.append(hop.asn)
|
|
|
|
return " -> ".join(as_path) if as_path else "Unknown"
|
|
|
|
@property
|
|
def as_path_detailed(self) -> str:
|
|
"""Detailed AS path with organization names."""
|
|
as_path = []
|
|
current_asn = None
|
|
current_org = None
|
|
|
|
for hop in self.hops:
|
|
if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn:
|
|
current_asn = hop.asn # Just number ("12345") or "IXP"
|
|
current_org = hop.org
|
|
|
|
# Format with org name if we have it
|
|
if current_org and current_org not in ["None", None]:
|
|
if current_asn == "IXP":
|
|
as_path.append(f"IXP ({current_org})")
|
|
else:
|
|
as_path.append(f"{current_asn} ({current_org})")
|
|
else:
|
|
as_path.append(current_asn)
|
|
|
|
return " -> ".join(as_path) if as_path else "Unknown"
|
|
|
|
@property
|
|
def as_path_data(self) -> t.List[t.Dict[str, t.Union[str, None]]]:
|
|
"""AS path data as structured list for frontend visualization."""
|
|
as_path_data = []
|
|
current_asn = None
|
|
current_org = None
|
|
|
|
for hop in self.hops:
|
|
if hop.asn and hop.asn not in ["None", None] and hop.asn != current_asn:
|
|
current_asn = hop.asn # Just number ("12345") or "IXP"
|
|
current_org = hop.org
|
|
|
|
as_path_data.append(
|
|
{
|
|
"asn": current_asn,
|
|
"org": current_org if current_org and current_org != "None" else None,
|
|
}
|
|
)
|
|
|
|
return as_path_data
|
|
|
|
async def enrich_with_ip_enrichment(self):
|
|
"""Enrich traceroute hops with IP enrichment data."""
|
|
from hyperglass.external.ip_enrichment import network_info
|
|
|
|
# Extract all IP addresses that need enrichment
|
|
ips_to_lookup = []
|
|
for hop in self.hops:
|
|
if hop.ip_address and hop.asn is None: # Only lookup if not already enriched
|
|
ips_to_lookup.append(hop.ip_address)
|
|
|
|
if not ips_to_lookup:
|
|
return
|
|
|
|
# Bulk lookup IP information
|
|
network_data = await network_info(*ips_to_lookup)
|
|
|
|
# Enrich hops with the retrieved data
|
|
for hop in self.hops:
|
|
if hop.ip_address in network_data:
|
|
data: TargetDetail = network_data[hop.ip_address]
|
|
# ASN field is now just number string ("12345") or "IXP"
|
|
asn_value = data.get("asn")
|
|
if asn_value and asn_value != "None":
|
|
hop.asn = asn_value # Store as-is: "12345" or "IXP"
|
|
else:
|
|
hop.asn = None
|
|
|
|
hop.org = data.get("org") if data.get("org") != "None" else None
|
|
hop.prefix = data.get("prefix") if data.get("prefix") != "None" else None
|
|
hop.country = data.get("country") if data.get("country") != "None" else None
|
|
hop.rir = data.get("rir") if data.get("rir") != "None" else None
|
|
hop.allocated = data.get("allocated") if data.get("allocated") != "None" else None
|
|
|
|
async def enrich_asn_organizations(self):
|
|
"""Enrich ASN organization names using bulk ASN lookup."""
|
|
from hyperglass.external.ip_enrichment import lookup_asns_bulk
|
|
from hyperglass.log import log
|
|
|
|
_log = log.bind(source="traceroute_asn_enrichment")
|
|
|
|
# Collect all unique ASNs that need organization info
|
|
asns_to_lookup = []
|
|
for hop in self.hops:
|
|
if hop.asn and hop.asn != "None" and hop.asn != "IXP":
|
|
asns_to_lookup.append(hop.asn)
|
|
_log.debug(f"Hop {hop.hop_number}: ASN={hop.asn}, current org='{hop.org}'")
|
|
|
|
if not asns_to_lookup:
|
|
_log.debug("No ASNs to lookup")
|
|
return
|
|
|
|
# Remove duplicates while preserving order
|
|
unique_asns = list(dict.fromkeys(asns_to_lookup))
|
|
_log.info(f"Looking up organizations for {len(unique_asns)} unique ASNs: {unique_asns}")
|
|
|
|
# Bulk lookup ASN organization data
|
|
asn_data = await lookup_asns_bulk(unique_asns)
|
|
_log.debug(f"Got ASN data: {asn_data}")
|
|
|
|
# Apply the organization data to hops
|
|
for hop in self.hops:
|
|
if hop.asn and hop.asn in asn_data:
|
|
data = asn_data[hop.asn]
|
|
org_name = data.get("name") if data.get("name") != f"AS{hop.asn}" else None
|
|
|
|
_log.debug(
|
|
f"Hop {hop.hop_number} ASN {hop.asn}: setting org='{org_name}' (was '{hop.org}')"
|
|
)
|
|
|
|
# Always update org from ASN data (more accurate than IP-based org)
|
|
hop.org = org_name
|
|
if not hop.country: # Only set country if not already set
|
|
hop.country = data.get("country") or None
|
|
|
|
# Store the ASN organization mapping for frontend path visualization
|
|
self.asn_organizations = asn_data
|