# 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, HypervisorDriver, 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, HypervisorDriver, LinuxDriver): """NAPALM driver for QNAP NAS systems running QTS. Declares all three roles it fills. The role bases carry no implementations, so listing them alongside ``LinuxDriver`` costs nothing and shadows nothing; what the driver can actually do is whatever it implements below. NAS services are ``get_storage_services()``; the OS service list inherited from ``LinuxDriver`` stays ``get_services()``. Two names because they are two different things with different return shapes, not because they collided. """ # A QNAP is three things at once, and the order says which one leads: # storage first, so netOrk shows it as a NAS. Nothing has to restate that # -- device_class is read from this line. TYPE_LABEL = "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" # ── QPKG, not apt ───────────────────────────────────────────────────────── # # QTS is Linux, so LinuxDriver's package methods are in the MRO and would # run happily -- against a package manager QTS does not have. They used to # be shadowed by StorageDriver's stubs, which made a QNAP refuse them by # accident. Now that role bases implement nothing, the refusal has to be # deliberate. QPKG parsing lands with the device harvest; until then, # refusing is the only honest answer. def get_packages(self) -> Any: raise NotImplementedError( "QTS uses QPKG, not a Linux package manager; QPKG parsing is not implemented yet" ) def install_package(self, name: str, version: str = "") -> None: raise NotImplementedError("QPKG install is not implemented yet") def uninstall_package(self, name: str) -> None: raise NotImplementedError("QPKG removal is not implemented yet")