# Licensed under the Apache License, Version 2.0 """NAPALM driver for QNAP NAS systems running QTS. QTS is a Linux distribution, so this driver inherits the whole OS surface from :class:`napalm_linux.linux.LinuxDriver` — packages, services, users, processes, Docker — and adds QNAP's own storage, QPKG and virtualisation layers on top: * Physical disk inventory and SMART via ``qcli_storage`` and ``get_hd_smartinfo`` * Storage pools and volumes via ``qcli_storage`` and ``/proc/mdstat`` * SMB/NFS/AFP/FTP shares from ``/etc/config/smb.conf`` and ``/etc/exports`` * QPKG packages instead of a distribution package manager * Virtualization Station guests via ``virsh`` * Container Station via LinuxDriver's Docker support, redirected to the QPKG-local ``docker`` binary Connects via SSH. SSH has to be enabled on the NAS first (Control Panel → Telnet/SSH). Targets QTS 4.x and QTS 5.x; the parsers are written against command output captured from both (see tools/harvest.sh). """ from __future__ import annotations import re from typing import Any from napalm_device_types import FingerprintRule, PortSpec, StorageDriver from napalm_linux.linux import LinuxDriver #: QNAP Systems' IANA enterprise number. Used by discovery to recognise a NAS #: from its SNMP sysObjectID before anyone has supplied credentials. QNAP_ENTERPRISE_OID = "1.3.6.1.4.1.24681" #: Where Container Station puts the docker binary. It is not on PATH, and the #: volume name varies with which pool the app was installed on, so this is a #: glob evaluated on the device rather than a fixed path. _DOCKER_GLOBS = ( "/share/*/.qpkg/container-station/bin/docker", "/share/*/.qpkg/container-station/usr/bin/docker", ) _VERSION_RE = re.compile(r"(\d+)\.") class QnapQtsDriver(StorageDriver, LinuxDriver): """NAPALM driver for QNAP NAS systems running QTS. **Inheritance order matters.** ``StorageDriver`` precedes ``LinuxDriver`` in the MRO, so its ``NotImplementedError`` stubs shadow LinuxDriver's working implementations wherever the names collide — ``get_services``, ``get_packages`` and ``install_package``. Each collision is resolved explicitly below rather than left to the MRO; see ``TestMroForwarding``. NAS services are exposed as ``get_storage_services()``, not ``get_services()``, because netOrk's poller reads the former for the storage snapshot and the latter for the OS service list. Same convention as napalm-openmediavault. """ TYPE_LABEL = "Storage" # Declared outright: the driver inherits LinuxDriver for the OS surface, so # netOrk's issubclass chain would reach OSDriver first and classify a QNAP # as "linux", hiding its Storage tab. VMs and containers stay visible # through capability introspection, not through this key. DEVICE_CLASS = "storage" VENDOR = "QNAP" DRIVER_NAME = "qnap_qts" driver_name = "qnap_qts" NETMIKO_DEVICE_TYPE = "linux" SNMP_OBJECT_ID_PREFIX = QNAP_ENTERPRISE_OID SNMP_FINGERPRINT = [ FingerprintRule("qnap", weight=9.0), FingerprintRule("nas", weight=1.0), ] # QTS answers with a stock OpenSSH banner, so SSH alone cannot identify a # QNAP — it only confirms the transport this driver needs. SSH_FINGERPRINT = [ FingerprintRule("openssh", weight=1.0), ] # Mandatory: without it every unidentified NAS web UI would score as a QNAP. HTTP_FINGERPRINT = [ FingerprintRule("qnap", weight=9.0, mandatory=True), FingerprintRule("qts", weight=4.0), ] PORT_SPECS = [ PortSpec("https", 443), PortSpec("http", 8080), ] # ── Lifecycle ───────────────────────────────────────────────────────────── def open(self) -> None: """Connect, then resolve the facts that decide which code paths run. QTS 4 and QTS 5 differ in the output of several tools, and the QPKG apps that provide docker and virsh live on whichever storage pool they were installed on. Resolving all of that once per session keeps every getter below free of discovery round trips. """ super().open() self._qts_major = self._detect_qts_major() self._docker_path = self._discover_docker_path() self._virsh_path = self._discover_virsh_path() def _detect_qts_major(self) -> int | None: """Return the QTS major version, or None when it cannot be read. Deliberately not fatal: a NAS that answers nothing useful here is still worth polling, and the newer code path is the better default. """ try: raw = self._send("getcfg System Version").strip() except Exception: return None match = _VERSION_RE.match(raw) return int(match.group(1)) if match else None def _discover_docker_path(self) -> str: """Locate Container Station's docker binary, falling back to PATH.""" try: found = self._send(f"ls {' '.join(_DOCKER_GLOBS)} 2>/dev/null | head -1").strip() except Exception: return "docker" return found.splitlines()[0].strip() if found else "docker" def _discover_virsh_path(self) -> str | None: """Locate Virtualization Station's virsh, or None when it is not installed. None is a normal outcome — plenty of QNAP models never run VMs — and it is what makes ``get_vms`` return an empty list instead of raising. """ try: found = self._send( "ls /share/*/.qpkg/QKVM/usr/bin/virsh /share/*/.qpkg/*/bin/virsh " "2>/dev/null | head -1" ).strip() except Exception: return None return found.splitlines()[0].strip() or None if found else None def _docker_bin(self) -> str: """Override LinuxDriver's hook: docker is not on PATH under QTS.""" return getattr(self, "_docker_path", None) or "docker" # ── MRO collision resolution ────────────────────────────────────────────── # # StorageDriver comes first in the MRO and its stubs would otherwise win. def get_services(self) -> Any: """OS services, not NAS services — this is what netOrk's poller reads. Forwarded explicitly past ``StorageDriver.get_services``, which shadows it and returns a different shape (dict of NAS services vs. list of OS services). """ return LinuxDriver.get_services(self)