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
This commit was merged in pull request #6.
This commit is contained in:
commit
03300b7954
4 files changed
+142
-74
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user