1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-05-03 13:16:26 +00:00
thatmattlove-hyperglass/hyperglass/plugins/_builtin/traceroute_ip_enrichment.py
Wilhelm Schonfeldt 4a1057651f
feat: comprehensive IP enrichment and traceroute improvements
MAJOR ENHANCEMENTS:

IP Enrichment Service (hyperglass/external/ip_enrichment.py):
- Increase IXP data cache duration from 24 hours to 7 days (604800s) for better performance
- Fix critical cache refresh logic: ensure_data_loaded() now properly checks expiry before using existing pickle files
- Remove 'force' refresh parameters from public APIs and admin endpoints to prevent potential abuse/DDOS
- Implement automatic refresh based on file timestamps and cache duration
- Add comprehensive debug logging gated by Settings.debug throughout the module
- Clean up verbose comments and improve code readability
- Update configuration model to enforce 7-day minimum cache timeout

MikroTik Traceroute Processing:
- Refactor trace_route_mikrotik plugin to use garbage cleaner before structured parsing
- Only log raw router output when Settings.debug is enabled to reduce log verbosity
- Simplify MikrotikTracerouteTable parser to expect pre-cleaned input from garbage cleaner
- Remove complex multi-table detection, format detection, and deduplication logic (handled by cleaner)
- Add concise debug messages for processing decisions and configuration states

Traceroute IP Enrichment (traceroute_ip_enrichment.py):
- Implement concurrent reverse DNS lookups using asyncio.to_thread and asyncio.gather
- Add async wrapper for reverse DNS with proper error handling and fallbacks
- Significant performance improvement for multi-hop traceroutes (parallel vs sequential DNS)
- Proper debug logging gates: only detailed logs when Settings.debug=True
- Upgrade operational messages to log.info level (start/completion status)
- Maintain compatibility with different event loop contexts and runtime environments

Configuration Updates:
- Update structured.ip_enrichment.cache_timeout default to 604800 seconds
- Update documentation to reflect new cache defaults and behavior
- Remove force refresh options from admin API endpoints

MIGRATION NOTES:
- Operators should ensure /etc/hyperglass/ip_enrichment directory is writable
- Any code relying on force refresh parameters must be updated
- Monitor logs for automatic refresh behavior and performance improvements
- The 7-day cache significantly reduces PeeringDB API load

PERFORMANCE BENEFITS:
- Faster traceroute enrichment due to concurrent DNS lookups
- Reduced external API calls with longer IXP cache duration
- More reliable refresh logic prevents stale cache usage
- Cleaner, more focused debug output when debug mode is disabled

TECHNICAL DETAILS:
- Uses asyncio.to_thread for non-blocking DNS operations
- Implements process-wide file locking for safe concurrent cache updates
- Robust fallbacks for various asyncio execution contexts
- Maintains backward compatibility while improving performance

FILES MODIFIED:
- hyperglass/external/ip_enrichment.py
- hyperglass/models/config/structured.py
- hyperglass/api/routes.py
- hyperglass/plugins/_builtin/trace_route_mikrotik.py
- hyperglass/models/parsing/mikrotik.py
- hyperglass/plugins/_builtin/traceroute_ip_enrichment.py
- docs/pages/configuration/config/structured-output.mdx
2025-10-05 21:25:58 +02:00

210 lines
8.6 KiB
Python

"""IP enrichment for structured traceroute data."""
# Standard Library
import asyncio
import socket
import typing as t
# Third Party
from pydantic import PrivateAttr
# Project
from hyperglass.log import log
from hyperglass.plugins._output import OutputPlugin
from hyperglass.models.data.traceroute import TracerouteResult
if t.TYPE_CHECKING:
from hyperglass.models.data import OutputDataModel
from hyperglass.models.api.query import Query
class ZTracerouteIpEnrichment(OutputPlugin):
"""Enrich structured traceroute output with IP enrichment ASN/organization data and reverse DNS."""
_hyperglass_builtin: bool = PrivateAttr(True)
platforms: t.Sequence[str] = (
"mikrotik_routeros",
"mikrotik_switchos",
"mikrotik",
"cisco_ios",
"juniper_junos",
"huawei",
"huawei_vrpv8",
)
directives: t.Sequence[str] = ("traceroute", "MikroTik_Traceroute")
common: bool = True
def _reverse_dns_lookup(self, ip: str) -> t.Optional[str]:
"""Perform reverse DNS lookup for an IP address."""
from hyperglass.settings import Settings
try:
hostname = socket.gethostbyaddr(ip)[0]
if Settings.debug:
log.debug(f"Reverse DNS for {ip}: {hostname}")
return hostname
except (socket.herror, socket.gaierror, socket.timeout) as e:
if Settings.debug:
log.debug(f"Reverse DNS lookup failed for {ip}: {e}")
return None
async def _reverse_dns_lookup_async(self, ip: str) -> t.Optional[str]:
"""Async wrapper around synchronous reverse DNS lookup using a thread.
Uses asyncio.to_thread to avoid blocking the event loop and allows
multiple lookups to be scheduled concurrently.
"""
try:
return await asyncio.to_thread(self._reverse_dns_lookup, ip)
except Exception as e:
from hyperglass.settings import Settings
if Settings.debug:
log.debug(f"Reverse DNS async lookup error for {ip}: {e}")
return None
async def _enrich_async(self, output: TracerouteResult) -> None:
"""Async helper to enrich traceroute data.
This performs IP enrichment (ASN lookups), ASN organization lookups,
and then runs reverse DNS lookups concurrently for hops missing hostnames.
"""
# First enrich with IP information (ASN numbers)
await output.enrich_with_ip_enrichment()
# Then enrich ASN numbers with organization names
await output.enrich_asn_organizations()
# Concurrent reverse DNS for hops missing hostnames
ips_to_lookup: list[str] = [
hop.ip_address for hop in output.hops if hop.ip_address and hop.hostname is None
]
if not ips_to_lookup:
return
# Schedule lookups concurrently
tasks = [asyncio.create_task(self._reverse_dns_lookup_async(ip)) for ip in ips_to_lookup]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Apply results back to hops in order
idx = 0
for hop in output.hops:
if hop.ip_address and hop.hostname is None:
res = results[idx]
idx += 1
if isinstance(res, Exception):
from hyperglass.settings import Settings
if Settings.debug:
log.debug(f"Reverse DNS lookup raised for {hop.ip_address}: {res}")
else:
hop.hostname = res
def process(self, *, output: "OutputDataModel", query: "Query") -> "OutputDataModel":
"""Enrich structured traceroute data with IP enrichment and reverse DNS information."""
if not isinstance(output, TracerouteResult):
return output
_log = log.bind(plugin=self.__class__.__name__)
# Import Settings for debug gating
from hyperglass.settings import Settings
_log.info(
f"Starting IP enrichment for {len(output.hops)} traceroute hops"
) # Check if IP enrichment is enabled in config
try:
from hyperglass.state import use_state
params = use_state("params")
# If structured config missing or traceroute enrichment disabled, skip
# IP enrichment but still perform reverse DNS lookups.
if (
not getattr(params, "structured", None)
or not params.structured.ip_enrichment.enrich_traceroute
or getattr(params.structured, "enable_for_traceroute", None) is False
):
if Settings.debug:
_log.debug("IP enrichment for traceroute disabled in configuration")
# Still do reverse DNS if enrichment is disabled
# Perform concurrent reverse DNS lookups for hops needing hostnames
ips = [
hop.ip_address for hop in output.hops if hop.ip_address and hop.hostname is None
]
if ips:
try:
# Run lookups in an event loop
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# We're inside an event loop; run tasks via asyncio.run in thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
lambda: asyncio.run(
asyncio.gather(
*[self._reverse_dns_lookup_async(ip) for ip in ips]
)
)
)
results = future.result()
else:
results = loop.run_until_complete(
asyncio.gather(
*[self._reverse_dns_lookup_async(ip) for ip in ips]
)
)
except RuntimeError:
results = asyncio.run(
asyncio.gather(*[self._reverse_dns_lookup_async(ip) for ip in ips])
)
# Apply results
idx = 0
for hop in output.hops:
if hop.ip_address and hop.hostname is None:
res = results[idx]
idx += 1
if not isinstance(res, Exception):
hop.hostname = res
except Exception as e:
if Settings.debug:
_log.debug(
f"Concurrent reverse DNS failed (fallback to sequential): {e}"
)
for hop in output.hops:
if hop.ip_address and hop.hostname is None:
hop.hostname = self._reverse_dns_lookup(hop.ip_address)
return output
except Exception as e:
if Settings.debug:
_log.debug(f"Could not check IP enrichment config: {e}")
# Use the built-in enrichment method from TracerouteResult
try:
# Run async enrichment in sync context
loop = None
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# If we're already in an event loop, create a new task
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self._enrich_async(output))
future.result()
else:
loop.run_until_complete(self._enrich_async(output))
except RuntimeError:
# No event loop, create one
asyncio.run(self._enrich_async(output))
_log.info("IP enrichment completed successfully")
except Exception as e:
_log.error(f"IP enrichment failed: {e}")
# Reverse DNS lookups already handled in _enrich_async for missing hostnames
_log.info(f"Completed enrichment for traceroute to {output.target}")
return output