1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-04-17 21:38:27 +00:00
thatmattlove-hyperglass/hyperglass/models/parsing/mikrotik.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

510 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Parser for MikroTik RouterOS (ROS v6/v7) structured in Huawei style."""
# Standard Library
import re
import typing as t
# Third Party
from pydantic import ConfigDict
# Project
from hyperglass.log import log
from hyperglass.models.data.bgp_route import BGPRoute, BGPRouteTable # Add BGPRoute import
# Local
from ..main import HyperglassModel
RPKI_STATE_MAP = {
"invalid": 0,
"valid": 1,
"unknown": 2,
"unverified": 3,
}
def remove_prefix(text: str, prefix: str) -> str:
if text.startswith(prefix):
return text[len(prefix) :]
return text
# Regex to find key=value pairs. The key can contain dots and hyphens.
# The value can be quoted or a single word.
TOKEN_RE = re.compile(r'([a-zA-Z0-9_.-]+)=(".*?"|\S+)')
# Regex to find flags at the beginning of a line (e.g., "Ab dst-address=...")
FLAGS_RE = re.compile(r"^\s*([DXIAcmsroivmyH\+b]+)\s+")
class MikrotikBase(HyperglassModel, extra="ignore"):
def __init__(self, **kwargs: t.Any) -> None:
super().__init__(**kwargs)
class MikrotikPaths(MikrotikBase):
available: int = 0
best: int = 0
select: int = 0
best_external: int = 0
add_path: int = 0
class MikrotikRouteEntry(MikrotikBase):
"""MikroTik Route Entry."""
model_config = ConfigDict(validate_assignment=False)
prefix: str
gateway: str = ""
distance: int = 0
scope: int = 0
target_scope: int = 0
as_path: t.List[int] = []
communities: t.List[str] = []
large_communities: t.List[str] = []
ext_communities: t.List[str] = []
local_preference: int = 100
metric: int = 0 # MED
origin: str = ""
is_active: bool = False
is_best: bool = False
is_valid: bool = False
rpki_state: int = RPKI_STATE_MAP.get("unknown", 2)
@property
def next_hop(self) -> str:
return self.gateway
@property
def age(self) -> int:
# MikroTik output does not provide route age, returning -1 to indicate unavailable.
return -1
@property
def weight(self) -> int:
return self.distance
@property
def med(self) -> int:
return self.metric
@property
def active(self) -> bool:
return self.is_active or self.is_best
@property
def all_communities(self) -> t.List[str]:
return self.communities + self.large_communities + self.ext_communities
@property
def source_as(self) -> int:
return self.as_path[-1] if self.as_path else 0
@property
def source_rid(self) -> str:
# MikroTik output does not provide source RID, returning empty string.
return ""
@property
def peer_rid(self) -> str:
return self.gateway
def _extract_paths(lines: t.List[str]) -> MikrotikPaths:
"""Simple count based on lines with dst/dst-address and 'A' flag."""
available = 0
best = 0
for raw in lines:
if ("dst-address=" in raw) or (" dst=" in f" {raw} "):
available += 1
m = FLAGS_RE.match(raw)
if m and "A" in m.group(1):
best += 1
return MikrotikPaths(available=available, best=best, select=best)
def _process_kv(route: dict, key: str, val: str):
_log = log.bind(parser="MikrotikBGPTable")
"""Process a key-value pair and update the route dictionary."""
# Normalize quoted values
if val.startswith('"') and val.endswith('"'):
val = val[1:-1]
if key in ("dst-address", "dst"):
route["prefix"] = val
elif key in ("gateway", "nexthop"):
# Extract only the IP from gateway (e.g., 168.254.0.2%vlan-2000)
route["gateway"] = val.split("%")[0]
elif key == "distance":
route["distance"] = int(val) if val.isdigit() else route.get("distance", 0)
elif key == "scope":
route["scope"] = int(val) if val.isdigit() else route.get("scope", 0)
elif key in ("target-scope", "target_scope"):
route["target_scope"] = int(val) if val.isdigit() else route.get("target_scope", 0)
# v7 keys (with dot)
elif key in (".as-path", "as-path", "bgp-as-path"):
if val and val.lower() != "none":
# Find all numbers in the as-path string
nums = re.findall(r"\b\d{1,10}\b", val)
route["as_path"] = [int(n) for n in nums if 1 <= int(n) <= 4294967295]
elif key in (".origin", "origin", "bgp-origin"):
route["origin"] = val
elif key in (".med", "med", "bgp-med"):
route["metric"] = int(val) if val.isdigit() else 0
elif key in (".local-pref", "local-pref", "bgp-local-pref"):
route["local_preference"] = int(val) if val.isdigit() else 100
elif key in (".communities", "communities", "bgp-communities"):
if val and val.lower() != "none":
route["communities"] = [c.strip() for c in val.split(",") if c.strip()]
elif key in (".large-communities", "large-communities", "bgp-large-communities"):
if val and val.lower() != "none":
route["large_communities"] = [c.strip() for c in val.split(",") if c.strip()]
elif key == "bgp-ext-communities":
if val and val.lower() != "none":
route["ext_communities"] = [c.strip() for c in val.split(",") if c.strip()]
elif key == "rpki":
# _log.debug(f"RPKI raw value: {val!r}")
clean_val = val.strip().strip('"').lower()
route["rpki_state"] = RPKI_STATE_MAP.get(clean_val, 2)
def _extract_route_entries(lines: t.List[str]) -> t.List[MikrotikRouteEntry]:
"""Extract route entries from a list of lines."""
routes: t.List[MikrotikRouteEntry] = []
current_route_lines = []
for line in lines:
stripped_line = line.strip()
# A new route entry starts with flags or is a continuation line.
# An empty line signifies the end of the previous block.
if not stripped_line and current_route_lines:
# Process the completed route block
route_data = _parse_route_block(current_route_lines)
if route_data:
routes.append(route_data)
current_route_lines = []
elif stripped_line:
# Check if this line is the start of a new entry
if FLAGS_RE.match(stripped_line) and current_route_lines:
route_data = _parse_route_block(current_route_lines)
if route_data:
routes.append(route_data)
current_route_lines = [stripped_line]
else:
current_route_lines.append(stripped_line)
# Process any remaining lines
if current_route_lines:
route_data = _parse_route_block(current_route_lines)
if route_data:
routes.append(route_data)
return routes
def _parse_route_block(block: t.List[str]) -> t.Optional[MikrotikRouteEntry]:
"""Parse a single route block and return a MikrotikRouteEntry."""
if not block:
return None
full_block_text = " ".join(block)
if "dst-address=" not in full_block_text and " dst=" not in f" {full_block_text} ":
return None
rd = {
"prefix": "",
"gateway": "",
"distance": 20,
"scope": 30,
"target_scope": 10,
"as_path": [],
"communities": [],
"large_communities": [],
"ext_communities": [],
"local_preference": 100,
"metric": 0,
"origin": "",
"is_active": False,
"is_best": False,
"is_valid": False,
"rpki_state": RPKI_STATE_MAP.get("unknown", 2),
}
# Check for 'A' (active) flag in the first line
m = FLAGS_RE.match(block[0])
if m and "A" in m.group(1):
rd["is_active"] = True
rd["is_best"] = True
# Find all key=value tokens in the entire block
for k, v in TOKEN_RE.findall(full_block_text):
_process_kv(rd, k, v)
if rd["prefix"]:
try:
return MikrotikRouteEntry(**rd)
except Exception as e:
log.warning(f"Failed to create MikroTik route entry ({rd.get('prefix','?')}: {e}")
return None
class MikrotikBGPRouteTable(BGPRouteTable):
"""Canonical MikroTik BGP Route Table."""
# No custom __init__ needed; inherit from BGPRouteTable (which should be a Pydantic model)
class MikrotikBGPTable(MikrotikBase):
"""MikroTik BGP Table in canonical format."""
local_router_id: str = ""
local_as_number: int = 0
paths: MikrotikPaths = MikrotikPaths()
routes: t.List[MikrotikRouteEntry] = []
@classmethod
def parse_text(cls, text: str) -> "MikrotikBGPTable":
_log = log.bind(parser="MikrotikBGPTable")
inst = cls()
lines = text.splitlines()
if not lines:
return inst
# Filter out command echoes and header lines
lines = [ln for ln in lines if not ln.strip().startswith((">", "Flags:", "[", "#"))]
inst.paths = _extract_paths(lines)
inst.routes = _extract_route_entries(lines)
_log.debug(f"Parsed {len(inst.routes)} MikroTik routes")
return inst
def bgp_table(self) -> BGPRouteTable:
routes = []
for route in self.routes:
route_data = {
"prefix": route.prefix,
"active": route.active,
"age": route.age,
"weight": route.weight,
"med": route.med,
"local_preference": route.local_preference,
"as_path": route.as_path,
"communities": route.all_communities,
"next_hop": route.next_hop,
"source_as": route.source_as,
"source_rid": route.source_rid,
"peer_rid": route.peer_rid,
"rpki_state": route.rpki_state,
}
# Instantiate BGPRoute to trigger validation (including external RPKI)
routes.append(BGPRoute(**route_data))
return MikrotikBGPRouteTable(
vrf="default",
count=len(routes),
routes=routes,
winning_weight="low",
)
class MikrotikTracerouteTable(MikrotikBase):
"""MikroTik Traceroute Table."""
target: str
source: str
hops: t.List["MikrotikTracerouteHop"] = []
max_hops: int = 30
packet_size: int = 60
@classmethod
def parse_text(cls, text: str, target: str, source: str) -> "MikrotikTracerouteTable":
"""Parse a cleaned MikroTik traceroute table.
The input is expected to be a single, clean traceroute table that has already
been processed by the garbage cleaner to remove paging artifacts and duplicates.
Expected format:
ADDRESS LOSS SENT LAST AVG BEST WORST STD-DEV STATUS
197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1
100% 3 timeout
41.78.188.153 0% 3 210.8ms 210.8 210.8 210.9 0
"""
_log = log.bind(parser="MikrotikTracerouteTable")
_log.debug(
"Parsing cleaned MikroTik traceroute table",
target=target,
source=source,
lines=len(text.splitlines()),
)
lines = text.strip().split("\n")
hops = []
hop_number = 1
# Find the table header to start parsing from
header_found = False
data_start_index = 0
for i, line in enumerate(lines):
if "ADDRESS" in line and "LOSS" in line and "SENT" in line:
header_found = True
data_start_index = i + 1
break
if not header_found:
_log.warning("No traceroute table header found in cleaned output")
return MikrotikTracerouteTable(target=target, source=source, hops=[])
# Parse data rows
for i in range(data_start_index, len(lines)):
line = lines[i].strip()
# Skip empty lines
if not line:
continue
# Stop at any remaining paging markers (shouldn't happen with cleaned input)
if "-- [Q quit|C-z pause]" in line:
break
try:
# Parse data line
parts = line.split()
if len(parts) < 3:
continue
# Check if this is a timeout line (starts with percentage)
if parts[0].endswith("%"):
# Timeout hop: "100% 3 timeout"
ip_address = None
loss_pct = int(parts[0].rstrip("%"))
sent_count = int(parts[1])
last_rtt = None
avg_rtt = None
best_rtt = None
worst_rtt = None
else:
# Normal hop: "197.157.67.233 0% 3 0.4ms 0.2 0.1 0.4 0.1"
ip_address = parts[0]
if len(parts) < 7:
continue
loss_pct = int(parts[1].rstrip("%"))
sent_count = int(parts[2])
# Parse RTT values
def parse_rtt(rtt_str: str) -> t.Optional[float]:
if rtt_str in ("timeout", "-", "0ms", "*"):
return None
rtt_clean = re.sub(r"ms$", "", rtt_str)
try:
return float(rtt_clean)
except ValueError:
return None
last_rtt = parse_rtt(parts[3])
avg_rtt = parse_rtt(parts[4])
best_rtt = parse_rtt(parts[5])
worst_rtt = parse_rtt(parts[6])
hop = MikrotikTracerouteHop(
hop_number=hop_number,
ip_address=ip_address,
hostname=None, # MikroTik doesn't do reverse DNS by default
loss_pct=loss_pct,
sent_count=sent_count,
last_rtt=last_rtt,
avg_rtt=avg_rtt,
best_rtt=best_rtt,
worst_rtt=worst_rtt,
)
hops.append(hop)
hop_number += 1
except (ValueError, IndexError) as e:
_log.debug("Failed to parse traceroute data line", line=line, error=str(e))
continue
result = MikrotikTracerouteTable(target=target, source=source, hops=hops)
_log.info("Parsed cleaned traceroute table", hops=len(hops))
return result
def traceroute_result(self):
"""Convert to TracerouteResult format."""
from hyperglass.models.data.traceroute import TracerouteResult, TracerouteHop
from hyperglass.log import log
_log = log.bind(parser="MikrotikTracerouteTable")
converted_hops = []
for hop in self.hops:
# Handle truncated IP addresses
ip_address = hop.ip_address
display_ip = None
if hop.ip_address and hop.ip_address.endswith("..."):
# For truncated IPs, store for display but set ip_address to None for validation
display_ip = hop.ip_address
ip_address = None
created_hop = TracerouteHop(
hop_number=hop.hop_number,
ip_address=ip_address, # None for truncated IPs
display_ip=display_ip, # Truncated IP for display
hostname=hop.hostname,
# Set RTT values to ensure avg_rtt property returns MikroTik's AVG value
# Since avg_rtt = (rtt1 + rtt2 + rtt3) / 3, we set all to the MikroTik AVG
rtt1=hop.avg_rtt, # Set to AVG so computed average is correct
rtt2=hop.avg_rtt, # Set to AVG so computed average is correct
rtt3=hop.avg_rtt, # Set to AVG so computed average is correct
# MikroTik-specific statistics (preserve original values)
loss_pct=hop.loss_pct,
sent_count=hop.sent_count,
last_rtt=hop.last_rtt, # Preserve LAST value
best_rtt=hop.best_rtt, # Preserve BEST value
worst_rtt=hop.worst_rtt, # Preserve WORST value
# BGP enrichment fields will be populated by enrichment plugin
# For truncated IPs, these will remain None/empty
asn=None,
org=None,
prefix=None,
country=None,
rir=None,
allocated=None,
)
converted_hops.append(created_hop)
return TracerouteResult(
target=self.target,
source=self.source,
hops=converted_hops,
max_hops=self.max_hops,
packet_size=self.packet_size,
raw_output=None, # Will be set by the plugin
)
class MikrotikTracerouteHop(MikrotikBase):
"""Individual MikroTik traceroute hop."""
hop_number: int
ip_address: t.Optional[str] = None
hostname: t.Optional[str] = None
# MikroTik-specific statistics
loss_pct: t.Optional[int] = None
sent_count: t.Optional[int] = None
last_rtt: t.Optional[float] = None
avg_rtt: t.Optional[float] = None
best_rtt: t.Optional[float] = None
worst_rtt: t.Optional[float] = None
@property
def is_timeout(self) -> bool:
"""Check if this hop is a timeout."""
return self.ip_address is None or self.loss_pct == 100