chore: track extracted mixin modules

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>
This commit is contained in:
Christian Manivong
2026-06-19 22:13:04 +02:00
co-authored by Claude Sonnet 4.6
parent b7e4831b26
commit 2c0a5e94bf
7 changed files with 1864 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
# -*- 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}