Merge pull request #931 from kernelkit/verify-lldp

Verify LLDP administrative states
This commit is contained in:
Ahmed Karic
2025-02-12 12:41:54 +01:00
committed by GitHub
13 changed files with 318 additions and 14 deletions
-4
View File
@@ -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;
+3 -1
View File
@@ -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)
+96
View File
@@ -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
+3
View File
@@ -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;
+1
View File
@@ -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[]
@@ -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
@@ -0,0 +1 @@
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'
<<<
+98
View File
@@ -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()
@@ -0,0 +1 @@
../lldp_enable_disable/topology.dot
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Title: 1x2 Pages: 1 -->
<svg width="424pt" height="55pt"
viewBox="0.00 0.00 424.03 55.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 51)">
<title>1x2</title>
<polygon fill="white" stroke="transparent" points="-4,4 -4,-51 420.03,-51 420.03,4 -4,4"/>
<!-- host -->
<g id="node1" class="node">
<title>host</title>
<polygon fill="none" stroke="black" points="0,-0.5 0,-46.5 100,-46.5 100,-0.5 0,-0.5"/>
<text text-anchor="middle" x="25" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">host</text>
<polyline fill="none" stroke="black" points="50,-0.5 50,-46.5 "/>
<text text-anchor="middle" x="75" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="50,-23.5 100,-23.5 "/>
<text text-anchor="middle" x="75" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
</g>
<!-- target -->
<g id="node2" class="node">
<title>target</title>
<polygon fill="none" stroke="black" points="300.03,-0.5 300.03,-46.5 416.03,-46.5 416.03,-0.5 300.03,-0.5"/>
<text text-anchor="middle" x="325.03" y="-31.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">mgmt</text>
<polyline fill="none" stroke="black" points="300.03,-23.5 350.03,-23.5 "/>
<text text-anchor="middle" x="325.03" y="-8.3" font-family="DejaVu Sans Mono, Book" font-size="14.00">data</text>
<polyline fill="none" stroke="black" points="350.03,-0.5 350.03,-46.5 "/>
<text text-anchor="middle" x="383.03" y="-19.8" font-family="DejaVu Sans Mono, Book" font-size="14.00">target</text>
</g>
<!-- host&#45;&#45;target -->
<g id="edge1" class="edge">
<title>host:mgmt&#45;&#45;target:mgmt</title>
<path fill="none" stroke="lightgrey" stroke-width="2" d="M100,-35.5C100,-35.5 300.03,-35.5 300.03,-35.5"/>
</g>
<!-- host&#45;&#45;target -->
<g id="edge2" class="edge">
<title>host:data&#45;&#45;target:data</title>
<path fill="none" stroke="black" stroke-width="2" d="M100,-11.5C100,-11.5 300.03,-11.5 300.03,-11.5"/>
<text text-anchor="middle" x="262.03" y="-14.9" font-family="DejaVu Serif, Book" font-size="12.00">10.0.0.10/24</text>
<text text-anchor="middle" x="134" y="-14.9" font-family="DejaVu Serif, Book" font-size="12.00">10.0.0.1/24</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -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"
+40
View File
@@ -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
]