From 60525bd35e9013a1cd64e750dcee22f6631ec1e3 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Wed, 24 Jun 2026 15:17:08 +0200 Subject: [PATCH] feat: initial napalm-yealink driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery-Fingerprinting für Yealink IP-Phones (T-series, W-series, CP-series, VP-series) und Video-Endpoints. - TYPE_LABEL = "Phone" (Override von AccessPointDriver) - SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.37403" (Yealink Enterprise OID) - HTTP_FINGERPRINT: "yealink" (mandatory, weight 10), Modell-Patterns - get_facts(): liest phoneName + firmware aus dem noauth-API-Response (/api/v1/system/info liefert Gerätedaten auch ohne Authentication) - Login via MD5-gehashtem Passwort auf /api/v1/session/login (HTTPS) Co-Authored-By: Claude Sonnet 4.6 --- napalm_yealink.egg-info/PKG-INFO | 8 + napalm_yealink.egg-info/SOURCES.txt | 10 + napalm_yealink.egg-info/dependency_links.txt | 1 + napalm_yealink.egg-info/entry_points.txt | 2 + napalm_yealink.egg-info/requires.txt | 3 + napalm_yealink.egg-info/top_level.txt | 1 + napalm_yealink/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 271 bytes .../__pycache__/yealink.cpython-312.pyc | Bin 0 -> 10972 bytes napalm_yealink/yealink.py | 224 ++++++++++++++++++ pyproject.toml | 21 ++ tests/__init__.py | 0 tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 171 bytes ...t_fingerprint.cpython-312-pytest-9.0.3.pyc | Bin 0 -> 8317 bytes tests/test_fingerprint.py | 43 ++++ 15 files changed, 316 insertions(+) create mode 100644 napalm_yealink.egg-info/PKG-INFO create mode 100644 napalm_yealink.egg-info/SOURCES.txt create mode 100644 napalm_yealink.egg-info/dependency_links.txt create mode 100644 napalm_yealink.egg-info/entry_points.txt create mode 100644 napalm_yealink.egg-info/requires.txt create mode 100644 napalm_yealink.egg-info/top_level.txt create mode 100644 napalm_yealink/__init__.py create mode 100644 napalm_yealink/__pycache__/__init__.cpython-312.pyc create mode 100644 napalm_yealink/__pycache__/yealink.cpython-312.pyc create mode 100644 napalm_yealink/yealink.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/test_fingerprint.cpython-312-pytest-9.0.3.pyc create mode 100644 tests/test_fingerprint.py diff --git a/napalm_yealink.egg-info/PKG-INFO b/napalm_yealink.egg-info/PKG-INFO new file mode 100644 index 0000000..f80d5df --- /dev/null +++ b/napalm_yealink.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.4 +Name: napalm-yealink +Version: 0.1.0 +Summary: NAPALM driver for Yealink IP phones and video endpoints +Requires-Python: >=3.10 +Requires-Dist: napalm +Requires-Dist: napalm-device-types +Requires-Dist: requests diff --git a/napalm_yealink.egg-info/SOURCES.txt b/napalm_yealink.egg-info/SOURCES.txt new file mode 100644 index 0000000..0377478 --- /dev/null +++ b/napalm_yealink.egg-info/SOURCES.txt @@ -0,0 +1,10 @@ +pyproject.toml +napalm_yealink/__init__.py +napalm_yealink/yealink.py +napalm_yealink.egg-info/PKG-INFO +napalm_yealink.egg-info/SOURCES.txt +napalm_yealink.egg-info/dependency_links.txt +napalm_yealink.egg-info/entry_points.txt +napalm_yealink.egg-info/requires.txt +napalm_yealink.egg-info/top_level.txt +tests/test_fingerprint.py \ No newline at end of file diff --git a/napalm_yealink.egg-info/dependency_links.txt b/napalm_yealink.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/napalm_yealink.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/napalm_yealink.egg-info/entry_points.txt b/napalm_yealink.egg-info/entry_points.txt new file mode 100644 index 0000000..dfce953 --- /dev/null +++ b/napalm_yealink.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[napalm.drivers] +yealink = napalm_yealink:YealinkDriver diff --git a/napalm_yealink.egg-info/requires.txt b/napalm_yealink.egg-info/requires.txt new file mode 100644 index 0000000..2db9ac5 --- /dev/null +++ b/napalm_yealink.egg-info/requires.txt @@ -0,0 +1,3 @@ +napalm +napalm-device-types +requests diff --git a/napalm_yealink.egg-info/top_level.txt b/napalm_yealink.egg-info/top_level.txt new file mode 100644 index 0000000..f0dc5ee --- /dev/null +++ b/napalm_yealink.egg-info/top_level.txt @@ -0,0 +1 @@ +napalm_yealink diff --git a/napalm_yealink/__init__.py b/napalm_yealink/__init__.py new file mode 100644 index 0000000..3849d8c --- /dev/null +++ b/napalm_yealink/__init__.py @@ -0,0 +1,3 @@ +from napalm_yealink.yealink import YealinkDriver + +__all__ = ["YealinkDriver"] diff --git a/napalm_yealink/__pycache__/__init__.cpython-312.pyc b/napalm_yealink/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f367807fa9f7f56c916f23964369d35c85e6216f GIT binary patch literal 271 zcmX@j%ge<81e32@XZZo?#~=<2FhLog#ej_I3@HpLj5!Rsj8Tk?3@J?Mj8ROL%$h7O zL5egPZ}CQ^Cgx=3WxEt*mZcUIF#|>YG?{OSL+IuWfqrYCg$ZP=4F=U=cVhXq?YOXrIz>?W$Txv=B4Bp=|haw zg&GIt!ferxkI&4@EQycTE2#X%VUwGmQks)$SHualgAs^}1%Si{W=2NFy9@>oxTP*| LNjI_=u>%DG2}(?j literal 0 HcmV?d00001 diff --git a/napalm_yealink/__pycache__/yealink.cpython-312.pyc b/napalm_yealink/__pycache__/yealink.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7803cb4fa0ce1647be29b58e4cd44b9ab08ea88b GIT binary patch literal 10972 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 Nq^sM0L9oK+{XaP53^4!z literal 0 HcmV?d00001 diff --git a/napalm_yealink/yealink.py b/napalm_yealink/yealink.py new file mode 100644 index 0000000..cfa4d22 --- /dev/null +++ b/napalm_yealink/yealink.py @@ -0,0 +1,224 @@ +"""NAPALM driver for Yealink IP phones and video endpoints. + +Yealink exposes a REST API at ``/api/v1/``. Authentication sends the +password as an MD5 hash in a JSON POST to ``/api/v1/session/login``. +The session cookie is then used for subsequent requests. + +Even without authentication, ``/api/v1/system/info`` returns a ``noauth`` +error that includes ``phoneName`` and ``firmware`` — enough for discovery. + +Pass credentials via *username* / *password* (driver hashes the password +automatically) or via ``optional_args={"verify_ssl": False}``. +""" + +from __future__ import annotations + +import hashlib +import socket +from typing import Any + +import requests +from requests.exceptions import RequestException + +from napalm.base.exceptions import ConnectionClosedException, ConnectionException +from napalm_device_types import AccessPointDriver, FingerprintRule +from napalm_device_types.models import HealthMetricsDict + + +class YealinkDriver(AccessPointDriver): + """NAPALM driver for Yealink IP phones (T-series, W-series, CP-series, VP-series).""" + + TYPE_LABEL = "Phone" + VENDOR = "Yealink" + DRIVER_NAME = "yealink" + + SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.37403" + HTTP_FINGERPRINT = [ + FingerprintRule("yealink", weight=10.0, mandatory=True), + FingerprintRule("sip-t", weight=5.0), + FingerprintRule("sip-w", weight=5.0), + FingerprintRule("sip-cp", weight=5.0), + FingerprintRule("vp59", weight=5.0), + ] + + _API_BASE = "/api/v1" + _LOGIN_PATH = "/api/v1/session/login" + _INFO_PATH = "/api/v1/system/info" + + def __init__( + self, + hostname: str, + username: str, + password: str, + timeout: int = 30, + optional_args: dict[str, Any] | None = None, + ) -> None: + self.hostname = hostname + self.username = username + self.password = password + self.timeout = timeout + optional_args = optional_args or {} + self._verify_ssl = optional_args.get("verify_ssl", False) + self._session: requests.Session | None = None + self._base_url = f"https://{hostname}" + + # ── Connection lifecycle ────────────────────────────────────────────────── + + def open(self) -> None: + self._session = requests.Session() + self._session.verify = self._verify_ssl + pw_hash = hashlib.md5(self.password.encode()).hexdigest() + try: + resp = self._session.post( + f"{self._base_url}{self._LOGIN_PATH}", + json={"username": self.username, "password": pw_hash}, + timeout=self.timeout, + ) + data = resp.json() + if data.get("ret") != "success": + # Still usable for read-only discovery data + pass + except RequestException as exc: + raise ConnectionException(f"Cannot connect to {self.hostname}: {exc}") from exc + + def close(self) -> None: + if self._session: + try: + self._session.post( + f"{self._base_url}{self._API_BASE}/session/logout", + timeout=5, + ) + except Exception: + pass + self._session.close() + self._session = None + + def is_alive(self) -> dict[str, bool]: + try: + sock = socket.create_connection((self.hostname, 443), timeout=5) + sock.close() + return {"is_alive": True} + except Exception: + return {"is_alive": False} + + # ── Facts ───────────────────────────────────────────────────────────────── + + def get_facts(self) -> dict[str, Any]: + """Return basic device facts. + + Works partially even without authentication — the noauth API response + includes phoneName and firmware. + """ + info: dict = {} + try: + resp = self._session.get( + f"{self._base_url}{self._INFO_PATH}", + timeout=self.timeout, + ) + data = resp.json() + # Yealink returns facts in 'data' (authed) or 'error' (noauth) + payload = data.get("data") or data.get("error") or {} + info = payload if isinstance(payload, dict) else {} + except Exception: + pass + + model = info.get("phoneName") or info.get("model") or "Yealink" + firmware = info.get("firmware") or info.get("firmwareVersion") or "" + hostname = info.get("hostName") or info.get("deviceName") or self.hostname + + return { + "vendor": self.VENDOR, + "model": model, + "os_version": firmware, + "hostname": hostname, + "fqdn": hostname, + "serial_number": info.get("serialNumber") or "", + "uptime": 0, + "interface_list": [], + } + + # ── NAPALM stubs (not implemented for phones) ───────────────────────────── + + def get_interfaces(self) -> dict: + raise NotImplementedError + + def get_config(self, retrieve: str = "all", full: bool = False, sanitized: bool = False) -> dict: + raise NotImplementedError + + def get_interfaces_ip(self) -> dict: + raise NotImplementedError + + def get_interfaces_counters(self) -> dict: + raise NotImplementedError + + def get_bgp_neighbors(self) -> dict: + raise NotImplementedError + + def get_environment(self) -> dict: + raise NotImplementedError + + def get_lldp_neighbors(self) -> dict: + raise NotImplementedError + + def get_lldp_neighbors_detail(self, interface: str = "") -> dict: + raise NotImplementedError + + def get_arp_table(self, vrf: str = "") -> list: + raise NotImplementedError + + def get_mac_address_table(self) -> list: + raise NotImplementedError + + def get_ntp_servers(self) -> dict: + raise NotImplementedError + + def get_ntp_stats(self) -> list: + raise NotImplementedError + + def get_users(self) -> dict: + raise NotImplementedError + + def get_route_to(self, destination: str = "", protocol: str = "", ip_type: str = "") -> dict: + raise NotImplementedError + + def get_snmp_information(self) -> dict: + raise NotImplementedError + + def ping(self, destination: str, **kwargs: Any) -> dict: + raise NotImplementedError + + def cli(self, commands: list[str], encoding: str = "text") -> dict: + raise NotImplementedError + + def load_replace_candidate(self, filename: str | None = None, config: str | None = None) -> None: + raise NotImplementedError + + def load_merge_candidate(self, filename: str | None = None, config: str | None = None) -> None: + raise NotImplementedError + + def compare_config(self) -> str: + raise NotImplementedError + + def commit_config(self, message: str = "") -> None: + raise NotImplementedError + + def discard_config(self) -> None: + raise NotImplementedError + + def rollback(self) -> None: + raise NotImplementedError + + # AccessPointDriver stubs + + def get_wireless_clients(self) -> list: + raise NotImplementedError + + def get_ssids(self) -> dict: + raise NotImplementedError + + def get_radio_info(self) -> dict: + raise NotImplementedError + + @classmethod + async def get_health_metrics(cls, snmp_get, snmp_walk) -> HealthMetricsDict: + raise NotImplementedError diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..46c32a4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "napalm-yealink" +version = "0.1.0" +description = "NAPALM driver for Yealink IP phones and video endpoints" +requires-python = ">=3.10" +dependencies = [ + "napalm", + "napalm-device-types", + "requests", +] + +[project.entry-points."napalm.drivers"] +yealink = "napalm_yealink:YealinkDriver" + +[tool.setuptools.packages.find] +where = ["."] +include = ["napalm_yealink*"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e4997b1f41d569929c48d5f46bfef4a0eb59df6 GIT binary patch literal 171 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 F{om{wAL0N2 literal 0 HcmV?d00001 diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py new file mode 100644 index 0000000..0808f94 --- /dev/null +++ b/tests/test_fingerprint.py @@ -0,0 +1,43 @@ +"""Fingerprint tests for YealinkDriver.""" + +from napalm_device_types import DeviceTypeDriver, FingerprintRule +from napalm_yealink import YealinkDriver + + +def test_yealink_is_device_type_driver(): + assert issubclass(YealinkDriver, DeviceTypeDriver) + + +def test_type_label(): + assert YealinkDriver.TYPE_LABEL == "Phone" + + +def test_vendor(): + assert YealinkDriver.VENDOR == "Yealink" + + +def test_driver_name(): + assert YealinkDriver.DRIVER_NAME == "yealink" + + +def test_snmp_oid_prefix(): + assert YealinkDriver.SNMP_OBJECT_ID_PREFIX == "1.3.6.1.4.1.37403" + + +def test_http_fingerprint_mandatory_yealink(): + mandatory = [r for r in YealinkDriver.HTTP_FINGERPRINT if r.mandatory] + assert any(r.pattern == "yealink" for r in mandatory) + + +def test_fingerprint_matches_t58_title(): + """'Yealink T58 Phone' im Titel → mandatory 'yealink' trifft zu.""" + combined = "yealink t58 phone nginx " + mandatory = [r for r in YealinkDriver.HTTP_FINGERPRINT if r.mandatory] + assert all(r.pattern in combined for r in mandatory) + + +def test_fingerprint_does_not_match_cisco(): + """Cisco-Seite enthält kein 'yealink' → mandatory Pattern fehlt.""" + combined = "cisco ip phone cp-8841 " + mandatory = [r for r in YealinkDriver.HTTP_FINGERPRINT if r.mandatory] + assert not all(r.pattern in combined for r in mandatory)