# -*- 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. """NAPALM driver for OpenWrt routers and access-points. Communicates via SSH. The device must be running OpenWrt 19.07 or newer. Netmiko device_type: ``linux`` """ import re import socket from typing import Dict, List, Optional, Union import netaddr from netmiko import ConnectHandler from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException from napalm_device_types import AccessPointDriver from napalm.base import helpers as napalm_helpers from napalm.base.exceptions import ( ConnectionException, ConnectionClosedException, CommandErrorException, MergeConfigException, ReplaceConfigException, ) from napalm.base.netmiko_helpers import netmiko_args class OpenWrtDriver(AccessPointDriver): """NAPALM driver for OpenWrt routers and access-points.""" VENDOR = "OpenWrt" NETMIKO_DEVICE_TYPE = "linux" def __init__( self, hostname: str, username: str, password: str, timeout: int = 60, optional_args: Optional[Dict] = None, ) -> None: self.hostname = hostname self.username = username self.password = password self.timeout = timeout self.device: Optional[ConnectHandler] = None if optional_args is None: optional_args = {} self.port = optional_args.pop("port", 22) self.netmiko_optional_args = netmiko_args(optional_args) # Config management state self._candidate_config: Optional[str] = None self._candidate_mode: Optional[str] = None # 'merge' or 'replace' self._backup_config: Optional[str] = None # ------------------------------------------------------------------ # Connection management # ------------------------------------------------------------------ def open(self) -> None: """Open an SSH connection to the device.""" try: self.device = ConnectHandler( device_type=self.NETMIKO_DEVICE_TYPE, host=self.hostname, username=self.username, password=self.password, timeout=self.timeout, port=self.port, **self.netmiko_optional_args, ) except NetmikoTimeoutException as exc: raise ConnectionException( f"Cannot connect to {self.hostname}: {exc}" ) from exc except NetmikoAuthenticationException as exc: raise ConnectionException( f"Authentication failed for {self.hostname}: {exc}" ) from exc def close(self) -> None: """Close the SSH connection.""" if self.device: self.device.disconnect() self.device = None def is_alive(self) -> Dict[str, bool]: """Return connection liveness.""" if self.device is None: return {"is_alive": False} try: return {"is_alive": self.device.remote_conn.transport.is_active()} except (socket.error, EOFError, AttributeError): return {"is_alive": False} # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _send_command(self, command: Union[str, List[str]]) -> str: """Send a shell command (or list of fallback commands) to the device. When a list is supplied, commands are tried in order and the first one that does not return an error indicator is returned. """ def _do_send(cmd: str) -> str: return self.device.send_command( cmd, read_timeout=self.timeout, ).strip() try: if isinstance(command, list): output = "" for cmd in command: output = _do_send(cmd) if not output.startswith(("sh: ", "ash: ", "-ash: ", "command not found")): return output return output return _do_send(command) except (socket.error, EOFError) as exc: raise ConnectionClosedException(str(exc)) from exc @staticmethod def _parse_openwrt_release(output: str) -> Dict[str, str]: """Parse ``/etc/openwrt_release`` key=value pairs.""" result: Dict[str, str] = {} for line in output.splitlines(): m = re.match(r'^(\w+)=["\']?([^"\']*)["\']?$', line.strip()) if m: result[m.group(1)] = m.group(2) return result @staticmethod def _parse_uptime_seconds(uptime_str: str) -> float: """Convert ``/proc/uptime`` first field (seconds.hundredths) to float.""" try: return float(uptime_str.split()[0]) except (IndexError, ValueError): return 0.0 # ------------------------------------------------------------------ # NAPALM getters # ------------------------------------------------------------------ def get_facts(self) -> Dict: """Return a dictionary of general device facts. Retrieves data from: - ``/etc/openwrt_release`` → os_version, model - ``/proc/uptime`` → uptime - ``uci get system.@system[0].hostname`` or ``hostname`` → hostname - ``cat /tmp/sysinfo/model`` → model (preferred) - ``ip link show`` → interface_list """ release_out = self._send_command("cat /etc/openwrt_release") release = self._parse_openwrt_release(release_out) os_version = release.get("DISTRIB_RELEASE", "") model = self._send_command("cat /tmp/sysinfo/model") if not model or model.startswith("cat:"): model = release.get("DISTRIB_TARGET", "") uptime_out = self._send_command("cat /proc/uptime") uptime = self._parse_uptime_seconds(uptime_out) hostname = self._send_command( ["uci get system.@system[0].hostname", "hostname"] ) interface_list = self._get_interface_list() return { "vendor": self.VENDOR, "model": model, "hostname": hostname, "fqdn": hostname, "os_version": os_version, "serial_number": "", "uptime": uptime, "interface_list": interface_list, } def _get_interface_list(self) -> List[str]: """Return a sorted list of interface names from ``ip link show``.""" output = self._send_command("ip link show") interfaces = [] for line in output.splitlines(): m = re.match(r"^\d+:\s+(\S+?)[@:]", line) if m: name = m.group(1) if ( name not in self._EXCLUDED_INTERFACES and not name.startswith(self._EXCLUDED_INTERFACE_PREFIXES) ): interfaces.append(name) return sorted(set(interfaces)) def get_interfaces(self) -> Dict[str, Dict]: """Return interface details, excluding loopback and raw radio (phy*) interfaces.""" output = self._send_command("ip link show") return self._filter_interfaces(self._parse_ip_link(output)) def _parse_ip_link(self, output: str) -> Dict[str, Dict]: """Parse ``ip link show`` output into NAPALM interface dicts.""" interfaces: Dict[str, Dict] = {} current: Optional[str] = None for line in output.splitlines(): # New interface block: "2: eth0: mtu 1500 ..." m = re.match( r"^\d+:\s+(\S+?)[@:].*<([^>]*)>.*\bmtu\s+(\d+).*\bstate\s+(\S+)", line, ) if m: name = m.group(1) flags = m.group(2).upper() mtu = int(m.group(3)) state = m.group(4).upper() is_up = state in ("UP", "UNKNOWN") and "UP" in flags.split(",") is_enabled = "UP" in flags.split(",") interfaces[name] = { "is_up": is_up, "is_enabled": is_enabled, "description": "", "last_flapped": -1.0, "speed": -1.0, "mtu": mtu, "mac_address": "", } current = name continue # MAC address line: " link/ether aa:bb:cc:dd:ee:ff ..." if current and "link/ether" in line: m2 = re.search(r"link/ether\s+(\S+)", line) if m2: try: interfaces[current]["mac_address"] = napalm_helpers.mac(m2.group(1)) except Exception: interfaces[current]["mac_address"] = m2.group(1) return interfaces def get_interfaces_ip(self) -> Dict[str, Dict]: """Return all configured IP addresses grouped by interface. Uses ``ip addr show``. Example output:: 2: eth0: mtu 1500 ... inet 192.168.1.1/24 brd 192.168.1.255 scope global eth0 inet6 fd00::1/64 scope global """ output = self._send_command("ip addr show") interfaces_ip: Dict[str, Dict] = {} current_iface: Optional[str] = None for line in output.splitlines(): # Interface line m = re.match(r"^\d+:\s+(\S+?)[@:]", line) if m: current_iface = m.group(1) continue if current_iface is None: continue # IPv4 m = re.match(r"^\s+inet\s+(\S+)", line) if m: cidr = m.group(1) try: ip_net = netaddr.IPNetwork(cidr) except (netaddr.AddrFormatError, ValueError): continue if current_iface not in interfaces_ip: interfaces_ip[current_iface] = {} interfaces_ip[current_iface].setdefault("ipv4", {})[str(ip_net.ip)] = { "prefix_length": ip_net.prefixlen } continue # IPv6 m = re.match(r"^\s+inet6\s+(\S+)", line) if m: cidr = m.group(1) try: ip_net = netaddr.IPNetwork(cidr) except (netaddr.AddrFormatError, ValueError): continue if current_iface not in interfaces_ip: interfaces_ip[current_iface] = {} interfaces_ip[current_iface].setdefault("ipv6", {})[str(ip_net.ip)] = { "prefix_length": ip_net.prefixlen } return interfaces_ip def get_config( self, retrieve: str = "all", full: bool = False, sanitized: bool = False, format: str = "text", ) -> Dict[str, str]: """Return the device configuration via ``uci export``. OpenWrt does not have a distinct startup/candidate config concept. ``running`` and ``startup`` both return ``uci export`` output. ``candidate`` is always empty. """ configs = {"running": "", "startup": "", "candidate": ""} if retrieve in ("all", "running"): configs["running"] = self._send_command("uci export") if retrieve in ("all", "startup"): configs["startup"] = self._send_command("uci export") return configs def get_arp_table(self, vrf: str = "") -> List[Dict]: """Return the ARP/neighbour table. Uses ``ip neigh show`` (preferred) which produces:: 192.168.1.100 dev br-lan lladdr aa:bb:cc:dd:ee:ff REACHABLE 192.168.1.1 dev br-lan lladdr 00:11:22:33:44:55 STALE """ output = self._send_command(["ip neigh show", "cat /proc/net/arp"]) arp_table = [] # ip neigh show format for line in output.splitlines(): line_s = line.strip() if not line_s: continue # Skip incomplete/failed entries if "FAILED" in line_s or "INCOMPLETE" in line_s: continue # ip neigh show: "192.168.1.100 dev br-lan lladdr aa:bb:cc:dd:ee:ff REACHABLE" m = re.match( r"^(\S+)\s+dev\s+(\S+)\s+lladdr\s+(\S+)", line_s, re.I, ) if m: ip_addr = m.group(1) interface = m.group(2) mac_raw = m.group(3) try: netaddr.IPAddress(ip_addr) except (netaddr.AddrFormatError, ValueError): continue try: mac_addr = napalm_helpers.mac(mac_raw) except Exception: mac_addr = mac_raw arp_table.append( { "interface": interface, "mac": mac_addr, "ip": ip_addr, "age": 0.0, } ) continue # /proc/net/arp fallback: "IP address HW type Flags HW address Mask Device" # skip header if line_s.startswith("IP address"): continue parts = line_s.split() if len(parts) >= 6: ip_addr = parts[0] mac_raw = parts[3] interface = parts[5] try: netaddr.IPAddress(ip_addr) except (netaddr.AddrFormatError, ValueError): continue if mac_raw in ("00:00:00:00:00:00", ""): continue try: mac_addr = napalm_helpers.mac(mac_raw) except Exception: mac_addr = mac_raw arp_table.append( { "interface": interface, "mac": mac_addr, "ip": ip_addr, "age": 0.0, } ) return arp_table def get_mac_address_table(self) -> List[Dict]: """Return the bridge forwarding database (MAC address table). Uses ``bridge fdb show`` which produces:: aa:bb:cc:dd:ee:ff dev br-lan master br-lan permanent 11:22:33:44:55:66 dev eth0.1 vlan 1 master br-lan """ output = self._send_command("bridge fdb show") mac_table = [] for line in output.splitlines(): line_s = line.strip() if not line_s: continue m = re.match(r"^(\S+)\s+dev\s+(\S+)", line_s) if not m: continue mac_raw = m.group(1) interface = m.group(2) # Skip broadcast/multicast self-entries that are always present if mac_raw.lower() in ("ff:ff:ff:ff:ff:ff", "33:33:00:00:00:01"): continue static = "permanent" in line_s or "static" in line_s # Extract VLAN if present: "vlan 10" vlan = 0 vlan_m = re.search(r"\bvlan\s+(\d+)", line_s) if vlan_m: vlan = int(vlan_m.group(1)) try: mac_addr = napalm_helpers.mac(mac_raw) except Exception: mac_addr = mac_raw mac_table.append( { "mac": mac_addr, "interface": interface, "vlan": vlan, "static": static, "active": True, "moves": None, "last_move": None, } ) return mac_table def get_lldp_neighbors(self) -> Dict[str, List[Dict]]: """Return LLDP neighbors (requires ``lldpd`` to be installed on the device). Uses ``lldpctl -f keyvalue`` output:: lldp.eth0.port.ifname=eth1 lldp.eth0.chassis.name=router-core """ neighbors: Dict[str, List[Dict]] = {} for row in self._get_lldp_table(): neighbors.setdefault(row["local_port"], []).append( {"hostname": row["system_name"], "port": row["port_id"]} ) return neighbors def _get_lldp_table(self) -> List[Dict]: """Parse ``lldpctl -f keyvalue`` into a list of row dicts. Ensures ``lldpd`` is enabled and running before querying; if it was not already running the daemon needs time to discover neighbors so the first call after a fresh install will return an empty list. """ # Enable + start lldpd if not already running (idempotent / silent) self._send_command( "pgrep lldpd >/dev/null 2>&1 || " "(/etc/init.d/lldpd enable 2>/dev/null; /etc/init.d/lldpd start 2>/dev/null)" ) output = self._send_command("lldpctl -f keyvalue") rows: List[Dict] = [] # Group by local interface prefix: lldp..* entries: Dict[str, Dict[str, str]] = {} for line in output.splitlines(): line_s = line.strip() if "=" not in line_s: continue key, _, value = line_s.partition("=") parts = key.split(".") # parts: ['lldp', '', , , ...] if len(parts) < 3 or parts[0] != "lldp": continue iface = parts[1] subkey = ".".join(parts[2:]) entries.setdefault(iface, {})[subkey] = value for iface, data in entries.items(): rows.append( { "local_port": iface, "remote_chassis_id": data.get("chassis.mac", data.get("chassis.id.value", "")), "port_id": data.get("port.ifname", data.get("port.id.value", "")), "mgmt_address": data.get("chassis.mgmt-ip", ""), "port_description": data.get("port.descr", ""), "system_name": data.get("chassis.name", ""), } ) return rows def get_lldp_neighbors_detail(self, interface: str = "") -> Dict[str, List[Dict]]: """Return detailed LLDP neighbor info.""" details: Dict[str, List[Dict]] = {} for row in self._get_lldp_table(): if interface and row["local_port"] != interface: continue details.setdefault(row["local_port"], []).append( { "parent_interface": "", "remote_port": row["port_id"], "remote_port_description": row["port_description"], "remote_chassis_id": row["remote_chassis_id"], "remote_system_name": row["system_name"], "remote_system_description": "", "remote_system_capab": [], "remote_system_enable_capab": [], } ) return details def get_environment(self) -> Dict: """Return device environment data (CPU, memory). CPU usage from ``/proc/stat`` (two samples 1 second apart via ``awk``). Memory from ``/proc/meminfo``. """ cpu_out = self._send_command( "awk '/^cpu /{idle1=$5; total1=$2+$3+$4+$5+$6+$7+$8} END{print (1-(idle1/total1))*100}' /proc/stat" ) mem_out = self._send_command("cat /proc/meminfo") cpu_pct = 0.0 try: cpu_pct = float(cpu_out.strip()) except (ValueError, AttributeError): pass mem_total = 0 mem_available = 0 for line in mem_out.splitlines(): if line.startswith("MemTotal:"): try: mem_total = int(line.split()[1]) except (IndexError, ValueError): pass elif line.startswith("MemAvailable:"): try: mem_available = int(line.split()[1]) except (IndexError, ValueError): pass return { "fans": {}, "temperature": {}, "power": {}, "cpu": {0: {"%usage": round(cpu_pct, 1)}}, "memory": { "available_ram": mem_available * 1024, "used_ram": (mem_total - mem_available) * 1024, }, } def get_interfaces_counters(self) -> Dict[str, Dict]: """Return per-interface packet and byte counters from ``/proc/net/dev``. ``/proc/net/dev`` columns (Receive | Transmit):: face |bytes packets errs drop fifo frame compressed multicast| \ bytes packets errs drop fifo colls carrier compressed """ output = self._send_command("cat /proc/net/dev") counters: Dict[str, Dict] = {} for line in output.splitlines(): # Skip header lines if "|" in line or "Inter" in line: continue line_s = line.strip() if not line_s: continue parts = line_s.replace(":", " ").split() if len(parts) < 17: continue iface = parts[0] try: counters[iface] = { "tx_errors": int(parts[10]), "rx_errors": int(parts[3]), "tx_discards": int(parts[11]), "rx_discards": int(parts[4]), "tx_octets": int(parts[9]), "rx_octets": int(parts[1]), "tx_unicast_packets": int(parts[10 - 1]), # packets field "rx_unicast_packets": int(parts[2]), "tx_multicast_packets": 0, "rx_multicast_packets": int(parts[8]), "tx_broadcast_packets": 0, "rx_broadcast_packets": 0, } except (IndexError, ValueError): continue return counters # ------------------------------------------------------------------ # NAPALM configuration management # ------------------------------------------------------------------ def load_merge_candidate( self, filename: Optional[str] = None, config: Optional[str] = None ) -> None: """Stage a set of UCI commands to be applied to the running config. *config* is a plain-text string of UCI commands (``uci set``, ``uci add``, ``uci del``, etc.) – one command per line. Blank lines and lines starting with ``#`` are ignored. The configuration is **not** applied until :meth:`commit_config` is called. :raises MergeConfigException: on invalid input. """ if filename is not None: try: with open(filename) as fh: config = fh.read() except OSError as exc: raise MergeConfigException(str(exc)) from exc if config is None: raise MergeConfigException("Either 'filename' or 'config' must be provided.") self._candidate_config = config self._candidate_mode = "merge" def load_replace_candidate( self, filename: Optional[str] = None, config: Optional[str] = None ) -> None: """Stage a full ``uci export`` replacement candidate. The candidate should be a complete ``uci export`` output. :meth:`compare_config` shows a unified diff against the current config. :meth:`commit_config` imports the candidate via ``uci import`` and commits all affected packages. :raises ReplaceConfigException: on invalid input. """ if filename is not None: try: with open(filename) as fh: config = fh.read() except OSError as exc: raise ReplaceConfigException(str(exc)) from exc if config is None: raise ReplaceConfigException("Either 'filename' or 'config' must be provided.") self._candidate_config = config self._candidate_mode = "replace" def compare_config(self) -> str: """Return a human-readable diff of the pending candidate vs running config. For a **merge** candidate: returns the staged UCI commands prefixed with ``+``. For a **replace** candidate: returns a unified diff between the current ``uci export`` output and the candidate text. Returns an empty string when no candidate is staged. """ if self._candidate_config is None: return "" if self._candidate_mode == "merge": lines = [] for line in self._candidate_config.splitlines(): if line.strip() and not line.strip().startswith("#"): lines.append(f"+{line}") return "\n".join(lines) # replace mode – unified diff import difflib running = self._send_command("uci export") diff = difflib.unified_diff( running.splitlines(), self._candidate_config.splitlines(), fromfile="running-config", tofile="candidate-config", lineterm="", ) return "\n".join(diff) def commit_config(self, message: str = "", revert_in: Optional[int] = None) -> None: """Apply the staged candidate configuration and commit it. **Merge mode**: each UCI command line is sent to the device shell, then ``uci commit`` is called to persist the changes. **Replace mode**: the candidate is piped through ``uci import`` and then ``uci commit`` is called for every affected package. :raises MergeConfigException: if no candidate is staged or if commands are rejected. :raises ReplaceConfigException: same, for replace candidates. """ if self._candidate_config is None: raise MergeConfigException("No candidate configuration is staged.") ex_cls = ReplaceConfigException if self._candidate_mode == "replace" else MergeConfigException # Save backup for potential rollback self._backup_config = self._send_command("uci export") errors: List[str] = [] try: if self._candidate_mode == "merge": for line in self._candidate_config.splitlines(): stripped = line.strip() if not stripped or stripped.startswith("#"): continue out = self._send_command(stripped) if out and ("uci: " in out.lower() or "error" in out.lower()): errors.append(f" {stripped!r}: {out}") self._send_command("uci commit") else: # Replace: pipe candidate through uci import # Write to a temp file and import it escaped = self._candidate_config.replace("'", "'\\''") self._send_command(f"printf '%s' '{escaped}' > /tmp/napalm_candidate.uci") out = self._send_command("uci import < /tmp/napalm_candidate.uci && uci commit") self._send_command("rm -f /tmp/napalm_candidate.uci") if out and "error" in out.lower(): errors.append(out) except Exception as exc: raise ex_cls(str(exc)) from exc if errors: raise ex_cls("The following commands were rejected:\n" + "\n".join(errors)) self._candidate_config = None self._candidate_mode = None def discard_config(self) -> None: """Discard the staged candidate configuration without applying it.""" self._candidate_config = None self._candidate_mode = None def rollback(self) -> None: """Restore the UCI configuration to the state before the last :meth:`commit_config`. Pipes the saved backup through ``uci import`` and then commits. :raises CommandErrorException: if no backup is available. """ if self._backup_config is None: raise CommandErrorException( "No backup configuration available – commit_config has not been called in this session." ) escaped = self._backup_config.replace("'", "'\\''") self._send_command(f"printf '%s' '{escaped}' > /tmp/napalm_rollback.uci") self._send_command("uci import < /tmp/napalm_rollback.uci && uci commit") self._send_command("rm -f /tmp/napalm_rollback.uci") self._backup_config = None def has_pending_commit(self) -> bool: """Return True when a candidate configuration is staged but not yet committed.""" return self._candidate_config is not None def get_vlans(self) -> Dict[str, Dict]: """Return VLAN information with proper tagged/untagged separation. Uses ``bridge vlan show`` (DSA-based OpenWrt ≥21.02) for VLAN/port membership and ``uci show network`` for VLAN names. A port marked *PVID Egress Untagged* is an untagged member. All other VLAN memberships for the same port are tagged. Also detects legacy 802.1q sub-interfaces (``eth0.10`` etc.) from ``ip link show``. The parent interface (``eth0``) is added as a tagged member for every such VLAN. """ bridge_out = self._send_command("bridge vlan show") uci_out = self._send_command("uci show network") # vlan_id → {name, tagged: [], untagged: []} vlans: Dict[str, Dict] = {} current_port: Optional[str] = None for line in bridge_out.splitlines(): line_s = line.strip() if not line_s or line_s.lower().startswith("port"): continue # Port line: "eth0 1 PVID Egress Untagged" m = re.match(r"^(\S+)\s+(\d+)(.*)", line) if m: current_port = m.group(1) vlan_id = str(int(m.group(2))) flags = m.group(3).upper() vlans.setdefault(vlan_id, {"name": "", "tagged": [], "untagged": []}) if "PVID" in flags or "UNTAGGED" in flags: if current_port not in vlans[vlan_id]["untagged"]: vlans[vlan_id]["untagged"].append(current_port) else: if current_port not in vlans[vlan_id]["tagged"]: vlans[vlan_id]["tagged"].append(current_port) continue # Continuation line with only a VLAN ID (tagged for current_port) m = re.match(r"^(\d+)", line_s) if m and current_port: vlan_id = str(int(m.group(1))) vlans.setdefault(vlan_id, {"name": "", "tagged": [], "untagged": []}) if current_port not in vlans[vlan_id]["tagged"]: vlans[vlan_id]["tagged"].append(current_port) # Enrich with UCI VLAN names from explicit bridge-vlan sections uci_entries: Dict[str, Dict[str, str]] = {} for line in uci_out.splitlines(): m = re.match(r"network\.@bridge-vlan\[(\d+)\]\.(\w+)='([^']*)'", line.strip()) if m: idx, key, value = m.group(1), m.group(2), m.group(3) uci_entries.setdefault(idx, {})[key] = value for entry in uci_entries.values(): if "vlan" in entry and "name" in entry: vlan_id = str(int(entry["vlan"])) if vlan_id in vlans: vlans[vlan_id]["name"] = entry["name"] # Also derive VLAN names from UCI network interface sections that # reference subinterfaces like eth0.N or br-ap.N: # network.guest.device='eth0.8' → VLAN 8 name = "guest" # network.ap_v8.device='br-ap.8' → VLAN 8 name = "ap_v8" # Only fills in names that are still empty after bridge-vlan lookup. for line in uci_out.splitlines(): m = re.match(r"network\.(\w+)\.device='[\w-]+\.(\d+)'", line.strip()) if m: section_name, vid_str = m.group(1), m.group(2) vlan_id = str(int(vid_str)) if vlan_id in vlans and not vlans[vlan_id]["name"]: vlans[vlan_id]["name"] = section_name # Also detect VLAN sub-interfaces (eth0.10, br-ap.8, …) from ip link show. # The sub-interface is the untagged egress point; its parent is tagged. link_out = self._send_command("ip link show") for line in link_out.splitlines(): lm = re.match(r"^\d+:\s+(\S+?)[@:]", line) if not lm: continue iface = lm.group(1) vm = re.match(r"^([\w-]+)\.(\d+)$", iface) # allow hyphens (br-ap) if not vm: continue parent = vm.group(1) # e.g. "eth0" or "br-ap" vlan_id = str(int(vm.group(2))) # e.g. "10" vlans.setdefault(vlan_id, {"name": "", "tagged": [], "untagged": []}) # sub-interface itself → untagged egress if iface not in vlans[vlan_id]["untagged"] and iface not in vlans[vlan_id]["tagged"]: vlans[vlan_id]["untagged"].append(iface) # parent → tagged trunk if parent not in vlans[vlan_id]["tagged"] and parent not in vlans[vlan_id]["untagged"]: vlans[vlan_id]["tagged"].append(parent) return vlans def delete_vlan(self, vlan_id: int) -> None: """Remove a VLAN from the device by deleting its UCI bridge-vlan section. Finds the ``network.@bridge-vlan[N]`` section whose ``.vlan`` matches *vlan_id*, deletes it and commits. If no matching section is found the method is a no-op (the VLAN may only exist as an eth0.N sub-interface, which cannot be deleted via UCI alone). :param vlan_id: VLAN ID to remove. :raises ValueError: If *vlan_id* is out of the valid range. """ if not 1 <= vlan_id <= 4094: raise ValueError(f"VLAN ID {vlan_id} is out of range (1–4094)") uci_out = self.cli(["uci show network"]).get("uci show network", "") idx = None for line in uci_out.splitlines(): m = re.match(r"network\.@bridge-vlan\[(\d+)\]\.vlan='(\d+)'", line.strip()) if m and int(m.group(2)) == vlan_id: idx = m.group(1) break if idx is None: # No explicit bridge-vlan section — nothing to delete via UCI return self.cli([ f"uci delete network.@bridge-vlan[{idx}]", "uci commit network", "/etc/init.d/network reload", ]) # ------------------------------------------------------------------ # CLI pass-through # ------------------------------------------------------------------ def get_ssids(self) -> Dict[str, Dict]: """Return configured SSIDs from UCI wireless configuration. Parses ``uci show wireless`` for ``wifi-iface`` entries and enriches each entry with: * ``band`` — human-readable frequency band ("2.4 GHz", "5 GHz", "6 GHz") derived from the radio's ``band`` or ``hwmode`` UCI key. * ``encryption`` — human-readable security mode ("WPA2-PSK", "Open", …). When the same SSID name is broadcast on multiple radios, the keys in the returned dict are disambiguated as ``"ssid (2.4 GHz)"`` / ``"ssid (5 GHz)"``. """ uci_out = self._send_command("uci show wireless") # Collect radio band info: radio0 → "2g", radio1 → "5g", … radio_bands: Dict[str, str] = {} iface_entries: Dict[str, Dict[str, str]] = {} # First pass: identify named sections that are wifi-iface types and # collect radio band info. named_iface_sections: set = set() for line in uci_out.splitlines(): line_s = line.strip() # named wifi-iface declaration: wireless.managed_family_2g=wifi-iface nm = re.match(r"wireless\.(\w+)=wifi-iface", line_s) if nm: named_iface_sections.add(nm.group(1)) continue # radio device config: wireless.radio0.band='2g' rm = re.match(r"wireless\.(radio\d+)\.(band|hwmode)='([^']*)'", line_s) if rm: radio, key, val = rm.group(1), rm.group(2), rm.group(3) if key == "band" or radio not in radio_bands: radio_bands[radio] = val # Second pass: collect iface properties (both anonymous and named sections) for line in uci_out.splitlines(): line_s = line.strip() # radio device config (already handled above) rm = re.match(r"wireless\.(radio\d+)\.(band|hwmode)='([^']*)'", line_s) if rm: radio, key, val = rm.group(1), rm.group(2), rm.group(3) # Prefer 'band' over 'hwmode' when both present if key == "band" or radio not in radio_bands: radio_bands[radio] = val continue # anonymous wifi-iface values: wireless.@wifi-iface[0].ssid='MyNet' im = re.match(r"wireless\.@wifi-iface\[(\d+)\]\.(\w+)='([^']*)'", line_s) if im: idx, key, val = im.group(1), im.group(2), im.group(3) iface_entries.setdefault(idx, {})[key] = val continue # named wifi-iface values: wireless.managed_family_2g.ssid='manivong' nm = re.match(r"wireless\.(\w+)\.(\w+)='([^']*)'", line_s) if nm and nm.group(1) in named_iface_sections: section, key, val = nm.group(1), nm.group(2), nm.group(3) iface_entries.setdefault(section, {})[key] = val def _band_label(radio: str) -> str: raw = radio_bands.get(radio, "").lower() if raw in ("2g", "11g", "b", "g", "bg", "bgn", "b/g", "b/g/n"): return "2.4 GHz" if raw in ("5g", "11a", "a", "ac", "ax5", "a/n", "a/n/ac"): return "5 GHz" if raw in ("6g", "ax6"): return "6 GHz" return "" _ENC_MAP = { "": "Open", "none": "Open", "0": "Open", "wep": "WEP", "wep-open": "WEP (Open)", "wep-shared": "WEP (Shared)", "psk": "WPA-PSK", "psk+ccmp": "WPA-PSK", "psk-mixed": "WPA/WPA2-PSK", "psk2": "WPA2-PSK", "psk2+ccmp": "WPA2-PSK", "psk2+aes": "WPA2-PSK", "psk3": "WPA3-SAE", "psk2+psk3": "WPA2/WPA3", "sae": "WPA3-SAE", "sae-mixed": "WPA2/WPA3", "wpa": "WPA-Enterprise", "wpa2": "WPA2-Enterprise", "wpa3": "WPA3-Enterprise", "ccmp": "WPA2-PSK", } def _enc_label(enc_raw: str) -> str: return _ENC_MAP.get(enc_raw.lower(), enc_raw.upper() or "Open") # Build network→vlan_id map from UCI network config. # A wifi-iface has option network='ap_7'; the corresponding UCI network # interface has either an explicit vid ('7') or a bridge device whose # name encodes the VLAN, e.g. br-ap.7 → VLAN 7. def _vlan_from_device(dev: str) -> Optional[int]: m = re.search(r"\.(\d+)$", dev) if m: return int(m.group(1)) return None net_vlan: Dict[str, int] = {} try: net_out = self._send_command("uci show network 2>/dev/null || true") net_entries: Dict[str, Dict[str, str]] = {} for line in net_out.splitlines(): line_s = line.strip() m = re.match(r"network\.(\w+)\.(\w+)='([^']*)'", line_s) if m: iface, key, val = m.group(1), m.group(2), m.group(3) net_entries.setdefault(iface, {})[key] = val for iface, props in net_entries.items(): vid_str = props.get("vid") or props.get("vlan") if vid_str and vid_str.isdigit(): net_vlan[iface] = int(vid_str) continue dev = props.get("device", "") vlan = _vlan_from_device(dev) if vlan is not None: net_vlan[iface] = vlan except Exception: pass # Non-fatal: VLAN info is optional enrichment # Build result; group entries with the same SSID name, merging bands result: Dict[str, Dict] = {} # Intermediate: ssid -> list of bands seen ssid_bands: Dict[str, List[str]] = {} for entry in iface_entries.values(): ssid = entry.get("ssid") if not ssid: continue radio = entry.get("device", "") band = _band_label(radio) disabled = entry.get("disabled", "0") == "1" enc_raw = entry.get("encryption", "") or "" encryption = _enc_label(enc_raw) hidden = entry.get("hidden", "0") == "1" network_name = entry.get("network", "") vlan_id: Optional[int] = net_vlan.get(network_name) ft_enabled = entry.get("ieee80211r", "0") == "1" ft_mobility_domain = entry.get("mobility_domain", "") ft_over_ds = entry.get("ft_over_ds", "1") == "1" client_isolation = entry.get("isolate", "0") == "1" _max_raw = entry.get("maxassoc") max_clients: Optional[int] = int(_max_raw) if _max_raw and str(_max_raw).isdigit() else None if ssid in result: # Merge: append band if not already present if band and band not in ssid_bands[ssid]: ssid_bands[ssid].append(band) # Keep alphabetical order so 2.4 GHz comes before 5 GHz ssid_bands[ssid].sort() result[ssid]["band"] = " + ".join(ssid_bands[ssid]) result[ssid]["bands_list"] = list(ssid_bands[ssid]) # If one radio is enabled, the SSID counts as enabled if not disabled: result[ssid]["enabled"] = True # Keep vlan_id if not yet set if result[ssid].get("vlan_id") is None and vlan_id is not None: result[ssid]["vlan_id"] = vlan_id # FT: if any radio has ieee80211r enabled, mark the SSID as FT-enabled if ft_enabled: result[ssid]["ieee80211r"] = True result[ssid]["mobility_domain"] = ft_mobility_domain result[ssid]["ft_over_ds"] = ft_over_ds # Client isolation: if any iface has it, mark True if client_isolation: result[ssid]["client_isolation"] = True # Max clients: keep first non-None value if max_clients is not None and result[ssid].get("max_clients") is None: result[ssid]["max_clients"] = max_clients else: ssid_bands[ssid] = [band] if band else [] result[ssid] = { "enabled": not disabled, "radio": radio, "band": band, "bands_list": list(ssid_bands[ssid]), "bssid": "", "encryption": encryption, "encryption_uci": enc_raw, "hidden": hidden, "client_isolation": client_isolation, "max_clients": max_clients, "clients": 0, "vlan_id": vlan_id, "ieee80211r": ft_enabled, "mobility_domain": ft_mobility_domain, "ft_over_ds": ft_over_ds, } return result def get_wireless_clients(self) -> List[Dict]: """Return currently associated wireless clients from all AP interfaces. Uses ``iw dev`` to discover AP-mode interfaces and then ``iw dev station dump`` to collect per-client statistics. """ from napalm_device_types.models import WirelessClientDict # Step 1: discover interfaces and their SSIDs / radio mappings iw_out = self._send_command("iw dev 2>/dev/null || true") iface_info: Dict[str, Dict[str, str]] = {} current_phy: str = "" current_iface: str = "" for line in iw_out.splitlines(): stripped = line.strip() phy_m = re.match(r"^phy#(\d+)$", stripped) if phy_m: current_phy = f"radio{phy_m.group(1)}" current_iface = "" continue iface_m = re.match(r"^Interface\s+(\S+)$", stripped) if iface_m: current_iface = iface_m.group(1) iface_info[current_iface] = {"ssid": "", "radio": current_phy, "type": ""} continue if not current_iface: continue ssid_m = re.match(r"^ssid\s+(.+)$", stripped) if ssid_m: iface_info[current_iface]["ssid"] = ssid_m.group(1) continue type_m = re.match(r"^type\s+(\S+)$", stripped) if type_m: iface_info[current_iface]["type"] = type_m.group(1) continue # channel 6 (2437 MHz), width: 20 MHz, ... chan_m = re.match(r"^channel\s+\d+\s+\((\d+)\s+MHz\)", stripped) if chan_m: try: freq = int(chan_m.group(1)) if freq < 3000: iface_info[current_iface]["band"] = "2.4 GHz" elif freq < 6000: iface_info[current_iface]["band"] = "5 GHz" else: iface_info[current_iface]["band"] = "6 GHz" except ValueError: pass # Filter to AP-mode interfaces only ap_ifaces = { name: info for name, info in iface_info.items() if info.get("type", "").upper() in ("AP", "AP/VLAN") } if not ap_ifaces: return [] # Step 2: fetch station dumps for all AP interfaces in one SSH call dump_cmd = " ; ".join( f"echo '=== {name} ===' && iw dev {name} station dump 2>/dev/null || true" for name in ap_ifaces ) station_out = self._send_command(dump_cmd) # Step 3: parse station dump output results: List[Dict] = [] active_iface: str = "" current_station: Optional[Dict] = None def _flush() -> None: if current_station and current_station.get("mac"): info = ap_ifaces.get(active_iface, {}) results.append(WirelessClientDict( mac=current_station["mac"], ssid=info.get("ssid", ""), radio=info.get("band") or info.get("radio", ""), signal=current_station.get("signal", 0), noise=0, tx_rate=current_station.get("tx_rate", 0.0), rx_rate=current_station.get("rx_rate", 0.0), uptime=current_station.get("uptime", 0), )) for line in station_out.splitlines(): stripped = line.strip() # Section header injected above: === wlan0 === hdr_m = re.match(r"^=== (\S+) ===$", stripped) if hdr_m: _flush() active_iface = hdr_m.group(1) current_station = None continue # Station aa:bb:cc:dd:ee:ff (on wlan0) sta_m = re.match(r"^Station\s+([\da-fA-F:]{17})\s+\(", stripped) if sta_m: _flush() current_station = {"mac": sta_m.group(1)} continue if current_station is None: continue # signal: -65 dBm (may be "signal: -65 [-65] dBm") sig_m = re.match(r"^signal:\s+([-\d]+)", stripped) if sig_m: try: current_station["signal"] = int(sig_m.group(1)) except ValueError: pass continue # tx bitrate: 54.0 MBit/s tx_m = re.match(r"^tx bitrate:\s+([\d.]+)", stripped) if tx_m: try: current_station["tx_rate"] = float(tx_m.group(1)) except ValueError: pass continue # rx bitrate: 72.2 MBit/s rx_m = re.match(r"^rx bitrate:\s+([\d.]+)", stripped) if rx_m: try: current_station["rx_rate"] = float(rx_m.group(1)) except ValueError: pass continue # connected time: 3600 seconds uptime_m = re.match(r"^connected time:\s+(\d+)", stripped) if uptime_m: try: current_station["uptime"] = int(uptime_m.group(1)) except ValueError: pass _flush() return results def get_radio_status(self) -> Dict[str, Dict]: """Return radio status from UCI and iwinfo. Combines ``uci show wireless`` for static config with ``iwinfo`` output for runtime channel/frequency and tx-power data. Returns a dict keyed by radio name (e.g. ``"radio0"``) with: * enabled (bool) * band (str) — ``"2.4GHz"``, ``"5GHz"``, ``"6GHz"`` * channel (int) — 0 means auto * channel_width (int) — channel bandwidth in MHz (0 if unknown) * tx_power (int) — TX power in dBm (0 if unknown) * frequency (float) — centre frequency in MHz (0 if unknown) * htmode (str) — e.g. ``"HT20"``, ``"VHT80"``, ``"HE80"`` * country (str) — regulatory country code, e.g. ``"DE"`` """ from napalm_device_types.models import RadioStatusDict uci_out = self._send_command("uci show wireless") radios: Dict[str, Dict[str, str]] = {} for line in uci_out.splitlines(): # wifi-device section: wireless.radio0.band='2g' m = re.match(r"wireless\.(radio\d+)\.(\w+)='([^']*)'", line.strip()) if m: radio, key, value = m.group(1), m.group(2), m.group(3) radios.setdefault(radio, {})[key] = value result: Dict[str, Dict] = {} for radio, cfg in sorted(radios.items()): band_raw = cfg.get("band", cfg.get("hwmode", "")) # Normalise band: '2g'/'11g' → '2.4GHz', '5g'/'11a' → '5GHz', '6g' → '6GHz' if band_raw in ("2g", "11g", "b", "g", "bg", "bgn"): band = "2.4GHz" elif band_raw in ("5g", "11a", "a", "ac", "ax5"): band = "5GHz" elif band_raw in ("6g", "ax6"): band = "6GHz" else: band = band_raw or "unknown" try: channel = int(cfg.get("channel", 0)) except (ValueError, TypeError): channel = 0 # 'auto' try: tx_power = int(cfg.get("txpower", 0)) except (ValueError, TypeError): tx_power = 0 disabled = cfg.get("disabled", "0") == "1" htmode = cfg.get("htmode", "") country = cfg.get("country", "") # Derive channel_width from htmode string (e.g. VHT80 → 80 MHz) _HTMODE_WIDTH = { "HT20": 20, "HT40": 40, "VHT20": 20, "VHT40": 40, "VHT80": 80, "VHT80+80": 80, "VHT160": 160, "HE20": 20, "HE40": 40, "HE80": 80, "HE160": 160, "EHT20": 20, "EHT40": 40, "EHT80": 80, "EHT160": 160, "EHT320": 320, } channel_width = _HTMODE_WIDTH.get(htmode.upper(), 0) result[radio] = { **RadioStatusDict( enabled=not disabled, band=band, channel=channel, channel_width=channel_width, tx_power=tx_power, frequency=0.0, # enriched below via iwinfo ), "htmode": htmode, "country": country, } # Enrich with iwinfo runtime data (channel, frequency, tx_power, channel_width) # iwinfo groups output per interface; we need to map interface → radio. # "phy0-ap0 ESSID: "MyNet"" → radio0 # " Tx-Power: 23 dBm" # " Channel: 44 (5.220 GHz), Width: 80 MHz" try: iwinfo_out = self._send_command("iwinfo 2>/dev/null || true") except Exception: iwinfo_out = "" current_radio: Optional[str] = None for line in iwinfo_out.splitlines(): # Interface header line: "phy0-ap0 ESSID: ..." iface_m = re.match(r"^(\S+)\s+ESSID:", line) if iface_m: iface_name = iface_m.group(1) phy_m = re.match(r"^phy(\d+)", iface_name) if phy_m: current_radio = f"radio{phy_m.group(1)}" else: current_radio = None continue if current_radio is None or current_radio not in result: continue # Channel and frequency: "Channel: 44 (5.220 GHz), Width: 80 MHz" ch_m = re.search(r"Channel:\s+(\d+)\s+\(([\d.]+)\s+GHz\)", line) if ch_m: result[current_radio]["channel"] = int(ch_m.group(1)) result[current_radio]["frequency"] = float(ch_m.group(2)) * 1000 # Width (MHz): "Width: 80 MHz" or ", Width: 80 MHz" width_m = re.search(r"Width:\s+(\d+)\s+MHz", line) if width_m: result[current_radio]["channel_width"] = int(width_m.group(1)) # Tx-Power: "Tx-Power: 23 dBm" pwr_m = re.search(r"Tx-Power:\s+(\d+)\s+dBm", line) if pwr_m: result[current_radio]["tx_power"] = int(pwr_m.group(1)) return result def get_system_config(self) -> Dict: """Return system-level configuration from UCI. Reads ``uci show system`` and ``uci show dropbear`` to collect: * hostname (str) * timezone (str) — POSIX TZ string, e.g. ``"CET-1CEST,M3.5.0,M10.5.0/3"`` * zonename (str) — human-readable name, e.g. ``"Europe/Berlin"`` * ntp_servers (list[str]) * dropbear_port (int) — SSH port * dropbear_password_auth (bool) — whether password login is allowed * dropbear_root_password_auth (bool) """ sys_out = self._send_command("uci show system 2>/dev/null || true") db_out = self._send_command("uci show dropbear 2>/dev/null || true") sys_cfg: Dict[str, str] = {} for line in sys_out.splitlines(): m = re.match(r"system\.@system\[0\]\.(\w+)='([^']*)'", line.strip()) if m: sys_cfg[m.group(1)] = m.group(2) # NTP server list: all servers on a single line, space-separated quoted values # e.g. system.ntp.server='0.openwrt.pool.ntp.org' '1.openwrt.pool.ntp.org' ... ntp_servers: List[str] = [] for line in sys_out.splitlines(): if re.match(r"system\.ntp\.server=", line.strip()): ntp_servers = re.findall(r"'([^']+)'", line) break # Dropbear settings db_cfg: Dict[str, str] = {} for line in db_out.splitlines(): # May be @dropbear[0] or named section m = re.match(r"dropbear\.[@\w]+\.(\w+)='([^']*)'", line.strip()) if m: db_cfg.setdefault(m.group(1), m.group(2)) try: ssh_port = int(db_cfg.get("Port", "22")) except (ValueError, TypeError): ssh_port = 22 def _bool_uci(val: str, default: bool = True) -> bool: return val.lower() not in ("0", "off", "false", "no") if val else default return { "hostname": sys_cfg.get("hostname", ""), "timezone": sys_cfg.get("timezone", ""), "zonename": sys_cfg.get("zonename", ""), "ntp_servers": ntp_servers, "dropbear_port": ssh_port, "dropbear_password_auth": _bool_uci(db_cfg.get("PasswordAuth", "on")), "dropbear_root_password_auth": _bool_uci(db_cfg.get("RootPasswordAuth", "on")), } # ------------------------------------------------------------------ # CLI pass-through # ------------------------------------------------------------------ def cli( self, commands: List[str], encoding: str = "text" ) -> Dict[str, Union[str, Dict]]: """Execute a list of shell commands and return their output. Each command is run via SSH. The key in the returned dictionary is the command string; the value is the raw text output. Example:: device.cli(["uname -a", "cat /etc/openwrt_release"]) """ result: Dict[str, Union[str, Dict]] = {} for cmd in commands: result[cmd] = self._send_command(cmd) return result # ------------------------------------------------------------------ # Package management (opkg ≤ OpenWrt 23 / apk ≥ OpenWrt 24) # ------------------------------------------------------------------ def get_packages(self) -> List[Dict]: """Return installed packages from the device's package manager. Automatically detects whether to use ``apk`` (OpenWrt 24+, Alpine APK) or ``opkg`` (older OpenWrt releases). Returns one entry per installed package. """ pm = self._send_command("command -v apk 2>/dev/null || echo __no_apk__").strip() if "__no_apk__" not in pm and pm: return self._get_packages_apk() return self._get_packages_opkg() def _get_packages_opkg(self) -> List[Dict]: """Parse ``opkg status`` (dpkg-style stanzas).""" out = self._send_command("opkg status") packages: List[Dict] = [] stanza: Dict[str, str] = {} for raw in out.splitlines(): line = raw.rstrip() if line == "": if stanza.get("Package"): packages.append(self._opkg_stanza_to_dict(stanza)) stanza = {} elif line[:1] in (" ", "\t"): # Continuation of previous field (e.g. multi-line Description) last_key = list(stanza)[-1] if stanza else None if last_key: stanza[last_key] += " " + line.strip() elif ":" in line: key, _, val = line.partition(":") stanza[key.strip()] = val.strip() if stanza.get("Package"): packages.append(self._opkg_stanza_to_dict(stanza)) return sorted(packages, key=lambda p: p["name"].lower()) @staticmethod def _opkg_stanza_to_dict(stanza: Dict[str, str]) -> Dict: status = stanza.get("Status", "") try: size = int(stanza.get("Installed-Size", 0) or 0) except ValueError: size = 0 return { "name": stanza["Package"], "version": stanza.get("Version", ""), "installed": "installed" in status.lower(), "description": stanza.get("Description", ""), "size": size, "source": stanza.get("Section", ""), } def _get_packages_apk(self) -> List[Dict]: """Parse ``apk list --installed`` output. Line format:: busybox-1.37.0-r0 x86_64 {busybox} (GPL-2.0-only) [installed] kmod-nft-bridge-6.6.75-r0 mips_24kc {kmod-nft-bridge} (GPL-2.0-only) [installed] """ out = self._send_command("apk list --installed 2>/dev/null") packages: List[Dict] = [] for line in out.splitlines(): line = line.strip() if not line or "[installed]" not in line: continue # Split name from version: version always starts with a digit after '-' m = re.match(r"^(.*?)-(\d\S*)\s+\S+\s+\{(\S+)\}", line) if m: name, version, origin = m.group(1), m.group(2), m.group(3) else: # Minimal fallback: first token only token = line.split()[0] vm = re.search(r"-(\d\S*)$", token) name = token[: vm.start()] if vm else token version = vm.group(1) if vm else "" origin = "" packages.append({ "name": name, "version": version, "installed": True, "description": "", "size": 0, "source": origin, }) return sorted(packages, key=lambda p: p["name"].lower()) def _pm_type(self) -> str: """Return ``'apk'`` if device has apk (OpenWrt 24+), otherwise ``'opkg'``.""" out = self._send_command("command -v apk 2>/dev/null || echo __no_apk__").strip() return "apk" if ("__no_apk__" not in out and out) else "opkg" def search_packages(self, query: str) -> List[Dict]: """Search available packages matching *query* (name or description). Runs ``opkg update`` / ``apk update`` first to ensure the package index is populated (OpenWrt stores it in RAM and loses it on reboot). """ import shlex safe_q = shlex.quote(query) if self._pm_type() == "apk": # Refresh index (no-ops if already current, safe to run every time) self._send_command("apk update 2>/dev/null || true") out = self._send_command(f"apk search {safe_q} 2>/dev/null") installed = {p["name"] for p in self._get_packages_apk()} packages: List[Dict] = [] for line in out.splitlines(): line = line.strip() if not line: continue m = re.match(r"^(.*?)-(\d\S*)(?:\s+(.*))?$", line) if m: name, version, description = m.group(1), m.group(2), (m.group(3) or "") else: name, version, description = line, "", "" packages.append({ "name": name, "version": version, "installed": name in installed, "description": description, "size": 0, "source": "", }) else: # opkg lists live in /var/opkg-lists/ (RAM) — cleared on reboot self._send_command("opkg update 2>/dev/null || true") out = self._send_command(f"opkg list 2>/dev/null | grep -i {safe_q}") installed = {p["name"] for p in self._get_packages_opkg()} packages = [] for line in out.splitlines(): line = line.strip() if not line: continue parts = line.split(" - ", 2) name = parts[0].strip() version = parts[1].strip() if len(parts) > 1 else "" description = parts[2].strip() if len(parts) > 2 else "" packages.append({ "name": name, "version": version, "installed": name in installed, "description": description, "size": 0, "source": "", }) return packages @staticmethod def _clean_pkg_output(raw: str) -> str: """Strip ANSI/VT100 escape sequences and progress-bar lines.""" # Strip CSI sequences (\x1b[...X), OSC, charset designations, and # 2-byte DEC private sequences like ESC 7 (cursor save) / ESC 8 (restore) cleaned = re.sub( r'\x1b(?:\[[0-9;?]*[a-zA-Z]|\][^\x07]*\x07|[()][0-9A-Za-z]|[\x30-\x7e])', '', raw, ) # After stripping cursor-save/restore sequences, apk progress updates # end up concatenated on a single line. Strip those inline patterns. cleaned = re.sub(r'\s*\d{1,3}%\s*#*', ' ', cleaned) lines = [] for segment in cleaned.split('\n'): # \r overwrites the line; keep only the portion after the last \r part = segment.split('\r')[-1].strip() if not part: continue # Drop pure progress-bar lines (only #, spaces, digits, %) if re.match(r'^[#\s\d%]*$', part): continue lines.append(part) return '\n'.join(lines) def install_package(self, name: str) -> Dict: """Install a package by name. Returns ``{"success": bool, "output": str}``.""" import shlex safe_name = shlex.quote(name) if self._pm_type() == "apk": raw = self._send_command(f"apk add {safe_name} 2>&1") else: raw = self._send_command(f"opkg install {safe_name} 2>&1") out = self._clean_pkg_output(raw) low = out.lower() success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package")) return {"success": success, "output": out} def uninstall_package(self, name: str) -> Dict: """Remove a package by name. Returns ``{"success": bool, "output": str}``.""" import shlex safe_name = shlex.quote(name) if self._pm_type() == "apk": raw = self._send_command(f"apk del {safe_name} 2>&1") else: raw = self._send_command(f"opkg remove {safe_name} 2>&1") out = self._clean_pkg_output(raw) low = out.lower() success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package")) return {"success": success, "output": out} # ------------------------------------------------------------------ # Device warnings & generic actions # ------------------------------------------------------------------ def get_device_warnings(self) -> List[Dict]: """Return a list of warning dicts for issues detected on this device. Currently detects: - lldpd not installed (LLDP neighbor discovery unavailable) - package updates available (uses local package cache, no network call) - update notifications not configured (auc not installed, opkg only) """ warnings: List[Dict] = [] # 1. LLDP daemon lldpd_path = self._send_command("which lldpd 2>/dev/null").strip() if not lldpd_path: warnings.append({ "code": "lldpd_not_installed", "severity": "warning", "action": "install_lldpd", }) pm = self._pm_type() # 2. Package updates available (local cache only – no opkg update) try: if pm == "apk": raw_upg = self._send_command("apk version 2>/dev/null | grep '<'") else: raw_upg = self._send_command("opkg list-upgradable 2>/dev/null") upgradable = [ln.strip() for ln in raw_upg.splitlines() if ln.strip()] except Exception: upgradable = [] if upgradable: warnings.append({ "code": "updates_available", "severity": "info", "action": None, "meta": { "count": len(upgradable), "packages": upgradable[:10], }, }) # 3. Attended sysupgrade client not installed (opkg systems only) if pm == "opkg": auc_path = self._send_command("which auc 2>/dev/null").strip() if not auc_path: warnings.append({ "code": "update_notifications_disabled", "severity": "warning", "action": "install_auc", }) # 4. base64 not available — needed for efficient config apply b64_path = self._send_command("command -v base64 2>/dev/null").strip() if not b64_path: warnings.append({ "code": "no_base64", "severity": "warning", "action": "install_coreutils_base64", }) return warnings def get_services(self) -> List[Dict]: """Return all system services with their running and enabled state. Uses ``ubus call service list`` for running/PID info and ``/etc/rc.d/S*`` symlinks for enabled-at-boot state. """ import json as _json # -- enabled set: names from /etc/rc.d/S symlinks ---- rc_out = self._send_command( "ls /etc/rc.d/ 2>/dev/null | grep '^S' | sed 's/^S[0-9]*//'" ) enabled: set[str] = {s.strip() for s in rc_out.splitlines() if s.strip()} # -- running info from procd via ubus --------------------------------- ubus_raw = self._send_command("ubus call service list 2>/dev/null") ubus_data: dict = {} try: ubus_data = _json.loads(ubus_raw) except (ValueError, TypeError): pass # Build index from ubus data service_map: dict[str, dict] = {} for svc_name, svc_info in ubus_data.items(): if not isinstance(svc_info, dict): continue instances = svc_info.get("instances", {}) running = any( inst.get("running", False) for inst in instances.values() if isinstance(inst, dict) ) pid = next( ( inst.get("pid", 0) for inst in instances.values() if isinstance(inst, dict) and inst.get("running") ), 0, ) service_map[svc_name] = {"running": running, "pid": pid} # -- all init scripts ------------------------------------------------- init_raw = self._send_command("ls -1 /etc/init.d/ 2>/dev/null") init_scripts: set[str] = {s.strip() for s in init_raw.splitlines() if s.strip()} # Merge: all known services (from init.d + ubus) all_names = init_scripts | set(service_map.keys()) # Exclude procd internal pseudo-service all_names.discard("") result: List[Dict] = [] for name in sorted(all_names): info = service_map.get(name, {}) result.append({ "name": name, "running": info.get("running", False), "enabled": name in enabled, "pid": info.get("pid", 0), }) return result def get_available_updates(self) -> List[Dict]: """Return list of upgradable packages from the local package manager cache.""" import re as _re pm = self._pm_type() updates: list[dict] = [] if pm == "apk": # Output format: "pkgname-current_ver < new_ver" raw = self._send_command("apk version 2>/dev/null | grep '<'") for line in raw.splitlines(): line = line.strip() m = _re.match(r'^(.+)-(\d\S*)\s+<\s+(\S+)', line) if m: updates.append({ "name": m.group(1), "current_version": m.group(2), "new_version": m.group(3), }) else: # opkg output: "pkgname - current_ver - new_ver" raw = self._send_command("opkg list-upgradable 2>/dev/null") for line in raw.splitlines(): parts = [p.strip() for p in line.split(" - ")] if len(parts) == 3: updates.append({ "name": parts[0], "current_version": parts[1], "new_version": parts[2], }) return sorted(updates, key=lambda u: u["name"]) def apply_updates(self, packages: List[str]) -> Dict: """Upgrade the given packages using the device's package manager.""" import re as _re for pkg in packages: if not _re.match(r'^[a-zA-Z0-9_\-\+\.]+$', pkg): raise ValueError(f"Invalid package name: {pkg!r}") pm = self._pm_type() pkg_args = " ".join(packages) if pm == "apk": cmd = f"apk upgrade {pkg_args} 2>&1" else: cmd = f"opkg upgrade {pkg_args} 2>&1" output = self._send_command(cmd) return {"success": True, "output": output} def manage_service(self, name: str, action: str) -> Dict: """Execute a lifecycle action (start/stop/restart/enable/disable) on a service.""" import re as _re if not _re.match(r'^[a-zA-Z0-9_\-]+$', name): raise ValueError(f"Invalid service name: {name!r}") if action not in ('start', 'stop', 'restart', 'enable', 'disable'): raise ValueError(f"Invalid action: {action!r}") output = self._send_command(f"/etc/init.d/{name} {action} 2>&1") return {"success": True, "output": output} def run_device_action(self, action: str) -> Dict: """Execute a named action on the device.""" if action == "install_lldpd": return self._action_install_lldpd() if action == "install_auc": return self._action_install_auc() if action == "install_coreutils_base64": return self._action_install_coreutils_base64() if action == "fix_snmp": return self._action_fix_snmp() raise NotImplementedError(f"Unknown action: {action!r}") def _action_install_coreutils_base64(self) -> Dict: """Install coreutils-base64 via the device package manager.""" pm = self._pm_type() if pm == "apk": raw = self._send_command("apk add coreutils-base64 2>&1") else: self._send_command("opkg update 2>&1") raw = self._send_command("opkg install coreutils-base64 2>&1") out = self._clean_pkg_output(raw) low = out.lower() success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package")) return {"success": success, "output": out} def _action_install_auc(self) -> Dict: """Install the attended sysupgrade client (auc) via opkg.""" self._send_command("opkg update 2>&1") raw = self._send_command("opkg install auc 2>&1") out = self._clean_pkg_output(raw) low = out.lower() success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package")) return {"success": success, "output": out} def _action_install_lldpd(self) -> Dict: """Install lldpd, add eth0 to its interface list and start the service.""" pm = self._pm_type() if pm == "apk": raw = self._send_command("apk add lldpd 2>&1") else: self._send_command("opkg update 2>&1") raw = self._send_command("opkg install lldpd 2>&1") out = self._clean_pkg_output(raw) # Add eth0 to lldpd UCI interface list (idempotent) current_ifaces = self._send_command("uci get lldpd.config.interface 2>/dev/null").strip() if "eth0" not in current_ifaces: self._send_command( "uci add_list lldpd.config.interface='eth0' 2>/dev/null; " "uci commit lldpd 2>/dev/null" ) # Enable and start the service self._send_command( "/etc/init.d/lldpd enable 2>/dev/null; " "/etc/init.d/lldpd start 2>/dev/null" ) low = out.lower() success = not any(kw in low for kw in ("error:", "failed", "not found", "unknown package")) return {"success": success, "output": out} # ------------------------------------------------------------------ # Hostname configuration # ------------------------------------------------------------------ def set_hostname(self, new_hostname: str) -> None: """Set the system hostname via UCI and reload the system service.""" self._send_command( f"uci set system.@system[0].hostname='{new_hostname}' && " f"uci commit system && " f"/etc/init.d/system reload" ) # ------------------------------------------------------------------ # Users # ------------------------------------------------------------------ def get_users(self) -> Dict[str, Dict]: """Return users configured on the device. Parses ``/etc/passwd`` for accounts with a valid login shell. SSH public keys are read from ``~/.ssh/authorized_keys`` (Dropbear also stores root keys at ``/etc/dropbear/authorized_keys``). Level mapping: - UID 0 (root) → 15 (full access) - all other users → 1 """ passwd_out = self._send_command("cat /etc/passwd") # Root authorized_keys locations on OpenWrt root_keys_out = self._send_command( ["cat /root/.ssh/authorized_keys", "cat /etc/dropbear/authorized_keys"] ) users: Dict[str, Dict] = {} valid_shells = {"/bin/sh", "/bin/ash", "/bin/bash", "/usr/bin/fish"} for line in passwd_out.splitlines(): parts = line.strip().split(":") if len(parts) < 7: continue username, password_hash, uid_str, _, _, home, shell = ( parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6], ) if shell not in valid_shells: continue try: uid = int(uid_str) except ValueError: continue level = 15 if uid == 0 else 1 # Collect SSH keys for this user sshkeys: List[str] = [] if uid == 0: for line_k in root_keys_out.splitlines(): line_k = line_k.strip() if line_k and not line_k.startswith("#"): sshkeys.append(line_k) else: # Try reading per-user authorized_keys keys_out = self._send_command(f"cat {home}/.ssh/authorized_keys 2>/dev/null") for line_k in keys_out.splitlines(): line_k = line_k.strip() if line_k and not line_k.startswith("#"): sshkeys.append(line_k) users[username] = { "level": level, "password": password_hash, "sshkeys": sshkeys, } return users # ------------------------------------------------------------------ # NTP # ------------------------------------------------------------------ def get_ntp_servers(self) -> Dict[str, Dict]: """Return configured NTP servers from ``uci show system``. UCI example:: system.ntp.server='0.openwrt.pool.ntp.org 1.openwrt.pool.ntp.org' """ uci_out = self._send_command("uci show system") servers: Dict[str, Dict] = {} for line in uci_out.splitlines(): # Handles both list and single-value UCI representations m = re.match(r"system\.ntp\.server(?:\[\d+\])?='([^']*)'", line.strip()) if m: for srv in m.group(1).split(): srv = srv.strip() if srv: servers[srv] = {} return servers def get_ntp_peers(self) -> Dict[str, Dict]: """Return NTP peers from ``uci show system``. OpenWrt's busybox ntpd does not differentiate peers from servers; the same UCI ``ntp.server`` list is returned. """ return self.get_ntp_servers() def get_ntp_stats(self) -> List[Dict]: """Return NTP synchronisation statistics. Tries ``ntpq -pn`` first (ntpd), then ``chronyc sources -v`` (chrony). Returns an empty list when neither tool is available. ``ntpq -pn`` example line:: *188.114.101.4 188.114.100.1 4 u 107 256 377 164.228 -13.866 2.695 ``chronyc sources -v`` example line:: ^* 192.168.1.1 2 6 17 8 +2345us[ 0ns] +/- 15ms """ ntpq_out = self._send_command("ntpq -pn") if ntpq_out and not ntpq_out.startswith(("ntpq: ", "sh: ", "ash: ", "command not found")): return self._parse_ntpq(ntpq_out) chrony_out = self._send_command("chronyc sources -v") if chrony_out and not chrony_out.startswith(("sh: ", "ash: ", "command not found")): return self._parse_chronyc(chrony_out) return [] @staticmethod def _parse_ntpq(output: str) -> List[Dict]: """Parse ``ntpq -pn`` tabular output.""" stats = [] for line in output.splitlines(): line_s = line.strip() if not line_s or line_s.startswith(("remote", "=")): continue # First char is the tally code (* = synchronized, + = candidate, etc.) tally = line_s[0] if line_s[0] in "* +-x.o#" else " " parts = line_s[1:].split() if len(parts) < 10: continue try: stats.append({ "remote": parts[0], "referenceid": parts[1], "synchronized": tally == "*", "stratum": int(parts[2]), "type": parts[3], "when": parts[4], "hostpoll": int(parts[5]), "reachability": int(parts[6], 8), # octal "delay": float(parts[7]), "offset": float(parts[8]), "jitter": float(parts[9]), }) except (ValueError, IndexError): continue return stats @staticmethod def _parse_chronyc(output: str) -> List[Dict]: """Parse ``chronyc sources -v`` tabular output.""" stats = [] for line in output.splitlines(): line_s = line.strip() # Data lines start with ^* ^+ ^- ^? m = re.match(r"^(\^[*+\-?])\s+(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)", line_s) if not m: continue tally = m.group(1) try: stats.append({ "remote": m.group(2), "referenceid": "", "synchronized": tally == "^*", "stratum": int(m.group(3)), "type": "u", "when": m.group(6), "hostpoll": int(m.group(4)), "reachability": int(m.group(5), 8) if re.match(r"^[0-7]+$", m.group(5)) else 0, "delay": 0.0, "offset": 0.0, "jitter": 0.0, }) except (ValueError, IndexError): continue return stats # ------------------------------------------------------------------ # SNMP # ------------------------------------------------------------------ def get_snmp_information(self) -> Dict: """Return SNMP configuration from ``uci show snmpd``. UCI example:: snmpd.@com2sec[0].community='public' snmpd.@com2sec[0].secname='public' snmpd.@system[0].sysContact='root@localhost' snmpd.@system[0].sysLocation='Unknown' """ uci_out = self._send_command("uci show snmpd") contact = "" location = "" chassis_id = "" community: Dict[str, Dict] = {} # Track com2sec entries by index com2sec: Dict[str, Dict[str, str]] = {} for line in uci_out.splitlines(): line_s = line.strip() m = re.match(r"snmpd\.@com2sec\[(\d+)\]\.(\w+)='([^']*)'", line_s) if m: idx, key, val = m.group(1), m.group(2), m.group(3) com2sec.setdefault(idx, {})[key] = val continue m = re.match(r"snmpd\.@system\[0\]\.sys(\w+)='([^']*)'", line_s) if m: key, val = m.group(1).lower(), m.group(2) if key == "contact": contact = val elif key == "location": location = val elif key == "name": chassis_id = val for entry in com2sec.values(): name = entry.get("community", entry.get("secname", "")) if not name: continue # OpenWrt snmpd doesn't distinguish rw/ro per community via UCI by default mode = "ro" if entry.get("secname", "").lower() in ("private", "readwrite", "rw"): mode = "rw" community[name] = { "acl": entry.get("source", "N/A"), "mode": mode, } return { "chassis_id": chassis_id, "community": community, "contact": contact, "location": location, } # ------------------------------------------------------------------ # Ping # ------------------------------------------------------------------ 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: """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: """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, } } # ------------------------------------------------------------------ # IPv6 neighbours # ------------------------------------------------------------------ def get_ipv6_neighbors_table(self) -> List[Dict]: """Return the IPv6 neighbour table from ``ip -6 neigh show``. Example output:: 2001:db8::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE fe80::1 dev br-lan lladdr 11:22:33:44:55:66 STALE """ output = self._send_command("ip -6 neigh show") table = [] for line in output.splitlines(): line_s = line.strip() if not line_s or "FAILED" in line_s or "INCOMPLETE" in line_s: continue m = re.match( r"^(\S+)\s+dev\s+(\S+)\s+lladdr\s+(\S+)\s+(\S+)", line_s, re.I, ) if not m: continue ip_addr = m.group(1) interface = m.group(2) mac_raw = m.group(3) state = m.group(4) try: netaddr.IPAddress(ip_addr, version=6) except (netaddr.AddrFormatError, ValueError): continue try: mac_addr = napalm_helpers.mac(mac_raw) except Exception: mac_addr = mac_raw table.append({ "interface": interface, "mac": mac_addr, "ip": ip_addr, "age": -1.0, "state": state, }) return table # ------------------------------------------------------------------ # Routing # ------------------------------------------------------------------ def get_route_to( self, destination: str = "", protocol: str = "", longer: bool = False ) -> Dict[str, List[Dict]]: """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]] = {} 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 # ------------------------------------------------------------------ # Traceroute # ------------------------------------------------------------------ def traceroute( self, destination: str, source: str = "", ttl: int = 30, timeout: int = 3, vrf: str = "", ) -> Dict: """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: """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] = {} 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] = {} 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} # ------------------------------------------------------------------ # Network instances (namespaces / default VRF) # ------------------------------------------------------------------ def get_network_instances(self, name: str = "") -> Dict[str, Dict]: """Return network instances (Linux network namespaces + default). The ``default`` instance contains all interfaces not assigned to a named namespace. Named namespaces are discovered via ``ip netns list``. Example:: { 'default': { 'name': 'default', 'type': 'DEFAULT_INSTANCE', 'state': {'route_distinguisher': None}, 'interfaces': {'interface': {'br-lan': {}, 'eth0': {}}} } } """ netns_out = self._send_command("ip netns list") iface_list = self._get_interface_list() instances: Dict[str, Dict] = {} # Named namespaces netns_names: List[str] = [] for line in netns_out.splitlines(): line_s = line.strip() if not line_s: continue # "myns (id: 3)" or just "myns" ns_name = line_s.split()[0] netns_names.append(ns_name) # Interfaces inside the namespace ns_ifaces_out = self._send_command(f"ip netns exec {ns_name} ip link show") ns_ifaces: Dict[str, Dict] = {} for iline in ns_ifaces_out.splitlines(): im = re.match(r"^\d+:\s+(\S+?)[@:]", iline) if im and im.group(1) != "lo": ns_ifaces[im.group(1)] = {} instances[ns_name] = { "name": ns_name, "type": "L3VRF", "state": {"route_distinguisher": None}, "interfaces": {"interface": ns_ifaces}, } # Default instance: interfaces NOT in any named namespace # (on most OpenWrt devices there are no named namespaces) default_ifaces = {iface: {} for iface in iface_list} instances["default"] = { "name": "default", "type": "DEFAULT_INSTANCE", "state": {"route_distinguisher": None}, "interfaces": {"interface": default_ifaces}, } if name: return {k: v for k, v in instances.items() if k == name} return instances # ── SNMP / Health ───────────────────────────────────────────────────────── def get_snmp_config(self): """Return SNMP agent config if snmpd is installed and running on OpenWrt.""" try: from napalm_device_types.models import SNMPConfigDict except ImportError: return None running = ( self._send_command( "/etc/init.d/snmpd running 2>/dev/null && echo active || echo inactive" ).strip() == "active" ) if not running: return None community = "public" try: # UCI config (set by luci-app-snmpd) uci_comm = self._send_command( "uci -q get snmpd.public.community 2>/dev/null || " "uci -q get snmpd.@com2sec[0].community 2>/dev/null || echo ''" ).strip() if uci_comm: community = uci_comm except Exception: pass return SNMPConfigDict(running=True, community=community, port=161, version="2c") def _action_fix_snmp(self) -> Dict: """Install and configure snmpd on OpenWrt. Installs snmpd-nossl (the daemon) and luci-app-snmpd (UCI schema + proper procd init script). Configures community 'public' via UCI. """ lines: list = [] # 1. Install packages — snmpd-nossl (daemon) + luci-app-snmpd (UCI init) pm = self._pm_type() if pm == "apk": raw = self._send_command("apk add snmpd-nossl luci-app-snmpd 2>&1") else: self._send_command("opkg update 2>/dev/null || true") raw = self._send_command("opkg install snmpd-nossl luci-app-snmpd 2>&1") out = self._clean_pkg_output(raw) low = out.lower() installed = not any(kw in low for kw in ("error:", "failed")) lines.append(f"[install] {out[-300:]}") if not installed and "already installed" not in low: return {"success": False, "output": "\n".join(lines)} # 2. Configure via UCI — modify the existing default sections only. # Do NOT create new named sections (causes duplicate directives in # the generated /var/run/snmpd.conf which crashes snmpd). # Also remove any stale named sections from previous fix attempts. # The init script reads these UCI field names to generate /var/run/snmpd.conf: # agent: agentaddress # com2sec: secname, source, community # group: group (name!), version, secname # view: viewname (not name!), type, oid # access: group, version, level, prefix, read, write, notify # Default luci-app-snmpd schema uses different field names for group/view/access, # so we patch all required fields explicitly. uci_cmds = [ # Remove any stale named sections from previous runs "uci -q delete snmpd.agent", "uci -q delete snmpd.public", # agent "uci set snmpd.@agent[0].agentaddress='161'", # com2sec "uci set snmpd.@com2sec[0].secname='ro'", "uci set snmpd.@com2sec[0].source='0.0.0.0/0'", "uci set snmpd.@com2sec[0].community='public'", # group — init script reads field 'group' (not 'name') "uci set snmpd.@group[0].group='rogroup'", "uci set snmpd.@group[0].version='v2c'", "uci set snmpd.@group[0].secname='ro'", # view — init script reads field 'viewname' (not 'name') "uci set snmpd.@view[0].viewname='all'", "uci set snmpd.@view[0].type='included'", "uci set snmpd.@view[0].oid='.1'", # access — init script needs write + notify or it returns early "uci set snmpd.@access[0].group='rogroup'", "uci set snmpd.@access[0].context='none'", "uci set snmpd.@access[0].version='v2c'", "uci set snmpd.@access[0].level='noAuthNoPriv'", "uci set snmpd.@access[0].prefix='exact'", "uci set snmpd.@access[0].read='all'", "uci set snmpd.@access[0].write='none'", "uci set snmpd.@access[0].notify='none'", "uci commit snmpd", ] for cmd in uci_cmds: self._send_command(f"{cmd} 2>/dev/null || true") lines.append("[config] Configured snmpd via UCI (all required fields set).") # 3. Firewall: allow UDP 161 from netOrk subnet try: raw_conn = self._send_command( "netstat -tn 2>/dev/null | awk '/ESTABLISHED.*:22/{print $5}' | head -1 | cut -d: -f1" ).strip() if raw_conn and raw_conn not in ("", "0.0.0.0"): subnet = raw_conn.rsplit(".", 1)[0] + ".0/24" self._send_command( f"uci -q delete firewall.snmp_netork 2>/dev/null; " f"uci set firewall.snmp_netork=rule; " f"uci set firewall.snmp_netork.name='Allow-SNMP-netOrk'; " f"uci set firewall.snmp_netork.src='*'; " f"uci set firewall.snmp_netork.dest_port='161'; " f"uci set firewall.snmp_netork.proto='udp'; " f"uci set firewall.snmp_netork.src_ip='{subnet}'; " f"uci set firewall.snmp_netork.target='ACCEPT'; " f"uci commit firewall; " f"/etc/init.d/firewall reload 2>/dev/null || true" ) lines.append(f"[firewall] Added UDP:161 allow rule for {subnet}.") except Exception as exc: lines.append(f"[firewall] skipped — {exc}") # 4. Break any crash-loop, then start cleanly import time as _time self._send_command("/etc/init.d/snmpd stop 2>/dev/null; true") _time.sleep(2) self._send_command("pkill -9 snmpd 2>/dev/null; true") # kill crash-loop zombie _time.sleep(3) self._send_command("/etc/init.d/snmpd enable 2>/dev/null; true") self._send_command("/etc/init.d/snmpd start 2>/dev/null; true") _time.sleep(4) lines.append("[service] snmpd started via procd.") # 5. Check if snmpd is now active (no local snmpget on OpenWrt by default) status = self._send_command( "/etc/init.d/snmpd running 2>/dev/null && echo active || echo inactive" ).strip() success = status == "active" if success: lines.append("[ok] snmpd is active.") else: lines.append(f"[warn] snmpd status: {status}") return {"success": success, "output": "\n".join(lines)}