diff --git a/docs/source/backends/openwrt.rst b/docs/source/backends/openwrt.rst index 26aa9a146..f774f24b2 100644 --- a/docs/source/backends/openwrt.rst +++ b/docs/source/backends/openwrt.rst @@ -641,8 +641,42 @@ key name type default allowed values ``max_age`` integer ``20`` timeout in seconds until topology updates on link loss +``vlan_filtering`` list ``[]``` a list of ``dict ({})`` + defining VLANs for the + bridge + + Refer to the :ref:`VLAN + options table + ` below + for a list of available + options. =========================== ======= ========= ============================ +.. _bridge_vlan_options: + +VLAN options: + +========= ======= ======================================================= +key name type allowed values +========= ======= ======================================================= +``vlan`` integer VLAN ID +``ports`` list A list of ``dict`` defining interfaces participating in + the VLAN + + =============== ======= =============================== + key name type allowed values + =============== ======= =============================== + ``ifname`` string interface name (this interface + should be a bridge member) + ``tagging`` string whether the port is tagged + (``t``) or untagged (``u``) + ``primary_vid`` boolean whether the current VLAN should + be used for all untagged + incoming traffic on this + interface + =============== ======= =============================== +========= ======= ======================================================= + Bridge interface example ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -700,6 +734,89 @@ Will be rendered as follows: option netmask '255.255.255.0' option proto 'static' +Using VLAN Filtering on a Bridge +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following *configuration dictionary*: + +.. code-block:: python + + { + "interfaces": [ + { + "type": "bridge", + "bridge_members": ["lan1", "lan2", "lan3"], + "name": "br-lan", + "vlan_filtering": [ + { + "vlan": 1, + "ports": [ + { + "ifname": "lan1", + "tagging": "t", + "primary_vid": True, + }, + {"ifname": "lan2", "tagging": "t"}, + ], + }, + { + "vlan": 2, + "ports": [ + { + "ifname": "lan1", + "tagging": "t", + "primary_vid": False, + }, + { + "ifname": "lan3", + "tagging": "u", + "primary_vid": True, + }, + ], + }, + ], + } + ] + } + +Will be rendered as follows: + +.. code-block:: + + package network + + config device 'device_br_lan' + option name 'br-lan' + list ports 'lan1' + list ports 'lan2' + list ports 'lan3' + option type 'bridge' + option vlan_filtering '1' + + config bridge-vlan 'vlan_br_lan_1' + option device 'br-lan' + list ports 'lan1:t*' + list ports 'lan2:t' + option vlan '1' + + config bridge-vlan 'vlan_br_lan_2' + option device 'br-lan' + list ports 'lan1:t' + list ports 'lan3:u*' + option vlan '2' + + config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' + + config interface 'vlan_br_lan_2' + option device 'br-lan.2' + option proto 'none' + + config interface 'br_lan' + option device 'br-lan' + option proto 'none' + Wireless settings ----------------- @@ -1712,6 +1829,112 @@ Will be rendered as follows: option signalrate '5' option username 'user123' +VLAN 802.1q / VLAN 802.1ad settings +----------------------------------- + +.. note:: + + The configuration setting for **VLAN 802.1q** and **VLAN 802.1ad** are + exactly same, except the ``type`` setting. Hence, the documentation + only explains **VLAN 802.1q**. + +Interfaces of type ``vlan_8021q`` contain a few options that are specific +to VLAN 802.1q interfaces. + +These are the ``OpenWrt`` backend NetJSON extensions for VLAN 802.1q +interfaces: + +======================= ======= ============== =========================== +key name type default allowed values +======================= ======= ============== =========================== +``type`` string ``vlan_8021q`` type of interface + (``vlan_8021ad`` for VLAN + 802.1ad) +``vid`` integer empty VLAN ID +``ingress_qos_mapping`` string empty Defines a mapping of VLAN + header priority to the + Linux internal packet + priority on incoming frames +``egress_qos_mapping`` string empty Defines a mapping of Linux + internal packet priority to + VLAN header priority but + for outgoing frames +======================= ======= ============== =========================== + +VLAN 802.1q example +~~~~~~~~~~~~~~~~~~~ + +The following *configuration dictionary*: + +.. code-block:: python + + { + "interfaces": [ + { + "type": "8021q", + "vid": 1, + "name": "br-lan", + "mac": "E8:6A:64:3E:4A:3A", + "mtu": 1500, + "ingress_qos_mapping": ["1:1"], + "egress_qos_mapping": ["2:2"], + } + ] + } + +Will be rendered as follows: + +.. code-block:: text + + package network + + config device 'device_br_lan_1' + list egress_qos_mapping '2:2' + option ifname 'br-lan' + list ingress_qos_mapping '1:1' + option macaddr 'E8:6A:64:3E:4A:3A' + option mtu '1500' + option name 'br-lan.1' + option type '8021q' + option vid '1' + + config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' + +VLAN 802.1ad example +~~~~~~~~~~~~~~~~~~~~ + +The following *configuration dictionary*: + +.. code-block:: python + + { + "interfaces": [ + { + "type": "8021ad", + "vid": 6, + "name": "eth0", + } + ] + } + +Will be rendered as follows: + +.. code-block:: text + + package network + + config device 'device_eth0_6' + option ifname 'eth0' + option name 'eth0.6' + option type '8021ad' + option vid '6' + + config interface 'vlan_eth0_6' + option device 'eth0.6' + option proto 'none' + Radio settings -------------- diff --git a/netjsonconfig/backends/openwrt/converters/interfaces.py b/netjsonconfig/backends/openwrt/converters/interfaces.py index a4eef1eef..c07681400 100644 --- a/netjsonconfig/backends/openwrt/converters/interfaces.py +++ b/netjsonconfig/backends/openwrt/converters/interfaces.py @@ -30,15 +30,21 @@ class Interfaces(OpenWrtConverter): ], 'all': ['vlan_filtering', 'macaddr', 'mtu'], } - _device_config = {} _custom_protocols = ['ppp'] _interface_dsa_types = [ 'loopback', 'ethernet', 'bridge', 'wireless', + '8021q', + '8021ad', ] + def __init__(self, backend): + super().__init__(backend) + self._device_config = {} + self._bridge_vlan_config_uci = [] + def __set_dsa_interface(self, interface): """ sets dsa interface property to manage new syntax introduced @@ -48,6 +54,8 @@ def __set_dsa_interface(self, interface): self.dsa and interface.get('proto', None) not in self._custom_protocols and interface.get('type', None) in self._interface_dsa_types + and interface.get('ifname', interface.get('device')) + not in self._bridge_vlan_config_uci ) def to_intermediate_loop(self, block, result, index=None): @@ -56,10 +64,22 @@ def to_intermediate_loop(self, block, result, index=None): interface = self.__intermediate_interface(block, uci_name) self.__set_dsa_interface(interface) if self.dsa_interface: + vlan_list = interface.pop('vlan_filtering', []) + if vlan_list: + interface['vlan_filtering'] = True uci_device = self.__intermediate_device(interface, address_list) if uci_device: result.setdefault('network', []) result['network'].append(self.sorted_dict(uci_device)) + uci_vlan_interfaces = [] + for vlan in vlan_list: + uci_vlan, uci_vlan_interface = self.__intermediate_vlan( + uci_name, interface, vlan + ) + result['network'].append(self.sorted_dict(uci_vlan)) + uci_vlan_interfaces.append(uci_vlan_interface) + for uci_interface in uci_vlan_interfaces: + result['network'].append(self.sorted_dict(uci_interface)) # create one or more "config interface" UCI blocks i = 1 for address in address_list: @@ -155,8 +175,6 @@ def __intermediate_interface(self, interface, uci_name): """ interface.update({'.type': 'interface', '.name': uci_name}) interface['ifname'] = interface.pop('name') - if 'network' in interface: - del interface['network'] if 'mac' in interface: # mac address of wireless interface must # be set in /etc/config/wireless, therfore @@ -179,6 +197,20 @@ def __intermediate_interface(self, interface, uci_name): method = getattr(self, f'_intermediate_{type_}', None) if method: interface = method(interface) + self._check_bridge_vlan(interface) + if 'network' in interface: + del interface['network'] + return interface + + def _check_bridge_vlan(self, interface): + if self.dsa: + if ( + '.' in interface.get('ifname', '') + and interface['ifname'] in self._bridge_vlan_config_uci + ): + # Cleans L2 options from the interface + self._add_l2_options({}, interface) + interface['device'] = interface.pop('ifname') return interface def _intermediate_modem_manager(self, interface): @@ -198,6 +230,19 @@ def _intermediate_vxlan(self, interface): interface['vid'] = interface.pop('vni') return interface + def _intermediate_8021_vlan(self, interface): + interface['name'] = '{}.{}'.format(interface['ifname'], interface['vid']) + interface['.name'] = interface.get( + 'network', 'vlan_{}_{}'.format(interface['.name'], interface['vid']) + ) + return interface + + def _intermediate_8021q(self, interface): + return self._intermediate_8021_vlan(interface) + + def _intermediate_8021ad(self, interface): + return self._intermediate_8021_vlan(interface) + _address_keys = ['address', 'mask', 'family', 'gateway'] def __intermediate_address(self, address): @@ -209,6 +254,39 @@ def __intermediate_address(self, address): del address[key] return address + def __intermediate_vlan(self, uci_name, interface, vlan): + vid = vlan['vlan'] + uci_vlan = { + '.type': 'bridge-vlan', + '.name': f'{uci_name}_{vid}', + 'vlan': vid, + 'device': interface['ifname'], + } + if uci_name == self._get_uci_name(interface['ifname']): + uci_vlan['.name'] = 'vlan_{}'.format(uci_vlan['.name']) + uci_vlan_interface = { + '.type': 'interface', + '.name': uci_vlan['.name'], + 'device': '{ifname}.{vid}'.format(ifname=interface['ifname'], vid=vid), + 'proto': 'none', + } + if 'ports' in vlan: + uci_vlan['ports'] = [] + for port in vlan.get('ports'): + tagging = '' + pvid = '' + if port.get('tagging'): + tagging = ':{tagging}'.format(tagging=port['tagging']) + if port.get('primary_vid'): + pvid = '*' + uci_vlan['ports'].append( + '{ifname}{tagging}{pvid}'.format( + ifname=port['ifname'], tagging=tagging, pvid=pvid + ) + ) + self._bridge_vlan_config_uci.append(uci_vlan_interface['device']) + return uci_vlan, uci_vlan_interface + def __intermediate_device(self, interface, address_list): """ Converts NetJSON bridge to intermediate @@ -228,8 +306,21 @@ def __intermediate_device(self, interface, address_list): # Add 'device' option in related interface configuration if not interface.get('device', None): interface['device'] = device['name'] - - if interface['type'] != 'bridge': + interface_type = interface['type'] + if interface_type.startswith('8021'): + device.update( + { + 'type': interface['type'], + 'vid': interface.pop('vid'), + 'name': interface.pop('name'), + '.name': 'device_{}'.format(interface['.name'].lstrip('vlan_')), + 'ifname': interface.pop('ifname'), + 'ingress_qos_mapping': interface.pop('ingress_qos_mapping', []), + 'egress_qos_mapping': interface.pop('egress_qos_mapping', []), + } + ) + interface['device'] = device['name'] + if interface_type != 'bridge': # A non-bridge interface that contains L2 options. if device == base: return {} @@ -419,16 +510,22 @@ def to_netjson_loop(self, block, result, index): elif _type == 'interface': if self.dsa: block = self.__netjson_dsa_interface(block) - if not self.__is_device_config(block) and not block.get('bridge_21', None): + if ( + block + and not self.__is_device_config(block) + and not block.get('bridge_21', None) + ): interface = self.__netjson_interface(block) - self.__netjson_dns(interface, result) - result.setdefault('interfaces', []) - result['interfaces'].append(interface) + if interface: + self.__netjson_dns(interface, result) + result.setdefault('interfaces', []) + result['interfaces'].append(interface) return result def __netjson_interface(self, interface): del interface['.type'] interface['network'] = interface.pop('.name') + interface['device_name'] = interface.get('name') interface['name'] = interface.pop('ifname', interface['network']) interface['type'] = self.__netjson_type(interface) interface = self.__netjson_addresses(interface) @@ -438,6 +535,8 @@ def __netjson_interface(self, interface): interface['disabled'] = interface.pop('enabled') == '0' if 'mtu' in interface: interface['mtu'] = int(interface['mtu']) + if 'vid' in interface: + interface['vid'] = int(interface['vid']) if 'macaddr' in interface: interface['mac'] = interface.pop('macaddr') if interface['network'] == self._get_uci_name(interface['name']): @@ -449,38 +548,64 @@ def __netjson_interface(self, interface): return interface def __get_device_config_for_interface(self, interface): - device = interface.get('device') + device = interface.get('device', '') name = interface.get('name') device_config = self._device_config.get(device, self._device_config.get(name)) if not device_config: + if '.' in device: + cleaned_device, _, _ = device.rpartition('.') + device_config = self._device_config.get(cleaned_device) + if not device_config: + return device_config + if interface.get('type') == 'bridge-vlan': return device_config # ifname has been renamed to device in OpenWrt 21.02 interface['ifname'] = interface.pop('device') return device_config + def __update_interface_device_config(self, interface, device_config): + if interface.get('type') == 'bridge-vlan': + return self.__netjson_vlan(interface, device_config) + interface = self._handle_bridge_vlan(interface, device_config) + if not interface: + return + if device_config.pop('bridge_21', None): + for option in device_config: + # ifname has been renamed to ports in OpenWrt 21.02 bridge + if option == 'ports': + interface['ifname'] = ' '.join(device_config[option]) + else: + interface[option] = device_config[option] + # Merging L2 options to interface + for options in ( + self._bridge_interface_options['all'] + + self._bridge_interface_options['stp'] + + self._bridge_interface_options['igmp_snooping'] + ): + if options in device_config: + interface[options] = device_config.pop(options) + if device_config.get('type', '').startswith('8021'): + interface['ifname'] = ''.join(device_config['name'].split('.')[:-1]) + return interface + + def _handle_bridge_vlan(self, interface, device_config): + if '.' in interface.get('ifname', ''): + _, _, vlan_id = interface['ifname'].rpartition('.') + if device_config.get('vlan_filtering', []): + for vlan in device_config['vlan_filtering']: + if vlan['vlan'] == int(vlan_id): + return + return interface + def __netjson_dsa_interface(self, interface): if self.__is_device_config(interface) or interface.get('bridge_21', None): self.__netjson_device(interface) else: device_config = self.__get_device_config_for_interface(interface) if device_config: - if device_config.pop('bridge_21', None): - for option in device_config: - if 'name' in option: - continue - # ifname has been renamed to ports in OpenWrt 21.02 bridge - if option == 'ports': - interface['ifname'] = ' '.join(device_config[option]) - else: - interface[option] = device_config[option] - # Merging L2 options to interface - for option in ( - self._bridge_interface_options['all'] - + self._bridge_interface_options['stp'] - + self._bridge_interface_options['igmp_snooping'] - ): - if option in device_config: - interface[option] = device_config.pop(option) + interface = self.__update_interface_device_config( + interface, device_config + ) # if device_config is empty but the interface references it elif 'device' in interface and 'ifname' not in interface: # .name may have '.' substituted with _, @@ -524,16 +649,44 @@ def __netjson_device(self, interface): name = interface.get('name') self._device_config[name] = interface + def __netjson_vlan(self, vlan, device_config): + netjson_vlan = {'vlan': int(vlan['vlan']), 'ports': []} + for port in vlan.get('ports', []): + port_config = port.split(':') + port = {'ifname': port_config[0]} + tagging = port_config[1][0] + pvid = False + if len(port_config[1]) > 1: + pvid = True + port.update( + { + 'tagging': tagging, + 'primary_vid': pvid, + } + ) + netjson_vlan['ports'].append(port) + if isinstance(device_config['vlan_filtering'], list): + device_config['vlan_filtering'].append(netjson_vlan) + else: + device_config['vlan_filtering'] = [netjson_vlan] + return + def __netjson_type(self, interface): - if 'type' in interface and interface['type'] == 'bridge': - interface['bridge_members'] = interface['name'].split() - interface['name'] = 'br-{0}'.format(interface['network']) - # cleanup automatically generated "br_" network prefix - interface['name'] = interface['name'].replace('br_', '') - self.__netjson_bridge_typecast(interface) - if interface.pop('bridge_empty', None) == '1': - interface['bridge_members'] = [] - return 'bridge' + device_name = interface.pop('device_name', None) + if 'type' in interface: + if interface['type'] == 'bridge': + interface['bridge_members'] = interface['name'].split() + interface['name'] = device_name or interface['network'] + if not interface['name'].startswith('br-'): + interface['name'] = 'br-{0}'.format(interface['name']) + # cleanup automatically generated "br_" network prefix + interface['name'] = interface['name'].replace('br_', '') + self.__netjson_bridge_typecast(interface) + if interface.pop('bridge_empty', None) == '1': + interface['bridge_members'] = [] + return 'bridge' + if interface['type'].startswith('802'): + return interface['type'] if interface['name'] in ['lo', 'lo0', 'loopback']: return 'loopback' return 'ethernet' @@ -543,7 +696,6 @@ def __netjson_bridge_typecast(self, interface): 'stp', 'igmp_snooping', 'multicast_querier', - 'vlan_filtering', ]: if option in interface: interface[option] = interface[option] == '1' diff --git a/netjsonconfig/backends/openwrt/openwrt.py b/netjsonconfig/backends/openwrt/openwrt.py index 36a788ba9..956d7f1ae 100644 --- a/netjsonconfig/backends/openwrt/openwrt.py +++ b/netjsonconfig/backends/openwrt/openwrt.py @@ -1,5 +1,6 @@ -from jsonschema.exceptions import ValidationError +from jsonschema import ValidationError as JsonSchemaError +from ...exceptions import ValidationError from ..base.backend import BaseBackend from ..vxlan.vxlan_wireguard import VxlanWireguard from ..wireguard.wireguard import Wireguard @@ -54,6 +55,27 @@ def __init__( self.dsa = dsa super().__init__(config, native, templates, context) + def validate(self): + self._validate_radios() + super().validate() + # When VLAN filtering is enabled on a "bridge" interfaces, + # primary VLAN ID can be set for only one VLAN. + for index, interface in enumerate(self.config.get('interfaces', [])): + pvid_mapping = [] + if interface.get('type') != 'bridge': + continue + for vlan in interface.get('vlan_filtering', []): + for port in vlan.get('ports', []): + if port.get('primary_vid', False): + if port['ifname'] in pvid_mapping: + raise ValidationError( + JsonSchemaError( + f'Invalid configuration triggered by "#/interfaces/{index}"' + ' says: Primary VID can be set only one VLAN for a port.' + ) + ) + pvid_mapping.append(port['ifname']) + def _generate_contents(self, tar): """ Adds configuration files to tarfile instance. @@ -149,10 +171,6 @@ def zerotier_auto_client(cls, **kwargs): data = ZeroTier.auto_client(**kwargs) return {'zerotier': [data]} - def validate(self): - self._validate_radios() - super().validate() - def _validate_radios(self): # We use "hwmode" or "band" property of "radio" configuration # to predict the radio frequency. If both of these @@ -168,7 +186,7 @@ def _validate_radios(self): and radio.get('hwmode') is None and radio.get('channel') == 0 ): - raise ValidationError( + raise JsonSchemaError( '"channel" cannot be set to "auto" when' ' "hwmode" or "band" property is not configured.' ) diff --git a/netjsonconfig/backends/openwrt/parser.py b/netjsonconfig/backends/openwrt/parser.py index 248411948..a26d64c16 100644 --- a/netjsonconfig/backends/openwrt/parser.py +++ b/netjsonconfig/backends/openwrt/parser.py @@ -81,15 +81,20 @@ def _get_uci_blocks(self, text): # list options else: block[key] = block.get(key, []) + [value] - # The new bridge syntax of OpenWrt moved "bridges" - # under "device" config_type. netjsonconfig - # process bridges using the interface converter, - # therefore we need to update block type here. - if block['.type'] == 'device': - block['.type'] = 'interface' - if block.get('type') == 'bridge': - block['bridge_21'] = True - else: - block['type'] = 'device' + self._set_uci_block_type(block) blocks.append(sorted_dict(block)) return blocks + + def _set_uci_block_type(self, block): + # The new bridge syntax of OpenWrt moved "bridges" + # under "device" config_type. netjsonconfig + # process bridges using the interface converter, + # therefore we need to update block type here. + if block['.type'] in ['device', 'bridge-vlan']: + if block.get('type') in ['bridge', '8021q', '8021ad']: + block['bridge_21'] = True + elif block['.type'] == 'bridge-vlan': + block['type'] = 'bridge-vlan' + elif not block.get('type', None): + block['type'] = 'device' + block['.type'] = 'interface' diff --git a/netjsonconfig/backends/openwrt/schema.py b/netjsonconfig/backends/openwrt/schema.py index c8d1d4865..6c275fd18 100644 --- a/netjsonconfig/backends/openwrt/schema.py +++ b/netjsonconfig/backends/openwrt/schema.py @@ -7,13 +7,14 @@ from ..wireguard.schema import base_wireguard_schema from .timezones import timezones +QOS_MAPPING_PATTERN = "^[0-9]\d*:[0-9]\d*$" + default_radio_driver = "mac80211" wireguard = base_wireguard_schema["properties"]["wireguard"]["items"]["properties"] wireguard_peers = wireguard["peers"]["items"]["properties"] interface_settings = default_schema["definitions"]["interface_settings"]["properties"] - schema = merge_config( default_schema, { @@ -30,6 +31,49 @@ } } }, + "vlan_interface_settings": { + "properties": { + "name": {"title": "Base device"}, + "vid": { + "type": "integer", + "title": "VLAN ID", + "propertyOrder": 2, + "minimum": 0, + }, + "ingress_qos_mapping": { + "type": "array", + "title": "Ingress QoS mapping", + "description": ( + "Defines a mapping of VLAN header priority to the Linux" + " internal packet priority on incoming frames" + ), + "uniqueItems": True, + "additionalItems": False, + "items": { + "title": "Mapping", + "type": "string", + "pattern": QOS_MAPPING_PATTERN, + }, + "propertyOrder": 18, + }, + "egress_qos_mapping": { + "type": "array", + "title": "Egress QoS mapping", + "description": ( + "Defines a mapping of Linux internal packet priority to VLAN header" + " priority but for outgoing frames" + ), + "uniqueItems": True, + "additionalItems": False, + "items": { + "title": "Mapping", + "type": "string", + "pattern": QOS_MAPPING_PATTERN, + }, + "propertyOrder": 19, + }, + } + }, "wireless_interface": { "properties": { "wireless": { @@ -264,10 +308,92 @@ "maximum": 40, "propertyOrder": 4, }, + "vlan_filtering": { + "type": "array", + "title": "VLAN Filtering", + "items": { + "type": "object", + "properties": { + "vlan": { + "title": "VLAN", + "type": "integer", + "minimum": 0, + }, + "ports": { + "title": "Ports", + "type": "array", + "items": { + "type": "object", + "required": ["ifname", "tagging"], + "properties": { + "ifname": { + "type": "string", + }, + "tagging": { + "type": "string", + "enum": ["t", "u"], + "options": { + "enum_titles": [ + "Egress tagged", + "Egress untagged", + ] + }, + }, + "primary_vid": { + "type": "boolean", + "title": "Primary VID", + "format": "checkbox", + }, + }, + }, + }, + }, + }, + }, } } ] }, + "vlan_8021q": { + "title": "VLAN (802.1q)", + "type": "object", + "required": ["type", "vid"], + "allOf": [ + { + "properties": { + "type": { + "type": "string", + "enum": ["8021q"], + "default": "8021q", + "propertyOrder": 1, + }, + } + }, + {"$ref": "#/definitions/base_interface_settings"}, + {"$ref": "#/definitions/interface_settings"}, + {"$ref": "#/definitions/vlan_interface_settings"}, + ], + }, + "vlan_8021ad": { + "title": "VLAN (802.1ad)", + "type": "object", + "required": ["type", "vid"], + "allOf": [ + { + "properties": { + "type": { + "type": "string", + "enum": ["8021ad"], + "default": "8021ad", + "propertyOrder": 1, + }, + } + }, + {"$ref": "#/definitions/base_interface_settings"}, + {"$ref": "#/definitions/interface_settings"}, + {"$ref": "#/definitions/vlan_interface_settings"}, + ], + }, "dialup_interface": { "title": "Dialup interface", "required": ["proto", "username", "password"], @@ -788,6 +914,8 @@ {"$ref": "#/definitions/modemmanager_interface"}, {"$ref": "#/definitions/vxlan_interface"}, {"$ref": "#/definitions/wireguard_interface"}, + {"$ref": "#/definitions/vlan_8021q"}, + {"$ref": "#/definitions/vlan_8021ad"}, ] } }, diff --git a/tests/openwrt/test_interfaces_dsa.py b/tests/openwrt/test_interfaces_dsa.py index 427167267..5e3d52443 100644 --- a/tests/openwrt/test_interfaces_dsa.py +++ b/tests/openwrt/test_interfaces_dsa.py @@ -1110,6 +1110,171 @@ def test_parse_l2_options_interface(self): o = OpenWrt(native=self._l2_options_interface_uci) self.assertEqual(o.config, self._l2_options_interface_netjson) + _vlan_filtering_bridge_netjson = { + "interfaces": [ + { + "type": "bridge", + "bridge_members": ["lan1", "lan2", "lan3"], + "name": "br-lan", + "network": "home_vlan", + "vlan_filtering": [ + { + "vlan": 1, + "ports": [ + {"ifname": "lan1", "tagging": "t", "primary_vid": True}, + {"ifname": "lan2", "tagging": "t"}, + ], + }, + { + "vlan": 2, + "ports": [ + {"ifname": "lan1", "tagging": "t", "primary_vid": False}, + {"ifname": "lan3", "tagging": "u", "primary_vid": True}, + ], + }, + ], + } + ] + } + + _vlan_filtering_bridge_uci = """package network + +config device 'device_home_vlan' + option name 'br-lan' + list ports 'lan1' + list ports 'lan2' + list ports 'lan3' + option type 'bridge' + option vlan_filtering '1' + +config bridge-vlan 'home_vlan_1' + option device 'br-lan' + list ports 'lan1:t*' + list ports 'lan2:t' + option vlan '1' + +config bridge-vlan 'home_vlan_2' + option device 'br-lan' + list ports 'lan1:t' + list ports 'lan3:u*' + option vlan '2' + +config interface 'home_vlan_1' + option device 'br-lan.1' + option proto 'none' + +config interface 'home_vlan_2' + option device 'br-lan.2' + option proto 'none' + +config interface 'home_vlan' + option device 'br-lan' + option proto 'none' +""" + + def test_render_bridge_vlan_filtering(self): + o = OpenWrt(self._vlan_filtering_bridge_netjson) + self.assertEqual(self._tabs(self._vlan_filtering_bridge_uci), o.render()) + + with self.subTest('Test setting PVID on same port on different VLANS'): + netjson = deepcopy(self._vlan_filtering_bridge_netjson) + netjson['interfaces'][0]['vlan_filtering'][1]['ports'][0][ + 'primary_vid' + ] = True + with self.assertRaises(ValidationError) as error: + OpenWrt(netjson).validate() + self.assertEqual( + error.exception.message, + ( + 'Invalid configuration triggered by "#/interfaces/0"' + ' says: Primary VID can be set only one VLAN for a port.' + ), + ) + + def test_parse_bridge_vlan_filtering(self): + o = OpenWrt(native=self._vlan_filtering_bridge_uci) + expected = deepcopy(self._vlan_filtering_bridge_netjson) + expected['interfaces'][0]['vlan_filtering'][0]['ports'][1][ + 'primary_vid' + ] = False + self.assertEqual(o.config, expected) + + _vlan_filtering_bridge_override_netjson = { + "interfaces": [ + { + "type": "bridge", + "bridge_members": ["lan1", "lan2", "lan3"], + "name": "br-lan", + "vlan_filtering": [ + { + "vlan": 1, + "ports": [ + {"ifname": "lan1", "tagging": "t", "primary_vid": False}, + {"ifname": "lan2", "tagging": "u", "primary_vid": False}, + ], + } + ], + }, + { + "type": "ethernet", + "name": "br-lan.1", + "mtu": 1500, + "mac": "61:4A:A0:D7:3F:0E", + "addresses": [ + { + "proto": "static", + "family": "ipv4", + "address": "192.168.2.1", + "mask": 24, + } + ], + }, + ] + } + _vlan_filtering_bridge_override_uci = """package network + +config device 'device_br_lan' + option name 'br-lan' + list ports 'lan1' + list ports 'lan2' + list ports 'lan3' + option type 'bridge' + option vlan_filtering '1' + +config bridge-vlan 'vlan_br_lan_1' + option device 'br-lan' + list ports 'lan1:t' + list ports 'lan2:u' + option vlan '1' + +config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' + +config interface 'br_lan' + option device 'br-lan' + option proto 'none' + +config interface 'br_lan_1' + option device 'br-lan.1' + option ipaddr '192.168.2.1' + option netmask '255.255.255.0' + option proto 'static' +""" + + def test_render_bridge_vlan_filtering_override_interface(self): + o = OpenWrt(self._vlan_filtering_bridge_override_netjson) + self.assertEqual( + self._tabs(self._vlan_filtering_bridge_override_uci), o.render() + ) + + def test_parse_bridge_vlan_filtering_override_interface(self): + o = OpenWrt(native=self._vlan_filtering_bridge_override_uci) + expected = deepcopy(self._vlan_filtering_bridge_override_netjson) + del expected['interfaces'][1]['mtu'] + del expected['interfaces'][1]['mac'] + self.assertEqual(o.config, expected) + def test_render_dns(self): o = OpenWrt( { @@ -1821,3 +1986,74 @@ def test_empty_dns(self): """ ) self.assertEqual(o.render(), expected) + + _vlan8021q_netjson = { + "interfaces": [ + { + "type": "8021q", + "vid": 1, + "name": "br-lan", + "mac": "E8:6A:64:3E:4A:3A", + "mtu": 1500, + "ingress_qos_mapping": ["1:1"], + "egress_qos_mapping": ["2:2"], + } + ] + } + + _vlan8021q_uci = """package network + +config device 'device_br_lan_1' + list egress_qos_mapping '2:2' + option ifname 'br-lan' + list ingress_qos_mapping '1:1' + option macaddr 'E8:6A:64:3E:4A:3A' + option mtu '1500' + option name 'br-lan.1' + option type '8021q' + option vid '1' + +config interface 'vlan_br_lan_1' + option device 'br-lan.1' + option proto 'none' +""" + + def test_render_vlan8021q(self): + o = OpenWrt(self._vlan8021q_netjson) + expected = self._tabs(self._vlan8021q_uci) + self.assertEqual(o.render(), expected) + + def test_parse_vlan8021q(self): + o = OpenWrt(native=self._tabs(self._vlan8021q_uci)) + expected = deepcopy(self._vlan8021q_netjson) + expected['interfaces'][0]['network'] = 'vlan_br_lan_1' + self.assertEqual(expected, o.config) + + _vlan8021ad_netjson = { + "interfaces": [ + {"type": "8021ad", "vid": 6, "name": "eth0", "network": "iot_vlan"} + ] + } + + _vlan8021ad_uci = """package network + +config device 'device_iot_vlan' + option ifname 'eth0' + option name 'eth0.6' + option type '8021ad' + option vid '6' + +config interface 'iot_vlan' + option device 'eth0.6' + option proto 'none' +""" + + def test_render_vlan8021ad(self): + o = OpenWrt(self._vlan8021ad_netjson) + expected = self._tabs(self._vlan8021ad_uci) + self.assertEqual(o.render(), expected) + + def test_parse_vlan8021ad(self): + o = OpenWrt(native=self._tabs(self._vlan8021ad_uci)) + expected = deepcopy(self._vlan8021ad_netjson) + self.assertEqual(expected, o.config)