|
2 | 2 | from copy import deepcopy |
3 | 3 | from ipaddress import ip_address, ip_interface |
4 | 4 |
|
| 5 | +from ....utils import merge_list |
5 | 6 | from ..schema import schema |
6 | 7 | from .base import OpenWrtConverter |
7 | 8 |
|
@@ -101,7 +102,14 @@ def to_intermediate_loop(self, block, result, index=None): |
101 | 102 | if address: |
102 | 103 | uci_interface.update(address) |
103 | 104 | 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 | + ) |
105 | 113 | i += 1 |
106 | 114 | return result |
107 | 115 |
|
@@ -262,11 +270,12 @@ def __intermediate_vlan(self, uci_name, interface, vlan): |
262 | 270 | "vlan": vid, |
263 | 271 | "device": interface["ifname"], |
264 | 272 | } |
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"]) |
267 | 274 | uci_vlan_interface = { |
268 | 275 | ".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}", |
270 | 279 | "device": "{ifname}.{vid}".format(ifname=interface["ifname"], vid=vid), |
271 | 280 | "proto": "none", |
272 | 281 | } |
@@ -492,12 +501,112 @@ def __intermediate_dns_search(self, uci, address): |
492 | 501 | if dns_search: |
493 | 502 | return " ".join(dns_search) |
494 | 503 |
|
| 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 | + |
495 | 544 | def __is_device_config(self, interface): |
496 | 545 | """ |
497 | 546 | determines if the configuration is a device from NetJSON |
498 | 547 | """ |
499 | 548 | return interface.get("type", None) == "device" |
500 | 549 |
|
| 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 | + |
501 | 610 | def to_netjson_loop(self, block, result, index): |
502 | 611 | _type = block.get(".type") |
503 | 612 | if _type == "globals": |
@@ -551,72 +660,97 @@ def __get_device_config_for_interface(self, interface): |
551 | 660 | device = interface.get("device", "") |
552 | 661 | name = interface.get("name") |
553 | 662 | 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) |
554 | 666 | 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": |
561 | 667 | return device_config |
562 | 668 | # ifname has been renamed to device in OpenWrt 21.02 |
563 | 669 | interface["ifname"] = interface.pop("device") |
564 | 670 | return device_config |
565 | 671 |
|
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): |
573 | 683 | for option in device_config: |
| 684 | + if option == "bridge_21": |
| 685 | + continue |
574 | 686 | # ifname has been renamed to ports in OpenWrt 21.02 bridge |
575 | 687 | if option == "ports": |
576 | 688 | interface["ifname"] = " ".join(device_config[option]) |
577 | 689 | else: |
578 | 690 | interface[option] = device_config[option] |
| 691 | + |
579 | 692 | # Merging L2 options to interface |
580 | 693 | for options in ( |
581 | 694 | self._bridge_interface_options["all"] |
582 | 695 | + self._bridge_interface_options["stp"] |
583 | 696 | + self._bridge_interface_options["igmp_snooping"] |
584 | 697 | ): |
585 | 698 | if options in device_config: |
586 | | - interface[options] = device_config.pop(options) |
| 699 | + interface[options] = device_config.get(options) |
587 | 700 | if device_config.get("type", "").startswith("8021"): |
588 | 701 | interface["ifname"] = "".join(device_config["name"].split(".")[:-1]) |
| 702 | + device_config["consumed"] = True |
589 | 703 | return interface |
590 | 704 |
|
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 |
598 | 727 | return interface |
599 | 728 |
|
600 | 729 | 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") |
616 | 745 | return interface |
617 | 746 |
|
618 | 747 | 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 |
620 | 754 | for option in [ |
621 | 755 | "txqueuelen", |
622 | 756 | "neighreachabletime", |
@@ -647,27 +781,31 @@ def __netjson_device(self, interface): |
647 | 781 | except KeyError: |
648 | 782 | continue |
649 | 783 | 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 |
651 | 788 |
|
652 | 789 | 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") |
653 | 793 | netjson_vlan = {"vlan": int(vlan["vlan"]), "ports": []} |
654 | 794 | for port in vlan.get("ports", []): |
655 | 795 | 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 |
667 | 805 | netjson_vlan["ports"].append(port) |
668 | | - if isinstance(device_config["vlan_filtering"], list): |
| 806 | + try: |
669 | 807 | device_config["vlan_filtering"].append(netjson_vlan) |
670 | | - else: |
| 808 | + except KeyError: |
671 | 809 | device_config["vlan_filtering"] = [netjson_vlan] |
672 | 810 | return |
673 | 811 |
|
|
0 commit comments