# -*- 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`` """ from __future__ import annotations import re import socket from typing import Any from netmiko import ConnectHandler from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException from napalm_device_types import AccessPointDriver from napalm.base.exceptions import ( ConnectionException, ConnectionClosedException, ) from napalm.base.netmiko_helpers import netmiko_args from napalm_openwrt.interfaces_mixin import OpenWrtInterfaceMixin from napalm_openwrt.wireless_mixin import OpenWrtWirelessMixin from napalm_openwrt.lldp_mixin import OpenWrtLLDPMixin from napalm_openwrt.vlan_mixin import OpenWrtVLANMixin from napalm_openwrt.config_mixin import OpenWrtConfigMixin from napalm_openwrt.system_mixin import OpenWrtSystemMixin from napalm_openwrt.packages_mixin import OpenWrtPackageMixin from napalm_openwrt.routing_mixin import OpenWrtRoutingMixin class OpenWrtDriver( AccessPointDriver, OpenWrtInterfaceMixin, OpenWrtWirelessMixin, OpenWrtLLDPMixin, OpenWrtVLANMixin, OpenWrtConfigMixin, OpenWrtSystemMixin, OpenWrtPackageMixin, OpenWrtRoutingMixin, ): """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: dict[str, Any] | None = None, ) -> None: self.hostname = hostname self.username = username self.password = password self.timeout = timeout self.device: ConnectHandler | None = 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: str | None = None self._candidate_mode: str | None = None self._backup_config: str | None = 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: 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 kept in driver # ------------------------------------------------------------------ def get_facts(self) -> dict[str, Any]: """Return a dictionary of general device facts. Retrieves data from: * ``/etc/openwrt_release`` * ``/proc/uptime`` * ``uname -a`` * ``ip link show`` (interface count) """ facts: dict[str, Any] = {} # OS version release_out = self._send_command("cat /etc/openwrt_release") release = self._parse_openwrt_release(release_out) # Hardware vendor from board_name (e.g. "sophos,ap100" → "Sophos") # Fall back to "OpenWrt" when board_name is unavailable or has no comma. board_name_raw = self._send_command("cat /tmp/sysinfo/board_name 2>/dev/null").strip() if board_name_raw and "," in board_name_raw and not board_name_raw.startswith("cat:"): facts["vendor"] = board_name_raw.split(",")[0].strip().title() else: facts["vendor"] = "OpenWrt" # Prefer the device-tree model (e.g. "Sophos AP100") over the SoC target model_raw = self._send_command("cat /tmp/sysinfo/model 2>/dev/null") if model_raw and not model_raw.startswith("cat: "): facts["model"] = model_raw.strip().replace("\x00", "") else: facts["model"] = ( release.get("DISTRIB_TARGET", "").replace("/", " ") ) facts["os_version"] = release.get( "DISTRIB_DESCRIPTION", release.get("DISTRIB_RELEASE", "unknown"), ) facts["serial_number"] = "" facts["uptime"] = self._parse_uptime_seconds( self._send_command("cat /proc/uptime") ) # hostname and FQDN uname_out = self._send_command("uname -a") uname_parts = uname_out.split() if len(uname_parts) >= 2: facts["hostname"] = uname_parts[1] facts["fqdn"] = uname_parts[1] else: facts["hostname"] = "unknown" facts["fqdn"] = "unknown" # Interface count interface_list = self._get_interface_list() facts["interface_list"] = interface_list facts["number_of_interfaces"] = len(interface_list) return facts 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 cli( self, commands: list[str], encoding: str = "text" ) -> dict[str, str]: """Execute a list of CLI commands and return their output.""" return { cmd: self._send_command(cmd) for cmd in commands } # ------------------------------------------------------------------ # Service management (forwarded to mixins because AccessPointDriver # defines NotImplementedError stubs for these) # ------------------------------------------------------------------ def get_services(self) -> list[dict[str, Any]]: return OpenWrtSystemMixin.get_services(self) def manage_service(self, name: str, action: str) -> dict[str, Any]: return OpenWrtSystemMixin.manage_service(self, name, action) def get_available_updates(self) -> list[dict[str, Any]]: return OpenWrtSystemMixin.get_available_updates(self) def apply_updates(self, packages: list[str]) -> dict[str, Any]: return OpenWrtSystemMixin.apply_updates(self, packages) def get_packages(self) -> list[dict[str, Any]]: return OpenWrtPackageMixin.get_packages(self) def install_package(self, name: str) -> dict[str, Any]: return OpenWrtPackageMixin.install_package(self, name) def remove_package(self, name: str) -> dict[str, Any]: return OpenWrtPackageMixin.uninstall_package(self, name) # ------------------------------------------------------------------ # Device actions # ------------------------------------------------------------------ def run_device_action(self, action: str) -> dict[str, Any]: """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_fix_snmp(self) -> dict[str, Any]: """Ensure snmpd is running and reachable on UDP/161. On OpenWRT the most common reason SNMP is unreachable is that the firewall management zone (typically named ``mgmt``) only allows SSH/HTTP/HTTPS/ICMP by default and default-drops everything else. snmpd runs but packets are rejected before reaching the process. This action: 1. Detects the management zone name from UCI (looks for the zone whose named rules already allow SSH — that zone handles the management interface where SNMP needs to be open too). 2. Removes any wrongly-scoped SNMP rule (one without src=). 3. Adds a named UCI rule ``allow_snmp_from_`` with ``src=`` so it ends up in the correct nftables chain. 4. Commits and reloads fw4 immediately (no reboot needed). 5. Ensures snmpd is enabled and running. """ lines: list[str] = [] # ── 1. Detect management zone name ─────────────────────────────── # Find the zone whose allow-SSH rule already exists — that is the # management zone. Falls back to "lan" if nothing more specific # is found (on vanilla APs without a dedicated mgmt zone the lan # zone has input=ACCEPT anyway). fw_raw = self._send_command("uci show firewall 2>/dev/null") mgmt_zone = "lan" # safe fallback — lan zone usually has ACCEPT for line in fw_raw.splitlines(): # Named rule pattern: firewall.allow_ssh_from_.src='' if ".src=" in line and "ssh" in line.lower(): zone_val = line.split("=", 1)[-1].strip().strip("'\"") if zone_val: mgmt_zone = zone_val break lines.append(f"[firewall] Management zone: {mgmt_zone!r}") # ── 2. Clean up any wrongly-scoped previous SNMP rule ──────────── # A rule named Allow-SNMP without src= lands in the global input # chain which is never reached for managed-zone traffic. existing_names = [ ln.split("=")[0].strip() for ln in fw_raw.splitlines() if ".name='Allow-SNMP'" in ln or ".name='allow_snmp" in ln.lower() ] for uci_key in existing_names: # Check whether this rule has the correct src src_line = next( (l for l in fw_raw.splitlines() if uci_key.replace(".name", ".src") in l), "", ) if f"='{mgmt_zone}'" not in src_line and f'="{mgmt_zone}"' not in src_line: self._send_command(f"uci delete {uci_key.replace('.name', '')} 2>/dev/null || true") lines.append(f"[firewall] Removed mis-scoped rule {uci_key}") # ── 3. Add correctly-scoped rule if not already present ────────── named_key = f"allow_snmp_from_{mgmt_zone}" if f"firewall.{named_key}" in fw_raw: lines.append(f"[firewall] Rule {named_key!r} already present — skipping add") else: rule_out = self._send_command( f"uci set firewall.{named_key}=rule" f" && uci set firewall.{named_key}.name='Allow-SNMP-from-{mgmt_zone}'" f" && uci set firewall.{named_key}.src='{mgmt_zone}'" f" && uci set firewall.{named_key}.target='ACCEPT'" f" && uci set firewall.{named_key}.proto='udp'" f" && uci set firewall.{named_key}.dest_port='161'" f" && uci commit firewall 2>&1" ) lines.append(f"[firewall] Added rule {named_key!r}: {rule_out.strip()[:80] or 'ok'}") # ── 4. Reload firewall ──────────────────────────────────────────── reload_out = self._send_command( "fw4 reload 2>&1 || /etc/init.d/firewall reload 2>&1 || true" ) lines.append(f"[firewall] Reload: {reload_out.strip()[:120] or 'ok'}") # ── 5. Ensure snmpd is enabled and running ──────────────────────── status = self._send_command("/etc/init.d/snmpd status 2>/dev/null") if "running" not in status.lower() and "active" not in status.lower(): self._send_command( "/etc/init.d/snmpd enable 2>/dev/null;" " /etc/init.d/snmpd start 2>/dev/null" ) lines.append("[snmpd] Service started and enabled") else: lines.append("[snmpd] Service already running") # ── 6. Local probe (best-effort) ────────────────────────────────── probe = self._send_command( "snmpget -v2c -cpublic -t2 -r0 -Ov 127.0.0.1 1.3.6.1.2.1.1.1.0 2>&1" " || echo 'snmp_client_not_available'" ) if "snmp_client_not_available" in probe: lines.append("[probe] No local SNMP client — cannot verify locally") success = True # firewall rule was added; remote poll will confirm else: ok_tokens = ("STRING:", "INTEGER:", "OID:", "Timeticks:", "Hex-STRING:", "IpAddress:") success = any(t in probe for t in ok_tokens) lines.append(f"[probe] {'ok' if success else 'FAILED'}: {probe.strip()[:120]}") return {"success": success, "output": "\n".join(lines)} def _action_install_coreutils_base64(self) -> dict[str, Any]: """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[str, Any]: """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[str, Any]: """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) # Configure the correct management interface and enable the service self._lldpd_fix_interface() # 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}