# -*- coding: utf-8 -*- # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import re from typing import Any import netaddr class OpenWrtRoutingMixin: """Mixin providing routing-related NAPALM methods (ping, traceroute, route).""" def ping( self, destination: str, source: str = "", ttl: int = 255, timeout: int = 2, size: int = 56, count: int = 5, vrf: str = "", source_interface: str = "", ) -> dict[str, Any]: """Execute ping on the device and return statistics. Builds a ``ping`` command with standard BusyBox/iputils flags:: ping -c -W -s [-t ] [-I ] Returns ``{'success': {...}}`` or ``{'error': ''}``. """ cmd_parts = ["ping", "-c", str(count), "-W", str(timeout), "-s", str(size)] if ttl != 255: cmd_parts += ["-t", str(ttl)] if source_interface: cmd_parts += ["-I", source_interface] elif source: cmd_parts += ["-I", source] cmd_parts.append(destination) output = self._send_command(" ".join(cmd_parts)) # Check for hard failure before parsing if re.search(r"unknown host|bad address|Network unreachable|not reachable", output, re.I): m = re.search(r"(unknown host.*|bad address.*|Network unreachable)", output, re.I) return {"error": m.group(0) if m else output.strip()} return self._parse_ping_output(output, destination) @staticmethod def _parse_ping_output(output: str, destination: str) -> dict[str, Any]: """Parse BusyBox/iputils ping output into NAPALM format.""" # "2 packets transmitted, 2 packets received, 0% packet loss" summary_m = re.search( r"(\d+)\s+packets?\s+transmitted.*?(\d+)\s+(?:packets?\s+)?received.*?(\d+)%\s+packet\s+loss", output, re.S | re.I, ) if not summary_m: return {"error": output.strip() or f"No response from {destination}"} sent = int(summary_m.group(1)) received = int(summary_m.group(2)) loss = sent - received # "round-trip min/avg/max = 6.987/7.055/7.123 ms" (BusyBox) # "rtt min/avg/max/mdev = 6.987/7.055/7.123/0.094 ms" (iputils) rtt_m = re.search( r"(?:round-trip|rtt)\s+min/avg/max(?:/(?:mdev|stddev))?\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)(?:/([\d.]+))?", output, re.I, ) rtt_min = rtt_avg = rtt_max = rtt_stddev = 0.0 if rtt_m: rtt_min = float(rtt_m.group(1)) rtt_avg = float(rtt_m.group(2)) rtt_max = float(rtt_m.group(3)) rtt_stddev = float(rtt_m.group(4)) if rtt_m.group(4) else 0.0 # Individual probe results results = [] for m in re.finditer( r"(\d+)\s+bytes\s+from\s+(\S+?):\s+(?:icmp_seq|seq)=\d+\s+.*?time=([\d.]+)\s*ms", output, re.I, ): ip = m.group(2).rstrip(":") results.append({"ip_address": ip, "rtt": float(m.group(3))}) return { "success": { "probes_sent": sent, "packet_loss": loss, "rtt_min": rtt_min, "rtt_avg": rtt_avg, "rtt_max": rtt_max, "rtt_stddev": rtt_stddev, "results": results, } } def get_route_to( self, destination: str = "", protocol: str = "", longer: bool = False ) -> dict[str, list[dict[str, Any]]]: """Return routes to *destination* from the kernel routing table. Uses ``ip route show`` (optionally filtered by prefix/match) and ``ip route get `` for the best-path lookup. Protocol filter is applied post-parse (kernel proto names: ``kernel``, ``static``, ``dhcp``, ``bird``, ``zebra``, …). Example ``ip route show`` output:: default via 192.168.1.1 dev br-wan proto dhcp src 203.0.113.1 metric 100 192.168.1.0/24 dev br-lan proto kernel scope link src 192.168.1.1 """ if destination: cmd = f"ip route show {'match ' if longer else ''}{destination}" else: cmd = "ip route show" output = self._send_command(cmd) routes: dict[str, list[dict[str, Any]]] = {} for line in output.splitlines(): line_s = line.strip() if not line_s: continue # Determine the prefix # "default via ..." → prefix = "0.0.0.0/0" # "192.168.1.0/24 dev ..." → prefix as-is if line_s.startswith("default"): prefix = "0.0.0.0/0" rest = line_s[len("default"):].strip() else: parts = line_s.split() prefix = parts[0] rest = " ".join(parts[1:]) # Extract fields next_hop = "" outgoing_iface = "" proto_raw = "kernel" metric = 0 m = re.search(r"\bvia\s+(\S+)", rest) if m: next_hop = m.group(1) m = re.search(r"\bdev\s+(\S+)", rest) if m: outgoing_iface = m.group(1) m = re.search(r"\bproto\s+(\S+)", rest) if m: proto_raw = m.group(1) m = re.search(r"\bmetric\s+(\d+)", rest) if m: metric = int(m.group(1)) # Map proto to NAPALM-style name proto_map = { "kernel": "connected", "static": "static", "dhcp": "static", "bird": "bgp", "zebra": "ospf", } napalm_proto = proto_map.get(proto_raw.lower(), proto_raw) if protocol and napalm_proto.lower() != protocol.lower(): continue entry = { "protocol": napalm_proto, "current_active": True, "last_active": True, "age": 0, "next_hop": next_hop, "outgoing_interface": outgoing_iface, "selected_next_hop": True, "preference": metric, "inactive_reason": "", "routing_table": "default", "protocol_attributes": {}, } routes.setdefault(prefix, []).append(entry) return routes def traceroute( self, destination: str, source: str = "", ttl: int = 30, timeout: int = 3, vrf: str = "", ) -> dict[str, Any]: """Execute traceroute on the device. Uses ``traceroute -m -w `` (BusyBox-compatible). Falls back to ``traceroute6`` for IPv6 destinations. Returns ``{'success': {hop: {'probes': {probe: {rtt, ip_address, host_name}}}}}`` or ``{'error': ''}``. """ # Detect IPv6 destination try: is_ipv6 = netaddr.IPAddress(destination).version == 6 except (netaddr.AddrFormatError, ValueError): is_ipv6 = ":" in destination cmd_base = "traceroute6" if is_ipv6 else "traceroute" cmd_parts = [cmd_base, "-m", str(ttl), "-w", str(timeout)] if source: cmd_parts += ["-s", source] cmd_parts.append(destination) output = self._send_command(" ".join(cmd_parts)) if re.search(r"unknown host|bad address|not reachable|cannot resolve", output, re.I): m = re.search(r"(unknown host.*|bad address.*|cannot resolve.*)", output, re.I) return {"error": m.group(0) if m else output.strip()} return self._parse_traceroute_output(output) @staticmethod def _parse_traceroute_output(output: str) -> dict[str, Any]: """Parse BusyBox traceroute output into NAPALM format. Example lines:: 1 192.168.1.1 (192.168.1.1) 1.123 ms 1.456 ms 1.789 ms 2 * * * """ hops: dict[int, dict[str, Any]] = {} for line in output.splitlines(): line_s = line.strip() # Hop line starts with an integer m = re.match(r"^(\d+)\s+(.*)", line_s) if not m: continue hop_id = int(m.group(1)) rest = m.group(2).strip() # All-star line: no response if re.match(r"^\*[\s*]*$", rest): hops[hop_id] = { "probes": { 1: {"rtt": -1.0, "ip_address": "*", "host_name": "*"}, 2: {"rtt": -1.0, "ip_address": "*", "host_name": "*"}, 3: {"rtt": -1.0, "ip_address": "*", "host_name": "*"}, } } continue # Extract host/IP and RTT values # Format: "hostname (ip) 1.1 ms 2.2 ms 3.3 ms" # or: "ip 1.1 ms 2.2 ms 3.3 ms" host_m = re.match(r"^(\S+)\s+\((\S+)\)", rest) if host_m: host_name = host_m.group(1) ip_address = host_m.group(2) else: # IP only ip_m = re.match(r"^(\d[\d.]+|[0-9a-f:]+)", rest) if ip_m: ip_address = ip_m.group(1) host_name = ip_address else: continue rtt_values = [float(x) for x in re.findall(r"([\d.]+)\s+ms", rest)] probes: dict[int, dict[str, Any]] = {} for i, rtt in enumerate(rtt_values[:3], start=1): probes[i] = { "rtt": rtt, "ip_address": ip_address, "host_name": host_name, } # Fill missing probes with star entries for i in range(len(rtt_values) + 1, 4): probes[i] = {"rtt": -1.0, "ip_address": "*", "host_name": "*"} if probes: hops[hop_id] = {"probes": probes} if not hops: return {"error": output.strip() or "No traceroute output received"} return {"success": hops}