Merge pull request 'feat!: configure_snmp_agent with the caller's values replaces fix_snmp (0.3.0)' (#6) from feat/snmp-agent-template into main
Tests / Test (Python 3.10) (push) Successful in 17s
Tests / Test (Python 3.11) (push) Successful in 17s
Tests / Test (Python 3.12) (push) Successful in 21s

This commit was merged in pull request #6.
This commit is contained in:
christianmanivong committed 2026-10-17 06:48:20 +00:00
commit 03300b7954
4 files changed
+142 -74

No files matched your search

+55 -71
View File
@@ -27,7 +27,7 @@ import netaddr
from netmiko import ConnectHandler from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException
from napalm_device_types import ConfigLifecycleMixin, FingerprintRule, SwitchDriver from napalm_device_types import ConfigLifecycleMixin, FingerprintRule, SnmpAgentMixin, SwitchDriver
from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict
from napalm.base import helpers as napalm_helpers from napalm.base import helpers as napalm_helpers
from napalm.base.exceptions import ( from napalm.base.exceptions import (
@@ -41,7 +41,7 @@ from napalm.base.netmiko_helpers import netmiko_args
import napalm.base.constants as C import napalm.base.constants as C
class TPLinkJetstreamDriver(ConfigLifecycleMixin, SwitchDriver): class TPLinkJetstreamDriver(ConfigLifecycleMixin, SnmpAgentMixin, SwitchDriver):
"""NAPALM driver for TP-Link Jetstream managed switches.""" """NAPALM driver for TP-Link Jetstream managed switches."""
VENDOR = "TP-Link" VENDOR = "TP-Link"
@@ -1935,81 +1935,65 @@ class TPLinkJetstreamDriver(ConfigLifecycleMixin, SwitchDriver):
"""Return device warnings. Currently delegates to SNMP detection only.""" """Return device warnings. Currently delegates to SNMP detection only."""
return [] return []
def run_device_action(self, action: str) -> dict: # ------------------------------------------------------------------
"""Execute a named action on the switch.""" # SNMP agent (napalm-device-types SnmpAgentMixin): the caller's values
if action == "fix_snmp": # ------------------------------------------------------------------
return self._action_fix_snmp()
raise NotImplementedError(f"Unknown action: {action!r}")
def _detect_management_vlan(self) -> int | None: def _management_vlan(self, address: str) -> int | None:
"""Detect which VLAN interface carries the management IP (self.hostname).""" """The VLAN interface whose IP address is exactly *address*."""
try: current = None
out = self._send_command("show ip interface") pattern = re.compile(rf"IP address[:\s]+{re.escape(address)}(?![\d.])", re.I)
import re as _re for line in self._send_command("show ip interface").splitlines():
# Match "VLAN<N> is up" followed by IP line containing self.hostname m = re.match(r"VLAN(\d+)\s+is", line, re.I)
vlan = None if m:
current_vlan = None current = int(m.group(1))
for line in out.splitlines(): elif current is not None and pattern.search(line):
m = _re.match(r"VLAN(\d+)\s+is", line, _re.IGNORECASE) return current
if m: return None
current_vlan = int(m.group(1))
if current_vlan and self.hostname in line and "IP address" in line:
vlan = current_vlan
break
return vlan
except Exception:
return None
def _action_fix_snmp(self) -> dict: def _install_snmp_agent(self) -> dict:
"""Enable SNMP and configure community 'public' on the switch. return {"ok": True, "output": "the SNMP agent is built in"}
TP-Link JetStream uses CLI commands — SNMP is built-in, no install needed. def _write_snmp_agent_config(self, settings: dict) -> dict:
Also sets snmp-server source-interface to the management VLAN so that community = settings["community"]
SNMP responses are sent from the correct interface. if not re.match(r'^[^\s"#]+$', community):
""" raise ValueError("the community must be one word without quotes or #")
lines: list = [] vlan = self._management_vlan(settings.get("listen") or self.hostname)
# Detect management VLAN (the VLAN interface carrying this device's IP)
mgmt_vlan = self._detect_management_vlan()
if mgmt_vlan:
lines.append(f"[detect] Management VLAN: {mgmt_vlan}")
else:
lines.append("[detect] Could not auto-detect management VLAN.")
# Configure SNMP in config mode
conf_prompt = self._conf_prompt() conf_prompt = self._conf_prompt()
self._enter_config_mode() self._enter_config_mode()
self.device.send_command("snmp-server", expect_string=conf_prompt, read_timeout=self.timeout) try:
lines.append("[config] SNMP enabled.") for command in (
"snmp-server",
f'snmp-server community "{community}" read-only "viewDefault"',
*([f"snmp-server source-interface vlan {vlan}"] if vlan else []),
):
self.device.send_command(
command, expect_string=conf_prompt, read_timeout=self.timeout
)
finally:
self._exit_config_mode()
notes = [f"source interface vlan {vlan}" if vlan else "management VLAN not found"]
if settings.get("contact") or settings.get("location"):
notes.append("contact and location not set (no verified CLI on this switch)")
return {"ok": True, "output": "community set (read-only); " + "; ".join(notes)}
# Community: TP-Link syntax is snmp-server community "<name>" read-only "<view>" @staticmethod
self.device.send_command( def _allow_snmp_sources(settings: dict) -> dict:
'snmp-server community "public" read-only "viewDefault"', sources = ", ".join(settings.get("allowed_sources", [])) or "none given"
expect_string=conf_prompt, note = f"no host firewall on the switch; sources not enforced ({sources})"
read_timeout=self.timeout, return {"ok": True, "output": note}
)
lines.append("[config] Community 'public' (read-only, viewDefault) configured.")
# Set source-interface so responses come from the management VLAN def _start_snmp_agent(self) -> dict:
if mgmt_vlan:
self.device.send_command(
f"snmp-server source-interface vlan {mgmt_vlan}",
expect_string=conf_prompt,
read_timeout=self.timeout,
)
lines.append(f"[config] Source-interface set to vlan {mgmt_vlan}.")
# Exit config mode and save
self._exit_config_mode()
self._save_config() self._save_config()
lines.append("[config] Configuration saved.") return {"ok": True, "output": "configuration saved"}
# Verify def configure_snmp_agent(self, settings: dict) -> dict:
out = self._send_command("show snmp community") """The template's steps, then a check that the switch lists the community."""
success = "public" in out and "disabled" not in out.lower() result = SnmpAgentMixin.configure_snmp_agent(self, settings)
if success: if not result["success"]:
lines.append("[ok] SNMP is active with community 'public'.") return result
else: listed = self._send_command("show snmp community")
lines.append(f"[warn] Verification failed: {out[:100]}") if re.search(rf"(?<!\S){re.escape(settings['community'])}(?!\S)", listed):
return result
return {"success": success, "output": "\n".join(lines)} missing = f"[verify] the switch does not list the community: {listed[:200]}"
return {**result, "success": False, "output": f"{result['output']}\n{missing}"}
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "napalm-tplink-jetstream" name = "napalm-tplink-jetstream"
version = "0.2.0" version = "0.3.0"
description = "NAPALM driver for TP-Link Jetstream managed switches" description = "NAPALM driver for TP-Link Jetstream managed switches"
readme = "README.md" readme = "README.md"
license = { text = "Apache-2.0" } license = { text = "Apache-2.0" }
@@ -24,7 +24,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"napalm>=4.0.0", "napalm>=4.0.0",
"napalm_device_types>=0.2.0", "napalm_device_types>=4.13.0",
"netmiko>=4.0.0", "netmiko>=4.0.0",
"paramiko>=5.0.0", # CVE-2026-44405 "paramiko>=5.0.0", # CVE-2026-44405
"netaddr", "netaddr",
+1 -1
View File
@@ -1,4 +1,4 @@
napalm>=4.0.0 napalm>=4.0.0
napalm_device_types>=0.1.0 napalm_device_types>=4.13.0
netmiko>=4.0.0 netmiko>=4.0.0
netaddr netaddr
+84
View File
@@ -0,0 +1,84 @@
"""The SNMP agent through napalm-device-types' configure_snmp_agent (netOrk #1105).
fix_snmp hard-coded community "public" and found the management VLAN by a
substring match (10.0.0.1 matched 10.0.0.10, #3). The community is the caller's
now, and the source interface is the VLAN that carries exactly the management
address.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from napalm_tplink_jetstream.tplink_jetstream import TPLinkJetstreamDriver
SETTINGS = {"community": "c0mm", "allowed_sources": ["10.7.224.20"], "contact": "ops"}
SHOW_IP_INTERFACE = """\
VLAN1 is up, line protocol is up
Primary IP address: 10.0.0.10/24
VLAN8 is up, line protocol is up
Primary IP address: 10.0.0.1/24
"""
@pytest.fixture
def driver():
d = TPLinkJetstreamDriver("10.0.0.1", "admin", "pw")
d.device = MagicMock()
d.sent: list[str] = []
d.device.send_command.side_effect = lambda cmd, **kw: d.sent.append(cmd) or ""
d._conf_prompt = lambda: r"\(config\)#"
d._enter_config_mode = MagicMock()
d._exit_config_mode = MagicMock()
d._save_config = MagicMock()
d._send_command = MagicMock(
side_effect=lambda cmd: SHOW_IP_INTERFACE
if cmd == "show ip interface"
else "Index Community-Name Access\n1 c0mm read-only\n"
)
return d
def test_the_callers_community(driver):
result = driver.configure_snmp_agent(SETTINGS)
assert result["success"] is True
assert 'snmp-server community "c0mm" read-only "viewDefault"' in driver.sent
assert not any("public" in c for c in driver.sent)
driver._save_config.assert_called_once()
def test_the_source_interface_is_the_vlan_of_exactly_the_management_address(driver):
driver.configure_snmp_agent(SETTINGS)
assert "snmp-server source-interface vlan 8" in driver.sent
def test_contact_and_location_are_reported_as_not_set(driver):
steps = {s["step"]: s for s in driver.configure_snmp_agent(SETTINGS)["steps"]}
assert "contact" in steps["configure"]["output"]
def test_a_community_the_switch_does_not_list_fails(driver):
driver._send_command.side_effect = lambda cmd: (
SHOW_IP_INTERFACE if cmd == "show ip interface" else "1 public read-only\n"
)
assert driver.configure_snmp_agent(SETTINGS)["success"] is False
@pytest.mark.parametrize("community", ["two words", 'q"uote', ""])
def test_a_community_that_would_break_the_cli_is_refused(driver, community):
result = driver.configure_snmp_agent({"community": community, "allowed_sources": []})
assert result["success"] is False
assert driver.sent == []
def test_the_old_action_is_gone():
for name in ("run_device_action", "_action_fix_snmp", "_detect_management_vlan"):
assert not hasattr(TPLinkJetstreamDriver, name), name