# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from __future__ import annotations import logging import re from typing import Any logger = logging.getLogger(__name__) #: Printed after a command so its exit status survives the shell session. _RC_MARKER = "__OWRT_RC=" _RC_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE) #: What apk prints when its index is not in RAM (after a reboot). _APK_INDEX_MISSING = re.compile(r"opening from cache .*No such file or directory") def _split_status(raw: str) -> tuple[str, int | None]: """``(output, exit_status)``; the status is None when the marker never arrived.""" statuses = _RC_RE.findall(raw or "") output = _RC_RE.sub("", raw or "").strip() return output, int(statuses[-1]) if statuses else None class OpenWrtSystemMixin: """Mixin providing system-level NAPALM getters. Environment, NTP, SNMP, users, services, updates and device actions. """ def get_environment(self) -> dict[str, Any]: """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_system_config(self) -> dict[str, Any]: """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) * syslog_remote (bool) — whether logs are shipped off the device * syslog_ip (str), syslog_port (int), syslog_proto (str) * luci_enabled (bool) — whether the uhttpd service serving LuCI is enabled * bridge_stp (bool | None) — STP on br-ap; None when there is no br-ap """ sys_out = self._send_command("uci show system 2>/dev/null || true") db_out = self._send_command("uci show dropbear 2>/dev/null || true") luci_out = self._send_command("/etc/init.d/uhttpd enabled 2>/dev/null && echo 1 || echo 0") net_out = self._send_command("uci show network 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 # Remote syslog. OpenWrt only ships logs when log_remote is set, so a # leftover log_ip without it means nothing is being sent. syslog_remote = _bool_uci(sys_cfg.get("log_remote", ""), default=False) try: syslog_port = int(sys_cfg.get("log_port", "514") or "514") except (ValueError, TypeError): syslog_port = 514 # STP on the AP bridge. None when there is no br-ap device section at # all — "no bridge" is a different statement from "bridge without STP". bridge_stp: bool | None = None br_section: str | None = None for line in net_out.splitlines(): m = re.match(r"network\.(\w+)\.name='br-ap'", line.strip()) if m: br_section = m.group(1) break if br_section: bridge_stp = False for line in net_out.splitlines(): m = re.match(rf"network\.{br_section}\.stp='([^']*)'", line.strip()) if m: bridge_stp = _bool_uci(m.group(1), default=False) break 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")), "syslog_remote": syslog_remote, "syslog_ip": sys_cfg.get("log_ip", "") if syslog_remote else "", "syslog_port": syslog_port, "syslog_proto": sys_cfg.get("log_proto", "udp"), "luci_enabled": luci_out.strip().endswith("1"), "bridge_stp": bridge_stp, } def get_snmp_information(self) -> dict[str, Any]: """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, Any] = {} # 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, } 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: logger.debug("snmpd community: ignored", exc_info=True) return SNMPConfigDict(running=True, community=community, port=161, version="2c") def get_users(self) -> dict[str, Any]: """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, Any] = {} 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 def get_services(self) -> list[dict[str, Any]]: """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[str, Any]] = [] 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 manage_service(self, name: str, action: str) -> dict[str, Any]: """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 get_available_updates(self) -> list[dict[str, Any]]: """Return list of upgradable packages from the local package manager cache. OpenWrt keeps its package index in RAM. Without it -- after a reboot, before ``refresh_available_updates`` -- ``apk version`` only warns and ``opkg list-upgradable`` prints nothing. Both raise here instead of reporting "nothing pending". """ if self._pm_type() == "apk": updates = self._updates_apk() else: updates = self._updates_opkg() return sorted(updates, key=lambda u: u["name"]) def _updates_apk(self) -> list[dict[str, Any]]: import re as _re output, status = _split_status(self._send_command(f"apk version 2>&1; echo {_RC_MARKER}$?")) if status != 0: raise RuntimeError(f"apk version failed (exit {status}): {output[-200:]}") if _APK_INDEX_MISSING.search(output): raise RuntimeError("The package index is not loaded (run apk update first)") updates: list[dict[str, Any]] = [] for line in output.splitlines(): # Output format: "pkgname-current_ver < new_ver" m = _re.match(r"^(.+)-(\d\S*)\s+<\s+(\S+)", line.strip()) if m: updates.append( { "name": m.group(1), "current_version": m.group(2), "new_version": m.group(3), } ) return updates def _updates_opkg(self) -> list[dict[str, Any]]: raw = self._send_command( '[ -n "$(ls /var/opkg-lists 2>/dev/null)" ] || echo __NO_INDEX__; ' "opkg list-upgradable 2>/dev/null" ) if "__NO_INDEX__" in raw: raise RuntimeError("The package index is not loaded (run opkg update first)") updates: list[dict[str, Any]] = [] for line in raw.splitlines(): # opkg output: "pkgname - current_ver - new_ver" 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 updates def refresh_available_updates(self) -> dict[str, Any]: """Load the package index into RAM (``apk update`` / ``opkg update``).""" cmd = "apk update" if self._pm_type() == "apk" else "opkg update" output, status = _split_status(self._send_command(f"{cmd} 2>&1; echo {_RC_MARKER}$?")) return {"success": status == 0, "output": output} def apply_updates(self, packages: list[str]) -> dict[str, Any]: """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} # ------------------------------------------------------------------ # NTP # ------------------------------------------------------------------ def get_ntp_servers(self) -> dict[str, Any]: """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, Any] = {} 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, Any]: """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[str, Any]]: """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[str, Any]]: """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[str, Any]]: """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