Split from monolithic openwrt.py into separate mixin files for interfaces, VLANs, packages, routing, wireless, LLDP, and config. Already in use by the main driver; just missing from git tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
310 lines
11 KiB
Python
310 lines
11 KiB
Python
# -*- 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 <count> -W <timeout> -s <size> [-t <ttl>] [-I <iface>] <dest>
|
|
|
|
Returns ``{'success': {...}}`` or ``{'error': '<message>'}``.
|
|
"""
|
|
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 <dest>`` 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 <ttl> -w <timeout> <dest>`` (BusyBox-compatible).
|
|
Falls back to ``traceroute6`` for IPv6 destinations.
|
|
|
|
Returns ``{'success': {hop: {'probes': {probe: {rtt, ip_address, host_name}}}}}``
|
|
or ``{'error': '<message>'}``.
|
|
"""
|
|
# 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}
|