commit 302c5b95cd106e30328fca80e6ab0d654b05dfe8 Author: Christian Manivong Date: Sat Jun 13 03:18:28 2026 +0200 init diff --git a/napalm_openmediavault/__init__.py b/napalm_openmediavault/__init__.py new file mode 100644 index 0000000..496533b --- /dev/null +++ b/napalm_openmediavault/__init__.py @@ -0,0 +1,25 @@ +""" +napalm-openmediavault +~~~~~~~~~~~~~~~~~~~~~ + +NAPALM driver for OpenMediaVault NAS systems. + +Extends LinuxDriver with OMV-specific storage capabilities: +physical disk inventory, md RAID pools, mounted filesystems, +and SMB/NFS/FTP share enumeration via the OMV config database. + +Usage:: + + from napalm_openmediavault import OpenMediaVaultDriver + + driver = OpenMediaVaultDriver("192.168.1.10", "admin", "secret") + driver.open() + print(driver.get_facts()) + print(driver.get_disks()) + print(driver.get_shares()) + driver.close() +""" + +from napalm_openmediavault.openmediavault import OpenMediaVaultDriver + +__all__ = ["OpenMediaVaultDriver"] diff --git a/napalm_openmediavault/openmediavault.py b/napalm_openmediavault/openmediavault.py new file mode 100644 index 0000000..bf2f0cb --- /dev/null +++ b/napalm_openmediavault/openmediavault.py @@ -0,0 +1,612 @@ +# -*- coding: utf-8 -*- +# Licensed under the Apache License, Version 2.0 + +"""NAPALM driver for OpenMediaVault NAS systems. + +Extends LinuxDriver with OMV-specific storage management: + +* Physical disk inventory via lsblk and smartctl +* md RAID array status via /proc/mdstat and mdadm +* Mounted filesystem info via df +* SMB, NFS, and FTP share enumeration via omv-confdbadm + +Connects via SSH. Sudo access is required for smartctl and mdadm. +All optional_args from LinuxDriver are accepted (pkg_manager, sudo_password, …). + +Tested against OpenMediaVault 6.x and 7.x (Debian Bullseye / Bookworm base). +""" + +import json as _json +import re +from typing import Any, Dict, List + +from napalm_device_types.models import ( + DiskPoolDict, + LogicalVolumeDict, + NASShareDict, + PhysicalDiskDict, + ReplicationJobDict, + StorageQuotaDict, + StorageServiceDict, + VolumeSnapshotDict, +) +from napalm_linux.linux import LinuxDriver + + +class OpenMediaVaultDriver(LinuxDriver): + """NAPALM driver for OpenMediaVault NAS systems. + + Inherits all Linux OS functionality from LinuxDriver (packages, + services, users, processes, cron, Docker) and adds OMV-specific + storage management capabilities. + + NAS services are exposed via ``get_storage_services()`` to avoid a + naming collision with LinuxDriver's ``get_services()`` (which returns + the full systemd service list). + """ + + driver_name = "openmediavault" + storage_device = True # signals netOrk to classify this driver as "storage" + + # ── Facts override ───────────────────────────────────────────────────────── + + def get_facts(self) -> Dict[str, Any]: + facts = super().get_facts() + try: + ver = self._send( + "dpkg-query -W -f='${Version}' openmediavault 2>/dev/null" + ).strip() + if ver: + facts["os_version"] = f"OMV {ver}" + except Exception: + pass + return facts + + # ── Warnings ────────────────────────────────────────────────────────────── + + def get_device_warnings(self) -> List[Dict[str, Any]]: + """Return warnings, ignoring packages held back by apt-get upgrade. + + On OMV, kernel and OMV-specific packages are routinely held back + (they require dist-upgrade or omv-upgrade). Reporting them as + "updates available" after a successful apt-get upgrade is misleading. + We simulate the upgrade first and only warn if there are packages + that a standard apt-get upgrade would actually install. + """ + warnings: List[Dict[str, Any]] = [] + try: + sim = self._sudo( + "DEBIAN_FRONTEND=noninteractive apt-get upgrade --simulate 2>&1", + read_timeout=60, + ) + m = re.search(r'(\d+) upgraded', sim) + upgradable_count = int(m.group(1)) if m else 0 + except Exception: + upgradable_count = -1 # unknown — fall back to parent behaviour + + if upgradable_count == 0: + return [] # nothing to do with apt-get upgrade; held-back only + if upgradable_count < 0: + return super().get_device_warnings() + + updates = self.get_available_updates() + if updates: + warnings.append({ + "code": "updates_available", + "severity": "warning", + "action": None, + "meta": { + "count": upgradable_count, + "packages": [u.get("name", "") for u in updates[:10]], + }, + }) + return warnings + + # ── Physical disks ───────────────────────────────────────────────────────── + + def get_disks(self) -> List[PhysicalDiskDict]: + """Return physical disk inventory. + + Uses ``lsblk -J`` for device enumeration and ``smartctl -iA`` + (via sudo) for health, temperature, and RPM data. + + Each entry contains: + + * slot (string) - device path, e.g. ``"/dev/sda"`` + * model (string) - drive model string + * serial (string) - drive serial number + * vendor (string) - drive manufacturer + * type (string) - ``"hdd"``, ``"ssd"``, or ``"nvme"`` + * size (int) - raw capacity in bytes + * rpm (int) - rotational speed; ``0`` for SSD/NVMe + * temperature (int) - temperature in Celsius; ``-1`` if unavailable + * health (string) - ``"healthy"``, ``"failed"``, or ``"unknown"`` + * pool (string) - md array name if member; empty string otherwise + + Example:: + + [ + { + "slot": "/dev/sda", + "model": "HGST HUS726T6TALE6L4", + "serial": "K3GXXXXX", + "vendor": "HGST", + "type": "hdd", + "size": 6001175126016, + "rpm": 7200, + "temperature": 34, + "health": "healthy", + "pool": "md1", + }, + ] + """ + result: List[PhysicalDiskDict] = [] + + try: + raw = self._send( + "lsblk -J -b -o NAME,SIZE,TYPE,ROTA,VENDOR,MODEL,SERIAL,TRAN 2>/dev/null" + ) + lsblk = _json.loads(raw) + except Exception: + return result + + for dev in lsblk.get("blockdevices", []): + if dev.get("type") != "disk": + continue + name = dev.get("name", "") + if not name: + continue + + size = int(dev.get("size") or 0) + rota = str(dev.get("rota", "1")) + vendor = (dev.get("vendor") or "").strip() + model = (dev.get("model") or "").strip() + serial = (dev.get("serial") or "").strip() + tran = (dev.get("tran") or "").strip().lower() + + if tran == "nvme": + disk_type = "nvme" + elif rota == "0": + disk_type = "ssd" + else: + disk_type = "hdd" + + temperature = -1 + health = "unknown" + rpm = 0 + + try: + smart = self._sudo( + f"smartctl -iA /dev/{name} 2>/dev/null", read_timeout=15 + ) + if "PASSED" in smart: + health = "healthy" + elif "FAILED" in smart: + health = "failed" + temp_m = re.search( + r"(?:Temperature_Celsius|Current Temperature|Temperature:)" + r"[^\n]*?(\d+)\s*(?:Celsius|\(|$)", + smart, + ) + if temp_m: + temperature = int(temp_m.group(1)) + if disk_type == "hdd": + rpm_m = re.search(r"Rotation Rate:\s+(\d+)\s+rpm", smart) + if rpm_m: + rpm = int(rpm_m.group(1)) + except Exception: + pass + + result.append(PhysicalDiskDict( + slot=f"/dev/{name}", + model=model, + serial=serial, + vendor=vendor, + type=disk_type, + size=size, + rpm=rpm, + temperature=temperature, + health=health, + pool=self._disk_to_pool(name), + )) + + return result + + def _disk_to_pool(self, disk_name: str) -> str: + """Return the md array a disk belongs to, or empty string.""" + try: + mdstat = self._send("cat /proc/mdstat 2>/dev/null") + for line in mdstat.splitlines(): + if disk_name in line and re.match(r'^md\d+', line): + return line.split(":")[0].strip() + except Exception: + pass + return "" + + # ── md RAID pools ────────────────────────────────────────────────────────── + + def get_disk_pools(self) -> Dict[str, DiskPoolDict]: + """Return md RAID array status from ``/proc/mdstat`` and ``mdadm --detail``. + + Keys are array names (e.g. ``"md1"``). Each value contains: + + * name (string) - array name + * type (string) - always ``"md"`` + * level (string) - RAID level, e.g. ``"raid1"``, ``"raid5"`` + * status (string) - ``"online"``, ``"degraded"``, ``"faulted"``, or ``"offline"`` + * total (int) - usable array size in bytes (``0`` if mdadm unavailable) + * used (int) - always ``0`` (use ``get_volumes()`` for per-filesystem usage) + * available (int) - always ``0`` + * disks (list of strings) - member device names (e.g. ``["sda1", "sdb1"]``) + * auto_expand (bool) - always ``False`` (not applicable for md) + * dedup (bool) - always ``False`` + * compression (string) - always ``"off"`` + + Example:: + + { + "md1": { + "name": "md1", + "type": "md", + "level": "raid1", + "status": "online", + "total": 2000398925824, + "used": 0, + "available": 0, + "disks": ["sda1", "sdb1"], + "auto_expand": False, + "dedup": False, + "compression": "off", + }, + } + """ + result: Dict[str, DiskPoolDict] = {} + + try: + mdstat = self._send("cat /proc/mdstat 2>/dev/null") + except Exception: + return result + + for line in mdstat.splitlines(): + m = re.match(r'^(md\d+)\s*:\s+(\S+)\s+(\S+)\s+(.*)', line) + if not m: + continue + + md_name, state_word, level_raw, members_raw = m.groups() + member_disks = re.findall(r'(\w+)\[\d+\]', members_raw) + status = "online" if state_word == "active" else "offline" + total = 0 + + try: + detail = self._sudo( + f"mdadm --detail /dev/{md_name} 2>/dev/null", read_timeout=10 + ) + size_m = re.search(r"Array Size\s*:\s*(\d+)\s*\(", detail) + if size_m: + total = int(size_m.group(1)) * 1024 # kB → bytes + if "degraded" in detail.lower(): + status = "degraded" + elif re.search(r"State\s*:.*(?:faulty|failed)", detail, re.IGNORECASE): + status = "faulted" + except Exception: + pass + + result[md_name] = DiskPoolDict( + name=md_name, + type="md", + level=level_raw.lower(), + status=status, + total=total, + used=0, + available=0, + disks=member_disks, + auto_expand=False, + dedup=False, + compression="off", + ) + + return result + + # ── Logical volumes / filesystems ────────────────────────────────────────── + + def get_volumes(self) -> Dict[str, LogicalVolumeDict]: + """Return mounted filesystem info from ``df``. + + Only real block devices (``/dev/…``) are included; tmpfs, devtmpfs, + overlays, and similar pseudo-filesystems are skipped. + + Keys are derived from the mountpoint (e.g. ``"srv-dev-disk-by-label-data"`` + for ``/srv/dev/disk/by-label/data``; ``"root"`` for ``/``). + + Each value contains: + + * name (string) - key repeated for convenience + * pool (string) - device name without ``/dev/`` prefix + * type (string) - always ``"filesystem"`` + * total (int) - total size in bytes + * used (int) - used space in bytes + * available (int) - available space in bytes + * mountpoint (string) - absolute mount path + * compression (string) - always ``"off"`` + * dedup (bool) - always ``False`` + * readonly (bool) - ``True`` if mounted ``ro`` + * snapshots (int) - always ``0`` + + Example:: + + { + "srv-data": { + "name": "srv-data", + "pool": "md1", + "type": "filesystem", + "total": 2000398925824, + "used": 945000000000, + "available": 1055398925824, + "mountpoint": "/srv/data", + "compression": "off", + "dedup": False, + "readonly": False, + "snapshots": 0, + }, + } + """ + result: Dict[str, LogicalVolumeDict] = {} + + try: + df_out = self._send("df -Pk 2>/dev/null") + except Exception: + return result + + try: + mounts = self._send("cat /proc/mounts 2>/dev/null") + except Exception: + mounts = "" + + for line in df_out.splitlines()[1:]: + parts = line.split() + if len(parts) < 6: + continue + device = parts[0] + total_k, used_k, avail_k = int(parts[1]), int(parts[2]), int(parts[3]) + mountpoint = parts[5] + + if not device.startswith("/dev/"): + continue + + key = mountpoint.lstrip("/").replace("/", "-") or "root" + pool = device.removeprefix("/dev/") + + readonly = bool(re.search( + rf'^{re.escape(device)}\s+\S+\s+\S+\s+(?:[^,\s]*,)*ro(?:,|\s|$)', + mounts, re.MULTILINE, + )) + + result[key] = LogicalVolumeDict( + name=key, + pool=pool, + type="filesystem", + total=total_k * 1024, + used=used_k * 1024, + available=avail_k * 1024, + mountpoint=mountpoint, + compression="off", + dedup=False, + readonly=readonly, + snapshots=0, + ) + + return result + + # ── NAS shares ──────────────────────────────────────────────────────────── + + def get_shares(self) -> Dict[str, NASShareDict]: + """Return all configured NAS shares from the OMV config database. + + Covers SMB/CIFS, NFS, and FTP shares. Keys are prefixed with the + protocol (e.g. ``"smb:media"``, ``"nfs:backups"``, ``"ftp:upload"``). + + Each value contains: + + * name (string) - share name without protocol prefix + * protocol (string) - ``"smb"``, ``"nfs"``, or ``"ftp"`` + * path (string) - absolute filesystem path + * volume (string) - last path component (leaf directory) + * enabled (bool) - whether the share is currently exported + * readonly (bool) - whether the share is exported read-only + * description (string) - human-readable comment (SMB only) + * clients (list of strings) - allowed client addresses (NFS only) + + Example:: + + { + "smb:media": { + "name": "media", + "protocol": "smb", + "path": "/srv/dev/disk/by-label/data/media", + "volume": "media", + "enabled": True, + "readonly": False, + "description": "Media library", + "clients": [], + }, + "nfs:backups": { + "name": "backups", + "protocol": "nfs", + "path": "/srv/dev/disk/by-label/data/backups", + "volume": "backups", + "enabled": True, + "readonly": False, + "description": "", + "clients": ["192.168.1.0/24"], + }, + } + """ + shares: Dict[str, NASShareDict] = {} + shares.update(self._smb_shares()) + shares.update(self._nfs_shares()) + shares.update(self._ftp_shares()) + return shares + + # ── Storage services ────────────────────────────────────────────────────── + + def get_storage_services(self) -> Dict[str, StorageServiceDict]: + """Return status of key NAS services (SMB, NFS, FTP, SSH, rsync). + + This method is separate from ``get_services()`` (inherited from + LinuxDriver) which returns the full systemd service list. + + Keys are service names. Each value contains: + + * name (string) - service name + * enabled (bool) - configured to start on boot + * running (bool) - currently active + * port (int) - primary listening port + * version (string) - protocol version; empty if unknown + + Example:: + + { + "smb": {"name": "smb", "enabled": True, "running": True, "port": 445, "version": ""}, + "nfs": {"name": "nfs", "enabled": True, "running": True, "port": 2049, "version": ""}, + "ftp": {"name": "ftp", "enabled": False,"running": False, "port": 21, "version": ""}, + "ssh": {"name": "ssh", "enabled": True, "running": True, "port": 22, "version": ""}, + "rsync": {"name": "rsync", "enabled": False,"running": False, "port": 873, "version": ""}, + } + """ + _SVC_MAP = [ + # (key, systemd unit, default port) + ("smb", "smbd", 445), + ("nfs", "nfs-server", 2049), + ("ftp", "proftpd", 21), + ("ssh", "ssh", 22), + ("rsync", "rsync", 873), + ] + result: Dict[str, StorageServiceDict] = {} + for key, unit, port in _SVC_MAP: + try: + running = ( + self._send(f"systemctl is-active {unit} 2>/dev/null || true").strip() + == "active" + ) + enabled = ( + self._send(f"systemctl is-enabled {unit} 2>/dev/null || true").strip() + == "enabled" + ) + except Exception: + running = enabled = False + result[key] = StorageServiceDict( + name=key, enabled=enabled, running=running, port=port, version="", + ) + return result + + # ── Not implemented ──────────────────────────────────────────────────────── + + def get_volume_snapshots(self, volume: str = "") -> List[VolumeSnapshotDict]: + raise NotImplementedError( + "OpenMediaVault does not expose volume snapshots via this driver" + ) + + def get_replication_jobs(self) -> List[ReplicationJobDict]: + raise NotImplementedError + + def get_quotas(self) -> List[StorageQuotaDict]: + raise NotImplementedError + + # ── Internal helpers ─────────────────────────────────────────────────────── + + def _omv_read(self, key: str) -> list: + """Query the OMV config database and return a list of objects. + + Handles both OMV 6+ (returns a JSON array directly) and older + versions that wrap the array in a dict under a common key. + """ + try: + raw = self._send(f"omv-confdbadm read {key!r} 2>/dev/null").strip() + if not raw: + return [] + data = _json.loads(raw) + if isinstance(data, list): + return data + # Older OMV wraps the list: {"share": [...]} + for wrapper in ("share", "rule", "entry", "mntent", "sharedfolder"): + if wrapper in data and isinstance(data[wrapper], list): + return data[wrapper] + return [] + except Exception: + return [] + + def _resolve_shared_folder(self, uuid: str) -> str: + """Resolve an OMV shared folder UUID to its absolute filesystem path.""" + if not uuid: + return "" + try: + for folder in self._omv_read("conf.system.sharedfolder"): + if folder.get("uuid") != uuid: + continue + rel = folder.get("reldirpath", "").strip("/") + mnt_uuid = folder.get("mntentref", "") + mnt_dir = "" + for mntent in self._omv_read("conf.system.filesystem.mountpoint"): + if mntent.get("uuid") == mnt_uuid: + mnt_dir = mntent.get("dir", "").rstrip("/") + break + return f"{mnt_dir}/{rel}".rstrip("/") if rel else mnt_dir + except Exception: + pass + return "" + + def _smb_shares(self) -> Dict[str, NASShareDict]: + out: Dict[str, NASShareDict] = {} + for s in self._omv_read("conf.service.smb.shares"): + name = s.get("name", "") + if not name: + continue + path = self._resolve_shared_folder(s.get("sharedfolderref", "")) + out[f"smb:{name}"] = NASShareDict( + name=name, + protocol="smb", + path=path, + volume=path.split("/")[-1] if path else "", + enabled=bool(s.get("enable", True)), + readonly=bool(s.get("readonly", False)), + description=s.get("comment", ""), + clients=[], + ) + return out + + def _nfs_shares(self) -> Dict[str, NASShareDict]: + out: Dict[str, NASShareDict] = {} + for s in self._omv_read("conf.service.nfs.shares"): + path = self._resolve_shared_folder(s.get("sharedfolderref", "")) + name = path.split("/")[-1] or s.get("sharedfolderref", "") + clients_raw = s.get("client", "") or "" + clients = [c.strip() for c in clients_raw.split(",") if c.strip()] + opts = s.get("options", "") or "" + out[f"nfs:{name}"] = NASShareDict( + name=name, + protocol="nfs", + path=path, + volume=path.split("/")[-1] if path else "", + enabled=bool(s.get("enable", True)), + readonly="ro" in [o.strip() for o in opts.split(",")], + description="", + clients=clients, + ) + return out + + def _ftp_shares(self) -> Dict[str, NASShareDict]: + out: Dict[str, NASShareDict] = {} + for s in self._omv_read("conf.service.ftp.shares"): + path = self._resolve_shared_folder(s.get("sharedfolderref", "")) + name = path.split("/")[-1] or s.get("sharedfolderref", "") + out[f"ftp:{name}"] = NASShareDict( + name=name, + protocol="ftp", + path=path, + volume=path.split("/")[-1] if path else "", + enabled=bool(s.get("enable", True)), + readonly=bool(s.get("readonly", False)), + description="", + clients=[], + ) + return out diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8357fe4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "napalm-openmediavault" +version = "0.1.0" +description = "NAPALM driver for OpenMediaVault NAS (SSH)" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "Apache-2.0" } +authors = [ + { name = "Christian Manivong", email = "christian@manivong.de" }, +] +keywords = ["napalm", "network", "automation", "nas", "openmediavault", "omv", "storage"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Topic :: System :: Networking", + "Topic :: System :: Systems Administration", +] +dependencies = [ + "napalm>=4.0", + "napalm-device-types>=0.3.0", + "napalm-linux>=0.1.0", +] + +[project.entry-points."napalm.drivers"] +openmediavault = "napalm_openmediavault:OpenMediaVaultDriver" + +[project.urls] +Repository = "https://github.com/chrismanivong/napalm-openmediavault" + +[tool.setuptools.packages.find] +where = ["."] +include = ["napalm_openmediavault*"] + +[tool.mypy] +python_version = "3.9" +strict = true +ignore_missing_imports = true diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29