Skip to content

Commit dc6acce

Browse files
authored
[fix/change] Fixed bridge VLAN filtering parsing & ula prefix interaction #356 #357
- Bridge-vlan sections were not generated when `ula_prefix` was present. - Several problems with parsing of complex bridge VLAN filtering cases were also resolved. - The documentation now includes an example showing how to override bridge-vlan interfaces. Fixes #356 Fixes #357
1 parent 33e2c0f commit dc6acce

File tree

4 files changed

+328
-77
lines changed

4 files changed

+328
-77
lines changed

docs/source/backends/openwrt.rst

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -775,7 +775,21 @@ The following *configuration dictionary*:
775775
],
776776
},
777777
],
778-
}
778+
},
779+
# Auto-generated interfaces for bridge-vlans (e.g. "br-lan.1", "br-lan.2")
780+
# can be overridden by defining them explicitly in the configuration.
781+
{
782+
"type": "ethernet",
783+
"name": "br-lan.2",
784+
"mtu": 1500,
785+
"mac": "61:4A:A0:D7:3F:0E",
786+
"addresses": [
787+
{
788+
"proto": "dhcp",
789+
"family": "ipv4",
790+
}
791+
],
792+
},
779793
]
780794
}
781795
@@ -805,13 +819,13 @@ Will be rendered as follows:
805819
list ports 'lan3:u*'
806820
option vlan '2'
807821
808-
config interface 'vlan_br_lan_1'
822+
config interface 'br_lan_1'
809823
option device 'br-lan.1'
810824
option proto 'none'
811825
812-
config interface 'vlan_br_lan_2'
826+
config interface 'br_lan_2'
813827
option device 'br-lan.2'
814-
option proto 'none'
828+
option proto 'dhcp'
815829
816830
config interface 'br_lan'
817831
option device 'br-lan'

netjsonconfig/backends/openwrt/converters/interfaces.py

Lines changed: 193 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from copy import deepcopy
33
from ipaddress import ip_address, ip_interface
44

5+
from ....utils import merge_list
56
from ..schema import schema
67
from .base import OpenWrtConverter
78

@@ -101,7 +102,14 @@ def to_intermediate_loop(self, block, result, index=None):
101102
if address:
102103
uci_interface.update(address)
103104
result.setdefault("network", [])
104-
result["network"].append(self.sorted_dict(uci_interface))
105+
# Use merge_list instead of appending the interface directly
106+
# to allow users to override the auto-generated interface
107+
# (e.g., when using VLAN filtering on a bridge).
108+
result["network"] = merge_list(
109+
result["network"],
110+
[self.sorted_dict(uci_interface)],
111+
identifiers=[".name", ".type"],
112+
)
105113
i += 1
106114
return result
107115

@@ -262,11 +270,12 @@ def __intermediate_vlan(self, uci_name, interface, vlan):
262270
"vlan": vid,
263271
"device": interface["ifname"],
264272
}
265-
if uci_name == self._get_uci_name(interface["ifname"]):
266-
uci_vlan[".name"] = "vlan_{}".format(uci_vlan[".name"])
273+
uci_vlan[".name"] = "vlan_{}".format(uci_vlan[".name"])
267274
uci_vlan_interface = {
268275
".type": "interface",
269-
".name": uci_vlan[".name"],
276+
# To avoid conflicts, auto-generated interfaces are prefixed with "if"
277+
# because UCI does not support multiple blocks with the same name.
278+
".name": f"{uci_name}_{vid}",
270279
"device": "{ifname}.{vid}".format(ifname=interface["ifname"], vid=vid),
271280
"proto": "none",
272281
}
@@ -492,12 +501,112 @@ def __intermediate_dns_search(self, uci, address):
492501
if dns_search:
493502
return " ".join(dns_search)
494503

504+
def to_netjson(self, remove_block=True):
505+
"""
506+
Override the base ``to_netjson`` method to correctly handle
507+
OpenWrt ≥ 21 (DSA) configurations.
508+
509+
On OpenWrt < 21 (pre-DSA), each ``interface`` block contained a complete
510+
description of that interface. Starting with OpenWrt 21 (DSA), key
511+
settings are split across multiple blocks (``device``, ``bridge-vlan``,
512+
and ``interface``). This means that individual blocks are no longer
513+
self-contained and must be parsed in a specific order to produce a
514+
valid and consistent NetJSON representation.
515+
516+
Parsing order:
517+
1. Parse all ``device`` and ``bridge-vlan`` blocks.
518+
2. Parse ``interface`` blocks not referencing VLAN interfaces.
519+
3. Add fallback interfaces for any unconsumed ``device_config``.
520+
4. Parse remaining ``interface`` blocks (including VLAN interfaces).
521+
"""
522+
523+
result = OrderedDict()
524+
# Parse device blocks
525+
result = self.__process_blocks(
526+
result,
527+
remove_block,
528+
self.__skip_non_device_block,
529+
self.__process_device_block,
530+
)
531+
# Parse non VLAN interfaces
532+
result = self.__process_blocks(result, remove_block, self.__skip_vlan_block)
533+
# Add fallback interfaces before parsing VLAN interfaces.
534+
# This ensures that the primary bridge/device interfaces are already present so
535+
# subsequently parsed VLAN/interface blocks can correctly reference or
536+
# override them. This preserves the required ordering for producing
537+
# a consistent NetJSON -> UCI mapping.
538+
result = self.__add_fallback_interfaces(result)
539+
# Parse remaining interfaces
540+
result = self.__process_blocks(result, remove_block, self.should_skip_block)
541+
542+
return result
543+
495544
def __is_device_config(self, interface):
496545
"""
497546
determines if the configuration is a device from NetJSON
498547
"""
499548
return interface.get("type", None) == "device"
500549

550+
def __skip_non_device_block(self, block):
551+
return self.should_skip_block(block) or (
552+
not block.get("bridge_21", None) and not self.__is_device_config(block)
553+
)
554+
555+
def __skip_vlan_block(self, block):
556+
return self.should_skip_block(block) or (
557+
block.get("device")
558+
and "." in block["device"]
559+
and block["device"].split(".")[0] in self._device_config
560+
)
561+
562+
def __process_blocks(self, result, remove_block, skip_fn, handler_fn=None):
563+
intermediate_data = self.to_netjson_clean(
564+
self.intermediate_data[self.intermediate_key]
565+
)
566+
handler_fn = handler_fn or self.to_netjson_loop
567+
for index, block in enumerate(list(intermediate_data), start=1):
568+
if skip_fn(block):
569+
continue
570+
if remove_block:
571+
self.intermediate_data[self.intermediate_key].remove(block)
572+
result = handler_fn(block, result, index)
573+
return result
574+
575+
def __process_device_block(self, block, result, index):
576+
if block.get("type") == "bridge-vlan":
577+
device_name = block.get("device")
578+
if device_name and device_name not in self._device_config:
579+
self._device_config[device_name] = {}
580+
self.__netjson_vlan(block, self._device_config[device_name])
581+
else:
582+
self.__netjson_device(block)
583+
return result
584+
585+
def __add_fallback_interfaces(self, result):
586+
"""Add fallback interfaces for any unconsumed device configs."""
587+
588+
def make_fallback_interface(name, config):
589+
interface_name = config.get(".name", name)
590+
if interface_name.startswith("device_"):
591+
interface_name = interface_name[7:] # len("device_") = 7
592+
return OrderedDict(
593+
{
594+
".type": "interface",
595+
".name": interface_name,
596+
"device": name,
597+
"proto": "none",
598+
}
599+
)
600+
601+
index = len(result) + 1
602+
for name, device_config in self._device_config.copy().items():
603+
if device_config.get("consumed", False):
604+
continue
605+
interface = make_fallback_interface(name, device_config)
606+
result = self.to_netjson_loop(interface, result, index)
607+
index += 1
608+
return result
609+
501610
def to_netjson_loop(self, block, result, index):
502611
_type = block.get(".type")
503612
if _type == "globals":
@@ -551,72 +660,97 @@ def __get_device_config_for_interface(self, interface):
551660
device = interface.get("device", "")
552661
name = interface.get("name")
553662
device_config = self._device_config.get(device, self._device_config.get(name))
663+
if not device_config and "." in device:
664+
cleaned_device, _, _ = device.rpartition(".")
665+
device_config = self._device_config.get(cleaned_device)
554666
if not device_config:
555-
if "." in device:
556-
cleaned_device, _, _ = device.rpartition(".")
557-
device_config = self._device_config.get(cleaned_device)
558-
if not device_config:
559-
return device_config
560-
if interface.get("type") == "bridge-vlan":
561667
return device_config
562668
# ifname has been renamed to device in OpenWrt 21.02
563669
interface["ifname"] = interface.pop("device")
564670
return device_config
565671

566-
def __update_interface_device_config(self, interface, device_config):
567-
if interface.get("type") == "bridge-vlan":
568-
return self.__netjson_vlan(interface, device_config)
569-
interface = self._handle_bridge_vlan(interface, device_config)
570-
if not interface:
571-
return
572-
if device_config.pop("bridge_21", None):
672+
def __add_options_from_device_config(self, interface, device_config):
673+
if device_config.get("bridge_21", None) and interface.get(
674+
"ifname"
675+
) != device_config.get("name"):
676+
interface[".name"] = self._get_uci_name(interface["ifname"])
677+
return interface
678+
679+
if device_config.get("consumed", False):
680+
return interface
681+
682+
if device_config.get("bridge_21", None):
573683
for option in device_config:
684+
if option == "bridge_21":
685+
continue
574686
# ifname has been renamed to ports in OpenWrt 21.02 bridge
575687
if option == "ports":
576688
interface["ifname"] = " ".join(device_config[option])
577689
else:
578690
interface[option] = device_config[option]
691+
579692
# Merging L2 options to interface
580693
for options in (
581694
self._bridge_interface_options["all"]
582695
+ self._bridge_interface_options["stp"]
583696
+ self._bridge_interface_options["igmp_snooping"]
584697
):
585698
if options in device_config:
586-
interface[options] = device_config.pop(options)
699+
interface[options] = device_config.get(options)
587700
if device_config.get("type", "").startswith("8021"):
588701
interface["ifname"] = "".join(device_config["name"].split(".")[:-1])
702+
device_config["consumed"] = True
589703
return interface
590704

591-
def _handle_bridge_vlan(self, interface, device_config):
592-
if "." in interface.get("ifname", ""):
593-
_, _, vlan_id = interface["ifname"].rpartition(".")
594-
if device_config.get("vlan_filtering", []):
595-
for vlan in device_config["vlan_filtering"]:
596-
if vlan["vlan"] == int(vlan_id):
597-
return
705+
def _handle_bridge_vlan_interface(self, interface, device_config):
706+
ifname = interface.get("ifname", "")
707+
if "." not in ifname:
708+
# no VLAN suffix, nothing to do
709+
return interface
710+
711+
_, _, vlan_id = interface["ifname"].rpartition(".")
712+
for vlan in device_config.get("vlan_filtering", []):
713+
if vlan["vlan"] == int(vlan_id):
714+
if interface.get("proto") == "none" and interface.keys() == {
715+
".type",
716+
".name",
717+
"ifname",
718+
"proto",
719+
}:
720+
# Return None to ignore this auto-generated interface.
721+
return
722+
# Auto-generated interface is being overridden by user.
723+
# Override the ".name" to avoid setting "network" field
724+
# in NetJSON output.
725+
interface[".name"] = self._get_uci_name(interface["ifname"])
726+
break
598727
return interface
599728

600729
def __netjson_dsa_interface(self, interface):
601-
if self.__is_device_config(interface) or interface.get("bridge_21", None):
602-
self.__netjson_device(interface)
603-
else:
604-
device_config = self.__get_device_config_for_interface(interface)
605-
if device_config:
606-
interface = self.__update_interface_device_config(
607-
interface, device_config
608-
)
609-
# if device_config is empty but the interface references it
610-
elif "device" in interface and "ifname" not in interface:
611-
# .name may have '.' substituted with _,
612-
# which will yield unexpected results
613-
# for this reason we use the name stored
614-
# in the device property before removing it
615-
interface["ifname"] = interface.pop("device")
730+
# Device configs are now handled in the first pass and removed,
731+
# so we only process actual interface blocks here
732+
device_config = self.__get_device_config_for_interface(interface)
733+
if device_config:
734+
interface = self._handle_bridge_vlan_interface(interface, device_config)
735+
if not interface:
736+
return
737+
interface = self.__add_options_from_device_config(interface, device_config)
738+
# if device_config is empty but the interface references it
739+
elif "device" in interface and "ifname" not in interface:
740+
# .name may have '.' substituted with _,
741+
# which will yield unexpected results
742+
# for this reason we use the name stored
743+
# in the device property before removing it
744+
interface["ifname"] = interface.pop("device")
616745
return interface
617746

618747
def __netjson_device(self, interface):
619-
interface["network"] = interface.pop(".name").lstrip("device_")
748+
name = interface.pop(".name")
749+
# Remove "device_" prefix if present
750+
if name.startswith("device_"):
751+
interface["network"] = name[7:] # len("device_") = 7
752+
else:
753+
interface["network"] = name
620754
for option in [
621755
"txqueuelen",
622756
"neighreachabletime",
@@ -647,27 +781,31 @@ def __netjson_device(self, interface):
647781
except KeyError:
648782
continue
649783
name = interface.get("name")
650-
self._device_config[name] = interface
784+
try:
785+
self._device_config[name].update(interface)
786+
except KeyError:
787+
self._device_config[name] = interface
651788

652789
def __netjson_vlan(self, vlan, device_config):
790+
# Clean up VLAN filtering option from the native config
791+
if device_config.get("vlan_filtering") == "1":
792+
device_config.pop("vlan_filtering")
653793
netjson_vlan = {"vlan": int(vlan["vlan"]), "ports": []}
654794
for port in vlan.get("ports", []):
655795
port_config = port.split(":")
656-
port = {"ifname": port_config[0]}
657-
tagging = port_config[1][0]
658-
pvid = False
659-
if len(port_config[1]) > 1:
660-
pvid = True
661-
port.update(
662-
{
663-
"tagging": tagging,
664-
"primary_vid": pvid,
665-
}
666-
)
796+
port = {
797+
"ifname": port_config[0],
798+
"tagging": "u",
799+
"primary_vid": False,
800+
}
801+
if len(port_config) > 1:
802+
port["tagging"] = port_config[1][0]
803+
if len(port_config[1]) > 1:
804+
port["primary_vid"] = True
667805
netjson_vlan["ports"].append(port)
668-
if isinstance(device_config["vlan_filtering"], list):
806+
try:
669807
device_config["vlan_filtering"].append(netjson_vlan)
670-
else:
808+
except KeyError:
671809
device_config["vlan_filtering"] = [netjson_vlan]
672810
return
673811

netjsonconfig/backends/openwrt/parser.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,12 @@ def _set_uci_block_type(self, block):
9191
# process bridges using the interface converter,
9292
# therefore we need to update block type here.
9393
if block[".type"] in ["device", "bridge-vlan"]:
94-
if block.get("type") in ["bridge", "8021q", "8021ad"]:
94+
if (
95+
block.get("type") in ["bridge", "8021q", "8021ad"]
96+
or block[".type"] == "bridge-vlan"
97+
):
9598
block["bridge_21"] = True
96-
elif block[".type"] == "bridge-vlan":
99+
if block[".type"] == "bridge-vlan":
97100
block["type"] = "bridge-vlan"
98101
elif not block.get("type", None):
99102
block["type"] = "device"

0 commit comments

Comments
 (0)