diff --git a/anta/input_models/snmp.py b/anta/input_models/snmp.py index d408d9311..0e032b834 100644 --- a/anta/input_models/snmp.py +++ b/anta/input_models/snmp.py @@ -6,10 +6,11 @@ from __future__ import annotations from ipaddress import IPv4Address +from typing import Literal from pydantic import BaseModel, ConfigDict -from anta.custom_types import Hostname, Interface, SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion +from anta.custom_types import Hostname, Interface, Port, SnmpEncryptionAlgorithm, SnmpHashingAlgorithm, SnmpVersion class SnmpHost(BaseModel): @@ -17,9 +18,19 @@ class SnmpHost(BaseModel): model_config = ConfigDict(extra="forbid") hostname: IPv4Address | Hostname - """IPv4 address or hostname of the SNMP notification host.""" + """IPv4 address or Hostname of the SNMP notification host.""" vrf: str = "default" - """Optional VRF for SNMP hosts. If not provided, it defaults to `default`.""" + """Optional VRF for SNMP Hosts. If not provided, it defaults to `default`.""" + notification_type: Literal["trap", "inform"] = "trap" + """Type of SNMP notification (trap or inform), it defaults to trap.""" + version: SnmpVersion | None = None + """SNMP protocol version. Required field in the `VerifySnmpNotificationHost` test.""" + udp_port: Port | int = 162 + """UDP port for SNMP. If not provided then defaults to 162.""" + community_string: str | None = None + """Optional SNMP community string for authentication,required for SNMP version is v1 or v2c. Can be provided in the `VerifySnmpNotificationHost` test.""" + user: str | None = None + """Optional SNMP user for authentication, required for SNMP version v3. Can be provided in the `VerifySnmpNotificationHost` test.""" def __str__(self) -> str: """Return a human-readable string representation of the SnmpHost for reporting. diff --git a/anta/tests/snmp.py b/anta/tests/snmp.py index 84c5470e6..0108d8512 100644 --- a/anta/tests/snmp.py +++ b/anta/tests/snmp.py @@ -491,6 +491,127 @@ def test(self) -> None: self.result.is_failure(f"{user} - Incorrect privacy type - Expected: {user.priv_type} Actual: {act_encryption}") +class VerifySnmpNotificationHost(AntaTest): + """Verifies the SNMP notification host(s) (SNMP manager) configurations. + + This test performs the following checks for each specified host: + + 1. Verifies that the SNMP host(s) is configured on the device. + 2. Verifies that the notification type ("trap" or "inform") matches the expected value. + 3. Ensures that UDP port provided matches the expected value. + 4. Ensures the following depending on SNMP version: + - For SNMP version v1/v2c, a valid community string is set and matches the expected value. + - For SNMP version v3, a valid user field is set and matches the expected value. + + Expected Results + ---------------- + * Success: The test will pass if all of the following conditions are met: + - The SNMP host(s) is configured on the device. + - The notification type ("trap" or "inform") and UDP port match the expected value. + - Ensures the following depending on SNMP version: + - For SNMP version v1/v2c, a community string is set and it matches the expected value. + - For SNMP version v3, a valid user field is set and matches the expected value. + * Failure: The test will fail if any of the following conditions is met: + - The SNMP host(s) is not configured on the device. + - The notification type ("trap" or "inform") or UDP port do not matches the expected value. + - Ensures the following depending on SNMP version: + - For SNMP version v1/v2c, a community string is not matches the expected value. + - For SNMP version v3, an user field is not matches the expected value. + + Examples + -------- + ```yaml + anta.tests.snmp: + - VerifySnmpNotificationHost: + notification_hosts: + - hostname: spine + vrf: default + notification_type: trap + version: v1 + udp_port: 162 + community_string: public + - hostname: 192.168.1.100 + vrf: default + notification_type: trap + version: v3 + udp_port: 162 + user: public + ``` + """ + + categories: ClassVar[list[str]] = ["snmp"] + commands: ClassVar[list[AntaCommand | AntaTemplate]] = [AntaCommand(command="show snmp notification host", revision=1)] + + class Input(AntaTest.Input): + """Input model for the VerifySnmpNotificationHost test.""" + + notification_hosts: list[SnmpHost] + """List of SNMP host(s).""" + + @field_validator("notification_hosts") + @classmethod + def validate_notification_hosts(cls, notification_hosts: list[SnmpHost]) -> list[SnmpHost]: + """Validate that all required fields are provided in each SNMP Notification Host.""" + for host in notification_hosts: + if host.version is None: + msg = f"{host}; 'version' field missing in the input" + raise ValueError(msg) + if host.version in ["v1", "v2c"] and host.community_string is None: + msg = f"{host} Version: {host.version}; 'community_string' field missing in the input" + raise ValueError(msg) + if host.version == "v3" and host.user is None: + msg = f"{host} Version: {host.version}; 'user' field missing in the input" + raise ValueError(msg) + return notification_hosts + + @AntaTest.anta_test + def test(self) -> None: + """Main test function for VerifySnmpNotificationHost.""" + self.result.is_success() + + # If SNMP is not configured, test fails. + if not (snmp_hosts := get_value(self.instance_commands[0].json_output, "hosts")): + self.result.is_failure("No SNMP host is configured.") + return + + for host in self.inputs.notification_hosts: + vrf = "" if host.vrf == "default" else host.vrf + hostname = str(host.hostname) + notification_type = host.notification_type + version = host.version + udp_port = host.udp_port + community_string = host.community_string + user = host.user + default_value = "Not Found" + + host_details = next( + (host for host in snmp_hosts if (host.get("hostname") == hostname and host.get("protocolVersion") == version and host.get("vrf") == vrf)), None + ) + # If expected SNMP host is not configured with the specified protocol version, test fails. + if not host_details: + self.result.is_failure(f"{host} Version: {version} - Not configured") + continue + + # If actual notification type does not match the expected value, test fails. + if notification_type != (actual_notification_type := get_value(host_details, "notificationType", default_value)): + self.result.is_failure(f"{host} - Incorrect notification type - Expected: {notification_type}, Actual: {actual_notification_type}") + + # If actual UDP port does not match the expected value, test fails. + if udp_port != (actual_udp_port := get_value(host_details, "port", default_value)): + self.result.is_failure(f"{host} - Incorrect UDP port - Expected: {udp_port}, Actual: {actual_udp_port}") + + user_found = user != (actual_user := get_value(host_details, "v3Params.user", default_value)) + version_user_check = (version == "v3", user_found) + + # If SNMP protocol version is v1 or v2c and actual community string does not match the expected value, test fails. + if version in ["v1", "v2c"] and community_string != (actual_community_string := get_value(host_details, "v1v2cParams.communityString", default_value)): + self.result.is_failure(f"{host} Version: {version} - Incorrect community string - Expected: {community_string}, Actual: {actual_community_string}") + + # If SNMP protocol version is v3 and actual user does not match the expected value, test fails. + elif all(version_user_check): + self.result.is_failure(f"{host} Version: {version} - Incorrect user - Expected: {user}, Actual: {actual_user}") + + class VerifySnmpSourceInterface(AntaTest): """Verifies SNMP source interfaces. diff --git a/examples/tests.yaml b/examples/tests.yaml index 34c9be702..790018adf 100644 --- a/examples/tests.yaml +++ b/examples/tests.yaml @@ -797,6 +797,21 @@ anta.tests.snmp: - VerifySnmpLocation: # Verifies the SNMP location of a device. location: New York + - VerifySnmpNotificationHost: + # Verifies the SNMP notification host(s) (SNMP manager) configurations. + notification_hosts: + - hostname: spine + vrf: default + notification_type: trap + version: v1 + udp_port: 162 + community_string: public + - hostname: 192.168.1.100 + vrf: default + notification_type: trap + version: v3 + udp_port: 162 + user: public - VerifySnmpPDUCounters: # Verifies the SNMP PDU counters. pdus: diff --git a/tests/units/anta_tests/test_snmp.py b/tests/units/anta_tests/test_snmp.py index fc30ad6ce..b2eee6a05 100644 --- a/tests/units/anta_tests/test_snmp.py +++ b/tests/units/anta_tests/test_snmp.py @@ -14,6 +14,7 @@ VerifySnmpIPv4Acl, VerifySnmpIPv6Acl, VerifySnmpLocation, + VerifySnmpNotificationHost, VerifySnmpPDUCounters, VerifySnmpSourceInterface, VerifySnmpStatus, @@ -537,6 +538,217 @@ ], }, }, + { + "name": "success", + "test": VerifySnmpNotificationHost, + "eos_data": [ + { + "hosts": [ + { + "hostname": "192.168.1.100", + "port": 162, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v3", + "v3Params": {"user": "public", "securityLevel": "authNoPriv"}, + }, + { + "hostname": "192.168.1.101", + "port": 162, + "vrf": "MGMT", + "notificationType": "trap", + "protocolVersion": "v2c", + "v1v2cParams": {"communityString": "public"}, + }, + ] + } + ], + "inputs": { + "notification_hosts": [ + {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"}, + {"hostname": "192.168.1.101", "vrf": "MGMT", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"}, + ] + }, + "expected": {"result": "success"}, + }, + { + "name": "failure-not-configured", + "test": VerifySnmpNotificationHost, + "eos_data": [{"hosts": []}], + "inputs": { + "notification_hosts": [ + {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"}, + {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"}, + ] + }, + "expected": {"result": "failure", "messages": ["No SNMP host is configured."]}, + }, + { + "name": "failure-details-host-not-found", + "test": VerifySnmpNotificationHost, + "eos_data": [ + { + "hosts": [ + { + "hostname": "192.168.1.100", + "port": 162, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v3", + "v3Params": {"user": "public", "securityLevel": "authNoPriv"}, + }, + ] + } + ], + "inputs": { + "notification_hosts": [ + {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"}, + {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"}, + ] + }, + "expected": {"result": "failure", "messages": ["Host: 192.168.1.101 VRF: default Version: v2c - Not configured"]}, + }, + { + "name": "failure-incorrect-notification-type", + "test": VerifySnmpNotificationHost, + "eos_data": [ + { + "hosts": [ + { + "hostname": "192.168.1.100", + "port": 162, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v3", + "v3Params": {"user": "public", "securityLevel": "authNoPriv"}, + }, + { + "hostname": "192.168.1.101", + "port": 162, + "vrf": "", + "notificationType": "inform", + "protocolVersion": "v2c", + "v1v2cParams": {"communityString": "public"}, + }, + ] + } + ], + "inputs": { + "notification_hosts": [ + {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "inform", "version": "v3", "udp_port": 162, "user": "public"}, + {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Host: 192.168.1.100 VRF: default - Incorrect notification type - Expected: inform, Actual: trap", + "Host: 192.168.1.101 VRF: default - Incorrect notification type - Expected: trap, Actual: inform", + ], + }, + }, + { + "name": "failure-incorrect-udp-port", + "test": VerifySnmpNotificationHost, + "eos_data": [ + { + "hosts": [ + { + "hostname": "192.168.1.100", + "port": 163, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v3", + "v3Params": {"user": "public", "securityLevel": "authNoPriv"}, + }, + { + "hostname": "192.168.1.101", + "port": 164, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v2c", + "v1v2cParams": {"communityString": "public"}, + }, + ] + } + ], + "inputs": { + "notification_hosts": [ + {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"}, + {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Host: 192.168.1.100 VRF: default - Incorrect UDP port - Expected: 162, Actual: 163", + "Host: 192.168.1.101 VRF: default - Incorrect UDP port - Expected: 162, Actual: 164", + ], + }, + }, + { + "name": "failure-incorrect-community-string-version-v1-v2c", + "test": VerifySnmpNotificationHost, + "eos_data": [ + { + "hosts": [ + { + "hostname": "192.168.1.100", + "port": 162, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v1", + "v1v2cParams": {"communityString": "private"}, + }, + { + "hostname": "192.168.1.101", + "port": 162, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v2c", + "v1v2cParams": {"communityString": "private"}, + }, + ] + } + ], + "inputs": { + "notification_hosts": [ + {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v1", "udp_port": 162, "community_string": "public"}, + {"hostname": "192.168.1.101", "vrf": "default", "notification_type": "trap", "version": "v2c", "udp_port": 162, "community_string": "public"}, + ] + }, + "expected": { + "result": "failure", + "messages": [ + "Host: 192.168.1.100 VRF: default Version: v1 - Incorrect community string - Expected: public, Actual: private", + "Host: 192.168.1.101 VRF: default Version: v2c - Incorrect community string - Expected: public, Actual: private", + ], + }, + }, + { + "name": "failure-incorrect-user-for-version-v3", + "test": VerifySnmpNotificationHost, + "eos_data": [ + { + "hosts": [ + { + "hostname": "192.168.1.100", + "port": 162, + "vrf": "", + "notificationType": "trap", + "protocolVersion": "v3", + "v3Params": {"user": "private", "securityLevel": "authNoPriv"}, + } + ] + } + ], + "inputs": { + "notification_hosts": [ + {"hostname": "192.168.1.100", "vrf": "default", "notification_type": "trap", "version": "v3", "udp_port": 162, "user": "public"}, + ] + }, + "expected": {"result": "failure", "messages": ["Host: 192.168.1.100 VRF: default Version: v3 - Incorrect user - Expected: public, Actual: private"]}, + }, { "name": "success", "test": VerifySnmpSourceInterface, diff --git a/tests/units/input_models/test_snmp.py b/tests/units/input_models/test_snmp.py index 94551ca76..e48ea301c 100644 --- a/tests/units/input_models/test_snmp.py +++ b/tests/units/input_models/test_snmp.py @@ -11,10 +11,10 @@ import pytest from pydantic import ValidationError -from anta.tests.snmp import VerifySnmpUser +from anta.tests.snmp import VerifySnmpNotificationHost, VerifySnmpUser if TYPE_CHECKING: - from anta.input_models.snmp import SnmpUser + from anta.input_models.snmp import SnmpHost, SnmpUser class TestVerifySnmpUserInput: @@ -42,3 +42,124 @@ def test_invalid(self, snmp_users: list[SnmpUser]) -> None: """Test VerifySnmpUser.Input invalid inputs.""" with pytest.raises(ValidationError): VerifySnmpUser.Input(snmp_users=snmp_users) + + +class TestSnmpHost: + """Test anta.input_models.snmp.SnmpHost.""" + + @pytest.mark.parametrize( + ("notification_hosts"), + [ + pytest.param( + [ + { + "hostname": "192.168.1.100", + "vrf": "test", + "notification_type": "trap", + "version": "v1", + "udp_port": 162, + "community_string": "public", + "user": None, + } + ], + id="valid-v1", + ), + pytest.param( + [ + { + "hostname": "192.168.1.100", + "vrf": "test", + "notification_type": "trap", + "version": "v2c", + "udp_port": 162, + "community_string": "public", + "user": None, + } + ], + id="valid-v2c", + ), + pytest.param( + [ + { + "hostname": "192.168.1.100", + "vrf": "test", + "notification_type": "trap", + "version": "v3", + "udp_port": 162, + "community_string": None, + "user": "public", + } + ], + id="valid-v3", + ), + ], + ) + def test_valid(self, notification_hosts: list[SnmpHost]) -> None: + """Test VerifySnmpNotificationHost.Input valid inputs.""" + VerifySnmpNotificationHost.Input(notification_hosts=notification_hosts) + + @pytest.mark.parametrize( + ("notification_hosts"), + [ + pytest.param( + [ + { + "hostname": "192.168.1.100", + "vrf": "test", + "notification_type": "trap", + "version": None, + "udp_port": 162, + "community_string": None, + "user": None, + } + ], + id="invalid-version", + ), + pytest.param( + [ + { + "hostname": "192.168.1.100", + "vrf": "test", + "notification_type": "trap", + "version": "v1", + "udp_port": 162, + "community_string": None, + "user": None, + } + ], + id="invalid-community-string-version-v1", + ), + pytest.param( + [ + { + "hostname": "192.168.1.100", + "vrf": "test", + "notification_type": "trap", + "version": "v2c", + "udp_port": 162, + "community_string": None, + "user": None, + } + ], + id="invalid-community-string-version-v2c", + ), + pytest.param( + [ + { + "hostname": "192.168.1.100", + "vrf": "test", + "notification_type": "trap", + "version": "v3", + "udp_port": 162, + "community_string": None, + "user": None, + } + ], + id="invalid-user-version-v3", + ), + ], + ) + def test_invalid(self, notification_hosts: list[SnmpHost]) -> None: + """Test VerifySnmpNotificationHost.Input invalid inputs.""" + with pytest.raises(ValidationError): + VerifySnmpNotificationHost.Input(notification_hosts=notification_hosts)