diff --git a/src/confd/yang/infix-lldp.yang b/src/confd/yang/infix-lldp.yang index 2de0a728..375c3077 100644 --- a/src/confd/yang/infix-lldp.yang +++ b/src/confd/yang/infix-lldp.yang @@ -76,10 +76,6 @@ module infix-lldp { deviation "/lldp:lldp/lldp:port/lldp:rx-statistics" { deviate not-supported; } - deviation "/lldp:lldp/lldp:port/lldp:remote-systems-data" { - deviate not-supported; - } - deviation "/lldp:lldp/lldp:port/lldp:message-fast-tx" { deviate not-supported; diff --git a/src/statd/python/yanger/__main__.py b/src/statd/python/yanger/__main__.py index 8c898c29..bf69178f 100644 --- a/src/statd/python/yanger/__main__.py +++ b/src/statd/python/yanger/__main__.py @@ -1,6 +1,5 @@ import logging import logging.handlers -import subprocess import json import sys # (built-in module) import os @@ -76,6 +75,9 @@ def main(): elif args.model == 'ietf-system': from . import ietf_system yang_data = ietf_system.operational() + elif args.model == 'ieee802-dot1ab-lldp': + from . import infix_lldp + yang_data = infix_lldp.operational() else: common.LOG.warning("Unsupported model %s", args.model) sys.exit(1) diff --git a/src/statd/python/yanger/infix_lldp.py b/src/statd/python/yanger/infix_lldp.py new file mode 100644 index 00000000..fcd5f5f5 --- /dev/null +++ b/src/statd/python/yanger/infix_lldp.py @@ -0,0 +1,96 @@ +from .host import HOST +from collections import defaultdict + +def operational(): + """Retrieve LLDP neighbor information and store in remote-systems-data under the correct port.""" + + # Reference: https://www.ieee802.org/1/files/public/YANGs/ieee802-types.yang + subtype_mapping = { + "component": "chassis-component", + "ifalias": "interface-alias", + "port": "port-component", + "mac": "mac-address", + "ip": "network-address", + "ifname": "interface-name", + "local": "local" + } + + DEFAULT_MAC = "00-00-00-00-00-00" + + port_data = defaultdict(lambda: {"remote-systems-data": [], "dest-mac-address": None}) + + data = HOST.run_json(["lldpcli", "show", "neighbors", "-f", "json"]) + + interfaces = data.get("lldp", {}).get("interface", []) + + if isinstance(interfaces, dict): + interfaces = [interfaces] + + for iface_entry in interfaces: + for iface_name, iface_data in iface_entry.items(): + remote_index = int(iface_data.get("rid", 0)) + time_mark = parse_time(iface_data.get("age")) + + chassis = iface_data.get("chassis", {}) + chassis_id_type, chassis_id_value = extract_chassis_id(chassis, subtype_mapping) + + port_info = iface_data.get("port", {}) + port_id_type = subtype_mapping.get(port_info.get("id", {}).get("type"), "unknown") + port_id_value = port_info.get("id", {}).get("value", "") + + dest_mac_address = ( + chassis_id_value.replace(":", "-") if chassis_id_type == "mac-address" else + port_id_value.replace(":", "-") if port_id_type == "mac-address" else + DEFAULT_MAC + ) + + remote_entry = { + "time-mark": time_mark, + "remote-index": remote_index, + "chassis-id-subtype": chassis_id_type, + "chassis-id": chassis_id_value, + "port-id-subtype": port_id_type, + "port-id": port_id_value + } + + port_data[iface_name]["remote-systems-data"].append(remote_entry) + + if port_data[iface_name]["dest-mac-address"] is None: + port_data[iface_name]["dest-mac-address"] = dest_mac_address + + formatted_output = { + "ieee802-dot1ab-lldp:lldp": { + "port": [ + { + "name": port_name, + "dest-mac-address": port_info["dest-mac-address"], + "remote-systems-data": port_info["remote-systems-data"] + } + for port_name, port_info in port_data.items() if port_info["remote-systems-data"] + ] + } + } + + return formatted_output + +def extract_chassis_id(chassis_block, subtype_mapping): + if "id" in chassis_block: + id_info = chassis_block["id"] + return subtype_mapping.get(id_info.get("type"), "unknown"), id_info.get("value", "") + + for _, value in chassis_block.items(): + if isinstance(value, dict) and "id" in value: + id_info = value["id"] + return subtype_mapping.get(id_info.get("type"), "unknown"), id_info.get("value", "") + + return "unknown", "" + +def parse_time(time_str): + """Convert LLDP time format to seconds""" + import re + if time_str: + match = re.search(r"(\d+)\s*day[s]*,\s*(\d+):(\d+):(\d+)", time_str) + if match: + days, hours, minutes, seconds = map(int, match.groups()) + return days * 86400 + hours * 3600 + minutes * 60 + seconds + return 0 diff --git a/src/statd/statd.c b/src/statd/statd.c index 062ffeaf..1fa66e41 100644 --- a/src/statd/statd.c +++ b/src/statd/statd.c @@ -41,6 +41,7 @@ #define XPATH_ROUTING_OSPF XPATH_ROUTING_BASE "/ospf" #define XPATH_CONTAIN_BASE "/infix-containers:containers" #define XPATH_DHCP_SERVER_BASE "/infix-dhcp-server:dhcp-server" +#define XPATH_LLDP_BASE "/ieee802-dot1ab-lldp:lldp" TAILQ_HEAD(sub_head, sub); @@ -344,6 +345,8 @@ static int subscribe_to_all(struct statd *statd) return SR_ERR_INTERNAL; if (subscribe(statd, "ietf-system", XPATH_SYSTEM_BASE, sr_generic_cb)) return SR_ERR_INTERNAL; + if (subscribe(statd, "ieee802-dot1ab-lldp", XPATH_LLDP_BASE, sr_generic_cb)) + return SR_ERR_INTERNAL; #ifdef CONTAINERS if (subscribe(statd, "infix-containers", XPATH_CONTAIN_BASE, sr_generic_cb)) return SR_ERR_INTERNAL; diff --git a/test/case/infix_services/Readme.adoc b/test/case/infix_services/Readme.adoc index b8f933eb..a0bdab82 100644 --- a/test/case/infix_services/Readme.adoc +++ b/test/case/infix_services/Readme.adoc @@ -9,6 +9,7 @@ include::mdns_allow_deny/Readme.adoc[] include::lldp_enable_disable/Readme.adoc[] +include::lldp_admin_status/Readme.adoc[] include::ssh_server_config/Readme.adoc[] diff --git a/test/case/infix_services/infix_services.yaml b/test/case/infix_services/infix_services.yaml index c5488a40..84aba211 100644 --- a/test/case/infix_services/infix_services.yaml +++ b/test/case/infix_services/infix_services.yaml @@ -2,6 +2,8 @@ - name: lldp_enable_disable case: lldp_enable_disable/test.py +- name: lldp_admin_status + case: lldp_admin_status/test.py - name: mdns_enable_disable case: mdns_enable_disable/test.py diff --git a/test/case/infix_services/lldp_admin_status/Readme.adoc b/test/case/infix_services/lldp_admin_status/Readme.adoc new file mode 120000 index 00000000..68ed7348 --- /dev/null +++ b/test/case/infix_services/lldp_admin_status/Readme.adoc @@ -0,0 +1 @@ +lldp_admin_status.adoc \ No newline at end of file diff --git a/test/case/infix_services/lldp_admin_status/lldp_admin_status.adoc b/test/case/infix_services/lldp_admin_status/lldp_admin_status.adoc new file mode 100644 index 00000000..9a865876 --- /dev/null +++ b/test/case/infix_services/lldp_admin_status/lldp_admin_status.adoc @@ -0,0 +1,27 @@ +=== LLDP admin status +==== Description +Verify that LLDP admin status is set properly by lldpd + +==== Topology +ifdef::topdoc[] +image::{topdoc}../../test/case/infix_services/lldp_admin_status/topology.svg[LLDP admin status topology] +endif::topdoc[] +ifndef::topdoc[] +ifdef::testgroup[] +image::lldp_admin_status/topology.svg[LLDP admin status topology] +endif::testgroup[] +ifndef::testgroup[] +image::topology.svg[LLDP admin status topology] +endif::testgroup[] +endif::topdoc[] +==== Test sequence +. Set up topology and attach to target DUT +. Enable target interface and enable LLDP +. Verify admin-status: 'rx-only' +. Verify admin-status: 'tx-only' +. Verify admin-status: 'disabled' +. Verify admin-status: 'tx-and-rx' + + +<<< + diff --git a/test/case/infix_services/lldp_admin_status/test.py b/test/case/infix_services/lldp_admin_status/test.py new file mode 100755 index 00000000..2bb7e275 --- /dev/null +++ b/test/case/infix_services/lldp_admin_status/test.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""LLDP admin status + +Verify that LLDP admin status is set properly by lldpd + +""" +import time +import infamy +import infamy.lldp as lldp + +from scapy.all import Ether, sendp +from scapy.contrib.lldp import ( + LLDPDU, LLDPDUChassisID, LLDPDUPortID, LLDPDUTimeToLive, LLDPDUEndOfLLDPDU +) + +def capture_traffic(iface, sec): + with infamy.IsolatedMacVlan(iface) as netns: + sniffer = infamy.Sniffer(netns, "ether proto 0x88cc") + with sniffer: + print("Capturing network traffic ...") + time.sleep(sec) + return sniffer.output() + +def send_lldp_packet(iface, chassis_id, chassis_id_subtype, ttl=3): + eth = Ether(dst="01:80:c2:00:00:0e", type=0x88cc) + lldpdu = eth / LLDPDU() + lldpdu /= LLDPDUChassisID(subtype=chassis_id_subtype, id=chassis_id) + lldpdu /= LLDPDUPortID(subtype=5, id=iface) + lldpdu /= LLDPDUTimeToLive(ttl=ttl) / LLDPDUEndOfLLDPDU() + sendp(lldpdu, iface=iface, verbose=False) + +def verify_neigh_presence(test, target, port, expect_neighbor): + """Verify neighbor (host) presence on the target system""" + neighbors = lldp.get_remote_systems_data(target, port) + if expect_neighbor and not neighbors: + print("Expected LLDP neighbor but found none.") + test.fail() + if not expect_neighbor and neighbors: + print("Unexpected LLDP neighbor found.") + test.fail() + +def verify_admin_status(test, target, port, admin_status, local_capture, remote_detect): + target.put_config_dicts({ + "ieee802-dot1ab-lldp": { + "lldp": { + "port": [{ + "name": target["data"], + "dest-mac-address": "00-00-00-00-00-00", + "admin-status": admin_status + }] + } + } + }) + + rc = capture_traffic(port, 5) + + if local_capture and "LLDP" not in rc.stdout: + test.fail() + if not local_capture and "LLDP" in rc.stdout: + test.fail() + + send_lldp_packet(port, "Chassis ID 007", 7) + verify_neigh_presence(test, target, target["data"], remote_detect) + +with infamy.Test() as test: + with test.step("Set up topology and attach to target DUT"): + env = infamy.Env() + target = env.attach("target", "mgmt") + _, hdata = env.ltop.xlate("host", "data") + + with test.step("Enable target interface and enable LLDP"): + target.put_config_dicts({ + "ietf-interfaces": { + "interfaces": { + "interface": [{ + "name": target["data"], + "enabled": True + }] + } + }, + "ieee802-dot1ab-lldp": { + "lldp": { + "enabled": True, + "message-tx-interval": 1 + } + } + }) + + with test.step("Verify admin-status: 'rx-only'"): + verify_admin_status(test, target, hdata, "rx-only", False, True) + with test.step("Verify admin-status: 'tx-only'"): + verify_admin_status(test, target, hdata, "tx-only", True, False) + with test.step("Verify admin-status: 'disabled'"): + verify_admin_status(test, target, hdata, "disabled", False, False) + with test.step("Verify admin-status: 'tx-and-rx'"): + verify_admin_status(test, target, hdata, "tx-and-rx", True, True) + + test.succeed() diff --git a/test/case/infix_services/lldp_admin_status/topology.dot b/test/case/infix_services/lldp_admin_status/topology.dot new file mode 120000 index 00000000..d004b72d --- /dev/null +++ b/test/case/infix_services/lldp_admin_status/topology.dot @@ -0,0 +1 @@ +../lldp_enable_disable/topology.dot \ No newline at end of file diff --git a/test/case/infix_services/lldp_admin_status/topology.svg b/test/case/infix_services/lldp_admin_status/topology.svg new file mode 100644 index 00000000..5b805b37 --- /dev/null +++ b/test/case/infix_services/lldp_admin_status/topology.svg @@ -0,0 +1,44 @@ + + + + + + +1x2 + + + +host + +host + +mgmt + +data + + + +target + +mgmt + +data + +target + + + +host:mgmt--target:mgmt + + + + +host:data--target:data + +10.0.0.10/24 +10.0.0.1/24 + + + diff --git a/test/case/infix_services/lldp_enable_disable/test.py b/test/case/infix_services/lldp_enable_disable/test.py index 6b9f5ac5..2a6f9345 100755 --- a/test/case/infix_services/lldp_enable_disable/test.py +++ b/test/case/infix_services/lldp_enable_disable/test.py @@ -12,14 +12,7 @@ with infamy.Test() as test: with test.step("Set up topology and attach to target DUT"): env = infamy.Env() target = env.attach("target", "mgmt") - - lldp_link = env.ltop.get_link("host", "target", flt=lambda e: "ieee-mc" in e.get("requires", "").split()) - if not lldp_link: - print("Skipping test: No link providing ieee-mc found in the topology.") - test.skip() - - log_hport, _ = lldp_link - _, phy_hport = env.ltop.xlate("host", log_hport) + _, hdata = env.ltop.xlate("host", "data") with test.step("Enable target interface and disable LLDP"): target.put_config_dicts({ @@ -48,7 +41,7 @@ with infamy.Test() as test: def verify(enabled, sec): """Verify lldp traffic, or no traffic if lldp is disabled.""" - with infamy.IsolatedMacVlan(phy_hport) as netns: + with infamy.IsolatedMacVlan(hdata) as netns: snif = infamy.Sniffer(netns, "ether proto 0x88cc") act = "enabling" if enabled else "disabling" diff --git a/test/infamy/lldp.py b/test/infamy/lldp.py new file mode 100644 index 00000000..bb668309 --- /dev/null +++ b/test/infamy/lldp.py @@ -0,0 +1,40 @@ +""" +Fetch LLDP local system data from remote device. +""" + +reverse_subtype_mapping = { + "chassis-component": 1, + "interface-alias": 2, + "port-component": 3, + "mac-address": 4, + "network-address": 5, + "interface-name": 6, + "local": 7 # 'local' maps to 7 as per LLDP spec +} + +def get_remote_systems_data(target, port): + """Fetch the full remote-systems-data list for a specific port""" + content = target.get_data("/ieee802-dot1ab-lldp:lldp") + + if not content: + return [] + + for port_entry in content.get("lldp", {}).get("port", []): + if port_entry.get("name") == port: + return port_entry.get("remote-systems-data", []) + + return [] + +def get_chassis_ids(target, port): + """Fetch all LLDP chassis IDs for neighbors on a specific port""" + neighbors = get_remote_systems_data(target, port) + return [neighbor.get("chassis-id") for neighbor in neighbors if "chassis-id" in neighbor] + +def get_chassis_ids_subtype(target, port): + """Fetch all LLDP chassis ID subtypes for neighbors on a specific port and convert them to numbers.""" + neighbors = get_remote_systems_data(target, port) + + return [ + reverse_subtype_mapping.get(neighbor.get("chassis-id-subtype"), 0) # Default to 0 if unknown + for neighbor in neighbors if "chassis-id-subtype" in neighbor + ]