From bb4427bfb4a8c03784dd6f097cbe521f561edaf4 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Tue, 7 Jan 2025 21:54:47 +0000 Subject: Add vyos-vpp CLI and python3 modules --- python/vyos/vpp/__init__.py | 20 ++ python/vyos/vpp/config_deps.py | 76 ++++++ python/vyos/vpp/config_filter.py | 58 +++++ python/vyos/vpp/config_verify.py | 160 ++++++++++++ python/vyos/vpp/configdb.py | 206 +++++++++++++++ python/vyos/vpp/control_host.py | 363 +++++++++++++++++++++++++++ python/vyos/vpp/control_vpp.py | 444 +++++++++++++++++++++++++++++++++ python/vyos/vpp/interface/__init__.py | 40 +++ python/vyos/vpp/interface/bond.py | 113 +++++++++ python/vyos/vpp/interface/bridge.py | 128 ++++++++++ python/vyos/vpp/interface/ethernet.py | 54 ++++ python/vyos/vpp/interface/geneve.py | 90 +++++++ python/vyos/vpp/interface/gre.py | 85 +++++++ python/vyos/vpp/interface/ipip.py | 83 ++++++ python/vyos/vpp/interface/loopback.py | 68 +++++ python/vyos/vpp/interface/vxlan.py | 94 +++++++ python/vyos/vpp/interface/wireguard.py | 56 +++++ python/vyos/vpp/interface/xconnect.py | 94 +++++++ python/vyos/vpp/nat/__init__.py | 0 python/vyos/vpp/utils.py | 281 +++++++++++++++++++++ 20 files changed, 2513 insertions(+) create mode 100644 python/vyos/vpp/__init__.py create mode 100644 python/vyos/vpp/config_deps.py create mode 100644 python/vyos/vpp/config_filter.py create mode 100644 python/vyos/vpp/config_verify.py create mode 100644 python/vyos/vpp/configdb.py create mode 100644 python/vyos/vpp/control_host.py create mode 100644 python/vyos/vpp/control_vpp.py create mode 100644 python/vyos/vpp/interface/__init__.py create mode 100644 python/vyos/vpp/interface/bond.py create mode 100644 python/vyos/vpp/interface/bridge.py create mode 100644 python/vyos/vpp/interface/ethernet.py create mode 100644 python/vyos/vpp/interface/geneve.py create mode 100644 python/vyos/vpp/interface/gre.py create mode 100644 python/vyos/vpp/interface/ipip.py create mode 100644 python/vyos/vpp/interface/loopback.py create mode 100644 python/vyos/vpp/interface/vxlan.py create mode 100644 python/vyos/vpp/interface/wireguard.py create mode 100644 python/vyos/vpp/interface/xconnect.py create mode 100644 python/vyos/vpp/nat/__init__.py create mode 100644 python/vyos/vpp/utils.py (limited to 'python') diff --git a/python/vyos/vpp/__init__.py b/python/vyos/vpp/__init__.py new file mode 100644 index 000000000..a320ac7d1 --- /dev/null +++ b/python/vyos/vpp/__init__.py @@ -0,0 +1,20 @@ +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from .control_vpp import VPPControl + +__all__ = ['VPPControl'] diff --git a/python/vyos/vpp/config_deps.py b/python/vyos/vpp/config_deps.py new file mode 100644 index 000000000..d826dedbb --- /dev/null +++ b/python/vyos/vpp/config_deps.py @@ -0,0 +1,76 @@ +# +# Copyright (C) 2023-2024 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +def deps_xconnect_dict(conf) -> dict[str, list[str]]: + """Get a dict of all xconnect interface members: + + keys: members + + values: xconnect interfaces + + Args: + conf (config): VyOS config object + + Returns: + dict[str, list[str]]: dict of members + """ + xconn_members_dict: dict[str, list[str]] = {} + config = conf.get_config_dict( + ['vpp', 'interfaces', 'xconnect'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + for xconn_name, xconn_config in config.items(): + for member_name in xconn_config.get('member', {}).get('interface', []): + xconn_ifaces_list = xconn_members_dict.get(xconn_name, []) + xconn_ifaces_list.append(xconn_name) + xconn_members_dict.update({member_name: xconn_ifaces_list}) + + return xconn_members_dict + + +def deps_bridge_dict(conf) -> dict[str, list[str]]: + """Get a dict of all bridge interface members: + + keys: members + + values: bridge interfaces + + Args: + conf (config): VyOS config object + + Returns: + dict[str, list[str]]: dict of members + """ + bridge_members_dict: dict[str, list[str]] = {} + config = conf.get_config_dict( + ['vpp', 'interfaces', 'bridge'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + for bridge_name, bridge_config in config.items(): + for member_name in bridge_config.get('member', {}).get('interface', []): + bridge_ifaces_list = bridge_members_dict.get(bridge_name, []) + bridge_ifaces_list.append(bridge_name) + bridge_members_dict.update({member_name: bridge_ifaces_list}) + + return bridge_members_dict diff --git a/python/vyos/vpp/config_filter.py b/python/vyos/vpp/config_filter.py new file mode 100644 index 000000000..4ab9ffda2 --- /dev/null +++ b/python/vyos/vpp/config_filter.py @@ -0,0 +1,58 @@ +# +# Copyright (C) 2024 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config + + +def iface_filter_eth(config: Config, iface: str) -> None: + """Filter out unsupported config nodes from Ethernet interface config + + Args: + config (Config): config object + iface (str): Ethernet interface name to filter + """ + allowed_nodes: list[str] = [ + 'address', + 'description', + 'dhcp-options', + 'dhcpv6-options', + 'disable', + 'eapol', + 'hw-id', + 'ip', + 'ipv6', + 'mtu', + 'redirect', + 'vif', + 'vif-s', + 'vrf', + ] + + # get list of config nides in a session configuration + iface_nodes = config._session_config.list_nodes(['interfaces', 'ethernet', iface]) + + # clean cached session config + if False in config._dict_cache: + del config._dict_cache[False] + + # remove unsupported config nodes + for cfg_node in iface_nodes: + if cfg_node not in allowed_nodes: + config._session_config.delete(['interfaces', 'ethernet', iface, cfg_node]) + print( + f'WARNING: {cfg_node} option in {iface} settings is not supported by VPP interfaces. It will be ignored.' + ) diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py new file mode 100644 index 000000000..6b35858d2 --- /dev/null +++ b/python/vyos/vpp/config_verify.py @@ -0,0 +1,160 @@ +# Used for verifying configuration vpp interfaces +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError + +from vyos.vpp.control_host import get_eth_driver + + +def verify_vpp_remove_kernel_interface(config: dict): + """Common verify for removed kernel-interfaces. + Verify that removed kernel interface are not used in 'vpp kernel-interfaces'. + + Example: + delete vpp interfaces gre|vxlan X kernel-interface vpp-tunX + set vpp kernel-interface vpp-tunX + """ + if ( + 'remove' in config + and 'kernel_interface_removed' in config + and 'vpp_kernel_interfaces' in config + ): + removed_interfaces = config['kernel_interface_removed'] + used_interfaces = config['vpp_kernel_interfaces'] + + for interface in removed_interfaces: + if interface in used_interfaces: + raise ConfigError( + f'"{interface}" is still in use within "vpp kernel-interfaces". ' + 'Please remove it before proceeding.' + ) + + +def verify_vpp_change_kernel_interface(config: dict): + """Common verify for changed kernel-interface + + Example: + set vpp interfaces gre|vxlan kernel-interface vpp-tunX' + commit + set vpp interfaces gre|vxlan kernel-interface vpp-tunY' + commit + + check if we have kernel interface config 'vpp kernel-interface vpp-tunX' + """ + kernel_interface_removed = config.get('kernel_interface_removed', []) + vpp_kernel_interfaces = config.get('vpp_kernel_interfaces', {}) + + for interface in kernel_interface_removed: + if interface in vpp_kernel_interfaces: + raise ConfigError( + f'interface "{interface}" is still in use within "vpp kernel-interfaces". ' + f'Please remove it "vpp kernel-interface {interface}" before proceeding.' + ) + + +def verify_vpp_exists_kernel_interface(config: dict): + """Verify is a kernel-interface already created by another VPP LCP pair + + Example: + set vpp interfaces vxlan vxlan10 kernel-interface vpp-tun10' + commit + set vpp interfaces vxlan vxlan20 kernel-interface vpp-tun10' + commit + """ + kernel_interface = config.get('kernel_interface', '') + vpp_interface = config.get('ifname', '') + candidate_kernel_interfaces = config.get('candidate_kernel_interfaces', []) + + for candidate_kernel_iface in candidate_kernel_interfaces: + if ( + vpp_interface != candidate_kernel_iface[0] + and kernel_interface == candidate_kernel_iface[1] + ): + raise ConfigError( + f'Kernel interface "{kernel_interface}" is already configured for {candidate_kernel_iface[0]}. ' + 'Duplicates are not allowed.' + ) + + +def verify_vpp_remove_xconnect_interface(config: dict): + if not config.get('remove'): + return + for xconn_member, xconn_iface in config.get('xconn_members').items(): + if xconn_member == config.get('ifname'): + raise ConfigError( + f'interface "{xconn_member}" is still in use within "vpp interfaces xconnect". ' + f'Please remove it from "vpp interface xconnect {xconn_iface}" before proceeding.' + ) + + +def verify_dev_driver(iface_name: str, driver_type: str) -> bool: + # Lists of drivers compatible with DPDK and XDP + drivers_dpdk: list[str] = [ + 'atlantic', + 'bnx2x', + 'e1000', + 'ena', + 'gve', + 'hv_netvsc', + 'i40e', + 'ice', + 'igc', + 'ixgbe', + 'liquidio', + 'mlx4_core', + 'mlx5_core', + 'qede', + 'sfc', + 'tap', + 'tun', + 'virtio_net', + 'vmxnet3', + ] + + drivers_xdp: list[str] = [ + 'atlantic', + 'ena', + 'gve', + 'hv_netvsc', + 'i40e', + 'ice', + 'igb', + 'igc', + 'ixgbe', + 'mlx4_core', + 'mlx5_core', + 'qede', + 'sfc', + 'tap', + 'tun', + 'virtio_net', + 'vmxnet3', + ] + + driver: str = get_eth_driver(iface_name) + + if driver_type == 'dpdk': + if driver in drivers_dpdk: + return True + elif driver_type == 'xdp': + if driver in drivers_xdp: + return True + else: + raise ConfigError(f'"Driver type {driver_type} is wrong') + + return False diff --git a/python/vyos/vpp/configdb.py b/python/vyos/vpp/configdb.py new file mode 100644 index 000000000..1830937ad --- /dev/null +++ b/python/vyos/vpp/configdb.py @@ -0,0 +1,206 @@ +# +# Copyright (C) 2024 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from json import loads as json_loads, dumps as json_dumps +from pathlib import Path +from typing import Any + +STORAGE_LOCATION = '/run/vpp' + + +class JSONStorage: + def __init__(self, name: str = '') -> None: + """Initiate a file storage + + Args: + name (str, optional): Unique storage name. Defaults to '' (generate a name). + + Raises: + err: In case a file for storage cannot be created + """ + # If a name is not provided, this is a temporary one-time storage + # this use case is strange, but let's allow this + if not name: + self.__temporary = True + self.__cache: dict[Any, Any] = {} + self.__locked = False + return + self.__temporary = False + + self.__storage = Path(f'{STORAGE_LOCATION}/{name}.json') + self.__lock_file = Path(f'{STORAGE_LOCATION}/{name}.lock') + + # prepare a folder + storage_dir = Path(STORAGE_LOCATION) + if not storage_dir.exists(): + storage_dir.mkdir(parents=True) + + # initialize lock status + self.__locked = False + if self.__storage_locked(): + raise FileExistsError(f'Cannot open locked storage: {self.__storage}') + self.__lock_file.touch() + + if not self.__storage.exists(): + try: + self.__storage.touch() + except Exception as err: + print(f'Unable to initiate storage: {err}') + raise err + # prepare an empty cache + self.__cache: dict[Any, Any] = {} + else: + # load a cache from file + self.__cache = self.__load_file() + + def __del__(self) -> None: + """Dump data to persistent storage and unlock it""" + if self.__temporary: + return + # dump a cache to storage + if self.__cache: + self.__dump_file() + # or remove a file + else: + self.__storage.unlink() + # unlock a storage + self.__lock_file.unlink() + + def __check_types(self, data: Any) -> None: + """Check if all the data have supported types + + Args: + data (Any): object to validate + + Raises: + TypeError: If a data type is not supported + """ + if isinstance(data, str | int | float | bool | None): + return + if isinstance(data, list): + for item in data: + self.__check_types(item) + return + if isinstance(data, dict): + for item in data.values(): + self.__check_types(item) + return + raise TypeError(f'Object type "{type(data)}" is not allowed') + + def __load_file(self) -> dict[Any, Any]: + """Read a file to a dictionary + + Returns: + dict[Any, Any]: loaded dict object + """ + data: bytes = self.__storage.read_bytes() + return json_loads(data) + + def __dump_file(self) -> None: + """Dump cache to a file""" + data: str = json_dumps(self.__cache) + self.__storage.write_text(data) + + def __lock(self) -> None: + """Lock storage + + Raises: + FileExistsError: Raised if a storage is already locked + """ + if self.__locked: + raise FileExistsError(f'Access is already locked: {self.__storage}') + self.__locked = True + + def __unlock(self) -> None: + """Unlock storage + + Raises: + FileNotFoundError: Raised if a storage is already unlocked + """ + if not self.__locked: + raise FileNotFoundError(f'Access is already unlocked: {self.__storage}') + self.__locked = False + + def __storage_locked(self) -> bool: + """Check if a storage is locked + + Returns: + bool: Lock status + """ + if self.__lock_file.exists(): + return True + return False + + def delete(self, key: Any = None) -> None: + """Delete data from a storage or a full storage + + Raises: + FileExistsError: Raised if a storage is locked + """ + if self.__locked: + raise FileExistsError( + f'Storage locked and delete operation cannot be performed: {self.__storage}' + ) + if key: + if key not in self.__cache: + raise ValueError( + f'Object {key} does not exist in storage {self.__storage}' + ) + del self.__cache[key] + else: + self.__cache = {} + + def write(self, key: Any, value: Any) -> None: + # Check types first + self.__check_types(key) + self.__check_types(value) + # check lock status + if self.__locked: + raise FileExistsError( + f'Storage is locked and cannot be written: {self.__storage}' + ) + # write a data to a cache + self.__lock() + self.__cache[key] = value + self.__unlock() + + def read(self, key: Any, default: Any = None) -> Any: + """Read data from a storage + + Args: + key (Any): key name + default (Any, optional): Value to return if a key does not exist. Defaults to None. + + Raises: + FileExistsError: Raised if a storage is locked + + Returns: + Any: Value to return + """ + # Check types first + self.__check_types(key) + # check lock status + if self.__locked: + raise FileExistsError( + f'Storage is locked and it is not safe to read: {self.__storage}' + ) + # read a data from cache + self.__lock() + data: Any | None = self.__cache.get(key, default) + self.__unlock() + + return data diff --git a/python/vyos/vpp/control_host.py b/python/vyos/vpp/control_host.py new file mode 100644 index 000000000..ead9f6968 --- /dev/null +++ b/python/vyos/vpp/control_host.py @@ -0,0 +1,363 @@ +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + + +from pathlib import Path +from re import fullmatch as re_fullmatch +from subprocess import run +from time import sleep + +from vyos.vpp.utils import EthtoolGDrvinfo + + +def pci_rescan(pci_addr: str = '') -> None: + """Rescan PCI device by removing it and rescan PCI bus + + If PCI address is not defined - just rescan PCI bus + + Args: + address (str, optional): PCI address of device. Defaults to ''. + """ + device_file = Path(f'/sys/bus/pci/devices/{pci_addr}/remove') + if pci_addr: + if device_file.exists(): + device_file.write_text('1') + # wait 10 seconds max until device will be removed + attempts = 100 + while device_file.exists() and attempts: + attempts -= 1 + sleep(0.1) + if device_file.exists(): + raise TimeoutError( + f'Timeout was reached for removing PCI device {pci_addr}' + ) + else: + raise FileNotFoundError(f'PCI device {pci_addr} does not exist') + rescan_file = Path('/sys/bus/pci/rescan') + rescan_file.write_text('1') + if pci_addr: + # wait 10 seconds max until device will be installed + attempts = 100 + while not device_file.exists() and attempts: + attempts -= 1 + sleep(0.1) + if not device_file.exists(): + raise TimeoutError( + f'Timeout was reached for installing PCI device {pci_addr}' + ) + + +def unbind_driver(bus_id: str, device_id: str) -> bool: + """Unbind a driver from a device + + Args: + bus_id (str): bus ID (pci, vmbus, etc.) + device_id (str): device id on the bus (PCI address, VMBus UUID) + + Returns: + bool: True if a driver has been unbound, False otherwise + """ + device_resolved: str = ( + Path(f'/sys/bus/{bus_id}/devices/{device_id}').resolve().as_posix() + ) + if not Path(f'{device_resolved}/driver').exists(): + return False + + Path(f'{device_resolved}/driver/unbind').write_text(device_id) + return True + + +def probe_driver(bus_id: str, device_id: str) -> None: + """Probe driver for a device on a bus + + Args: + bus_id (str): bus ID (pci, vmbus, etc.) + device_id (str): device id on the bus (PCI address, VMBus UUID) + """ + Path(f'/sys/bus/{bus_id}/drivers_probe').write_text(device_id) + + +def load_kernel_module(module_name: str) -> None: + """Load a kernel module + + Args: + module_name (str): module name + """ + # check if a module already loaded + if Path(f'/sys/module/{module_name}').exists(): + return + + # execute modprobe with the specified module name + run(['/usr/sbin/modprobe', '-q', module_name], check=True) + + +def override_driver(bus_id: str, device_id: str, driver_name: str = '') -> None: + """Override a driver for a device + + Args: + bus_id (str): bus ID (pci, vmbus, etc.) + device_id (str): device id on the bus (PCI address, VMBus UUID) + driver_name (str, optional): Kernel module (driver) name. Defaults to '' - clear an override. + + Raises: + FileNotFoundError: A device does not support driver override + ChildProcessError: Failed to override a driver + """ + device_resolved: str = ( + Path(f'/sys/bus/{bus_id}/devices/{device_id}').resolve().as_posix() + ) + # check if a device supports driver override + if not Path(f'{device_resolved}/driver_override').exists(): + raise FileNotFoundError(f'{device_resolved} does not support driver override') + + if driver_name: + load_kernel_module(driver_name) + + unbind_driver(bus_id, device_id) + + # vfio-pci needs special approach + if driver_name == 'vfio-pci': + vendor: str = Path(f'{device_resolved}/vendor').read_text() + device: str = Path(f'{device_resolved}/device').read_text() + Path('/sys/module/vfio_pci/drivers/pci:vfio-pci/new_id').write_text( + f'{vendor} {device}' + ) + + # override a driver + Path(f'{device_resolved}/driver_override').write_text(f'{driver_name}\n') + + # probe a driver + probe_driver(bus_id, device_id) + + # check the result + if not Path(f'{device_resolved}/driver').exists(): + raise ChildProcessError( + f'Failed to override a driver to {driver_name} for {bus_id}, {device_id}' + ) + + +def get_bus_name(iface: str) -> str: + """Get bus name + Works for PCI, VMbus, maybe something else. + Does not work for Virtio and other virtual devices + (however, it does not seem we need this for such kind of devices). + + Args: + iface (str): interface name + + Returns: + str: bus name + """ + device_resolved: Path = Path(f'/sys/class/net/{iface}/device').resolve() + + # Iterate upwards until a `bus` directory is found + current_path: Path = device_resolved + while True: + # Check if a bus info is available + subsystem_path = Path(f'{current_path}/subsystem') + if subsystem_path.is_symlink(): + # Read the link to determine the bus type + bus_path = subsystem_path.resolve() + # Check if the parent directory is a 'bus' directory in '/sys/bus/' + if bus_path.parent.name == 'bus': + # Return only the last name of the path, e.g., 'pci' + return bus_path.name + + # Move up one directory level + current_path = current_path.parent + if current_path == Path('/sys'): + break # Stop if we reach the root of /sys without finding a bus type + + return '' # Return None if no bus type was found + + +def get_eth_name(dev_id: str) -> str: + """Find Ethernet interface name by PCI address or UUID + + Args: + dev_id (str): PCI address or UUID + + Raises: + FileNotFoundError: no Ethernet interface was found + + Returns: + str: Ethernet interface name + """ + # find all PCI devices with eth* names + net_devs: dict[str, str] = {} + net_devs_dir = Path('/sys/class/net') + regex_filter = r'^/sys/devices/pci[\w/:\.]+/(?P\w+:\w+:\w+\.\w+)/[\w/:\.]+/(?Peth\d+)$' + for dir in net_devs_dir.iterdir(): + # PCI devices + real_dir: str = dir.resolve().as_posix() + re_obj = re_fullmatch(regex_filter, real_dir) + if re_obj: + iface_name: str = re_obj.group('iface_name') + iface_addr: str = re_obj.group('pci_addr') + net_devs.update({iface_addr: iface_name}) + # UUID devices + else: + try: + bus_type: str = get_bus_name(dir.name) + iface_addr = EthtoolGDrvinfo(dir.name).bus_info_expand(bus_type) + net_devs.update({iface_addr: dir.name}) + except FileNotFoundError: + pass + + # match to provided PCI address or UUID and return a name if found + if dev_id in net_devs: + return net_devs[dev_id] + # raise error if device was not found + raise FileNotFoundError( + f'A device with ID {dev_id} not found in ethernet interfaces' + ) + + +def get_dev_id(iface: str) -> str: + """Get device ID by its interface name + + Args: + iface (str): interface name + + Raises: + FileNotFoundError: no Ethernet interface was found + + Returns: + str: device ID (PCI address or UUID) + """ + try: + # Try to get details via ethtool first + ethtool_info = EthtoolGDrvinfo(iface) + # For devices represented by UUID we need to expand them + # to their full representation + if ethtool_info.driver == 'hv_netvsc': + return ethtool_info.bus_info_expand('vmbus') + return ethtool_info.bus_info + except Exception: + # raise error if a device ID was not found + raise FileNotFoundError(f'Cannot find device ID for interface {iface}') + + +def get_eth_driver(iface: str) -> str: + """Find kernel module used for Ethernet interface + + Args: + iface (str): Ethernet interface name + + Raises: + FileNotFoundError: no Ethernet interface was found + + Returns: + str: kernel module name + """ + iface_driver: str = '' + driver_dir = Path(f'/sys/class/net/{iface}/device/driver/module') + if not driver_dir.exists(): + # raise error if device was not found + raise FileNotFoundError(f'PCI device {iface} not found in ethernet interfaces') + + iface_driver: str = driver_dir.resolve().name + return iface_driver + + +def unsafe_noiommu_mode(status: bool) -> None: + """Control unsafe_noiommu_mode parameter of vfio module + + Args: + status (bool): Target status + + Raises: + ChildProcessError: Raised if failed to set unsafe_noiommu_mode + """ + param_path = Path('/sys/module/vfio/parameters/enable_unsafe_noiommu_mode') + current_status: str = param_path.read_text().strip() + target_status: str = 'Y' if status else 'N' + if current_status != target_status: + param_path.write_text(target_status) + if param_path.read_text().strip() != target_status: + raise ChildProcessError('Failed to set unsafe_noiommu_mode') + + +def rename_iface(name_old: str, name_new: str) -> None: + """Rename interface + + Args: + name_old (str): old name + name_new (str): new name + """ + run(['ip', 'link', 'set', name_old, 'down']) + rename_cmd: list[str] = ['ip', 'link', 'set', name_old, 'name', name_new] + run(rename_cmd) + + +def set_promisc(iface_name: str, operation: str) -> None: + """Set promisc mode for interface + + Args: + iface_name (str): name of an interface + operation (str): operation (on, off) + """ + run(['ip', 'link', 'set', iface_name, 'promisc', operation]) + + +def set_mtu(iface_name: str, mtu: int) -> None: + """Set MTU for interface + + Args: + iface_name (str): name of an interface + mtu (int): MTU + """ + run(['ip', 'link', 'set', iface_name, 'mtu', str(mtu)]) + + +def get_eth_mac(iface_name: str) -> str: + """Get MAC address of an interface + + Args: + iface_name (str): name of an interface + + Raises: + FileNotFoundError: interface was not found + + Returns: + str: MAC address + """ + dev_addr_path = Path(f'/sys/class/net/{iface_name}/address') + if dev_addr_path.exists(): + return dev_addr_path.read_text().strip() + else: + # raise error if device was not found + raise FileNotFoundError(f'Interface {iface_name} not found') + + +def xdp_remove(iface_name: str) -> None: + """Remove XDP BPF program from an interfce + + Args: + iface_name (str): name of an interface + """ + run(['ip', 'link', 'set', iface_name, 'xdp', 'off']) + + +def set_status(iface_name: str, status: str) -> None: + """Set interface status + + Args: + iface_name (str): name of an interface + status (str): status - "up" or "down" + """ + run(['ip', 'link', 'set', iface_name, status]) diff --git a/python/vyos/vpp/control_vpp.py b/python/vyos/vpp/control_vpp.py new file mode 100644 index 000000000..39ecac906 --- /dev/null +++ b/python/vyos/vpp/control_vpp.py @@ -0,0 +1,444 @@ +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from collections.abc import Callable +from functools import wraps +from re import search as re_search, MULTILINE as re_M +from systemd import journal +from time import sleep +from typing import TypeVar, ParamSpec, Literal + +from vpp_papi import VPPApiClient +from vpp_papi import VPPIOError, VPPValueError + +# define types for static type checkers +AnyType = TypeVar('AnyType') +AnyParam = ParamSpec('AnyParam') + + +class VPPControl: + """Control VPP network stack""" + + class _Decorators: + """Decorators for VPPControl""" + + @classmethod + def api_call( + cls, decorated_func: Callable[AnyParam, AnyType] + ) -> Callable[AnyParam, AnyType]: + """Check if API is connected before API call + + Args: + decorated_func: function to decorate + + Raises: + VPPIOError: Connection to API is not established + """ + + @wraps(decorated_func) + def api_safe_wrapper( + cls, *args: AnyParam.args, **kwargs: AnyParam.kwargs + ) -> AnyType: + if not cls.connected: + raise VPPIOError(2, 'VPP API is not connected') + return decorated_func(cls, *args, **kwargs) + + return api_safe_wrapper + + @classmethod + def check_retval( + cls, decorated_func: Callable[AnyParam, AnyType] + ) -> Callable[AnyParam, AnyType]: + """Check retval from API response + + Args: + decorated_func: function to decorate + + Raises: + VPPValueError: raised when retval is not 0 + """ + + @wraps(decorated_func) + def check_retval_wrapper( + cls, *args: AnyParam.args, **kwargs: AnyParam.kwargs + ) -> AnyType: + return_value = decorated_func(cls, *args, **kwargs) + if not return_value.retval == 0: + raise VPPValueError(f'VPP API call failed: {return_value.retval}') + return return_value + + return check_retval_wrapper + + def __init__(self, attempts: int = 5, interval: int = 1000) -> None: + """Create VPP API connection + + Args: + attempts (int, optional): attempts to connect. Defaults to 5. + interval (int, optional): interval between attempts in ms. Defaults to 1000. + + Raises: + VPPIOError: Connection to API cannot be established + """ + self.__vpp_api_client = VPPApiClient() + # connect with interval + while attempts: + try: + attempts -= 1 + self.__vpp_api_client.connect('vpp-vyos') + break + except (ConnectionRefusedError, FileNotFoundError) as err: + error_message = f'VPP API connection timeout: {err}' + journal.send(error_message, priority=journal.LOG_ERR) + sleep(interval / 1000) + # raise exception if connection was not successful in the end + if not self.__vpp_api_client.transport.connected: + raise VPPIOError(2, 'Cannot connect to VPP API') + + def __del__(self) -> None: + """Disconnect from VPP API (destructor)""" + self.disconnect() + + def disconnect(self) -> None: + """Disconnect from VPP API""" + if self.__vpp_api_client.transport.connected: + self.__vpp_api_client.disconnect() + + @_Decorators.check_retval + @_Decorators.api_call + def cli_cmd(self, command: str): + """Send raw CLI command + + Args: + command (str): command to send + + Returns: + vpp_papi.vpp_serializer.cli_inband_reply: CLI reply class + """ + return self.__vpp_api_client.api.cli_inband(cmd=command) + + @_Decorators.api_call + def get_mac(self, ifname: str) -> str: + """Find MAC address by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + str: MAC address + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.interface_name == ifname: + return iface.l2_address.mac_string + return '' + + @_Decorators.api_call + def get_sw_if_index(self, ifname: str) -> int | None: + """Find interface index by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + int | None: Interface index or None (if was not fount) + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.interface_name == ifname: + return iface.sw_if_index + return None + + @_Decorators.check_retval + @_Decorators.api_call + def lcp_pair_add( + self, + iface_name_vpp: str, + iface_name_kernel: str, + iface_type: Literal['tun', 'tap', ''] = '', + ) -> None: + """Create LCP interface pair between VPP and kernel + + Args: + iface_name_vpp (str): interface name in VPP + iface_name_kernel (str): interface name in kernel + iface_type (Literal['tun', 'tap', ''], optional): Use explicit interface type in kernel. Defaults to ''. + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + if iface_index: + api_call_args: dict[str, bool | int | str] = { + 'is_add': True, + 'sw_if_index': iface_index, + 'host_if_name': iface_name_kernel, + } + if iface_type: + iface_type_resolve = {'tun': 1, 'tap': 0} + api_call_args['host_if_type'] = iface_type_resolve[iface_type] + return self.__vpp_api_client.api.lcp_itf_pair_add_del_v2(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def lcp_pair_del(self, iface_name_vpp: str, iface_name_kernel: str) -> None: + """Delete LCP interface pair between VPP and kernel + + Args: + iface_name_vpp (str): interface name in VPP + iface_name_kernel (str): interface name in kernel + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + if iface_index: + return self.__vpp_api_client.api.lcp_itf_pair_add_del_v2( + is_add=False, sw_if_index=iface_index, host_if_name=iface_name_kernel + ) + + @_Decorators.api_call + def lcp_pair_find( + self, + kernel_name: str = '', + vpp_index_hw: int | None = None, + vpp_index_kernel: int | None = None, + vpp_name_hw: str = '', + vpp_name_kernel: str = '', + ) -> dict[str, str | int] | None: + """Find LCP pair details + + Args: + kernel_name (str, optional): Interface name in the kernel. Defaults to ''. + vpp_index_hw (int | None, optional): Interface index in VPP (hardware). Defaults to None. + vpp_index_kernel (int | None, optional): Interface index in VPP (kernel). Defaults to None. + vpp_name_hw (str, optional): Interface name in VPP (hardware). Defaults to ''. + vpp_name_kernel (str, optional): Interface name in VPP (to kernel). Defaults to ''. + + Returns: + dict[str, str | int] | None: LCP pair details + """ + filter_dict = {} + for filter_name, filter_value in locals().items(): + if filter_value: + filter_dict[filter_name] = filter_value + + # Get list of pairs + lcp_pairs = self.lcp_pairs_list() + + # Check each pair + for pair in lcp_pairs: + pair_found = False + # For each item provided in function arguments + for filter_name, filter_value in filter_dict.items(): + # Stop if filter value is not as in a current pair + if filter_name in pair and pair[filter_name] != filter_value: + pair_found = False + break + # Set flag to True and check the next filter value + pair_found = True + + if pair_found: + return pair + + return None + + @_Decorators.api_call + def lcp_pairs_list(self) -> list[dict[str, str | int]]: + """List all LCP pairs + + Returns: + list[dict[str, str | int]]: LCP pairs details + """ + lcp_pairs_details = [] + + lcp_pairs = self.__vpp_api_client.api.lcp_itf_pair_get()[1] + vpp_ifaces = self.__vpp_api_client.api.sw_interface_dump() + for pair in lcp_pairs: + pair_details = { + 'kernel_name': pair.host_if_name, + 'vpp_index_hw': pair.phy_sw_if_index, + 'vpp_index_kernel': pair.host_sw_if_index, + } + for vpp_iface in vpp_ifaces: + if vpp_iface.sw_if_index == pair_details['vpp_index_hw']: + pair_details['vpp_name_hw'] = vpp_iface.interface_name + if vpp_iface.sw_if_index == pair_details['vpp_index_kernel']: + pair_details['vpp_name_kernel'] = vpp_iface.interface_name + + lcp_pairs_details.append(pair_details) + + return lcp_pairs_details + + @_Decorators.check_retval + @_Decorators.api_call + def lcp_resync(self) -> None: + """Resynchronize objects between kernel and VPP via Netlink + + This clears all routes in VPP configured by LCP and re-creates them + based on the current state of the kernel. + """ + return self.__vpp_api_client.api.lcp_resync() + + @_Decorators.check_retval + @_Decorators.api_call + def iface_rxmode(self, iface_name: str, rx_mode: str) -> None: + """Set interface rx-mode in VPP + + Args: + iface_name (str): interface name in VPP + rx_mode (str): mode (polling, interrupt, adaptive) + """ + modes_dict: dict[str, int] = {'polling': 1, 'interrupt': 2, 'adaptive': 3} + if rx_mode not in modes_dict: + raise VPPValueError(f'Mode {rx_mode} is not known') + iface_index = self.get_sw_if_index(iface_name) + return self.__vpp_api_client.api.sw_interface_set_rx_mode( + sw_if_index=iface_index, mode=modes_dict[rx_mode] + ) + + @_Decorators.api_call + def get_pci_addr(self, ifname: str) -> str: + """Find PCI address of interface by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + str: PCI address + """ + hw_info = self.cli_cmd(f'show hardware-interfaces {ifname}').reply + + regex_filter = r'^\s+pci: device (?P\w+:\w+) subsystem (?P\w+:\w+) address (?P
\w+:\w+:\w+\.\w+) numa (?P\w+)$' + re_obj = re_search(regex_filter, hw_info, re_M) + + # return empty string if no interface or no PCI info was found + if not hw_info or not re_obj: + return '' + + address = re_obj.groupdict().get('address', '') + + # we need to modify address to match kernel style + # for example: 0000:06:14.00 -> 0000:06:14.0 + address_chunks: list[str] = address.split('.') + address_normalized: str = f'{address_chunks[0]}.{int(address_chunks[1])}' + + return address_normalized + + @_Decorators.check_retval + @_Decorators.api_call + def xdp_iface_create( + self, + host_if: str, + name: str, + rxq_num: int = 0, + rxq_size: int = 0, + txq_size: int = 0, + mode: Literal['auto', 'copy', 'zero-copy'] = 'auto', + flags: Literal['no_syscall_lock', ''] = '', + ) -> None: + """Create XDP interface + + Args: + host_if (str): name of an interface in kernel + name (str): name of an interface in VPP + rxq_num (int, optional): Number of receive queues to connect to. Defaults to 0 (all). + rxq_size (int, optional): Size of receive queue. Defaults to 0. + txq_size (int, optional): Size of tranceive queue. Defaults to 0. + mode (Literal['auto', 'copy', 'zero-copy', optional): Zero-copy mode. Defaults to 'auto'. + flags (Literal['no_syscall_lock', ''], optional): Syscall lock mode. Defaults to ''. + """ + api_call_args: dict[str, int | str] = { + 'host_if': host_if, + 'name': name, + 'rxq_num': rxq_num, + 'rxq_size': rxq_size, + 'txq_size': txq_size, + } + if mode != 'auto': + mode_resolve: dict[str, int] = {'auto': 0, 'copy': 1, 'zero-copy': 2} + api_call_args['mode'] = mode_resolve[mode] + if flags == 'no_systcall_lock': + api_call_args['flags'] = 1 + return self.__vpp_api_client.api.af_xdp_create_v3(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def xdp_iface_delete(self, iface_name_vpp: str) -> None: + """Delete XDP interface + + Args: + iface_name_vpp (str): Name of an interface in VPP + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + if iface_index: + api_call_args: dict[str, int] = {'sw_if_index': iface_index} + return self.__vpp_api_client.api.af_xdp_delete(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def set_iface_mac(self, iface_name_vpp: str, mac_address: str) -> None: + """Set MAC address of an interface + + Args: + iface_name_vpp (str): Name of an interface in VPP + mac_address (str): MAC address + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + api_call_args: dict[str, str | int] = { + 'sw_if_index': iface_index, + 'mac_address': mac_address, + } + return self.__vpp_api_client.api.sw_interface_set_mac_address(**api_call_args) + + @_Decorators.check_retval + @_Decorators.api_call + def set_iface_mtu(self, iface_name_vpp: str, mtu: int) -> None: + """Set MTU for interface + + Args: + iface_name_vpp (str): Name of an interface in VPP + mtu (int): MTU + """ + iface_index = self.get_sw_if_index(iface_name_vpp) + api_call_args: dict[str, str | int] = {'sw_if_index': iface_index, 'mtu': mtu} + return self.__vpp_api_client.api.hw_interface_set_mtu(**api_call_args) + + @_Decorators.api_call + def get_sw_if_dev_type(self, ifname: str) -> int | None: + """Find interface device type by interface name in VPP + + Args: + ifname (str): interface name inside VPP + + Returns: + int | None: Interface device type or None (if was not fount) + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.interface_name == ifname: + return iface.interface_dev_type + return None + + @property + def connected(self) -> bool: + """Check if VPP API is connected + + Returns: + bool: True if connected, False if not + """ + return self.__vpp_api_client.transport.connected + + @property + @_Decorators.api_call + def api(self): + """Call API + + Returns: + Callable[AnyParam, AnyType]: API functions + """ + return self.__vpp_api_client.api diff --git a/python/vyos/vpp/interface/__init__.py b/python/vyos/vpp/interface/__init__.py new file mode 100644 index 000000000..14659ed7b --- /dev/null +++ b/python/vyos/vpp/interface/__init__.py @@ -0,0 +1,40 @@ +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from .bond import BondInterface +from .bridge import BridgeInterface +from .ethernet import EthernetInterface +from .geneve import GeneveInterface +from .gre import GREInterface +from .ipip import IPIPInterface +from .loopback import LoopbackInterface +from .vxlan import VXLANInterface +from .wireguard import WireguardInterface +from .xconnect import XconnectInterface + +__all__ = [ + 'BondInterface', + 'BridgeInterface', + 'EthernetInterface', + 'GeneveInterface', + 'GREInterface', + 'IPIPInterface', + 'LoopbackInterface', + 'VXLANInterface', + 'WireguardInterface', + 'XconnectInterface', +] diff --git a/python/vyos/vpp/interface/bond.py b/python/vyos/vpp/interface/bond.py new file mode 100644 index 000000000..1cc306670 --- /dev/null +++ b/python/vyos/vpp/interface/bond.py @@ -0,0 +1,113 @@ +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl +from vyos.vpp.control_host import set_promisc + +vpp = VPPControl() + + +class BondInterface: + def __init__( + self, + ifname, + mode: str = '', + load_balance: int = 0, + mac: str = '', + kernel_interface: str = '', + ): + self.instance = int(ifname.removeprefix('bond')) + self.ifname = f'BondEthernet{self.instance}' + self.mode = mode + self.load_balance = load_balance + self.mac = mac + self.kernel_interface = kernel_interface + + def add(self): + """Create Bond interface + https://github.com/FDio/vpp/blob/stable/2306/src/vnet/bonding/bond.api + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0', mode=5) + a.add() + """ + # Create interface 'bondX' + create_args = { + 'id': self.instance, + 'mode': self.mode, + 'lb': self.load_balance, + } + if self.mac: + create_args.update({'use_custom_mac': True, 'mac_address': self.mac}) + vpp.api.bond_create2(**create_args) + if self.kernel_interface: + vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def delete(self): + """Delete Bond interface + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.delete() + """ + bond_if_index = vpp.get_sw_if_index(self.ifname) + vpp.api.bond_delete(sw_if_index=bond_if_index) + + def add_member(self, interface): + """Add member to Bond interface + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.add_member(interface='eth0') + """ + bond_if_index = vpp.get_sw_if_index(f'BondEthernet{self.instance}') + member_if_index = vpp.get_sw_if_index(interface) + member_if_type = vpp.get_sw_if_dev_type(interface) + vpp.api.bond_add_member( + bond_sw_if_index=bond_if_index, sw_if_index=member_if_index + ) + vpp.api.sw_interface_set_promisc(sw_if_index=member_if_index, promisc_on=True) + if member_if_type == 'AF_XDP interface': + set_promisc(f'defunct_{interface}', 'on') + + def detach_member(self, interface): + """Detach member from Bond interface + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.detach_member(interface='eth0') + """ + member_if_index = vpp.get_sw_if_index(interface) + vpp.api.bond_detach_member(sw_if_index=member_if_index) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0', mode=5) + a.kernel_add() + """ + vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0', mode=5) + a.kernel_delete() + """ + vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/bridge.py b/python/vyos/vpp/interface/bridge.py new file mode 100644 index 000000000..e220547de --- /dev/null +++ b/python/vyos/vpp/interface/bridge.py @@ -0,0 +1,128 @@ +# +# Copyright (C) 2023-2024 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +class BridgeInterface: + def __init__( + self, + ifname: str, + flood: bool = True, + forward: bool = True, + learn: bool = True, + uu_flood: bool = True, + arp_term: bool = False, + ): + self.ifname = ifname + self.interface_suffix = int(self.ifname.replace('br', '')) + self.flood = flood + self.forward = forward + self.learn = learn + self.uu_flood = uu_flood + self.arp_term = arp_term + + def add(self): + """Create Bridge interface + https://github.com/FDio/vpp/blob/stable/2306/src/vnet/l2/l2.api + + Bridge-domain 0 is reserved for the default bridge-domain. + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.add() + """ + vpp.api.bridge_domain_add_del_v2( + is_add=True, + bd_id=self.interface_suffix, + flood=self.flood, + forward=self.forward, + learn=self.learn, + uu_flood=self.uu_flood, + arp_term=self.arp_term, + ) + + def delete(self): + """Delete Bridge interface + + Bridge-members must be detached before deleting the bridge interface. + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.delete() + """ + vpp.api.bridge_domain_add_del_v2(is_add=False, bd_id=self.interface_suffix) + + def add_member(self, member: str | int): + """Add member to Bridge interface + + Attaches a VPP interface to the Bridge interface specified by `interface_suffix`. + The `member` parameter can be either the name (str) or the index (int) of the network + VPP interface to be added as a member to the bridge. + + Args: + member (str or int): The name or index of the VPP network interface + to be added as a member to the bridge. + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.add_member(member='eth0') + """ + bridge_index = self.interface_suffix + # If the 'member' is an Integer or digit, assume it's an interface index + if isinstance(member, int): + member_if_index = member + elif member.isdigit(): + member_if_index = int(member) + else: + member_if_index = vpp.get_sw_if_index(member) + + return vpp.api.sw_interface_set_l2_bridge( + rx_sw_if_index=member_if_index, bd_id=bridge_index, port_type=0 + ) + + def detach_member(self, member: str | int): + """Detach member from Bridge interface. + Bridge-domain 0 is reserved for the default bridge-domain. + The `member` parameter can be either the name (str) or the index (int) + of the network VPP interface + + Args: + member (str or int): The name or index of the VPP network interface + to be detached from the bridge. + + Example: + from vyos.vpp.interface import BridgeInterface + a = BridgeInterface(ifname='br23') + a.detach_member(member='eth0') + """ + # If the 'member' is an Integer or digit, assume it's an interface index + if isinstance(member, int): + member_if_index = member + elif member.isdigit(): + member_if_index = int(member) + else: + member_if_index = vpp.get_sw_if_index(member) + + return vpp.api.sw_interface_set_l2_bridge( + rx_sw_if_index=member_if_index, bd_id=0, port_type=0 + ) diff --git a/python/vyos/vpp/interface/ethernet.py b/python/vyos/vpp/interface/ethernet.py new file mode 100644 index 000000000..ddd3dc42f --- /dev/null +++ b/python/vyos/vpp/interface/ethernet.py @@ -0,0 +1,54 @@ +# VyOS implementation of VPP Ethernet interface +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +class EthernetInterface: + """Interface Ethernet""" + + def __init__(self, ifname, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('eth')) + self.ifname = ifname + self.kernel_interface = kernel_interface + + def add(self): + pass + + def delete(self): + pass + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import EthernetInterface + a = EthernetInterface(ifname='eth0') + a.kernel_add() + """ + vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import EthernetInterface + a = EthernetInterface(ifname='eth0') + a.kernel_delete() + """ + vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/geneve.py b/python/vyos/vpp/interface/geneve.py new file mode 100644 index 000000000..3d5e75003 --- /dev/null +++ b/python/vyos/vpp/interface/geneve.py @@ -0,0 +1,90 @@ +# VyOS implementation of Geneve interface +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +def show(): + """Show Geneve interface + Example: + from vyos.vpp.interface import geneve + geneve.show() + """ + return vpp.api.geneve_tunnel_dump() + + +class GeneveInterface: + def __init__(self, ifname, source_address, remote, vni, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('geneve')) + self.ifname = f'geneve_tunnel{self.instance}' + self.src_address = source_address + self.dst_address = remote + self.vni = vni + self.kernel_interface = kernel_interface + + def add(self): + """Create Geneve interface + https://github.com/FDio/vpp/blob/stable/2306/src/plugins/geneve/geneve.api + + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='geneve25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.add() + """ + return vpp.api.geneve_add_del_tunnel2( + is_add=True, + local_address=self.src_address, + remote_address=self.dst_address, + vni=self.vni, + l3_mode=False, + ) + + def delete(self): + """Delete Geneve interface + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.delete() + """ + return vpp.api.geneve_add_del_tunnel2( + is_add=False, + local_address=self.src_address, + remote_address=self.dst_address, + vni=self.vni, + l3_mode=False, + ) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.kernel_add() + """ + vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import GeneveInterface + a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) + a.kernel_delete() + """ + vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/gre.py b/python/vyos/vpp/interface/gre.py new file mode 100644 index 000000000..0017127ec --- /dev/null +++ b/python/vyos/vpp/interface/gre.py @@ -0,0 +1,85 @@ +# VyOS implementation of VPP GRE interface +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +def show(): + """Show GRE interface + Example: + from vyos.vpp.interface import gre + gre.show() + """ + return vpp.api.gre_tunnel_dump() + + +class GREInterface: + def __init__(self, ifname, source_address, remote, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('gre')) + self.ifname = ifname + self.src_address = source_address + self.dst_address = remote + self.kernel_interface = kernel_interface + + def add(self): + """Create GRE interface + https://github.com/FDio/vpp/blob/stable/2306/src/plugins/gre/gre.api + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a.add() + """ + vpp.api.gre_tunnel_add_del( + is_add=True, + tunnel={ + 'src': self.src_address, + 'dst': self.dst_address, + 'instance': self.instance, + }, + ) + + def delete(self): + """Delete GRE interface + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a.delete() + """ + return vpp.api.gre_tunnel_add_del( + is_add=False, tunnel={'src': self.src_address, 'dst': self.dst_address} + ) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a.kernel_add() + """ + vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import GREInterface + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a.kernel_delete() + """ + vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/ipip.py b/python/vyos/vpp/interface/ipip.py new file mode 100644 index 000000000..e1f19735b --- /dev/null +++ b/python/vyos/vpp/interface/ipip.py @@ -0,0 +1,83 @@ +# VyOS implementation of VPP IPIP interface +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +def show(): + """Show IPIP interface + Example: + from vyos.vpp.interface import ipip + ipip.show() + """ + return vpp.api.ipip_tunnel_dump() + + +class IPIPInterface: + def __init__(self, ifname, source_address, remote, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('ipip')) + self.ifname = ifname + self.src_address = source_address + self.dst_address = remote + self.kernel_interface = kernel_interface + + def add(self): + """Create IPIP interface + https://github.com/FDio/vpp/blob/stable/2310/src/vnet/ipip/ipip.api + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.add() + """ + vpp.api.ipip_add_tunnel( + tunnel={ + 'src': self.src_address, + 'dst': self.dst_address, + 'instance': self.instance, + }, + ) + + def delete(self): + """Delete IPIP interface + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.delete() + """ + ipip_if_index = vpp.get_sw_if_index(f'ipip{self.instance}') + return vpp.api.ipip_del_tunnel(sw_if_index=ipip_if_index) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.kernel_add() + """ + vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import IPIPInterface + a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') + a.kernel_delete() + """ + vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/loopback.py b/python/vyos/vpp/interface/loopback.py new file mode 100644 index 000000000..df7b82356 --- /dev/null +++ b/python/vyos/vpp/interface/loopback.py @@ -0,0 +1,68 @@ +# VyOS implementation of VPP Loopback interface +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +class LoopbackInterface: + """Interface Loopback""" + + def __init__(self, ifname, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('lo')) + self.ifname = f'loop{self.instance}' + self.kernel_interface = kernel_interface + + def add(self): + """Create Loopback interface + https://github.com/FDio/vpp/blob/stable/2306/src/vnet/interface.api + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.add() + """ + vpp.api.create_loopback_instance(is_specified=True, user_instance=self.instance) + + def delete(self): + """Delete Loopback interface + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.delete() + """ + loopback_if_index = vpp.get_sw_if_index(f'loop{self.instance}') + return vpp.api.delete_loopback(sw_if_index=loopback_if_index) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.kernel_add() + """ + vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import LoopbackInterface + a = LoopbackInterface(ifname='lo1') + a.kernel_delete() + """ + vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/vxlan.py b/python/vyos/vpp/interface/vxlan.py new file mode 100644 index 000000000..907fcbee0 --- /dev/null +++ b/python/vyos/vpp/interface/vxlan.py @@ -0,0 +1,94 @@ +# VyOS implementation of VPP VXLAN interface +# +# Copyright (C) 2023-2024 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +def show(): + """Show VXLAN interface + Example: + from vyos.vpp.interface import vxlan + vxlan.show() + """ + return vpp.api.vxlan_tunnel_dump() + + +class VXLANInterface: + """Interface VXLAN""" + + def __init__(self, ifname, source_address, remote, vni, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('vxlan')) + self.ifname = f'vxlan_tunnel{self.instance}' + self.src_address = source_address + self.dst_address = remote + self.vni = vni + self.kernel_interface = kernel_interface + + def add(self): + """Create VXLAN interface + https://github.com/FDio/vpp/blob/stable/2306/src/plugins/vxlan/vxlan.api + + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a.add() + """ + vpp.api.vxlan_add_del_tunnel_v3( + is_add=True, + src_address=self.src_address, + dst_address=self.dst_address, + vni=self.vni, + instance=self.instance, + decap_next_index=1, + is_l3=False, + ) + + def delete(self): + """Delete VXLAN interface + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a.delete() + """ + return vpp.api.vxlan_add_del_tunnel_v3( + is_add=False, + src_address=self.src_address, + dst_address=self.dst_address, + vni=self.vni, + is_l3=False, + ) + + def kernel_add(self): + """Add LCP pair + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a.kernel_add() + """ + vpp.lcp_pair_add(self.ifname, self.kernel_interface) + + def kernel_delete(self): + """Delete LCP pair + Example: + from vyos.vpp.interface import VXLANInterface + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a.kernel_delete() + """ + vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/wireguard.py b/python/vyos/vpp/interface/wireguard.py new file mode 100644 index 000000000..582929851 --- /dev/null +++ b/python/vyos/vpp/interface/wireguard.py @@ -0,0 +1,56 @@ +# VyOS implementation of VPP Wireguard interface +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + +vpp = VPPControl() + + +class WireguardInterface: + """Interface Wireguard""" + + def __init__(self, ifname, listen_port=51820, kernel_interface: str = ''): + self.instance = int(ifname.removeprefix('wg')) + self.ifname = ifname + self.listen_port = listen_port + self.kernel_interface = kernel_interface + + def add(self): + """Create Wireguard interface + https://github.com/FDio/vpp/blob/stable/2306/src/plugins/wireguard/wireguard.api + Example: + from vyos.vpp.interface import WireguardInterface + a = WireguardInterface(ifname='wg5', listen_port=51820, generate_key=True) + a.add() + """ + vpp.api.wireguard_interface_create( + generate_key=True, + interface={'user_instance': self.instance, 'listen_port': self.listen_port}, + ) + if self.kernel_interface: + vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + + def delete(self): + """Delete Wireguard interface + Example: + from vyos.vpp.interface import WireguardInterface + a = WireguardInterface(ifname='wg5') + a.delete() + """ + wg_if_index = vpp.get_sw_if_index(f'wg{self.instance}') + return vpp.api.wireguard_interface_delete(sw_if_index=wg_if_index) diff --git a/python/vyos/vpp/interface/xconnect.py b/python/vyos/vpp/interface/xconnect.py new file mode 100644 index 000000000..b4bec3321 --- /dev/null +++ b/python/vyos/vpp/interface/xconnect.py @@ -0,0 +1,94 @@ +# VyOS implementation of VPP bridge interface +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl +from vyos.vpp.utils import iftunnel_transform + +vpp = VPPControl() + + +class XconnectInterface: + def __init__( + self, + ifname: str, + members: list = [], + ): + self.ifname = ifname + self.members = members + + def add_l2_xconnect(self): + """Add l2 cross connect + Args: + members (list): The list of the xconnect members + Example: + from vyos.vpp.interface import XconnectInterface + a = XconnectInterface(ifname='xcon0', members=['eth0', 'vxlan0']) + a.add_l2_xconnect() + """ + interface_transform_filter = ('vxlan', 'gre') + first_member = self.members[0] + second_member = self.members[1] + # Check if member in required filter to transform 'vxlanX' => 'vxlan_tunnelX' + if first_member.startswith(interface_transform_filter): + first_member = iftunnel_transform(first_member) + if second_member.startswith(interface_transform_filter): + second_member = iftunnel_transform(second_member) + + member_first_if_index = vpp.get_sw_if_index(first_member) + member_second_if_index = vpp.get_sw_if_index(second_member) + vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_first_if_index, + tx_sw_if_index=member_second_if_index, + enable=True, + ) + vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_second_if_index, + tx_sw_if_index=member_first_if_index, + enable=True, + ) + + def del_l2_xconnect(self): + """Move l2 cross connect member to mode l3 (delte xconnect) + Args: + members (list): The list of the xconnect members + Example: + from vyos.vpp.interface import XconnectInterface + a = XconnectInterface(ifname='xcon0', members=['eth0', 'vxlan0']) + a.del_l2_xconnect() + """ + interface_transform_filter = ('vxlan', 'gre') + first_member = self.members[0] + second_member = self.members[1] + # Check if member in required filter to transform 'vxlanX' => 'vxlan_tunnelX' + if first_member.startswith(interface_transform_filter): + first_member = iftunnel_transform(first_member) + if second_member.startswith(interface_transform_filter): + second_member = iftunnel_transform(second_member) + + member_first_if_index = vpp.get_sw_if_index(first_member) + member_second_if_index = vpp.get_sw_if_index(second_member) + vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_first_if_index, + tx_sw_if_index=member_second_if_index, + enable=False, + ) + vpp.api.sw_interface_set_l2_xconnect( + rx_sw_if_index=member_second_if_index, + tx_sw_if_index=member_first_if_index, + enable=False, + ) diff --git a/python/vyos/vpp/nat/__init__.py b/python/vyos/vpp/nat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/vyos/vpp/utils.py b/python/vyos/vpp/utils.py new file mode 100644 index 000000000..08d69bba2 --- /dev/null +++ b/python/vyos/vpp/utils.py @@ -0,0 +1,281 @@ +# +# Copyright (C) 2023 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ctypes +import socket +from fcntl import ioctl +from pathlib import Path +from struct import pack + + +def iftunnel_transform(iface: str) -> str: + """Transform interface name from `xxxNN` to `xxx_tunnelNN` + + Args: + iface (str): original interface name + + Raises: + ValueError: Raised if an interface name does not start with a alpha and ends with decimal digit + + Returns: + str: Transformed interface name + """ + # Check format + if not iface[0].isascii() or not iface[-1].isdecimal(): + raise ValueError(f'Wrong interface name format: {iface}') + # Transform + iface_type: str = iface.rstrip('0123456789') + iface_num: str = iface.removeprefix(iface_type) + # Return transformed + return f'{iface_type}_tunnel{iface_num}' + + +def cli_ifaces_list(config_instance, mode: str = 'candidate') -> list[str]: + """List of all VPP interfaces (CLI names) + + Args: + config_instance (VyOS Config): VyOS Config instance + mode (str, optional): `candidate` or `running`. Defaults to 'candidate'. + + Returns: + list[str]: list of interfaces + """ + + effective_mode: bool = True if mode == 'running' else False + + # Read a config + config = config_instance.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + effective=effective_mode, + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + vpp_ifaces: list[str] = [] + + # Get a list of Ethernet interfaces + for iface in config.get('settings', {}).get('interface', {}).keys(): + vpp_ifaces.append(iface) + + # Get a list of VPP interfaces + for iface_type in config.get('interfaces', {}).keys(): + for iface in config.get('interfaces', {}).get(iface_type, {}).keys(): + vpp_ifaces.append(iface) + + return vpp_ifaces + + +def vpp_ifaces_list(vpp_api) -> list[dict]: + """List interfaces in VPP + + Args: + vpp_api (_type_): VPP API object + + Returns: + list[dict]: list of dictionaries with interfaces + """ + ifaces_list: list[dict] = [] + sw_ifaces_dump = vpp_api.sw_interface_dump() + while sw_ifaces_dump: + iface_details = sw_ifaces_dump.pop() + ifaces_list.append(iface_details._asdict()) + + return ifaces_list + + +def vpp_ip_addresses_by_index(vpp_api, index: str) -> list[str]: + """List of IP addresses for interface by its index in VPP + + Args: + vpp_api (_type_): VPP API object + index (str): interface index in vpp + + Returns: + list[str]: list of IP addresses + """ + ip_addresses_list: list[dict] = [] + ip_address_dump = vpp_api.ip_address_dump(sw_if_index=index) + while ip_address_dump: + ip_address_details = ip_address_dump.pop() + ip_addresses_list.append(str(ip_address_details._asdict().get('prefix'))) + return ip_addresses_list + + +def vpp_ifaces_stats( + iface_name: str = '', +) -> dict[str, dict[str, int | dict[str, int]]]: + from re import compile as re_compile + from vpp_papi import vpp_stats + + def total_value(val_list: vpp_stats.SimpleList) -> int | dict[str, int]: + """Helper for aggregation stats from multiple workers + + Args: + val_list (vpp_stats.SimpleList): list of stats for all workers + + Returns: + int | dict[str, int]: Summary stats + """ + # if all items are int return their sum + if all(isinstance(value, int) for value in val_list): + return sum(val_list) + # if all items are tuple + if all(isinstance(value, tuple) for value in val_list): + combined_stats = {} + # process individual workers + for worker_stats in val_list: + packets, octets = worker_stats + sum_packets = combined_stats.get('packets', 0) + packets + sum_octets = combined_stats.get('octets', 0) + octets + combined_stats = {'packets': sum_packets, 'bytes': sum_octets} + return combined_stats + + # items are something unknown, just return what we received + return val_list + + stats = vpp_stats.VPPStats() + + ifaces_stats: dict[str, dict[str, int | dict[str, int]]] = {} + + # prepare parser for stats output + regex_parser = re_compile(r'^/interfaces/(?P[^/]+)/(?P[^/]+)') + # get list of available stats and dump them + stats_list: list[str] = stats.ls([f'^/interfaces/{iface_name}']) + stats_dump: list[dict[str, int]] = stats.dump(stats_list) + + # parse outputs and convert it to a dictionary + for stats_key, stats_value in stats_dump.items(): + parsed_key = regex_parser.search(stats_key).groupdict() + iface_name = parsed_key['iface'] + param = parsed_key['param'] + stats_item = {param: total_value(stats_value)} + if iface_name in ifaces_stats: + ifaces_stats[iface_name].update(stats_item) + else: + ifaces_stats[iface_name] = stats_item + + return ifaces_stats + + +def cli_ifaces_lcp_kernel_list( + config_instance, mode: str = 'candidate' +) -> list[tuple[str, str]]: + """List of all VPP kernel-interfaces (CLI names, attached VPP interfaces) + + Args: + config_instance (VyOS Config): VyOS Config instance + mode (str, optional): `candidate` or `running`. Defaults to 'candidate'. + + Returns: + list[tuple[str, str]]: list of interfaces ([(vpp_iface, kernel_iface)]) + """ + + effective_mode: bool = True if mode == 'running' else False + + # Read a config + config = config_instance.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + effective=effective_mode, + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + lcp_kernel_ifaces: list[tuple[str, str]] = [] + + # Get a list with kernel interfaces + for ifaces_list in config.get('interfaces', {}).values(): + for iface_name, iface_settings in ifaces_list.items(): + if 'kernel_interface' in iface_settings: + lcp_kernel_ifaces.append( + (iface_name, iface_settings['kernel_interface']) + ) + + return lcp_kernel_ifaces + + +class EthtoolGDrvinfo: + """Return interface details like `ethtol -i` does""" + + # TODO + # this probably need to be replaced with a code generator + # like ctypeslib or C extension + class EthtoolDrvinfo(ctypes.Structure): + _fields_ = [ + ('cmd', ctypes.c_uint32), + ('driver', ctypes.c_char * 32), # Driver short name + ('version', ctypes.c_char * 32), # Driver version + ('fw_version', ctypes.c_char * 32), # Firmware version + # Be careful: bus info can be longer than 32 chars and thus truncated + ('bus_info', ctypes.c_char * 32), # Bus info. + ('erom_version', ctypes.c_char * 32), # Expansion ROM version + ('reserved2', ctypes.c_char * 12), # Reserved for future use + ('n_priv_flags', ctypes.c_uint32), # Number of private flags + ('n_stats', ctypes.c_uint32), # Number of U64 stats + ('testinfo_len', ctypes.c_uint32), # Test info length + ('eedump_len', ctypes.c_uint32), # EEPROM dump length + ('regdump_len', ctypes.c_uint32), # Register dump length + ] + + def __init__(self, iface: str): + # Constants for ethtool + SIOCETHTOOL = 0x8946 # pretend to be ethtool + ETHTOOL_GDRVINFO = 0x00000003 # Command to get driver info + + # Create a dummy socket + sockfd = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Prepare the request for getting driver info + drvinfo = self.EthtoolDrvinfo(cmd=ETHTOOL_GDRVINFO) + ifreq: bytes = pack('16sP', iface.encode('utf-8'), ctypes.addressof(drvinfo)) + + # Make an ioctl call to get the driver info + try: + ioctl(sockfd, SIOCETHTOOL, ifreq) + except OSError: + raise FileNotFoundError(f'There is no Ethernet device: {iface}') + + # Close the socket + sockfd.close() + + # save the information + self.driver: str = drvinfo.driver.decode('utf-8').strip('\x00') + self.version: str = drvinfo.version.decode('utf-8').strip('\x00') + self.fw_version: str = drvinfo.fw_version.decode('utf-8').strip('\x00') + self.bus_info: str = drvinfo.bus_info.decode('utf-8').strip('\x00') + self.erom_version: str = drvinfo.erom_version.decode('utf-8').strip('\x00') + self.reserved2: str = drvinfo.reserved2.decode('utf-8').strip('\x00') + self.n_priv_flags: int = drvinfo.n_priv_flags + self.testinfo_len: int = drvinfo.testinfo_len + self.eedump_len: int = drvinfo.eedump_len + self.regdump_len: int = drvinfo.regdump_len + + def bus_info_expand(self, bus_name: str) -> str: + bus_path = Path(f'/sys/bus/{bus_name}/devices').glob(f'{self.bus_info}*') + dev_ids = list(bus_path) + if not dev_ids: + raise FileNotFoundError( + f'No matching IDs on the bus: {self.bus_info} on {bus_name}' + ) + if len(dev_ids) > 1: + raise FileNotFoundError( + f'There are more than one matching IDs on the bus: {dev_ids} on {bus_name}' + ) + return dev_ids[0].name -- cgit v1.2.3 From 10de7ce46af1d38edfc449800ce730ad9bb4fbaa Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Sat, 11 Jan 2025 14:20:29 +0000 Subject: Move VPPContron to interface init --- python/vyos/vpp/interface/bond.py | 33 +++++++++++++++++---------------- python/vyos/vpp/interface/bridge.py | 17 ++++++++--------- python/vyos/vpp/interface/ethernet.py | 9 ++++----- python/vyos/vpp/interface/geneve.py | 13 ++++++------- python/vyos/vpp/interface/gre.py | 13 ++++++------- python/vyos/vpp/interface/ipip.py | 15 +++++++-------- python/vyos/vpp/interface/loopback.py | 17 +++++++++-------- python/vyos/vpp/interface/vxlan.py | 13 ++++++------- python/vyos/vpp/interface/wireguard.py | 13 ++++++------- python/vyos/vpp/interface/xconnect.py | 21 ++++++++++----------- 10 files changed, 79 insertions(+), 85 deletions(-) (limited to 'python') diff --git a/python/vyos/vpp/interface/bond.py b/python/vyos/vpp/interface/bond.py index 1cc306670..28aae74a1 100644 --- a/python/vyos/vpp/interface/bond.py +++ b/python/vyos/vpp/interface/bond.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl from vyos.vpp.control_host import set_promisc -vpp = VPPControl() - class BondInterface: def __init__( @@ -36,6 +34,7 @@ class BondInterface: self.load_balance = load_balance self.mac = mac self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): """Create Bond interface @@ -53,9 +52,9 @@ class BondInterface: } if self.mac: create_args.update({'use_custom_mac': True, 'mac_address': self.mac}) - vpp.api.bond_create2(**create_args) + self.vpp.api.bond_create2(**create_args) if self.kernel_interface: - vpp.lcp_pair_add(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) def delete(self): """Delete Bond interface @@ -64,8 +63,8 @@ class BondInterface: a = BondInterface(ifname='bond0') a.delete() """ - bond_if_index = vpp.get_sw_if_index(self.ifname) - vpp.api.bond_delete(sw_if_index=bond_if_index) + bond_if_index = self.vpp.get_sw_if_index(self.ifname) + self.vpp.api.bond_delete(sw_if_index=bond_if_index) def add_member(self, interface): """Add member to Bond interface @@ -74,13 +73,15 @@ class BondInterface: a = BondInterface(ifname='bond0') a.add_member(interface='eth0') """ - bond_if_index = vpp.get_sw_if_index(f'BondEthernet{self.instance}') - member_if_index = vpp.get_sw_if_index(interface) - member_if_type = vpp.get_sw_if_dev_type(interface) - vpp.api.bond_add_member( + bond_if_index = self.vpp.get_sw_if_index(f'BondEthernet{self.instance}') + member_if_index = self.vpp.get_sw_if_index(interface) + member_if_type = self.vpp.get_sw_if_dev_type(interface) + self.vpp.api.bond_add_member( bond_sw_if_index=bond_if_index, sw_if_index=member_if_index ) - vpp.api.sw_interface_set_promisc(sw_if_index=member_if_index, promisc_on=True) + self.vpp.api.sw_interface_set_promisc( + sw_if_index=member_if_index, promisc_on=True + ) if member_if_type == 'AF_XDP interface': set_promisc(f'defunct_{interface}', 'on') @@ -91,8 +92,8 @@ class BondInterface: a = BondInterface(ifname='bond0') a.detach_member(interface='eth0') """ - member_if_index = vpp.get_sw_if_index(interface) - vpp.api.bond_detach_member(sw_if_index=member_if_index) + member_if_index = self.vpp.get_sw_if_index(interface) + self.vpp.api.bond_detach_member(sw_if_index=member_if_index) def kernel_add(self): """Add LCP pair @@ -101,7 +102,7 @@ class BondInterface: a = BondInterface(ifname='bond0', mode=5) a.kernel_add() """ - vpp.lcp_pair_add(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) def kernel_delete(self): """Delete LCP pair @@ -110,4 +111,4 @@ class BondInterface: a = BondInterface(ifname='bond0', mode=5) a.kernel_delete() """ - vpp.lcp_pair_del(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/bridge.py b/python/vyos/vpp/interface/bridge.py index e220547de..407eab08c 100644 --- a/python/vyos/vpp/interface/bridge.py +++ b/python/vyos/vpp/interface/bridge.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2023-2024 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,8 +17,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - class BridgeInterface: def __init__( @@ -37,6 +35,7 @@ class BridgeInterface: self.learn = learn self.uu_flood = uu_flood self.arp_term = arp_term + self.vpp = VPPControl() def add(self): """Create Bridge interface @@ -49,7 +48,7 @@ class BridgeInterface: a = BridgeInterface(ifname='br23') a.add() """ - vpp.api.bridge_domain_add_del_v2( + self.vpp.api.bridge_domain_add_del_v2( is_add=True, bd_id=self.interface_suffix, flood=self.flood, @@ -69,7 +68,7 @@ class BridgeInterface: a = BridgeInterface(ifname='br23') a.delete() """ - vpp.api.bridge_domain_add_del_v2(is_add=False, bd_id=self.interface_suffix) + self.vpp.api.bridge_domain_add_del_v2(is_add=False, bd_id=self.interface_suffix) def add_member(self, member: str | int): """Add member to Bridge interface @@ -94,9 +93,9 @@ class BridgeInterface: elif member.isdigit(): member_if_index = int(member) else: - member_if_index = vpp.get_sw_if_index(member) + member_if_index = self.vpp.get_sw_if_index(member) - return vpp.api.sw_interface_set_l2_bridge( + return self.vpp.api.sw_interface_set_l2_bridge( rx_sw_if_index=member_if_index, bd_id=bridge_index, port_type=0 ) @@ -121,8 +120,8 @@ class BridgeInterface: elif member.isdigit(): member_if_index = int(member) else: - member_if_index = vpp.get_sw_if_index(member) + member_if_index = self.vpp.get_sw_if_index(member) - return vpp.api.sw_interface_set_l2_bridge( + return self.vpp.api.sw_interface_set_l2_bridge( rx_sw_if_index=member_if_index, bd_id=0, port_type=0 ) diff --git a/python/vyos/vpp/interface/ethernet.py b/python/vyos/vpp/interface/ethernet.py index ddd3dc42f..368d94c7f 100644 --- a/python/vyos/vpp/interface/ethernet.py +++ b/python/vyos/vpp/interface/ethernet.py @@ -1,6 +1,6 @@ # VyOS implementation of VPP Ethernet interface # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - class EthernetInterface: """Interface Ethernet""" @@ -28,6 +26,7 @@ class EthernetInterface: self.instance = int(ifname.removeprefix('eth')) self.ifname = ifname self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): pass @@ -42,7 +41,7 @@ class EthernetInterface: a = EthernetInterface(ifname='eth0') a.kernel_add() """ - vpp.lcp_pair_add(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) def kernel_delete(self): """Delete LCP pair @@ -51,4 +50,4 @@ class EthernetInterface: a = EthernetInterface(ifname='eth0') a.kernel_delete() """ - vpp.lcp_pair_del(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/geneve.py b/python/vyos/vpp/interface/geneve.py index 3d5e75003..a55e29d80 100644 --- a/python/vyos/vpp/interface/geneve.py +++ b/python/vyos/vpp/interface/geneve.py @@ -1,6 +1,6 @@ # VyOS implementation of Geneve interface # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - def show(): """Show Geneve interface @@ -38,6 +36,7 @@ class GeneveInterface: self.dst_address = remote self.vni = vni self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): """Create Geneve interface @@ -48,7 +47,7 @@ class GeneveInterface: a = GeneveInterface(ifname='geneve25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) a.add() """ - return vpp.api.geneve_add_del_tunnel2( + return self.vpp.api.geneve_add_del_tunnel2( is_add=True, local_address=self.src_address, remote_address=self.dst_address, @@ -63,7 +62,7 @@ class GeneveInterface: a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) a.delete() """ - return vpp.api.geneve_add_del_tunnel2( + return self.vpp.api.geneve_add_del_tunnel2( is_add=False, local_address=self.src_address, remote_address=self.dst_address, @@ -78,7 +77,7 @@ class GeneveInterface: a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) a.kernel_add() """ - vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') def kernel_delete(self): """Delete LCP pair @@ -87,4 +86,4 @@ class GeneveInterface: a = GeneveInterface(ifname='vxlan25', source_address='192.0.2.1', remote='203.0.113.25', vni=25) a.kernel_delete() """ - vpp.lcp_pair_del(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/gre.py b/python/vyos/vpp/interface/gre.py index 0017127ec..8a9128836 100644 --- a/python/vyos/vpp/interface/gre.py +++ b/python/vyos/vpp/interface/gre.py @@ -1,6 +1,6 @@ # VyOS implementation of VPP GRE interface # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - def show(): """Show GRE interface @@ -37,6 +35,7 @@ class GREInterface: self.src_address = source_address self.dst_address = remote self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): """Create GRE interface @@ -46,7 +45,7 @@ class GREInterface: a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') a.add() """ - vpp.api.gre_tunnel_add_del( + self.vpp.api.gre_tunnel_add_del( is_add=True, tunnel={ 'src': self.src_address, @@ -62,7 +61,7 @@ class GREInterface: a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') a.delete() """ - return vpp.api.gre_tunnel_add_del( + return self.vpp.api.gre_tunnel_add_del( is_add=False, tunnel={'src': self.src_address, 'dst': self.dst_address} ) @@ -73,7 +72,7 @@ class GREInterface: a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') a.kernel_add() """ - vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') def kernel_delete(self): """Delete LCP pair @@ -82,4 +81,4 @@ class GREInterface: a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') a.kernel_delete() """ - vpp.lcp_pair_del(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/ipip.py b/python/vyos/vpp/interface/ipip.py index e1f19735b..031b0a698 100644 --- a/python/vyos/vpp/interface/ipip.py +++ b/python/vyos/vpp/interface/ipip.py @@ -1,6 +1,6 @@ # VyOS implementation of VPP IPIP interface # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - def show(): """Show IPIP interface @@ -37,6 +35,7 @@ class IPIPInterface: self.src_address = source_address self.dst_address = remote self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): """Create IPIP interface @@ -46,7 +45,7 @@ class IPIPInterface: a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') a.add() """ - vpp.api.ipip_add_tunnel( + self.vpp.api.ipip_add_tunnel( tunnel={ 'src': self.src_address, 'dst': self.dst_address, @@ -61,8 +60,8 @@ class IPIPInterface: a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') a.delete() """ - ipip_if_index = vpp.get_sw_if_index(f'ipip{self.instance}') - return vpp.api.ipip_del_tunnel(sw_if_index=ipip_if_index) + ipip_if_index = self.vpp.get_sw_if_index(f'ipip{self.instance}') + return self.vpp.api.ipip_del_tunnel(sw_if_index=ipip_if_index) def kernel_add(self): """Add LCP pair @@ -71,7 +70,7 @@ class IPIPInterface: a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') a.kernel_add() """ - vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') def kernel_delete(self): """Delete LCP pair @@ -80,4 +79,4 @@ class IPIPInterface: a = IPIPInterface(ifname='ipip0', source_address='192.0.2.1', remote='192.0.2.5') a.kernel_delete() """ - vpp.lcp_pair_del(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/loopback.py b/python/vyos/vpp/interface/loopback.py index df7b82356..8a5414d54 100644 --- a/python/vyos/vpp/interface/loopback.py +++ b/python/vyos/vpp/interface/loopback.py @@ -1,6 +1,6 @@ # VyOS implementation of VPP Loopback interface # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - class LoopbackInterface: """Interface Loopback""" @@ -28,6 +26,7 @@ class LoopbackInterface: self.instance = int(ifname.removeprefix('lo')) self.ifname = f'loop{self.instance}' self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): """Create Loopback interface @@ -37,7 +36,9 @@ class LoopbackInterface: a = LoopbackInterface(ifname='lo1') a.add() """ - vpp.api.create_loopback_instance(is_specified=True, user_instance=self.instance) + self.vpp.api.create_loopback_instance( + is_specified=True, user_instance=self.instance + ) def delete(self): """Delete Loopback interface @@ -46,8 +47,8 @@ class LoopbackInterface: a = LoopbackInterface(ifname='lo1') a.delete() """ - loopback_if_index = vpp.get_sw_if_index(f'loop{self.instance}') - return vpp.api.delete_loopback(sw_if_index=loopback_if_index) + loopback_if_index = self.vpp.get_sw_if_index(f'loop{self.instance}') + return self.vpp.api.delete_loopback(sw_if_index=loopback_if_index) def kernel_add(self): """Add LCP pair @@ -56,7 +57,7 @@ class LoopbackInterface: a = LoopbackInterface(ifname='lo1') a.kernel_add() """ - vpp.lcp_pair_add(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) def kernel_delete(self): """Delete LCP pair @@ -65,4 +66,4 @@ class LoopbackInterface: a = LoopbackInterface(ifname='lo1') a.kernel_delete() """ - vpp.lcp_pair_del(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/vxlan.py b/python/vyos/vpp/interface/vxlan.py index 907fcbee0..91e7b3059 100644 --- a/python/vyos/vpp/interface/vxlan.py +++ b/python/vyos/vpp/interface/vxlan.py @@ -1,6 +1,6 @@ # VyOS implementation of VPP VXLAN interface # -# Copyright (C) 2023-2024 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - def show(): """Show VXLAN interface @@ -40,6 +38,7 @@ class VXLANInterface: self.dst_address = remote self.vni = vni self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): """Create VXLAN interface @@ -50,7 +49,7 @@ class VXLANInterface: a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) a.add() """ - vpp.api.vxlan_add_del_tunnel_v3( + self.vpp.api.vxlan_add_del_tunnel_v3( is_add=True, src_address=self.src_address, dst_address=self.dst_address, @@ -67,7 +66,7 @@ class VXLANInterface: a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) a.delete() """ - return vpp.api.vxlan_add_del_tunnel_v3( + return self.vpp.api.vxlan_add_del_tunnel_v3( is_add=False, src_address=self.src_address, dst_address=self.dst_address, @@ -82,7 +81,7 @@ class VXLANInterface: a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) a.kernel_add() """ - vpp.lcp_pair_add(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) def kernel_delete(self): """Delete LCP pair @@ -91,4 +90,4 @@ class VXLANInterface: a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) a.kernel_delete() """ - vpp.lcp_pair_del(self.ifname, self.kernel_interface) + self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) diff --git a/python/vyos/vpp/interface/wireguard.py b/python/vyos/vpp/interface/wireguard.py index 582929851..649ed5b05 100644 --- a/python/vyos/vpp/interface/wireguard.py +++ b/python/vyos/vpp/interface/wireguard.py @@ -1,6 +1,6 @@ # VyOS implementation of VPP Wireguard interface # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -18,8 +18,6 @@ from vyos.vpp import VPPControl -vpp = VPPControl() - class WireguardInterface: """Interface Wireguard""" @@ -29,6 +27,7 @@ class WireguardInterface: self.ifname = ifname self.listen_port = listen_port self.kernel_interface = kernel_interface + self.vpp = VPPControl() def add(self): """Create Wireguard interface @@ -38,12 +37,12 @@ class WireguardInterface: a = WireguardInterface(ifname='wg5', listen_port=51820, generate_key=True) a.add() """ - vpp.api.wireguard_interface_create( + self.vpp.api.wireguard_interface_create( generate_key=True, interface={'user_instance': self.instance, 'listen_port': self.listen_port}, ) if self.kernel_interface: - vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') + self.vpp.lcp_pair_add(self.ifname, self.kernel_interface, 'tun') def delete(self): """Delete Wireguard interface @@ -52,5 +51,5 @@ class WireguardInterface: a = WireguardInterface(ifname='wg5') a.delete() """ - wg_if_index = vpp.get_sw_if_index(f'wg{self.instance}') - return vpp.api.wireguard_interface_delete(sw_if_index=wg_if_index) + wg_if_index = self.vpp.get_sw_if_index(f'wg{self.instance}') + return self.vpp.api.wireguard_interface_delete(sw_if_index=wg_if_index) diff --git a/python/vyos/vpp/interface/xconnect.py b/python/vyos/vpp/interface/xconnect.py index b4bec3321..41cf773d9 100644 --- a/python/vyos/vpp/interface/xconnect.py +++ b/python/vyos/vpp/interface/xconnect.py @@ -1,6 +1,6 @@ # VyOS implementation of VPP bridge interface # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -19,8 +19,6 @@ from vyos.vpp import VPPControl from vyos.vpp.utils import iftunnel_transform -vpp = VPPControl() - class XconnectInterface: def __init__( @@ -30,6 +28,7 @@ class XconnectInterface: ): self.ifname = ifname self.members = members + self.vpp = VPPControl() def add_l2_xconnect(self): """Add l2 cross connect @@ -49,14 +48,14 @@ class XconnectInterface: if second_member.startswith(interface_transform_filter): second_member = iftunnel_transform(second_member) - member_first_if_index = vpp.get_sw_if_index(first_member) - member_second_if_index = vpp.get_sw_if_index(second_member) - vpp.api.sw_interface_set_l2_xconnect( + member_first_if_index = self.vpp.get_sw_if_index(first_member) + member_second_if_index = self.vpp.get_sw_if_index(second_member) + self.vpp.api.sw_interface_set_l2_xconnect( rx_sw_if_index=member_first_if_index, tx_sw_if_index=member_second_if_index, enable=True, ) - vpp.api.sw_interface_set_l2_xconnect( + self.vpp.api.sw_interface_set_l2_xconnect( rx_sw_if_index=member_second_if_index, tx_sw_if_index=member_first_if_index, enable=True, @@ -80,14 +79,14 @@ class XconnectInterface: if second_member.startswith(interface_transform_filter): second_member = iftunnel_transform(second_member) - member_first_if_index = vpp.get_sw_if_index(first_member) - member_second_if_index = vpp.get_sw_if_index(second_member) - vpp.api.sw_interface_set_l2_xconnect( + member_first_if_index = self.vpp.get_sw_if_index(first_member) + member_second_if_index = self.vpp.get_sw_if_index(second_member) + self.vpp.api.sw_interface_set_l2_xconnect( rx_sw_if_index=member_first_if_index, tx_sw_if_index=member_second_if_index, enable=False, ) - vpp.api.sw_interface_set_l2_xconnect( + self.vpp.api.sw_interface_set_l2_xconnect( rx_sw_if_index=member_second_if_index, tx_sw_if_index=member_first_if_index, enable=False, -- cgit v1.2.3 From bd2ea9f20502171b12f8bb4d900f504af9c446f1 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Sat, 11 Jan 2025 15:12:42 +0000 Subject: Use VPPControl for the show class interfaces --- python/vyos/vpp/interface/geneve.py | 1 + python/vyos/vpp/interface/gre.py | 1 + python/vyos/vpp/interface/ipip.py | 1 + python/vyos/vpp/interface/vxlan.py | 1 + 4 files changed, 4 insertions(+) (limited to 'python') diff --git a/python/vyos/vpp/interface/geneve.py b/python/vyos/vpp/interface/geneve.py index a55e29d80..8260b1bfa 100644 --- a/python/vyos/vpp/interface/geneve.py +++ b/python/vyos/vpp/interface/geneve.py @@ -25,6 +25,7 @@ def show(): from vyos.vpp.interface import geneve geneve.show() """ + vpp = VPPControl() return vpp.api.geneve_tunnel_dump() diff --git a/python/vyos/vpp/interface/gre.py b/python/vyos/vpp/interface/gre.py index 8a9128836..20a7ae9d4 100644 --- a/python/vyos/vpp/interface/gre.py +++ b/python/vyos/vpp/interface/gre.py @@ -25,6 +25,7 @@ def show(): from vyos.vpp.interface import gre gre.show() """ + vpp = VPPControl() return vpp.api.gre_tunnel_dump() diff --git a/python/vyos/vpp/interface/ipip.py b/python/vyos/vpp/interface/ipip.py index 031b0a698..1aa1e22f2 100644 --- a/python/vyos/vpp/interface/ipip.py +++ b/python/vyos/vpp/interface/ipip.py @@ -25,6 +25,7 @@ def show(): from vyos.vpp.interface import ipip ipip.show() """ + vpp = VPPControl() return vpp.api.ipip_tunnel_dump() diff --git a/python/vyos/vpp/interface/vxlan.py b/python/vyos/vpp/interface/vxlan.py index 91e7b3059..35b3f9fc1 100644 --- a/python/vyos/vpp/interface/vxlan.py +++ b/python/vyos/vpp/interface/vxlan.py @@ -25,6 +25,7 @@ def show(): from vyos.vpp.interface import vxlan vxlan.show() """ + vpp = VPPControl() return vpp.api.vxlan_tunnel_dump() -- cgit v1.2.3 From 1b6e491537c98d396b25aaf734b66b6ba5413dac Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Tue, 14 Jan 2025 12:29:23 +0200 Subject: vpp: add example of adding vxlan kernel_add --- python/vyos/vpp/interface/vxlan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/vpp/interface/vxlan.py b/python/vyos/vpp/interface/vxlan.py index 35b3f9fc1..36206e8c4 100644 --- a/python/vyos/vpp/interface/vxlan.py +++ b/python/vyos/vpp/interface/vxlan.py @@ -79,7 +79,7 @@ class VXLANInterface: """Add LCP pair Example: from vyos.vpp.interface import VXLANInterface - a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23) + a = VXLANInterface(ifname='vxlan23', source_address='192.0.2.1', remote='203.0.113.23', vni=23, kernel_interface='vpptap10') a.kernel_add() """ self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) -- cgit v1.2.3 From 2212a438b234f34f32e08efef2f841ba55a3b6a0 Mon Sep 17 00:00:00 2001 From: sskaje Date: Tue, 31 Dec 2024 10:44:01 +0800 Subject: wireguard: T4930: allow peers via FQDN * set interfaces wireguard wgXX peer YY hostname --- interface-definitions/interfaces_wireguard.xml.in | 25 +++ op-mode-definitions/reset-wireguard.xml.in | 34 ++++ python/vyos/ifconfig/control.py | 4 +- python/vyos/ifconfig/wireguard.py | 196 +++++++++++++++++----- src/conf_mode/interfaces_wireguard.py | 40 ++++- src/conf_mode/nat.py | 8 +- src/op_mode/reset_wireguard.py | 55 ++++++ src/services/vyos-domain-resolver | 48 ++++++ 8 files changed, 360 insertions(+), 50 deletions(-) create mode 100644 op-mode-definitions/reset-wireguard.xml.in create mode 100755 src/op_mode/reset_wireguard.py (limited to 'python') diff --git a/interface-definitions/interfaces_wireguard.xml.in b/interface-definitions/interfaces_wireguard.xml.in index ce49de038..4f8b6c751 100644 --- a/interface-definitions/interfaces_wireguard.xml.in +++ b/interface-definitions/interfaces_wireguard.xml.in @@ -40,6 +40,19 @@ 0 + + + DNS retries when resolve fails + + u32:1-15 + Maximum number of retries + + + + + + 3 + Base64 encoded private key @@ -104,6 +117,18 @@ + + + Hostname of tunnel endpoint + + hostname + FQDN of WireGuard endpoint + + + + + + #include diff --git a/op-mode-definitions/reset-wireguard.xml.in b/op-mode-definitions/reset-wireguard.xml.in new file mode 100644 index 000000000..c2243f519 --- /dev/null +++ b/op-mode-definitions/reset-wireguard.xml.in @@ -0,0 +1,34 @@ + + + + + + + Reset WireGuard Peers + + + + + WireGuard interface name + + interfaces wireguard + + + sudo ${vyos_op_scripts_dir}/reset_wireguard.py reset_peer --interface="$4" + + + + WireGuard peer name + + interfaces wireguard ${COMP_WORDS[3]} peer + + + sudo ${vyos_op_scripts_dir}/reset_wireguard.py reset_peer --interface="$4" --peer="$6" + + + + + + + + diff --git a/python/vyos/ifconfig/control.py b/python/vyos/ifconfig/control.py index 7402da55a..a886c1b9e 100644 --- a/python/vyos/ifconfig/control.py +++ b/python/vyos/ifconfig/control.py @@ -48,7 +48,7 @@ class Control(Section): def _popen(self, command): return popen(command, self.debug) - def _cmd(self, command): + def _cmd(self, command, env=None): import re if 'netns' in self.config: # This command must be executed from default netns 'ip link set dev X netns X' @@ -61,7 +61,7 @@ class Control(Section): command = command else: command = f'ip netns exec {self.config["netns"]} {command}' - return cmd(command, self.debug) + return cmd(command, self.debug, env=env) def _get_command(self, config, name): """ diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 519012625..9d0bd1a6c 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -25,6 +25,7 @@ from hurry.filesize import alternative from vyos.ifconfig import Interface from vyos.ifconfig import Operational from vyos.template import is_ipv6 +from vyos.template import is_ipv4 class WireGuardOperational(Operational): def _dump(self): @@ -90,12 +91,15 @@ class WireGuardOperational(Operational): c.set_level(['interfaces', 'wireguard', self.config['ifname']]) description = c.return_effective_value(['description']) ips = c.return_effective_values(['address']) + hostnames = c.return_effective_values(['host-name']) answer = 'interface: {}\n'.format(self.config['ifname']) if description: answer += ' description: {}\n'.format(description) if ips: answer += ' address: {}\n'.format(', '.join(ips)) + if hostnames: + answer += ' hostname: {}\n'.format(', '.join(hostnames)) answer += ' public key: {}\n'.format(wgdump['public_key']) answer += ' private key: (hidden)\n' @@ -155,6 +159,94 @@ class WireGuardOperational(Operational): answer += '\n' return answer + def get_latest_handshakes(self): + """Get latest handshake time for each peer""" + output = {} + + # Dump wireguard last handshake + tmp = self._cmd(f'wg show {self.ifname} latest-handshakes') + # Output: + # PUBLIC-KEY= 1732812147 + for line in tmp.split('\n'): + if not line: + # Skip empty lines and last line + continue + items = line.split('\t') + + if len(items) != 2: + continue + + output[items[0]] = int(items[1]) + + return output + + def reset_peer(self, peer_name=None, public_key=None): + from vyos.configquery import ConfigTreeQuery + + c = ConfigTreeQuery() + + max_dns_retry = c.value( + ['interfaces', 'wireguard', self.ifname, 'max-dns-retry'] + ) + if max_dns_retry is None: + max_dns_retry = 3 + + current_peers = self._dump().get(self.ifname, {}).get('peers', {}) + + for peer in c.list_nodes(['interfaces', 'wireguard', self.ifname, 'peer']): + peer_public_key = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'public-key'] + ) + if peer_name is None or peer == peer_name or public_key == peer_public_key: + address = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'address'] + ) + host_name = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'host-name'] + ) + port = c.value( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'port'] + ) + + if (not address and not host_name) or not port: + if peer_name is not None: + print(f'Peer {peer_name} endpoint not set') + continue + + # address has higher priority than host-name + if address: + new_endpoint = f'{address}:{port}' + else: + new_endpoint = f'{host_name}:{port}' + + if c.exists( + ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'disable'] + ): + continue + + cmd = f'wg set {self.ifname} peer {peer_public_key} endpoint {new_endpoint}' + try: + if ( + peer_public_key in current_peers + and 'endpoint' in current_peers[peer_public_key] + and current_peers[peer_public_key]['endpoint'] is not None + ): + current_endpoint = current_peers[peer_public_key]['endpoint'] + message = f'Resetting {self.ifname} peer {peer_public_key} from {current_endpoint} endpoint to {new_endpoint} ... ' + else: + message = f'Resetting {self.ifname} peer {peer_public_key} endpoint to {new_endpoint} ... ' + print( + message, + end='', + ) + + self._cmd( + cmd, env={'WG_ENDPOINT_RESOLUTION_RETRIES': str(max_dns_retry)} + ) + print('done') + except: + print(f'Error\nPlease try to run command manually:\n{cmd}\n') + @Interface.register class WireGuardIf(Interface): @@ -186,16 +278,23 @@ class WireGuardIf(Interface): tmp_file.flush() # Wireguard base command is identical for every peer - base_cmd = 'wg set {ifname}' + base_cmd = 'wg set ' + config['ifname'] + max_dns_retry = config['max_dns_retry'] if 'max_dns_retry' in config else 3 + + interface_cmd = base_cmd if 'port' in config: - base_cmd += ' listen-port {port}' + interface_cmd += ' listen-port {port}' if 'fwmark' in config: - base_cmd += ' fwmark {fwmark}' + interface_cmd += ' fwmark {fwmark}' - base_cmd += f' private-key {tmp_file.name}' - base_cmd = base_cmd.format(**config) + interface_cmd += f' private-key {tmp_file.name}' + interface_cmd = interface_cmd.format(**config) # T6490: execute command to ensure interface configured - self._cmd(base_cmd) + self._cmd(interface_cmd) + + # If no PSK is given remove it by using /dev/null - passing keys via + # the shell (usually bash) is considered insecure, thus we use a file + no_psk_file = '/dev/null' if 'peer' in config: for peer, peer_config in config['peer'].items(): @@ -203,43 +302,62 @@ class WireGuardIf(Interface): # marked as disabled - also active sessions are terminated as # the public key was already removed when entering this method! if 'disable' in peer_config: + # remove peer if disabled, no error report even if peer not exists + cmd = base_cmd + ' peer {public_key} remove' + self._cmd(cmd.format(**peer_config)) continue - # start of with a fresh 'wg' command - cmd = base_cmd + ' peer {public_key}' - - # If no PSK is given remove it by using /dev/null - passing keys via - # the shell (usually bash) is considered insecure, thus we use a file - no_psk_file = '/dev/null' psk_file = no_psk_file - if 'preshared_key' in peer_config: - psk_file = '/tmp/tmp.wireguard.psk' - with open(psk_file, 'w') as f: - f.write(peer_config['preshared_key']) - cmd += f' preshared-key {psk_file}' - - # Persistent keepalive is optional - if 'persistent_keepalive' in peer_config: - cmd += ' persistent-keepalive {persistent_keepalive}' - - # Multiple allowed-ip ranges can be defined - ensure we are always - # dealing with a list - if isinstance(peer_config['allowed_ips'], str): - peer_config['allowed_ips'] = [peer_config['allowed_ips']] - cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) - - # Endpoint configuration is optional - if {'address', 'port'} <= set(peer_config): - if is_ipv6(peer_config['address']): - cmd += ' endpoint [{address}]:{port}' - else: - cmd += ' endpoint {address}:{port}' - - self._cmd(cmd.format(**peer_config)) - # PSK key file is not required to be stored persistently as its backed by CLI - if psk_file != no_psk_file and os.path.exists(psk_file): - os.remove(psk_file) + # start of with a fresh 'wg' command + peer_cmd = base_cmd + ' peer {public_key}' + + try: + cmd = peer_cmd + + if 'preshared_key' in peer_config: + psk_file = '/tmp/tmp.wireguard.psk' + with open(psk_file, 'w') as f: + f.write(peer_config['preshared_key']) + cmd += f' preshared-key {psk_file}' + + # Persistent keepalive is optional + if 'persistent_keepalive' in peer_config: + cmd += ' persistent-keepalive {persistent_keepalive}' + + # Multiple allowed-ip ranges can be defined - ensure we are always + # dealing with a list + if isinstance(peer_config['allowed_ips'], str): + peer_config['allowed_ips'] = [peer_config['allowed_ips']] + cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) + + self._cmd(cmd.format(**peer_config)) + + cmd = peer_cmd + + # Ensure peer is created even if dns not working + if {'address', 'port'} <= set(peer_config): + if is_ipv6(peer_config['address']): + cmd += ' endpoint [{address}]:{port}' + elif is_ipv4(peer_config['address']): + cmd += ' endpoint {address}:{port}' + else: + # don't set endpoint if address uses domain name + continue + elif {'host_name', 'port'} <= set(peer_config): + cmd += ' endpoint {host_name}:{port}' + + self._cmd( + cmd.format(**peer_config), + env={'WG_ENDPOINT_RESOLUTION_RETRIES': str(max_dns_retry)}, + ) + except: + # todo: logging + pass + finally: + # PSK key file is not required to be stored persistently as its backed by CLI + if psk_file != no_psk_file and os.path.exists(psk_file): + os.remove(psk_file) # call base class super().update(config) diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index b6fd6b0b2..1dbaa9d4e 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -29,11 +29,12 @@ from vyos.ifconfig import WireGuardIf from vyos.utils.kernel import check_kmod from vyos.utils.network import check_port_availability from vyos.utils.network import is_wireguard_key_pair +from vyos.utils.process import call from vyos import ConfigError from vyos import airbag +from pathlib import Path airbag.enable() - def get_config(config=None): """ Retrive CLI config as dictionary. Dictionary can never be empty, as at least the @@ -54,6 +55,12 @@ def get_config(config=None): if is_node_changed(conf, base + [ifname, 'peer']): wireguard.update({'rebuild_required': {}}) + wireguard['peers_need_resolve'] = [] + if 'peer' in wireguard: + for peer, peer_config in wireguard['peer'].items(): + if 'disable' not in peer_config and 'host_name' in peer_config: + wireguard['peers_need_resolve'].append(peer) + return wireguard def verify(wireguard): @@ -82,16 +89,15 @@ def verify(wireguard): for tmp in wireguard['peer']: peer = wireguard['peer'][tmp] + if 'host_name' in peer and 'address' in peer: + raise ConfigError('"host-name" and "address" are mutually exclusive') + if 'allowed_ips' not in peer: raise ConfigError(f'Wireguard allowed-ips required for peer "{tmp}"!') if 'public_key' not in peer: raise ConfigError(f'Wireguard public-key required for peer "{tmp}"!') - if ('address' in peer and 'port' not in peer) or ('port' in peer and 'address' not in peer): - raise ConfigError('Both Wireguard port and address must be defined ' - f'for peer "{tmp}" if either one of them is set!') - if peer['public_key'] in public_keys: raise ConfigError(f'Duplicate public-key defined on peer "{tmp}"') @@ -99,6 +105,13 @@ def verify(wireguard): if is_wireguard_key_pair(wireguard['private_key'], peer['public_key']): raise ConfigError(f'Peer "{tmp}" has the same public key as the interface "{wireguard["ifname"]}"') + if 'port' not in peer: + if 'host_name' in peer or 'address' in peer: + raise ConfigError(f'Missing "host-name" or "address" on peer "{tmp}"') + else: + if 'host_name' not in peer and 'address' not in peer: + raise ConfigError(f'Missing "host-name" and "address" on peer "{tmp}"') + public_keys.append(peer['public_key']) def generate(wireguard): @@ -122,6 +135,23 @@ def apply(wireguard): wg = WireGuardIf(**wireguard) wg.update(wireguard) + domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname'] + + ## DOMAIN RESOLVER + domain_action = 'restart' + if 'peers_need_resolve' in wireguard and len(wireguard['peers_need_resolve']) > 0 and 'disable' not in wireguard: + from vyos.utils.file import write_file + + text = f'# Automatically generated by interfaces_wireguard.py\nThis file indicates that vyos-domain-resolver service is used by the interfaces_wireguard.\n' + text += "intefaces:\n" + "".join([f" - {peer}\n" for peer in wireguard['peers_need_resolve']]) + Path(domain_resolver_usage).write_text(text) + write_file(domain_resolver_usage, text) + else: + Path(domain_resolver_usage).unlink(missing_ok=True) + if not Path('/run').glob('use-vyos-domain-resolver*'): + domain_action = 'stop' + call(f'systemctl {domain_action} vyos-domain-resolver.service') + return None if __name__ == '__main__': diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 98b2f3f29..504b3e82a 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -17,6 +17,7 @@ import os from sys import exit +from pathlib import Path from vyos.base import Warning from vyos.config import Config @@ -43,7 +44,6 @@ k_mod = ['nft_nat', 'nft_chain_nat'] nftables_nat_config = '/run/nftables_nat.conf' nftables_static_nat_conf = '/run/nftables_static-nat-rules.nft' domain_resolver_usage = '/run/use-vyos-domain-resolver-nat' -domain_resolver_usage_firewall = '/run/use-vyos-domain-resolver-firewall' valid_groups = [ 'address_group', @@ -265,9 +265,9 @@ def apply(nat): text = f'# Automatically generated by nat.py\nThis file indicates that vyos-domain-resolver service is used by nat.\n' write_file(domain_resolver_usage, text) elif os.path.exists(domain_resolver_usage): - os.unlink(domain_resolver_usage) - if not os.path.exists(domain_resolver_usage_firewall): - # Firewall not using domain resolver + Path(domain_resolver_usage).unlink(missing_ok=True) + + if not Path('/run').glob('use-vyos-domain-resolver*'): domain_action = 'stop' call(f'systemctl {domain_action} vyos-domain-resolver.service') diff --git a/src/op_mode/reset_wireguard.py b/src/op_mode/reset_wireguard.py new file mode 100755 index 000000000..1fcfb31b5 --- /dev/null +++ b/src/op_mode/reset_wireguard.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import sys +import typing + +import vyos.opmode + +from vyos.ifconfig import WireGuardIf +from vyos.configquery import ConfigTreeQuery + + +def _verify(func): + """Decorator checks if WireGuard interface config exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + interface = kwargs.get('interface') + if not config.exists(['interfaces', 'wireguard', interface]): + unconf_message = f'WireGuard interface {interface} is not configured' + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + return func(*args, **kwargs) + + return _wrapper + + +@_verify +def reset_peer(interface: str, peer: typing.Optional[str] = None): + intf = WireGuardIf(interface, create=False, debug=False) + return intf.operational.reset_peer(peer) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index bc74a05d1..6eab7e7e5 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -27,12 +27,14 @@ from vyos.utils.dict import dict_search_args from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults +from vyos.template import is_ip base = ['firewall'] timeout = 300 cache = False base_firewall = ['firewall'] base_nat = ['nat'] +base_interfaces = ['interfaces'] domain_state = {} @@ -171,6 +173,50 @@ def update_fqdn(config, node): logger.info(f'Updated {count} sets in {node} - result: {code}') +def update_interfaces(config, node): + if node == 'interfaces': + wireguard_interfaces = dict_search_args(config, 'wireguard') + + # WireGuard redo handshake usually every 180 seconds, but not documented officially. + # If peer with domain name in its endpoint didn't get handshake for over 300 seconds, + # we do re-resolv and reset its endpoint from config tree. + handshake_threshold = 300 + + from vyos.ifconfig import WireGuardIf + + check_wireguard_peer_public_keys = {} + # for each wireguard interfaces + for interface, wireguard in wireguard_interfaces.items(): + check_wireguard_peer_public_keys[interface] = [] + for peer, peer_config in wireguard['peer'].items(): + # check peer if peer host-name or address is set + if 'host-name' in peer_config or 'address' in peer_config: + # check latest handshake + check_wireguard_peer_public_keys[interface].append( + peer_config['public_key'] + ) + + now_time = time.time() + for ( + interface, + check_peer_public_keys + ) in check_wireguard_peer_public_keys.items(): + if len(check_peer_public_keys) == 0: + continue + + intf = WireGuardIf(interface, create=False, debug=False) + handshakes = intf.operational.get_latest_handshakes() + + for public_key, handshake_time in handshakes.items(): + if public_key in check_peer_public_keys and ( + handshake_time == 0 + or now_time - handshake_time > handshake_threshold + ): + intf.operational.reset_peer(public_key=public_key) + + print(f'Wireguard: reset {interface} peer {public_key}') + + if __name__ == '__main__': logger.info(f'VyOS domain resolver') @@ -184,10 +230,12 @@ if __name__ == '__main__': conf = ConfigTreeQuery() firewall = get_config(conf, base_firewall) nat = get_config(conf, base_nat) + interfaces = get_config(conf, base_interfaces) logger.info(f'interval: {timeout}s - cache: {cache}') while True: update_fqdn(firewall, 'firewall') update_fqdn(nat, 'nat') + update_interfaces(interfaces, 'interfaces') time.sleep(timeout) -- cgit v1.2.3 From f01c4d0173bb49bfd5bd4f1ef5675cc8c597595a Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 18 Jan 2025 23:06:37 +0100 Subject: wireguard: T4930: add mnemonic for WIREGUARD_REKEY_AFTER_TIME WireGuard performs a handshake every WIREGUARD_REKEY_AFTER_TIME if data is being transmitted between the peers. If no data is transmitted, the handshake will not be initiated unless new data begins to flow. Each handshake generates a new session key, and the key is rotated at least every 120 seconds or upon data transmission after a prolonged silence. --- python/vyos/utils/kernel.py | 4 ++++ src/services/vyos-domain-resolver | 38 +++++++++++++++++--------------------- 2 files changed, 21 insertions(+), 21 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/kernel.py b/python/vyos/utils/kernel.py index 847f80108..05eac8a6a 100644 --- a/python/vyos/utils/kernel.py +++ b/python/vyos/utils/kernel.py @@ -15,6 +15,10 @@ import os +# A list of used Kernel constants +# https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/drivers/net/wireguard/messages.h?h=linux-6.6.y#n45 +WIREGUARD_REKEY_AFTER_TIME = 120 + def check_kmod(k_mod): """ Common utility function to load required kernel modules on demand """ from vyos import ConfigError diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index 6eab7e7e5..a4b0869fa 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -22,12 +22,13 @@ from vyos.configdict import dict_merge from vyos.configquery import ConfigTreeQuery from vyos.firewall import fqdn_config_parse from vyos.firewall import fqdn_resolve +from vyos.ifconfig import WireGuardIf from vyos.utils.commit import commit_in_progress from vyos.utils.dict import dict_search_args +from vyos.utils.kernel import WIREGUARD_REKEY_AFTER_TIME from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults -from vyos.template import is_ip base = ['firewall'] timeout = 300 @@ -175,50 +176,45 @@ def update_fqdn(config, node): def update_interfaces(config, node): if node == 'interfaces': - wireguard_interfaces = dict_search_args(config, 'wireguard') + wg_interfaces = dict_search_args(config, 'wireguard') - # WireGuard redo handshake usually every 180 seconds, but not documented officially. - # If peer with domain name in its endpoint didn't get handshake for over 300 seconds, - # we do re-resolv and reset its endpoint from config tree. - handshake_threshold = 300 - - from vyos.ifconfig import WireGuardIf - - check_wireguard_peer_public_keys = {} + peer_public_keys = {} # for each wireguard interfaces - for interface, wireguard in wireguard_interfaces.items(): - check_wireguard_peer_public_keys[interface] = [] + for interface, wireguard in wg_interfaces.items(): + peer_public_keys[interface] = [] for peer, peer_config in wireguard['peer'].items(): # check peer if peer host-name or address is set if 'host-name' in peer_config or 'address' in peer_config: # check latest handshake - check_wireguard_peer_public_keys[interface].append( + peer_public_keys[interface].append( peer_config['public_key'] ) now_time = time.time() - for ( - interface, - check_peer_public_keys - ) in check_wireguard_peer_public_keys.items(): + for (interface, check_peer_public_keys) in peer_public_keys.items(): if len(check_peer_public_keys) == 0: continue intf = WireGuardIf(interface, create=False, debug=False) handshakes = intf.operational.get_latest_handshakes() + # WireGuard performs a handshake every WIREGUARD_REKEY_AFTER_TIME + # if data is being transmitted between the peers. If no data is + # transmitted, the handshake will not be initiated unless new + # data begins to flow. Each handshake generates a new session + # key, and the key is rotated at least every 120 seconds or + # upon data transmission after a prolonged silence. for public_key, handshake_time in handshakes.items(): if public_key in check_peer_public_keys and ( handshake_time == 0 - or now_time - handshake_time > handshake_threshold + or (now_time - handshake_time > 3*WIREGUARD_REKEY_AFTER_TIME) ): intf.operational.reset_peer(public_key=public_key) - - print(f'Wireguard: reset {interface} peer {public_key}') + print(f'WireGuard: reset {interface} peer {public_key}') if __name__ == '__main__': - logger.info(f'VyOS domain resolver') + logger.info('VyOS domain resolver') count = 1 while commit_in_progress(): -- cgit v1.2.3 From 20f0deb28d3d88537171f869234520ceb4f67f01 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 19 Jan 2025 00:12:03 +0100 Subject: wireguard: T4930: use get_config_dict() rather then individual config queries Extend ConfigTreeQuery().get_config_dict() with arguments to read in default CLI values, too. This removes the need for hardcoded default values at multiple places like: if max_dns_retry is None: max_dns_retry = 3 in this case. --- python/vyos/configquery.py | 9 +++-- python/vyos/ifconfig/wireguard.py | 73 ++++++++++++++------------------------- 2 files changed, 32 insertions(+), 50 deletions(-) (limited to 'python') diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py index 5d6ca9be9..4c4ead0a3 100644 --- a/python/vyos/configquery.py +++ b/python/vyos/configquery.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors +# Copyright 2021-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -120,11 +120,14 @@ class ConfigTreeQuery(GenericConfigQuery): def get_config_dict(self, path=[], effective=False, key_mangling=None, get_first_key=False, no_multi_convert=False, - no_tag_node_value_mangle=False): + no_tag_node_value_mangle=False, with_defaults=False, + with_recursive_defaults=False): return self.config.get_config_dict(path, effective=effective, key_mangling=key_mangling, get_first_key=get_first_key, no_multi_convert=no_multi_convert, - no_tag_node_value_mangle=no_tag_node_value_mangle) + no_tag_node_value_mangle=no_tag_node_value_mangle, + with_defaults=with_defaults, + with_recursive_defaults=with_recursive_defaults) class VbashOpRun(GenericOpRun): def __init__(self): diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 9d0bd1a6c..f5217aecb 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright 2019-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -22,6 +22,7 @@ from tempfile import NamedTemporaryFile from hurry.filesize import size from hurry.filesize import alternative +from vyos.configquery import ConfigTreeQuery from vyos.ifconfig import Interface from vyos.ifconfig import Operational from vyos.template import is_ipv6 @@ -181,53 +182,40 @@ class WireGuardOperational(Operational): return output def reset_peer(self, peer_name=None, public_key=None): - from vyos.configquery import ConfigTreeQuery - c = ConfigTreeQuery() - - max_dns_retry = c.value( - ['interfaces', 'wireguard', self.ifname, 'max-dns-retry'] - ) - if max_dns_retry is None: - max_dns_retry = 3 + tmp = c.get_config_dict(['interfaces', 'wireguard', self.ifname], + effective=True, get_first_key=True, + key_mangling=('-', '_'), with_defaults=True) current_peers = self._dump().get(self.ifname, {}).get('peers', {}) - for peer in c.list_nodes(['interfaces', 'wireguard', self.ifname, 'peer']): - peer_public_key = c.value( - ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'public-key'] - ) + for peer, peer_config in tmp['peer'].items(): + peer_public_key = peer_config['public_key'] if peer_name is None or peer == peer_name or public_key == peer_public_key: - address = c.value( - ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'address'] - ) - host_name = c.value( - ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'host-name'] - ) - port = c.value( - ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'port'] - ) - - if (not address and not host_name) or not port: + if ('address' not in peer_config and 'host_name' not in peer_config) or 'port' not in peer_config: if peer_name is not None: - print(f'Peer {peer_name} endpoint not set') + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" address/host-name unset!') continue + # As we work with an effective config, a port CLI node is always + # available when an address/host-name is defined on the CLI + port = peer_config['port'] + # address has higher priority than host-name - if address: + if 'address' in peer_config: + address = peer_config['address'] new_endpoint = f'{address}:{port}' else: + host_name = peer_config['host_name'] new_endpoint = f'{host_name}:{port}' - if c.exists( - ['interfaces', 'wireguard', self.ifname, 'peer', peer, 'disable'] - ): + if 'disable' in peer_config: + print(f'WireGuard interface "{self.ifname}" peer "{peer_name}" disabled!') continue cmd = f'wg set {self.ifname} peer {peer_public_key} endpoint {new_endpoint}' try: - if ( - peer_public_key in current_peers + if (peer_public_key in current_peers and 'endpoint' in current_peers[peer_public_key] and current_peers[peer_public_key]['endpoint'] is not None ): @@ -235,14 +223,10 @@ class WireGuardOperational(Operational): message = f'Resetting {self.ifname} peer {peer_public_key} from {current_endpoint} endpoint to {new_endpoint} ... ' else: message = f'Resetting {self.ifname} peer {peer_public_key} endpoint to {new_endpoint} ... ' - print( - message, - end='', - ) - - self._cmd( - cmd, env={'WG_ENDPOINT_RESOLUTION_RETRIES': str(max_dns_retry)} - ) + print(message, end='') + + self._cmd(cmd, env={'WG_ENDPOINT_RESOLUTION_RETRIES': + tmp['max_dns_retry']}) print('done') except: print(f'Error\nPlease try to run command manually:\n{cmd}\n') @@ -272,15 +256,12 @@ class WireGuardIf(Interface): get_config_dict(). It's main intention is to consolidate the scattered interface setup code and provide a single point of entry when workin on any interface.""" - tmp_file = NamedTemporaryFile('w') tmp_file.write(config['private_key']) tmp_file.flush() # Wireguard base command is identical for every peer - base_cmd = 'wg set ' + config['ifname'] - max_dns_retry = config['max_dns_retry'] if 'max_dns_retry' in config else 3 - + base_cmd = f'wg set {self.ifname}' interface_cmd = base_cmd if 'port' in config: interface_cmd += ' listen-port {port}' @@ -347,10 +328,8 @@ class WireGuardIf(Interface): elif {'host_name', 'port'} <= set(peer_config): cmd += ' endpoint {host_name}:{port}' - self._cmd( - cmd.format(**peer_config), - env={'WG_ENDPOINT_RESOLUTION_RETRIES': str(max_dns_retry)}, - ) + self._cmd(cmd.format(**peer_config), env={ + 'WG_ENDPOINT_RESOLUTION_RETRIES': config['max_dns_retry']}) except: # todo: logging pass -- cgit v1.2.3 From 98414a69f0018915ac999f51975618dd5fbe817d Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 19 Jan 2025 00:14:25 +0100 Subject: wireguard: T4930: drop unused WireGuardOperational().show_interface() method Method is not referenced in the code base, remove dead code. --- python/vyos/ifconfig/wireguard.py | 78 --------------------------------------- 1 file changed, 78 deletions(-) (limited to 'python') diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index f5217aecb..341fd32ff 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -82,84 +82,6 @@ class WireGuardOperational(Operational): } return output - def show_interface(self): - from vyos.config import Config - - c = Config() - - wgdump = self._dump().get(self.config['ifname'], None) - - c.set_level(['interfaces', 'wireguard', self.config['ifname']]) - description = c.return_effective_value(['description']) - ips = c.return_effective_values(['address']) - hostnames = c.return_effective_values(['host-name']) - - answer = 'interface: {}\n'.format(self.config['ifname']) - if description: - answer += ' description: {}\n'.format(description) - if ips: - answer += ' address: {}\n'.format(', '.join(ips)) - if hostnames: - answer += ' hostname: {}\n'.format(', '.join(hostnames)) - - answer += ' public key: {}\n'.format(wgdump['public_key']) - answer += ' private key: (hidden)\n' - answer += ' listening port: {}\n'.format(wgdump['listen_port']) - answer += '\n' - - for peer in c.list_effective_nodes(['peer']): - if wgdump['peers']: - pubkey = c.return_effective_value(['peer', peer, 'public-key']) - if pubkey in wgdump['peers']: - wgpeer = wgdump['peers'][pubkey] - - answer += ' peer: {}\n'.format(peer) - answer += ' public key: {}\n'.format(pubkey) - - """ figure out if the tunnel is recently active or not """ - status = 'inactive' - if wgpeer['latest_handshake'] is None: - """ no handshake ever """ - status = 'inactive' - else: - if int(wgpeer['latest_handshake']) > 0: - delta = timedelta( - seconds=int(time.time() - wgpeer['latest_handshake']) - ) - answer += ' latest handshake: {}\n'.format(delta) - if time.time() - int(wgpeer['latest_handshake']) < (60 * 5): - """ Five minutes and the tunnel is still active """ - status = 'active' - else: - """ it's been longer than 5 minutes """ - status = 'inactive' - elif int(wgpeer['latest_handshake']) == 0: - """ no handshake ever """ - status = 'inactive' - answer += ' status: {}\n'.format(status) - - if wgpeer['endpoint'] is not None: - answer += ' endpoint: {}\n'.format(wgpeer['endpoint']) - - if wgpeer['allowed_ips'] is not None: - answer += ' allowed ips: {}\n'.format( - ','.join(wgpeer['allowed_ips']).replace(',', ', ') - ) - - if wgpeer['transfer_rx'] > 0 or wgpeer['transfer_tx'] > 0: - rx_size = size(wgpeer['transfer_rx'], system=alternative) - tx_size = size(wgpeer['transfer_tx'], system=alternative) - answer += ' transfer: {} received, {} sent\n'.format( - rx_size, tx_size - ) - - if wgpeer['persistent_keepalive'] is not None: - answer += ' persistent keepalive: every {} seconds\n'.format( - wgpeer['persistent_keepalive'] - ) - answer += '\n' - return answer - def get_latest_handshakes(self): """Get latest handshake time for each peer""" output = {} -- cgit v1.2.3 From 937d370576d30eb6743e4733eda8e3882172e6ac Mon Sep 17 00:00:00 2001 From: khramshinr Date: Thu, 17 Oct 2024 17:12:06 +0600 Subject: T6641: Add vyos-network-event-logger Service The service parses and logs network events for improved monitoring and diagnostics. Supported event types include: - `RTM_NEWROUTE`, `RTM_DELROUTE` - `RTM_NEWLINK`, `RTM_DELLINK` - `RTM_NEWADDR`, `RTM_DELADDR` - `RTM_NEWNEIGH`, `RTM_DELNEIGH`, `RTM_GETNEIGH` - `RTM_NEWRULE`, `RTM_DELRULE` Added operational mode commands for filtered log retrieval: - `show log network-event `: Retrieve logs filtered by event type and interface. - `show interfaces event-log `: Display interface-specific logs filtered by event type. --- .../include/netlink/log-level.xml.i | 21 + .../include/netlink/queue-size.xml.i | 15 + .../service_monitoring_network_event.xml.in | 61 + interface-definitions/system_conntrack.xml.in | 34 +- .../include/log/network-event-type-interface.xml.i | 11 + .../include/show-interface-type-event-log.xml.i | 40 + op-mode-definitions/show-interfaces-bonding.xml.in | 1 + op-mode-definitions/show-interfaces-bridge.xml.in | 1 + op-mode-definitions/show-interfaces-dummy.xml.in | 1 + .../show-interfaces-ethernet.xml.in | 1 + op-mode-definitions/show-interfaces-geneve.xml.in | 1 + op-mode-definitions/show-interfaces-input.xml.in | 1 + op-mode-definitions/show-interfaces-l2tpv3.xml.in | 1 + .../show-interfaces-loopback.xml.in | 1 + op-mode-definitions/show-interfaces-macsec.xml.in | 3 + op-mode-definitions/show-interfaces-pppoe.xml.in | 1 + .../show-interfaces-pseudo-ethernet.xml.in | 1 + op-mode-definitions/show-interfaces-sstpc.xml.in | 1 + op-mode-definitions/show-interfaces-tunnel.xml.in | 1 + .../show-interfaces-virtual-ethernet.xml.in | 1 + op-mode-definitions/show-interfaces-vti.xml.in | 1 + op-mode-definitions/show-interfaces-vxlan.xml.in | 1 + .../show-interfaces-wireguard.xml.in | 1 + .../show-interfaces-wireless.xml.in | 1 + op-mode-definitions/show-interfaces-wwan.xml.in | 1 + op-mode-definitions/show-log.xml.in | 62 + python/vyos/include/__init__.py | 15 + python/vyos/include/uapi/__init__.py | 15 + python/vyos/include/uapi/linux/__init__.py | 15 + python/vyos/include/uapi/linux/fib_rules.py | 20 + python/vyos/include/uapi/linux/icmpv6.py | 18 + python/vyos/include/uapi/linux/if_arp.py | 176 +++ python/vyos/include/uapi/linux/lwtunnel.py | 38 + python/vyos/include/uapi/linux/neighbour.py | 34 + python/vyos/include/uapi/linux/rtnetlink.py | 63 + .../cli/test_service_monitoring_network_event.py | 65 ++ src/conf_mode/service_monitoring_network_event.py | 93 ++ src/services/vyos-network-event-logger | 1218 ++++++++++++++++++++ src/systemd/vyos-network-event-logger.service | 21 + 39 files changed, 2024 insertions(+), 32 deletions(-) create mode 100644 interface-definitions/include/netlink/log-level.xml.i create mode 100644 interface-definitions/include/netlink/queue-size.xml.i create mode 100644 interface-definitions/service_monitoring_network_event.xml.in create mode 100644 op-mode-definitions/include/log/network-event-type-interface.xml.i create mode 100644 op-mode-definitions/include/show-interface-type-event-log.xml.i create mode 100644 python/vyos/include/__init__.py create mode 100644 python/vyos/include/uapi/__init__.py create mode 100644 python/vyos/include/uapi/linux/__init__.py create mode 100644 python/vyos/include/uapi/linux/fib_rules.py create mode 100644 python/vyos/include/uapi/linux/icmpv6.py create mode 100644 python/vyos/include/uapi/linux/if_arp.py create mode 100644 python/vyos/include/uapi/linux/lwtunnel.py create mode 100644 python/vyos/include/uapi/linux/neighbour.py create mode 100644 python/vyos/include/uapi/linux/rtnetlink.py create mode 100644 smoketest/scripts/cli/test_service_monitoring_network_event.py create mode 100644 src/conf_mode/service_monitoring_network_event.py create mode 100644 src/services/vyos-network-event-logger create mode 100644 src/systemd/vyos-network-event-logger.service (limited to 'python') diff --git a/interface-definitions/include/netlink/log-level.xml.i b/interface-definitions/include/netlink/log-level.xml.i new file mode 100644 index 000000000..bbaf9412c --- /dev/null +++ b/interface-definitions/include/netlink/log-level.xml.i @@ -0,0 +1,21 @@ + + + + Set log-level + + info debug + + + info + Info log level + + + debug + Debug log level + + + (info|debug) + + + + diff --git a/interface-definitions/include/netlink/queue-size.xml.i b/interface-definitions/include/netlink/queue-size.xml.i new file mode 100644 index 000000000..d284838cf --- /dev/null +++ b/interface-definitions/include/netlink/queue-size.xml.i @@ -0,0 +1,15 @@ + + + + Internal message queue size + + u32:100-2147483647 + Queue size + + + + + Queue size must be between 100 and 2147483647 + + + diff --git a/interface-definitions/service_monitoring_network_event.xml.in b/interface-definitions/service_monitoring_network_event.xml.in new file mode 100644 index 000000000..edf23a06a --- /dev/null +++ b/interface-definitions/service_monitoring_network_event.xml.in @@ -0,0 +1,61 @@ + + + + + + + Monitoring services + + + + + Network event logger + 1280 + + + + + Network event type + + + + + Log routing table update events + + + + + + Log link status change events + + + + + + Log address assignment and removal events + + + + + + Log neighbor (ARP/ND) table update events + + + + + + Log policy routing rule change events + + + + + + #include + #include + + + + + + + diff --git a/interface-definitions/system_conntrack.xml.in b/interface-definitions/system_conntrack.xml.in index cd59d1308..54610b625 100644 --- a/interface-definitions/system_conntrack.xml.in +++ b/interface-definitions/system_conntrack.xml.in @@ -263,38 +263,8 @@ - - - Internal message queue size - - u32:100-999999 - Queue size - - - - - Queue size must be between 100 and 999999 - - - - - Set log-level. Log must be enable. - - info debug - - - info - Info log level - - - debug - Debug log level - - - (info|debug) - - - + #include + #include diff --git a/op-mode-definitions/include/log/network-event-type-interface.xml.i b/op-mode-definitions/include/log/network-event-type-interface.xml.i new file mode 100644 index 000000000..2d781223c --- /dev/null +++ b/op-mode-definitions/include/log/network-event-type-interface.xml.i @@ -0,0 +1,11 @@ + + + + Show log for specific interface + + + + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service | grep "$(echo "\[$4\]" | tr '[:lower:]' '[:upper:]')" | grep "\b$6\b" + + diff --git a/op-mode-definitions/include/show-interface-type-event-log.xml.i b/op-mode-definitions/include/show-interface-type-event-log.xml.i new file mode 100644 index 000000000..c69073fda --- /dev/null +++ b/op-mode-definitions/include/show-interface-type-event-log.xml.i @@ -0,0 +1,40 @@ + + + + Show network interface change event log + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" + + + + Show log for route events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]" + + + + Show log for network link events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]" + + + + Show log for network address events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]" + + + + Show log for neighbor table events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]" + + + + Show log for PBR rule change events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\b$4\b" | grep -i "\[$6\]" + + + + diff --git a/op-mode-definitions/show-interfaces-bonding.xml.in b/op-mode-definitions/show-interfaces-bonding.xml.in index e2950331b..0abb7cd5a 100644 --- a/op-mode-definitions/show-interfaces-bonding.xml.in +++ b/op-mode-definitions/show-interfaces-bonding.xml.in @@ -67,6 +67,7 @@ + #include diff --git a/op-mode-definitions/show-interfaces-bridge.xml.in b/op-mode-definitions/show-interfaces-bridge.xml.in index dc813682d..998dacd38 100644 --- a/op-mode-definitions/show-interfaces-bridge.xml.in +++ b/op-mode-definitions/show-interfaces-bridge.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=bridge + #include diff --git a/op-mode-definitions/show-interfaces-dummy.xml.in b/op-mode-definitions/show-interfaces-dummy.xml.in index b8ec7da91..18f21e97e 100644 --- a/op-mode-definitions/show-interfaces-dummy.xml.in +++ b/op-mode-definitions/show-interfaces-dummy.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=dummy + #include diff --git a/op-mode-definitions/show-interfaces-ethernet.xml.in b/op-mode-definitions/show-interfaces-ethernet.xml.in index 09f0b3933..8a23455bf 100644 --- a/op-mode-definitions/show-interfaces-ethernet.xml.in +++ b/op-mode-definitions/show-interfaces-ethernet.xml.in @@ -68,6 +68,7 @@ + #include diff --git a/op-mode-definitions/show-interfaces-geneve.xml.in b/op-mode-definitions/show-interfaces-geneve.xml.in index d3d188031..b5fe84ca7 100644 --- a/op-mode-definitions/show-interfaces-geneve.xml.in +++ b/op-mode-definitions/show-interfaces-geneve.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=geneve + #include diff --git a/op-mode-definitions/show-interfaces-input.xml.in b/op-mode-definitions/show-interfaces-input.xml.in index e5d420056..c9856f77f 100644 --- a/op-mode-definitions/show-interfaces-input.xml.in +++ b/op-mode-definitions/show-interfaces-input.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=input + #include diff --git a/op-mode-definitions/show-interfaces-l2tpv3.xml.in b/op-mode-definitions/show-interfaces-l2tpv3.xml.in index 2d165171c..88b73d7d7 100644 --- a/op-mode-definitions/show-interfaces-l2tpv3.xml.in +++ b/op-mode-definitions/show-interfaces-l2tpv3.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=l2tpv3 + #include diff --git a/op-mode-definitions/show-interfaces-loopback.xml.in b/op-mode-definitions/show-interfaces-loopback.xml.in index d341a6359..467e1a13d 100644 --- a/op-mode-definitions/show-interfaces-loopback.xml.in +++ b/op-mode-definitions/show-interfaces-loopback.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=loopback + #include diff --git a/op-mode-definitions/show-interfaces-macsec.xml.in b/op-mode-definitions/show-interfaces-macsec.xml.in index 28264d252..640031b77 100644 --- a/op-mode-definitions/show-interfaces-macsec.xml.in +++ b/op-mode-definitions/show-interfaces-macsec.xml.in @@ -29,6 +29,9 @@ ip macsec show $4 + + #include + diff --git a/op-mode-definitions/show-interfaces-pppoe.xml.in b/op-mode-definitions/show-interfaces-pppoe.xml.in index 1c6e0b83e..c1f502cb3 100644 --- a/op-mode-definitions/show-interfaces-pppoe.xml.in +++ b/op-mode-definitions/show-interfaces-pppoe.xml.in @@ -28,6 +28,7 @@ if [ -d "/sys/class/net/$4" ]; then /usr/sbin/pppstats "$4"; fi + #include diff --git a/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in b/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in index 4ab2a5fbb..a9e4257ce 100644 --- a/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in +++ b/op-mode-definitions/show-interfaces-pseudo-ethernet.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=pseudo-ethernet + #include diff --git a/op-mode-definitions/show-interfaces-sstpc.xml.in b/op-mode-definitions/show-interfaces-sstpc.xml.in index 307276f72..3bd7a8247 100644 --- a/op-mode-definitions/show-interfaces-sstpc.xml.in +++ b/op-mode-definitions/show-interfaces-sstpc.xml.in @@ -28,6 +28,7 @@ if [ -d "/sys/class/net/$4" ]; then /usr/sbin/pppstats "$4"; fi + #include diff --git a/op-mode-definitions/show-interfaces-tunnel.xml.in b/op-mode-definitions/show-interfaces-tunnel.xml.in index b99b0cbb2..579b173cb 100644 --- a/op-mode-definitions/show-interfaces-tunnel.xml.in +++ b/op-mode-definitions/show-interfaces-tunnel.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=tunnel + #include diff --git a/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in b/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in index 18ae806b7..4112a17af 100644 --- a/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in +++ b/op-mode-definitions/show-interfaces-virtual-ethernet.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=virtual-ethernet + #include diff --git a/op-mode-definitions/show-interfaces-vti.xml.in b/op-mode-definitions/show-interfaces-vti.xml.in index ae5cfeb9c..d13b3e7cc 100644 --- a/op-mode-definitions/show-interfaces-vti.xml.in +++ b/op-mode-definitions/show-interfaces-vti.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=vti + #include diff --git a/op-mode-definitions/show-interfaces-vxlan.xml.in b/op-mode-definitions/show-interfaces-vxlan.xml.in index fd729b986..89c8d075b 100644 --- a/op-mode-definitions/show-interfaces-vxlan.xml.in +++ b/op-mode-definitions/show-interfaces-vxlan.xml.in @@ -19,6 +19,7 @@ ${vyos_op_scripts_dir}/interfaces.py show_summary --intf-name="$4" --intf-type=vxlan + #include diff --git a/op-mode-definitions/show-interfaces-wireguard.xml.in b/op-mode-definitions/show-interfaces-wireguard.xml.in index 0e61ccd74..d86152a21 100644 --- a/op-mode-definitions/show-interfaces-wireguard.xml.in +++ b/op-mode-definitions/show-interfaces-wireguard.xml.in @@ -43,6 +43,7 @@ sudo ${vyos_op_scripts_dir}/interfaces_wireguard.py show_summary --intf-name="$4" + #include diff --git a/op-mode-definitions/show-interfaces-wireless.xml.in b/op-mode-definitions/show-interfaces-wireless.xml.in index 09c9a7895..b0a1502de 100644 --- a/op-mode-definitions/show-interfaces-wireless.xml.in +++ b/op-mode-definitions/show-interfaces-wireless.xml.in @@ -73,6 +73,7 @@ + #include diff --git a/op-mode-definitions/show-interfaces-wwan.xml.in b/op-mode-definitions/show-interfaces-wwan.xml.in index 3682282a3..2301b32d0 100644 --- a/op-mode-definitions/show-interfaces-wwan.xml.in +++ b/op-mode-definitions/show-interfaces-wwan.xml.in @@ -80,6 +80,7 @@ echo not implemented + #include diff --git a/op-mode-definitions/show-log.xml.in b/op-mode-definitions/show-log.xml.in index 7ace50cc9..5ee7c973f 100755 --- a/op-mode-definitions/show-log.xml.in +++ b/op-mode-definitions/show-log.xml.in @@ -958,6 +958,68 @@ journalctl --no-hostname --boot --unit squid.service + + + Show log for network events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service + + + + Show log for specific interface + + + + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep $5 + + + + Show log for route events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]" + + #include + + + + + Show log for network link events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]" + + #include + + + + + Show log for network address events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]" + + #include + + + + + Show log for neighbor table events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]" + + #include + + + + + Show log for PBR rule change events + + journalctl --no-hostname --boot --unit vyos-network-event-logger.service --grep "\[$4\]" + + #include + + + + diff --git a/python/vyos/include/__init__.py b/python/vyos/include/__init__.py new file mode 100644 index 000000000..22e836531 --- /dev/null +++ b/python/vyos/include/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + diff --git a/python/vyos/include/uapi/__init__.py b/python/vyos/include/uapi/__init__.py new file mode 100644 index 000000000..22e836531 --- /dev/null +++ b/python/vyos/include/uapi/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + diff --git a/python/vyos/include/uapi/linux/__init__.py b/python/vyos/include/uapi/linux/__init__.py new file mode 100644 index 000000000..22e836531 --- /dev/null +++ b/python/vyos/include/uapi/linux/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + diff --git a/python/vyos/include/uapi/linux/fib_rules.py b/python/vyos/include/uapi/linux/fib_rules.py new file mode 100644 index 000000000..72f0b18cb --- /dev/null +++ b/python/vyos/include/uapi/linux/fib_rules.py @@ -0,0 +1,20 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +FIB_RULE_PERMANENT = 0x00000001 +FIB_RULE_INVERT = 0x00000002 +FIB_RULE_UNRESOLVED = 0x00000004 +FIB_RULE_IIF_DETACHED = 0x00000008 +FIB_RULE_DEV_DETACHED = FIB_RULE_IIF_DETACHED +FIB_RULE_OIF_DETACHED = 0x00000010 diff --git a/python/vyos/include/uapi/linux/icmpv6.py b/python/vyos/include/uapi/linux/icmpv6.py new file mode 100644 index 000000000..47e0c723c --- /dev/null +++ b/python/vyos/include/uapi/linux/icmpv6.py @@ -0,0 +1,18 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +ICMPV6_ROUTER_PREF_LOW = 3 +ICMPV6_ROUTER_PREF_MEDIUM = 0 +ICMPV6_ROUTER_PREF_HIGH = 1 +ICMPV6_ROUTER_PREF_INVALID = 2 diff --git a/python/vyos/include/uapi/linux/if_arp.py b/python/vyos/include/uapi/linux/if_arp.py new file mode 100644 index 000000000..90cb66ebd --- /dev/null +++ b/python/vyos/include/uapi/linux/if_arp.py @@ -0,0 +1,176 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# ARP protocol HARDWARE identifiers +ARPHRD_NETROM = 0 # from KA9Q: NET/ROM pseudo +ARPHRD_ETHER = 1 # Ethernet 10Mbps +ARPHRD_EETHER = 2 # Experimental Ethernet +ARPHRD_AX25 = 3 # AX.25 Level 2 +ARPHRD_PRONET = 4 # PROnet token ring +ARPHRD_CHAOS = 5 # Chaosnet +ARPHRD_IEEE802 = 6 # IEEE 802.2 Ethernet/TR/TB +ARPHRD_ARCNET = 7 # ARCnet +ARPHRD_APPLETLK = 8 # APPLEtalk +ARPHRD_DLCI = 15 # Frame Relay DLCI +ARPHRD_ATM = 19 # ATM +ARPHRD_METRICOM = 23 # Metricom STRIP (new IANA id) +ARPHRD_IEEE1394 = 24 # IEEE 1394 IPv4 - RFC 2734 +ARPHRD_EUI64 = 27 # EUI-64 +ARPHRD_INFINIBAND = 32 # InfiniBand + +# Dummy types for non-ARP hardware +ARPHRD_SLIP = 256 +ARPHRD_CSLIP = 257 +ARPHRD_SLIP6 = 258 +ARPHRD_CSLIP6 = 259 +ARPHRD_RSRVD = 260 # Notional KISS type +ARPHRD_ADAPT = 264 +ARPHRD_ROSE = 270 +ARPHRD_X25 = 271 # CCITT X.25 +ARPHRD_HWX25 = 272 # Boards with X.25 in firmware +ARPHRD_CAN = 280 # Controller Area Network +ARPHRD_MCTP = 290 +ARPHRD_PPP = 512 +ARPHRD_CISCO = 513 # Cisco HDLC +ARPHRD_HDLC = ARPHRD_CISCO # Alias for CISCO +ARPHRD_LAPB = 516 # LAPB +ARPHRD_DDCMP = 517 # Digital's DDCMP protocol +ARPHRD_RAWHDLC = 518 # Raw HDLC +ARPHRD_RAWIP = 519 # Raw IP + +ARPHRD_TUNNEL = 768 # IPIP tunnel +ARPHRD_TUNNEL6 = 769 # IP6IP6 tunnel +ARPHRD_FRAD = 770 # Frame Relay Access Device +ARPHRD_SKIP = 771 # SKIP vif +ARPHRD_LOOPBACK = 772 # Loopback device +ARPHRD_LOCALTLK = 773 # Localtalk device +ARPHRD_FDDI = 774 # Fiber Distributed Data Interface +ARPHRD_BIF = 775 # AP1000 BIF +ARPHRD_SIT = 776 # sit0 device - IPv6-in-IPv4 +ARPHRD_IPDDP = 777 # IP over DDP tunneller +ARPHRD_IPGRE = 778 # GRE over IP +ARPHRD_PIMREG = 779 # PIMSM register interface +ARPHRD_HIPPI = 780 # High Performance Parallel Interface +ARPHRD_ASH = 781 # Nexus 64Mbps Ash +ARPHRD_ECONET = 782 # Acorn Econet +ARPHRD_IRDA = 783 # Linux-IrDA +ARPHRD_FCPP = 784 # Point to point fibrechannel +ARPHRD_FCAL = 785 # Fibrechannel arbitrated loop +ARPHRD_FCPL = 786 # Fibrechannel public loop +ARPHRD_FCFABRIC = 787 # Fibrechannel fabric + +ARPHRD_IEEE802_TR = 800 # Magic type ident for TR +ARPHRD_IEEE80211 = 801 # IEEE 802.11 +ARPHRD_IEEE80211_PRISM = 802 # IEEE 802.11 + Prism2 header +ARPHRD_IEEE80211_RADIOTAP = 803 # IEEE 802.11 + radiotap header +ARPHRD_IEEE802154 = 804 +ARPHRD_IEEE802154_MONITOR = 805 # IEEE 802.15.4 network monitor + +ARPHRD_PHONET = 820 # PhoNet media type +ARPHRD_PHONET_PIPE = 821 # PhoNet pipe header +ARPHRD_CAIF = 822 # CAIF media type +ARPHRD_IP6GRE = 823 # GRE over IPv6 +ARPHRD_NETLINK = 824 # Netlink header +ARPHRD_6LOWPAN = 825 # IPv6 over LoWPAN +ARPHRD_VSOCKMON = 826 # Vsock monitor header + +ARPHRD_VOID = 0xFFFF # Void type, nothing is known +ARPHRD_NONE = 0xFFFE # Zero header length + +# ARP protocol opcodes +ARPOP_REQUEST = 1 # ARP request +ARPOP_REPLY = 2 # ARP reply +ARPOP_RREQUEST = 3 # RARP request +ARPOP_RREPLY = 4 # RARP reply +ARPOP_InREQUEST = 8 # InARP request +ARPOP_InREPLY = 9 # InARP reply +ARPOP_NAK = 10 # (ATM)ARP NAK + +ARPHRD_TO_NAME = { + ARPHRD_NETROM: "netrom", + ARPHRD_ETHER: "ether", + ARPHRD_EETHER: "eether", + ARPHRD_AX25: "ax25", + ARPHRD_PRONET: "pronet", + ARPHRD_CHAOS: "chaos", + ARPHRD_IEEE802: "ieee802", + ARPHRD_ARCNET: "arcnet", + ARPHRD_APPLETLK: "atalk", + ARPHRD_DLCI: "dlci", + ARPHRD_ATM: "atm", + ARPHRD_METRICOM: "metricom", + ARPHRD_IEEE1394: "ieee1394", + ARPHRD_INFINIBAND: "infiniband", + ARPHRD_SLIP: "slip", + ARPHRD_CSLIP: "cslip", + ARPHRD_SLIP6: "slip6", + ARPHRD_CSLIP6: "cslip6", + ARPHRD_RSRVD: "rsrvd", + ARPHRD_ADAPT: "adapt", + ARPHRD_ROSE: "rose", + ARPHRD_X25: "x25", + ARPHRD_HWX25: "hwx25", + ARPHRD_CAN: "can", + ARPHRD_PPP: "ppp", + ARPHRD_HDLC: "hdlc", + ARPHRD_LAPB: "lapb", + ARPHRD_DDCMP: "ddcmp", + ARPHRD_RAWHDLC: "rawhdlc", + ARPHRD_TUNNEL: "ipip", + ARPHRD_TUNNEL6: "tunnel6", + ARPHRD_FRAD: "frad", + ARPHRD_SKIP: "skip", + ARPHRD_LOOPBACK: "loopback", + ARPHRD_LOCALTLK: "ltalk", + ARPHRD_FDDI: "fddi", + ARPHRD_BIF: "bif", + ARPHRD_SIT: "sit", + ARPHRD_IPDDP: "ip/ddp", + ARPHRD_IPGRE: "gre", + ARPHRD_PIMREG: "pimreg", + ARPHRD_HIPPI: "hippi", + ARPHRD_ASH: "ash", + ARPHRD_ECONET: "econet", + ARPHRD_IRDA: "irda", + ARPHRD_FCPP: "fcpp", + ARPHRD_FCAL: "fcal", + ARPHRD_FCPL: "fcpl", + ARPHRD_FCFABRIC: "fcfb0", + ARPHRD_FCFABRIC+1: "fcfb1", + ARPHRD_FCFABRIC+2: "fcfb2", + ARPHRD_FCFABRIC+3: "fcfb3", + ARPHRD_FCFABRIC+4: "fcfb4", + ARPHRD_FCFABRIC+5: "fcfb5", + ARPHRD_FCFABRIC+6: "fcfb6", + ARPHRD_FCFABRIC+7: "fcfb7", + ARPHRD_FCFABRIC+8: "fcfb8", + ARPHRD_FCFABRIC+9: "fcfb9", + ARPHRD_FCFABRIC+10: "fcfb10", + ARPHRD_FCFABRIC+11: "fcfb11", + ARPHRD_FCFABRIC+12: "fcfb12", + ARPHRD_IEEE802_TR: "tr", + ARPHRD_IEEE80211: "ieee802.11", + ARPHRD_IEEE80211_PRISM: "ieee802.11/prism", + ARPHRD_IEEE80211_RADIOTAP: "ieee802.11/radiotap", + ARPHRD_IEEE802154: "ieee802.15.4", + ARPHRD_IEEE802154_MONITOR: "ieee802.15.4/monitor", + ARPHRD_PHONET: "phonet", + ARPHRD_PHONET_PIPE: "phonet_pipe", + ARPHRD_CAIF: "caif", + ARPHRD_IP6GRE: "gre6", + ARPHRD_NETLINK: "netlink", + ARPHRD_6LOWPAN: "6lowpan", + ARPHRD_NONE: "none", + ARPHRD_VOID: "void", +} \ No newline at end of file diff --git a/python/vyos/include/uapi/linux/lwtunnel.py b/python/vyos/include/uapi/linux/lwtunnel.py new file mode 100644 index 000000000..6797a762b --- /dev/null +++ b/python/vyos/include/uapi/linux/lwtunnel.py @@ -0,0 +1,38 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +LWTUNNEL_ENCAP_NONE = 0 +LWTUNNEL_ENCAP_MPLS = 1 +LWTUNNEL_ENCAP_IP = 2 +LWTUNNEL_ENCAP_ILA = 3 +LWTUNNEL_ENCAP_IP6 = 4 +LWTUNNEL_ENCAP_SEG6 = 5 +LWTUNNEL_ENCAP_BPF = 6 +LWTUNNEL_ENCAP_SEG6_LOCAL = 7 +LWTUNNEL_ENCAP_RPL = 8 +LWTUNNEL_ENCAP_IOAM6 = 9 +LWTUNNEL_ENCAP_XFRM = 10 + +ENCAP_TO_NAME = { + LWTUNNEL_ENCAP_MPLS: 'mpls', + LWTUNNEL_ENCAP_IP: 'ip', + LWTUNNEL_ENCAP_IP6: 'ip6', + LWTUNNEL_ENCAP_ILA: 'ila', + LWTUNNEL_ENCAP_BPF: 'bpf', + LWTUNNEL_ENCAP_SEG6: 'seg6', + LWTUNNEL_ENCAP_SEG6_LOCAL: 'seg6local', + LWTUNNEL_ENCAP_RPL: 'rpl', + LWTUNNEL_ENCAP_IOAM6: 'ioam6', + LWTUNNEL_ENCAP_XFRM: 'xfrm', +} diff --git a/python/vyos/include/uapi/linux/neighbour.py b/python/vyos/include/uapi/linux/neighbour.py new file mode 100644 index 000000000..d5caf44b9 --- /dev/null +++ b/python/vyos/include/uapi/linux/neighbour.py @@ -0,0 +1,34 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +NTF_USE = (1 << 0) +NTF_SELF = (1 << 1) +NTF_MASTER = (1 << 2) +NTF_PROXY = (1 << 3) +NTF_EXT_LEARNED = (1 << 4) +NTF_OFFLOADED = (1 << 5) +NTF_STICKY = (1 << 6) +NTF_ROUTER = (1 << 7) +NTF_EXT_MANAGED = (1 << 0) +NTF_EXT_LOCKED = (1 << 1) + +NTF_FlAGS = { + 'self': NTF_SELF, + 'router': NTF_ROUTER, + 'extern_learn': NTF_EXT_LEARNED, + 'offload': NTF_OFFLOADED, + 'master': NTF_MASTER, + 'sticky': NTF_STICKY, + 'locked': NTF_EXT_LOCKED, +} diff --git a/python/vyos/include/uapi/linux/rtnetlink.py b/python/vyos/include/uapi/linux/rtnetlink.py new file mode 100644 index 000000000..e31272460 --- /dev/null +++ b/python/vyos/include/uapi/linux/rtnetlink.py @@ -0,0 +1,63 @@ +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +RTM_F_NOTIFY = 0x100 +RTM_F_CLONED = 0x200 +RTM_F_EQUALIZE = 0x400 +RTM_F_PREFIX = 0x800 +RTM_F_LOOKUP_TABLE = 0x1000 +RTM_F_FIB_MATCH = 0x2000 +RTM_F_OFFLOAD = 0x4000 +RTM_F_TRAP = 0x8000 +RTM_F_OFFLOAD_FAILED = 0x20000000 + +RTNH_F_DEAD = 1 +RTNH_F_PERVASIVE = 2 +RTNH_F_ONLINK = 4 +RTNH_F_OFFLOAD = 8 +RTNH_F_LINKDOWN = 16 +RTNH_F_UNRESOLVED = 32 +RTNH_F_TRAP = 64 + +RT_TABLE_COMPAT = 252 +RT_TABLE_DEFAULT = 253 +RT_TABLE_MAIN = 254 +RT_TABLE_LOCAL = 255 + +RTAX_FEATURE_ECN = (1 << 0) +RTAX_FEATURE_SACK = (1 << 1) +RTAX_FEATURE_TIMESTAMP = (1 << 2) +RTAX_FEATURE_ALLFRAG = (1 << 3) +RTAX_FEATURE_TCP_USEC_TS = (1 << 4) + +RT_FlAGS = { + 'dead': RTNH_F_DEAD, + 'onlink': RTNH_F_ONLINK, + 'pervasive': RTNH_F_PERVASIVE, + 'offload': RTNH_F_OFFLOAD, + 'trap': RTNH_F_TRAP, + 'notify': RTM_F_NOTIFY, + 'linkdown': RTNH_F_LINKDOWN, + 'unresolved': RTNH_F_UNRESOLVED, + 'rt_offload': RTM_F_OFFLOAD, + 'rt_trap': RTM_F_TRAP, + 'rt_offload_failed': RTM_F_OFFLOAD_FAILED, +} + +RT_TABLE_TO_NAME = { + RT_TABLE_COMPAT: 'compat', + RT_TABLE_DEFAULT: 'default', + RT_TABLE_MAIN: 'main', + RT_TABLE_LOCAL: 'local', +} diff --git a/smoketest/scripts/cli/test_service_monitoring_network_event.py b/smoketest/scripts/cli/test_service_monitoring_network_event.py new file mode 100644 index 000000000..3c9b4bf7f --- /dev/null +++ b/smoketest/scripts/cli/test_service_monitoring_network_event.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import unittest +from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.utils.file import read_json + + +base_path = ['service', 'monitoring', 'network-event'] + + +def get_logger_config(): + return read_json('/run/vyos-network-event-logger.conf') + + +class TestMonitoringNetworkEvent(VyOSUnitTestSHIM.TestCase): + @classmethod + def setUpClass(cls): + super(TestMonitoringNetworkEvent, cls).setUpClass() + + # ensure we can also run this test on a live system - so lets clean + # out the current configuration :) + cls.cli_delete(cls, base_path) + + def tearDown(self): + self.cli_delete(base_path) + self.cli_commit() + + def test_network_event_log(self): + expected_config = { + 'event': { + 'route': {}, + 'link': {}, + 'addr': {}, + 'neigh': {}, + 'rule': {}, + }, + 'queue_size': '10000' + } + + self.cli_set(base_path + ['event', 'route']) + self.cli_set(base_path + ['event', 'link']) + self.cli_set(base_path + ['event', 'addr']) + self.cli_set(base_path + ['event', 'neigh']) + self.cli_set(base_path + ['event', 'rule']) + self.cli_set(base_path + ['queue-size', '10000']) + self.cli_commit() + self.assertEqual(expected_config, get_logger_config()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/src/conf_mode/service_monitoring_network_event.py b/src/conf_mode/service_monitoring_network_event.py new file mode 100644 index 000000000..104e6ce23 --- /dev/null +++ b/src/conf_mode/service_monitoring_network_event.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import json + +from sys import exit + +from vyos.config import Config +from vyos.utils.file import write_file +from vyos.utils.process import call +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +vyos_network_event_logger_config = r'/run/vyos-network-event-logger.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + base = ['service', 'monitoring', 'network-event'] + if not conf.exists(base): + return None + + monitoring = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + monitoring = conf.merge_defaults(monitoring, recursive=True) + + return monitoring + + +def verify(monitoring): + if not monitoring: + return None + + return None + + +def generate(monitoring): + if not monitoring: + # Delete config + if os.path.exists(vyos_network_event_logger_config): + os.unlink(vyos_network_event_logger_config) + + return None + + # Create config + log_conf_json = json.dumps(monitoring, indent=4) + write_file(vyos_network_event_logger_config, log_conf_json) + + return None + + +def apply(monitoring): + # Reload systemd manager configuration + systemd_service = 'vyos-network-event-logger.service' + + if not monitoring: + call(f'systemctl stop {systemd_service}') + return + + call(f'systemctl restart {systemd_service}') + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/services/vyos-network-event-logger b/src/services/vyos-network-event-logger new file mode 100644 index 000000000..840ff3cda --- /dev/null +++ b/src/services/vyos-network-event-logger @@ -0,0 +1,1218 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +import logging +import multiprocessing +import queue +import signal +import socket +import threading +from pathlib import Path +from time import sleep +from typing import Dict, AnyStr, List, Union + +from pyroute2.common import AF_MPLS +from pyroute2.iproute import IPRoute +from pyroute2.netlink import rtnl, nlmsg +from pyroute2.netlink.nfnetlink.nfctsocket import nfct_msg +from pyroute2.netlink.rtnl import (rt_proto as RT_PROTO, rt_type as RT_TYPES, + rtypes as RTYPES + ) +from pyroute2.netlink.rtnl.fibmsg import FR_ACT_GOTO, FR_ACT_NOP, FR_ACT_TO_TBL, \ + fibmsg +from pyroute2.netlink.rtnl import ifaddrmsg +from pyroute2.netlink.rtnl import ifinfmsg +from pyroute2.netlink.rtnl import ndmsg +from pyroute2.netlink.rtnl import rtmsg +from pyroute2.netlink.rtnl.rtmsg import nh, rtmsg_base + +from vyos.include.uapi.linux.fib_rules import * +from vyos.include.uapi.linux.icmpv6 import * +from vyos.include.uapi.linux.if_arp import * +from vyos.include.uapi.linux.lwtunnel import * +from vyos.include.uapi.linux.neighbour import * +from vyos.include.uapi.linux.rtnetlink import * + +from vyos.utils.file import read_json + + +manager = multiprocessing.Manager() +cache = manager.dict() + + +class UnsupportedMessageType(Exception): + pass + +shutdown_event = multiprocessing.Event() + +logging.basicConfig(level=logging.INFO, format='%(message)s') +logger = logging.getLogger(__name__) + + +class DebugFormatter(logging.Formatter): + def format(self, record): + self._style._fmt = '[%(asctime)s] %(levelname)s: %(message)s' + return super().format(record) + + +def set_log_level(level: str) -> None: + if level == 'debug': + logger.setLevel(logging.DEBUG) + logger.parent.handlers[0].setFormatter(DebugFormatter()) + else: + logger.setLevel(logging.INFO) + +IFF_FLAGS = { + 'RUNNING': ifinfmsg.IFF_RUNNING, + 'LOOPBACK': ifinfmsg.IFF_LOOPBACK, + 'BROADCAST': ifinfmsg.IFF_BROADCAST, + 'POINTOPOINT': ifinfmsg.IFF_POINTOPOINT, + 'MULTICAST': ifinfmsg.IFF_MULTICAST, + 'NOARP': ifinfmsg.IFF_NOARP, + 'ALLMULTI': ifinfmsg.IFF_ALLMULTI, + 'PROMISC': ifinfmsg.IFF_PROMISC, + 'MASTER': ifinfmsg.IFF_MASTER, + 'SLAVE': ifinfmsg.IFF_SLAVE, + 'DEBUG': ifinfmsg.IFF_DEBUG, + 'DYNAMIC': ifinfmsg.IFF_DYNAMIC, + 'AUTOMEDIA': ifinfmsg.IFF_AUTOMEDIA, + 'PORTSEL': ifinfmsg.IFF_PORTSEL, + 'NOTRAILERS': ifinfmsg.IFF_NOTRAILERS, + 'UP': ifinfmsg.IFF_UP, + 'LOWER_UP': ifinfmsg.IFF_LOWER_UP, + 'DORMANT': ifinfmsg.IFF_DORMANT, + 'ECHO': ifinfmsg.IFF_ECHO, +} + +NEIGH_STATE_FLAGS = { + 'INCOMPLETE': ndmsg.NUD_INCOMPLETE, + 'REACHABLE': ndmsg.NUD_REACHABLE, + 'STALE': ndmsg.NUD_STALE, + 'DELAY': ndmsg.NUD_DELAY, + 'PROBE': ndmsg.NUD_PROBE, + 'FAILED': ndmsg.NUD_FAILED, + 'NOARP': ndmsg.NUD_NOARP, + 'PERMANENT': ndmsg.NUD_PERMANENT, +} + +IFA_FLAGS = { + 'secondary': ifaddrmsg.IFA_F_SECONDARY, + 'temporary': ifaddrmsg.IFA_F_SECONDARY, + 'nodad': ifaddrmsg.IFA_F_NODAD, + 'optimistic': ifaddrmsg.IFA_F_OPTIMISTIC, + 'dadfailed': ifaddrmsg.IFA_F_DADFAILED, + 'home': ifaddrmsg.IFA_F_HOMEADDRESS, + 'deprecated': ifaddrmsg.IFA_F_DEPRECATED, + 'tentative': ifaddrmsg.IFA_F_TENTATIVE, + 'permanent': ifaddrmsg.IFA_F_PERMANENT, + 'mngtmpaddr': ifaddrmsg.IFA_F_MANAGETEMPADDR, + 'noprefixroute': ifaddrmsg.IFA_F_NOPREFIXROUTE, + 'autojoin': ifaddrmsg.IFA_F_MCAUTOJOIN, + 'stable-privacy': ifaddrmsg.IFA_F_STABLE_PRIVACY, +} + +RT_SCOPE_TO_NAME = { + rtmsg.RT_SCOPE_UNIVERSE: 'global', + rtmsg.RT_SCOPE_SITE: 'site', + rtmsg.RT_SCOPE_LINK: 'link', + rtmsg.RT_SCOPE_HOST: 'host', + rtmsg.RT_SCOPE_NOWHERE: 'nowhere', +} + +FAMILY_TO_NAME = { + socket.AF_INET: 'inet', + socket.AF_INET6: 'inet6', + socket.AF_PACKET: 'link', + AF_MPLS: 'mpls', + socket.AF_BRIDGE: 'bridge', +} + +_INFINITY = 4294967295 + + +def _get_iif_name(idx: int) -> str: + """ + Retrieves the interface name associated with a given index. + """ + try: + if_info = IPRoute().link("get", index=idx) + if if_info: + return if_info[0].get_attr('IFLA_IFNAME') + except Exception as e: + pass + + return '' + + +def remember_if_index(idx: int, event_type: int) -> None: + """ + Manages the caching of network interface names based on their index and event type. + + - For RTM_DELLINK event, the interface name is removed from the cache if exists. + - For RTM_NEWLINK event, the interface name is retrieved and updated in the cache. + """ + name = cache.get(idx) + if name: + if event_type == rtnl.RTM_DELLINK: + del cache[idx] + else: + name = _get_iif_name(idx) + if name: + cache[idx] = name + else: + cache[idx] = _get_iif_name(idx) + + +class BaseFormatter: + """ + A base class providing utility methods for formatting network message data. + """ + def _get_if_name_by_index(self, idx: int) -> str: + """ + Retrieves the name of a network interface based on its index. + + Uses a cached lookup for efficiency. If the name is not found in the cache, + it queries the system and updates the cache. + """ + if_name = cache.get(idx) + if not if_name: + if_name = _get_iif_name(idx) + cache[idx] = if_name + + return if_name + + def _format_rttable(self, idx: int) -> str: + """ + Formats a route table identifier into a readable name. + """ + return f'{RT_TABLE_TO_NAME.get(idx, idx)}' + + def _parse_flag(self, data: int, flags: dict) -> list: + """ + Extracts and returns flag names equal the bits set in a numeric value. + """ + result = list() + if data: + for key, val in flags.items(): + if data & val: + result.append(key) + data &= ~val + + if data: + result.append(f"{data:#x}") + + return result + + def af_bit_len(self, af: int) -> int: + """ + Gets the bit length of a given address family. + Supports common address families like IPv4, IPv6, and MPLS. + """ + _map = { + socket.AF_INET6: 128, + socket.AF_INET: 32, + AF_MPLS: 20, + } + + return _map.get(af) + + def _format_simple_field(self, data: str, prefix: str='') -> str: + """ + Formats a simple field with an optional prefix. + + A simple field represents a value that does not require additional + parsing and is used as is. + """ + return self._output(f'{prefix} {data}') if data is not None else '' + + def _output(self, data: str) -> str: + """ + Standardizes the output format. + + Ensures that the output is enclosed with single spaces and has no leading + or trailing whitespace. + """ + return f' {data.strip()} ' if data else '' + + +class BaseMSGFormatter(BaseFormatter): + """ + A base formatter class for network messages. + This class provides common methods for formatting network-related messages, + """ + + def _prepare_start_message(self, event: str) -> str: + """ + Prepares a starting message string based on the event type. + """ + if event in ['RTM_DELROUTE', 'RTM_DELLINK', 'RTM_DELNEIGH', + 'RTM_DELADDR', 'RTM_DELADDRLABEL', 'RTM_DELRULE', + 'RTM_DELNETCONF']: + return 'Deleted ' + if event == 'RTM_GETNEIGH': + return 'Miss ' + return '' + + def _format_flow_field(self, data: int) -> str: + """ + Formats a flow field to represent traffic realms. + """ + to = data & 0xFFFF + from_ = data >> 16 + result = f"realm{'s' if from_ else ''} " + if from_: + result += f'{from_}/' + result += f'{to}' + + return self._output(result) + + def format(self, msg: nlmsg) -> str: + """ + Abstract method to format a complete message. + + This method must be implemented by subclasses to provide specific formatting + logic for different types of messages. + """ + raise NotImplementedError(f'{msg.get("event")}: {msg}') + + +class LinkFormatter(BaseMSGFormatter): + """ + A formatter class for handling link-related network messages + `RTM_NEWLINK` and `RTM_DELLINK`. + """ + def _format_iff_flags(self, flags: int) -> str: + """ + Formats interface flags into a human-readable string. + """ + result = list() + if flags: + if flags & IFF_FLAGS['UP'] and not flags & IFF_FLAGS['RUNNING']: + result.append('NO-CARRIER') + + flags &= ~IFF_FLAGS['RUNNING'] + + result.extend(self._parse_flag(flags, IFF_FLAGS)) + + return self._output(f'<{(",").join(result)}>') + + def _format_if_props(self, data: ifinfmsg.ifinfbase.proplist) -> str: + """ + Formats interface alternative name properties. + """ + result = '' + for rec in data.altnames(): + result += f'[altname {rec}] ' + return self._output(result) + + def _format_link(self, msg: ifinfmsg.ifinfmsg) -> str: + """ + Formats the link attribute of a network interface message. + """ + if msg.get_attr("IFLA_LINK") is not None: + iflink = msg.get_attr("IFLA_LINK") + if iflink: + if msg.get_attr("IFLA_LINK_NETNSID"): + return f'if{iflink}' + else: + return self._get_if_name_by_index(iflink) + return 'NONE' + + def _format_link_info(self, msg: ifinfmsg.ifinfmsg) -> str: + """ + Formats detailed information about the link, including type, address, + broadcast address, and permanent address. + """ + result = f'link/{ARPHRD_TO_NAME.get(msg.get("ifi_type"), msg.get("ifi_type"))}' + result += self._format_simple_field(msg.get_attr('IFLA_ADDRESS')) + + if msg.get_attr("IFLA_BROADCAST"): + if msg.get('flags') & ifinfmsg.IFF_POINTOPOINT: + result += f' peer' + else: + result += f' brd' + result += f' {msg.get_attr("IFLA_BROADCAST")}' + + if msg.get_attr("IFLA_PERM_ADDRESS"): + if not msg.get_attr("IFLA_ADDRESS") or \ + msg.get_attr("IFLA_ADDRESS") != msg.get_attr("IFLA_PERM_ADDRESS"): + result += f' permaddr {msg.get_attr("IFLA_PERM_ADDRESS")}' + + return self._output(result) + + def format(self, msg: ifinfmsg.ifinfmsg): + """ + Formats a network link message into a structured output string. + """ + if msg.get("family") not in [socket.AF_UNSPEC, socket.AF_BRIDGE]: + return None + + message = self._prepare_start_message(msg.get('event')) + + link = self._format_link(msg) + + message += f'{msg.get("index")}: {msg.get_attr("IFLA_IFNAME")}' + message += f'@{link}' if link else '' + message += f': {self._format_iff_flags(msg.get("flags"))}' + + message += self._format_simple_field(msg.get_attr('IFLA_MTU'), prefix='mtu') + message += self._format_simple_field(msg.get_attr('IFLA_QDISC'), prefix='qdisc') + message += self._format_simple_field(msg.get_attr('IFLA_OPERSTATE'), prefix='state') + message += self._format_simple_field(msg.get_attr('IFLA_GROUP'), prefix='group') + message += self._format_simple_field(msg.get_attr('IFLA_MASTER'), prefix='master') + + message += self._format_link_info(msg) + + if msg.get_attr('IFLA_PROP_LIST'): + message += self._format_if_props(msg.get_attr('IFLA_PROP_LIST')) + + return self._output(message) + + +class EncapFormatter(BaseFormatter): + """ + A formatter class for handling encapsulation attributes in routing messages. + """ + # TODO: implement other lwtunnel decoder in pyroute2 + # https://github.com/svinota/pyroute2/blob/78cfe838bec8d96324811a3962bda15fb028e0ce/pyroute2/netlink/rtnl/rtmsg.py#L657 + def __init__(self): + """ + Initializes the EncapFormatter with supported encapsulation types. + """ + self.formatters = { + rtmsg.LWTUNNEL_ENCAP_MPLS: self.mpls_format, + rtmsg.LWTUNNEL_ENCAP_SEG6: self.seg6_format, + rtmsg.LWTUNNEL_ENCAP_BPF: self.bpf_format, + rtmsg.LWTUNNEL_ENCAP_SEG6_LOCAL: self.seg6local_format, + } + + def _format_srh(self, data: rtmsg_base.seg6_encap_info.ipv6_sr_hdr): + """ + Formats Segment Routing Header (SRH) attributes. + """ + result = '' + # pyroute2 decode mode only as inline or encap (encap, l2encap, encap.red, l2encap.red") + # https://github.com/svinota/pyroute2/blob/78cfe838bec8d96324811a3962bda15fb028e0ce/pyroute2/netlink/rtnl/rtmsg.py#L220 + for key in ['mode', 'segs']: + + val = data.get(key) + + if val: + if key == 'segs': + result += f'{key} {len(val)} {val} ' + else: + result += f'{key} {val} ' + + return self._output(result) + + def _format_bpf_object(self, data: rtmsg_base.bpf_encap_info, attr_name: str, attr_key: str): + """ + Formats eBPF program attributes. + """ + attr = data.get_attr(attr_name) + if not attr: + return '' + result = '' + if attr.get_attr("LWT_BPF_PROG_NAME"): + result += f'{attr.get_attr("LWT_BPF_PROG_NAME")} ' + if attr.get_attr("LWT_BPF_PROG_FD"): + result += f'{attr.get_attr("LWT_BPF_PROG_FD")} ' + + return self._output(f'{attr_key} {result.strip()}') + + def mpls_format(self, data: rtmsg_base.mpls_encap_info): + """ + Formats MPLS encapsulation attributes. + """ + result = '' + if data.get_attr("MPLS_IPTUNNEL_DST"): + for rec in data.get_attr("MPLS_IPTUNNEL_DST"): + for key, val in rec.items(): + if val: + result += f'{key} {val} ' + + if data.get_attr("MPLS_IPTUNNEL_TTL"): + result += f' ttl {data.get_attr("MPLS_IPTUNNEL_TTL")}' + + return self._output(result) + + def bpf_format(self, data: rtmsg_base.bpf_encap_info): + """ + Formats eBPF encapsulation attributes. + """ + result = '' + result += self._format_bpf_object(data, 'LWT_BPF_IN', 'in') + result += self._format_bpf_object(data, 'LWT_BPF_OUT', 'out') + result += self._format_bpf_object(data, 'LWT_BPF_XMIT', 'xmit') + + if data.get_attr('LWT_BPF_XMIT_HEADROOM'): + result += f'headroom {data.get_attr("LWT_BPF_XMIT_HEADROOM")} ' + + return self._output(result) + + def seg6_format(self, data: rtmsg_base.seg6_encap_info): + """ + Formats Segment Routing (SEG6) encapsulation attributes. + """ + result = '' + if data.get_attr("SEG6_IPTUNNEL_SRH"): + result += self._format_srh(data.get_attr("SEG6_IPTUNNEL_SRH")) + + return self._output(result) + + def seg6local_format(self, data: rtmsg_base.seg6local_encap_info): + """ + Formats SEG6 local encapsulation attributes. + """ + result = '' + formatters = { + 'SEG6_LOCAL_ACTION': lambda val: f' action {next((k for k, v in data.action.actions.items() if v == val), "unknown")}', + 'SEG6_LOCAL_SRH': lambda val: f' {self._format_srh(val)}', + 'SEG6_LOCAL_TABLE': lambda val: f' table {self._format_rttable(val)}', + 'SEG6_LOCAL_NH4': lambda val: f' nh4 {val}', + 'SEG6_LOCAL_NH6': lambda val: f' nh6 {val}', + 'SEG6_LOCAL_IIF': lambda val: f' iif {self._get_if_name_by_index(val)}', + 'SEG6_LOCAL_OIF': lambda val: f' oif {self._get_if_name_by_index(val)}', + 'SEG6_LOCAL_BPF': lambda val: f' endpoint {val.get("LWT_BPF_PROG_NAME")}', + 'SEG6_LOCAL_VRFTABLE': lambda val: f' vrftable {self._format_rttable(val)}', + } + + for rec in data.get('attrs'): + if rec[0] in formatters: + result += formatters[rec[0]](rec[1]) + + return self._output(result) + + def format(self, type: int, data: Union[rtmsg_base.mpls_encap_info, + rtmsg_base.bpf_encap_info, + rtmsg_base.seg6_encap_info, + rtmsg_base.seg6local_encap_info]): + """ + Formats encapsulation attributes based on their type. + """ + result = '' + formatter = self.formatters.get(type) + + result += f'encap {ENCAP_TO_NAME.get(type, "unknown")}' + + if formatter: + result += f' {formatter(data)}' + + return self._output(result) + + +class RouteFormatter(BaseMSGFormatter): + """ + A formatter class for handling network routing messages + `RTM_NEWROUTE` and `RTM_DELROUTE`. + """ + + def _format_rt_flags(self, flags: int) -> str: + """ + Formats route flags into a comma-separated string. + """ + result = list() + result.extend(self._parse_flag(flags, RT_FlAGS)) + + return self._output(",".join(result)) + + def _format_rta_encap(self, type: int, data: Union[rtmsg_base.mpls_encap_info, + rtmsg_base.bpf_encap_info, + rtmsg_base.seg6_encap_info, + rtmsg_base.seg6local_encap_info]) -> str: + """ + Formats encapsulation attributes. + """ + return EncapFormatter().format(type, data) + + def _format_rta_newdest(self, data: str) -> str: + """ + Formats a new destination attribute. + """ + return self._output(f'as to {data}') + + def _format_rta_gateway(self, data: str) -> str: + """ + Formats a gateway attribute. + """ + return self._output(f'via {data}') + + def _format_rta_via(self, data: str) -> str: + """ + Formats a 'via' route attribute. + """ + return self._output(f'{data}') + + def _format_rta_metrics(self, data: rtmsg_base.metrics): + """ + Formats routing metrics. + """ + result = '' + + def __format_metric_time(_val: int) -> str: + """Formats metric time values into seconds or milliseconds.""" + return f"{_val / 1000}s" if _val >= 1000 else f"{_val}ms" + + def __format_reatures(_val: int) -> str: + """Parse and formats routing feature flags.""" + result = self._parse_flag(_val, {'ecn': RTAX_FEATURE_ECN, + 'tcp_usec_ts': RTAX_FEATURE_TCP_USEC_TS}) + return ",".join(result) + + formatters = { + 'RTAX_MTU': lambda val: f' mtu {val}', + 'RTAX_WINDOW': lambda val: f' window {val}', + 'RTAX_RTT': lambda val: f' rtt {__format_metric_time(val / 8)}', + 'RTAX_RTTVAR': lambda val: f' rttvar {__format_metric_time(val / 4)}', + 'RTAX_SSTHRESH': lambda val: f' ssthresh {val}', + 'RTAX_CWND': lambda val: f' cwnd {val}', + 'RTAX_ADVMSS': lambda val: f' advmss {val}', + 'RTAX_REORDERING': lambda val: f' reordering {val}', + 'RTAX_HOPLIMIT': lambda val: f' hoplimit {val}', + 'RTAX_INITCWND': lambda val: f' initcwnd {val}', + 'RTAX_FEATURES': lambda val: f' features {__format_reatures(val)}', + 'RTAX_RTO_MIN': lambda val: f' rto_min {__format_metric_time(val)}', + 'RTAX_INITRWND': lambda val: f' initrwnd {val}', + 'RTAX_QUICKACK': lambda val: f' quickack {val}', + } + + for rec in data.get('attrs'): + if rec[0] in formatters: + result += formatters[rec[0]](rec[1]) + + return self._output(result) + + def _format_rta_pref(self, data: int) -> str: + """ + Formats a pref attribute. + """ + pref = { + ICMPV6_ROUTER_PREF_LOW: "low", + ICMPV6_ROUTER_PREF_MEDIUM: "medium", + ICMPV6_ROUTER_PREF_HIGH: "high", + } + + return self._output(f' pref {pref.get(data, data)}') + + def _format_rta_multipath(self, mcast_cloned: bool, family: int, data: List[nh]) -> str: + """ + Formats multipath route attributes. + """ + result = '' + first = True + for rec in data: + if mcast_cloned: + if first: + result += ' Oifs: ' + first = False + else: + result += ' ' + else: + result += ' nexthop ' + + if rec.get_attr('RTA_ENCAP'): + result += self._format_rta_encap(rec.get_attr('RTA_ENCAP_TYPE'), + rec.get_attr('RTA_ENCAP')) + + if rec.get_attr('RTA_NEWDST'): + result += self._format_rta_newdest(rec.get_attr('RTA_NEWDST')) + + if rec.get_attr('RTA_GATEWAY'): + result += self._format_rta_gateway(rec.get_attr('RTA_GATEWAY')) + + if rec.get_attr('RTA_VIA'): + result += self._format_rta_via(rec.get_attr('RTA_VIA')) + + if rec.get_attr('RTA_FLOW'): + result += self._format_flow_field(rec.get_attr('RTA_FLOW')) + + result += f' dev {self._get_if_name_by_index(rec.get("oif"))}' + if mcast_cloned: + if rec.get("hops") != 1: + result += f' (ttl>{rec.get("hops")})' + else: + if family != AF_MPLS: + result += f' weight {rec.get("hops") + 1}' + + result += self._format_rt_flags(rec.get("flags")) + + return self._output(result) + + def format(self, msg: rtmsg.rtmsg) -> str: + """ + Formats a network route message into a human-readable string representation. + """ + message = self._prepare_start_message(msg.get('event')) + + message += RT_TYPES.get(msg.get('type')) + + if msg.get_attr('RTA_DST'): + host_len = self.af_bit_len(msg.get('family')) + if msg.get('dst_len') != host_len: + message += f' {msg.get_attr("RTA_DST")}/{msg.get("dst_len")}' + else: + message += f' {msg.get_attr("RTA_DST")}' + elif msg.get('dst_len'): + message += f' 0/{msg.get("dst_len")}' + else: + message += ' default' + + if msg.get_attr('RTA_SRC'): + message += f' from {msg.get_attr("RTA_SRC")}' + elif msg.get('src_len'): + message += f' from 0/{msg.get("src_len")}' + + message += self._format_simple_field(msg.get_attr('RTA_NH_ID'), prefix='nhid') + + if msg.get_attr('RTA_NEWDST'): + message += self._format_rta_newdest(msg.get_attr('RTA_NEWDST')) + + if msg.get_attr('RTA_ENCAP'): + message += self._format_rta_encap(msg.get_attr('RTA_ENCAP_TYPE'), + msg.get_attr('RTA_ENCAP')) + + message += self._format_simple_field(msg.get('tos'), prefix='tos') + + if msg.get_attr('RTA_GATEWAY'): + message += self._format_rta_gateway(msg.get_attr('RTA_GATEWAY')) + + if msg.get_attr('RTA_VIA'): + message += self._format_rta_via(msg.get_attr('RTA_VIA')) + + if msg.get_attr('RTA_OIF') is not None: + message += f' dev {self._get_if_name_by_index(msg.get_attr("RTA_OIF"))}' + + if msg.get_attr("RTA_TABLE"): + message += f' table {self._format_rttable(msg.get_attr("RTA_TABLE"))}' + + if not msg.get('flags') & RTM_F_CLONED: + message += f' proto {RT_PROTO.get(msg.get("proto"))}' + + if not msg.get('scope') == rtmsg.RT_SCOPE_UNIVERSE: + message += f' scope {RT_SCOPE_TO_NAME.get(msg.get("scope"))}' + + message += self._format_simple_field(msg.get_attr('RTA_PREFSRC'), prefix='src') + message += self._format_simple_field(msg.get_attr('RTA_PRIORITY'), prefix='metric') + + message += self._format_rt_flags(msg.get("flags")) + + if msg.get_attr('RTA_MARK'): + mark = msg.get_attr("RTA_MARK") + if mark >= 16: + message += f' mark 0x{mark:x}' + else: + message += f' mark {mark}' + + if msg.get_attr('RTA_FLOW'): + message += self._format_flow_field(msg.get_attr('RTA_FLOW')) + + message += self._format_simple_field(msg.get_attr('RTA_UID'), prefix='uid') + + if msg.get_attr('RTA_METRICS'): + message += self._format_rta_metrics(msg.get_attr("RTA_METRICS")) + + if msg.get_attr('RTA_IIF') is not None: + message += f' iif {self._get_if_name_by_index(msg.get_attr("RTA_IIF"))}' + + if msg.get_attr('RTA_PREF') is not None: + message += self._format_rta_pref(msg.get_attr("RTA_PREF")) + + if msg.get_attr('RTA_TTL_PROPAGATE') is not None: + message += f' ttl-propogate {"enabled" if msg.get_attr("RTA_TTL_PROPAGATE") else "disabled"}' + + if msg.get_attr('RTA_MULTIPATH') is not None: + _tmp = self._format_rta_multipath( + mcast_cloned=msg.get('flags') & RTM_F_CLONED and msg.get('type') == RTYPES['RTN_MULTICAST'], + family=msg.get('family'), + data=msg.get_attr("RTA_MULTIPATH")) + message += f' {_tmp}' + + return self._output(message) + + +class AddrFormatter(BaseMSGFormatter): + """ + A formatter class for handling address-related network messages + `RTM_NEWADDR` and `RTM_DELADDR`. + """ + INFINITY_LIFE_TIME = _INFINITY + + def _format_ifa_flags(self, flags: int, family: int) -> str: + """ + Formats address flags into a human-readable string. + """ + result = list() + if flags: + if not flags & IFA_FLAGS['permanent']: + result.append('dynamic') + flags &= ~IFA_FLAGS['permanent'] + + if flags & IFA_FLAGS['temporary'] and family == socket.AF_INET6: + result.append('temporary') + flags &= ~IFA_FLAGS['temporary'] + + result.extend(self._parse_flag(flags, IFA_FLAGS)) + + return self._output(",".join(result)) + + def _format_ifa_addr(self, local: str, addr: str, preflen: int, priority: int) -> str: + """ + Formats address information into a shuman-readable string. + """ + result = '' + local = local or addr + addr = addr or local + + if local: + result += f'{local}' + if addr and addr != local: + result += f' peer {addr}' + result += f'/{preflen}' + + if priority: + result += f' {priority}' + + return self._output(result) + + def _format_ifa_cacheinfo(self, data: ifaddrmsg.ifaddrmsg.cacheinfo) -> str: + """ + Formats cache information for an address. + """ + result = '' + _map = { + 'ifa_valid': 'valid_lft', + 'ifa_preferred': 'preferred_lft', + } + + for key in ['ifa_valid', 'ifa_preferred']: + val = data.get(key) + if val == self.INFINITY_LIFE_TIME: + result += f'{_map.get(key)} forever ' + else: + result += f'{_map.get(key)} {val}sec ' + + return self._output(result) + + def format(self, msg: ifaddrmsg.ifaddrmsg) -> str: + """ + Formats a full network address message. + Combine attributes such as index, family, address, flags, and cache + information into a structured output string. + """ + message = self._prepare_start_message(msg.get('event')) + + message += f'{msg.get("index")}: {self._get_if_name_by_index(msg.get("index"))} ' + message += f'{FAMILY_TO_NAME.get(msg.get("family"), msg.get("family"))} ' + + message += self._format_ifa_addr( + msg.get_attr('IFA_LOCAL'), + msg.get_attr('IFA_ADDRESS'), + msg.get('prefixlen'), + msg.get_attr('IFA_RT_PRIORITY') + ) + message += self._format_simple_field(msg.get_attr('IFA_BROADCAST'), prefix='brd') + message += self._format_simple_field(msg.get_attr('IFA_ANYCAST'), prefix='any') + + if msg.get('scope') is not None: + message += f' scope {RT_SCOPE_TO_NAME.get(msg.get("scope"))}' + + message += self._format_ifa_flags(msg.get_attr("IFA_FLAGS"), msg.get("family")) + message += self._format_simple_field(msg.get_attr('IFA_LABEL'), prefix='label:') + + if msg.get_attr('IFA_CACHEINFO'): + message += self._format_ifa_cacheinfo(msg.get_attr('IFA_CACHEINFO')) + + return self._output(message) + + +class NeighFormatter(BaseMSGFormatter): + """ + A formatter class for handling neighbor-related network messages + `RTM_NEWNEIGH`, `RTM_DELNEIGH` and `RTM_GETNEIGH` + """ + def _format_ntf_flags(self, flags: int) -> str: + """ + Formats neighbor table entry flags into a human-readable string. + """ + result = list() + result.extend(self._parse_flag(flags, NTF_FlAGS)) + + return self._output(",".join(result)) + + def _format_neigh_state(self, data: int) -> str: + """ + Formats the state of a neighbor entry. + """ + result = list() + result.extend(self._parse_flag(data, NEIGH_STATE_FLAGS)) + + return self._output(",".join(result)) + + def format(self, msg: ndmsg.ndmsg) -> str: + """ + Formats a full neighbor-related network message. + Combine attributes such as destination, device, link-layer address, + flags, state, and protocol into a structured output string. + """ + message = self._prepare_start_message(msg.get('event')) + message += self._format_simple_field(msg.get_attr('NDA_DST'), prefix='') + + if msg.get("ifindex") is not None: + message += f' dev {self._get_if_name_by_index(msg.get("ifindex"))}' + + message += self._format_simple_field(msg.get_attr('NDA_LLADDR'), prefix='lladdr') + message += f' {self._format_ntf_flags(msg.get("flags"))}' + message += f' {self._format_neigh_state(msg.get("state"))}' + + if msg.get_attr('NDA_PROTOCOL'): + message += f' proto {RT_PROTO.get(msg.get_attr("NDA_PROTOCOL"), msg.get_attr("NDA_PROTOCOL"))}' + + return self._output(message) + + +class RuleFormatter(BaseMSGFormatter): + """ + A formatter class for handling ruting tule network messages + `RTM_NEWRULE` and `RTM_DELRULE` + """ + def _format_direction(self, data: str, length: int, host_len: int): + """ + Formats the direction of traffic based on source or destination and prefix length. + """ + result = '' + if data: + result += f' {data}' + if length != host_len: + result += f'/{length}' + elif length: + result += f' 0/{length}' + + return self._output(result) + + def _format_fra_interface(self, data: str, flags: int, prefix: str): + """ + Formats interface-related attributes. + """ + result = f'{prefix} {data}' + if flags & FIB_RULE_IIF_DETACHED: + result += '[detached]' + + return self._output(result) + + def _format_fra_range(self, data: [str, dict], prefix: str): + """ + Formats a range of values (e.g., UID, sport, or dport). + """ + result = '' + if data: + if isinstance(data, str): + result += f' {prefix} {data}' + else: + result += f' {prefix} {data.get("start")}:{data.get("end")}' + return self._output(result) + + def _format_fra_table(self, msg: fibmsg): + """ + Formats the lookup table and associated attributes in the message. + """ + def __format_field(data: int, prefix: str): + if data and data not in [-1, _INFINITY]: + return f' {prefix} {data}' + return '' + + result = '' + table = msg.get_attr('FRA_TABLE') or msg.get('table') + if table: + result += f' lookup {self._format_rttable(table)}' + result += __format_field(msg.get_attr('FRA_SUPPRESS_PREFIXLEN'), 'suppress_prefixlength') + result += __format_field(msg.get_attr('FRA_SUPPRESS_IFGROUP'), 'suppress_ifgroup') + + return self._output(result) + + def _format_fra_action(self, msg: fibmsg): + """ + Formats the action associated with the rule. + """ + result = '' + if msg.get('action') == RTYPES.get('RTN_NAT'): + if msg.get_attr('RTA_GATEWAY'): # looks like deprecated but still use in iproute2 + result += f' map-to {msg.get_attr("RTA_GATEWAY")}' + else: + result += ' masquerade' + + elif msg.get('action') == FR_ACT_GOTO: + result += f' goto {msg.get_attr("FRA_GOTO") or "none"}' + if msg.get('flags') & FIB_RULE_UNRESOLVED: + result += ' [unresolved]' + + elif msg.get('action') == FR_ACT_NOP: + result += ' nop' + + elif msg.get('action') != FR_ACT_TO_TBL: + result += f' {RTYPES.get(msg.get("action"))}' + + return self._output(result) + + def format(self, msg: fibmsg): + """ + Formats a complete routing rule message. + Combines information about source, destination, interfaces, actions, + and other attributes into a single formatted string. + """ + message = self._prepare_start_message(msg.get('event')) + host_len = self.af_bit_len(msg.get('family')) + message += self._format_simple_field(msg.get_attr('FRA_PRIORITY'), prefix='') + + if msg.get('flags') & FIB_RULE_INVERT: + message += ' not' + + tmp = self._format_direction(msg.get_attr('FRA_SRC'), msg.get('src_len'), host_len) + message += ' from' + (tmp if tmp else ' all ') + + if msg.get_attr('FRA_DST'): + tmp = self._format_direction(msg.get_attr('FRA_DST'), msg.get('dst_len'), host_len) + message += ' to' + tmp + + if msg.get('tos'): + message += f' tos {hex(msg.get("tos"))}' + + if msg.get_attr('FRA_FWMARK') or msg.get_attr('FRA_FWMASK'): + mark = msg.get_attr('FRA_FWMARK') or 0 + mask = msg.get_attr('FRA_FWMASK') or 0 + if mask != 0xFFFFFFFF: + message += f' fwmark {mark}/{mask}' + else: + message += f' fwmark {mark}' + + if msg.get_attr('FRA_IIFNAME'): + message += self._format_fra_interface( + msg.get_attr('FRA_IIFNAME'), + msg.get('flags'), + 'iif' + ) + + if msg.get_attr('FRA_OIFNAME'): + message += self._format_fra_interface( + msg.get_attr('FRA_OIFNAME'), + msg.get('flags'), + 'oif' + ) + + if msg.get_attr('FRA_L3MDEV'): + message += f' lookup [l3mdev-table]' + + if msg.get_attr('FRA_UID_RANGE'): + message += self._format_fra_range(msg.get_attr('FRA_UID_RANGE'), 'uidrange') + + message += self._format_simple_field(msg.get_attr('FRA_IP_PROTO'), prefix='ipproto') + + if msg.get_attr('FRA_SPORT_RANGE'): + message += self._format_fra_range(msg.get_attr('FRA_SPORT_RANGE'), 'sport') + + if msg.get_attr('FRA_DPORT_RANGE'): + message += self._format_fra_range(msg.get_attr('FRA_DPORT_RANGE'), 'dport') + + message += self._format_simple_field(msg.get_attr('FRA_TUN_ID'), prefix='tun_id') + + message += self._format_fra_table(msg) + + if msg.get_attr('FRA_FLOW'): + message += self._format_flow_field(msg.get_attr('FRA_FLOW')) + + message += self._format_fra_action(msg) + + if msg.get_attr('FRA_PROTOCOL'): + message += f' proto {RT_PROTO.get(msg.get_attr("FRA_PROTOCOL"), msg.get_attr("FRA_PROTOCOL"))}' + + return self._output(message) + + +class AddrlabelFormatter(BaseMSGFormatter): + # Not implemented decoder on pytroute2 but ip monitor use it message + pass + + +class PrefixFormatter(BaseMSGFormatter): + # Not implemented decoder on pytroute2 but ip monitor use it message + pass + + +class NetconfFormatter(BaseMSGFormatter): + # Not implemented decoder on pytroute2 but ip monitor use it message + pass + + +EVENT_MAP = { + rtnl.RTM_NEWROUTE: {'parser': RouteFormatter, 'event': 'route'}, + rtnl.RTM_DELROUTE: {'parser': RouteFormatter, 'event': 'route'}, + rtnl.RTM_NEWLINK: {'parser': LinkFormatter, 'event': 'link'}, + rtnl.RTM_DELLINK: {'parser': LinkFormatter, 'event': 'link'}, + rtnl.RTM_NEWADDR: {'parser': AddrFormatter, 'event': 'addr'}, + rtnl.RTM_DELADDR: {'parser': AddrFormatter, 'event': 'addr'}, + # rtnl.RTM_NEWADDRLABEL: {'parser': AddrlabelFormatter, 'event': 'addrlabel'}, + # rtnl.RTM_DELADDRLABEL: {'parser': AddrlabelFormatter, 'event': 'addrlabel'}, + rtnl.RTM_NEWNEIGH: {'parser': NeighFormatter, 'event': 'neigh'}, + rtnl.RTM_DELNEIGH: {'parser': NeighFormatter, 'event': 'neigh'}, + rtnl.RTM_GETNEIGH: {'parser': NeighFormatter, 'event': 'neigh'}, + # rtnl.RTM_NEWPREFIX: {'parser': PrefixFormatter, 'event': 'prefix'}, + rtnl.RTM_NEWRULE: {'parser': RuleFormatter, 'event': 'rule'}, + rtnl.RTM_DELRULE: {'parser': RuleFormatter, 'event': 'rule'}, + # rtnl.RTM_NEWNETCONF: {'parser': NetconfFormatter, 'event': 'netconf'}, + # rtnl.RTM_DELNETCONF: {'parser': NetconfFormatter, 'event': 'netconf'}, +} + + +def sig_handler(signum, frame): + process_name = multiprocessing.current_process().name + logger.debug( + f'[{process_name}]: {"Shutdown" if signum == signal.SIGTERM else "Reload"} signal received...' + ) + shutdown_event.set() + + +def parse_event_type(header: Dict) -> tuple: + """ + Extract event type and parser. + """ + event_type = EVENT_MAP.get(header['type'], {}).get('event', 'unknown') + _parser = EVENT_MAP.get(header['type'], {}).get('parser') + + if _parser is None: + raise UnsupportedMessageType(f'Unsupported message type: {header["type"]}') + + return event_type, _parser + + +def is_need_to_log(event_type: AnyStr, conf_event: Dict): + """ + Filter message by event type and protocols + """ + conf = conf_event.get(event_type) + if conf == {}: + return True + return False + + +def parse_event(msg: nfct_msg, conf_event: Dict) -> str: + """ + Convert nfct_msg to internal data dict. + """ + data = '' + event_type, parser = parse_event_type(msg['header']) + if event_type == 'link': + remember_if_index(idx=msg.get('index'), event_type=msg['header'].get('type')) + + if not is_need_to_log(event_type, conf_event): + return data + + message = parser().format(msg) + if message: + data = f'{f"[{event_type}]".upper():<{7}} {message}' + + return data + + +def worker(ct: IPRoute, shutdown_event: multiprocessing.Event, conf_event: Dict) -> None: + """ + Main function of parser worker process + """ + process_name = multiprocessing.current_process().name + logger.debug(f'[{process_name}] started') + timeout = 0.1 + while not shutdown_event.is_set(): + if not ct.buffer_queue.empty(): + msg = None + try: + for msg in ct.get(): + message = parse_event(msg, conf_event) + if message: + if logger.level == logging.DEBUG: + logger.debug(f'[{process_name}]: {message} raw: {msg}') + else: + logger.info(message) + except queue.Full: + logger.error('IPRoute message queue if full.') + except UnsupportedMessageType as e: + logger.debug(f'{e} =====> raw msg: {msg}') + except Exception as e: + logger.error(f'Unexpected error: {e.__class__} {e} [{msg}]') + else: + sleep(timeout) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + '-c', + '--config', + action='store', + help='Path to vyos-network-event-logger configuration', + required=True, + type=Path, + ) + + args = parser.parse_args() + try: + config = read_json(args.config) + except Exception as err: + logger.error(f'Configuration file "{args.config}" does not exist or malformed: {err}') + exit(1) + + set_log_level(config.get('log_level', 'info')) + + signal.signal(signal.SIGHUP, sig_handler) + signal.signal(signal.SIGTERM, sig_handler) + + if 'event' in config: + event_groups = list(config.get('event').keys()) + else: + logger.error(f'Configuration is wrong. Event filter is empty.') + exit(1) + + conf_event = config['event'] + qsize = config.get('queue_size') + ct = IPRoute(async_qsize=int(qsize) if qsize else None) + ct.buffer_queue = multiprocessing.Queue(ct.async_qsize) + ct.bind(async_cache=True) + + processes = list() + try: + for _ in range(multiprocessing.cpu_count()): + p = multiprocessing.Process(target=worker, args=(ct, shutdown_event, conf_event)) + processes.append(p) + p.start() + logger.info('IPRoute socket bound and listening for messages.') + + while not shutdown_event.is_set(): + if not ct.pthread.is_alive(): + if ct.buffer_queue.qsize() / ct.async_qsize < 0.9: + if not shutdown_event.is_set(): + logger.debug('Restart listener thread') + # restart listener thread after queue overloaded when queue size low than 90% + ct.pthread = threading.Thread(name='Netlink async cache', target=ct.async_recv) + ct.pthread.daemon = True + ct.pthread.start() + else: + sleep(0.1) + finally: + for p in processes: + p.join() + if not p.is_alive(): + logger.debug(f'[{p.name}]: finished') + ct.close() + logging.info('IPRoute socket closed.') + exit() diff --git a/src/systemd/vyos-network-event-logger.service b/src/systemd/vyos-network-event-logger.service new file mode 100644 index 000000000..990dc43ba --- /dev/null +++ b/src/systemd/vyos-network-event-logger.service @@ -0,0 +1,21 @@ +[Unit] +Description=VyOS network-event logger daemon + +# Seemingly sensible way to say "as early as the system is ready" +# All vyos-configd needs is read/write mounted root +After=vyos.target + +[Service] +ExecStart=/usr/bin/python3 -u /usr/libexec/vyos/services/vyos-network-event-logger -c /run/vyos-network-event-logger.conf +Type=idle + +SyslogIdentifier=vyos-network-event-logger +SyslogFacility=daemon + +Restart=on-failure + +User=root +Group=vyattacfg + +[Install] +WantedBy=multi-user.target -- cgit v1.2.3 From 63bd816bdfcc041d10941dab51a9a0792218ef42 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Tue, 21 Jan 2025 17:23:50 +0000 Subject: bonding: set interface state up after adding By default VPP creates interface via API with the 'down' state. Add methods to set interface state UP/DOWN. It probably should reuse the common Class in the future. We do not have classes for interface (vpp) state settings. --- python/vyos/vpp/interface/bond.py | 20 ++++++++++++++++++++ src/conf_mode/vpp_interfaces_bonding.py | 2 ++ 2 files changed, 22 insertions(+) (limited to 'python') diff --git a/python/vyos/vpp/interface/bond.py b/python/vyos/vpp/interface/bond.py index 28aae74a1..a90f2950b 100644 --- a/python/vyos/vpp/interface/bond.py +++ b/python/vyos/vpp/interface/bond.py @@ -112,3 +112,23 @@ class BondInterface: a.kernel_delete() """ self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) + + def set_state_up(self): + """Set Bond interface state to UP + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.set_state_up() + """ + bond_if_index = self.vpp.get_sw_if_index(self.ifname) + self.vpp.api.sw_interface_set_flags(sw_if_index=bond_if_index, flags=1) + + def set_state_down(self): + """Set Bond interface state to DOWN + Example: + from vyos.vpp.interface import BondInterface + a = BondInterface(ifname='bond0') + a.set_state_down() + """ + bond_if_index = self.vpp.get_sw_if_index(self.ifname) + self.vpp.api.sw_interface_set_flags(sw_if_index=bond_if_index, flags=0) diff --git a/src/conf_mode/vpp_interfaces_bonding.py b/src/conf_mode/vpp_interfaces_bonding.py index e1919e946..0ec4f7151 100644 --- a/src/conf_mode/vpp_interfaces_bonding.py +++ b/src/conf_mode/vpp_interfaces_bonding.py @@ -202,6 +202,8 @@ def apply(config): for member in members: i.add_member(interface=member) + i.set_state_up() + call_dependents() return None -- cgit v1.2.3 From 20ecb6d4ccaf903bcaaf658b1a655dd8d79b989c Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Wed, 22 Jan 2025 15:01:05 +0000 Subject: GRE: Add tunnel-type erspan, l3 and teb Add tunnel type - erspan - l3 - teb (Transparent Ethernet Bridge) By default L3 GRE interfaces cannot be bridged to a bridge interface. Add the ability to change tunnel type. set vpp interfaces gre gre2 tunnel-type 'teb' --- interface-definitions/vpp.xml.in | 25 +++++++++++++++++++++ python/vyos/vpp/interface/gre.py | 45 ++++++++++++++++++++++++++++++++++--- smoketest/scripts/cli/test_vpp.py | 4 ++-- src/conf_mode/vpp_interfaces_gre.py | 5 +++-- 4 files changed, 72 insertions(+), 7 deletions(-) (limited to 'python') diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 0718ba0fe..c946839db 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -180,6 +180,31 @@ + + + GRE tunnel type + + erspan l3 teb + + + erspan + Encapsulated Remote Switched Port Analyzer + + + l3 + Generic Routing Encapsulation (network layer) + + + teb + L2 Transparent Ethernet Bridge + + + (erspan|l3|teb) + + Invalid encapsulation, must be one of: l3, teb or erspan + + l3 + #include #include #include diff --git a/python/vyos/vpp/interface/gre.py b/python/vyos/vpp/interface/gre.py index 20a7ae9d4..65559f294 100644 --- a/python/vyos/vpp/interface/gre.py +++ b/python/vyos/vpp/interface/gre.py @@ -30,20 +30,58 @@ def show(): class GREInterface: - def __init__(self, ifname, source_address, remote, kernel_interface: str = ''): + """ + Class representing a GRE (Generic Routing Encapsulation) interface. + + Attributes: + ifname (str): The interface name. + source_address (str): The source IP address for the GRE tunnel. + remote (str): The remote IP address for the GRE tunnel. + tunnel_type (str): The type of GRE tunnel. Defaults to 'l3'. + kernel_interface (str): The associated kernel interface. Defaults to an empty string. + instance (int): The instance number derived from the interface name. + vpp (VPPControl): An instance of the VPPControl class for interacting with the VPP API. + """ + + # Mapping of tunnel types https://github.com/FDio/vpp/blob/stable/2406/src/plugins/gre/gre.api#L25-L35 + TUNNEL_TYPE_MAP = { + "l3": 0, + "teb": 1, + "erspan": 2, + } + + def __init__( + self, + ifname, + source_address, + remote, + tunnel_type: str = 'l3', + kernel_interface: str = '', + ): + """ + Initialize a GREInterface instance. + + Args: + ifname (str): The interface name. + source_address (str): The source IP address for the GRE tunnel. + remote (str): The remote IP address for the GRE tunnel. + tunnel_type (str): The type of GRE tunnel. Defaults to 'l3'. + kernel_interface (str): The associated kernel interface. Defaults to an empty string. + """ self.instance = int(ifname.removeprefix('gre')) self.ifname = ifname self.src_address = source_address self.dst_address = remote + self.tunnel_type = self.TUNNEL_TYPE_MAP[tunnel_type] self.kernel_interface = kernel_interface self.vpp = VPPControl() def add(self): """Create GRE interface - https://github.com/FDio/vpp/blob/stable/2306/src/plugins/gre/gre.api + https://github.com/FDio/vpp/blob/stable/2406/src/plugins/gre/gre.api Example: from vyos.vpp.interface import GREInterface - a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25') + a = GREInterface(ifname='gre0', source_address='192.0.2.1', remote='203.0.113.25', tunnel_type='l3') a.add() """ self.vpp.api.gre_tunnel_add_del( @@ -52,6 +90,7 @@ class GREInterface: 'src': self.src_address, 'dst': self.dst_address, 'instance': self.instance, + 'type': self.tunnel_type, }, ) diff --git a/smoketest/scripts/cli/test_vpp.py b/smoketest/scripts/cli/test_vpp.py index 582c41c68..ba0d0d728 100755 --- a/smoketest/scripts/cli/test_vpp.py +++ b/smoketest/scripts/cli/test_vpp.py @@ -552,7 +552,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertRegex( normalized_out, r'BondEthernet23\s+\d+\s+up', - "Interface BondEthernet23 is not in the expected state 'up'." + "Interface BondEthernet23 is not in the expected state 'up'.", ) # set kernel interface @@ -617,7 +617,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertRegex( normalized_out, r'BondEthernet23\s+\d+\s+up', - "Interface BondEthernet23 is not in the expected state 'up'." + "Interface BondEthernet23 is not in the expected state 'up'.", ) # delete vpp kernel-interface vlan diff --git a/src/conf_mode/vpp_interfaces_gre.py b/src/conf_mode/vpp_interfaces_gre.py index feff0d6b5..4cb1a71ea 100644 --- a/src/conf_mode/vpp_interfaces_gre.py +++ b/src/conf_mode/vpp_interfaces_gre.py @@ -124,7 +124,7 @@ def verify(config): return None # source-address and remote are mandatory options - required_keys = {'source_address', 'remote'} + required_keys = {'source_address', 'remote', 'tunnel_type'} if not all(key in config for key in required_keys): missing_keys = required_keys - set(config.keys()) raise ConfigError( @@ -159,7 +159,8 @@ def apply(config): src_addr = config.get('source_address') dst_addr = config.get('remote') kernel_interface = config.get('kernel_interface', '') - i = GREInterface(ifname, src_addr, dst_addr, kernel_interface) + tunnel_type = config.get('tunnel_type') + i = GREInterface(ifname, src_addr, dst_addr, tunnel_type, kernel_interface) i.add() # Add kernel-interface (LCP) if interface is not exist -- cgit v1.2.3 From 9425dce22f1bc8393d5ea6b2ae39b4ef70b7fa50 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Fri, 24 Jan 2025 15:26:27 +0000 Subject: GRE: Add ability to configure multipoint mode Add ability to configure multipoint mode. Remote IP address in this case has to be 0.0.0.0 Only one tunnel with the same source IP is allowed in the point-to-multipoint mode set vpp interfaces gre gre0 mode 'point-to-multipoint' set vpp interfaces gre gre0 remote '0.0.0.0' set vpp interfaces gre gre0 source-address '192.0.2.1' --- interface-definitions/vpp.xml.in | 21 +++++++++++++++++++++ python/vyos/vpp/interface/gre.py | 16 +++++++++++++--- src/conf_mode/vpp_interfaces_gre.py | 19 +++++++++++++++++-- 3 files changed, 51 insertions(+), 5 deletions(-) (limited to 'python') diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index c946839db..e3a2f9aff 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -180,6 +180,27 @@ + + + GRE tunnel mode + + point-to-point point-to-multipoint + + + point-to-point + Point to point mode + + + point-to-multipoint + Point to multipoint mode + + + (point-to-point|point-to-multipoint) + + Invalid mode, must be one of: point-to-point or point-to-multipoint + + point-to-point + GRE tunnel type diff --git a/python/vyos/vpp/interface/gre.py b/python/vyos/vpp/interface/gre.py index 65559f294..8f3dbebae 100644 --- a/python/vyos/vpp/interface/gre.py +++ b/python/vyos/vpp/interface/gre.py @@ -38,6 +38,7 @@ class GREInterface: source_address (str): The source IP address for the GRE tunnel. remote (str): The remote IP address for the GRE tunnel. tunnel_type (str): The type of GRE tunnel. Defaults to 'l3'. + mode (str): The mode of the GRE tunnel. Options are 'point-to-point' and 'point-to-multipoint'. Defaults to 'point-to-point'. kernel_interface (str): The associated kernel interface. Defaults to an empty string. instance (int): The instance number derived from the interface name. vpp (VPPControl): An instance of the VPPControl class for interacting with the VPP API. @@ -45,9 +46,14 @@ class GREInterface: # Mapping of tunnel types https://github.com/FDio/vpp/blob/stable/2406/src/plugins/gre/gre.api#L25-L35 TUNNEL_TYPE_MAP = { - "l3": 0, - "teb": 1, - "erspan": 2, + 'l3': 0, + 'teb': 1, + 'erspan': 2, + } + + MODE_MAP = { + 'point-to-point': 0, + 'point-to-multipoint': 1, } def __init__( @@ -56,6 +62,7 @@ class GREInterface: source_address, remote, tunnel_type: str = 'l3', + mode: str = 'point-to-point', kernel_interface: str = '', ): """ @@ -65,6 +72,7 @@ class GREInterface: ifname (str): The interface name. source_address (str): The source IP address for the GRE tunnel. remote (str): The remote IP address for the GRE tunnel. + mode (str): The mode of the GRE tunnel. Options are 'point-to-point' and 'point-to-multipoint'. Defaults to 'point-to-point'. tunnel_type (str): The type of GRE tunnel. Defaults to 'l3'. kernel_interface (str): The associated kernel interface. Defaults to an empty string. """ @@ -73,6 +81,7 @@ class GREInterface: self.src_address = source_address self.dst_address = remote self.tunnel_type = self.TUNNEL_TYPE_MAP[tunnel_type] + self.mode = self.MODE_MAP[mode] self.kernel_interface = kernel_interface self.vpp = VPPControl() @@ -90,6 +99,7 @@ class GREInterface: 'src': self.src_address, 'dst': self.dst_address, 'instance': self.instance, + 'mode': self.mode, 'type': self.tunnel_type, }, ) diff --git a/src/conf_mode/vpp_interfaces_gre.py b/src/conf_mode/vpp_interfaces_gre.py index 4cb1a71ea..334725994 100644 --- a/src/conf_mode/vpp_interfaces_gre.py +++ b/src/conf_mode/vpp_interfaces_gre.py @@ -124,13 +124,27 @@ def verify(config): return None # source-address and remote are mandatory options - required_keys = {'source_address', 'remote', 'tunnel_type'} + required_keys = {'source_address', 'remote', 'mode', 'tunnel_type'} if not all(key in config for key in required_keys): missing_keys = required_keys - set(config.keys()) raise ConfigError( f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" ) + # check multipoint mode + if config.get('mode') == 'point-to-multipoint': + # For multipoint mode, remote IP must be 0.0.0.0 + if config.get('remote') != '0.0.0.0': + raise ConfigError('For point-to-multipoint mode, remote must be 0.0.0.0') + # Only one multipoint GRE tunnel is allowed from the same source address + for ifname, iface in config.get('vpp_interfaces', {}).items(): + if iface.get('mode') == 'point-to-multipoint' and iface.get( + 'source_address' + ) == config.get('source_address'): + raise ConfigError( + 'Only one multipoint GRE tunnel is allowed from the same source address' + ) + # Change 'vpp interfaces gre greX kernel-interface vpp-tunX' # => 'vpp interfaces gre greX kernel-interface vpp-tunY' # check if we have kernel interface config 'vpp kernel-interface vpp-tunX' @@ -159,8 +173,9 @@ def apply(config): src_addr = config.get('source_address') dst_addr = config.get('remote') kernel_interface = config.get('kernel_interface', '') + mode = config.get('mode') tunnel_type = config.get('tunnel_type') - i = GREInterface(ifname, src_addr, dst_addr, tunnel_type, kernel_interface) + i = GREInterface(ifname, src_addr, dst_addr, tunnel_type, mode, kernel_interface) i.add() # Add kernel-interface (LCP) if interface is not exist -- cgit v1.2.3 From 529c24a9d550b30ba7fabcbae3a5bc0d5d35c647 Mon Sep 17 00:00:00 2001 From: metron2 Date: Mon, 6 Jan 2025 17:55:13 -0500 Subject: T6998: dhcpy.py - fix datetime to be timezone aware --- python/vyos/kea.py | 12 +++--------- src/op_mode/dhcp.py | 13 +++++-------- 2 files changed, 8 insertions(+), 17 deletions(-) (limited to 'python') diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 951c83693..8b3628db1 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -476,9 +476,7 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list lifetime = lease['valid-lft'] expiry = lease['cltt'] + lifetime - lease['start_timestamp'] = datetime.fromtimestamp( - expiry - lifetime, timezone.utc - ) + lease['start_timestamp'] = datetime.fromtimestamp(lease['cltt'], timezone.utc) lease['expire_timestamp'] = ( datetime.fromtimestamp(expiry, timezone.utc) if expiry else None ) @@ -515,14 +513,10 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list data_lease['remaining'] = '-' if lease['valid-lft'] > 0: - data_lease['remaining'] = lease['expire_timestamp'] - datetime.now( - timezone.utc - ) - - if data_lease['remaining'].days >= 0: + if lease['expire_timestamp'] > datetime.now(timezone.utc): # substraction gives us a timedelta object which can't be formatted with strftime # so we use str(), split gets rid of the microseconds - data_lease['remaining'] = str(data_lease['remaining']).split('.')[0] + data_lease['remaining'] = str(lease['expire_timestamp'] - datetime.now(timezone.utc)).split('.')[0] # Do not add old leases if ( diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index b3d7d4dd3..7091808e0 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -19,6 +19,7 @@ import sys import typing from datetime import datetime +from datetime import timezone from glob import glob from ipaddress import ip_address from tabulate import tabulate @@ -110,10 +111,8 @@ def _get_formatted_server_leases(raw_data, family='inet'): ipaddr = lease.get('ip') hw_addr = lease.get('mac') state = lease.get('state') - start = lease.get('start') - start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') - end = lease.get('end') - end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-' + start = datetime.fromtimestamp(lease.get('start'), timezone.utc) + end = datetime.fromtimestamp(lease.get('end'), timezone.utc) if lease.get('end') else '-' remain = lease.get('remaining') pool = lease.get('pool') hostname = lease.get('hostname') @@ -138,10 +137,8 @@ def _get_formatted_server_leases(raw_data, family='inet'): for lease in raw_data: ipaddr = lease.get('ip') state = lease.get('state') - start = lease.get('last_communication') - start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S') - end = lease.get('end') - end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') + start = datetime.fromtimestamp(lease.get('last_communication'), timezone.utc) + end = datetime.fromtimestamp(lease.get('end'), timezone.utc) if lease.get('end') else '-' remain = lease.get('remaining') lease_type = lease.get('type') pool = lease.get('pool') -- cgit v1.2.3 From e8e05f87e5c23eb59d7895ff50e90f3d3e60e2f1 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Wed, 22 Jan 2025 01:42:33 -0600 Subject: T6998: Remove vestigial helper and reformat --- python/vyos/kea.py | 23 ++++++++++++----------- src/op_mode/dhcp.py | 22 +++++++++++++--------- 2 files changed, 25 insertions(+), 20 deletions(-) (limited to 'python') diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 8b3628db1..baac75eda 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -474,10 +474,11 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list data = [] for lease in leases: lifetime = lease['valid-lft'] - expiry = lease['cltt'] + lifetime + start = lease['cltt'] + expiry = start + lifetime - lease['start_timestamp'] = datetime.fromtimestamp(lease['cltt'], timezone.utc) - lease['expire_timestamp'] = ( + lease['start_time'] = datetime.fromtimestamp(start, timezone.utc) + lease['expire_time'] = ( datetime.fromtimestamp(expiry, timezone.utc) if expiry else None ) @@ -491,7 +492,7 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list else '-' ) data_lease['end'] = ( - lease['expire_timestamp'].timestamp() if lease['expire_timestamp'] else None + lease['expire_time'].timestamp() if lease['expire_time'] else None ) data_lease['origin'] = 'local' # TODO: Determine remote in HA # remove trailing dot in 'hostname' to ensure consistency for `vyos-hostsd-client` @@ -499,10 +500,10 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list if inet == '4': data_lease['mac'] = lease['hw-address'] - data_lease['start'] = lease['start_timestamp'].timestamp() + data_lease['start'] = lease['start_time'].timestamp() if inet == '6': - data_lease['last_communication'] = lease['start_timestamp'].timestamp() + data_lease['last_communication'] = lease['start_time'].timestamp() data_lease['duid'] = _format_hex_string(lease['duid']) data_lease['type'] = lease['type'] @@ -512,11 +513,11 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list data_lease['remaining'] = '-' - if lease['valid-lft'] > 0: - if lease['expire_timestamp'] > datetime.now(timezone.utc): - # substraction gives us a timedelta object which can't be formatted with strftime - # so we use str(), split gets rid of the microseconds - data_lease['remaining'] = str(lease['expire_timestamp'] - datetime.now(timezone.utc)).split('.')[0] + now = datetime.now(timezone.utc) + if lease['valid-lft'] > 0 and lease['expire_time'] > now: + # substraction gives us a timedelta object which can't be formatted + # with strftime so we use str(), split gets rid of the microseconds + data_lease['remaining'] = str(lease['expire_time'] - now).split('.')[0] # Do not add old leases if ( diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index 7091808e0..8eed2c6cd 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -82,12 +82,6 @@ ArgState = typing.Literal[ ArgOrigin = typing.Literal['local', 'remote'] -def _utc_to_local(utc_dt): - return datetime.fromtimestamp( - (datetime.fromtimestamp(utc_dt) - datetime(1970, 1, 1)).total_seconds() - ) - - def _get_raw_server_leases( config, family='inet', pool=None, sorted=None, state=[], origin=None ) -> list: @@ -112,7 +106,11 @@ def _get_formatted_server_leases(raw_data, family='inet'): hw_addr = lease.get('mac') state = lease.get('state') start = datetime.fromtimestamp(lease.get('start'), timezone.utc) - end = datetime.fromtimestamp(lease.get('end'), timezone.utc) if lease.get('end') else '-' + end = ( + datetime.fromtimestamp(lease.get('end'), timezone.utc) + if lease.get('end') + else '-' + ) remain = lease.get('remaining') pool = lease.get('pool') hostname = lease.get('hostname') @@ -137,8 +135,14 @@ def _get_formatted_server_leases(raw_data, family='inet'): for lease in raw_data: ipaddr = lease.get('ip') state = lease.get('state') - start = datetime.fromtimestamp(lease.get('last_communication'), timezone.utc) - end = datetime.fromtimestamp(lease.get('end'), timezone.utc) if lease.get('end') else '-' + start = datetime.fromtimestamp( + lease.get('last_communication'), timezone.utc + ) + end = ( + datetime.fromtimestamp(lease.get('end'), timezone.utc) + if lease.get('end') + else '-' + ) remain = lease.get('remaining') lease_type = lease.get('type') pool = lease.get('pool') -- cgit v1.2.3 From 9723f1054a98d82ee71a18eb672472be0a632f8e Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Sun, 26 Jan 2025 15:16:58 -0600 Subject: dhcp: T7052: Fix remaining time evaluation and formatting errors The remaining time for a lease was not being correctly evaluated and formatted. As a result, expired leases show up with `show dhcp server leases`. Also, the empty hostname should be replaced by '-'. --- python/vyos/kea.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 951c83693..fe1564355 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -497,7 +497,7 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list ) data_lease['origin'] = 'local' # TODO: Determine remote in HA # remove trailing dot in 'hostname' to ensure consistency for `vyos-hostsd-client` - data_lease['hostname'] = lease.get('hostname', '-').rstrip('.') + data_lease['hostname'] = lease.get('hostname', '').rstrip('.') or '-' if inet == '4': data_lease['mac'] = lease['hw-address'] @@ -512,7 +512,7 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list prefix_len = lease['prefix-len'] data_lease['ip'] += f'/{prefix_len}' - data_lease['remaining'] = '-' + data_lease['remaining'] = '' if lease['valid-lft'] > 0: data_lease['remaining'] = lease['expire_timestamp'] - datetime.now( @@ -526,7 +526,7 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list # Do not add old leases if ( - data_lease['remaining'] + data_lease['remaining'] != '' and data_lease['pool'] in pools and data_lease['state'] != 'free' and (not state or state == 'all' or data_lease['state'] in state) -- cgit v1.2.3 From f76e349c11ae30d104ae09fef4e73c85f35d1885 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Mon, 27 Jan 2025 12:29:12 +0000 Subject: Bonding: Use common class for the interface state --- python/vyos/vpp/interface/interface.py | 0 src/conf_mode/vpp_interfaces_bonding.py | 5 ++--- 2 files changed, 2 insertions(+), 3 deletions(-) create mode 100644 python/vyos/vpp/interface/interface.py (limited to 'python') diff --git a/python/vyos/vpp/interface/interface.py b/python/vyos/vpp/interface/interface.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/conf_mode/vpp_interfaces_bonding.py b/src/conf_mode/vpp_interfaces_bonding.py index 0ec4f7151..a93239d57 100644 --- a/src/conf_mode/vpp_interfaces_bonding.py +++ b/src/conf_mode/vpp_interfaces_bonding.py @@ -190,8 +190,9 @@ def apply(config): members = config.get('member', {}).get('interface', []) mac = config.get('mac', '') kernel_interface = config.get('kernel_interface', '') + state = 'up' if 'disable' not in config else 'down' - i = BondInterface(ifname, mode, lb, mac, kernel_interface) + i = BondInterface(ifname, mode, lb, mac, kernel_interface, state) # Introduce a delay to address instability in the VPP API, which may fail to create the LCP # or establish a connection. This should be reviewed and resolved in future releases. time.sleep(2) @@ -202,8 +203,6 @@ def apply(config): for member in members: i.add_member(interface=member) - i.set_state_up() - call_dependents() return None -- cgit v1.2.3 From 9e1c4f73a01ce27bb840072ee771da68593cbe08 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Mon, 27 Jan 2025 12:29:53 +0000 Subject: Add common interface state --- python/vyos/vpp/interface/bond.py | 30 +++++---------------- python/vyos/vpp/interface/interface.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 23 deletions(-) (limited to 'python') diff --git a/python/vyos/vpp/interface/bond.py b/python/vyos/vpp/interface/bond.py index a90f2950b..6a96bce58 100644 --- a/python/vyos/vpp/interface/bond.py +++ b/python/vyos/vpp/interface/bond.py @@ -15,11 +15,11 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -from vyos.vpp import VPPControl from vyos.vpp.control_host import set_promisc +from vyos.vpp.interface.interface import Interface -class BondInterface: +class BondInterface(Interface): def __init__( self, ifname, @@ -27,14 +27,16 @@ class BondInterface: load_balance: int = 0, mac: str = '', kernel_interface: str = '', + state: str = 'up', ): + super().__init__(ifname) self.instance = int(ifname.removeprefix('bond')) self.ifname = f'BondEthernet{self.instance}' self.mode = mode self.load_balance = load_balance self.mac = mac self.kernel_interface = kernel_interface - self.vpp = VPPControl() + self.state = state def add(self): """Create Bond interface @@ -55,6 +57,8 @@ class BondInterface: self.vpp.api.bond_create2(**create_args) if self.kernel_interface: self.vpp.lcp_pair_add(self.ifname, self.kernel_interface) + # Set interface state + self.set_state(self.state) def delete(self): """Delete Bond interface @@ -112,23 +116,3 @@ class BondInterface: a.kernel_delete() """ self.vpp.lcp_pair_del(self.ifname, self.kernel_interface) - - def set_state_up(self): - """Set Bond interface state to UP - Example: - from vyos.vpp.interface import BondInterface - a = BondInterface(ifname='bond0') - a.set_state_up() - """ - bond_if_index = self.vpp.get_sw_if_index(self.ifname) - self.vpp.api.sw_interface_set_flags(sw_if_index=bond_if_index, flags=1) - - def set_state_down(self): - """Set Bond interface state to DOWN - Example: - from vyos.vpp.interface import BondInterface - a = BondInterface(ifname='bond0') - a.set_state_down() - """ - bond_if_index = self.vpp.get_sw_if_index(self.ifname) - self.vpp.api.sw_interface_set_flags(sw_if_index=bond_if_index, flags=0) diff --git a/python/vyos/vpp/interface/interface.py b/python/vyos/vpp/interface/interface.py index e69de29bb..33dc779c2 100644 --- a/python/vyos/vpp/interface/interface.py +++ b/python/vyos/vpp/interface/interface.py @@ -0,0 +1,49 @@ +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class Interface: + def __init__(self, ifname): + self.ifname = ifname + self.vpp = VPPControl() + + def set_state(self, state: str): + """Set interface state to UP or DOWN + Args: + state (str): The state of the interface. Options are 'up' and 'down'. + Example: + from vyos.vpp.interface import Interface + a = Interface(ifname='eth0') + a.set_state(state='up') + """ + if state not in ['up', 'down']: + raise ValueError(f"Invalid state: {state}") + state_flag = 1 if state == 'up' else 0 + if_index = self.vpp.get_sw_if_index(self.ifname) + self.vpp.api.sw_interface_set_flags(sw_if_index=if_index, flags=state_flag) + + def get_state(self): + """Get interface state + Example: + from vyos.vpp.interface import Interface + a = Interface(ifname='eth0') + a.get_state() + """ + if_index = self.vpp.get_sw_if_index(self.ifname) + return self.vpp.api.sw_interface_dump(sw_if_index=if_index)[0]['flags'] -- cgit v1.2.3 From cb546cb6caa0e1f52c425eb13a093e66343e1bd1 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Mon, 27 Jan 2025 11:18:27 -0500 Subject: utils: T7095: remove unused `auth` parameter --- python/vyos/utils/process.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/process.py b/python/vyos/utils/process.py index 054088325..d47e64588 100644 --- a/python/vyos/utils/process.py +++ b/python/vyos/utils/process.py @@ -21,20 +21,17 @@ from subprocess import STDOUT from subprocess import DEVNULL -def get_wrapper(vrf, netns, auth): +def get_wrapper(vrf, netns): wrapper = '' if vrf: wrapper = f'ip vrf exec {vrf} ' elif netns: wrapper = f'ip netns exec {netns} ' - if auth: - wrapper = f'{auth} {wrapper}' return wrapper def popen(command, flag='', shell=None, input=None, timeout=None, env=None, - stdout=PIPE, stderr=PIPE, decode='utf-8', auth='', vrf=None, - netns=None): + stdout=PIPE, stderr=PIPE, decode='utf-8', vrf=None, netns=None): """ popen is a wrapper helper around subprocess.Popen with it default setting it will return a tuple (out, err) @@ -82,7 +79,7 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, 'Permission denied: cannot execute commands in VRF and netns contexts as an unprivileged user' ) - wrapper = get_wrapper(vrf, netns, auth) + wrapper = get_wrapper(vrf, netns) command = f'{wrapper} {command}' cmd_msg = f"cmd '{command}'" @@ -155,7 +152,7 @@ def run(command, flag='', shell=None, input=None, timeout=None, env=None, def cmd(command, flag='', shell=None, input=None, timeout=None, env=None, stdout=PIPE, stderr=PIPE, decode='utf-8', raising=None, message='', - expect=[0], auth='', vrf=None, netns=None): + expect=[0], vrf=None, netns=None): """ A wrapper around popen, which returns the stdout and will raise the error code of a command @@ -171,12 +168,11 @@ def cmd(command, flag='', shell=None, input=None, timeout=None, env=None, input=input, timeout=timeout, env=env, shell=shell, decode=decode, - auth=auth, vrf=vrf, netns=netns, ) if code not in expect: - wrapper = get_wrapper(vrf, netns, auth='') + wrapper = get_wrapper(vrf, netns) command = f'{wrapper} {command}' feedback = message + '\n' if message else '' feedback += f'failed to run command: {command}\n' -- cgit v1.2.3 From a24d2f87fdde466625d9b6173657f07cf4401f30 Mon Sep 17 00:00:00 2001 From: Robert Gingras Date: Mon, 27 Jan 2025 11:30:46 -0500 Subject: utils: T7095: make wrapper use_shell aware and only utilize if vrf or netns are requested --- python/vyos/utils/process.py | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/process.py b/python/vyos/utils/process.py index d47e64588..faed8f1b0 100644 --- a/python/vyos/utils/process.py +++ b/python/vyos/utils/process.py @@ -14,6 +14,7 @@ # License along with this library. If not, see . import os +import shlex from subprocess import Popen from subprocess import PIPE @@ -22,11 +23,11 @@ from subprocess import DEVNULL def get_wrapper(vrf, netns): - wrapper = '' + wrapper = None if vrf: - wrapper = f'ip vrf exec {vrf} ' + wrapper = ['ip', 'vrf', 'exec', vrf] elif netns: - wrapper = f'ip netns exec {netns} ' + wrapper = ['ip', 'netns', 'exec', netns] return wrapper @@ -72,6 +73,15 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, if not debug.enabled(flag): flag = 'command' + use_shell = shell + stdin = None + if shell is None: + use_shell = False + if ' ' in command: + use_shell = True + if env: + use_shell = True + # Must be run as root to execute command in VRF or network namespace if vrf or netns: if os.getuid() != 0: @@ -79,21 +89,17 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, 'Permission denied: cannot execute commands in VRF and netns contexts as an unprivileged user' ) - wrapper = get_wrapper(vrf, netns) - command = f'{wrapper} {command}' + wrapper = get_wrapper(vrf, netns) + if use_shell: + command = f'{shlex.join(wrapper)} {command}' + else: + if type(command) is not list: + command = [command] + command = wrapper + command - cmd_msg = f"cmd '{command}'" + cmd_msg = f"cmd '{command}'" if use_shell else f"cmd '{shlex.join(command)}'" debug.message(cmd_msg, flag) - use_shell = shell - stdin = None - if shell is None: - use_shell = False - if ' ' in command: - use_shell = True - if env: - use_shell = True - if input: stdin = PIPE input = input.encode() if type(input) is str else input -- cgit v1.2.3 From dfda2c7f305df94690cc4538a74ab213d25c579c Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Mon, 27 Jan 2025 18:01:57 +0000 Subject: Use common class for none ethernet interfaces state --- python/vyos/vpp/interface/__init__.py | 2 ++ python/vyos/vpp/interface/gre.py | 10 ++++++++-- python/vyos/vpp/interface/ipip.py | 17 ++++++++++++++--- python/vyos/vpp/interface/loopback.py | 11 +++++++---- python/vyos/vpp/interface/vxlan.py | 18 +++++++++++++++--- python/vyos/vpp/interface/xconnect.py | 2 ++ src/conf_mode/vpp_interfaces_gre.py | 5 ++++- src/conf_mode/vpp_interfaces_ipip.py | 3 ++- src/conf_mode/vpp_interfaces_loopback.py | 3 ++- src/conf_mode/vpp_interfaces_vxlan.py | 3 ++- src/conf_mode/vpp_interfaces_xconnect.py | 3 ++- 11 files changed, 60 insertions(+), 17 deletions(-) (limited to 'python') diff --git a/python/vyos/vpp/interface/__init__.py b/python/vyos/vpp/interface/__init__.py index 14659ed7b..2048e07ca 100644 --- a/python/vyos/vpp/interface/__init__.py +++ b/python/vyos/vpp/interface/__init__.py @@ -20,6 +20,7 @@ from .bridge import BridgeInterface from .ethernet import EthernetInterface from .geneve import GeneveInterface from .gre import GREInterface +from .interface import Interface from .ipip import IPIPInterface from .loopback import LoopbackInterface from .vxlan import VXLANInterface @@ -32,6 +33,7 @@ __all__ = [ 'EthernetInterface', 'GeneveInterface', 'GREInterface', + 'Interface', 'IPIPInterface', 'LoopbackInterface', 'VXLANInterface', diff --git a/python/vyos/vpp/interface/gre.py b/python/vyos/vpp/interface/gre.py index 8f3dbebae..5bef280c6 100644 --- a/python/vyos/vpp/interface/gre.py +++ b/python/vyos/vpp/interface/gre.py @@ -17,6 +17,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from vyos.vpp import VPPControl +from vyos.vpp.interface.interface import Interface def show(): @@ -29,7 +30,7 @@ def show(): return vpp.api.gre_tunnel_dump() -class GREInterface: +class GREInterface(Interface): """ Class representing a GRE (Generic Routing Encapsulation) interface. @@ -64,6 +65,7 @@ class GREInterface: tunnel_type: str = 'l3', mode: str = 'point-to-point', kernel_interface: str = '', + state: str = 'up', ): """ Initialize a GREInterface instance. @@ -75,7 +77,9 @@ class GREInterface: mode (str): The mode of the GRE tunnel. Options are 'point-to-point' and 'point-to-multipoint'. Defaults to 'point-to-point'. tunnel_type (str): The type of GRE tunnel. Defaults to 'l3'. kernel_interface (str): The associated kernel interface. Defaults to an empty string. + state (str): The state of the interface. Defaults to 'up'. """ + super().__init__(ifname) self.instance = int(ifname.removeprefix('gre')) self.ifname = ifname self.src_address = source_address @@ -83,7 +87,7 @@ class GREInterface: self.tunnel_type = self.TUNNEL_TYPE_MAP[tunnel_type] self.mode = self.MODE_MAP[mode] self.kernel_interface = kernel_interface - self.vpp = VPPControl() + self.initial_state = state def add(self): """Create GRE interface @@ -103,6 +107,8 @@ class GREInterface: 'type': self.tunnel_type, }, ) + # Set interface state + self.set_state(self.initial_state) def delete(self): """Delete GRE interface diff --git a/python/vyos/vpp/interface/ipip.py b/python/vyos/vpp/interface/ipip.py index 1aa1e22f2..4779a8292 100644 --- a/python/vyos/vpp/interface/ipip.py +++ b/python/vyos/vpp/interface/ipip.py @@ -17,6 +17,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from vyos.vpp import VPPControl +from vyos.vpp.interface.interface import Interface def show(): @@ -29,14 +30,22 @@ def show(): return vpp.api.ipip_tunnel_dump() -class IPIPInterface: - def __init__(self, ifname, source_address, remote, kernel_interface: str = ''): +class IPIPInterface(Interface): + def __init__( + self, + ifname, + source_address, + remote, + kernel_interface: str = '', + state: str = 'up', + ): + super().__init__(ifname) self.instance = int(ifname.removeprefix('ipip')) self.ifname = ifname self.src_address = source_address self.dst_address = remote self.kernel_interface = kernel_interface - self.vpp = VPPControl() + self.initial_state = state def add(self): """Create IPIP interface @@ -53,6 +62,8 @@ class IPIPInterface: 'instance': self.instance, }, ) + # Set interface state + self.set_state(self.initial_state) def delete(self): """Delete IPIP interface diff --git a/python/vyos/vpp/interface/loopback.py b/python/vyos/vpp/interface/loopback.py index 8a5414d54..d417f627d 100644 --- a/python/vyos/vpp/interface/loopback.py +++ b/python/vyos/vpp/interface/loopback.py @@ -16,17 +16,18 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -from vyos.vpp import VPPControl +from vyos.vpp.interface.interface import Interface -class LoopbackInterface: +class LoopbackInterface(Interface): """Interface Loopback""" - def __init__(self, ifname, kernel_interface: str = ''): + def __init__(self, ifname, kernel_interface: str = '', state: str = 'up'): + super().__init__(ifname) self.instance = int(ifname.removeprefix('lo')) self.ifname = f'loop{self.instance}' self.kernel_interface = kernel_interface - self.vpp = VPPControl() + self.initial_state = state def add(self): """Create Loopback interface @@ -39,6 +40,8 @@ class LoopbackInterface: self.vpp.api.create_loopback_instance( is_specified=True, user_instance=self.instance ) + # Set interface state + self.set_state(self.initial_state) def delete(self): """Delete Loopback interface diff --git a/python/vyos/vpp/interface/vxlan.py b/python/vyos/vpp/interface/vxlan.py index 36206e8c4..eacc901dc 100644 --- a/python/vyos/vpp/interface/vxlan.py +++ b/python/vyos/vpp/interface/vxlan.py @@ -17,6 +17,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. from vyos.vpp import VPPControl +from vyos.vpp.interface.interface import Interface def show(): @@ -29,17 +30,26 @@ def show(): return vpp.api.vxlan_tunnel_dump() -class VXLANInterface: +class VXLANInterface(Interface): """Interface VXLAN""" - def __init__(self, ifname, source_address, remote, vni, kernel_interface: str = ''): + def __init__( + self, + ifname, + source_address, + remote, + vni, + kernel_interface: str = '', + state: str = 'up', + ): + super().__init__(ifname) self.instance = int(ifname.removeprefix('vxlan')) self.ifname = f'vxlan_tunnel{self.instance}' self.src_address = source_address self.dst_address = remote self.vni = vni self.kernel_interface = kernel_interface - self.vpp = VPPControl() + self.initial_state = state def add(self): """Create VXLAN interface @@ -59,6 +69,8 @@ class VXLANInterface: decap_next_index=1, is_l3=False, ) + # Set interface state + self.set_state(self.initial_state) def delete(self): """Delete VXLAN interface diff --git a/python/vyos/vpp/interface/xconnect.py b/python/vyos/vpp/interface/xconnect.py index 41cf773d9..badece6e8 100644 --- a/python/vyos/vpp/interface/xconnect.py +++ b/python/vyos/vpp/interface/xconnect.py @@ -25,10 +25,12 @@ class XconnectInterface: self, ifname: str, members: list = [], + state: str = 'up', ): self.ifname = ifname self.members = members self.vpp = VPPControl() + self.initial_state = state def add_l2_xconnect(self): """Add l2 cross connect diff --git a/src/conf_mode/vpp_interfaces_gre.py b/src/conf_mode/vpp_interfaces_gre.py index 817345135..c8e84f169 100644 --- a/src/conf_mode/vpp_interfaces_gre.py +++ b/src/conf_mode/vpp_interfaces_gre.py @@ -167,7 +167,10 @@ def apply(config): kernel_interface = config.get('kernel_interface', '') mode = config.get('mode') tunnel_type = config.get('tunnel_type') - i = GREInterface(ifname, src_addr, dst_addr, tunnel_type, mode, kernel_interface) + state = 'up' if 'disable' not in config else 'down' + i = GREInterface( + ifname, src_addr, dst_addr, tunnel_type, mode, kernel_interface, state + ) i.add() # Add kernel-interface (LCP) if interface is not exist diff --git a/src/conf_mode/vpp_interfaces_ipip.py b/src/conf_mode/vpp_interfaces_ipip.py index e15eaf5e5..3b03f91cd 100644 --- a/src/conf_mode/vpp_interfaces_ipip.py +++ b/src/conf_mode/vpp_interfaces_ipip.py @@ -159,7 +159,8 @@ def apply(config): src_addr = config.get('source_address') dst_addr = config.get('remote') kernel_interface = config.get('kernel_interface', '') - i = IPIPInterface(ifname, src_addr, dst_addr, kernel_interface) + state = 'up' if 'disable' not in config else 'down' + i = IPIPInterface(ifname, src_addr, dst_addr, kernel_interface, state) i.add() if 'kernel_interface' in config: diff --git a/src/conf_mode/vpp_interfaces_loopback.py b/src/conf_mode/vpp_interfaces_loopback.py index a96c379a1..6aa3fc850 100644 --- a/src/conf_mode/vpp_interfaces_loopback.py +++ b/src/conf_mode/vpp_interfaces_loopback.py @@ -130,7 +130,8 @@ def apply(config): # Add interface kernel_interface = config.get('kernel_interface', '') - i = LoopbackInterface(ifname, kernel_interface) + state = 'up' if 'disable' not in config else 'down' + i = LoopbackInterface(ifname, kernel_interface, state) i.add() # Add kernel-interface (LCP) if interface is not exist diff --git a/src/conf_mode/vpp_interfaces_vxlan.py b/src/conf_mode/vpp_interfaces_vxlan.py index de3114124..81bc34bea 100644 --- a/src/conf_mode/vpp_interfaces_vxlan.py +++ b/src/conf_mode/vpp_interfaces_vxlan.py @@ -165,7 +165,8 @@ def apply(config): dst_addr = config.get('remote') vni = int(config.get('vni')) kernel_interface = config.get('kernel_interface', '') - i = VXLANInterface(ifname, src_addr, dst_addr, vni, kernel_interface) + state = 'up' if 'disable' not in config else 'down' + i = VXLANInterface(ifname, src_addr, dst_addr, vni, kernel_interface, state) i.add() # Add kernel-interface (LCP) if interface is not exist diff --git a/src/conf_mode/vpp_interfaces_xconnect.py b/src/conf_mode/vpp_interfaces_xconnect.py index 3659c6479..c323684a3 100644 --- a/src/conf_mode/vpp_interfaces_xconnect.py +++ b/src/conf_mode/vpp_interfaces_xconnect.py @@ -124,7 +124,8 @@ def apply(config): # Add xconnect members = config.get('member', {}).get('interface') - i = XconnectInterface(ifname, members=members) + state = 'up' if 'disable' not in config else 'down' + i = XconnectInterface(ifname, members=members, state=state) i.add_l2_xconnect() return None -- cgit v1.2.3 From e588ac20f67735c5fa924c94783b636de4378618 Mon Sep 17 00:00:00 2001 From: Daniil Baturin Date: Mon, 27 Jan 2025 21:04:17 +0000 Subject: opmode: T7084: reorganize the op mode cache format for ease of search (#4313) * opmode: T7084: reorganize the op mode cache format for ease of search * opmode: T7084: normalize formatting --- python/vyos/xml_ref/generate_op_cache.py | 95 ++++++++++++++++---------------- 1 file changed, 49 insertions(+), 46 deletions(-) (limited to 'python') diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index cd2ac890e..95779d066 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright (C) 2024-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -33,9 +33,9 @@ _here = dirname(__file__) sys.path.append(join(_here, '..')) from defaults import directories -from op_definition import NodeData from op_definition import PathData + xml_op_cache_json = 'xml_op_cache.json' xml_op_tmp = join('/tmp', xml_op_cache_json) op_ref_cache = abspath(join(_here, 'op_cache.py')) @@ -74,7 +74,7 @@ def translate_op_script(s: str) -> str: return s -def insert_node(n: Element, l: list[PathData], path = None) -> None: +def insert_node(n: Element, l: list[PathData], path=None) -> None: # pylint: disable=too-many-locals,too-many-branches prop: OptElement = n.find('properties') children: OptElement = n.find('children') @@ -95,65 +95,67 @@ def insert_node(n: Element, l: list[PathData], path = None) -> None: if command_text is not None: command_text = translate_command(command_text, path) - comp_help = None + comp_help = {} if prop is not None: - che = prop.findall("completionHelp") + che = prop.findall('completionHelp') + for c in che: - lists = c.findall("list") - paths = c.findall("path") - scripts = c.findall("script") - - comp_help = {} - list_l = [] - for i in lists: - list_l.append(i.text) - path_l = [] - for i in paths: - path_str = re.sub(r'\s+', '/', i.text) - path_l.append(path_str) - script_l = [] - for i in scripts: - script_str = translate_op_script(i.text) - script_l.append(script_str) - - comp_help['list'] = list_l - comp_help['fs_path'] = path_l - comp_help['script'] = script_l - - for d in l: - if name in list(d): - break - else: - d = {} - l.append(d) - - inner_l = d.setdefault(name, []) - - inner_d: PathData = {'node_data': NodeData(node_type=node_type, - help_text=help_text, - comp_help=comp_help, - command=command_text, - path=path)} - inner_l.append(inner_d) + comp_list_els = c.findall('list') + comp_path_els = c.findall('path') + comp_script_els = c.findall('script') + + comp_lists = [] + for i in comp_list_els: + comp_lists.append(i.text) + + comp_paths = [] + for i in comp_path_els: + comp_paths.append(i.text) + + comp_scripts = [] + for i in comp_script_els: + comp_script_str = translate_op_script(i.text) + comp_scripts.append(comp_script_str) + + if comp_lists: + comp_help['list'] = comp_lists + if comp_paths: + comp_help['path'] = comp_paths + if comp_scripts: + comp_help['script'] = comp_scripts + + cur_node_dict = {} + cur_node_dict['name'] = name + cur_node_dict['type'] = node_type + cur_node_dict['comp_help'] = comp_help + cur_node_dict['help'] = help_text + cur_node_dict['command'] = command_text + cur_node_dict['path'] = path + cur_node_dict['children'] = [] + l.append(cur_node_dict) if children is not None: - inner_nodes = children.iterfind("*") + inner_nodes = children.iterfind('*') for inner_n in inner_nodes: inner_path = path[:] - insert_node(inner_n, inner_l, inner_path) + insert_node(inner_n, cur_node_dict['children'], inner_path) def parse_file(file_path, l): tree = ET.parse(file_path) root = tree.getroot() - for n in root.iterfind("*"): + for n in root.iterfind('*'): insert_node(n, l) def main(): parser = ArgumentParser(description='generate dict from xml defintions') - parser.add_argument('--xml-dir', type=str, required=True, - help='transcluded xml op-mode-definition file') + parser.add_argument( + '--xml-dir', + type=str, + required=True, + help='transcluded xml op-mode-definition file', + ) args = vars(parser.parse_args()) @@ -170,5 +172,6 @@ def main(): with open(op_ref_cache, 'w') as f: f.write(f'op_reference = {str(l)}') + if __name__ == '__main__': main() -- cgit v1.2.3 From 2d5462f4a024469629f6fb2c5286e7f45d6a6df1 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Wed, 29 Jan 2025 15:27:42 +0200 Subject: Use lcp_nl_resync instead of lcp_resync for control_vpp.py Use lcp_nl_resync (available with patch 04) 0004-Improved-lcp-resync.patch It improves sync speed and should fix or smoketest issues related to connecting to VPP API speed --- python/vyos/vpp/control_vpp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/vpp/control_vpp.py b/python/vyos/vpp/control_vpp.py index 39ecac906..2c81b2db2 100644 --- a/python/vyos/vpp/control_vpp.py +++ b/python/vyos/vpp/control_vpp.py @@ -283,7 +283,7 @@ class VPPControl: This clears all routes in VPP configured by LCP and re-creates them based on the current state of the kernel. """ - return self.__vpp_api_client.api.lcp_resync() + return self.__vpp_api_client.api.lcp_nl_resync() @_Decorators.check_retval @_Decorators.api_call -- cgit v1.2.3 From 332405d8e006eadc77ccd7bb99b3e9e6e6d8d0cc Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 31 Jan 2025 11:39:48 -0600 Subject: vyconf: T6718: drop hybrid set/delete functions The 'hybrid' mode of vyconfd validation and Cstore commit is no longer needed, in preparation for full vyconfd support. Revert "vyconf: T6718: use vy_set/delete in configsession and util" This reverts commit 6999f85b2fc1c6e2421242e30e3810bd19250f3e. --- python/vyos/configsession.py | 4 ++-- python/vyos/utils/misc.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index dd3ad1e3d..90b96b88c 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -23,8 +23,8 @@ from vyos.utils.process import is_systemd_service_running from vyos.utils.dict import dict_to_paths CLI_SHELL_API = '/bin/cli-shell-api' -SET = '/usr/libexec/vyos/vyconf/vy_set' -DELETE = '/usr/libexec/vyos/vyconf/vy_delete' +SET = '/opt/vyatta/sbin/my_set' +DELETE = '/opt/vyatta/sbin/my_delete' COMMENT = '/opt/vyatta/sbin/my_comment' COMMIT = '/opt/vyatta/sbin/my_commit' DISCARD = '/opt/vyatta/sbin/my_discard' diff --git a/python/vyos/utils/misc.py b/python/vyos/utils/misc.py index ac8011b8d..d82655914 100644 --- a/python/vyos/utils/misc.py +++ b/python/vyos/utils/misc.py @@ -52,7 +52,7 @@ def install_into_config(conf, config_paths, override_prompt=True): continue try: - cmd(f'/usr/libexec/vyos/vyconf/vy_set {path}') + cmd(f'/opt/vyatta/sbin/my_set {path}') count += 1 except: failed.append(path) -- cgit v1.2.3 From cf7721f7d5345e484e0c57b643913d2353dca6f5 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 2 Feb 2025 21:40:46 +0100 Subject: defaults: T6989: provide single source of systemd services Some systemd services are re-used over multiple configuration files. Keep a single source of the real systemd names and only reference them by dictionary keys. --- python/vyos/defaults.py | 7 ++++++- src/conf_mode/service_snmp.py | 3 ++- src/conf_mode/system_host-name.py | 9 ++++++--- src/conf_mode/system_syslog.py | 6 ++++-- 4 files changed, 18 insertions(+), 7 deletions(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 9757a34df..89e51707b 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -1,4 +1,4 @@ -# Copyright 2018-2024 VyOS maintainers and contributors +# Copyright 2018-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -40,6 +40,11 @@ directories = { 'ca_certificates' : '/usr/local/share/ca-certificates/vyos' } +systemd_services = { + 'rsyslog' : 'rsyslog.service', + 'snmpd' : 'snmpd.service', +} + config_status = '/tmp/vyos-config-status' api_config_state = '/run/http-api-state' frr_debug_enable = '/tmp/vyos.frr.debug' diff --git a/src/conf_mode/service_snmp.py b/src/conf_mode/service_snmp.py index 1174b1238..d85f20820 100755 --- a/src/conf_mode/service_snmp.py +++ b/src/conf_mode/service_snmp.py @@ -22,6 +22,7 @@ from vyos.base import Warning from vyos.config import Config from vyos.configdict import dict_merge from vyos.configverify import verify_vrf +from vyos.defaults import systemd_services from vyos.snmpv3_hashgen import plaintext_to_md5 from vyos.snmpv3_hashgen import plaintext_to_sha1 from vyos.snmpv3_hashgen import random @@ -43,7 +44,7 @@ config_file_access = r'/usr/share/snmp/snmpd.conf' config_file_user = r'/var/lib/snmp/snmpd.conf' default_script_dir = r'/config/user-data/' systemd_override = r'/run/systemd/system/snmpd.service.d/override.conf' -systemd_service = 'snmpd.service' +systemd_service = systemd_services['snmpd'] def get_config(config=None): if config: diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py index 3f245f166..fef034d1c 100755 --- a/src/conf_mode/system_host-name.py +++ b/src/conf_mode/system_host-name.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright (C) 2018-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -23,6 +23,7 @@ import vyos.hostsd_client from vyos.base import Warning from vyos.config import Config from vyos.configdict import leaf_node_changed +from vyos.defaults import systemd_services from vyos.ifconfig import Section from vyos.template import is_ip from vyos.utils.process import cmd @@ -174,11 +175,13 @@ def apply(config): # Restart services that use the hostname if hostname_new != hostname_old: - call("systemctl restart rsyslog.service") + tmp = systemd_services['rsyslog'] + call(f'systemctl restart {tmp}') # If SNMP is running, restart it too if process_named_running('snmpd') and config['snmpd_restart_reqired']: - call('systemctl restart snmpd.service') + tmp = systemd_services['snmpd'] + call(f'systemctl restart {tmp}') return None diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py index 00c571ea9..414bd4b6b 100755 --- a/src/conf_mode/system_syslog.py +++ b/src/conf_mode/system_syslog.py @@ -21,6 +21,7 @@ from sys import exit from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_vrf +from vyos.defaults import systemd_services from vyos.utils.network import is_addr_assigned from vyos.utils.process import call from vyos.template import render @@ -33,6 +34,9 @@ airbag.enable() rsyslog_conf = '/run/rsyslog/rsyslog.conf' logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' +systemd_socket = 'syslog.socket' +systemd_service = systemd_services['rsyslog'] + def get_config(config=None): if config: conf = config @@ -107,8 +111,6 @@ def generate(syslog): return None def apply(syslog): - systemd_socket = 'syslog.socket' - systemd_service = 'syslog.service' if not syslog: call(f'systemctl stop {systemd_service} {systemd_socket}') return None -- cgit v1.2.3 From 68002a3839d259d40d9a7bd88fe72c7361679388 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Wed, 5 Feb 2025 22:11:22 +0100 Subject: vyos.ifconfig: T5103: force dhclient restart on VRF change Moving an interface in, out or between VRFs will not re-install the received default route. This is because the dhclient binary is not restarted in the new VRF. Dhclient itself will report an error like: "receive_packet failed on eth0.10: Network is down". Take the return value of vyos.ifconfig.Interface().set_vrf() into account to forcefully restart the DHCP client process and optain a proper lease. --- python/vyos/configdict.py | 4 --- python/vyos/ifconfig/interface.py | 40 ++++++++++++++++----------- smoketest/scripts/cli/base_interfaces_test.py | 27 +++++++++++++++++- 3 files changed, 50 insertions(+), 21 deletions(-) (limited to 'python') diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 5a353b110..a6594871e 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -492,10 +492,6 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk dhcp = is_node_changed(config, base + [ifname, 'dhcp-options']) if dhcp: dict.update({'dhcp_options_changed' : {}}) - # Changine interface VRF assignemnts require a DHCP restart, too - dhcp = is_node_changed(config, base + [ifname, 'vrf']) - if dhcp: dict.update({'dhcp_options_changed' : {}}) - # Some interfaces come with a source_interface which must also not be part # of any other bond or bridge interface as it is exclusivly assigned as the # Kernels "lower" interface to this new "virtual/upper" interface. diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index cb73e2597..91ee09d90 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -595,12 +595,16 @@ class Interface(Control): """ Add/Remove interface from given VRF instance. + Keyword arguments: + vrf: VRF instance name or empty string (default VRF) + + Return True if VRF was changed, False otherwise + Example: >>> from vyos.ifconfig import Interface >>> Interface('eth0').set_vrf('foo') >>> Interface('eth0').set_vrf() """ - # Don't allow for netns yet if 'netns' in self.config: return False @@ -617,15 +621,17 @@ class Interface(Control): # Get routing table ID number for VRF vrf_table_id = get_vrf_tableid(vrf) # Add map element with interface and zone ID - if vrf_table_id: + if vrf_table_id and old_vrf_tableid != vrf_table_id: # delete old table ID from nftables if it has changed, e.g. interface moved to a different VRF - if old_vrf_tableid and old_vrf_tableid != int(vrf_table_id): - self._del_interface_from_ct_iface_map() + self._del_interface_from_ct_iface_map() self._add_interface_to_ct_iface_map(vrf_table_id) + return True else: - self._del_interface_from_ct_iface_map() + if old_vrf_tableid != get_vrf_tableid(self.ifname): + self._del_interface_from_ct_iface_map() + return True - return True + return False def set_arp_cache_tmo(self, tmo): """ @@ -1181,7 +1187,7 @@ class Interface(Control): """ return self.get_addr_v4() + self.get_addr_v6() - def add_addr(self, addr): + def add_addr(self, addr: str, vrf_changed: bool=False) -> bool: """ Add IP(v6) address to interface. Address is only added if it is not already assigned to that interface. Address format must be validated @@ -1214,7 +1220,7 @@ class Interface(Control): # add to interface if addr == 'dhcp': - self.set_dhcp(True) + self.set_dhcp(True, vrf_changed=vrf_changed) elif addr == 'dhcpv6': self.set_dhcpv6(True) elif not is_intf_addr_assigned(self.ifname, addr, netns=netns): @@ -1222,7 +1228,6 @@ class Interface(Control): tmp = f'{netns_cmd} ip addr add {addr} dev {self.ifname}' # Add broadcast address for IPv4 if is_ipv4(addr): tmp += ' brd +' - self._cmd(tmp) else: return False @@ -1232,7 +1237,7 @@ class Interface(Control): return True - def del_addr(self, addr): + def del_addr(self, addr: str, vrf_changed: bool=False) -> bool: """ Delete IP(v6) address from interface. Address is only deleted if it is assigned to that interface. Address format must be exactly the same as @@ -1356,7 +1361,7 @@ class Interface(Control): cmd = f'bridge vlan add dev {ifname} vid {native_vlan_id} pvid untagged master' self._cmd(cmd) - def set_dhcp(self, enable): + def set_dhcp(self, enable: bool, vrf_changed: bool=False): """ Enable/Disable DHCP client on a given interface. """ @@ -1396,7 +1401,9 @@ class Interface(Control): # the old lease is released a new one is acquired (T4203). We will # only restart DHCP client if it's option changed, or if it's not # running, but it should be running (e.g. on system startup) - if 'dhcp_options_changed' in self.config or not is_systemd_service_active(systemd_service): + if (vrf_changed or + ('dhcp_options_changed' in self.config) or + (not is_systemd_service_active(systemd_service))): return self._cmd(f'systemctl restart {systemd_service}') else: if is_systemd_service_active(systemd_service): @@ -1676,23 +1683,24 @@ class Interface(Control): # XXX: Bind interface to given VRF or unbind it if vrf is not set. Unbinding # will call 'ip link set dev eth0 nomaster' which will also drop the # interface out of any bridge or bond - thus this is checked before. + vrf_changed = False if 'is_bond_member' in config: bond_if = next(iter(config['is_bond_member'])) tmp = get_interface_config(config['ifname']) if 'master' in tmp and tmp['master'] != bond_if: - self.set_vrf('') + vrf_changed = self.set_vrf('') elif 'is_bridge_member' in config: bridge_if = next(iter(config['is_bridge_member'])) tmp = get_interface_config(config['ifname']) if 'master' in tmp and tmp['master'] != bridge_if: - self.set_vrf('') + vrf_changed = self.set_vrf('') else: - self.set_vrf(config.get('vrf', '')) + vrf_changed = self.set_vrf(config.get('vrf', '')) # Add this section after vrf T4331 for addr in new_addr: - self.add_addr(addr) + self.add_addr(addr, vrf_changed=vrf_changed) # Configure MSS value for IPv4 TCP connections tmp = dict_search('ip.adjust_mss', config) diff --git a/smoketest/scripts/cli/base_interfaces_test.py b/smoketest/scripts/cli/base_interfaces_test.py index c19bfcfe2..85888a448 100644 --- a/smoketest/scripts/cli/base_interfaces_test.py +++ b/smoketest/scripts/cli/base_interfaces_test.py @@ -38,6 +38,7 @@ from vyos.utils.network import is_intf_addr_assigned from vyos.utils.network import is_ipv6_link_local from vyos.utils.network import get_nft_vrf_zone_mapping from vyos.xml_ref import cli_defined +from vyos.xml_ref import default_value dhclient_base_dir = directories['isc_dhclient_dir'] dhclient_process_name = 'dhclient' @@ -282,6 +283,9 @@ class BasicInterfaceTest: if not self._test_dhcp or not self._test_vrf: self.skipTest('not supported') + cli_default_metric = default_value(self._base_path + [self._interfaces[0], + 'dhcp-options', 'default-route-distance']) + vrf_name = 'purple4' self.cli_set(['vrf', 'name', vrf_name, 'table', '65000']) @@ -307,7 +311,28 @@ class BasicInterfaceTest: self.assertIn(str(dhclient_pid), vrf_pids) # and the commandline has the appropriate options cmdline = read_file(f'/proc/{dhclient_pid}/cmdline') - self.assertIn('-e\x00IF_METRIC=210', cmdline) # 210 is the default value + self.assertIn(f'-e\x00IF_METRIC={cli_default_metric}', cmdline) + + # T5103: remove interface from VRF instance and move DHCP client + # back to default VRF. This must restart the DHCP client process + for interface in self._interfaces: + self.cli_delete(self._base_path + [interface, 'vrf']) + + self.cli_commit() + + # Validate interface state + for interface in self._interfaces: + tmp = get_interface_vrf(interface) + self.assertEqual(tmp, 'default') + # Check if dhclient process runs + dhclient_pid = process_named_running(dhclient_process_name, cmdline=interface, timeout=10) + self.assertTrue(dhclient_pid) + # .. inside the appropriate VRF instance + vrf_pids = cmd(f'ip vrf pids {vrf_name}') + self.assertNotIn(str(dhclient_pid), vrf_pids) + # and the commandline has the appropriate options + cmdline = read_file(f'/proc/{dhclient_pid}/cmdline') + self.assertIn(f'-e\x00IF_METRIC={cli_default_metric}', cmdline) self.cli_delete(['vrf', 'name', vrf_name]) -- cgit v1.2.3 From bb70ea569f4548b103c54bbb7c393221a6da0a23 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Wed, 5 Feb 2025 22:37:39 +0100 Subject: wireguard: T4930: remove pylint W0611: unused import --- python/vyos/ifconfig/wireguard.py | 5 ----- 1 file changed, 5 deletions(-) (limited to 'python') diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 341fd32ff..fed7a5f84 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -14,14 +14,9 @@ # License along with this library. If not, see . import os -import time -from datetime import timedelta from tempfile import NamedTemporaryFile -from hurry.filesize import size -from hurry.filesize import alternative - from vyos.configquery import ConfigTreeQuery from vyos.ifconfig import Interface from vyos.ifconfig import Operational -- cgit v1.2.3 From bc4adcf9a4b7dee5e0a56c39b707e40f6d64f482 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Wed, 5 Feb 2025 23:12:45 +0100 Subject: vyos.ifconfig: T7135: only restart DHCPv6 client if needed Previously the DHCPv6 client was restarted on any change to the interface, including changes only to the interface description. Re-use pattern from IPv4 DHCP to only restart the DHCP client if necessary. --- python/vyos/configdict.py | 8 ++++++++ python/vyos/ifconfig/interface.py | 17 ++++++++++------- smoketest/scripts/cli/base_interfaces_test.py | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) (limited to 'python') diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index a6594871e..78b98a3eb 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -491,6 +491,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'dhcp-options']) if dhcp: dict.update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'dhcpv6-options']) + if dhcpv6: dict.update({'dhcpv6_options_changed' : {}}) # Some interfaces come with a source_interface which must also not be part # of any other bond or bridge interface as it is exclusivly assigned as the @@ -539,6 +541,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'vif', vif, 'dhcp-options']) if dhcp: dict['vif'][vif].update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'vif', vif, 'dhcpv6-options']) + if dhcpv6: dict['vif'][vif].update({'dhcpv6_options_changed' : {}}) for vif_s, vif_s_config in dict.get('vif_s', {}).items(): # Add subinterface name to dictionary @@ -565,6 +569,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'dhcp-options']) if dhcp: dict['vif_s'][vif_s].update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'dhcpv6-options']) + if dhcpv6: dict['vif_s'][vif_s].update({'dhcpv6_options_changed' : {}}) for vif_c, vif_c_config in vif_s_config.get('vif_c', {}).items(): # Add subinterface name to dictionary @@ -593,6 +599,8 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk # Check if any DHCP options changed which require a client restat dhcp = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'vif-c', vif_c, 'dhcp-options']) if dhcp: dict['vif_s'][vif_s]['vif_c'][vif_c].update({'dhcp_options_changed' : {}}) + dhcpv6 = is_node_changed(config, base + [ifname, 'vif-s', vif_s, 'vif-c', vif_c, 'dhcpv6-options']) + if dhcpv6: dict['vif_s'][vif_s]['vif_c'][vif_c].update({'dhcpv6_options_changed' : {}}) # Check vif, vif-s/vif-c VLAN interfaces for removal dict = get_removed_vlans(config, base + [ifname], dict) diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 91ee09d90..85f2d3484 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -1222,7 +1222,7 @@ class Interface(Control): if addr == 'dhcp': self.set_dhcp(True, vrf_changed=vrf_changed) elif addr == 'dhcpv6': - self.set_dhcpv6(True) + self.set_dhcpv6(True, vrf_changed=vrf_changed) elif not is_intf_addr_assigned(self.ifname, addr, netns=netns): netns_cmd = f'ip netns exec {netns}' if netns else '' tmp = f'{netns_cmd} ip addr add {addr} dev {self.ifname}' @@ -1430,7 +1430,7 @@ class Interface(Control): return None - def set_dhcpv6(self, enable): + def set_dhcpv6(self, enable: bool, vrf_changed: bool=False): """ Enable/Disable DHCPv6 client on a given interface. """ @@ -1459,7 +1459,10 @@ class Interface(Control): # We must ignore any return codes. This is required to enable # DHCPv6-PD for interfaces which are yet not up and running. - return self._popen(f'systemctl restart {systemd_service}') + if (vrf_changed or + ('dhcpv6_options_changed' in self.config) or + (not is_systemd_service_active(systemd_service))): + return self._popen(f'systemctl restart {systemd_service}') else: if is_systemd_service_active(systemd_service): self._cmd(f'systemctl stop {systemd_service}') @@ -1676,10 +1679,6 @@ class Interface(Control): else: self.del_addr(addr) - # start DHCPv6 client when only PD was configured - if dhcpv6pd: - self.set_dhcpv6(True) - # XXX: Bind interface to given VRF or unbind it if vrf is not set. Unbinding # will call 'ip link set dev eth0 nomaster' which will also drop the # interface out of any bridge or bond - thus this is checked before. @@ -1698,6 +1697,10 @@ class Interface(Control): else: vrf_changed = self.set_vrf(config.get('vrf', '')) + # start DHCPv6 client when only PD was configured + if dhcpv6pd: + self.set_dhcpv6(True, vrf_changed=vrf_changed) + # Add this section after vrf T4331 for addr in new_addr: self.add_addr(addr, vrf_changed=vrf_changed) diff --git a/smoketest/scripts/cli/base_interfaces_test.py b/smoketest/scripts/cli/base_interfaces_test.py index 85888a448..78c807d59 100644 --- a/smoketest/scripts/cli/base_interfaces_test.py +++ b/smoketest/scripts/cli/base_interfaces_test.py @@ -366,6 +366,26 @@ class BasicInterfaceTest: vrf_pids = cmd(f'ip vrf pids {vrf_name}') self.assertIn(str(tmp), vrf_pids) + # T7135: remove interface from VRF instance and move DHCP client + # back to default VRF. This must restart the DHCP client process + for interface in self._interfaces: + self.cli_delete(self._base_path + [interface, 'vrf']) + + self.cli_commit() + + # Validate interface state + for interface in self._interfaces: + tmp = get_interface_vrf(interface) + self.assertEqual(tmp, 'default') + + # Check if dhclient process runs + tmp = process_named_running(dhcp6c_process_name, cmdline=interface, timeout=10) + self.assertTrue(tmp) + # .. inside the appropriate VRF instance + vrf_pids = cmd(f'ip vrf pids {vrf_name}') + self.assertNotIn(str(tmp), vrf_pids) + + self.cli_delete(['vrf', 'name', vrf_name]) def test_move_interface_between_vrf_instances(self): -- cgit v1.2.3 From a04bd4901b0a7ecf289a0ab12b8cd20a3f539eb6 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Wed, 5 Feb 2025 23:13:16 +0100 Subject: vyos.ifconfig: T5103: revert change to del_addr() signature An optional argument vrf_changed was added to the function signature but it was not put to use. We only need to restart DHCP client on add_addr(). --- python/vyos/ifconfig/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 85f2d3484..5d8326bb3 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -1237,7 +1237,7 @@ class Interface(Control): return True - def del_addr(self, addr: str, vrf_changed: bool=False) -> bool: + def del_addr(self, addr: str) -> bool: """ Delete IP(v6) address from interface. Address is only deleted if it is assigned to that interface. Address format must be exactly the same as -- cgit v1.2.3 From 006b557d49ae4acdac8f6b730b338df374a9d617 Mon Sep 17 00:00:00 2001 From: "Nataliia S." <81954790+natali-rs1985@users.noreply.github.com> Date: Thu, 6 Feb 2025 10:47:07 +0200 Subject: T7069: Add function to get available cpus (#4334) --- python/vyos/utils/cpu.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) (limited to 'python') diff --git a/python/vyos/utils/cpu.py b/python/vyos/utils/cpu.py index 3bea5ac12..8ace77d15 100644 --- a/python/vyos/utils/cpu.py +++ b/python/vyos/utils/cpu.py @@ -99,3 +99,18 @@ def get_core_count(): core_count += 1 return core_count + + +def get_available_cpus(): + """ List of cpus with ids that are available in the system + Uses 'lscpu' command + + Returns: list[dict[str, str | int | bool]]: cpus details + """ + import json + + from vyos.utils.process import cmd + + out = json.loads(cmd('lscpu --extended -b --json')) + + return out['cpus'] -- cgit v1.2.3 From e47feb25a640b469623cfed26e1e4403d82adfa3 Mon Sep 17 00:00:00 2001 From: khramshinr Date: Wed, 5 Feb 2025 12:29:05 +0700 Subject: T6058: Fix popen command wrapper handling Ensure `wrapper` is only prepended to `command` when it is non-empty --- python/vyos/utils/process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/utils/process.py b/python/vyos/utils/process.py index 054088325..121b6e240 100644 --- a/python/vyos/utils/process.py +++ b/python/vyos/utils/process.py @@ -83,7 +83,7 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, ) wrapper = get_wrapper(vrf, netns, auth) - command = f'{wrapper} {command}' + command = f'{wrapper} {command}' if wrapper else command cmd_msg = f"cmd '{command}'" debug.message(cmd_msg, flag) -- cgit v1.2.3 From 9e313faaef139215dbcff0f79721164e627bed30 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 8 Feb 2025 11:36:14 +0100 Subject: vyos.ifconfig: T5103: always stop the DHCP client process bevore changing VRF Always stop the DHCP client process to clean up routes within the VRF where the process was originally started. There is no need to add a condition to only call the method if "address dhcp" was defined, as this is handled inside set_dhcp(v6) by only stopping if the daemon is running. DHCP client process restart will be handled later on once the interface is moved to the new VRF. --- python/vyos/ifconfig/interface.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 5d8326bb3..979b62578 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -615,8 +615,18 @@ class Interface(Control): # Get current VRF table ID old_vrf_tableid = get_vrf_tableid(self.ifname) - self.set_interface('vrf', vrf) + # Always stop the DHCP client process to clean up routes within the VRF + # where the process was originally started. There is no need to add a + # condition to only call the method if "address dhcp" was defined, as + # this is handled inside set_dhcp(v6) by only stopping if the daemon is + # running. DHCP client process restart will be handled later on once the + # interface is moved to the new VRF. + self.set_dhcp(False) + self.set_dhcpv6(False) + + # Move interface in/out of VRF + self.set_interface('vrf', vrf) if vrf: # Get routing table ID number for VRF vrf_table_id = get_vrf_tableid(vrf) -- cgit v1.2.3 From a03174843512340f2f970fd6c3fe189b7bba92d6 Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Wed, 12 Oct 2022 17:08:02 +0200 Subject: wlb: T4470: Migrate WAN load balancer to Python/XML --- data/templates/load-balancing/nftables-wlb.j2 | 64 +++++ data/templates/load-balancing/wlb.conf.j2 | 134 ---------- debian/control | 3 - python/vyos/defaults.py | 3 +- python/vyos/template.py | 5 + python/vyos/wanloadbalance.py | 153 +++++++++++ smoketest/scripts/cli/base_vyostest_shim.py | 9 + smoketest/scripts/cli/test_load-balancing_wan.py | 150 ++++++++--- src/conf_mode/load-balancing_wan.py | 119 ++++----- src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb | 61 +++++ src/helpers/vyos-load-balancer.py | 312 +++++++++++++++++++++++ src/systemd/vyos-wan-load-balance.service | 12 +- 12 files changed, 762 insertions(+), 263 deletions(-) create mode 100644 data/templates/load-balancing/nftables-wlb.j2 delete mode 100644 data/templates/load-balancing/wlb.conf.j2 create mode 100644 python/vyos/wanloadbalance.py create mode 100755 src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb create mode 100755 src/helpers/vyos-load-balancer.py (limited to 'python') diff --git a/data/templates/load-balancing/nftables-wlb.j2 b/data/templates/load-balancing/nftables-wlb.j2 new file mode 100644 index 000000000..75604aca1 --- /dev/null +++ b/data/templates/load-balancing/nftables-wlb.j2 @@ -0,0 +1,64 @@ +#!/usr/sbin/nft -f + +{% if first_install is not vyos_defined %} +delete table ip vyos_wanloadbalance +{% endif %} +table ip vyos_wanloadbalance { + chain wlb_nat_postrouting { + type nat hook postrouting priority srcnat - 1; policy accept; +{% for ifname, health_conf in interface_health.items() if health_state[ifname].if_addr %} +{% if disable_source_nat is not vyos_defined %} +{% set state = health_state[ifname] %} + ct mark {{ state.mark }} counter snat to {{ state.if_addr }} +{% endif %} +{% endfor %} + } + + chain wlb_mangle_prerouting { + type filter hook prerouting priority mangle; policy accept; +{% for ifname, health_conf in interface_health.items() %} +{% set state = health_state[ifname] %} +{% if sticky_connections is vyos_defined %} + iifname "{{ ifname }}" ct state new ct mark set {{ state.mark }} +{% endif %} +{% endfor %} +{% if rule is vyos_defined %} +{% for rule_id, rule_conf in rule.items() %} +{% if rule_conf.exclude is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, exclude=True, action='accept') }} +{% else %} +{% set limit = rule_conf.limit is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, limit=limit, weight=True, health_state=health_state) }} + {{ rule_conf | wlb_nft_rule(rule_id, restore_mark=True) }} +{% endif %} +{% endfor %} +{% endif %} + } + + chain wlb_mangle_output { + type filter hook output priority -150; policy accept; +{% if enable_local_traffic is vyos_defined %} + meta mark != 0x0 counter accept + meta l4proto icmp counter accept + ip saddr 127.0.0.0/8 ip daddr 127.0.0.0/8 counter accept +{% if rule is vyos_defined %} +{% for rule_id, rule_conf in rule.items() %} +{% if rule_conf.exclude is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, local=True, exclude=True, action='accept') }} +{% else %} +{% set limit = rule_conf.limit is vyos_defined %} + {{ rule_conf | wlb_nft_rule(rule_id, local=True, limit=limit, weight=True, health_state=health_state) }} + {{ rule_conf | wlb_nft_rule(rule_id, local=True, restore_mark=True) }} +{% endif %} +{% endfor %} +{% endif %} +{% endif %} + } + +{% for ifname, health_conf in interface_health.items() %} +{% set state = health_state[ifname] %} + chain wlb_mangle_isp_{{ ifname }} { + meta mark set {{ state.mark }} ct mark set {{ state.mark }} counter accept + } +{% endfor %} +} diff --git a/data/templates/load-balancing/wlb.conf.j2 b/data/templates/load-balancing/wlb.conf.j2 deleted file mode 100644 index 7f04d797e..000000000 --- a/data/templates/load-balancing/wlb.conf.j2 +++ /dev/null @@ -1,134 +0,0 @@ -### Autogenerated by load-balancing_wan.py ### - -{% if disable_source_nat is vyos_defined %} -disable-source-nat -{% endif %} -{% if enable_local_traffic is vyos_defined %} -enable-local-traffic -{% endif %} -{% if sticky_connections is vyos_defined %} -sticky-connections inbound -{% endif %} -{% if flush_connections is vyos_defined %} -flush-conntrack -{% endif %} -{% if hook is vyos_defined %} -hook "{{ hook }}" -{% endif %} -{% if interface_health is vyos_defined %} -health { -{% for interface, interface_config in interface_health.items() %} - interface {{ interface }} { -{% if interface_config.failure_count is vyos_defined %} - failure-ct {{ interface_config.failure_count }} -{% endif %} -{% if interface_config.success_count is vyos_defined %} - success-ct {{ interface_config.success_count }} -{% endif %} -{% if interface_config.nexthop is vyos_defined %} - nexthop {{ interface_config.nexthop }} -{% endif %} -{% if interface_config.test is vyos_defined %} -{% for test_rule, test_config in interface_config.test.items() %} - rule {{ test_rule }} { -{% if test_config.type is vyos_defined %} -{% set type_translate = {'ping': 'ping', 'ttl': 'udp', 'user-defined': 'user-defined'} %} - type {{ type_translate[test_config.type] }} { -{% if test_config.ttl_limit is vyos_defined and test_config.type == 'ttl' %} - ttl {{ test_config.ttl_limit }} -{% endif %} -{% if test_config.test_script is vyos_defined and test_config.type == 'user-defined' %} - test-script {{ test_config.test_script }} -{% endif %} -{% if test_config.target is vyos_defined %} - target {{ test_config.target }} -{% endif %} - resp-time {{ test_config.resp_time | int * 1000 }} - } -{% endif %} - } -{% endfor %} -{% endif %} - } -{% endfor %} -} -{% endif %} - -{% if rule is vyos_defined %} -{% for rule, rule_config in rule.items() %} -rule {{ rule }} { -{% if rule_config.exclude is vyos_defined %} - exclude -{% endif %} -{% if rule_config.failover is vyos_defined %} - failover -{% endif %} -{% if rule_config.limit is vyos_defined %} - limit { -{% if rule_config.limit.burst is vyos_defined %} - burst {{ rule_config.limit.burst }} -{% endif %} -{% if rule_config.limit.rate is vyos_defined %} - rate {{ rule_config.limit.rate }} -{% endif %} -{% if rule_config.limit.period is vyos_defined %} - period {{ rule_config.limit.period }} -{% endif %} -{% if rule_config.limit.threshold is vyos_defined %} - thresh {{ rule_config.limit.threshold }} -{% endif %} - } -{% endif %} -{% if rule_config.per_packet_balancing is vyos_defined %} - per-packet-balancing -{% endif %} -{% if rule_config.protocol is vyos_defined %} - protocol {{ rule_config.protocol }} -{% endif %} -{% if rule_config.destination is vyos_defined %} - destination { -{% if rule_config.destination.address is vyos_defined %} - address "{{ rule_config.destination.address }}" -{% endif %} -{% if rule_config.destination.port is vyos_defined %} -{% if '-' in rule_config.destination.port %} - port-ipt "-m multiport --dports {{ rule_config.destination.port | replace('-', ':') }}" -{% elif ',' in rule_config.destination.port %} - port-ipt "-m multiport --dports {{ rule_config.destination.port }}" -{% else %} - port-ipt " --dport {{ rule_config.destination.port }}" -{% endif %} -{% endif %} - } -{% endif %} -{% if rule_config.source is vyos_defined %} - source { -{% if rule_config.source.address is vyos_defined %} - address "{{ rule_config.source.address }}" -{% endif %} -{% if rule_config.source.port is vyos_defined %} -{% if '-' in rule_config.source.port %} - port-ipt "-m multiport --sports {{ rule_config.source.port | replace('-', ':') }}" -{% elif ',' in rule_config.destination.port %} - port-ipt "-m multiport --sports {{ rule_config.source.port }}" -{% else %} - port.ipt " --sport {{ rule_config.source.port }}" -{% endif %} -{% endif %} - } -{% endif %} -{% if rule_config.inbound_interface is vyos_defined %} - inbound-interface {{ rule_config.inbound_interface }} -{% endif %} -{% if rule_config.interface is vyos_defined %} -{% for interface, interface_config in rule_config.interface.items() %} - interface {{ interface }} { -{% if interface_config.weight is vyos_defined %} - weight {{ interface_config.weight }} -{% endif %} - } -{% endfor %} -{% endif %} -} -{% endfor %} -{% endif %} diff --git a/debian/control b/debian/control index 57709ea24..0d040a374 100644 --- a/debian/control +++ b/debian/control @@ -203,9 +203,6 @@ Depends: # For "load-balancing haproxy" haproxy, # End "load-balancing haproxy" -# For "load-balancing wan" - vyatta-wanloadbalance, -# End "load-balancing wan" # For "service dhcp-relay" isc-dhcp-relay, # For "service dhcp-server" diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 89e51707b..86194cd55 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -37,7 +37,8 @@ directories = { 'dhcp6_client_dir' : '/run/dhcp6c', 'vyos_configdir' : '/opt/vyatta/config', 'completion_dir' : f'{base_dir}/completion', - 'ca_certificates' : '/usr/local/share/ca-certificates/vyos' + 'ca_certificates' : '/usr/local/share/ca-certificates/vyos', + 'ppp_nexthop_dir' : '/run/ppp_nexthop' } systemd_services = { diff --git a/python/vyos/template.py b/python/vyos/template.py index be9f781a6..7ba608b32 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -779,6 +779,11 @@ def conntrack_ct_policy(protocol_conf): return ", ".join(output) +@register_filter('wlb_nft_rule') +def wlb_nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False): + from vyos.wanloadbalance import nft_rule as wlb_nft_rule + return wlb_nft_rule(rule_conf, rule_id, local, exclude, limit, weight, health_state, action, restore_mark) + @register_filter('range_to_regex') def range_to_regex(num_range): """Convert range of numbers or list of ranges diff --git a/python/vyos/wanloadbalance.py b/python/vyos/wanloadbalance.py new file mode 100644 index 000000000..62e109f21 --- /dev/null +++ b/python/vyos/wanloadbalance.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from vyos.defaults import directories +from vyos.utils.process import run + +dhclient_lease = 'dhclient_{0}.lease' + +def nft_rule(rule_conf, rule_id, local=False, exclude=False, limit=False, weight=None, health_state=None, action=None, restore_mark=False): + output = [] + + if 'inbound_interface' in rule_conf: + ifname = rule_conf['inbound_interface'] + if local and not exclude: + output.append(f'oifname != "{ifname}"') + elif not local: + output.append(f'iifname "{ifname}"') + + if 'protocol' in rule_conf and rule_conf['protocol'] != 'all': + protocol = rule_conf['protocol'] + operator = '' + + if protocol[:1] == '!': + operator = '!=' + protocol = protocol[1:] + + if protocol == 'tcp_udp': + protocol = '{ tcp, udp }' + + output.append(f'meta l4proto {operator} {protocol}') + + for direction in ['source', 'destination']: + if direction not in rule_conf: + continue + + direction_conf = rule_conf[direction] + prefix = direction[:1] + + if 'address' in direction_conf: + operator = '' + address = direction_conf['address'] + if address[:1] == '!': + operator = '!=' + address = address[1:] + output.append(f'ip {prefix}addr {operator} {address}') + + if 'port' in direction_conf: + operator = '' + port = direction_conf['port'] + if port[:1] == '!': + operator = '!=' + port = port[1:] + output.append(f'th {prefix}port {operator} {port}') + + if 'source_based_routing' not in rule_conf and not restore_mark: + output.append('ct state new') + + if limit and 'limit' in rule_conf and 'rate' in rule_conf['limit']: + output.append(f'limit rate {rule_conf["limit"]["rate"]}/{rule_conf["limit"]["period"]}') + if 'burst' in rule_conf['limit']: + output.append(f'burst {rule_conf["limit"]["burst"]} packets') + + output.append('counter') + + if restore_mark: + output.append('meta mark set ct mark') + elif weight: + weights, total_weight = wlb_weight_interfaces(rule_conf, health_state) + if len(weights) > 1: # Create weight-based verdict map + vmap_str = ", ".join(f'{weight} : jump wlb_mangle_isp_{ifname}' for ifname, weight in weights) + output.append(f'numgen random mod {total_weight} vmap {{ {vmap_str} }}') + elif len(weights) == 1: # Jump to single ISP + ifname, _ = weights[0] + output.append(f'jump wlb_mangle_isp_{ifname}') + else: # No healthy interfaces + return "" + elif action: + output.append(action) + + return " ".join(output) + +def wlb_weight_interfaces(rule_conf, health_state): + interfaces = [] + + for ifname, if_conf in rule_conf['interface'].items(): + if ifname in health_state and health_state[ifname]['state']: + weight = int(if_conf.get('weight', 1)) + interfaces.append((ifname, weight)) + + if not interfaces: + return [], 0 + + if 'failover' in rule_conf: + for ifpair in sorted(interfaces, key=lambda i: i[1], reverse=True): + return [ifpair], ifpair[1] # Return highest weight interface that is ACTIVE when in failover + + total_weight = sum(weight for _, weight in interfaces) + out = [] + start = 0 + for ifname, weight in sorted(interfaces, key=lambda i: i[1]): # build weight ranges + end = start + weight - 1 + out.append((ifname, f'{start}-{end}' if end > start else start)) + start = weight + + return out, total_weight + +def health_ping_host(host, ifname, count=1, wait_time=0): + cmd_str = f'ping -c {count} -W {wait_time} -I {ifname} {host}' + rc = run(cmd_str) + return rc == 0 + +def health_ping_host_ttl(host, ifname, count=1, ttl_limit=0): + cmd_str = f'ping -c {count} -t {ttl_limit} -I {ifname} {host}' + rc = run(cmd_str) + return rc != 0 + +def parse_dhcp_nexthop(ifname): + lease_file = os.path.join(directories['isc_dhclient_dir'], dhclient_lease.format(ifname)) + + if not os.path.exists(lease_file): + return False + + with open(lease_file, 'r') as f: + for line in f.readlines(): + data = line.replace('\n', '').split('=') + if data[0] == 'new_routers': + return data[1].replace("'", '').split(" ")[0] + + return None + +def parse_ppp_nexthop(ifname): + nexthop_file = os.path.join(directories['ppp_nexthop_dir'], ifname) + + if not os.path.exists(nexthop_file): + return False + + with open(nexthop_file, 'r') as f: + return f.read() diff --git a/smoketest/scripts/cli/base_vyostest_shim.py b/smoketest/scripts/cli/base_vyostest_shim.py index a89b8dce5..edf940efd 100644 --- a/smoketest/scripts/cli/base_vyostest_shim.py +++ b/smoketest/scripts/cli/base_vyostest_shim.py @@ -183,6 +183,15 @@ class VyOSUnitTestSHIM: break self.assertTrue(not matched if inverse else matched, msg=search) + def verify_nftables_chain_exists(self, table, chain, inverse=False): + try: + cmd(f'sudo nft list chain {table} {chain}') + if inverse: + self.fail(f'Chain exists: {table} {chain}') + except OSError: + if not inverse: + self.fail(f'Chain does not exist: {table} {chain}') + # Verify ip rule output def verify_rules(self, rules_search, inverse=False, addr_family='inet'): rule_output = cmd(f'ip -family {addr_family} rule show') diff --git a/smoketest/scripts/cli/test_load-balancing_wan.py b/smoketest/scripts/cli/test_load-balancing_wan.py index 92b4000b8..f652988b2 100755 --- a/smoketest/scripts/cli/test_load-balancing_wan.py +++ b/smoketest/scripts/cli/test_load-balancing_wan.py @@ -14,10 +14,13 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import os import unittest import time from base_vyostest_shim import VyOSUnitTestSHIM +from vyos.utils.file import chmod_755 +from vyos.utils.file import write_file from vyos.utils.process import call from vyos.utils.process import cmd @@ -54,6 +57,16 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): self.cli_delete(base_path) self.cli_commit() + removed_chains = [ + 'wlb_mangle_isp_veth1', + 'wlb_mangle_isp_veth2', + 'wlb_mangle_isp_eth201', + 'wlb_mangle_isp_eth202' + ] + + for chain in removed_chains: + self.verify_nftables_chain_exists('ip vyos_wanloadbalance', chain, inverse=True) + def test_table_routes(self): ns1 = 'ns201' ns2 = 'ns202' @@ -93,6 +106,7 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): cmd_in_netns(ns3, 'ip link set dev eth0 up') # Set load-balancing configuration + self.cli_set(base_path + ['wan', 'hook', '/bin/true']) self.cli_set(base_path + ['wan', 'interface-health', iface1, 'failure-count', '2']) self.cli_set(base_path + ['wan', 'interface-health', iface1, 'nexthop', '203.0.113.1']) self.cli_set(base_path + ['wan', 'interface-health', iface1, 'success-count', '1']) @@ -102,7 +116,8 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): self.cli_set(base_path + ['wan', 'rule', '10', 'inbound-interface', iface3]) self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'address', '198.51.100.0/24']) - + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface1]) + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface2]) # commit changes self.cli_commit() @@ -127,7 +142,6 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): delete_netns(ns3) def test_check_chains(self): - ns1 = 'nsA' ns2 = 'nsB' ns3 = 'nsC' @@ -137,43 +151,28 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): container_iface1 = 'ceth0' container_iface2 = 'ceth1' container_iface3 = 'ceth2' - mangle_isp1 = """table ip mangle { - chain ISP_veth1 { - counter ct mark set 0xc9 - counter meta mark set 0xc9 - counter accept + mangle_isp1 = """table ip vyos_wanloadbalance { + chain wlb_mangle_isp_veth1 { + meta mark set 0x000000c9 ct mark set 0x000000c9 counter accept } }""" - mangle_isp2 = """table ip mangle { - chain ISP_veth2 { - counter ct mark set 0xca - counter meta mark set 0xca - counter accept + mangle_isp2 = """table ip vyos_wanloadbalance { + chain wlb_mangle_isp_veth2 { + meta mark set 0x000000ca ct mark set 0x000000ca counter accept } }""" - mangle_prerouting = """table ip mangle { - chain PREROUTING { + mangle_prerouting = """table ip vyos_wanloadbalance { + chain wlb_mangle_prerouting { type filter hook prerouting priority mangle; policy accept; - counter jump WANLOADBALANCE_PRE - } -}""" - mangle_wanloadbalance_pre = """table ip mangle { - chain WANLOADBALANCE_PRE { - iifname "veth3" ip saddr 198.51.100.0/24 ct state new meta random & 2147483647 < 1073741824 counter jump ISP_veth1 - iifname "veth3" ip saddr 198.51.100.0/24 ct state new counter jump ISP_veth2 + iifname "veth3" ip saddr 198.51.100.0/24 ct state new limit rate 5/second burst 5 packets counter numgen random mod 11 vmap { 0 : jump wlb_mangle_isp_veth1, 1-10 : jump wlb_mangle_isp_veth2 } iifname "veth3" ip saddr 198.51.100.0/24 counter meta mark set ct mark } }""" - nat_wanloadbalance = """table ip nat { - chain WANLOADBALANCE { - ct mark 0xc9 counter snat to 203.0.113.10 - ct mark 0xca counter snat to 192.0.2.10 - } -}""" - nat_vyos_pre_snat_hook = """table ip nat { - chain VYOS_PRE_SNAT_HOOK { + nat_wanloadbalance = """table ip vyos_wanloadbalance { + chain wlb_nat_postrouting { type nat hook postrouting priority srcnat - 1; policy accept; - counter jump WANLOADBALANCE + ct mark 0x000000c9 counter snat to 203.0.113.10 + ct mark 0x000000ca counter snat to 192.0.2.10 } }""" @@ -214,7 +213,7 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): self.cli_set(base_path + ['wan', 'rule', '10', 'inbound-interface', iface3]) self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'address', '198.51.100.0/24']) self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface1]) - self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface2]) + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', iface2, 'weight', '10']) # commit changes self.cli_commit() @@ -222,25 +221,19 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): time.sleep(5) # Check mangle chains - tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface1}') + tmp = cmd(f'sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_isp_{iface1}') self.assertEqual(tmp, mangle_isp1) - tmp = cmd(f'sudo nft -s list chain mangle ISP_{iface2}') + tmp = cmd(f'sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_isp_{iface2}') self.assertEqual(tmp, mangle_isp2) - tmp = cmd(f'sudo nft -s list chain mangle PREROUTING') + tmp = cmd('sudo nft -s list chain ip vyos_wanloadbalance wlb_mangle_prerouting') self.assertEqual(tmp, mangle_prerouting) - tmp = cmd(f'sudo nft -s list chain mangle WANLOADBALANCE_PRE') - self.assertEqual(tmp, mangle_wanloadbalance_pre) - # Check nat chains - tmp = cmd(f'sudo nft -s list chain nat WANLOADBALANCE') + tmp = cmd('sudo nft -s list chain ip vyos_wanloadbalance wlb_nat_postrouting') self.assertEqual(tmp, nat_wanloadbalance) - tmp = cmd(f'sudo nft -s list chain nat VYOS_PRE_SNAT_HOOK') - self.assertEqual(tmp, nat_vyos_pre_snat_hook) - # Delete veth interfaces and netns for iface in [iface1, iface2, iface3]: call(f'sudo ip link del dev {iface}') @@ -249,6 +242,81 @@ class TestLoadBalancingWan(VyOSUnitTestSHIM.TestCase): delete_netns(ns2) delete_netns(ns3) + def test_criteria_failover_hook(self): + isp1_iface = 'eth0' + isp2_iface = 'eth1' + lan_iface = 'eth2' + + hook_path = '/tmp/wlb_hook.sh' + hook_output_path = '/tmp/wlb_hook_output' + hook_script = f""" +#!/bin/sh + +ifname=$WLB_INTERFACE_NAME +state=$WLB_INTERFACE_STATE + +echo "$ifname - $state" > {hook_output_path} +""" + + write_file(hook_path, hook_script) + chmod_755(hook_path) + + self.cli_set(['interfaces', 'ethernet', isp1_iface, 'address', '203.0.113.2/30']) + self.cli_set(['interfaces', 'ethernet', isp2_iface, 'address', '192.0.2.2/30']) + self.cli_set(['interfaces', 'ethernet', lan_iface, 'address', '198.51.100.2/30']) + + self.cli_set(base_path + ['wan', 'hook', hook_path]) + self.cli_set(base_path + ['wan', 'interface-health', isp1_iface, 'failure-count', '1']) + self.cli_set(base_path + ['wan', 'interface-health', isp1_iface, 'nexthop', '203.0.113.2']) + self.cli_set(base_path + ['wan', 'interface-health', isp1_iface, 'success-count', '1']) + self.cli_set(base_path + ['wan', 'interface-health', isp2_iface, 'failure-count', '1']) + self.cli_set(base_path + ['wan', 'interface-health', isp2_iface, 'nexthop', '192.0.2.2']) + self.cli_set(base_path + ['wan', 'interface-health', isp2_iface, 'success-count', '1']) + self.cli_set(base_path + ['wan', 'rule', '10', 'failover']) + self.cli_set(base_path + ['wan', 'rule', '10', 'inbound-interface', lan_iface]) + self.cli_set(base_path + ['wan', 'rule', '10', 'protocol', 'udp']) + self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'address', '198.51.100.0/24']) + self.cli_set(base_path + ['wan', 'rule', '10', 'source', 'port', '53']) + self.cli_set(base_path + ['wan', 'rule', '10', 'destination', 'address', '192.0.2.0/24']) + self.cli_set(base_path + ['wan', 'rule', '10', 'destination', 'port', '53']) + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', isp1_iface]) + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', isp1_iface, 'weight', '10']) + self.cli_set(base_path + ['wan', 'rule', '10', 'interface', isp2_iface]) + + # commit changes + self.cli_commit() + + time.sleep(5) + + # Verify isp1 + criteria + + nftables_search = [ + [f'iifname "{lan_iface}"', 'ip saddr 198.51.100.0/24', 'udp sport 53', 'ip daddr 192.0.2.0/24', 'udp dport 53', f'jump wlb_mangle_isp_{isp1_iface}'] + ] + + self.verify_nftables_chain(nftables_search, 'ip vyos_wanloadbalance', 'wlb_mangle_prerouting') + + # Trigger failure on isp1 health check + + self.cli_delete(['interfaces', 'ethernet', isp1_iface, 'address', '203.0.113.2/30']) + self.cli_commit() + + time.sleep(10) + + # Verify failover to isp2 + + nftables_search = [ + [f'iifname "{lan_iface}"', f'jump wlb_mangle_isp_{isp2_iface}'] + ] + + self.verify_nftables_chain(nftables_search, 'ip vyos_wanloadbalance', 'wlb_mangle_prerouting') + + # Verify hook output + + self.assertTrue(os.path.exists(hook_output_path)) + + with open(hook_output_path, 'r') as f: + self.assertIn('eth0 - FAILED', f.read()) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/load-balancing_wan.py b/src/conf_mode/load-balancing_wan.py index 5da0b906b..b3dd80a9a 100755 --- a/src/conf_mode/load-balancing_wan.py +++ b/src/conf_mode/load-balancing_wan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright (C) 2023-2024 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -14,24 +14,16 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os - from sys import exit -from shutil import rmtree -from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents from vyos.utils.process import cmd -from vyos.template import render from vyos import ConfigError from vyos import airbag airbag.enable() -load_balancing_dir = '/run/load-balance' -load_balancing_conf_file = f'{load_balancing_dir}/wlb.conf' -systemd_service = 'vyos-wan-load-balance.service' - +service = 'vyos-wan-load-balance.service' def get_config(config=None): if config: @@ -40,6 +32,7 @@ def get_config(config=None): conf = Config() base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, @@ -59,87 +52,61 @@ def verify(lb): if not lb: return None - if 'interface_health' not in lb: - raise ConfigError( - 'A valid WAN load-balance configuration requires an interface with a nexthop!' - ) - - for interface, interface_config in lb['interface_health'].items(): - if 'nexthop' not in interface_config: - raise ConfigError( - f'interface-health {interface} nexthop must be specified!') - - if 'test' in interface_config: - for test_rule, test_config in interface_config['test'].items(): - if 'type' in test_config: - if test_config['type'] == 'user-defined' and 'test_script' not in test_config: - raise ConfigError( - f'test {test_rule} script must be defined for test-script!' - ) - - if 'rule' not in lb: - Warning( - 'At least one rule with an (outbound) interface must be defined for WAN load balancing to be active!' - ) + if 'interface_health' in lb: + for ifname, health_conf in lb['interface_health'].items(): + if 'nexthop' not in health_conf: + raise ConfigError(f'Nexthop must be configured for interface {ifname}') + + if 'test' not in health_conf: + continue + + for test_id, test_conf in health_conf['test'].items(): + if 'type' not in test_conf: + raise ConfigError(f'No type configured for health test on interface {ifname}') + + if test_conf['type'] == 'user-defined' and 'test_script' not in test_conf: + raise ConfigError(f'Missing user-defined script for health test on interface {ifname}') else: - for rule, rule_config in lb['rule'].items(): - if 'inbound_interface' not in rule_config: - raise ConfigError(f'rule {rule} inbound-interface must be specified!') - if {'failover', 'exclude'} <= set(rule_config): - raise ConfigError(f'rule {rule} failover cannot be configured with exclude!') - if {'limit', 'exclude'} <= set(rule_config): - raise ConfigError(f'rule {rule} limit cannot be used with exclude!') - if 'interface' not in rule_config: - if 'exclude' not in rule_config: - Warning( - f'rule {rule} will be inactive because no (outbound) interfaces have been defined for this rule' - ) - for direction in {'source', 'destination'}: - if direction in rule_config: - if 'protocol' in rule_config and 'port' in rule_config[ - direction]: - if rule_config['protocol'] not in {'tcp', 'udp'}: - raise ConfigError('ports can only be specified when protocol is "tcp" or "udp"') + raise ConfigError('Interface health tests must be configured') + if 'rule' in lb: + for rule_id, rule_conf in lb['rule'].items(): + if 'interface' not in rule_conf: + raise ConfigError(f'Interface not specified on load-balancing wan rule {rule_id}') -def generate(lb): - if not lb: - # Delete /run/load-balance/wlb.conf - if os.path.isfile(load_balancing_conf_file): - os.unlink(load_balancing_conf_file) - # Delete old directories - if os.path.isdir(load_balancing_dir): - rmtree(load_balancing_dir, ignore_errors=True) - if os.path.exists('/var/run/load-balance/wlb.out'): - os.unlink('/var/run/load-balance/wlb.out') + if 'failover' in rule_conf and 'exclude' in rule_conf: + raise ConfigError(f'Failover cannot be configured with exclude on load-balancing wan rule {rule_id}') - return None + if 'limit' in rule_conf: + if 'exclude' in rule_conf: + raise ConfigError(f'Limit cannot be configured with exclude on load-balancing wan rule {rule_id}') - # Create load-balance dir - if not os.path.isdir(load_balancing_dir): - os.mkdir(load_balancing_dir) + if 'rate' in rule_conf['limit'] and 'period' not in rule_conf['limit']: + raise ConfigError(f'Missing "limit period" on load-balancing wan rule {rule_id}') - render(load_balancing_conf_file, 'load-balancing/wlb.conf.j2', lb) + if 'period' in rule_conf['limit'] and 'rate' not in rule_conf['limit']: + raise ConfigError(f'Missing "limit rate" on load-balancing wan rule {rule_id}') - return None + for direction in ['source', 'destination']: + if direction in rule_conf: + if 'port' in rule_conf[direction]: + if 'protocol' not in rule_conf: + raise ConfigError(f'Protocol required to specify port on load-balancing wan rule {rule_id}') + + if rule_conf['protocol'] not in ['tcp', 'udp', 'tcp_udp']: + raise ConfigError(f'Protocol must be tcp, udp or tcp_udp to specify port on load-balancing wan rule {rule_id}') +def generate(lb): + return None def apply(lb): if not lb: - try: - cmd(f'systemctl stop {systemd_service}') - except Exception as e: - print(f"Error message: {e}") - + cmd(f'sudo systemctl stop {service}') else: - cmd('sudo sysctl -w net.netfilter.nf_conntrack_acct=1') - cmd(f'systemctl restart {systemd_service}') + cmd(f'sudo systemctl restart {service}') call_dependents() - return None - - if __name__ == '__main__': try: c = get_config() diff --git a/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb new file mode 100755 index 000000000..fff258afa --- /dev/null +++ b/src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# This is a Python hook script which is invoked whenever a PPPoE session goes +# "ip-up". It will call into our vyos.ifconfig library and will then execute +# common tasks for the PPPoE interface. The reason we have to "hook" this is +# that we can not create a pppoeX interface in advance in linux and then connect +# pppd to this already existing interface. + +import os +import signal + +from sys import argv +from sys import exit + +from vyos.defaults import directories + +# When the ppp link comes up, this script is called with the following +# parameters +# $1 the interface name used by pppd (e.g. ppp3) +# $2 the tty device name +# $3 the tty device speed +# $4 the local IP address for the interface +# $5 the remote IP address +# $6 the parameter specified by the 'ipparam' option to pppd + +if (len(argv) < 7): + exit(1) + +wlb_pid_file = '/run/wlb_daemon.pid' + +interface = argv[6] +nexthop = argv[5] + +if not os.path.exists(directories['ppp_nexthop_dir']): + os.mkdir(directories['ppp_nexthop_dir']) + +nexthop_file = os.path.join(directories['ppp_nexthop_dir'], interface) + +with open(nexthop_file, 'w') as f: + f.write(nexthop) + +# Trigger WLB daemon update +if os.path.exists(wlb_pid_file): + with open(wlb_pid_file, 'r') as f: + pid = int(f.read()) + + os.kill(pid, signal.SIGUSR2) diff --git a/src/helpers/vyos-load-balancer.py b/src/helpers/vyos-load-balancer.py new file mode 100755 index 000000000..2f07160b4 --- /dev/null +++ b/src/helpers/vyos-load-balancer.py @@ -0,0 +1,312 @@ +#!/usr/bin/python3 + +# Copyright 2024 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . + +import json +import os +import signal +import sys +import time + +from vyos.config import Config +from vyos.template import render +from vyos.utils.commit import commit_in_progress +from vyos.utils.network import get_interface_address +from vyos.utils.process import rc_cmd +from vyos.utils.process import run +from vyos.xml_ref import get_defaults +from vyos.wanloadbalance import health_ping_host +from vyos.wanloadbalance import health_ping_host_ttl +from vyos.wanloadbalance import parse_dhcp_nexthop +from vyos.wanloadbalance import parse_ppp_nexthop + +nftables_wlb_conf = '/run/nftables_wlb.conf' +wlb_status_file = '/run/wlb_status.json' +wlb_pid_file = '/run/wlb_daemon.pid' +sleep_interval = 5 # Main loop sleep interval + +def health_check(ifname, conf, state, test_defaults): + # Run health tests for interface + + if get_ipv4_address(ifname) is None: + return False + + if 'test' not in conf: + resp_time = test_defaults['resp-time'] + target = conf['nexthop'] + + if target == 'dhcp': + target = state['dhcp_nexthop'] + + if not target: + return False + + return health_ping_host(target, ifname, wait_time=resp_time) + + for test_id, test_conf in conf['test'].items(): + check_type = test_conf['type'] + + if check_type == 'ping': + resp_time = test_conf['resp_time'] + target = test_conf['target'] + if not health_ping_host(target, ifname, wait_time=resp_time): + return False + elif check_type == 'ttl': + target = test_conf['target'] + ttl_limit = test_conf['ttl_limit'] + if not health_ping_host_ttl(target, ifname, ttl_limit=ttl_limit): + return False + elif check_type == 'user-defined': + script = test_conf['test_script'] + rc = run(script) + if rc != 0: + return False + + return True + +def on_state_change(lb, ifname, state): + # Run hook on state change + if 'hook' in lb: + script_path = os.path.join('/config/scripts/', lb['hook']) + env = { + 'WLB_INTERFACE_NAME': ifname, + 'WLB_INTERFACE_STATE': 'ACTIVE' if state else 'FAILED' + } + + code = run(script_path, env=env) + if code != 0: + print('WLB hook returned non-zero error code') + + print(f'INFO: State change: {ifname} -> {state}') + +def get_ipv4_address(ifname): + # Get primary ipv4 address on interface (for source nat) + addr_json = get_interface_address(ifname) + if 'addr_info' in addr_json and len(addr_json['addr_info']) > 0: + for addr_info in addr_json['addr_info']: + if addr_info['family'] == 'inet': + if 'local' in addr_info: + return addr_json['addr_info'][0]['local'] + return None + +def dynamic_nexthop_update(lb, ifname): + # Update on DHCP/PPP address/nexthop changes + # Return True if nftables needs to be updated - IP change + + if 'dhcp_nexthop' in lb['health_state'][ifname]: + if ifname[:5] == 'pppoe': + dhcp_nexthop_addr = parse_ppp_nexthop(ifname) + else: + dhcp_nexthop_addr = parse_dhcp_nexthop(ifname) + + table_num = lb['health_state'][ifname]['table_number'] + + if dhcp_nexthop_addr and lb['health_state'][ifname]['dhcp_nexthop'] != dhcp_nexthop_addr: + lb['health_state'][ifname]['dhcp_nexthop'] = dhcp_nexthop_addr + run(f'ip route replace table {table_num} default dev {ifname} via {dhcp_nexthop_addr}') + + if_addr = get_ipv4_address(ifname) + if if_addr and if_addr != lb['health_state'][ifname]['if_addr']: + lb['health_state'][ifname]['if_addr'] = if_addr + return True + + return False + +def nftables_update(lb): + # Atomically reload nftables table from template + if not os.path.exists(nftables_wlb_conf): + lb['first_install'] = True + elif 'first_install' in lb: + del lb['first_install'] + + render(nftables_wlb_conf, 'load-balancing/nftables-wlb.j2', lb) + + rc, out = rc_cmd(f'nft -f {nftables_wlb_conf}') + + if rc != 0: + print('ERROR: Failed to apply WLB nftables config') + print('Output:', out) + return False + + return True + +def cleanup(lb): + if 'interface_health' in lb: + index = 1 + for ifname, health_conf in lb['interface_health'].items(): + table_num = lb['mark_offset'] + index + run(f'ip route del table {table_num} default') + run(f'ip rule del fwmark {hex(table_num)} table {table_num}') + index += 1 + + run(f'nft delete table ip vyos_wanloadbalance') + +def get_config(): + conf = Config() + base = ['load-balancing', 'wan'] + lb = conf.get_config_dict(base, key_mangling=('-', '_'), + get_first_key=True, with_recursive_defaults=True) + + lb['test_defaults'] = get_defaults(base + ['interface-health', 'A', 'test', 'B'], get_first_key=True) + + return lb + +if __name__ == '__main__': + while commit_in_progress(): + print("Notice: Waiting for commit to complete...") + time.sleep(1) + + lb = get_config() + + lb['health_state'] = {} + lb['mark_offset'] = 0xc8 + + # Create state dicts, interface address and nexthop, install routes and ip rules + if 'interface_health' in lb: + index = 1 + for ifname, health_conf in lb['interface_health'].items(): + table_num = lb['mark_offset'] + index + addr = get_ipv4_address(ifname) + lb['health_state'][ifname] = { + 'if_addr': addr, + 'failure_count': 0, + 'success_count': 0, + 'last_success': 0, + 'last_failure': 0, + 'state': addr is not None, + 'state_changed': False, + 'table_number': table_num, + 'mark': hex(table_num) + } + + if health_conf['nexthop'] == 'dhcp': + lb['health_state'][ifname]['dhcp_nexthop'] = None + + dynamic_nexthop_update(lb, ifname) + else: + run(f'ip route replace table {table_num} default dev {ifname} via {health_conf["nexthop"]}') + + run(f'ip rule add fwmark {hex(table_num)} table {table_num}') + + index += 1 + + nftables_update(lb) + + run('ip route flush cache') + + if 'flush_connections' in lb: + run('conntrack --delete') + run('conntrack -F expect') + + with open(wlb_status_file, 'w') as f: + f.write(json.dumps(lb['health_state'])) + + # Signal handler SIGUSR2 -> dhcpcd update + def handle_sigusr2(signum, frame): + for ifname, health_conf in lb['interface_health'].items(): + if 'nexthop' in health_conf and health_conf['nexthop'] == 'dhcp': + retval = dynamic_nexthop_update(lb, ifname) + + if retval: + nftables_update(lb) + + # Signal handler SIGTERM -> exit + def handle_sigterm(signum, frame): + if os.path.exists(wlb_status_file): + os.unlink(wlb_status_file) + + if os.path.exists(wlb_pid_file): + os.unlink(wlb_pid_file) + + if os.path.exists(nftables_wlb_conf): + os.unlink(nftables_wlb_conf) + + cleanup(lb) + sys.exit(0) + + signal.signal(signal.SIGUSR2, handle_sigusr2) + signal.signal(signal.SIGINT, handle_sigterm) + signal.signal(signal.SIGTERM, handle_sigterm) + + with open(wlb_pid_file, 'w') as f: + f.write(str(os.getpid())) + + # Main loop + + try: + while True: + ip_change = False + + if 'interface_health' in lb: + for ifname, health_conf in lb['interface_health'].items(): + state = lb['health_state'][ifname] + + result = health_check(ifname, health_conf, state=state, test_defaults=lb['test_defaults']) + + state_changed = result != state['state'] + state['state_changed'] = False + + if result: + state['failure_count'] = 0 + state['success_count'] += 1 + state['last_success'] = time.time() + if state_changed and state['success_count'] >= int(health_conf['success_count']): + state['state'] = True + state['state_changed'] = True + elif not result: + state['failure_count'] += 1 + state['success_count'] = 0 + state['last_failure'] = time.time() + if state_changed and state['failure_count'] >= int(health_conf['failure_count']): + state['state'] = False + state['state_changed'] = True + + if state['state_changed']: + state['if_addr'] = get_ipv4_address(ifname) + on_state_change(lb, ifname, state['state']) + + if dynamic_nexthop_update(lb, ifname): + ip_change = True + + if any(state['state_changed'] for ifname, state in lb['health_state'].items()): + if not nftables_update(lb): + break + + run('ip route flush cache') + + if 'flush_connections' in lb: + run('conntrack --delete') + run('conntrack -F expect') + + with open(wlb_status_file, 'w') as f: + f.write(json.dumps(lb['health_state'])) + elif ip_change: + nftables_update(lb) + + time.sleep(sleep_interval) + except Exception as e: + print('WLB ERROR:', e) + + if os.path.exists(wlb_status_file): + os.unlink(wlb_status_file) + + if os.path.exists(wlb_pid_file): + os.unlink(wlb_pid_file) + + if os.path.exists(nftables_wlb_conf): + os.unlink(nftables_wlb_conf) + + cleanup(lb) diff --git a/src/systemd/vyos-wan-load-balance.service b/src/systemd/vyos-wan-load-balance.service index 7d62a2ff6..a59f2c3ae 100644 --- a/src/systemd/vyos-wan-load-balance.service +++ b/src/systemd/vyos-wan-load-balance.service @@ -1,15 +1,11 @@ [Unit] -Description=VyOS WAN load-balancing service +Description=VyOS WAN Load Balancer After=vyos-router.service [Service] -ExecStart=/opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid -ExecReload=/bin/kill -s SIGTERM $MAINPID && sleep 5 && /opt/vyatta/sbin/wan_lb -f /run/load-balance/wlb.conf -d -i /var/run/vyatta/wlb.pid -ExecStop=/bin/kill -s SIGTERM $MAINPID -PIDFile=/var/run/vyatta/wlb.pid -KillMode=process -Restart=on-failure -RestartSec=5s +Type=simple +Restart=always +ExecStart=/usr/bin/python3 /usr/libexec/vyos/vyos-load-balancer.py [Install] WantedBy=multi-user.target -- cgit v1.2.3 From d1bb249b96cc03a2bd223326cefe2bc4a0dbad4a Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Tue, 17 Dec 2024 08:29:22 -0600 Subject: dhcp: T6948: kea: Add kea helpers and enrich leases with domain name Add helpers: - `kea_add_lease` to add a lease to the running kea server - `kea_get_domain_from_subnet_id` to get the domain name from subnet id Also, enrich leases with domain name from subnet id --- python/vyos/kea.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) (limited to 'python') diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 65e2d99b4..c7947af3e 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -384,6 +384,41 @@ def kea_get_leases(inet): return leases['arguments']['leases'] +def kea_add_lease( + inet, + ip_address, + host_name=None, + mac_address=None, + iaid=None, + duid=None, + subnet_id=None, +): + args = {'ip-address': ip_address} + + if host_name: + args['hostname'] = host_name + + if subnet_id: + args['subnet-id'] = subnet_id + + # IPv4 requires MAC address, IPv6 requires either MAC address or DUID + if mac_address: + args['hw-address'] = mac_address + if duid: + args['duid'] = duid + + # IPv6 requires IAID + if inet == '6' and iaid: + args['iaid'] = iaid + + result = _ctrl_socket_command(inet, f'lease{inet}-add', args) + + if result and 'result' in result: + return result['result'] == 0 + + return False + + def kea_delete_lease(inet, ip_address): args = {'ip-address': ip_address} @@ -430,6 +465,32 @@ def kea_get_pool_from_subnet_id(config, inet, subnet_id): return None +def kea_get_domain_from_subnet_id(config, inet, subnet_id): + shared_networks = dict_search_args( + config, 'arguments', f'Dhcp{inet}', 'shared-networks' + ) + + if not shared_networks: + return None + + for network in shared_networks: + if f'subnet{inet}' not in network: + continue + + for subnet in network[f'subnet{inet}']: + if 'id' in subnet and int(subnet['id']) == int(subnet_id): + for option in subnet['option-data']: + if option['name'] == 'domain-name': + return option['data'] + + # domain-name is not found in subnet, fallback to shared-network pool option + for option in network['option-data']: + if option['name'] == 'domain-name': + return option['data'] + + return None + + def kea_get_static_mappings(config, inet, pools=[]) -> list: """ Get DHCP static mapping from active Kea DHCPv4 or DHCPv6 configuration @@ -491,6 +552,11 @@ def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list if config else '-' ) + data_lease['domain'] = ( + kea_get_domain_from_subnet_id(config, inet, lease['subnet-id']) + if config + else '' + ) data_lease['end'] = ( lease['expire_time'].timestamp() if lease['expire_time'] else None ) -- cgit v1.2.3 From c861855ea914de0df0eb8a8bb856d51b1e4b869b Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Wed, 19 Feb 2025 19:43:11 +0100 Subject: Revert "wireguard: T4930: drop unused WireGuardOperational().show_interface() method" This reverts commit 98414a69f0018915ac999f51975618dd5fbe817d. --- python/vyos/ifconfig/wireguard.py | 78 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) (limited to 'python') diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index fed7a5f84..be9bffd20 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -77,6 +77,84 @@ class WireGuardOperational(Operational): } return output + def show_interface(self): + from vyos.config import Config + + c = Config() + + wgdump = self._dump().get(self.config['ifname'], None) + + c.set_level(['interfaces', 'wireguard', self.config['ifname']]) + description = c.return_effective_value(['description']) + ips = c.return_effective_values(['address']) + hostnames = c.return_effective_values(['host-name']) + + answer = 'interface: {}\n'.format(self.config['ifname']) + if description: + answer += ' description: {}\n'.format(description) + if ips: + answer += ' address: {}\n'.format(', '.join(ips)) + if hostnames: + answer += ' hostname: {}\n'.format(', '.join(hostnames)) + + answer += ' public key: {}\n'.format(wgdump['public_key']) + answer += ' private key: (hidden)\n' + answer += ' listening port: {}\n'.format(wgdump['listen_port']) + answer += '\n' + + for peer in c.list_effective_nodes(['peer']): + if wgdump['peers']: + pubkey = c.return_effective_value(['peer', peer, 'public-key']) + if pubkey in wgdump['peers']: + wgpeer = wgdump['peers'][pubkey] + + answer += ' peer: {}\n'.format(peer) + answer += ' public key: {}\n'.format(pubkey) + + """ figure out if the tunnel is recently active or not """ + status = 'inactive' + if wgpeer['latest_handshake'] is None: + """ no handshake ever """ + status = 'inactive' + else: + if int(wgpeer['latest_handshake']) > 0: + delta = timedelta( + seconds=int(time.time() - wgpeer['latest_handshake']) + ) + answer += ' latest handshake: {}\n'.format(delta) + if time.time() - int(wgpeer['latest_handshake']) < (60 * 5): + """ Five minutes and the tunnel is still active """ + status = 'active' + else: + """ it's been longer than 5 minutes """ + status = 'inactive' + elif int(wgpeer['latest_handshake']) == 0: + """ no handshake ever """ + status = 'inactive' + answer += ' status: {}\n'.format(status) + + if wgpeer['endpoint'] is not None: + answer += ' endpoint: {}\n'.format(wgpeer['endpoint']) + + if wgpeer['allowed_ips'] is not None: + answer += ' allowed ips: {}\n'.format( + ','.join(wgpeer['allowed_ips']).replace(',', ', ') + ) + + if wgpeer['transfer_rx'] > 0 or wgpeer['transfer_tx'] > 0: + rx_size = size(wgpeer['transfer_rx'], system=alternative) + tx_size = size(wgpeer['transfer_tx'], system=alternative) + answer += ' transfer: {} received, {} sent\n'.format( + rx_size, tx_size + ) + + if wgpeer['persistent_keepalive'] is not None: + answer += ' persistent keepalive: every {} seconds\n'.format( + wgpeer['persistent_keepalive'] + ) + answer += '\n' + return answer + def get_latest_handshakes(self): """Get latest handshake time for each peer""" output = {} -- cgit v1.2.3 From 13f99beada6ac28ede7c74d434b84a63bf9905b0 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Wed, 19 Feb 2025 15:19:09 +0000 Subject: T7181: VPP add initial source NAT implentation Add initial source NAT implementation ``` set vpp nat44 source inbound-interface 'eth2' set vpp nat44 source outbound-interface 'eth1' set vpp nat44 source translation address '192.0.2.1-192.0.2.2' ``` Add initial simple implementation of the source NAT In the future, we'll extend it to the rules if it is possible to do via VPP API --- data/config-mode-dependencies/vyos-vpp.json | 8 ++ data/templates/vpp/startup.conf.j2 | 4 +- interface-definitions/vpp.xml.in | 65 ++++++++++++ python/vyos/vpp/nat/__init__.py | 3 + python/vyos/vpp/nat/nat44.py | 151 ++++++++++++++++++++++++++++ src/conf_mode/vpp.py | 4 + src/conf_mode/vpp_nat_source.py | 119 ++++++++++++++++++++++ 7 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 python/vyos/vpp/nat/nat44.py create mode 100644 src/conf_mode/vpp_nat_source.py (limited to 'python') diff --git a/data/config-mode-dependencies/vyos-vpp.json b/data/config-mode-dependencies/vyos-vpp.json index 04dc4a771..50e465e80 100644 --- a/data/config-mode-dependencies/vyos-vpp.json +++ b/data/config-mode-dependencies/vyos-vpp.json @@ -10,34 +10,42 @@ "vpp_interfaces_loopback": ["vpp_interfaces_loopback"], "vpp_interfaces_vxlan": ["vpp_interfaces_vxlan"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_bonding": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ethernet": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_geneve": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_gre": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ipip": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_loopback": { + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_vxlan": { "vpp_interfaces_bridge": ["vpp_interfaces_bridge"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_nat_source": ["vpp_nat_source"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] } } diff --git a/data/templates/vpp/startup.conf.j2 b/data/templates/vpp/startup.conf.j2 index cf10b6ce5..c3bfc2b51 100644 --- a/data/templates/vpp/startup.conf.j2 +++ b/data/templates/vpp/startup.conf.j2 @@ -94,8 +94,8 @@ plugins { plugin pppoe_plugin.so { enable } # NAT uncomment if needed # plugin cnat_plugin.so { enable } - # plugin nat_plugin.so { enable } - # plugin nat44_plugin.so { enable } + plugin nat_plugin.so { enable } + plugin nat44_ei_plugin.so { enable } # plugin nat44_ei_plugin.so { enable } # plugin nat64_plugin.so { enable } # plugin nat66_plugin.so { enable } diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 1f1cb44cc..175b28a0b 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -833,6 +833,71 @@ + + + NAT44 + + + + + Source NAT setting + 320 + + + + + Inbound interface of NAT traffic + + any + + + + + + + Outbound interface of NAT traffic + + any + + + + + + + Outside NAT IP (source NAT only) + + + + + IP address, subnet, or range + + masquerade + + + ipv4 + IPv4 address to match + + + ipv4range + IPv4 address range to match + + + masquerade + NAT to the primary address of outbound-interface + + + + + (masquerade) + + + + + + + + + VPP kernel interface settings diff --git a/python/vyos/vpp/nat/__init__.py b/python/vyos/vpp/nat/__init__.py index e69de29bb..bbd011435 100644 --- a/python/vyos/vpp/nat/__init__.py +++ b/python/vyos/vpp/nat/__init__.py @@ -0,0 +1,3 @@ +from .nat44 import Nat44 + +__all__ = ['Nat44'] diff --git a/python/vyos/vpp/nat/nat44.py b/python/vyos/vpp/nat/nat44.py new file mode 100644 index 000000000..47d0ced1d --- /dev/null +++ b/python/vyos/vpp/nat/nat44.py @@ -0,0 +1,151 @@ +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class Nat44: + def __init__( + self, + interface_in: str, + interface_out: str, + translation_pool: str, + ): + self.interface_in = interface_in + self.interface_out = interface_out + self.translation_pool = translation_pool + self.vpp = VPPControl() + + def enable_nat44_ed(self): + """Enable NAT44 endpoint dependent plugin + Example: + from vyos.vpp.nat import Nat44 + nat44 = Nat44() + nat44.enable_nat44_ed() + https://github.com/FDio/vpp/blob/stable/2410/src/plugins/nat/nat44-ed/nat44_ed.api + """ + self.vpp.api.nat44_ed_plugin_enable_disable(enable=True) + + def enable_nat44_ei(self): + """Enable NAT44 endpoint independent plugin + Example: + from vyos.vpp.nat import Nat44 + nat44 = Nat44() + nat44.enable_nat44_ei() + """ + self.vpp.api.nat44_ei_plugin_enable_disable(enable=True) + + def enable_nat44_forwarding(self): + """Enable NAT44 forwarding + Example: + from vyos.vpp.nat import Nat44 + nat44 = Nat44() + nat44.enable_nat44_forwarding() + """ + self.vpp.api.nat44_forwarding_enable_disable(enable=True) + + def disable_nat44_forwarding(self): + """Disable NAT44 forwarding + Example: + from vyos.vpp.nat import Nat44 + nat44 = Nat44() + nat44.disable_nat44_forwarding() + """ + self.vpp.api.nat44_forwarding_enable_disable(enable=False) + + def add_nat44_out_interface(self): + """Add NAT44 output interface + Example: + from vyos.vpp.nat import Nat44 + nat44 = Nat44('eth0') + nat44.add_nat44_out_interface() + """ + self.vpp.api.nat44_ed_add_del_output_interface( + sw_if_index=self.vpp.get_sw_if_index(self.interface_out), + is_add=True, + ) + + def delete_nat44_out_interface(self): + """Delete NAT44 output interface""" + self.vpp.api.nat44_ed_add_del_output_interface( + sw_if_index=self.vpp.get_sw_if_index(self.interface_out), + is_add=False, + ) + + # needs to check + def add_nat44_interface_inside(self): + """Add NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=1, + sw_if_index=self.vpp.get_sw_if_index(self.interface_in), + is_add=True, + ) + + # needs to check + def delete_nat44_interface_inside(self): + """Delete NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=1, + sw_if_index=self.vpp.get_sw_if_index(self.interface_in), + is_add=False, + ) + + # needs to check + def add_nat44_interface_outside(self): + """Add NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=0, + sw_if_index=self.vpp.get_sw_if_index(self.interface_out), + is_add=True, + ) + + # needs to check + def delete_nat44_interface_outside(self): + """Delete NAT44 interface""" + self.vpp.api.nat44_interface_add_del_feature( + flags=0, + sw_if_index=self.vpp.get_sw_if_index(self.interface_out), + is_add=False, + ) + + def add_nat44_address_range(self): + """Add NAT44 address range""" + if '-' not in self.translation_pool and self.translation_pool != 'masquerade': + first_ip_address = last_ip_address = self.translation_pool + else: + first_ip_address, last_ip_address = self.translation_pool.split('-') + self.vpp.api.nat44_add_del_address_range( + first_ip_address=first_ip_address, + last_ip_address=last_ip_address, + is_add=True, + ) + + def delete_nat44_address_range(self): + """Delete NAT44 address range""" + if '-' not in self.translation_pool and self.translation_pool != 'masquerade': + first_ip_address = last_ip_address = self.translation_pool + else: + first_ip_address, last_ip_address = self.translation_pool.split('-') + self.vpp.api.nat44_add_del_address_range( + first_ip_address=first_ip_address, + last_ip_address=last_ip_address, + is_add=False, + ) + + def enable_ipfix(self): + """Enable NAT44 IPFIX logging""" + self.vpp.api.nat44_ei_ipfix_enable_disable(enable=True) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index ec6873e0d..f0eb3a0bf 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -114,6 +114,10 @@ def get_config(config=None): # to be reinitialized after the commit set_dependents('ethernet', conf, removed_iface) + # NAT dependency + if conf.exists(['vpp', 'nat44', 'source']): + set_dependents('vpp_nat_source', conf) + if not conf.exists(base): return { 'removed_ifaces': removed_ifaces, diff --git a/src/conf_mode/vpp_nat_source.py b/src/conf_mode/vpp_nat_source.py new file mode 100644 index 000000000..2e1884c3a --- /dev/null +++ b/src/conf_mode/vpp_nat_source.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.config import Config +from vyos import ConfigError +from vyos.vpp.nat.nat44 import Nat44 + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat44', 'source'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dicitonary per interface delete + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + if effective_config: + config.update({'effective': effective_config}) + + return config + + +def verify(config): + if 'remove' in config: + return None + + required_keys = {'inbound_interface', 'outbound_interface'} + if not all(key in config for key in required_keys): + missing_keys = required_keys - set(config.keys()) + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" + ) + + if not config.get('translation', {}).get('address'): + raise ConfigError('Translation requires address') + + if config.get('translation', {}).get('address') == 'masquerade': + raise ConfigError('Masquerade is not implemented') + + +def generate(config): + pass + + +def apply(config): + # Delete NAT source + if 'effective' in config: + remove_config = config.get('effective') + interface_in = remove_config.get('inbound_interface') + interface_out = remove_config.get('outbound_interface') + translation_address = remove_config.get('translation', {}).get('address') + + n = Nat44(interface_in, interface_out, translation_address) + n.delete_nat44_out_interface() + n.delete_nat44_interface_inside() + n.delete_nat44_address_range() + + if 'remove' in config: + return None + + # Add NAT44 + interface_in = config.get('inbound_interface') + interface_out = config.get('outbound_interface') + translation_address = config.get('translation', {}).get('address') + + n = Nat44(interface_in, interface_out, translation_address) + n.enable_nat44_ed() + n.enable_nat44_forwarding() + n.add_nat44_out_interface() + # n.add_nat44_interface_inside() + n.add_nat44_address_range() + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) -- cgit v1.2.3 From ac890f5e3ff7d0bb4853199204e4db7c4f1dcc3e Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Thu, 20 Feb 2025 19:33:16 +0100 Subject: firewall: T7148: Bridge state-policy uses drop in place of reject --- data/templates/firewall/nftables.j2 | 6 +++--- python/vyos/template.py | 13 +++++++++---- smoketest/scripts/cli/test_firewall.py | 7 +++++++ 3 files changed, 19 insertions(+), 7 deletions(-) (limited to 'python') diff --git a/data/templates/firewall/nftables.j2 b/data/templates/firewall/nftables.j2 index a35143870..67473da8e 100755 --- a/data/templates/firewall/nftables.j2 +++ b/data/templates/firewall/nftables.j2 @@ -435,13 +435,13 @@ table bridge vyos_filter { {% if global_options.state_policy is vyos_defined %} chain VYOS_STATE_POLICY { {% if global_options.state_policy.established is vyos_defined %} - {{ global_options.state_policy.established | nft_state_policy('established') }} + {{ global_options.state_policy.established | nft_state_policy('established', bridge=True) }} {% endif %} {% if global_options.state_policy.invalid is vyos_defined %} - {{ global_options.state_policy.invalid | nft_state_policy('invalid') }} + {{ global_options.state_policy.invalid | nft_state_policy('invalid', bridge=True) }} {% endif %} {% if global_options.state_policy.related is vyos_defined %} - {{ global_options.state_policy.related | nft_state_policy('related') }} + {{ global_options.state_policy.related | nft_state_policy('related', bridge=True) }} {% endif %} return } diff --git a/python/vyos/template.py b/python/vyos/template.py index 7ba608b32..e75db1a8d 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -612,12 +612,17 @@ def nft_default_rule(fw_conf, fw_name, family): return " ".join(output) @register_filter('nft_state_policy') -def nft_state_policy(conf, state): +def nft_state_policy(conf, state, bridge=False): out = [f'ct state {state}'] + action = conf['action'] if 'action' in conf else None + + if bridge and action == 'reject': + action = 'drop' # T7148 - Bridge cannot use reject + if 'log' in conf: log_state = state[:3].upper() - log_action = (conf['action'] if 'action' in conf else 'accept')[:1].upper() + log_action = (action if action else 'accept')[:1].upper() out.append(f'log prefix "[STATE-POLICY-{log_state}-{log_action}]"') if 'log_level' in conf: @@ -626,8 +631,8 @@ def nft_state_policy(conf, state): out.append('counter') - if 'action' in conf: - out.append(conf['action']) + if action: + out.append(action) return " ".join(out) diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index 93d41a7f7..33144c7fa 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -658,6 +658,13 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): self.verify_nftables(nftables_search, 'ip vyos_filter') + # T7148 - Ensure bridge rule reject -> drop + self.cli_set(['firewall', 'global-options', 'state-policy', 'invalid', 'action', 'reject']) + self.cli_commit() + + self.verify_nftables([['ct state invalid', 'reject']], 'ip vyos_filter') + self.verify_nftables([['ct state invalid', 'drop']], 'bridge vyos_filter') + # Check conntrack is enabled from state-policy self.verify_nftables_chain([['accept']], 'ip vyos_conntrack', 'FW_CONNTRACK') self.verify_nftables_chain([['accept']], 'ip6 vyos_conntrack', 'FW_CONNTRACK') -- cgit v1.2.3 From 4abb970491dfc83234b817808d18ae6bfeeeffe8 Mon Sep 17 00:00:00 2001 From: khramshinr Date: Thu, 20 Feb 2025 14:08:54 +0700 Subject: T7073: Verify VPP buffers page size T7077: Verify VPP memory default-hugepage-size T7079: Verify VPP memory main-heap-page-size T7080: Verify VPP statseg page-size get available hugepage sizes align memory main-heap-size by page size validate host_resources max_map_count --- .../include/unformat_log2_page_size.xml.i | 16 +--- interface-definitions/vpp.xml.in | 4 +- python/vyos/vpp/utils.py | 94 ++++++++++++++++++++++ smoketest/scripts/cli/test_vpp.py | 76 +++++++++++++++-- src/completion/list_mem_page_size.py | 69 ++++++++++++++++ src/conf_mode/vpp.py | 47 +++++++---- 6 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 src/completion/list_mem_page_size.py (limited to 'python') diff --git a/interface-definitions/include/unformat_log2_page_size.xml.i b/interface-definitions/include/unformat_log2_page_size.xml.i index 240fd5bb8..ce6f70dc8 100644 --- a/interface-definitions/include/unformat_log2_page_size.xml.i +++ b/interface-definitions/include/unformat_log2_page_size.xml.i @@ -1,6 +1,7 @@ default default-hugepage + default @@ -10,20 +11,7 @@ default-hugepage Default huge-page - - <number>K - Kilobyte - - - <number>M - Megabyte - - - <number>G - Gigabyte - - - (default|default-hugepage|\d+K|\d+M|\d+G) + (default|default-hugepage|4K|8K|1024K|64K|256K|2048K|4096K|16384K|262144K|1048576K|16777216K|1M|2M|4M|16M|256M|1024M|16384M|1G|16G) diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 1f1cb44cc..e0784fc43 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -774,7 +774,9 @@ Default hugepage size - #include + + + diff --git a/python/vyos/vpp/utils.py b/python/vyos/vpp/utils.py index 08d69bba2..681d25578 100644 --- a/python/vyos/vpp/utils.py +++ b/python/vyos/vpp/utils.py @@ -16,12 +16,16 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import ctypes +import os import socket from fcntl import ioctl from pathlib import Path from struct import pack +mem_shift = {'K': 10, 'k': 10, 'M': 20, 'm': 20, 'G': 30, 'g': 30} + + def iftunnel_transform(iface: str) -> str: """Transform interface name from `xxxNN` to `xxx_tunnelNN` @@ -211,6 +215,96 @@ def cli_ifaces_lcp_kernel_list( return lcp_kernel_ifaces +def get_default_hugepage_size() -> int: + """ + Retrieve the system's default huge page size. + :return: The default huge page size in bytes. + """ + page_size = None + try: + # default huge page size + memfd = os.memfd_create('tmp', os.MFD_HUGETLB) + st = os.fstat(memfd) + page_size = st.st_blksize + os.close(memfd) + except OSError: + pass + + return page_size + + +def get_default_page_size() -> int: + """ + Retrieve the system's default page size. + :return: The default page size in bytes. + """ + return os.sysconf('SC_PAGESIZE') + + +def get_hugepage_sizes() -> list[int]: + """ + Retrieve all available huge page sizes from the system. + :return: A list of huge page sizes in bytes. + """ + huge_sizes = [] + path = '/sys/kernel/mm/hugepages/' + try: + entries = os.listdir(path) + for entry in entries: + if entry.startswith('hugepages-'): + try: + size_kb = int(entry.replace('hugepages-', '').replace('kB', '')) + huge_sizes.append(size_kb << 10) # Convert KB to bytes + except ValueError: + pass + except FileNotFoundError: + pass + + return huge_sizes + + +def human_memory_to_bytes(value: str) -> int: + """ + Convert a human-readable vpp memory format (K, M, G) to a byte value. + + :param value: The string memory size in vpp human-readable format. + :return: A int representing the value. + """ + try: + return int(value) + except ValueError: + return int(value[:-1]) << mem_shift[value[-1]] + + +def bytes_to_human_memory(value: int, unit: str) -> str | None: + """ + Convert a byte value to a human-readable format (K, M, G). + + :param value: The size in bytes. + :param unit: The unit to convert to ('K', 'M', 'G'). + :return: A string representing the value in the specified unit, or None if zero. + """ + val = value >> mem_shift[unit] + return f'{val}{unit}' if val else None + + +def human_page_memory_to_bytes(value: str) -> int: + """ + Convert a human-readable vpp page size format to a byte value. + + :param value: The string memory size in vpp human-readable format. + :return: A int representing the value. + """ + default = { + 'default': get_default_page_size, + 'default-hugepage': get_default_hugepage_size, + } + try: + return default[value]() + except KeyError: + return human_memory_to_bytes(value) + + class EthtoolGDrvinfo: """Return interface details like `ethtol -i` does""" diff --git a/smoketest/scripts/cli/test_vpp.py b/smoketest/scripts/cli/test_vpp.py index 86b00da44..9f28c970f 100755 --- a/smoketest/scripts/cli/test_vpp.py +++ b/smoketest/scripts/cli/test_vpp.py @@ -19,7 +19,9 @@ import os import re +import sys import unittest +from collections import defaultdict from json import loads @@ -30,6 +32,9 @@ from vyos.utils.process import process_named_running from vyos.utils.file import read_file from vyos.utils.process import rc_cmd +sys.path.append(os.getenv('vyos_completion_dir')) +from list_mem_page_size import list_mem_page_size + PROCESS_NAME = 'vpp_main' VPP_CONF = '/run/vpp/vpp.conf' base_path = ['vpp'] @@ -37,6 +42,38 @@ driver = 'dpdk' interface = 'eth1' +def get_vpp_config(): + config = defaultdict(dict) + current_section = None + + with open(VPP_CONF, 'r') as f: + for line in f: + line = line.strip() + + if not line or line.startswith('#'): # Ignore empty lines and comments + continue + + section_match = re.match(r'([a-zA-Z0-9_-]+)\s*{', line) + if section_match: + current_section = section_match.group(1) + config[current_section] = {} + continue + + if line == '}': # End of section + current_section = None + continue + + key_value_match = re.match(r'([a-zA-Z0-9_-]+)\s+(.+)', line) + if key_value_match: + key, value = key_value_match.groups() + if current_section: + config[current_section][key] = value + else: + config[key] = value + + return config + + def get_address(interface): rc, data = rc_cmd(f'ip --json address show dev {interface}') if rc == 0: @@ -60,12 +97,13 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.cli_set(base_path + ['settings', 'unix', 'poll-sleep-usec', '10']) def tearDown(self): - # Check for running process - self.assertTrue(process_named_running(PROCESS_NAME)) - - # delete test config - self.cli_delete(base_path) - self.cli_commit() + try: + # Check for running process + self.assertTrue(process_named_running(PROCESS_NAME)) + finally: + # Ensure these cleanup operations always run + self.cli_delete(base_path) + self.cli_commit() self.assertFalse(os.path.exists(VPP_CONF)) self.assertFalse(process_named_running(PROCESS_NAME)) @@ -1064,6 +1102,32 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): for config_entry in config_entries: self.assertIn(config_entry, config) + def test_13_mem_page_size(self): + sizes = ['default', 'default-hugepage'] + list_mem_page_size() + for size in sizes: + self.cli_set(base_path + ['settings', 'buffers', 'page-size', size]) + self.cli_set(base_path + ['settings', 'statseg', 'page-size', size]) + self.cli_set( + base_path + ['settings', 'memory', 'main-heap-page-size', size] + ) + self.cli_commit() + + conf = get_vpp_config() + self.assertEqual(conf['buffers']['page-size'], size) + self.assertEqual(conf['statseg']['page-size'], size) + self.assertEqual(conf['memory']['main-heap-page-size'], size) + + def test_14_mem_default_hugepage(self): + sizes = list_mem_page_size(hugepage_only=True) + for size in sizes: + self.cli_set( + base_path + ['settings', 'memory', 'default-hugepage-size', size] + ) + self.cli_commit() + + conf = get_vpp_config() + self.assertEqual(conf['memory']['default-hugepage-size'], size) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/completion/list_mem_page_size.py b/src/completion/list_mem_page_size.py new file mode 100644 index 000000000..1ed81a15f --- /dev/null +++ b/src/completion/list_mem_page_size.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see . +import argparse +from vyos.vpp.utils import ( + get_hugepage_sizes, + get_default_hugepage_size, + get_default_page_size, + bytes_to_human_memory, +) + + +def get_default_page_sizes() -> list[int]: + """ + Retrieve the system's default page sizes, including huge pages. + :return: A list of page sizes in bytes. + """ + page_sizes = [] + # default system page size + page_size = get_default_page_size() + if page_size: + page_sizes.append(page_size) + + # default huge page size + page_size = get_default_hugepage_size() + if page_size: + page_sizes.append(page_size) + + return page_sizes + + +def list_mem_page_size(hugepage_only=None) -> list[str]: + result = [] + page_sizes = get_hugepage_sizes() + + if not hugepage_only: + page_sizes += get_default_page_sizes() + + page_sizes = set(page_sizes) + for unit in ['K', 'M', 'G']: + for size in page_sizes: + if val := bytes_to_human_memory(size, unit): + result.append(val) + + return result + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + '--hugepage_only', type=str, help='List only available hugepage sizes.' + ) + args = parser.parse_args() + + result = list_mem_page_size(args.hugepage_only) + print(' '.join(result)) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index ec6873e0d..aea0c1754 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -39,7 +39,11 @@ from vyos.vpp import control_host from vyos.vpp.config_deps import deps_xconnect_dict from vyos.vpp.config_verify import verify_dev_driver from vyos.vpp.config_filter import iface_filter_eth -from vyos.vpp.utils import EthtoolGDrvinfo +from vyos.vpp.utils import ( + EthtoolGDrvinfo, + human_page_memory_to_bytes, + human_memory_to_bytes, +) from vyos.vpp.configdb import JSONStorage airbag.enable() @@ -252,18 +256,6 @@ def get_config(config=None): return config -def convert_to_int(val): - rates = { - 'K': 1024, - 'M': 1024**2, - 'G': 1024**3, - } - try: - return int(val) - except ValueError: - return int(val[:-1]) * rates[val[-1]] - - def verify_memory(settings): memory_available: int = virtual_memory().available cpus: int = get_core_count() @@ -283,12 +275,24 @@ def verify_memory(settings): ) memory_required += netlink_buffer_size - memory_main_heap = convert_to_int( + memory_main_heap = human_memory_to_bytes( settings.get('memory', {}).get('main_heap_size', '1G') ) - memory_required += memory_main_heap - statseg_size = convert_to_int(settings.get('statseg', {}).get('size', '96M')) + memory_main_heap_page_size = settings.get('memory', {}).get( + 'main_heap_page_size', 0 + ) + if memory_main_heap_page_size: + memory_main_heap_page_size = human_page_memory_to_bytes( + memory_main_heap_page_size + ) + memory_required += (memory_main_heap + memory_main_heap_page_size - 1) & ~( + memory_main_heap_page_size - 1 + ) + else: + memory_required += memory_main_heap + + statseg_size = human_memory_to_bytes(settings.get('statseg', {}).get('size', '96M')) memory_required += statseg_size if memory_available < memory_required: @@ -452,6 +456,17 @@ def verify(config): raise ConfigError('"cpu corelist-workers" is not correct') verify_memory(config['settings']) + if 'host_resources' in config['settings']: + if ( + 'nr_hugepages' in config['settings']['host_resources'] + and 'max_map_count' in config['settings']['host_resources'] + ): + if int(config['settings']['host_resources']['max_map_count']) < 2 * int( + config['settings']['host_resources']['nr_hugepages'] + ): + raise ConfigError( + 'The max_map_count must be greater than or equal to (2 * nr_hugepages)' + ) # Check if deleted interfaces are not xconnect memebrs for iface_config in config.get('removed_ifaces', []): -- cgit v1.2.3 From 0a7096c874340ced4eb6aa17ae47b8d0ae6d692c Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Thu, 20 Feb 2025 17:08:53 +0200 Subject: T7171: Add dstport option to GENEVE tunnels --- interface-definitions/interfaces_geneve.xml.in | 4 ++++ python/vyos/ifconfig/geneve.py | 2 +- src/conf_mode/interfaces_geneve.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) (limited to 'python') diff --git a/interface-definitions/interfaces_geneve.xml.in b/interface-definitions/interfaces_geneve.xml.in index 990c5bd91..c1e6c33d5 100644 --- a/interface-definitions/interfaces_geneve.xml.in +++ b/interface-definitions/interfaces_geneve.xml.in @@ -23,6 +23,10 @@ #include #include #include + #include + + 6081 + GENEVE tunnel parameters diff --git a/python/vyos/ifconfig/geneve.py b/python/vyos/ifconfig/geneve.py index f7fddb812..f53ef4166 100644 --- a/python/vyos/ifconfig/geneve.py +++ b/python/vyos/ifconfig/geneve.py @@ -48,7 +48,7 @@ class GeneveIf(Interface): 'parameters.ipv6.flowlabel' : 'flowlabel', } - cmd = 'ip link add name {ifname} type geneve id {vni} remote {remote}' + cmd = 'ip link add name {ifname} type geneve id {vni} remote {remote} dstport {port}' for vyos_key, iproute2_key in mapping.items(): # dict_search will return an empty dict "{}" for valueless nodes like # "parameters.nolearning" - thus we need to test the nodes existence diff --git a/src/conf_mode/interfaces_geneve.py b/src/conf_mode/interfaces_geneve.py index 007708d4a..1c5b4d0e7 100755 --- a/src/conf_mode/interfaces_geneve.py +++ b/src/conf_mode/interfaces_geneve.py @@ -47,7 +47,7 @@ def get_config(config=None): # GENEVE interfaces are picky and require recreation if certain parameters # change. But a GENEVE interface should - of course - not be re-created if # it's description or IP address is adjusted. Feels somehow logic doesn't it? - for cli_option in ['remote', 'vni', 'parameters']: + for cli_option in ['remote', 'vni', 'parameters', 'port']: if is_node_changed(conf, base + [ifname, cli_option]): geneve.update({'rebuild_required': {}}) -- cgit v1.2.3 From 47a8502fcdc4a371742b52176074af91debd66a7 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Mon, 24 Feb 2025 19:24:46 +0000 Subject: vd-275: Add loopback bvi interface for a bridge member Allow to configure VPP loopback interface as BVI interface In the VPP a bridge-domain is the L2 bridge and does not have its own interface Loopback is required if we want to ping from/to the bridge. ``` set vpp interfaces bridge br10 member interface lo23 bvi ``` --- interface-definitions/vpp.xml.in | 13 +++++++++--- python/vyos/vpp/interface/bridge.py | 5 +++-- smoketest/scripts/cli/test_vpp.py | 29 +++++++++++++++++++++++-- src/conf_mode/vpp_interfaces_bridge.py | 39 +++++++++++++++++++++++++++------- 4 files changed, 71 insertions(+), 15 deletions(-) (limited to 'python') diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index e0784fc43..a7c293c94 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -132,7 +132,7 @@ Bridge member interfaces - + Member interface name @@ -142,9 +142,16 @@ txt Interface name - - + + + + Bridge Virtual Interface (BVI) + + + + + diff --git a/python/vyos/vpp/interface/bridge.py b/python/vyos/vpp/interface/bridge.py index 407eab08c..5ebe46d45 100644 --- a/python/vyos/vpp/interface/bridge.py +++ b/python/vyos/vpp/interface/bridge.py @@ -70,7 +70,7 @@ class BridgeInterface: """ self.vpp.api.bridge_domain_add_del_v2(is_add=False, bd_id=self.interface_suffix) - def add_member(self, member: str | int): + def add_member(self, member: str | int, port_type: int = 0): """Add member to Bridge interface Attaches a VPP interface to the Bridge interface specified by `interface_suffix`. @@ -80,6 +80,7 @@ class BridgeInterface: Args: member (str or int): The name or index of the VPP network interface to be added as a member to the bridge. + port_type: 0 - Normal port, 1 - BVI port Example: from vyos.vpp.interface import BridgeInterface @@ -96,7 +97,7 @@ class BridgeInterface: member_if_index = self.vpp.get_sw_if_index(member) return self.vpp.api.sw_interface_set_l2_bridge( - rx_sw_if_index=member_if_index, bd_id=bridge_index, port_type=0 + rx_sw_if_index=member_if_index, bd_id=bridge_index, port_type=port_type ) def detach_member(self, member: str | int): diff --git a/smoketest/scripts/cli/test_vpp.py b/smoketest/scripts/cli/test_vpp.py index 69f1410ab..426d19884 100755 --- a/smoketest/scripts/cli/test_vpp.py +++ b/smoketest/scripts/cli/test_vpp.py @@ -740,7 +740,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertRegex(out, r'\s*eth1\s+\d+\s+\d+') # Set non exist member - # expect raise ConfigErro + # expect raise ConfigError self.cli_set( base_path + [ @@ -799,7 +799,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): # Perform assertions based on the normalized output self.assertIn('BD-ID Index BSN Age(min)', normalized_out) - self.assertIn('10 1 1 off', normalized_out) + self.assertIn('10 1 0 off', normalized_out) self.assertIn('Learning U-Forwrd UU-Flood Flooding', normalized_out) self.assertIn('on on flood on', normalized_out) self.assertIn('Interface If-idx ISN', normalized_out) @@ -819,6 +819,31 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertRegex(out, r'\s*eth1\s+\d+\s+\d+') self.assertRegex(out, r'\s*vxlan_tunnel23\s+\d+\s+\d+') + # Add Loopback BVI to the bridge + self.cli_set(base_path + ['interfaces', 'loopback', f'lo{vni}']) + self.cli_set( + base_path + + [ + 'interfaces', + 'bridge', + interface_bridge, + 'member', + 'interface', + f'lo{vni}', + 'bvi', + ] + ) + # commit changes + self.cli_commit() + + # check bridge interface + _, out = rc_cmd('sudo vppctl show bridge-domain 10 detail') + # Normalize the output for consistent whitespace + normalized_out = re.sub(r'\s+', ' ', out) + + self.assertIn('10 1 0 off', normalized_out) + self.assertRegex(out, r'\bloop23\s+\d+\s+\d+\s+\d+\s+\*\s+') + def test_08_vpp_ipip(self): interface_ipip = 'ipip12' interface_kernel = 'vpptun12' diff --git a/src/conf_mode/vpp_interfaces_bridge.py b/src/conf_mode/vpp_interfaces_bridge.py index e7a2427f9..d98067a6a 100644 --- a/src/conf_mode/vpp_interfaces_bridge.py +++ b/src/conf_mode/vpp_interfaces_bridge.py @@ -19,7 +19,7 @@ import os from vyos.config import Config -from vyos.configdict import leaf_node_changed +from vyos.configdict import node_changed from vyos import ConfigError from vyos.vpp.interface import BridgeInterface from vyos.vpp.utils import iftunnel_transform @@ -77,7 +77,7 @@ def get_config(config=None) -> dict: ) # determine which members have been removed - interfaces_removed = leaf_node_changed(conf, base + [ifname, 'member', 'interface']) + interfaces_removed = node_changed(conf, base + [ifname, 'member', 'interface']) if interfaces_removed: config['members_removed'] = interfaces_removed @@ -92,19 +92,30 @@ def verify(config): # Check if interface exists in vpp before adding to bridge-domain - allowed_prefixes = ('gre', 'geneve', 'vxlan') + allowed_prefixes = ('gre', 'geneve', 'lo', 'vxlan') if 'member' in config: - for member in config.get('member', {}).get('interface', []): - # Check if the interface is explicitly listed or starts with allowed prefixes + bvi_exists = False + for member, member_config in ( + config.get('member', {}).get('interface', {}).items() + ): + # Check if the interface exists in VPP settings or starts with allowed prefixes if not ( - member in config.get('vpp_interfaces', []) + member in config.get('vpp_interfaces', {}) or member.startswith(allowed_prefixes) ): raise ConfigError( f"Interface '{member}' not found in 'vpp settings interface' or does not start with allowed prefixes {allowed_prefixes}" ) + # Check if BVI is already defined, only one BVI per bridge domain is allowed + if 'bvi' in member_config: + if bvi_exists: + raise ConfigError("Only one BVI per bridge domain is allowed") + if not member.startswith('lo'): + raise ConfigError("BVI can only be defined on loopback interface") + bvi_exists = True + def generate(config): pass @@ -120,6 +131,9 @@ def apply(config): for member in config.get('members_removed'): if member.startswith(interface_transform_filter): member = iftunnel_transform(member) + if member.startswith('lo'): + # interface name in VPP is loopX + member = member.replace('lo', 'loop') i.detach_member(member=member) # Delete bridge domain @@ -138,10 +152,19 @@ def apply(config): # Add members to bridge if members: br = BridgeInterface(ifname) - for member in members: + port_type = 0 + for member, member_config in members.items(): if member.startswith(interface_transform_filter): member = iftunnel_transform(member) - br.add_member(member=member) + if member.startswith('lo'): + # interface name in VPP is loopX + member = member.replace('lo', 'loop') + if 'bvi' in member_config: + port_type = 1 + + br.add_member(member=member, port_type=port_type) + # set default port type 0 (not BVI) + port_type = 0 return None -- cgit v1.2.3 From 23477683cea2777f570ac0d98098aa2c6f041661 Mon Sep 17 00:00:00 2001 From: James Roberts Date: Sat, 1 Mar 2025 15:11:57 -0500 Subject: Revert "wireguard: T4930: remove pylint W0611: unused import" This reverts commit bb70ea569f4548b103c54bbb7c393221a6da0a23. --- python/vyos/ifconfig/wireguard.py | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'python') diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index be9bffd20..f5217aecb 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -14,9 +14,14 @@ # License along with this library. If not, see . import os +import time +from datetime import timedelta from tempfile import NamedTemporaryFile +from hurry.filesize import size +from hurry.filesize import alternative + from vyos.configquery import ConfigTreeQuery from vyos.ifconfig import Interface from vyos.ifconfig import Operational -- cgit v1.2.3 From 731195c7adb21208767199f5487dfdf29ce09380 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 4 Mar 2025 16:02:41 +0100 Subject: T5400: add local build of libvyosconfig to Makefile libvyosconfig is both a build and a run dependency of vyos-1x. Satisfying the build dependency within the Docker image requires coordination of updates to vyos-build/libvyosconfig/vyos-1x on any changes to the library; simplify this process by moving the build to a step of the vyos-1x Makefile. --- Makefile | 15 +++++++++++++-- debian/control | 1 - python/vyos/configtree.py | 4 +++- 3 files changed, 16 insertions(+), 4 deletions(-) (limited to 'python') diff --git a/Makefile b/Makefile index b5d114e59..e194b6d2c 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ CFLAGS := BUILD_ARCH := $(shell dpkg-architecture -q DEB_BUILD_ARCH) J2LINT := $(shell command -v j2lint 2> /dev/null) PYLINT_FILES := $(shell git ls-files *.py src/migration-scripts) +LIBVYOSCONFIG_BUILD_PATH := /tmp/libvyosconfig/_build/libvyosconfig.so config_xml_src = $(wildcard interface-definitions/*.xml.in) config_xml_obj = $(config_xml_src:.xml.in=.xml) @@ -19,9 +20,19 @@ op_xml_obj = $(op_xml_src:.xml.in=.xml) mkdir -p $(BUILD_DIR)/$(dir $@) $(CURDIR)/scripts/transclude-template $< > $(BUILD_DIR)/$@ +.PHONY: libvyosconfig +.ONESHELL: +libvyosconfig: + if ! [ -f $(LIBVYOSCONFIG_BUILD_PATH) ]; then + git clone https://github.com/vyos/libvyosconfig.git /tmp/libvyosconfig || exit 1 + cd /tmp/libvyosconfig && \ + git checkout 677d1e2bf8109b9fd4da60e20376f992b747e384 || exit 1 + ./build.sh + fi + .PHONY: interface_definitions .ONESHELL: -interface_definitions: $(config_xml_obj) +interface_definitions: $(config_xml_obj) libvyosconfig mkdir -p $(TMPL_DIR) $(CURDIR)/scripts/override-default $(BUILD_DIR)/interface-definitions @@ -75,7 +86,7 @@ vyshim: $(MAKE) -C $(SHIM_DIR) .PHONY: all -all: clean interface_definitions op_mode_definitions test j2lint vyshim generate-configd-include-json +all: clean libvyosconfig interface_definitions op_mode_definitions test j2lint vyshim generate-configd-include-json .PHONY: clean clean: diff --git a/debian/control b/debian/control index 0d040a374..efc008af2 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,6 @@ Build-Depends: fakeroot, gcc, iproute2, - libvyosconfig0 (>= 0.0.7), libzmq3-dev, python3 (>= 3.10), # For QA diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index 8d27a7e46..4ad0620a5 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -19,7 +19,9 @@ import logging from ctypes import cdll, c_char_p, c_void_p, c_int, c_bool -LIBPATH = '/usr/lib/libvyosconfig.so.0' +BUILD_PATH = '/tmp/libvyosconfig/_build/libvyosconfig.so' +INSTALL_PATH = '/usr/lib/libvyosconfig.so.0' +LIBPATH = BUILD_PATH if os.path.isfile(BUILD_PATH) else INSTALL_PATH def replace_backslash(s, search, replace): -- cgit v1.2.3 From 877166b8aef1132f5bad208aee00444819264501 Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Mon, 3 Mar 2025 15:41:27 +0200 Subject: T7189: VPP source of the tunnel interface should be checked and configured Changed priority for VPP interfaces: all VPP interfaces must be configured after Ethernet interfaces --- interface-definitions/vpp.xml.in | 18 +++++----- python/vyos/vpp/config_verify.py | 13 +++++++ python/vyos/vpp/utils.py | 37 +++++++++++++++++++ smoketest/scripts/cli/test_vpp.py | 67 ++++++++++++++++++++++++++++++++--- src/conf_mode/vpp_interfaces_gre.py | 11 +++++- src/conf_mode/vpp_interfaces_ipip.py | 11 +++++- src/conf_mode/vpp_interfaces_vxlan.py | 11 +++++- 7 files changed, 151 insertions(+), 17 deletions(-) (limited to 'python') diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 150803a0d..e9075588e 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -14,7 +14,7 @@ - 304 + 324 Bonding Interface/Link Aggregation bond[0-9]+ @@ -112,7 +112,7 @@ - 307 + 327 Bridge domain @@ -159,7 +159,7 @@ - 305 + 325 Generic Network Encapsulation (GRE) Interface gre[0-9]+ @@ -246,7 +246,7 @@ - 305 + 325 IP encapsulation tunnel interface ipip[0-9]+ @@ -267,7 +267,7 @@ - 305 + 325 Loopback Interface lo[0-9]+ @@ -286,7 +286,7 @@ - 305 + 325 Virtual Extensible LAN (VXLAN) Interface vxlan[0-9]+ @@ -309,7 +309,7 @@ Layer 2 cross connect - 305 + 325 xcon[0-9]+ @@ -845,7 +845,7 @@ VPP kernel interface settings - 308 + 328 vpptapN Kernel interface name diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py index 6b35858d2..830d4af73 100644 --- a/python/vyos/vpp/config_verify.py +++ b/python/vyos/vpp/config_verify.py @@ -102,6 +102,19 @@ def verify_vpp_remove_xconnect_interface(config: dict): ) +def verify_vpp_tunnel_source_address(config: dict): + from vyos.utils.network import is_intf_addr_assigned + + address = config.get('source_address') + for iface in config.get('vpp_ether_vif_ifaces', []): + if is_intf_addr_assigned(iface, address): + return True + + raise ConfigError( + f'Source address "{address}" is not assigned on any Ethernet or VIF interface!' + ) + + def verify_dev_driver(iface_name: str, driver_type: str) -> bool: # Lists of drivers compatible with DPDK and XDP drivers_dpdk: list[str] = [ diff --git a/python/vyos/vpp/utils.py b/python/vyos/vpp/utils.py index 681d25578..fe08a98b8 100644 --- a/python/vyos/vpp/utils.py +++ b/python/vyos/vpp/utils.py @@ -85,6 +85,43 @@ def cli_ifaces_list(config_instance, mode: str = 'candidate') -> list[str]: return vpp_ifaces +def cli_ethernet_with_vifs_ifaces(config_instance) -> list[str]: + """List of all VPP Ethernet interfaces with VIFs + + Args: + config_instance (VyOS Config): VyOS Config instance + + Returns: + list[str]: list of interfaces + """ + from vyos.configdict import get_interface_dict + + # Read a config + config = config_instance.get_config_dict( + ['vpp'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_recursive_defaults=True, + ) + + ifaces: list[str] = [] + + # Get a list of Ethernet interfaces + for iface in config.get('settings', {}).get('interface', {}).keys(): + ifaces.append(iface) + + # Add Ethernet interfaces with VIFs + for iface in ifaces: + _, iface_config = get_interface_dict( + config_instance, ['interfaces', 'ethernet'], ifname=iface + ) + ifaces.extend([f'{iface}.{vif}' for vif in iface_config.get('vif', {})]) + ifaces.extend([f'{iface}.{vif_s}' for vif_s in iface_config.get('vif_s', {})]) + + return ifaces + + def vpp_ifaces_list(vpp_api) -> list[dict]: """List interfaces in VPP diff --git a/smoketest/scripts/cli/test_vpp.py b/smoketest/scripts/cli/test_vpp.py index 6c19fbba0..57f47067a 100755 --- a/smoketest/scripts/cli/test_vpp.py +++ b/smoketest/scripts/cli/test_vpp.py @@ -106,6 +106,10 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.cli_delete(base_path) self.cli_commit() + # delete address for Ethernet interface + self.cli_delete(['interfaces', 'ethernet', interface, 'address']) + self.cli_commit() + self.assertFalse(os.path.exists(VPP_CONF)) self.assertFalse(process_named_running(PROCESS_NAME)) @@ -170,11 +174,21 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): base_path + ['interfaces', 'vxlan', interface_vxlan, 'source-address', source_address] ) + self.cli_set(base_path + ['interfaces', 'vxlan', interface_vxlan, 'vni', vni]) + + # remote and source address must not be the same + # expect raise ConfigError + self.cli_set( + base_path + + ['interfaces', 'vxlan', interface_vxlan, 'remote', source_address] + ) + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set( base_path + ['interfaces', 'vxlan', interface_vxlan, 'remote', remote_address] ) - self.cli_set(base_path + ['interfaces', 'vxlan', interface_vxlan, 'vni', vni]) self.cli_set( base_path + [ @@ -214,6 +228,23 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): new_source_address, ] ) + + # source address of the tunnel interface should be configured + # expect raise ConfigError + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set( + [ + 'interfaces', + 'ethernet', + interface, + 'vif', + vni, + 'address', + f'{new_source_address}/24', + ] + ) self.cli_commit() # check gre interface after update @@ -274,7 +305,11 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertFalse(os.path.isdir(f'/sys/class/net/{interface_kernel}')) # delete vxlan interface - self.cli_set(base_path + ['interfaces', 'vxlan', interface_vxlan]) + self.cli_delete(base_path + ['interfaces', 'vxlan', interface_vxlan]) + self.cli_commit() + + # delete vif Ethernet interface + self.cli_delete(['interfaces', 'ethernet', interface, 'vif']) self.cli_commit() def test_03_vpp_gre(self): @@ -302,6 +337,15 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): + ['kernel-interfaces', interface_kernel, 'address', f'{kernel_address}/31'] ) + # source address of the tunnel interface should be configured + # expect raise ConfigError + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set( + ['interfaces', 'ethernet', interface, 'address', f'{source_address}/24'] + ) + # commit changes self.cli_commit() @@ -319,6 +363,10 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): base_path + ['interfaces', 'gre', interface_gre, 'source-address', new_source_address] ) + + self.cli_set( + ['interfaces', 'ethernet', interface, 'address', f'{new_source_address}/24'] + ) self.cli_commit() # check gre interface after update @@ -364,7 +412,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertFalse(os.path.isdir(f'/sys/class/net/{interface_kernel}')) # delete gre interface - self.cli_set(base_path + ['interfaces', 'gre', interface_gre]) + self.cli_delete(base_path + ['interfaces', 'gre', interface_gre]) self.cli_commit() @unittest.skip('Skipping this test geneve index always is 0') @@ -566,7 +614,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertFalse(os.path.isdir(f'/sys/class/net/{interface_kernel}')) # delete loopback interface - self.cli_set(base_path + ['interfaces', 'loopback', interface_loopback]) + self.cli_delete(base_path + ['interfaces', 'loopback', interface_loopback]) self.cli_commit() @unittest.skip('Skipping temporary bonding, sometimes get recursion T7117') @@ -900,6 +948,15 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): new_source_address, ] ) + + # source address of the tunnel interface should be configured + # expect raise ConfigError + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set( + ['interfaces', 'ethernet', interface, 'address', f'{new_source_address}/24'] + ) self.cli_commit() # check ipip interface after update @@ -951,7 +1008,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertFalse(os.path.isdir(f'/sys/class/net/{interface_kernel}')) # delete ipip interface - self.cli_set(base_path + ['interfaces', 'ipip', interface_ipip]) + self.cli_delete(base_path + ['interfaces', 'ipip', interface_ipip]) self.cli_commit() def test_09_vpp_xconnect(self): diff --git a/src/conf_mode/vpp_interfaces_gre.py b/src/conf_mode/vpp_interfaces_gre.py index de7398248..823f3ff4d 100644 --- a/src/conf_mode/vpp_interfaces_gre.py +++ b/src/conf_mode/vpp_interfaces_gre.py @@ -32,8 +32,9 @@ from vyos.vpp.config_verify import ( verify_vpp_change_kernel_interface, verify_vpp_remove_xconnect_interface, verify_vpp_exists_kernel_interface, + verify_vpp_tunnel_source_address, ) -from vyos.vpp.utils import cli_ifaces_lcp_kernel_list +from vyos.vpp.utils import cli_ifaces_lcp_kernel_list, cli_ethernet_with_vifs_ifaces def get_config(config=None) -> dict: @@ -94,6 +95,9 @@ def get_config(config=None) -> dict: # list of all kernel interfaces `vpp interface xxx kernel-interface xxx` config['candidate_kernel_interfaces'] = cli_ifaces_lcp_kernel_list(conf) + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + # Dependency config['xconn_members'] = deps_xconnect_dict(conf) if ifname in config['xconn_members']: @@ -130,6 +134,11 @@ def verify(config): f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" ) + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + # check multipoint mode if config.get('mode') == 'point-to-multipoint': # For multipoint mode, remote IP must be 0.0.0.0 diff --git a/src/conf_mode/vpp_interfaces_ipip.py b/src/conf_mode/vpp_interfaces_ipip.py index 2428a9850..c52e7c997 100644 --- a/src/conf_mode/vpp_interfaces_ipip.py +++ b/src/conf_mode/vpp_interfaces_ipip.py @@ -31,8 +31,9 @@ from vyos.vpp.config_verify import ( verify_vpp_change_kernel_interface, verify_vpp_remove_xconnect_interface, verify_vpp_exists_kernel_interface, + verify_vpp_tunnel_source_address, ) -from vyos.vpp.utils import cli_ifaces_lcp_kernel_list +from vyos.vpp.utils import cli_ifaces_lcp_kernel_list, cli_ethernet_with_vifs_ifaces def get_config(config=None) -> dict: @@ -93,6 +94,9 @@ def get_config(config=None) -> dict: # list of all kernel interfaces `vpp interface xxx kernel-interface xxx` config['candidate_kernel_interfaces'] = cli_ifaces_lcp_kernel_list(conf) + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + # Dependency config['xconn_members'] = deps_xconnect_dict(conf) if ifname in config['xconn_members']: @@ -130,6 +134,11 @@ def verify(config): f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" ) + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + # Change 'vpp interfaces ipip ipipX kernel-interface vpp-tunX' # => 'vpp interfaces ipip ipipX kernel-interface vpp-tunY' # check if we have kernel interface config 'vpp kernel-interface vpp-tunX' diff --git a/src/conf_mode/vpp_interfaces_vxlan.py b/src/conf_mode/vpp_interfaces_vxlan.py index b94b39d6d..963cd90ec 100644 --- a/src/conf_mode/vpp_interfaces_vxlan.py +++ b/src/conf_mode/vpp_interfaces_vxlan.py @@ -33,8 +33,9 @@ from vyos.vpp.config_verify import ( verify_vpp_change_kernel_interface, verify_vpp_remove_xconnect_interface, verify_vpp_exists_kernel_interface, + verify_vpp_tunnel_source_address, ) -from vyos.vpp.utils import cli_ifaces_lcp_kernel_list +from vyos.vpp.utils import cli_ifaces_lcp_kernel_list, cli_ethernet_with_vifs_ifaces def get_config(config=None) -> dict: @@ -95,6 +96,9 @@ def get_config(config=None) -> dict: # list of all kernel interfaces `vpp interface xxx kernel-interface xxx` config['candidate_kernel_interfaces'] = cli_ifaces_lcp_kernel_list(conf) + # list of all Ethernet interfaces with vifs + config['vpp_ether_vif_ifaces'] = cli_ethernet_with_vifs_ifaces(conf) + # Dependency config['xconn_members'] = deps_xconnect_dict(conf) if ifname in config['xconn_members']: @@ -134,6 +138,11 @@ def verify(config): f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" ) + # verify source address and remote address + verify_vpp_tunnel_source_address(config) + if config.get('source_address') == config.get('remote'): + raise ConfigError('Remote address must not be the same as source address') + # Change 'vpp interfaces vxlan greX kernel-interface vpp-tunX' # => 'vpp interfaces gre vxlanX kernel-interface vpp-tunY' # check if we have kernel interface config 'vpp kernel-interface vpp-tunX' -- cgit v1.2.3 From dbe09d4c3dac302ad7b24cdd4f2b1ed123436442 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 31 Jan 2025 15:48:33 -0600 Subject: T6946: add wrapper for show_commit_data and test function --- python/vyos/config_mgmt.py | 10 ++++---- python/vyos/configtree.py | 16 ++++++++++++ src/helpers/show_commit_data.py | 56 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) create mode 100755 src/helpers/show_commit_data.py (limited to 'python') diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 1c2b70fdf..dd8910afb 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -287,7 +287,7 @@ Proceed ?""" # commits under commit-confirm are not added to revision list unless # confirmed, hence a soft revert is to revision 0 - revert_ct = self._get_config_tree_revision(0) + revert_ct = self.get_config_tree_revision(0) message = '[commit-confirm] Reverting to previous config now' os.system('wall -n ' + message) @@ -351,7 +351,7 @@ Proceed ?""" ) return msg, 1 - rollback_ct = self._get_config_tree_revision(rev) + rollback_ct = self.get_config_tree_revision(rev) try: load(rollback_ct, switch='explicit') print('Rollback diff has been applied.') @@ -382,7 +382,7 @@ Proceed ?""" if rev1 is not None: if not self._check_revision_number(rev1): return f'Invalid revision number {rev1}', 1 - ct1 = self._get_config_tree_revision(rev1) + ct1 = self.get_config_tree_revision(rev1) ct2 = self.working_config msg = f'No changes between working and revision {rev1} configurations.\n' if rev2 is not None: @@ -390,7 +390,7 @@ Proceed ?""" return f'Invalid revision number {rev2}', 1 # compare older to newer ct2 = ct1 - ct1 = self._get_config_tree_revision(rev2) + ct1 = self.get_config_tree_revision(rev2) msg = f'No changes between revisions {rev2} and {rev1} configurations.\n' out = '' @@ -575,7 +575,7 @@ Proceed ?""" r = f.read().decode() return r - def _get_config_tree_revision(self, rev: int): + def get_config_tree_revision(self, rev: int): c = self._get_file_revision(rev) return ConfigTree(c) diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index 4ad0620a5..2b8930882 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -488,6 +488,22 @@ def mask_inclusive(left, right, libpath=LIBPATH): return tree +def show_commit_data(active_tree, proposed_tree, libpath=LIBPATH): + if not ( + isinstance(active_tree, ConfigTree) and isinstance(proposed_tree, ConfigTree) + ): + raise TypeError('Arguments must be instances of ConfigTree') + + __lib = cdll.LoadLibrary(libpath) + __show_commit_data = __lib.show_commit_data + __show_commit_data.argtypes = [c_void_p, c_void_p] + __show_commit_data.restype = c_char_p + + res = __show_commit_data(active_tree._get_config(), proposed_tree._get_config()) + + return res.decode() + + def reference_tree_to_json(from_dir, to_file, internal_cache='', libpath=LIBPATH): try: __lib = cdll.LoadLibrary(libpath) diff --git a/src/helpers/show_commit_data.py b/src/helpers/show_commit_data.py new file mode 100755 index 000000000..d507ed9a4 --- /dev/null +++ b/src/helpers/show_commit_data.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# +# This script is used to show the commit data of the configuration + +import sys +from pathlib import Path +from argparse import ArgumentParser + +from vyos.config_mgmt import ConfigMgmt +from vyos.configtree import ConfigTree +from vyos.configtree import show_commit_data + +cm = ConfigMgmt() + +parser = ArgumentParser( + description='Show commit priority queue; no options compares the last two commits' +) +parser.add_argument('--active-config', help='Path to the active configuration file') +parser.add_argument('--proposed-config', help='Path to the proposed configuration file') +args = parser.parse_args() + +active_arg = args.active_config +proposed_arg = args.proposed_config + +if active_arg and not proposed_arg: + print('--proposed-config is required when --active-config is specified') + sys.exit(1) + +if not active_arg and not proposed_arg: + active = cm.get_config_tree_revision(1) + proposed = cm.get_config_tree_revision(0) +else: + if active_arg: + active = ConfigTree(Path(active_arg).read_text()) + else: + active = cm.get_config_tree_revision(0) + + proposed = ConfigTree(Path(proposed_arg).read_text()) + +ret = show_commit_data(active, proposed) +print(ret) -- cgit v1.2.3 From defc2b665940a8fa14dcc1753dc40bdee0f52a64 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 5 Feb 2025 15:30:50 -0600 Subject: T7121: add configtree read/write to internal representation --- python/vyos/configtree.py | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) (limited to 'python') diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index 2b8930882..c1db0782b 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -66,9 +66,14 @@ class ConfigTreeError(Exception): class ConfigTree(object): - def __init__(self, config_string=None, address=None, libpath=LIBPATH): - if config_string is None and address is None: - raise TypeError("ConfigTree() requires one of 'config_string' or 'address'") + def __init__( + self, config_string=None, address=None, internal=None, libpath=LIBPATH + ): + if config_string is None and address is None and internal is None: + raise TypeError( + "ConfigTree() requires one of 'config_string', 'address', or 'internal'" + ) + self.__config = None self.__lib = cdll.LoadLibrary(libpath) @@ -89,6 +94,13 @@ class ConfigTree(object): self.__to_commands.argtypes = [c_void_p, c_char_p] self.__to_commands.restype = c_char_p + self.__read_internal = self.__lib.read_internal + self.__read_internal.argtypes = [c_char_p] + self.__read_internal.restype = c_void_p + + self.__write_internal = self.__lib.write_internal + self.__write_internal.argtypes = [c_void_p, c_char_p] + self.__to_json = self.__lib.to_json self.__to_json.argtypes = [c_void_p] self.__to_json.restype = c_char_p @@ -168,7 +180,21 @@ class ConfigTree(object): self.__destroy = self.__lib.destroy self.__destroy.argtypes = [c_void_p] - if address is None: + self.__equal = self.__lib.equal + self.__equal.argtypes = [c_void_p, c_void_p] + self.__equal.restype = c_bool + + if address is not None: + self.__config = address + self.__version = '' + elif internal is not None: + config = self.__read_internal(internal.encode()) + if config is None: + msg = self.__get_error().decode() + raise ValueError('Failed to read internal rep: {0}'.format(msg)) + else: + self.__config = config + elif config_string is not None: config_section, version_section = extract_version(config_string) config_section = escape_backslash(config_section) config = self.__from_string(config_section.encode()) @@ -179,8 +205,9 @@ class ConfigTree(object): self.__config = config self.__version = version_section else: - self.__config = address - self.__version = '' + raise TypeError( + "ConfigTree() requires one of 'config_string', 'address', or 'internal'" + ) self.__migration = os.environ.get('VYOS_MIGRATION') if self.__migration: @@ -190,6 +217,11 @@ class ConfigTree(object): if self.__config is not None: self.__destroy(self.__config) + def __eq__(self, other): + if isinstance(other, ConfigTree): + return self.__equal(self._get_config(), other._get_config()) + return False + def __str__(self): return self.to_string() @@ -199,6 +231,9 @@ class ConfigTree(object): def get_version_string(self): return self.__version + def write_cache(self, file_name): + self.__write_internal(self._get_config(), file_name) + def to_string(self, ordered_values=False, no_version=False): config_string = self.__to_string(self.__config, ordered_values).decode() config_string = unescape_backslash(config_string) -- cgit v1.2.3 From 33394e41516a7a7591629e913c7f981e2494e80a Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 16 Mar 2025 21:04:21 -0500 Subject: T7121: add Config init from internal cache The internal cache is used as a faster replacement to parsing the active and proposed configs on initialization of a commit session. --- python/vyos/configsource.py | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'python') diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py index 59e5ac8a1..65cef5333 100644 --- a/python/vyos/configsource.py +++ b/python/vyos/configsource.py @@ -319,3 +319,13 @@ class ConfigSourceString(ConfigSource): self._session_config = ConfigTree(session_config_text) if session_config_text else None except ValueError: raise ConfigSourceError(f"Init error in {type(self)}") + +class ConfigSourceCache(ConfigSource): + def __init__(self, running_config_cache=None, session_config_cache=None): + super().__init__() + + try: + self._running_config = ConfigTree(internal=running_config_cache) if running_config_cache else None + self._session_config = ConfigTree(internal=session_config_cache) if session_config_cache else None + except ValueError: + raise ConfigSourceError(f"Init error in {type(self)}") -- cgit v1.2.3 From 3078c7c976627a735df9175fb28b2d33b77d1cae Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 16 Mar 2025 21:05:02 -0500 Subject: T7121: generate Python protobuf files at build --- .gitignore | 3 +++ python/setup.py | 38 ++++++++++++++++++++++++++++++++++++++ python/vyos/defaults.py | 3 ++- python/vyos/proto/__init__.py | 0 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 python/vyos/proto/__init__.py (limited to 'python') diff --git a/.gitignore b/.gitignore index d1bfc91d7..27ed8000f 100644 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,9 @@ data/reftree.cache # autogenerated vyos-configd JSON definition data/configd-include.json +# autogenerated vyos-commitd protobuf files +python/vyos/proto/*pb2.py + # We do not use pip Pipfile Pipfile.lock diff --git a/python/setup.py b/python/setup.py index 2d614e724..96dc211f7 100644 --- a/python/setup.py +++ b/python/setup.py @@ -1,5 +1,11 @@ import os +import sys +import subprocess from setuptools import setup +from setuptools.command.build_py import build_py + +sys.path.append('./vyos') +from defaults import directories def packages(directory): return [ @@ -8,6 +14,35 @@ def packages(directory): if os.path.isfile(os.path.join(_[0], '__init__.py')) ] + +class GenerateProto(build_py): + ver = os.environ.get('OCAML_VERSION') + if ver: + proto_path = f'/opt/opam/{ver}/share/vyconf' + else: + proto_path = directories['proto_path'] + + def run(self): + # find all .proto files in vyconf proto_path + proto_files = [] + for _, _, files in os.walk(self.proto_path): + for file in files: + if file.endswith('.proto'): + proto_files.append(file) + + # compile each .proto file to Python + for proto_file in proto_files: + subprocess.check_call( + [ + 'protoc', + '--python_out=vyos/proto', + f'--proto_path={self.proto_path}/', + proto_file, + ] + ) + + build_py.run(self) + setup( name = "vyos", version = "1.3.0", @@ -29,4 +64,7 @@ setup( "config-mgmt = vyos.config_mgmt:run", ], }, + cmdclass={ + 'build_py': GenerateProto, + }, ) diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 86194cd55..2600cf4fb 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -38,7 +38,8 @@ directories = { 'vyos_configdir' : '/opt/vyatta/config', 'completion_dir' : f'{base_dir}/completion', 'ca_certificates' : '/usr/local/share/ca-certificates/vyos', - 'ppp_nexthop_dir' : '/run/ppp_nexthop' + 'ppp_nexthop_dir' : '/run/ppp_nexthop', + 'proto_path': '/usr/share/vyos/vyconf' } systemd_services = { diff --git a/python/vyos/proto/__init__.py b/python/vyos/proto/__init__.py new file mode 100644 index 000000000..e69de29bb -- cgit v1.2.3 From b3427a6aa693728b69b32430a521c8cc9cc5c015 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 16 Mar 2025 21:07:25 -0500 Subject: T7121: add defaults entry for vyconfd.conf The vyconfd configuration file contains socket name, canonical directories, and file names shared with vyos-commitd. --- python/vyos/defaults.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 2600cf4fb..2b08ff68e 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -39,7 +39,7 @@ directories = { 'completion_dir' : f'{base_dir}/completion', 'ca_certificates' : '/usr/local/share/ca-certificates/vyos', 'ppp_nexthop_dir' : '/run/ppp_nexthop', - 'proto_path': '/usr/share/vyos/vyconf' + 'proto_path' : '/usr/share/vyos/vyconf' } systemd_services = { @@ -70,3 +70,5 @@ rt_symbolic_names = { rt_global_vrf = rt_symbolic_names['main'] rt_global_table = rt_symbolic_names['main'] + +vyconfd_conf = '/etc/vyos/vyconfd.conf' -- cgit v1.2.3 From b6df26f89df776bb63e79203c06744f9bed65c72 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 16 Mar 2025 21:10:07 -0500 Subject: T7121: add test_commit wrapper and test script --- python/vyos/configtree.py | 13 ++++++++++++ src/helpers/test_commit.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100755 src/helpers/test_commit.py (limited to 'python') diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index c1db0782b..83954327c 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -539,6 +539,19 @@ def show_commit_data(active_tree, proposed_tree, libpath=LIBPATH): return res.decode() +def test_commit(active_tree, proposed_tree, libpath=LIBPATH): + if not ( + isinstance(active_tree, ConfigTree) and isinstance(proposed_tree, ConfigTree) + ): + raise TypeError('Arguments must be instances of ConfigTree') + + __lib = cdll.LoadLibrary(libpath) + __test_commit = __lib.test_commit + __test_commit.argtypes = [c_void_p, c_void_p] + + __test_commit(active_tree._get_config(), proposed_tree._get_config()) + + def reference_tree_to_json(from_dir, to_file, internal_cache='', libpath=LIBPATH): try: __lib = cdll.LoadLibrary(libpath) diff --git a/src/helpers/test_commit.py b/src/helpers/test_commit.py new file mode 100755 index 000000000..00a413687 --- /dev/null +++ b/src/helpers/test_commit.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# +# This script is used to test execution of the commit algorithm by vyos-commitd + +from pathlib import Path +from argparse import ArgumentParser +from datetime import datetime + +from vyos.configtree import ConfigTree +from vyos.configtree import test_commit + + +parser = ArgumentParser( + description='Execute commit priority queue' +) +parser.add_argument( + '--active-config', help='Path to the active configuration file', required=True +) +parser.add_argument( + '--proposed-config', help='Path to the proposed configuration file', required=True +) +args = parser.parse_args() + +active_arg = args.active_config +proposed_arg = args.proposed_config + +active = ConfigTree(Path(active_arg).read_text()) +proposed = ConfigTree(Path(proposed_arg).read_text()) + + +time_begin_commit = datetime.now() +test_commit(active, proposed) +time_end_commit = datetime.now() +print(f'commit time: {time_end_commit - time_begin_commit}') -- cgit v1.2.3 From b5b3e85f0bc8170b97d3e1af2383477c0854914d Mon Sep 17 00:00:00 2001 From: oniko94 Date: Fri, 7 Feb 2025 13:40:37 +0200 Subject: T6353: Add password strength check and user warning --- debian/control | 2 + debian/vyos-1x.postinst | 14 ++++++- python/vyos/utils/auth.py | 64 +++++++++++++++++++++++++++++ smoketest/scripts/cli/base_vyostest_shim.py | 1 - smoketest/scripts/cli/test_system_login.py | 20 ++++++++- src/conf_mode/system_login.py | 21 +++++++++- src/op_mode/image_installer.py | 19 +++++++++ 7 files changed, 136 insertions(+), 5 deletions(-) (limited to 'python') diff --git a/debian/control b/debian/control index efc008af2..4f1207078 100644 --- a/debian/control +++ b/debian/control @@ -123,6 +123,8 @@ Depends: # Live filesystem tools squashfs-tools, fuse-overlayfs, +# Tools for checking password strength + python3-cracklib, ## End installer auditd, iputils-arping, diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index fde58651a..ba97f37f6 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -195,6 +195,10 @@ if [ ! -x $PRECONFIG_SCRIPT ]; then EOF fi +# cracklib-runtime default database location +CRACKLIB_DIR=/var/cache/cracklib +CRACKLIB_DB=cracklib_dict + # create /opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script POSTCONFIG_SCRIPT=/opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script if [ ! -x $POSTCONFIG_SCRIPT ]; then @@ -206,7 +210,15 @@ if [ ! -x $POSTCONFIG_SCRIPT ]; then # This script is executed at boot time after VyOS configuration is fully applied. # Any modifications required to work around unfixed bugs # or use services not available through the VyOS CLI system can be placed here. - +# +# T6353 - Just in case, check if cracklib was installed properly +# If the database file is missing, re-install the runtime package +# +if [ ! -f "${CRACKLIB_DIR}/${CRACKLIB_DB}.pwd" ]; then + mkdir -p $CRACKLIB_DIR + /usr/sbin/create-cracklib-dict -o $CRACKLIB_DIR/$CRACKLIB_DB \ + /usr/share/dict/cracklib-small +fi EOF fi diff --git a/python/vyos/utils/auth.py b/python/vyos/utils/auth.py index a0b3e1cae..a27d8a28a 100644 --- a/python/vyos/utils/auth.py +++ b/python/vyos/utils/auth.py @@ -13,10 +13,74 @@ # You should have received a copy of the GNU Lesser General Public License along with this library; # if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +import cracklib +import math import re +import string +from enum import StrEnum +from decimal import Decimal from vyos.utils.process import cmd + +DEFAULT_PASSWORD = 'vyos' +LOW_ENTROPY_MSG = 'should be at least 8 characters long;' +WEAK_PASSWORD_MSG= 'The password complexity is too low - @MSG@' + + +class EPasswdStrength(StrEnum): + WEAK = 'Weak' + DECENT = 'Decent' + STRONG = 'Strong' + + +def calculate_entropy(charset: str, passwd: str) -> float: + """ + Calculate the entropy of a password based on the set of characters used + Uses E = log2(R**L) formula, where + - R is the range (length) of the character set + - L is the length of password + """ + return math.log(math.pow(len(charset), len(passwd)), 2) + +def evaluate_strength(passwd: str) -> dict[str, str]: + """ Evaluates password strength and returns a check result dict """ + charset = (cracklib.ASCII_UPPERCASE + cracklib.ASCII_LOWERCASE + + string.punctuation + string.digits) + + result = { + 'strength': '', + 'error': '', + } + + try: + cracklib.FascistCheck(passwd) + except ValueError as e: + # The password is vulnerable to dictionary attack no matter the entropy + if 'is' in str(e): + msg = str(e).replace('is', 'should not be') + else: + msg = f'should not be {e}' + result.update(strength=EPasswdStrength.WEAK) + result.update(error=WEAK_PASSWORD_MSG.replace('@MSG@', msg)) + else: + # Now check the password's entropy + # Cast to Decimal for more precise rounding + entropy = Decimal.from_float(calculate_entropy(charset, passwd)) + + match round(entropy): + case e if e in range(0, 59): + result.update(strength=EPasswdStrength.WEAK) + result.update( + error=WEAK_PASSWORD_MSG.replace('@MSG@', LOW_ENTROPY_MSG) + ) + case e if e in range(60, 119): + result.update(strength=EPasswdStrength.DECENT) + case e if e >= 120: + result.update(strength=EPasswdStrength.STRONG) + + return result + def make_password_hash(password): """ Makes a password hash for /etc/shadow using mkpasswd """ diff --git a/smoketest/scripts/cli/base_vyostest_shim.py b/smoketest/scripts/cli/base_vyostest_shim.py index edf940efd..6da4ed9e6 100644 --- a/smoketest/scripts/cli/base_vyostest_shim.py +++ b/smoketest/scripts/cli/base_vyostest_shim.py @@ -94,7 +94,6 @@ class VyOSUnitTestSHIM: def cli_commit(self): if self.debug: print('commit') - self._session.commit() # During a commit there is a process opening commit_lock, and run() # returns 0 while run(f'sudo lsof -nP {commit_lock}') == 0: diff --git a/smoketest/scripts/cli/test_system_login.py b/smoketest/scripts/cli/test_system_login.py index d79f5521c..ed72f378e 100755 --- a/smoketest/scripts/cli/test_system_login.py +++ b/smoketest/scripts/cli/test_system_login.py @@ -25,7 +25,9 @@ import shutil from base_vyostest_shim import VyOSUnitTestSHIM +from contextlib import redirect_stdout from gzip import GzipFile +from io import StringIO, TextIOWrapper from subprocess import Popen from subprocess import PIPE from pwd import getpwall @@ -42,6 +44,7 @@ from vyos.xml_ref import default_value base_path = ['system', 'login'] users = ['vyos1', 'vyos-roxx123', 'VyOS-123_super.Nice'] +weak_passwd_user = ['test_user', 'passWord1'] ssh_test_command = '/opt/vyatta/bin/vyatta-op-cmd-wrapper show version' @@ -194,18 +197,20 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): def test_system_login_user(self): for user in users: name = f'VyOS Roxx {user}' + passwd = f'{user}-pSWd-t3st' home_dir = f'/tmp/smoketest/{user}' - self.cli_set(base_path + ['user', user, 'authentication', 'plaintext-password', user]) + self.cli_set(base_path + ['user', user, 'authentication', 'plaintext-password', passwd]) self.cli_set(base_path + ['user', user, 'full-name', name]) self.cli_set(base_path + ['user', user, 'home-directory', home_dir]) self.cli_commit() for user in users: + passwd = f'{user}-pSWd-t3st' tmp = ['su','-', user] proc = Popen(tmp, stdin=PIPE, stdout=PIPE, stderr=PIPE) - tmp = f'{user}\nuname -a' + tmp = f'{passwd}\nuname -a' proc.stdin.write(tmp.encode()) proc.stdin.flush() (stdout, stderr) = proc.communicate() @@ -229,6 +234,17 @@ class TestSystemLogin(VyOSUnitTestSHIM.TestCase): tmp = cmd(f'sudo passwd -S {locked_user}') self.assertIn(f'{locked_user} P ', tmp) + def test_system_login_weak_password_warning(self): + self.cli_set(base_path + [ + 'user', weak_passwd_user[0], 'authentication', + 'plaintext-password', weak_passwd_user[1] + ]) + + out = self.cli_commit().strip() + + self.assertIn('WARNING: The password complexity is too low', out) + self.cli_delete(base_path + ['user', weak_passwd_user[0]]) + def test_system_login_otp(self): otp_user = 'otp-test_user' otp_password = 'SuperTestPassword' diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py index d3a969d9b..1e6061ecf 100755 --- a/src/conf_mode/system_login.py +++ b/src/conf_mode/system_login.py @@ -15,6 +15,7 @@ # along with this program. If not, see . import os +import warnings from passlib.hosts import linux_context from psutil import users @@ -24,11 +25,17 @@ from pwd import getpwuid from sys import exit from time import sleep +from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_vrf from vyos.template import render from vyos.template import is_ipv4 -from vyos.utils.auth import get_current_user +from vyos.utils.auth import ( + DEFAULT_PASSWORD, + EPasswdStrength, + evaluate_strength, + get_current_user +) from vyos.utils.configfs import delete_cli_node from vyos.utils.configfs import add_cli_node from vyos.utils.dict import dict_search @@ -146,6 +153,18 @@ def verify(login): if s_user.pw_name == user and s_user.pw_uid < MIN_USER_UID: raise ConfigError(f'User "{user}" can not be created, conflict with local system account!') + # T6353: Check password for complexity using cracklib. + # A user password should be sufficiently complex + plaintext_password = dict_search( + path='authentication.plaintext_password', + dict_object=user_config + ) or None + + if plaintext_password is not None: + result = evaluate_strength(plaintext_password) + if result['strength'] == EPasswdStrength.WEAK: + Warning(result['error']) + for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): if 'type' not in pubkey_options: raise ConfigError(f'Missing type for public-key "{pubkey}"!') diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index 609b0b347..c6e9c7f6f 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -32,10 +32,16 @@ from errno import ENOSPC from psutil import disk_partitions +from vyos.base import Warning from vyos.configtree import ConfigTree from vyos.remote import download from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER from vyos.template import render +from vyos.utils.auth import ( + DEFAULT_PASSWORD, + EPasswdStrength, + evaluate_strength +) from vyos.utils.io import ask_input, ask_yes_no, select_entry from vyos.utils.file import chmod_2775 from vyos.utils.process import cmd, run, rc_cmd @@ -83,6 +89,9 @@ MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.' MSG_WARN_ROOT_SIZE_TOOSMALL: str = 'The size is too small. Try again' MSG_WARN_IMAGE_NAME_WRONG: str = 'The suggested name is unsupported!\n'\ 'It must be between 1 and 64 characters long and contains only the next characters: .+-_ a-z A-Z 0-9' + +MSG_WARN_CHANGE_PASSWORD: str = 'Default password used. Consider changing ' \ + 'it on next login.' MSG_WARN_PASSWORD_CONFIRM: str = 'The entered values did not match. Try again' 'Installing a different image flavor may cause functionality degradation or break your system.\n' \ 'Do you want to continue with installation?' @@ -778,10 +787,20 @@ def install_image() -> None: while True: user_password: str = ask_input(MSG_INPUT_PASSWORD, no_echo=True, non_empty=True) + + if user_password == DEFAULT_PASSWORD: + Warning(MSG_WARN_CHANGE_PASSWORD) + else: + result = evaluate_strength(user_password) + if result['strength'] == EPasswdStrength.WEAK: + Warning(result['error']) + confirm: str = ask_input(MSG_INPUT_PASSWORD_CONFIRM, no_echo=True, non_empty=True) + if user_password == confirm: break + print(MSG_WARN_PASSWORD_CONFIRM) # ask for default console -- cgit v1.2.3 From 6dd7dbc2500d8f5c662389ababefaf0ab2669fe9 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 18 Mar 2025 11:49:13 -0500 Subject: T7246: do not pass unneeded version string to parser Previously the parser would ignore lines beginning with '//', however this is unnecessarily restrictive. Pass only config information to parser, as the version string is saved separately for reconstruction on render. --- python/vyos/configtree.py | 2 +- tests/data/config.valid | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index 83954327c..dade852c7 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -50,7 +50,7 @@ def unescape_backslash(string: str) -> str: def extract_version(s): """Extract the version string from the config string""" t = re.split('(^//)', s, maxsplit=1, flags=re.MULTILINE) - return (s, ''.join(t[1:])) + return (t[0], ''.join(t[1:])) def check_path(path): diff --git a/tests/data/config.valid b/tests/data/config.valid index 1fbdd1505..0836bf60b 100644 --- a/tests/data/config.valid +++ b/tests/data/config.valid @@ -35,5 +35,5 @@ empty-node { trailing-leaf-node-without-value -// Trailing comment -// Another trailing comment +// some version string info +// continued -- cgit v1.2.3 From 9e2bdc96ea63e7ee1adb002df17e0d9ecc1cd410 Mon Sep 17 00:00:00 2001 From: Alex W Date: Thu, 30 Jan 2025 20:22:41 +0000 Subject: firewall: T5493: Implement remote-group --- data/templates/firewall/nftables-defines.j2 | 9 ++++ interface-definitions/firewall.xml.in | 13 +++++ .../include/firewall/common-rule-ipv4.xml.i | 2 + .../firewall/source-destination-remote-group.xml.i | 17 +++++++ python/vyos/firewall.py | 7 +++ python/vyos/utils/network.py | 16 ++++++ smoketest/scripts/cli/test_firewall.py | 34 +++++++++++++ src/conf_mode/firewall.py | 26 ++++++++-- src/op_mode/firewall.py | 34 +++++++------ src/services/vyos-domain-resolver | 58 +++++++++++++++++++++- 10 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 interface-definitions/include/firewall/source-destination-remote-group.xml.i (limited to 'python') diff --git a/data/templates/firewall/nftables-defines.j2 b/data/templates/firewall/nftables-defines.j2 index fa6cd74c0..3147b4c37 100644 --- a/data/templates/firewall/nftables-defines.j2 +++ b/data/templates/firewall/nftables-defines.j2 @@ -35,6 +35,15 @@ } {% endfor %} {% endif %} +{% if group.remote_group is vyos_defined and is_l3 and not is_ipv6 %} +{% for name, name_config in group.remote_group.items() %} + set R_{{ name }} { + type {{ ip_type }} + flags interval + auto-merge + } +{% endfor %} +{% endif %} {% if group.mac_group is vyos_defined %} {% for group_name, group_conf in group.mac_group.items() %} {% set includes = group_conf.include if group_conf.include is vyos_defined else [] %} diff --git a/interface-definitions/firewall.xml.in b/interface-definitions/firewall.xml.in index e4fe9a508..7538c3cc5 100644 --- a/interface-definitions/firewall.xml.in +++ b/interface-definitions/firewall.xml.in @@ -138,6 +138,19 @@ + + + Firewall remote-group + + #include + + Name of firewall group can only contain alphanumeric letters, hyphen, underscores and dot + + + #include + #include + + Firewall interface-group diff --git a/interface-definitions/include/firewall/common-rule-ipv4.xml.i b/interface-definitions/include/firewall/common-rule-ipv4.xml.i index 803b94b06..b67ef25dc 100644 --- a/interface-definitions/include/firewall/common-rule-ipv4.xml.i +++ b/interface-definitions/include/firewall/common-rule-ipv4.xml.i @@ -16,6 +16,7 @@ #include #include #include + #include @@ -39,6 +40,7 @@ #include #include #include + #include \ No newline at end of file diff --git a/interface-definitions/include/firewall/source-destination-remote-group.xml.i b/interface-definitions/include/firewall/source-destination-remote-group.xml.i new file mode 100644 index 000000000..16463c8eb --- /dev/null +++ b/interface-definitions/include/firewall/source-destination-remote-group.xml.i @@ -0,0 +1,17 @@ + + + + Group + + + + + Group of remote addresses + + firewall group remote-group + + + + + + diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 314e8dfe3..9f01f8be1 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -310,6 +310,13 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): operator = '!=' group_name = group_name[1:] output.append(f'{ip_name} {prefix}addr {operator} @D_{group_name}') + elif 'remote_group' in group: + group_name = group['remote_group'] + operator = '' + if group_name[0] == '!': + operator = '!=' + group_name = group_name[1:] + output.append(f'{ip_name} {prefix}addr {operator} @R_{group_name}') if 'mac_group' in group: group_name = group['mac_group'] operator = '' diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index dc0c0a6d6..2f666f0ee 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -599,3 +599,19 @@ def get_nft_vrf_zone_mapping() -> dict: for (vrf_name, vrf_id) in vrf_list: output.append({'interface' : vrf_name, 'vrf_tableid' : vrf_id}) return output + +def is_valid_ipv4_address_or_range(addr: str) -> bool: + """ + Validates if the provided address is a valid IPv4, CIDR or IPv4 range + :param addr: address to test + :return: bool: True if provided address is valid + """ + from ipaddress import ip_network + try: + if '-' in addr: # If we are checking a range, validate both address's individually + split = addr.split('-') + return is_valid_ipv4_address_or_range(split[0]) and is_valid_ipv4_address_or_range(split[1]) + else: + return ip_network(addr).version == 4 + except: + return False diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index 33144c7fa..2829edbfb 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -1273,5 +1273,39 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): with self.assertRaises(ConfigSessionError): self.cli_commit() + def test_ipv4_remote_group(self): + # Setup base config for test + self.cli_set(['firewall', 'group', 'remote-group', 'group01', 'url', 'http://127.0.0.1:80/list.txt']) + self.cli_set(['firewall', 'group', 'remote-group', 'group01', 'description', 'Example Group 01']) + self.cli_set(['firewall', 'ipv4', 'input', 'filter', 'rule', '10', 'action', 'drop']) + self.cli_set(['firewall', 'ipv4', 'input', 'filter', 'rule', '10', 'protocol', 'tcp']) + self.cli_set(['firewall', 'ipv4', 'input', 'filter', 'rule', '10', 'destination', 'group', 'remote-group', 'group01']) + + self.cli_commit() + + # Test remote-group had been loaded correctly in nft + nftables_search = [ + ['R_group01'], + ['type ipv4_addr'], + ['flags interval'], + ['meta l4proto', 'daddr @R_group01', "ipv4-INP-filter-10"] + ] + self.verify_nftables(nftables_search, 'ip vyos_filter') + + # Test remote-group cannot be configured without a URL + self.cli_delete(['firewall', 'group', 'remote-group', 'group01', 'url']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_discard() + + # Test remote-group cannot be set alongside address in rules + self.cli_set(['firewall', 'ipv4', 'input', 'filter', 'rule', '10', 'destination', 'address', '127.0.0.1']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_discard() + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index 768bb127d..cebe57092 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -44,6 +44,7 @@ airbag.enable() nftables_conf = '/run/nftables.conf' domain_resolver_usage = '/run/use-vyos-domain-resolver-firewall' +firewall_config_dir = "/config/firewall" sysctl_file = r'/run/sysctl/10-vyos-firewall.conf' @@ -53,7 +54,8 @@ valid_groups = [ 'network_group', 'port_group', 'interface_group', - ## Added for group ussage in bridge firewall + 'remote_group', + ## Added for group usage in bridge firewall 'ipv4_address_group', 'ipv6_address_group', 'ipv4_network_group', @@ -311,8 +313,8 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): raise ConfigError('Only one of address, fqdn or geoip can be specified') if 'group' in side_conf: - if len({'address_group', 'network_group', 'domain_group'} & set(side_conf['group'])) > 1: - raise ConfigError('Only one address-group, network-group or domain-group can be specified') + if len({'address_group', 'network_group', 'domain_group', 'remote_group'} & set(side_conf['group'])) > 1: + raise ConfigError('Only one address-group, network-group, remote-group or domain-group can be specified') for group in valid_groups: if group in side_conf['group']: @@ -332,7 +334,7 @@ def verify_rule(firewall, family, hook, priority, rule_id, rule_conf): error_group = fw_group.replace("_", "-") - if group in ['address_group', 'network_group', 'domain_group']: + if group in ['address_group', 'network_group', 'domain_group', 'remote_group']: types = [t for t in ['address', 'fqdn', 'geoip'] if t in side_conf] if types: raise ConfigError(f'{error_group} and {types[0]} cannot both be defined') @@ -442,6 +444,11 @@ def verify(firewall): for group_name, group in groups.items(): verify_nested_group(group_name, group, groups, []) + if 'remote_group' in firewall['group']: + for group_name, group in firewall['group']['remote_group'].items(): + if 'url' not in group: + raise ConfigError(f'remote-group {group_name} must have a url configured') + for family in ['ipv4', 'ipv6', 'bridge']: if family in firewall: for chain in ['name','forward','input','output', 'prerouting']: @@ -539,6 +546,15 @@ def verify(firewall): def generate(firewall): render(nftables_conf, 'firewall/nftables.j2', firewall) render(sysctl_file, 'firewall/sysctl-firewall.conf.j2', firewall) + + # Cleanup remote-group cache files + if os.path.exists(firewall_config_dir): + for fw_file in os.listdir(firewall_config_dir): + # Delete matching files in 'config/firewall' that no longer exist as a remote-group in config + if fw_file.startswith("R_") and fw_file.endswith(".txt"): + if 'group' not in firewall or 'remote_group' not in firewall['group'] or fw_file[2:-4] not in firewall['group']['remote_group'].keys(): + os.unlink(os.path.join(firewall_config_dir, fw_file)) + return None def parse_firewall_error(output): @@ -598,7 +614,7 @@ def apply(firewall): ## DOMAIN RESOLVER domain_action = 'restart' - if dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'].items() or firewall['ip6_fqdn'].items(): + if dict_search_args(firewall, 'group', 'remote_group') or dict_search_args(firewall, 'group', 'domain_group') or firewall['ip_fqdn'].items() or firewall['ip6_fqdn'].items(): text = f'# Automatically generated by firewall.py\nThis file indicates that vyos-domain-resolver service is used by the firewall.\n' Path(domain_resolver_usage).write_text(text) else: diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py index c197ca434..7a3ab921d 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -253,15 +253,17 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule if not source_addr: source_addr = dict_search_args(rule_conf, 'source', 'group', 'domain_group') if not source_addr: - source_addr = dict_search_args(rule_conf, 'source', 'fqdn') + source_addr = dict_search_args(rule_conf, 'source', 'group', 'remote_group') if not source_addr: - source_addr = dict_search_args(rule_conf, 'source', 'geoip', 'country_code') - if source_addr: - source_addr = str(source_addr)[1:-1].replace('\'','') - if 'inverse_match' in dict_search_args(rule_conf, 'source', 'geoip'): - source_addr = 'NOT ' + str(source_addr) + source_addr = dict_search_args(rule_conf, 'source', 'fqdn') if not source_addr: - source_addr = 'any' + source_addr = dict_search_args(rule_conf, 'source', 'geoip', 'country_code') + if source_addr: + source_addr = str(source_addr)[1:-1].replace('\'','') + if 'inverse_match' in dict_search_args(rule_conf, 'source', 'geoip'): + source_addr = 'NOT ' + str(source_addr) + if not source_addr: + source_addr = 'any' # Get destination dest_addr = dict_search_args(rule_conf, 'destination', 'address') @@ -272,15 +274,17 @@ def output_firewall_name_statistics(family, hook, prior, prior_conf, single_rule if not dest_addr: dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'domain_group') if not dest_addr: - dest_addr = dict_search_args(rule_conf, 'destination', 'fqdn') + dest_addr = dict_search_args(rule_conf, 'destination', 'group', 'remote_group') if not dest_addr: - dest_addr = dict_search_args(rule_conf, 'destination', 'geoip', 'country_code') - if dest_addr: - dest_addr = str(dest_addr)[1:-1].replace('\'','') - if 'inverse_match' in dict_search_args(rule_conf, 'destination', 'geoip'): - dest_addr = 'NOT ' + str(dest_addr) + dest_addr = dict_search_args(rule_conf, 'destination', 'fqdn') if not dest_addr: - dest_addr = 'any' + dest_addr = dict_search_args(rule_conf, 'destination', 'geoip', 'country_code') + if dest_addr: + dest_addr = str(dest_addr)[1:-1].replace('\'','') + if 'inverse_match' in dict_search_args(rule_conf, 'destination', 'geoip'): + dest_addr = 'NOT ' + str(dest_addr) + if not dest_addr: + dest_addr = 'any' # Get inbound interface iiface = dict_search_args(rule_conf, 'inbound_interface', 'name') @@ -571,6 +575,8 @@ def show_firewall_group(name=None): row.append("\n".join(sorted(group_conf['port']))) elif 'interface' in group_conf: row.append("\n".join(sorted(group_conf['interface']))) + elif 'url' in group_conf: + row.append(group_conf['url']) else: row.append('N/D') rows.append(row) diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index 48c6b86d8..aba5ba9db 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -13,19 +13,22 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . - import json import time import logging +import os from vyos.configdict import dict_merge from vyos.configquery import ConfigTreeQuery from vyos.firewall import fqdn_config_parse from vyos.firewall import fqdn_resolve from vyos.ifconfig import WireGuardIf +from vyos.remote import download from vyos.utils.commit import commit_in_progress from vyos.utils.dict import dict_search_args from vyos.utils.kernel import WIREGUARD_REKEY_AFTER_TIME +from vyos.utils.file import makedir, chmod_775, write_file, read_file +from vyos.utils.network import is_valid_ipv4_address_or_range from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults @@ -37,6 +40,8 @@ base_firewall = ['firewall'] base_nat = ['nat'] base_interfaces = ['interfaces'] +firewall_config_dir = "/config/firewall" + domain_state = {} ipv4_tables = { @@ -121,6 +126,56 @@ def nft_valid_sets(): except: return [] +def update_remote_group(config): + conf_lines = [] + count = 0 + valid_sets = nft_valid_sets() + + remote_groups = dict_search_args(config, 'group', 'remote_group') + if remote_groups: + # Create directory for list files if necessary + if not os.path.isdir(firewall_config_dir): + makedir(firewall_config_dir, group='vyattacfg') + chmod_775(firewall_config_dir) + + for set_name, remote_config in remote_groups.items(): + if 'url' not in remote_config: + continue + nft_set_name = f'R_{set_name}' + + # Create list file if necessary + list_file = os.path.join(firewall_config_dir, f"{nft_set_name}.txt") + if not os.path.exists(list_file): + write_file(list_file, '', user="root", group="vyattacfg", mode=0o644) + + # Attempt to download file, use cached version if download fails + try: + download(list_file, remote_config['url'], raise_error=True) + except: + logger.error(f'Failed to download list-file for {set_name} remote group') + logger.info(f'Using cached list-file for {set_name} remote group') + + # Read list file + ip_list = [] + for line in read_file(list_file).splitlines(): + line_first_word = line.strip().partition(' ')[0] + + if is_valid_ipv4_address_or_range(line_first_word): + ip_list.append(line_first_word) + + # Load tables + for table in ipv4_tables: + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) + + count += 1 + + nft_conf_str = "\n".join(conf_lines) + "\n" + code = run(f'nft --file -', input=nft_conf_str) + + logger.info(f'Updated {count} remote-groups in firewall - result: {code}') + + def update_fqdn(config, node): conf_lines = [] count = 0 @@ -234,5 +289,6 @@ if __name__ == '__main__': while True: update_fqdn(firewall, 'firewall') update_fqdn(nat, 'nat') + update_remote_group(firewall) update_interfaces(interfaces, 'interfaces') time.sleep(timeout) -- cgit v1.2.3 From d9ec5d1e70d3991ac64498734157cfb7934034ee Mon Sep 17 00:00:00 2001 From: oniko94 Date: Tue, 25 Mar 2025 01:56:28 +0200 Subject: T7278: Remove cracklib hack from postinstall script template --- debian/vyos-1x.postinst | 14 +------------- python/vyos/utils/auth.py | 14 ++++++++++---- src/conf_mode/system_login.py | 3 ++- src/op_mode/image_installer.py | 3 ++- 4 files changed, 15 insertions(+), 19 deletions(-) (limited to 'python') diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index ba97f37f6..fde58651a 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -195,10 +195,6 @@ if [ ! -x $PRECONFIG_SCRIPT ]; then EOF fi -# cracklib-runtime default database location -CRACKLIB_DIR=/var/cache/cracklib -CRACKLIB_DB=cracklib_dict - # create /opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script POSTCONFIG_SCRIPT=/opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script if [ ! -x $POSTCONFIG_SCRIPT ]; then @@ -210,15 +206,7 @@ if [ ! -x $POSTCONFIG_SCRIPT ]; then # This script is executed at boot time after VyOS configuration is fully applied. # Any modifications required to work around unfixed bugs # or use services not available through the VyOS CLI system can be placed here. -# -# T6353 - Just in case, check if cracklib was installed properly -# If the database file is missing, re-install the runtime package -# -if [ ! -f "${CRACKLIB_DIR}/${CRACKLIB_DB}.pwd" ]; then - mkdir -p $CRACKLIB_DIR - /usr/sbin/create-cracklib-dict -o $CRACKLIB_DIR/$CRACKLIB_DB \ - /usr/share/dict/cracklib-small -fi + EOF fi diff --git a/python/vyos/utils/auth.py b/python/vyos/utils/auth.py index a27d8a28a..5d0e3464a 100644 --- a/python/vyos/utils/auth.py +++ b/python/vyos/utils/auth.py @@ -23,15 +23,18 @@ from decimal import Decimal from vyos.utils.process import cmd -DEFAULT_PASSWORD = 'vyos' -LOW_ENTROPY_MSG = 'should be at least 8 characters long;' -WEAK_PASSWORD_MSG= 'The password complexity is too low - @MSG@' - +DEFAULT_PASSWORD: str = 'vyos' +LOW_ENTROPY_MSG: str = 'should be at least 8 characters long;' +WEAK_PASSWORD_MSG: str = 'The password complexity is too low - @MSG@' +CRACKLIB_ERROR_MSG: str = 'A following error occurred: @MSG@\n' \ + 'Possibly the cracklib database is corrupted or is missing. ' \ + 'Try reinstalling the python3-cracklib package.' class EPasswdStrength(StrEnum): WEAK = 'Weak' DECENT = 'Decent' STRONG = 'Strong' + ERROR = 'Cracklib Error' def calculate_entropy(charset: str, passwd: str) -> float: @@ -63,6 +66,9 @@ def evaluate_strength(passwd: str) -> dict[str, str]: msg = f'should not be {e}' result.update(strength=EPasswdStrength.WEAK) result.update(error=WEAK_PASSWORD_MSG.replace('@MSG@', msg)) + except Exception as e: + result.update(strength=EPasswdStrength.ERROR) + result.update(error=CRACKLIB_ERROR_MSG.replace('@MSG@', str(e))) else: # Now check the password's entropy # Cast to Decimal for more precise rounding diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py index 1e6061ecf..3fed6d273 100755 --- a/src/conf_mode/system_login.py +++ b/src/conf_mode/system_login.py @@ -160,9 +160,10 @@ def verify(login): dict_object=user_config ) or None + failed_check_status = [EPasswdStrength.WEAK, EPasswdStrength.ERROR] if plaintext_password is not None: result = evaluate_strength(plaintext_password) - if result['strength'] == EPasswdStrength.WEAK: + if result['strength'] in failed_check_status: Warning(result['error']) for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index c6e9c7f6f..82756daec 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -783,6 +783,7 @@ def install_image() -> None: break print(MSG_WARN_IMAGE_NAME_WRONG) + failed_check_status = [EPasswdStrength.WEAK, EPasswdStrength.ERROR] # ask for password while True: user_password: str = ask_input(MSG_INPUT_PASSWORD, no_echo=True, @@ -792,7 +793,7 @@ def install_image() -> None: Warning(MSG_WARN_CHANGE_PASSWORD) else: result = evaluate_strength(user_password) - if result['strength'] == EPasswdStrength.WEAK: + if result['strength'] in failed_check_status: Warning(result['error']) confirm: str = ask_input(MSG_INPUT_PASSWORD_CONFIRM, no_echo=True, -- cgit v1.2.3 From 795154d9009b669f8858ed983c6b7486aaee1125 Mon Sep 17 00:00:00 2001 From: sskaje Date: Fri, 28 Mar 2025 15:47:24 +0800 Subject: geoip: T5636: Add geoip for policy route/route6 --- data/templates/firewall/nftables-geoip-update.j2 | 33 ++++++++++++ data/templates/firewall/nftables-policy.j2 | 17 ++++++ interface-definitions/policy_route.xml.in | 4 ++ python/vyos/firewall.py | 67 +++++++++++++++++------- src/conf_mode/firewall.py | 2 +- src/conf_mode/policy_route.py | 47 +++++++++++++++++ src/helpers/geoip-update.py | 17 +++--- 7 files changed, 159 insertions(+), 28 deletions(-) (limited to 'python') diff --git a/data/templates/firewall/nftables-geoip-update.j2 b/data/templates/firewall/nftables-geoip-update.j2 index 832ccc3e9..d8f80d1f5 100644 --- a/data/templates/firewall/nftables-geoip-update.j2 +++ b/data/templates/firewall/nftables-geoip-update.j2 @@ -31,3 +31,36 @@ table ip6 vyos_filter { {% endfor %} } {% endif %} + + +{% if ipv4_sets_policy is vyos_defined %} +{% for setname, ip_list in ipv4_sets_policy.items() %} +flush set ip vyos_mangle {{ setname }} +{% endfor %} + +table ip vyos_mangle { +{% for setname, ip_list in ipv4_sets_policy.items() %} + set {{ setname }} { + type ipv4_addr + flags interval + elements = { {{ ','.join(ip_list) }} } + } +{% endfor %} +} +{% endif %} + +{% if ipv6_sets_policy is vyos_defined %} +{% for setname, ip_list in ipv6_sets_policy.items() %} +flush set ip6 vyos_mangle {{ setname }} +{% endfor %} + +table ip6 vyos_mangle { +{% for setname, ip_list in ipv6_sets_policy.items() %} + set {{ setname }} { + type ipv6_addr + flags interval + elements = { {{ ','.join(ip_list) }} } + } +{% endfor %} +} +{% endif %} diff --git a/data/templates/firewall/nftables-policy.j2 b/data/templates/firewall/nftables-policy.j2 index 9e28899b0..00d0e8a62 100644 --- a/data/templates/firewall/nftables-policy.j2 +++ b/data/templates/firewall/nftables-policy.j2 @@ -33,6 +33,15 @@ table ip vyos_mangle { {% endif %} } {% endfor %} + +{% if geoip_updated.name is vyos_defined %} +{% for setname in geoip_updated.name %} + set {{ setname }} { + type ipv4_addr + flags interval + } +{% endfor %} +{% endif %} {% endif %} {{ group_tmpl.groups(firewall_group, False, True) }} @@ -65,6 +74,14 @@ table ip6 vyos_mangle { {% endif %} } {% endfor %} +{% if geoip_updated.ipv6_name is vyos_defined %} +{% for setname in geoip_updated.ipv6_name %} + set {{ setname }} { + type ipv6_addr + flags interval + } +{% endfor %} +{% endif %} {% endif %} {{ group_tmpl.groups(firewall_group, True, True) }} diff --git a/interface-definitions/policy_route.xml.in b/interface-definitions/policy_route.xml.in index 9cc22540b..48f728923 100644 --- a/interface-definitions/policy_route.xml.in +++ b/interface-definitions/policy_route.xml.in @@ -35,6 +35,7 @@ #include #include #include + #include @@ -45,6 +46,7 @@ #include #include #include + #include #include @@ -90,6 +92,7 @@ #include #include #include + #include @@ -100,6 +103,7 @@ #include #include #include + #include #include diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 9f01f8be1..9c320c82d 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -233,6 +233,9 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): hook_name = 'prerouting' if hook == 'NAM': hook_name = f'name' + # for policy + if hook == 'route' or hook == 'route6': + hook_name = hook output.append(f'{ip_name} {prefix}addr {operator} @GEOIP_CC{def_suffix}_{hook_name}_{fw_name}_{rule_id}') if 'mac_address' in side_conf: @@ -738,14 +741,14 @@ class GeoIPLock(object): def __exit__(self, exc_type, exc_value, tb): os.unlink(self.file) -def geoip_update(firewall, force=False): +def geoip_update(firewall=None, policy=None, force=False): with GeoIPLock(geoip_lock_file) as lock: if not lock: print("Script is already running") return False - if not firewall: - print("Firewall is not configured") + if not firewall and not policy: + print("Firewall and policy are not configured") return True if not os.path.exists(geoip_database): @@ -760,23 +763,41 @@ def geoip_update(firewall, force=False): ipv4_sets = {} ipv6_sets = {} + ipv4_codes_policy = {} + ipv6_codes_policy = {} + + ipv4_sets_policy = {} + ipv6_sets_policy = {} + # Map country codes to set names - for codes, path in dict_search_recursive(firewall, 'country_code'): - set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' - if ( path[0] == 'ipv4'): - for code in codes: - ipv4_codes.setdefault(code, []).append(set_name) - elif ( path[0] == 'ipv6' ): - set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' - for code in codes: - ipv6_codes.setdefault(code, []).append(set_name) - - if not ipv4_codes and not ipv6_codes: + if firewall: + for codes, path in dict_search_recursive(firewall, 'country_code'): + set_name = f'GEOIP_CC_{path[1]}_{path[2]}_{path[4]}' + if ( path[0] == 'ipv4'): + for code in codes: + ipv4_codes.setdefault(code, []).append(set_name) + elif ( path[0] == 'ipv6' ): + set_name = f'GEOIP_CC6_{path[1]}_{path[2]}_{path[4]}' + for code in codes: + ipv6_codes.setdefault(code, []).append(set_name) + + if policy: + for codes, path in dict_search_recursive(policy, 'country_code'): + set_name = f'GEOIP_CC_{path[0]}_{path[1]}_{path[3]}' + if ( path[0] == 'route'): + for code in codes: + ipv4_codes_policy.setdefault(code, []).append(set_name) + elif ( path[0] == 'route6' ): + set_name = f'GEOIP_CC6_{path[0]}_{path[1]}_{path[3]}' + for code in codes: + ipv6_codes_policy.setdefault(code, []).append(set_name) + + if not ipv4_codes and not ipv6_codes and not ipv4_codes_policy and not ipv6_codes_policy: if force: - print("GeoIP not in use by firewall") + print("GeoIP not in use by firewall and policy") return True - geoip_data = geoip_load_data([*ipv4_codes, *ipv6_codes]) + geoip_data = geoip_load_data([*ipv4_codes, *ipv6_codes, *ipv4_codes_policy, *ipv6_codes_policy]) # Iterate IP blocks to assign to sets for start, end, code in geoip_data: @@ -785,19 +806,29 @@ def geoip_update(firewall, force=False): ip_range = f'{start}-{end}' if start != end else start for setname in ipv4_codes[code]: ipv4_sets.setdefault(setname, []).append(ip_range) + if code in ipv4_codes_policy and ipv4: + ip_range = f'{start}-{end}' if start != end else start + for setname in ipv4_codes_policy[code]: + ipv4_sets_policy.setdefault(setname, []).append(ip_range) if code in ipv6_codes and not ipv4: ip_range = f'{start}-{end}' if start != end else start for setname in ipv6_codes[code]: ipv6_sets.setdefault(setname, []).append(ip_range) + if code in ipv6_codes_policy and not ipv4: + ip_range = f'{start}-{end}' if start != end else start + for setname in ipv6_codes_policy[code]: + ipv6_sets_policy.setdefault(setname, []).append(ip_range) render(nftables_geoip_conf, 'firewall/nftables-geoip-update.j2', { 'ipv4_sets': ipv4_sets, - 'ipv6_sets': ipv6_sets + 'ipv6_sets': ipv6_sets, + 'ipv4_sets_policy': ipv4_sets_policy, + 'ipv6_sets_policy': ipv6_sets_policy, }) result = run(f'nft --file {nftables_geoip_conf}') if result != 0: - print('Error: GeoIP failed to update firewall') + print('Error: GeoIP failed to update firewall/policy') return False return True diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index cebe57092..72f2d39f4 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -627,7 +627,7 @@ def apply(firewall): # Call helper script to Update set contents if 'name' in firewall['geoip_updated'] or 'ipv6_name' in firewall['geoip_updated']: print('Updating GeoIP. Please wait...') - geoip_update(firewall) + geoip_update(firewall=firewall) return None diff --git a/src/conf_mode/policy_route.py b/src/conf_mode/policy_route.py index 223175b8a..521764896 100755 --- a/src/conf_mode/policy_route.py +++ b/src/conf_mode/policy_route.py @@ -21,13 +21,16 @@ from sys import exit from vyos.base import Warning from vyos.config import Config +from vyos.configdiff import get_config_diff, Diff from vyos.template import render from vyos.utils.dict import dict_search_args +from vyos.utils.dict import dict_search_recursive from vyos.utils.process import cmd from vyos.utils.process import run from vyos.utils.network import get_vrf_tableid from vyos.defaults import rt_global_table from vyos.defaults import rt_global_vrf +from vyos.firewall import geoip_update from vyos import ConfigError from vyos import airbag airbag.enable() @@ -43,6 +46,43 @@ valid_groups = [ 'interface_group' ] +def geoip_updated(conf, policy): + diff = get_config_diff(conf) + node_diff = diff.get_child_nodes_diff(['policy'], expand_nodes=Diff.DELETE, recursive=True) + + out = { + 'name': [], + 'ipv6_name': [], + 'deleted_name': [], + 'deleted_ipv6_name': [] + } + updated = False + + for key, path in dict_search_recursive(policy, 'geoip'): + set_name = f'GEOIP_CC_{path[0]}_{path[1]}_{path[3]}' + if (path[0] == 'route'): + out['name'].append(set_name) + elif (path[0] == 'route6'): + set_name = f'GEOIP_CC6_{path[0]}_{path[1]}_{path[3]}' + out['ipv6_name'].append(set_name) + + updated = True + + if 'delete' in node_diff: + for key, path in dict_search_recursive(node_diff['delete'], 'geoip'): + set_name = f'GEOIP_CC_{path[0]}_{path[1]}_{path[3]}' + if (path[0] == 'route'): + out['deleted_name'].append(set_name) + elif (path[0] == 'route6'): + set_name = f'GEOIP_CC6_{path[0]}_{path[1]}_{path[3]}' + out['deleted_ipv6_name'].append(set_name) + updated = True + + if updated: + return out + + return False + def get_config(config=None): if config: conf = config @@ -60,6 +100,7 @@ def get_config(config=None): if 'dynamic_group' in policy['firewall_group']: del policy['firewall_group']['dynamic_group'] + policy['geoip_updated'] = geoip_updated(conf, policy) return policy def verify_rule(policy, name, rule_conf, ipv6, rule_id): @@ -203,6 +244,12 @@ def apply(policy): apply_table_marks(policy) + if policy['geoip_updated']: + # Call helper script to Update set contents + if 'name' in policy['geoip_updated'] or 'ipv6_name' in policy['geoip_updated']: + print('Updating GeoIP. Please wait...') + geoip_update(policy=policy) + return None if __name__ == '__main__': diff --git a/src/helpers/geoip-update.py b/src/helpers/geoip-update.py index 34accf2cc..061c95401 100755 --- a/src/helpers/geoip-update.py +++ b/src/helpers/geoip-update.py @@ -25,20 +25,19 @@ def get_config(config=None): conf = config else: conf = ConfigTreeQuery() - base = ['firewall'] - if not conf.exists(base): - return None - - return conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) + return ( + conf.get_config_dict(['firewall'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) if conf.exists(['firewall']) else None, + conf.get_config_dict(['policy'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) if conf.exists(['policy']) else None, + ) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument("--force", help="Force update", action="store_true") args = parser.parse_args() - firewall = get_config() - - if not geoip_update(firewall, force=args.force): + firewall, policy = get_config() + if not geoip_update(firewall=firewall, policy=policy, force=args.force): sys.exit(1) -- cgit v1.2.3 From 07a3b0f5ae87a2ab400390c9fa0ca632d7815e15 Mon Sep 17 00:00:00 2001 From: "Nataliia S." <81954790+natali-rs1985@users.noreply.github.com> Date: Tue, 1 Apr 2025 17:30:12 +0300 Subject: T7283: VPP add static NAT support (#24) * T7283: VPP add static NAT support Add static mapping NAT implementation ``` set vpp nat44 static rule 10 outbound-interface 'eth0' set vpp nat44 static rule 10 inbound-interface 'eth1' set vpp nat44 static rule 10 destination address 192.168.122.10 # optional, if not set outbound interface ip address is used set vpp nat44 static rule 10 destination port 6545 # optional set vpp nat44 static rule 10 protocol tcp|udp|icmp|all # optional, defaults to "all" set vpp nat44 static rule 10 translation address 100.64.0.10 set vpp nat44 static rule 10 translation port 64010 # optional ``` * Improve help strings (Daniil Baturin) --------- Co-authored-by: Daniil Baturin --- data/config-mode-dependencies/vyos-vpp.json | 2 + interface-definitions/vpp.xml.in | 105 ++++++++++++++ op-mode-definitions/show_vpp_nat44.xml.in | 36 +++++ python/vyos/vpp/control_vpp.py | 15 ++ python/vyos/vpp/nat/nat44.py | 81 +++++++++-- src/conf_mode/vpp.py | 2 + src/conf_mode/vpp_nat_static.py | 209 ++++++++++++++++++++++++++++ src/op_mode/show_vpp_nat44.py | 183 ++++++++++++++++++++++++ 8 files changed, 625 insertions(+), 8 deletions(-) create mode 100644 op-mode-definitions/show_vpp_nat44.xml.in create mode 100644 src/conf_mode/vpp_nat_static.py create mode 100644 src/op_mode/show_vpp_nat44.py (limited to 'python') diff --git a/data/config-mode-dependencies/vyos-vpp.json b/data/config-mode-dependencies/vyos-vpp.json index 50e465e80..307e763b9 100644 --- a/data/config-mode-dependencies/vyos-vpp.json +++ b/data/config-mode-dependencies/vyos-vpp.json @@ -11,6 +11,7 @@ "vpp_interfaces_vxlan": ["vpp_interfaces_vxlan"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat_static": ["vpp_nat_static"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_bonding": { @@ -49,3 +50,4 @@ "vpp_kernel_interface": ["vpp_kernel-interfaces"] } } + diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 94d87a8dd..66a38f546 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -905,6 +905,111 @@ + + + Static NAT settings + 321 + + + + + Rule number for NAT + + u32 + Number of NAT rule + + + + + + NAT external parameters + + + + + IP address + + ipv4 + IPv4 address + + + + + + + #include + + + + + Protocol to NAT + + tcp udp icmp all + + + all + All protocols (TCP, UDP, and ICMP) + + + icmp + Internet control message protocol + + + tcp + Transmission control protocol + + + udp + user datagram protocol + + + (tcp|udp|icmp|all) + + + all + + + + Outbound interface of NAT traffic + + any + + + + + + + Inbound interface of NAT traffic + + any + + + + + + + NAT internal parameters + + + + + IP address + + ipv4 + IPv4 address + + + + + + + #include + + + + + + diff --git a/op-mode-definitions/show_vpp_nat44.xml.in b/op-mode-definitions/show_vpp_nat44.xml.in new file mode 100644 index 000000000..f2e77267a --- /dev/null +++ b/op-mode-definitions/show_vpp_nat44.xml.in @@ -0,0 +1,36 @@ + + + + + + + + + Show VPP NAT44 information + + + + + Show VPP NAT44 static mapping + + sudo ${vyos_op_scripts_dir}/show_vpp_nat44.py show_static + + + + Show VPP NAT44 sessions + + sudo ${vyos_op_scripts_dir}/show_vpp_nat44.py show_sessions + + + + Show VPP NAT44 summary + + sudo ${vyos_op_scripts_dir}/show_vpp_nat44.py show_summary + + + + + + + + diff --git a/python/vyos/vpp/control_vpp.py b/python/vyos/vpp/control_vpp.py index 2c81b2db2..9d45bbd91 100644 --- a/python/vyos/vpp/control_vpp.py +++ b/python/vyos/vpp/control_vpp.py @@ -160,6 +160,21 @@ class VPPControl: return iface.sw_if_index return None + @_Decorators.api_call + def get_interface_name(self, index: int) -> str | None: + """Find interface name by interface index in VPP + + Args: + index (int): interface index inside VPP + + Returns: + str | None: Interface name or None (if was not found) + """ + for iface in self.__vpp_api_client.api.sw_interface_dump(): + if iface.sw_if_index == index: + return iface.interface_name + return None + @_Decorators.check_retval @_Decorators.api_call def lcp_pair_add( diff --git a/python/vyos/vpp/nat/nat44.py b/python/vyos/vpp/nat/nat44.py index 47d0ced1d..985e5a583 100644 --- a/python/vyos/vpp/nat/nat44.py +++ b/python/vyos/vpp/nat/nat44.py @@ -86,38 +86,34 @@ class Nat44: is_add=False, ) - # needs to check def add_nat44_interface_inside(self): """Add NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=1, + flags=0x20, sw_if_index=self.vpp.get_sw_if_index(self.interface_in), is_add=True, ) - # needs to check def delete_nat44_interface_inside(self): """Delete NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=1, + flags=0x20, sw_if_index=self.vpp.get_sw_if_index(self.interface_in), is_add=False, ) - # needs to check def add_nat44_interface_outside(self): """Add NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=0, + flags=0x10, sw_if_index=self.vpp.get_sw_if_index(self.interface_out), is_add=True, ) - # needs to check def delete_nat44_interface_outside(self): """Delete NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=0, + flags=0x10, sw_if_index=self.vpp.get_sw_if_index(self.interface_out), is_add=False, ) @@ -149,3 +145,72 @@ class Nat44: def enable_ipfix(self): """Enable NAT44 IPFIX logging""" self.vpp.api.nat44_ei_ipfix_enable_disable(enable=True) + + +class Nat44Static(Nat44): + def __init__(self): + self.vpp = VPPControl() + + def add_inbound_interface(self, interface_in): + self.interface_in = interface_in + self.add_nat44_interface_inside() + + def delete_inbound_interface(self, interface_in): + self.interface_in = interface_in + self.delete_nat44_interface_inside() + + def add_outbound_interface(self, interface_out): + self.interface_out = interface_out + self.add_nat44_interface_outside() + + def delete_outbound_interface(self, interface_out): + self.interface_out = interface_out + self.delete_nat44_interface_outside() + + def add_nat44_static_mapping( + self, + iface_out, + local_ip, + external_ip, + local_port, + external_port, + protocol, + use_iface, + ): + """Add NAT44 static mapping""" + self.vpp.api.nat44_add_del_static_mapping_v2( + local_ip_address=local_ip, + external_ip_address=external_ip, + protocol=protocol, + local_port=local_port, + external_port=external_port, + flags=0x08 if not (protocol or local_port) else 0x00, + external_sw_if_index=( + self.vpp.get_sw_if_index(iface_out) if use_iface else 0xFFFFFFFF + ), + is_add=True, + ) + + def delete_nat44_static_mapping( + self, + iface_out, + local_ip, + external_ip, + local_port, + external_port, + protocol, + use_iface, + ): + """Delete NAT44 static mapping""" + self.vpp.api.nat44_add_del_static_mapping_v2( + local_ip_address=local_ip, + external_ip_address=external_ip, + protocol=protocol, + local_port=local_port, + external_port=external_port, + flags=0x08 if not (protocol or local_port) else 0x00, + external_sw_if_index=( + self.vpp.get_sw_if_index(iface_out) if use_iface else 0xFFFFFFFF + ), + is_add=False, + ) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index 1f4c6c0f0..ca7d19160 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -138,6 +138,8 @@ def get_config(config=None): # NAT dependency if conf.exists(['vpp', 'nat44', 'source']): set_dependents('vpp_nat_source', conf) + if conf.exists(['vpp', 'nat44', 'static']): + set_dependents('vpp_nat_static', conf) if not conf.exists(base): return { diff --git a/src/conf_mode/vpp_nat_static.py b/src/conf_mode/vpp_nat_static.py new file mode 100644 index 000000000..50384a00b --- /dev/null +++ b/src/conf_mode/vpp_nat_static.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.configdiff import Diff +from vyos.configdiff import get_config_diff +from vyos.configdict import node_changed +from vyos.config import Config +from vyos import ConfigError +from vyos.vpp.nat.nat44 import Nat44Static + + +protocol_map = { + 'all': 0, + 'icmp': 1, + 'tcp': 6, + 'udp': 17, +} + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat44', 'static'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dictionary per interface delete + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + in_iface_add = [] + in_iface_del = [] + out_iface_add = [] + out_iface_del = [] + + changed_rules = node_changed( + conf, + base + ['rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + diff = get_config_diff(conf) + + for rule in changed_rules: + base_rule = base + ['rule', rule] + tmp = node_changed( + conf, + base_rule, + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + if 'inbound_interface' in tmp: + new, old = diff.get_value_diff(base_rule + ['inbound-interface']) + in_iface_add.append(new) if new else None + in_iface_del.append(old) if old else None + if 'outbound_interface' in tmp: + new, old = diff.get_value_diff(base_rule + ['outbound-interface']) + out_iface_add.append(new) if new else None + out_iface_del.append(old) if old else None + + final_in_iface_add = list(set(in_iface_add) - set(in_iface_del)) + final_in_iface_del = list(set(in_iface_del) - set(in_iface_add)) + final_out_iface_add = list(set(out_iface_add) - set(out_iface_del)) + final_out_iface_del = list(set(out_iface_del) - set(out_iface_add)) + + config.update( + { + 'in_iface_add': final_in_iface_add, + 'in_iface_del': final_in_iface_del, + 'out_iface_add': final_out_iface_add, + 'out_iface_del': final_out_iface_del, + 'changed_rules': changed_rules, + } + ) + + if effective_config: + config.update({'effective': effective_config}) + + return config + + +def verify(config): + if 'remove' in config: + return None + + required_keys = {'inbound_interface', 'outbound_interface'} + for rule, rule_config in config['rule'].items(): + missing_keys = required_keys - rule_config.keys() + if missing_keys: + raise ConfigError( + f"Required options are missing: {', '.join(missing_keys).replace('_', '-')} in rule {rule}" + ) + + if not rule_config.get('translation', {}).get('address'): + raise ConfigError(f'Translation requires address in rule {rule}') + + has_dest_port = 'port' in rule_config.get('destination', {}) + has_trans_port = 'port' in rule_config.get('translation', {}) + + if not has_trans_port == has_dest_port: + raise ConfigError( + 'Source and destination ports must either both be specified, or neither must be specified' + ) + + +def generate(config): + pass + + +def apply(config): + n = Nat44Static() + + # Delete inbound interfaces + for interface in config['in_iface_del']: + n.delete_inbound_interface(interface) + # Delete outbound interfaces + for interface in config['out_iface_del']: + n.delete_outbound_interface(interface) + # Delete NAT static mapping rules + for rule in config['changed_rules']: + if rule in config.get('effective', {}).get('rule', {}): + rule_config = config['effective']['rule'][rule] + n.delete_nat44_static_mapping( + iface_out=rule_config.get('outbound_interface'), + local_ip=rule_config.get('translation').get('address'), + external_ip=rule_config.get('destination', {}).get('address', ''), + local_port=int(rule_config.get('translation', {}).get('port', 0)), + external_port=int(rule_config.get('destination', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + use_iface=( + True + if not rule_config.get('destination', {}).get('address') + else False + ), + ) + + if 'remove' in config: + return None + + # Add NAT44 static mapping rules + n.enable_nat44_ed() + for interface in config['in_iface_add']: + n.add_inbound_interface(interface) + for interface in config['out_iface_add']: + n.add_outbound_interface(interface) + for rule in config['changed_rules']: + if rule in config.get('rule', {}): + rule_config = config['rule'][rule] + n.add_nat44_static_mapping( + iface_out=rule_config.get('outbound_interface'), + local_ip=rule_config.get('translation').get('address'), + external_ip=rule_config.get('destination', {}).get('address', ''), + local_port=int(rule_config.get('translation', {}).get('port', 0)), + external_port=int(rule_config.get('destination', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + use_iface=( + True + if not rule_config.get('destination', {}).get('address') + else False + ), + ) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/op_mode/show_vpp_nat44.py b/src/op_mode/show_vpp_nat44.py new file mode 100644 index 000000000..1aae8164e --- /dev/null +++ b/src/op_mode/show_vpp_nat44.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +protocol_map = { + 0: 'all', + 1: 'icmp', + 6: 'tcp', + 17: 'udp', +} + + +def _verify(func): + """Decorator checks if config for VPP NAT44 exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + base = 'vpp nat44' + if not config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem(f'{base} is not configured') + + return func(*args, **kwargs) + + return _wrapper + + +def _get_raw_output_sessions(vpp_api): + users: list[dict] = vpp_api.nat44_user_dump() + sessions_list: list[dict] = [] + for user in users: + ip_address = str(user._asdict().get('ip_address')) + user_sessions_dump = vpp_api.nat44_user_session_v3_dump(ip_address=ip_address) + user_sessions = [ + json.loads(json.dumps(session._asdict(), default=str)) + for session in user_sessions_dump + ] + sessions_list.extend(user_sessions) + return sorted(sessions_list, key=lambda x: x["inside_ip_address"]) + + +def _get_formatted_output_sessions(sessions_list): + print('NAT44 ED sessions:') + print(f'--------------- {len(sessions_list)} sessions ---------------') + for session in sessions_list: + in_ip_addr = session.get('inside_ip_address') + in_port = session.get('inside_port') + out_ip_addr = session.get('outside_ip_address') + out_port = session.get('outside_port') + protocol = protocol_map[session.get('protocol')].upper() + last_heard = session.get('last_heard') + time_since_last_heard = session.get('time_since_last_heard') + total_bytes = session.get('total_bytes') + total_pkts = session.get('total_pkts') + ext_host_address = session.get('ext_host_address') + ext_host_port = session.get('ext_host_port') + is_timed_out = session.get('is_timed_out') + + print(f' i2o {in_ip_addr} proto {protocol} port {in_port}') + print(f' o2i {out_ip_addr} proto {protocol} port {out_port}') + print(f' external host {ext_host_address}:{ext_host_port}') + print( + f' i2o flow: match: saddr {in_ip_addr} sport {in_port} daddr {ext_host_address} dport {ext_host_port} proto {protocol} rewrite: saddr {out_ip_addr}' + + ( + f' sport {out_port}' + if protocol != 'ICMP' + else f' daddr {ext_host_address} icmp-id {ext_host_port}' + ) + ) + print( + f' o2i flow: match: saddr {ext_host_address} sport {ext_host_port} daddr {out_ip_addr} dport {out_port} proto {protocol} rewrite: ' + + ( + f'daddr {in_ip_addr} dport {in_port}' + if protocol != 'ICMP' + else f' saddr {ext_host_address} daddr {in_ip_addr} icmp-id {ext_host_port}' + ) + ) + print(f' last heard {last_heard}') + print(f' time since last heard {time_since_last_heard}') + print(f' total packets {total_pkts}, total bytes {total_bytes}') + if is_timed_out: + print(' session timed out') + print('\n') + + +def _get_raw_output_static_rules(vpp_api): + nat_static_dump = vpp_api.nat44_static_mapping_dump() + rules_list = [ + json.loads(json.dumps(rule._asdict(), default=str)) for rule in nat_static_dump + ] + return rules_list + + +def _get_formatted_output_rules(vpp, rules_list): + data_entries = [] + for rule in rules_list: + dest_address = rule.get('external_ip_address') + dest_port = rule.get('external_port') or '' + trans_address = rule.get('local_ip_address') + trans_port = rule.get('local_port') or '' + protocol = protocol_map[rule.get('protocol', 0)] + dest_sh_if_index = rule.get('external_sw_if_index') + + vpp_if_name = vpp.get_interface_name(dest_sh_if_index) + if vpp_if_name: + dest_address = vpp_if_name + + values = [dest_address, dest_port, trans_address, trans_port, protocol] + data_entries.append(values) + headers = [ + 'Des_address/interface', + 'Dest_port', + 'Trans_address', + 'Trans_port', + 'Protocol', + ] + out = sorted(data_entries, key=lambda x: x[2]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def show_sessions(raw: bool): + vpp = VPPControl() + sessions_list: list[dict] = _get_raw_output_sessions(vpp.api) + + if raw: + return sessions_list + + else: + return _get_formatted_output_sessions(sessions_list) + + +@_verify +def show_summary(raw: bool): + vpp = VPPControl() + return vpp.cli_cmd('show nat44 summary').reply + + +@_verify +def show_static(raw: bool): + vpp = VPPControl() + rules_list: list[dict] = _get_raw_output_static_rules(vpp.api) + + if raw: + return rules_list + + else: + return _get_formatted_output_rules(vpp, rules_list) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) -- cgit v1.2.3 From 09cb7801d790597e5fac0c19f872977891778323 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 24 Mar 2025 09:53:08 -0500 Subject: T7272: drop test functions --- python/vyos/configtree.py | 29 ----------------------------- 1 file changed, 29 deletions(-) (limited to 'python') diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index dade852c7..ff40fbad0 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -523,35 +523,6 @@ def mask_inclusive(left, right, libpath=LIBPATH): return tree -def show_commit_data(active_tree, proposed_tree, libpath=LIBPATH): - if not ( - isinstance(active_tree, ConfigTree) and isinstance(proposed_tree, ConfigTree) - ): - raise TypeError('Arguments must be instances of ConfigTree') - - __lib = cdll.LoadLibrary(libpath) - __show_commit_data = __lib.show_commit_data - __show_commit_data.argtypes = [c_void_p, c_void_p] - __show_commit_data.restype = c_char_p - - res = __show_commit_data(active_tree._get_config(), proposed_tree._get_config()) - - return res.decode() - - -def test_commit(active_tree, proposed_tree, libpath=LIBPATH): - if not ( - isinstance(active_tree, ConfigTree) and isinstance(proposed_tree, ConfigTree) - ): - raise TypeError('Arguments must be instances of ConfigTree') - - __lib = cdll.LoadLibrary(libpath) - __test_commit = __lib.test_commit - __test_commit.argtypes = [c_void_p, c_void_p] - - __test_commit(active_tree._get_config(), proposed_tree._get_config()) - - def reference_tree_to_json(from_dir, to_file, internal_cache='', libpath=LIBPATH): try: __lib = cdll.LoadLibrary(libpath) -- cgit v1.2.3 From 51dadbaffc515f455a82221f47c6750ad9eeccc1 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 31 Mar 2025 00:29:47 -0500 Subject: T7292: generate vyconfd client library dataclasses --- .gitignore | 2 + python/setup.py | 11 ++ python/vyos/proto/generate_dataclass.py | 178 ++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100755 python/vyos/proto/generate_dataclass.py (limited to 'python') diff --git a/.gitignore b/.gitignore index 27ed8000f..839d2afff 100644 --- a/.gitignore +++ b/.gitignore @@ -153,6 +153,8 @@ data/configd-include.json # autogenerated vyos-commitd protobuf files python/vyos/proto/*pb2.py +python/vyos/proto/*.desc +python/vyos/proto/vyconf_proto.py # We do not use pip Pipfile diff --git a/python/setup.py b/python/setup.py index 96dc211f7..571b956ee 100644 --- a/python/setup.py +++ b/python/setup.py @@ -7,6 +7,9 @@ from setuptools.command.build_py import build_py sys.path.append('./vyos') from defaults import directories +def desc_out(f): + return os.path.splitext(f)[0] + '.desc' + def packages(directory): return [ _[0].replace('/','.') @@ -37,9 +40,17 @@ class GenerateProto(build_py): 'protoc', '--python_out=vyos/proto', f'--proto_path={self.proto_path}/', + f'--descriptor_set_out=vyos/proto/{desc_out(proto_file)}', proto_file, ] ) + subprocess.check_call( + [ + 'vyos/proto/generate_dataclass.py', + 'vyos/proto/vyconf.desc', + '--out-dir=vyos/proto', + ] + ) build_py.run(self) diff --git a/python/vyos/proto/generate_dataclass.py b/python/vyos/proto/generate_dataclass.py new file mode 100755 index 000000000..c6296c568 --- /dev/null +++ b/python/vyos/proto/generate_dataclass.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# +import argparse +import os + +from google.protobuf.descriptor_pb2 import FileDescriptorSet # pylint: disable=no-name-in-module +from google.protobuf.descriptor_pb2 import FieldDescriptorProto # pylint: disable=no-name-in-module +from humps import decamelize + +HEADER = """\ +from enum import IntEnum +from dataclasses import dataclass +from dataclasses import field +""" + + +def normalize(s: str) -> str: + """Decamelize and avoid syntactic collision""" + t = decamelize(s) + return t + '_' if t in ['from'] else t + + +def generate_dataclass(descriptor_proto): + class_name = descriptor_proto.name + fields = [] + for field_p in descriptor_proto.field: + field_name = field_p.name + field_type, field_default = get_type(field_p.type, field_p.type_name) + match field_p.label: + case FieldDescriptorProto.LABEL_REPEATED: + field_type = f'list[{field_type}] = field(default_factory=list)' + case FieldDescriptorProto.LABEL_OPTIONAL: + field_type = f'{field_type} = None' + case _: + field_type = f'{field_type} = {field_default}' + + fields.append(f' {field_name}: {field_type}') + + code = f""" +@dataclass +class {class_name}: +{chr(10).join(fields) if fields else ' pass'} +""" + + return code + + +def generate_request(descriptor_proto): + class_name = descriptor_proto.name + fields = [] + f_vars = [] + for field_p in descriptor_proto.field: + field_name = field_p.name + field_type, field_default = get_type(field_p.type, field_p.type_name) + match field_p.label: + case FieldDescriptorProto.LABEL_REPEATED: + field_type = f'list[{field_type}] = []' + case FieldDescriptorProto.LABEL_OPTIONAL: + field_type = f'{field_type} = None' + case _: + field_type = f'{field_type} = {field_default}' + + fields.append(f'{normalize(field_name)}: {field_type}') + f_vars.append(f'{normalize(field_name)}') + + fields.insert(0, 'token: str = None') + + code = f""" +def set_request_{decamelize(class_name)}({', '.join(fields)}): + reqi = {class_name} ({', '.join(f_vars)}) + req = Request({decamelize(class_name)}=reqi) + req_env = RequestEnvelope(token, req) + return req_env +""" + + return code + + +def generate_nested_dataclass(descriptor_proto): + out = '' + for nested_p in descriptor_proto.nested_type: + out = out + generate_dataclass(nested_p) + + return out + + +def generate_nested_request(descriptor_proto): + out = '' + for nested_p in descriptor_proto.nested_type: + out = out + generate_request(nested_p) + + return out + + +def generate_enum_dataclass(descriptor_proto): + code = '' + for enum_p in descriptor_proto.enum_type: + enums = [] + enum_name = enum_p.name + for enum_val in enum_p.value: + enums.append(f' {enum_val.name} = {enum_val.number}') + + code += f""" +class {enum_name}(IntEnum): +{chr(10).join(enums)} +""" + + return code + + +def get_type(field_type, type_name): + res = 'Any', None + match field_type: + case FieldDescriptorProto.TYPE_STRING: + res = 'str', '""' + case FieldDescriptorProto.TYPE_INT32 | FieldDescriptorProto.TYPE_INT64: + res = 'int', 0 + case FieldDescriptorProto.TYPE_FLOAT | FieldDescriptorProto.TYPE_DOUBLE: + res = 'float', 0.0 + case FieldDescriptorProto.TYPE_BOOL: + res = 'bool', False + case FieldDescriptorProto.TYPE_MESSAGE | FieldDescriptorProto.TYPE_ENUM: + res = type_name.split('.')[-1], None + case _: + pass + + return res + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('descriptor_file', help='protobuf .desc file') + parser.add_argument('--out-dir', help='directory to write generated file') + args = parser.parse_args() + desc_file = args.descriptor_file + out_dir = args.out_dir + + with open(desc_file, 'rb') as f: + descriptor_set_data = f.read() + + descriptor_set = FileDescriptorSet() + descriptor_set.ParseFromString(descriptor_set_data) + + for file_proto in descriptor_set.file: + f = f'{file_proto.name.replace(".", "_")}.py' + f = os.path.join(out_dir, f) + dataclass_code = '' + nested_code = '' + enum_code = '' + request_code = '' + with open(f, 'w') as f: + enum_code += generate_enum_dataclass(file_proto) + for message_proto in file_proto.message_type: + dataclass_code += generate_dataclass(message_proto) + nested_code += generate_nested_dataclass(message_proto) + enum_code += generate_enum_dataclass(message_proto) + request_code += generate_nested_request(message_proto) + + f.write(HEADER) + f.write(enum_code) + f.write(nested_code) + f.write(dataclass_code) + f.write(request_code) -- cgit v1.2.3 From f6460364fada3bfb1bd578bba3188a1a7c10eb94 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 31 Mar 2025 00:32:47 -0500 Subject: T7292: add vyconfd client library functions --- python/vyos/proto/vyconf_client.py | 87 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 python/vyos/proto/vyconf_client.py (limited to 'python') diff --git a/python/vyos/proto/vyconf_client.py b/python/vyos/proto/vyconf_client.py new file mode 100644 index 000000000..f34549309 --- /dev/null +++ b/python/vyos/proto/vyconf_client.py @@ -0,0 +1,87 @@ +# Copyright 2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +import socket +from dataclasses import asdict + +from vyos.proto import vyconf_proto +from vyos.proto import vyconf_pb2 + +from google.protobuf.json_format import MessageToDict +from google.protobuf.json_format import ParseDict + +socket_path = '/var/run/vyconfd.sock' + + +def send_socket(msg: bytearray) -> bytes: + data = bytes() + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(socket_path) + client.sendall(msg) + + data_length = client.recv(4) + if data_length: + length = int.from_bytes(data_length) + data = client.recv(length) + + client.close() + + return data + + +def request_to_msg(req: vyconf_proto.RequestEnvelope) -> vyconf_pb2.RequestEnvelope: + # pylint: disable=no-member + + msg = vyconf_pb2.RequestEnvelope() + msg = ParseDict(asdict(req), msg, ignore_unknown_fields=True) + return msg + + +def msg_to_response(msg: vyconf_pb2.Response) -> vyconf_proto.Response: + # pylint: disable=no-member + + d = MessageToDict(msg, preserving_proto_field_name=True) + + response = vyconf_proto.Response(**d) + return response + + +def write_request(req: vyconf_proto.RequestEnvelope) -> bytearray: + req_msg = request_to_msg(req) + encoded_data = req_msg.SerializeToString() + byte_size = req_msg.ByteSize() + length_bytes = byte_size.to_bytes(4) + arr = bytearray(length_bytes) + arr.extend(encoded_data) + + return arr + + +def read_response(msg: bytes) -> vyconf_proto.Response: + response_msg = vyconf_pb2.Response() # pylint: disable=no-member + response_msg.ParseFromString(msg) + response = msg_to_response(response_msg) + + return response + + +def send_request(name, *args, **kwargs): + func = getattr(vyconf_proto, f'set_request_{name}') + request_env = func(*args, **kwargs) + msg = write_request(request_env) + response_msg = send_socket(msg) + response = read_response(response_msg) + + return response -- cgit v1.2.3 From 330af6a781e9a83dcfd2721011a7acc1bf47caed Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 4 Apr 2025 21:29:59 +0200 Subject: frrender: T7273: always start from the configs root level Working on T7273 revealed that when committing the following CLI config "set interfaces vxlan vxlan0 parameters neighbor-suppress" the CLI level queried via conf.get_level() was at ['interfaces', 'vxlan']. This had the side effect that queries on the configuration like: conf.exists(['protocols', 'bgp']) returned False, as it would look accidently at the level: ['interfaces', 'vxlan', 'protocols', 'bgp'] This error was there from the beginning of the FRRender class implementation. --- python/vyos/frrender.py | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'python') diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index ba44978d1..8d469e3e2 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -60,6 +60,10 @@ def get_frrender_dict(conf, argv=None) -> dict: from vyos.configdict import get_dhcp_interfaces from vyos.configdict import get_pppoe_interfaces + # We need to re-set the CLI path to the root level, as this function uses + # conf.exists() with an absolute path form the CLI root + conf.set_level([]) + # Create an empty dictionary which will be filled down the code path and # returned to the caller dict = {} @@ -599,8 +603,10 @@ def get_frrender_dict(conf, argv=None) -> dict: dict.update({'vrf' : vrf}) if os.path.exists(frr_debug_enable): + print(f'---- get_frrender_dict({conf}) ----') import pprint pprint.pprint(dict) + print('-----------------------------------') return dict -- cgit v1.2.3 From 3677fe08b5eed6a7c2849e9c2f9f7d043e8e9279 Mon Sep 17 00:00:00 2001 From: Alex W Date: Sat, 5 Apr 2025 18:52:49 +0100 Subject: kea: T7324: Fix kea_get_domain_from_subnet_id returning incorrect value --- python/vyos/kea.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'python') diff --git a/python/vyos/kea.py b/python/vyos/kea.py index c7947af3e..9fc5dde3d 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -483,10 +483,10 @@ def kea_get_domain_from_subnet_id(config, inet, subnet_id): if option['name'] == 'domain-name': return option['data'] - # domain-name is not found in subnet, fallback to shared-network pool option - for option in network['option-data']: - if option['name'] == 'domain-name': - return option['data'] + # domain-name is not found in subnet, fallback to shared-network pool option + for option in network['option-data']: + if option['name'] == 'domain-name': + return option['data'] return None -- cgit v1.2.3 From 4d32bcddda72c1df944470f06b75908b2dc31667 Mon Sep 17 00:00:00 2001 From: "Nataliia S." <81954790+natali-rs1985@users.noreply.github.com> Date: Tue, 8 Apr 2025 17:15:42 +0300 Subject: T7315: Change CLI fot VPP NAT (#25) New CLI ``` set vpp nat44 static rule 10 outside-interface 'eth0' set vpp nat44 static rule 10 inside-interface 'eth1' set vpp nat44 static rule 10 external address 192.168.122.10 set vpp nat44 static rule 10 external port 6545 # optional set vpp nat44 static rule 10 protocol tcp|udp|icmp|all # optional, defaults to "all" set vpp nat44 static rule 10 local address 100.64.0.10 set vpp nat44 static rule 10 local port 64010 # optional ``` --- interface-definitions/vpp.xml.in | 25 ++++++++-------- python/vyos/vpp/nat/nat44.py | 32 ++++---------------- src/conf_mode/vpp_nat_source.py | 10 +++---- src/conf_mode/vpp_nat_static.py | 63 +++++++++++++++++----------------------- src/op_mode/show_vpp_nat44.py | 27 +++++++---------- 5 files changed, 61 insertions(+), 96 deletions(-) (limited to 'python') diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 66a38f546..263f9cb91 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -853,18 +853,18 @@ 320 - + - Inbound interface of NAT traffic + NAT inside interface any - + - Outbound interface of NAT traffic + NAT outside interface any @@ -920,7 +920,7 @@ - + NAT external parameters @@ -968,27 +968,25 @@ all - + - Outbound interface of NAT traffic + NAT outside interface - any - + - Inbound interface of NAT traffic + NAT inside interface - any - + - NAT internal parameters + NAT local parameters @@ -1006,6 +1004,7 @@ #include + #include diff --git a/python/vyos/vpp/nat/nat44.py b/python/vyos/vpp/nat/nat44.py index 985e5a583..97c4518e6 100644 --- a/python/vyos/vpp/nat/nat44.py +++ b/python/vyos/vpp/nat/nat44.py @@ -151,31 +151,24 @@ class Nat44Static(Nat44): def __init__(self): self.vpp = VPPControl() - def add_inbound_interface(self, interface_in): + def add_inside_interface(self, interface_in): self.interface_in = interface_in self.add_nat44_interface_inside() - def delete_inbound_interface(self, interface_in): + def delete_inside_interface(self, interface_in): self.interface_in = interface_in self.delete_nat44_interface_inside() - def add_outbound_interface(self, interface_out): + def add_outside_interface(self, interface_out): self.interface_out = interface_out self.add_nat44_interface_outside() - def delete_outbound_interface(self, interface_out): + def delete_outside_interface(self, interface_out): self.interface_out = interface_out self.delete_nat44_interface_outside() def add_nat44_static_mapping( - self, - iface_out, - local_ip, - external_ip, - local_port, - external_port, - protocol, - use_iface, + self, local_ip, external_ip, local_port, external_port, protocol ): """Add NAT44 static mapping""" self.vpp.api.nat44_add_del_static_mapping_v2( @@ -185,21 +178,11 @@ class Nat44Static(Nat44): local_port=local_port, external_port=external_port, flags=0x08 if not (protocol or local_port) else 0x00, - external_sw_if_index=( - self.vpp.get_sw_if_index(iface_out) if use_iface else 0xFFFFFFFF - ), is_add=True, ) def delete_nat44_static_mapping( - self, - iface_out, - local_ip, - external_ip, - local_port, - external_port, - protocol, - use_iface, + self, local_ip, external_ip, local_port, external_port, protocol ): """Delete NAT44 static mapping""" self.vpp.api.nat44_add_del_static_mapping_v2( @@ -209,8 +192,5 @@ class Nat44Static(Nat44): local_port=local_port, external_port=external_port, flags=0x08 if not (protocol or local_port) else 0x00, - external_sw_if_index=( - self.vpp.get_sw_if_index(iface_out) if use_iface else 0xFFFFFFFF - ), is_add=False, ) diff --git a/src/conf_mode/vpp_nat_source.py b/src/conf_mode/vpp_nat_source.py index 2e1884c3a..40b16a3c8 100644 --- a/src/conf_mode/vpp_nat_source.py +++ b/src/conf_mode/vpp_nat_source.py @@ -61,7 +61,7 @@ def verify(config): if 'remove' in config: return None - required_keys = {'inbound_interface', 'outbound_interface'} + required_keys = {'inside_interface', 'outside_interface'} if not all(key in config for key in required_keys): missing_keys = required_keys - set(config.keys()) raise ConfigError( @@ -83,8 +83,8 @@ def apply(config): # Delete NAT source if 'effective' in config: remove_config = config.get('effective') - interface_in = remove_config.get('inbound_interface') - interface_out = remove_config.get('outbound_interface') + interface_in = remove_config.get('inside_interface') + interface_out = remove_config.get('outside_interface') translation_address = remove_config.get('translation', {}).get('address') n = Nat44(interface_in, interface_out, translation_address) @@ -96,8 +96,8 @@ def apply(config): return None # Add NAT44 - interface_in = config.get('inbound_interface') - interface_out = config.get('outbound_interface') + interface_in = config.get('inside_interface') + interface_out = config.get('outside_interface') translation_address = config.get('translation', {}).get('address') n = Nat44(interface_in, interface_out, translation_address) diff --git a/src/conf_mode/vpp_nat_static.py b/src/conf_mode/vpp_nat_static.py index 50384a00b..b890ea150 100644 --- a/src/conf_mode/vpp_nat_static.py +++ b/src/conf_mode/vpp_nat_static.py @@ -86,12 +86,12 @@ def get_config(config=None) -> dict: expand_nodes=Diff.DELETE | Diff.ADD, ) - if 'inbound_interface' in tmp: - new, old = diff.get_value_diff(base_rule + ['inbound-interface']) + if 'inside_interface' in tmp: + new, old = diff.get_value_diff(base_rule + ['inside-interface']) in_iface_add.append(new) if new else None in_iface_del.append(old) if old else None - if 'outbound_interface' in tmp: - new, old = diff.get_value_diff(base_rule + ['outbound-interface']) + if 'outside_interface' in tmp: + new, old = diff.get_value_diff(base_rule + ['outside-interface']) out_iface_add.append(new) if new else None out_iface_del.append(old) if old else None @@ -120,7 +120,7 @@ def verify(config): if 'remove' in config: return None - required_keys = {'inbound_interface', 'outbound_interface'} + required_keys = {'inside_interface', 'outside_interface'} for rule, rule_config in config['rule'].items(): missing_keys = required_keys - rule_config.keys() if missing_keys: @@ -128,13 +128,16 @@ def verify(config): f"Required options are missing: {', '.join(missing_keys).replace('_', '-')} in rule {rule}" ) - if not rule_config.get('translation', {}).get('address'): - raise ConfigError(f'Translation requires address in rule {rule}') + if not rule_config.get('local', {}).get('address'): + raise ConfigError(f'Local settings require address in rule {rule}') - has_dest_port = 'port' in rule_config.get('destination', {}) - has_trans_port = 'port' in rule_config.get('translation', {}) + if not rule_config.get('external', {}).get('address'): + raise ConfigError(f'External settings require address in rule {rule}') - if not has_trans_port == has_dest_port: + has_local_port = 'port' in rule_config.get('local', {}) + has_external_port = 'port' in rule_config.get('external', {}) + + if not has_external_port == has_local_port: raise ConfigError( 'Source and destination ports must either both be specified, or neither must be specified' ) @@ -147,28 +150,22 @@ def generate(config): def apply(config): n = Nat44Static() - # Delete inbound interfaces + # Delete inside interfaces for interface in config['in_iface_del']: - n.delete_inbound_interface(interface) - # Delete outbound interfaces + n.delete_inside_interface(interface) + # Delete outside interfaces for interface in config['out_iface_del']: - n.delete_outbound_interface(interface) + n.delete_outside_interface(interface) # Delete NAT static mapping rules for rule in config['changed_rules']: if rule in config.get('effective', {}).get('rule', {}): rule_config = config['effective']['rule'][rule] n.delete_nat44_static_mapping( - iface_out=rule_config.get('outbound_interface'), - local_ip=rule_config.get('translation').get('address'), - external_ip=rule_config.get('destination', {}).get('address', ''), - local_port=int(rule_config.get('translation', {}).get('port', 0)), - external_port=int(rule_config.get('destination', {}).get('port', 0)), + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), protocol=protocol_map[rule_config.get('protocol', 'all')], - use_iface=( - True - if not rule_config.get('destination', {}).get('address') - else False - ), ) if 'remove' in config: @@ -177,24 +174,18 @@ def apply(config): # Add NAT44 static mapping rules n.enable_nat44_ed() for interface in config['in_iface_add']: - n.add_inbound_interface(interface) + n.add_inside_interface(interface) for interface in config['out_iface_add']: - n.add_outbound_interface(interface) + n.add_outside_interface(interface) for rule in config['changed_rules']: if rule in config.get('rule', {}): rule_config = config['rule'][rule] n.add_nat44_static_mapping( - iface_out=rule_config.get('outbound_interface'), - local_ip=rule_config.get('translation').get('address'), - external_ip=rule_config.get('destination', {}).get('address', ''), - local_port=int(rule_config.get('translation', {}).get('port', 0)), - external_port=int(rule_config.get('destination', {}).get('port', 0)), + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), protocol=protocol_map[rule_config.get('protocol', 'all')], - use_iface=( - True - if not rule_config.get('destination', {}).get('address') - else False - ), ) diff --git a/src/op_mode/show_vpp_nat44.py b/src/op_mode/show_vpp_nat44.py index 1aae8164e..fcf28f76a 100644 --- a/src/op_mode/show_vpp_nat44.py +++ b/src/op_mode/show_vpp_nat44.py @@ -116,27 +116,22 @@ def _get_raw_output_static_rules(vpp_api): return rules_list -def _get_formatted_output_rules(vpp, rules_list): +def _get_formatted_output_rules(rules_list): data_entries = [] for rule in rules_list: - dest_address = rule.get('external_ip_address') - dest_port = rule.get('external_port') or '' - trans_address = rule.get('local_ip_address') - trans_port = rule.get('local_port') or '' + external_address = rule.get('external_ip_address') + external_port = rule.get('external_port') or '' + local_address = rule.get('local_ip_address') + local_port = rule.get('local_port') or '' protocol = protocol_map[rule.get('protocol', 0)] - dest_sh_if_index = rule.get('external_sw_if_index') - vpp_if_name = vpp.get_interface_name(dest_sh_if_index) - if vpp_if_name: - dest_address = vpp_if_name - - values = [dest_address, dest_port, trans_address, trans_port, protocol] + values = [external_address, external_port, local_address, local_port, protocol] data_entries.append(values) headers = [ - 'Des_address/interface', - 'Dest_port', - 'Trans_address', - 'Trans_port', + 'External address', + 'External port', + 'Local address', + 'Local port', 'Protocol', ] out = sorted(data_entries, key=lambda x: x[2]) @@ -170,7 +165,7 @@ def show_static(raw: bool): return rules_list else: - return _get_formatted_output_rules(vpp, rules_list) + return _get_formatted_output_rules(rules_list) if __name__ == '__main__': -- cgit v1.2.3 From 1e5077c8f5ac28eade4d2ad8898ed8bd3f79edc7 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 31 Mar 2025 14:28:37 -0500 Subject: T7302: implement commit dry-run for vyconfd/commitd --- python/vyos/config.py | 12 ++++++++++++ python/vyos/configdep.py | 9 +++++++-- src/services/vyos-commitd | 14 ++++++++++---- 3 files changed, 29 insertions(+), 6 deletions(-) (limited to 'python') diff --git a/python/vyos/config.py b/python/vyos/config.py index 1fab46761..546eeceab 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -149,6 +149,18 @@ class Config(object): return self._running_config return self._session_config + def get_bool_attr(self, attr) -> bool: + if not hasattr(self, attr): + return False + else: + tmp = getattr(self, attr) + if not isinstance(tmp, bool): + return False + return tmp + + def set_bool_attr(self, attr, val): + setattr(self, attr, val) + def _make_path(self, path): # Backwards-compatibility stuff: original implementation used string paths # libvyosconfig paths are lists, but since node names cannot contain whitespace, diff --git a/python/vyos/configdep.py b/python/vyos/configdep.py index cf7c9d543..747af8dbe 100644 --- a/python/vyos/configdep.py +++ b/python/vyos/configdep.py @@ -102,11 +102,16 @@ def run_config_mode_script(target: str, config: 'Config'): mod = load_as_module(name, path) config.set_level([]) + dry_run = config.get_bool_attr('dry_run') try: c = mod.get_config(config) mod.verify(c) - mod.generate(c) - mod.apply(c) + if not dry_run: + mod.generate(c) + mod.apply(c) + else: + if hasattr(mod, 'call_dependents'): + mod.call_dependents() except (VyOSError, ConfigError) as e: raise ConfigError(str(e)) from e diff --git a/src/services/vyos-commitd b/src/services/vyos-commitd index 55f0c8741..e7f2d82c7 100755 --- a/src/services/vyos-commitd +++ b/src/services/vyos-commitd @@ -233,8 +233,9 @@ def initialization(session: Session) -> Session: scripts_called = [] setattr(config, 'scripts_called', scripts_called) - dry_run = False - setattr(config, 'dry_run', dry_run) + dry_run = session.dry_run + config.set_bool_attr('dry_run', dry_run) + logger.debug(f'commit dry_run is {dry_run}') session.config = config @@ -247,11 +248,16 @@ def run_script(script_name: str, config: Config, args: list) -> tuple[bool, str] script = conf_mode_scripts[script_name] script.argv = args config.set_level([]) + dry_run = config.get_bool_attr('dry_run') try: c = script.get_config(config) script.verify(c) - script.generate(c) - script.apply(c) + if not dry_run: + script.generate(c) + script.apply(c) + else: + if hasattr(script, 'call_dependents'): + script.call_dependents() except ConfigError as e: logger.error(e) return False, str(e) -- cgit v1.2.3 From 85c34d9f520e0221c5c476474781632693738154 Mon Sep 17 00:00:00 2001 From: srividya0208 Date: Thu, 10 Apr 2025 10:34:02 -0400 Subject: mtu_value: T7316:commit validation for interfaces when mtu configured <1200 --- python/vyos/configverify.py | 3 +++ src/conf_mode/interfaces_bridge.py | 2 ++ src/conf_mode/interfaces_pseudo-ethernet.py | 2 ++ src/conf_mode/interfaces_virtual-ethernet.py | 2 ++ src/conf_mode/interfaces_vti.py | 2 ++ src/conf_mode/interfaces_wwan.py | 2 ++ 6 files changed, 13 insertions(+) (limited to 'python') diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 4084425b1..e2c00577a 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -356,6 +356,7 @@ def verify_vlan_config(config): verify_vrf(vlan) verify_mirror_redirect(vlan) verify_mtu_parent(vlan, config) + verify_mtu_ipv6(vlan) # 802.1ad (Q-in-Q) VLANs for s_vlan_id in config.get('vif_s', {}): @@ -367,6 +368,7 @@ def verify_vlan_config(config): verify_vrf(s_vlan) verify_mirror_redirect(s_vlan) verify_mtu_parent(s_vlan, config) + verify_mtu_ipv6(s_vlan) for c_vlan_id in s_vlan.get('vif_c', {}): c_vlan = s_vlan['vif_c'][c_vlan_id] @@ -378,6 +380,7 @@ def verify_vlan_config(config): verify_mirror_redirect(c_vlan) verify_mtu_parent(c_vlan, config) verify_mtu_parent(c_vlan, s_vlan) + verify_mtu_ipv6(c_vlan) def verify_diffie_hellman_length(file, min_keysize): diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index aff93af2a..95dcc543e 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -25,6 +25,7 @@ from vyos.configdict import has_vlan_subinterface_configured from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import BridgeIf from vyos.configdict import has_address_configured from vyos.configdict import has_vrf_configured @@ -136,6 +137,7 @@ def verify(bridge): verify_dhcpv6(bridge) verify_vrf(bridge) + verify_mtu_ipv6(bridge) verify_mirror_redirect(bridge) ifname = bridge['ifname'] diff --git a/src/conf_mode/interfaces_pseudo-ethernet.py b/src/conf_mode/interfaces_pseudo-ethernet.py index 446beffd3..b066fd542 100755 --- a/src/conf_mode/interfaces_pseudo-ethernet.py +++ b/src/conf_mode/interfaces_pseudo-ethernet.py @@ -27,6 +27,7 @@ from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_source_interface from vyos.configverify import verify_vlan_config from vyos.configverify import verify_mtu_parent +from vyos.configverify import verify_mtu_ipv6 from vyos.configverify import verify_mirror_redirect from vyos.ifconfig import MACVLANIf from vyos.utils.network import interface_exists @@ -71,6 +72,7 @@ def verify(peth): verify_vrf(peth) verify_address(peth) verify_mtu_parent(peth, peth['parent']) + verify_mtu_ipv6(peth) verify_mirror_redirect(peth) # use common function to verify VLAN configuration verify_vlan_config(peth) diff --git a/src/conf_mode/interfaces_virtual-ethernet.py b/src/conf_mode/interfaces_virtual-ethernet.py index cb6104f59..59ce474fc 100755 --- a/src/conf_mode/interfaces_virtual-ethernet.py +++ b/src/conf_mode/interfaces_virtual-ethernet.py @@ -23,6 +23,7 @@ from vyos.configdict import get_interface_dict from vyos.configverify import verify_address from vyos.configverify import verify_bridge_delete from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import VethIf from vyos.utils.network import interface_exists airbag.enable() @@ -62,6 +63,7 @@ def verify(veth): return None verify_vrf(veth) + verify_mtu_ipv6(veth) verify_address(veth) if 'peer_name' not in veth: diff --git a/src/conf_mode/interfaces_vti.py b/src/conf_mode/interfaces_vti.py index 20629c6c1..915bde066 100755 --- a/src/conf_mode/interfaces_vti.py +++ b/src/conf_mode/interfaces_vti.py @@ -20,6 +20,7 @@ from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import VTIIf from vyos import ConfigError from vyos import airbag @@ -40,6 +41,7 @@ def get_config(config=None): def verify(vti): verify_vrf(vti) + verify_mtu_ipv6(vti) verify_mirror_redirect(vti) return None diff --git a/src/conf_mode/interfaces_wwan.py b/src/conf_mode/interfaces_wwan.py index 230eb14d6..ddbebfb4a 100755 --- a/src/conf_mode/interfaces_wwan.py +++ b/src/conf_mode/interfaces_wwan.py @@ -26,6 +26,7 @@ from vyos.configverify import verify_authentication from vyos.configverify import verify_interface_exists from vyos.configverify import verify_mirror_redirect from vyos.configverify import verify_vrf +from vyos.configverify import verify_mtu_ipv6 from vyos.ifconfig import WWANIf from vyos.utils.dict import dict_search from vyos.utils.process import cmd @@ -98,6 +99,7 @@ def verify(wwan): verify_interface_exists(wwan, ifname) verify_authentication(wwan) verify_vrf(wwan) + verify_mtu_ipv6(wwan) verify_mirror_redirect(wwan) return None -- cgit v1.2.3 From ad5f14c783c18d48f678d8ab89afc02b6d5d215c Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Thu, 10 Apr 2025 11:03:33 -0400 Subject: bridge:T7322: Fix bridge allowed-vlan handling Allowed VLAN ranges are unnecessarily deconstructed into individual vlans, and then added one by one to the bridge. This can take a long time if a large range like 1-4084 is used. - python/vyos/configdict.py - Added get_vlans_ids_and_range function to return configured ranges - python/vyos/ifconfig/bridge.py - Modified add and delete vlan section to not loop unnecessarily --- python/vyos/configdict.py | 17 +++++++++++++++++ python/vyos/ifconfig/bridge.py | 14 ++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) (limited to 'python') diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 78b98a3eb..73372022b 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -626,6 +626,23 @@ def get_vlan_ids(interface): return vlan_ids +def get_vlans_ids_and_range(interface): + vlan_ids = set() + + vlan_filter_status = json.loads(cmd(f'bridge -j -d vlan show dev {interface}')) + + if vlan_filter_status is not None: + for interface_status in vlan_filter_status: + for vlan_entry in iface.get("vlans", []): + start = vlan_entry["vlan"] + end = vlan_entry.get("vlanEnd") + if end: + vlan_ids.add(f"{start}-{end}") + else: + vlan_ids.add(str(start)) + + return vlan_ids + def get_accel_dict(config, base, chap_secrets, with_pki=False): """ Common utility function to retrieve and mangle the Accel-PPP configuration diff --git a/python/vyos/ifconfig/bridge.py b/python/vyos/ifconfig/bridge.py index d534dade7..f81026965 100644 --- a/python/vyos/ifconfig/bridge.py +++ b/python/vyos/ifconfig/bridge.py @@ -19,7 +19,7 @@ from vyos.utils.assertion import assert_list from vyos.utils.assertion import assert_positive from vyos.utils.dict import dict_search from vyos.utils.network import interface_exists -from vyos.configdict import get_vlan_ids +from vyos.configdict import get_vlans_ids_and_range from vyos.configdict import list_diff @Interface.register @@ -380,7 +380,7 @@ class BridgeIf(Interface): add_vlan = [] native_vlan_id = None allowed_vlan_ids= [] - cur_vlan_ids = get_vlan_ids(interface) + cur_vlan_ids = get_vlans_ids_and_range(interface) if 'native_vlan' in interface_config: vlan_id = interface_config['native_vlan'] @@ -389,14 +389,8 @@ class BridgeIf(Interface): if 'allowed_vlan' in interface_config: for vlan in interface_config['allowed_vlan']: - vlan_range = vlan.split('-') - if len(vlan_range) == 2: - for vlan_add in range(int(vlan_range[0]),int(vlan_range[1]) + 1): - add_vlan.append(str(vlan_add)) - allowed_vlan_ids.append(str(vlan_add)) - else: - add_vlan.append(vlan) - allowed_vlan_ids.append(vlan) + add_vlan.append(vlan) + allowed_vlan_ids.append(vlan) # Remove redundant VLANs from the system for vlan in list_diff(cur_vlan_ids, add_vlan): -- cgit v1.2.3 From 1d636f4c3779f4b5b08ccb1643dd80bc86c10fbf Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Thu, 10 Apr 2025 11:21:39 -0400 Subject: bridge:T7322: Fix bridge allowed-vlan handling Fix indentation error in get_vlans_ids_and_range function. --- python/vyos/configdict.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) (limited to 'python') diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 73372022b..586ddf632 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -627,21 +627,21 @@ def get_vlan_ids(interface): return vlan_ids def get_vlans_ids_and_range(interface): - vlan_ids = set() + vlan_ids = set() - vlan_filter_status = json.loads(cmd(f'bridge -j -d vlan show dev {interface}')) + vlan_filter_status = json.loads(cmd(f'bridge -j -d vlan show dev {interface}')) if vlan_filter_status is not None: for interface_status in vlan_filter_status: - for vlan_entry in iface.get("vlans", []): - start = vlan_entry["vlan"] - end = vlan_entry.get("vlanEnd") - if end: - vlan_ids.add(f"{start}-{end}") - else: - vlan_ids.add(str(start)) - - return vlan_ids + for vlan_entry in interface_status.get("vlans", []): + start = vlan_entry["vlan"] + end = vlan_entry.get("vlanEnd") + if end: + vlan_ids.add(f"{start}-{end}") + else: + vlan_ids.add(str(start)) + + return vlan_ids def get_accel_dict(config, base, chap_secrets, with_pki=False): """ -- cgit v1.2.3 From ce855f66c5a3b4febf7078be78cda58bc86955db Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 7 Apr 2025 12:22:20 -0500 Subject: T7321: normalize formatting --- python/vyos/component_version.py | 55 +++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 17 deletions(-) (limited to 'python') diff --git a/python/vyos/component_version.py b/python/vyos/component_version.py index 94215531d..85705bb24 100644 --- a/python/vyos/component_version.py +++ b/python/vyos/component_version.py @@ -49,7 +49,9 @@ DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') REGEX_WARN_VYOS = r'(// Warning: Do not remove the following line.)' REGEX_WARN_VYATTA = r'(/\* Warning: Do not remove the following line. \*/)' REGEX_COMPONENT_VERSION_VYOS = r'// vyos-config-version:\s+"([\w@:-]+)"\s*' -REGEX_COMPONENT_VERSION_VYATTA = r'/\* === vyatta-config-version:\s+"([\w@:-]+)"\s+=== \*/' +REGEX_COMPONENT_VERSION_VYATTA = ( + r'/\* === vyatta-config-version:\s+"([\w@:-]+)"\s+=== \*/' +) REGEX_RELEASE_VERSION_VYOS = r'// Release version:\s+(\S*)\s*' REGEX_RELEASE_VERSION_VYATTA = r'/\* Release version:\s+(\S*)\s*\*/' @@ -62,16 +64,31 @@ CONFIG_FILE_VERSION = """\ warn_filter_vyos = re.compile(REGEX_WARN_VYOS) warn_filter_vyatta = re.compile(REGEX_WARN_VYATTA) -regex_filter = { 'vyos': dict(zip(['component', 'release'], - [re.compile(REGEX_COMPONENT_VERSION_VYOS), - re.compile(REGEX_RELEASE_VERSION_VYOS)])), - 'vyatta': dict(zip(['component', 'release'], - [re.compile(REGEX_COMPONENT_VERSION_VYATTA), - re.compile(REGEX_RELEASE_VERSION_VYATTA)])) } +regex_filter = { + 'vyos': dict( + zip( + ['component', 'release'], + [ + re.compile(REGEX_COMPONENT_VERSION_VYOS), + re.compile(REGEX_RELEASE_VERSION_VYOS), + ], + ) + ), + 'vyatta': dict( + zip( + ['component', 'release'], + [ + re.compile(REGEX_COMPONENT_VERSION_VYATTA), + re.compile(REGEX_RELEASE_VERSION_VYATTA), + ], + ) + ), +} + @dataclass class VersionInfo: - component: Optional[dict[str,int]] = None + component: Optional[dict[str, int]] = None release: str = get_version() vintage: str = 'vyos' config_body: Optional[str] = None @@ -84,8 +101,9 @@ class VersionInfo: return bool(self.config_body is None) def update_footer(self): - f = CONFIG_FILE_VERSION.format(component_to_string(self.component), - self.release) + f = CONFIG_FILE_VERSION.format( + component_to_string(self.component), self.release + ) self.footer_lines = f.splitlines() def update_syntax(self): @@ -121,13 +139,16 @@ class VersionInfo: except Exception as e: raise ValueError(e) from e + def component_to_string(component: dict) -> str: - l = [f'{k}@{v}' for k, v in sorted(component.items(), key=lambda x: x[0])] + l = [f'{k}@{v}' for k, v in sorted(component.items(), key=lambda x: x[0])] # noqa: E741 return ':'.join(l) + def component_from_string(string: str) -> dict: return {k: int(v) for k, v in re.findall(r'([\w,-]+)@(\d+)', string)} + def version_info_from_file(config_file) -> VersionInfo: """Return config file component and release version info.""" version_info = VersionInfo() @@ -166,27 +187,27 @@ def version_info_from_file(config_file) -> VersionInfo: return version_info + def version_info_from_system() -> VersionInfo: """Return system component and release version info.""" d = component_version() sort_d = dict(sorted(d.items(), key=lambda x: x[0])) - version_info = VersionInfo( - component = sort_d, - release = get_version(), - vintage = 'vyos' - ) + version_info = VersionInfo(component=sort_d, release=get_version(), vintage='vyos') return version_info + def version_info_copy(v: VersionInfo) -> VersionInfo: """Make a copy of dataclass.""" return replace(v) + def version_info_prune_component(x: VersionInfo, y: VersionInfo) -> VersionInfo: """In place pruning of component keys of x not in y.""" if x.component is None or y.component is None: return - x.component = { k: v for k,v in x.component.items() if k in y.component } + x.component = {k: v for k, v in x.component.items() if k in y.component} + def add_system_version(config_str: str = None, out_file: str = None): """Wrap config string with system version and write to out_file. -- cgit v1.2.3 From 249f3d52d7585108fa7acf1c73a1dde000bd95b9 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 7 Apr 2025 12:26:42 -0500 Subject: T7321: add append version util --- python/vyos/component_version.py | 8 ++++++++ 1 file changed, 8 insertions(+) (limited to 'python') diff --git a/python/vyos/component_version.py b/python/vyos/component_version.py index 85705bb24..81d986658 100644 --- a/python/vyos/component_version.py +++ b/python/vyos/component_version.py @@ -223,3 +223,11 @@ def add_system_version(config_str: str = None, out_file: str = None): version_info.write(out_file) else: sys.stdout.write(version_info.write_string()) + + +def append_system_version(file: str): + """Append system version data to existing file""" + version_info = version_info_from_system() + version_info.update_footer() + with open(file, 'a') as f: + f.write(version_info.write_string()) -- cgit v1.2.3 From 2b1010e1259da89a674c5c06e71bda6e8904b0d0 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 7 Apr 2025 15:39:54 -0500 Subject: T7321: translate enums by value instead of name --- python/vyos/proto/vyconf_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/proto/vyconf_client.py b/python/vyos/proto/vyconf_client.py index f34549309..b385f0951 100644 --- a/python/vyos/proto/vyconf_client.py +++ b/python/vyos/proto/vyconf_client.py @@ -52,7 +52,9 @@ def request_to_msg(req: vyconf_proto.RequestEnvelope) -> vyconf_pb2.RequestEnvel def msg_to_response(msg: vyconf_pb2.Response) -> vyconf_proto.Response: # pylint: disable=no-member - d = MessageToDict(msg, preserving_proto_field_name=True) + d = MessageToDict( + msg, preserving_proto_field_name=True, use_integers_for_enums=True + ) response = vyconf_proto.Response(**d) return response -- cgit v1.2.3 From 6efc149e3246919b39cc831c8fa5110c19c68ace Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 7 Apr 2025 15:40:46 -0500 Subject: T7321: add VyconfSession class and methods Encapsulation of standard config session functions, to replace legacy versions in configsession.py. --- python/vyos/vyconf_session.py | 99 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 python/vyos/vyconf_session.py (limited to 'python') diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py new file mode 100644 index 000000000..cff3769fb --- /dev/null +++ b/python/vyos/vyconf_session.py @@ -0,0 +1,99 @@ +# Copyright 2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . +# +# + +import tempfile +import shutil + +from vyos.proto import vyconf_client +from vyos.migrate import ConfigMigrate +from vyos.migrate import ConfigMigrateError +from vyos.component_version import append_system_version + + +def output(o): + out = '' + for res in (o.output, o.error, o.warning): + if res is not None: + out = out + res + return out + + +class VyconfSession: + def __init__(self, token: str = None): + if token is None: + out = vyconf_client.send_request('setup_session') + self.__token = out.output + else: + self.__token = token + + def set(self, path: list[str]) -> tuple[str, int]: + out = vyconf_client.send_request('set', token=self.__token, path=path) + return output(out), out.status + + def delete(self, path: list[str]) -> tuple[str, int]: + out = vyconf_client.send_request('delete', token=self.__token, path=path) + return output(out), out.status + + def commit(self) -> tuple[str, int]: + out = vyconf_client.send_request('commit', token=self.__token) + return output(out), out.status + + def discard(self) -> tuple[str, int]: + out = vyconf_client.send_request('discard', token=self.__token) + return output(out), out.status + + def session_changed(self) -> bool: + out = vyconf_client.send_request('session_changed', token=self.__token) + return not bool(out.status) + + def load_config(self, file: str, migrate: bool = False) -> tuple[str, int]: + # pylint: disable=consider-using-with + if migrate: + tmp = tempfile.NamedTemporaryFile() + shutil.copy2(file, tmp.name) + config_migrate = ConfigMigrate(tmp.name) + try: + config_migrate.run() + except ConfigMigrateError as e: + tmp.close() + return repr(e), 1 + file = tmp.name + else: + tmp = '' + + out = vyconf_client.send_request('load', token=self.__token, location=file) + if tmp: + tmp.close() + + return output(out), out.status + + def save_config(self, file: str, append_version: bool = False) -> tuple[str, int]: + out = vyconf_client.send_request('save', token=self.__token, location=file) + if append_version: + append_system_version(file) + return output(out), out.status + + def show_config(self, path: list[str] = None) -> tuple[str, int]: + if path is None: + path = [] + out = vyconf_client.send_request('show_config', token=self.__token, path=path) + return output(out), out.status + + def __del__(self): + out = vyconf_client.send_request('teardown', token=self.__token) + if out.status: + print(f'Could not tear down session {self.__token}: {output(out)}') -- cgit v1.2.3 From 957eb2d49edeca10dadfd0204bdf54edf7c09601 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 11 Apr 2025 12:23:17 -0500 Subject: T7321: add decorator to raise named exception on error --- python/vyos/vyconf_session.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index cff3769fb..506095625 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -17,6 +17,8 @@ import tempfile import shutil +from functools import wraps +from typing import Type from vyos.proto import vyconf_client from vyos.migrate import ConfigMigrate @@ -33,25 +35,44 @@ def output(o): class VyconfSession: - def __init__(self, token: str = None): + def __init__(self, token: str = None, on_error: Type[Exception] = None): if token is None: out = vyconf_client.send_request('setup_session') self.__token = out.output else: self.__token = token + self.on_error = on_error + + @staticmethod + def raise_exception(f): + @wraps(f) + def wrapped(self, *args, **kwargs): + if self.on_error is None: + return f(self, *args, **kwargs) + o, e = f(self, *args, **kwargs) + if e: + raise self.on_error(o) + return o, e + + return wrapped + + @raise_exception def set(self, path: list[str]) -> tuple[str, int]: out = vyconf_client.send_request('set', token=self.__token, path=path) return output(out), out.status + @raise_exception def delete(self, path: list[str]) -> tuple[str, int]: out = vyconf_client.send_request('delete', token=self.__token, path=path) return output(out), out.status + @raise_exception def commit(self) -> tuple[str, int]: out = vyconf_client.send_request('commit', token=self.__token) return output(out), out.status + @raise_exception def discard(self) -> tuple[str, int]: out = vyconf_client.send_request('discard', token=self.__token) return output(out), out.status @@ -60,6 +81,7 @@ class VyconfSession: out = vyconf_client.send_request('session_changed', token=self.__token) return not bool(out.status) + @raise_exception def load_config(self, file: str, migrate: bool = False) -> tuple[str, int]: # pylint: disable=consider-using-with if migrate: @@ -81,12 +103,14 @@ class VyconfSession: return output(out), out.status + @raise_exception def save_config(self, file: str, append_version: bool = False) -> tuple[str, int]: out = vyconf_client.send_request('save', token=self.__token, location=file) if append_version: append_system_version(file) return output(out), out.status + @raise_exception def show_config(self, path: list[str] = None) -> tuple[str, int]: if path is None: path = [] -- cgit v1.2.3 From 99720f43b5ffba6ba455f1c16a7e9062df6e9642 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 11 Apr 2025 12:26:23 -0500 Subject: T7321: expose vyconfd client functions in configsession --- python/vyos/configsession.py | 55 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 8 deletions(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 90b96b88c..a3be29881 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -21,6 +21,10 @@ import subprocess from vyos.defaults import directories from vyos.utils.process import is_systemd_service_running from vyos.utils.dict import dict_to_paths +from vyos.utils.boot import boot_configuration_complete +from vyos.vyconf_session import VyconfSession + +vyconf_backend = False CLI_SHELL_API = '/bin/cli-shell-api' SET = '/opt/vyatta/sbin/my_set' @@ -165,6 +169,11 @@ class ConfigSession(object): self.__run_command([CLI_SHELL_API, 'setupSession']) + if vyconf_backend and boot_configuration_complete(): + self._vyconf_session = VyconfSession(on_error=ConfigSessionError) + else: + self._vyconf_session = None + def __del__(self): try: output = ( @@ -209,7 +218,10 @@ class ConfigSession(object): value = [] else: value = [value] - self.__run_command([SET] + path + value) + if self._vyconf_session is None: + self.__run_command([SET] + path + value) + else: + self._vyconf_session.set(path + value) def set_section(self, path: list, d: dict): try: @@ -223,7 +235,10 @@ class ConfigSession(object): value = [] else: value = [value] - self.__run_command([DELETE] + path + value) + if self._vyconf_session is None: + self.__run_command([DELETE] + path + value) + else: + self._vyconf_session.delete(path + value) def load_section(self, path: list, d: dict): try: @@ -261,20 +276,34 @@ class ConfigSession(object): self.__run_command([COMMENT] + path + value) def commit(self): - out = self.__run_command([COMMIT]) + if self._vyconf_session is None: + out = self.__run_command([COMMIT]) + else: + out, _ = self._vyconf_session.commit() + return out def discard(self): - self.__run_command([DISCARD]) + if self._vyconf_session is None: + self.__run_command([DISCARD]) + else: + out, _ = self._vyconf_session.discard() def show_config(self, path, format='raw'): - config_data = self.__run_command(SHOW_CONFIG + path) + if self._vyconf_session is None: + config_data = self.__run_command(SHOW_CONFIG + path) + else: + config_data, _ = self._vyconf_session.show_config() if format == 'raw': return config_data def load_config(self, file_path): - out = self.__run_command(LOAD_CONFIG + [file_path]) + if self._vyconf_session is None: + out = self.__run_command(LOAD_CONFIG + [file_path]) + else: + out, _ = self._vyconf_session.load_config(file=file_path) + return out def load_explicit(self, file_path): @@ -287,11 +316,21 @@ class ConfigSession(object): raise ConfigSessionError(e) from e def migrate_and_load_config(self, file_path): - out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path]) + if self._vyconf_session is None: + out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path]) + else: + out, _ = self._vyconf_session.load_config(file=file_path, migrate=True) + return out def save_config(self, file_path): - out = self.__run_command(SAVE_CONFIG + [file_path]) + if self._vyconf_session is None: + out = self.__run_command(SAVE_CONFIG + [file_path]) + else: + out, _ = self._vyconf_session.save_config( + file=file_path, append_version=True + ) + return out def install_image(self, url): -- cgit v1.2.3 From f3e77facc06750caafb100cdc6e96a1dc362182a Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:22:10 +0100 Subject: kea: T7281: Use Kea internal option for option 121 routes, remove option 249 Remove legacy windows static route on option 249 --- data/templates/dhcp-server/kea-dhcp4.conf.j2 | 14 -------------- python/vyos/kea.py | 8 +++----- python/vyos/template.py | 22 ---------------------- smoketest/scripts/cli/test_service_dhcp-server.py | 14 ++++++-------- 4 files changed, 9 insertions(+), 49 deletions(-) (limited to 'python') diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2 index 2e10d58e0..ee5716743 100644 --- a/data/templates/dhcp-server/kea-dhcp4.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp4.conf.j2 @@ -24,20 +24,6 @@ "name": "{{ lease_file }}" }, "option-def": [ - { - "name": "rfc3442-static-route", - "code": 121, - "type": "record", - "array": true, - "record-types": "uint8,uint8,uint8,uint8,uint8,uint8,uint8" - }, - { - "name": "windows-static-route", - "code": 249, - "type": "record", - "array": true, - "record-types": "uint8,uint8,uint8,uint8,uint8,uint8,uint8" - }, { "name": "wpad-url", "code": 252, diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 9fc5dde3d..264142f13 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -21,7 +21,6 @@ from datetime import datetime from datetime import timezone from vyos.template import is_ipv6 -from vyos.template import isc_static_route from vyos.template import netmask_from_cidr from vyos.utils.dict import dict_search_args from vyos.utils.file import file_permissions @@ -111,22 +110,21 @@ def kea_parse_options(config): default_route = '' if 'default_router' in config: - default_route = isc_static_route('0.0.0.0/0', config['default_router']) + default_route = f'0.0.0.0/0 - {config["default_router"]}' routes = [ - isc_static_route(route, route_options['next_hop']) + f'{route} - {route_options["next_hop"]}' for route, route_options in config['static_route'].items() ] options.append( { - 'name': 'rfc3442-static-route', + 'name': 'classless-static-route', 'data': ', '.join( routes if not default_route else routes + [default_route] ), } ) - options.append({'name': 'windows-static-route', 'data': ', '.join(routes)}) if 'time_zone' in config: with open('/usr/share/zoneinfo/' + config['time_zone'], 'rb') as f: diff --git a/python/vyos/template.py b/python/vyos/template.py index e75db1a8d..f5baf8dbd 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -390,28 +390,6 @@ def compare_netmask(netmask1, netmask2): except: return False -@register_filter('isc_static_route') -def isc_static_route(subnet, router): - # https://ercpe.de/blog/pushing-static-routes-with-isc-dhcp-server - # Option format is: - # , , , , , , - # where bytes with the value 0 are omitted. - from ipaddress import ip_network - net = ip_network(subnet) - # add netmask - string = str(net.prefixlen) + ',' - # add network bytes - if net.prefixlen: - width = net.prefixlen // 8 - if net.prefixlen % 8: - width += 1 - string += ','.join(map(str,tuple(net.network_address.packed)[:width])) + ',' - - # add router bytes - string += ','.join(router.split('.')) - - return string - @register_filter('is_file') def is_file(filename): if os.path.exists(filename): diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index 7c2ebff89..c07cf3a0c 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -217,8 +217,11 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): self.cli_set(pool + ['option', 'wpad-url', wpad]) self.cli_set(pool + ['option', 'server-identifier', server_identifier]) + static_route = '10.0.0.0/24' + static_route_nexthop = '192.0.2.1' + self.cli_set( - pool + ['option', 'static-route', '10.0.0.0/24', 'next-hop', '192.0.2.1'] + pool + ['option', 'static-route', static_route, 'next-hop', static_route_nexthop] ) self.cli_set(pool + ['option', 'ipv6-only-preferred', ipv6_only_preferred]) self.cli_set(pool + ['option', 'time-zone', 'Europe/London']) @@ -312,15 +315,10 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], { - 'name': 'rfc3442-static-route', - 'data': '24,10,0,0,192,0,2,1, 0,192,0,2,1', + 'name': 'classless-static-route', + 'data': f'{static_route} - {static_route_nexthop}, 0.0.0.0/0 - {router}', }, ) - self.verify_config_object( - obj, - ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], - {'name': 'windows-static-route', 'data': '24,10,0,0,192,0,2,1'}, - ) self.verify_config_object( obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], -- cgit v1.2.3 From f7c5c77376b9138d239cdccda605713b5d7681e1 Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Thu, 20 Mar 2025 16:49:46 +0100 Subject: kea: T7281: Add support for ping-check in Kea --- data/templates/dhcp-server/kea-dhcp4.conf.j2 | 10 ++++++++++ interface-definitions/include/dhcp/ping-check.xml.i | 8 ++++++++ interface-definitions/service_dhcp-server.xml.in | 2 ++ python/vyos/kea.py | 5 ++++- python/vyos/template.py | 6 +++++- smoketest/scripts/cli/test_service_dhcp-server.py | 18 ++++++++++++++++++ 6 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 interface-definitions/include/dhcp/ping-check.xml.i (limited to 'python') diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2 index ee5716743..8d9ffb194 100644 --- a/data/templates/dhcp-server/kea-dhcp4.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp4.conf.j2 @@ -54,6 +54,16 @@ } }, {% endif %} + { + "library": "/usr/lib/{{ machine }}-linux-gnu/kea/hooks/libdhcp_ping_check.so", + "parameters": { + "enable-ping-check" : false, + "min-ping-requests" : 1, + "reply-timeout" : 100, + "ping-cltt-secs" : 60, + "ping-channel-threads" : 0 + } + }, { "library": "/usr/lib/{{ machine }}-linux-gnu/kea/hooks/libdhcp_lease_cmds.so", "parameters": {} diff --git a/interface-definitions/include/dhcp/ping-check.xml.i b/interface-definitions/include/dhcp/ping-check.xml.i new file mode 100644 index 000000000..a506f68e4 --- /dev/null +++ b/interface-definitions/include/dhcp/ping-check.xml.i @@ -0,0 +1,8 @@ + + + + Sends ICMP Echo request to the address being assigned + + + + diff --git a/interface-definitions/service_dhcp-server.xml.in b/interface-definitions/service_dhcp-server.xml.in index 9a194de4f..c0ab7c048 100644 --- a/interface-definitions/service_dhcp-server.xml.in +++ b/interface-definitions/service_dhcp-server.xml.in @@ -112,6 +112,7 @@ #include + #include #include #include @@ -128,6 +129,7 @@ #include + #include #include #include diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 264142f13..a2a35cf65 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -145,7 +145,7 @@ def kea_parse_options(config): def kea_parse_subnet(subnet, config): - out = {'subnet': subnet, 'id': int(config['subnet_id'])} + out = {'subnet': subnet, 'id': int(config['subnet_id']), 'user-context': {}} if 'option' in config: out['option-data'] = kea_parse_options(config['option']) @@ -163,6 +163,9 @@ def kea_parse_subnet(subnet, config): out['valid-lifetime'] = int(config['lease']) out['max-valid-lifetime'] = int(config['lease']) + if 'ping_check' in config: + out['user-context']['enable-ping-check'] = True + if 'range' in config: pools = [] for num, range_config in config['range'].items(): diff --git a/python/vyos/template.py b/python/vyos/template.py index f5baf8dbd..7ba85a046 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -873,7 +873,8 @@ def kea_shared_network_json(shared_networks): network = { 'name': name, 'authoritative': ('authoritative' in config), - 'subnet4': [] + 'subnet4': [], + 'user-context': {} } if 'option' in config: @@ -885,6 +886,9 @@ def kea_shared_network_json(shared_networks): if 'bootfile_server' in config['option']: network['next-server'] = config['option']['bootfile_server'] + if 'ping_check' in config: + network['user-context']['enable-ping-check'] = True + if 'subnet' in config: for subnet, subnet_config in config['subnet'].items(): if 'disable' in subnet_config: diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index c07cf3a0c..0d73e12f3 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -106,9 +106,12 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): self.cli_set(base_path + ['listen-interface', interface]) + self.cli_set(base_path + ['shared-network-name', shared_net_name, 'ping-check']) + pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] self.cli_set(pool + ['subnet-id', '1']) self.cli_set(pool + ['ignore-client-id']) + self.cli_set(pool + ['ping-check']) # we use the first subnet IP address as default gateway self.cli_set(pool + ['option', 'default-router', router]) self.cli_set(pool + ['option', 'name-server', dns_1]) @@ -151,6 +154,21 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'max-valid-lifetime', 86400 ) + # Verify ping-check + self.verify_config_value( + obj, + ['Dhcp4', 'shared-networks', 0, 'user-context'], + 'enable-ping-check', + True + ) + + self.verify_config_value( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'user-context'], + 'enable-ping-check', + True + ) + # Verify options self.verify_config_object( obj, -- cgit v1.2.3 From 77c7dfac383578ad6fabb9b3786681c267d0beb7 Mon Sep 17 00:00:00 2001 From: David Vølker Date: Mon, 14 Apr 2025 20:23:12 +0200 Subject: kea: T7310: add support for RFC-5417 (option 138) (#4430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dhcp-server: T7310: add support for option 138 CAPWAP AC to KEA * kea: T7310: Update data/templates/dhcp-server/kea-dhcp4.conf.j2 Co-authored-by: Simon <965089+sarthurdev@users.noreply.github.com> * kea: T7310: Update python/vyos/kea.py Co-authored-by: Simon <965089+sarthurdev@users.noreply.github.com> * kea: T7310: add smoketest for capwap-ac-v4 * kea: T7310: Update python/vyos/kea.py Co-authored-by: Simon <965089+sarthurdev@users.noreply.github.com> --------- Co-authored-by: David Vølker Co-authored-by: Simon <965089+sarthurdev@users.noreply.github.com> --- interface-definitions/include/dhcp/option-v4.xml.i | 12 ++++++++++++ interface-definitions/include/dhcp/option-v6.xml.i | 12 ++++++++++++ python/vyos/kea.py | 2 ++ smoketest/scripts/cli/test_service_dhcp-server.py | 9 +++++++++ smoketest/scripts/cli/test_service_dhcpv6-server.py | 5 +++++ 5 files changed, 40 insertions(+) (limited to 'python') diff --git a/interface-definitions/include/dhcp/option-v4.xml.i b/interface-definitions/include/dhcp/option-v4.xml.i index bd6fc6043..08fbcca4a 100644 --- a/interface-definitions/include/dhcp/option-v4.xml.i +++ b/interface-definitions/include/dhcp/option-v4.xml.i @@ -59,6 +59,18 @@ DHCP client prefix length must be 0 to 32 + + + IP address of CAPWAP access controller (Option 138) + + ipv4 + CAPWAP AC controller + + + + + + IP address of default router diff --git a/interface-definitions/include/dhcp/option-v6.xml.i b/interface-definitions/include/dhcp/option-v6.xml.i index e1897f52d..202843ddf 100644 --- a/interface-definitions/include/dhcp/option-v6.xml.i +++ b/interface-definitions/include/dhcp/option-v6.xml.i @@ -7,6 +7,18 @@ #include #include #include + + + IP address of CAPWAP access controller (Option 52) + + ipv6 + CAPWAP AC controller + + + + + + NIS domain name for client to use diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 9fc5dde3d..de397d8f9 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -44,6 +44,7 @@ kea4_options = { 'wpad_url': 'wpad-url', 'ipv6_only_preferred': 'v6-only-preferred', 'captive_portal': 'v4-captive-portal', + 'capwap_controller': 'capwap-ac-v4', } kea6_options = { @@ -56,6 +57,7 @@ kea6_options = { 'nisplus_server': 'nisp-servers', 'sntp_server': 'sntp-servers', 'captive_portal': 'v6-captive-portal', + 'capwap_controller': 'capwap-ac-v6', } kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket' diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index 7c2ebff89..7bb850b22 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -197,6 +197,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): wpad = 'http://wpad.vyos.io/foo/bar' server_identifier = bootfile_server ipv6_only_preferred = '300' + capwap_access_controller = '192.168.2.125' pool = base_path + ['shared-network-name', shared_net_name, 'subnet', subnet] self.cli_set(pool + ['subnet-id', '1']) @@ -216,6 +217,9 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): self.cli_set(pool + ['option', 'bootfile-server', bootfile_server]) self.cli_set(pool + ['option', 'wpad-url', wpad]) self.cli_set(pool + ['option', 'server-identifier', server_identifier]) + self.cli_set( + pool + ['option', 'capwap-controller', capwap_access_controller] + ) self.cli_set( pool + ['option', 'static-route', '10.0.0.0/24', 'next-hop', '192.0.2.1'] @@ -298,6 +302,11 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], {'name': 'dhcp-server-identifier', 'data': server_identifier}, ) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], + {'name': 'capwap-ac-v4', 'data': capwap_access_controller}, + ) self.verify_config_object( obj, ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], diff --git a/smoketest/scripts/cli/test_service_dhcpv6-server.py b/smoketest/scripts/cli/test_service_dhcpv6-server.py index 6ecf6c1cf..6535ca72d 100755 --- a/smoketest/scripts/cli/test_service_dhcpv6-server.py +++ b/smoketest/scripts/cli/test_service_dhcpv6-server.py @@ -108,6 +108,7 @@ class TestServiceDHCPv6Server(VyOSUnitTestSHIM.TestCase): self.cli_set(pool + ['lease-time', 'default', lease_time]) self.cli_set(pool + ['lease-time', 'maximum', max_lease_time]) self.cli_set(pool + ['lease-time', 'minimum', min_lease_time]) + self.cli_set(pool + ['option', 'capwap-controller', dns_1]) self.cli_set(pool + ['option', 'name-server', dns_1]) self.cli_set(pool + ['option', 'name-server', dns_2]) self.cli_set(pool + ['option', 'name-server', dns_2]) @@ -154,6 +155,10 @@ class TestServiceDHCPv6Server(VyOSUnitTestSHIM.TestCase): self.verify_config_value(obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'max-valid-lifetime', int(max_lease_time)) # Verify options + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'capwap-ac-v6', 'data': dns_1}) self.verify_config_object( obj, ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], -- cgit v1.2.3 From 3fe5f8fb95a444ecb5b8489736a2e33419746f93 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Tue, 15 Apr 2025 20:47:09 +0200 Subject: grub: T7327: honor "system option kernel" settings during image upgrade When performing an image upgrade and Linux Kernel command-line option that should be passed via GRUB to the Linux Kernel are missing on the first boot. This is because when generating the GRUB command-line via the op-mode scripts the CLI nodes defining the options are not honored. This commit re-implements the code-path in op-mode which generates the strings passed via GRUB to the Linux Kernel command-line. NOTE: If (for a yet unknown reason) a Kernel command-line option string changes during a major - or minor - upgrade of the Linux Kernel, we will need to adapt that logic and possibly call a helper from within the NEW updated image rootfs. Thus we can ship future information back into the past like the "Grays Sports Almanac" from Back to the Future Part II. --- python/vyos/system/grub_util.py | 5 ++--- src/conf_mode/system_option.py | 7 +++++-- src/op_mode/image_installer.py | 44 +++++++++++++++++++++++++++++++++++------ 3 files changed, 45 insertions(+), 11 deletions(-) (limited to 'python') diff --git a/python/vyos/system/grub_util.py b/python/vyos/system/grub_util.py index 4a3d8795e..ad95bb4f9 100644 --- a/python/vyos/system/grub_util.py +++ b/python/vyos/system/grub_util.py @@ -56,13 +56,12 @@ def set_kernel_cmdline_options(cmdline_options: str, version: str = '', @image.if_not_live_boot def update_kernel_cmdline_options(cmdline_options: str, - root_dir: str = '') -> None: + root_dir: str = '', + version = image.get_running_image()) -> None: """Update Kernel custom cmdline options""" if not root_dir: root_dir = disk.find_persistence() - version = image.get_running_image() - boot_opts_current = grub.get_boot_opts(version, root_dir) boot_opts_proposed = grub.BOOT_OPTS_STEM + f'{version} {cmdline_options}' diff --git a/src/conf_mode/system_option.py b/src/conf_mode/system_option.py index 064a1aa91..b45a9d8a6 100755 --- a/src/conf_mode/system_option.py +++ b/src/conf_mode/system_option.py @@ -122,6 +122,10 @@ def generate(options): render(ssh_config, 'system/ssh_config.j2', options) render(usb_autosuspend, 'system/40_usb_autosuspend.j2', options) + # XXX: This code path and if statements must be kept in sync with the Kernel + # option handling in image_installer.py:get_cli_kernel_options(). This + # occurance is used for having the appropriate options passed to GRUB + # when re-configuring options on the CLI. cmdline_options = [] if 'kernel' in options: if 'disable_mitigations' in options['kernel']: @@ -131,8 +135,7 @@ def generate(options): if 'amd_pstate_driver' in options['kernel']: mode = options['kernel']['amd_pstate_driver'] cmdline_options.append( - f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}' - ) + f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}') grub_util.update_kernel_cmdline_options(' '.join(cmdline_options)) return None diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index 179913f15..2660309a5 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -24,7 +24,9 @@ from glob import glob from sys import exit from os import environ from os import readlink -from os import getpid, getppid +from os import getpid +from os import getppid +from json import loads from typing import Union from urllib.parse import urlparse from passlib.hosts import linux_context @@ -35,15 +37,23 @@ from psutil import disk_partitions from vyos.base import Warning from vyos.configtree import ConfigTree from vyos.remote import download -from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER +from vyos.system import disk +from vyos.system import grub +from vyos.system import image +from vyos.system import compat +from vyos.system import raid +from vyos.system import SYSTEM_CFG_VER +from vyos.system import grub_util from vyos.template import render from vyos.utils.auth import ( DEFAULT_PASSWORD, EPasswdStrength, evaluate_strength ) +from vyos.utils.dict import dict_search from vyos.utils.io import ask_input, ask_yes_no, select_entry from vyos.utils.file import chmod_2775 +from vyos.utils.file import read_file from vyos.utils.process import cmd, run, rc_cmd from vyos.version import get_version_data @@ -477,6 +487,25 @@ def setup_grub(root_dir: str) -> None: render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {}) render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {}) +def get_cli_kernel_options(config_file: str) -> list: + config = ConfigTree(read_file(config_file)) + config_dict = loads(config.to_json()) + kernel_options = dict_search('system.option.kernel', config_dict) + cmdline_options = [] + + # XXX: This code path and if statements must be kept in sync with the Kernel + # option handling in system_options.py:generate(). This occurance is used + # for having the appropriate options passed to GRUB after an image upgrade! + if 'disable-mitigations' in kernel_options: + cmdline_options.append('mitigations=off') + if 'disable-power-saving' in kernel_options: + cmdline_options.append('intel_idle.max_cstate=0 processor.max_cstate=1') + if 'amd-pstate-driver' in kernel_options: + mode = kernel_options['amd-pstate-driver'] + cmdline_options.append( + f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}') + + return cmdline_options def configure_authentication(config_file: str, password: str) -> None: """Write encrypted password to config file @@ -491,10 +520,7 @@ def configure_authentication(config_file: str, password: str) -> None: plaintext exposed """ encrypted_password = linux_context.hash(password) - - with open(config_file) as f: - config_string = f.read() - + config_string = read_file(config_file) config = ConfigTree(config_string) config.set([ 'system', 'login', 'user', 'vyos', 'authentication', @@ -1045,6 +1071,12 @@ def add_image(image_path: str, vrf: str = None, username: str = '', if set_as_default: grub.set_default(image_name, root_dir) + cmdline_options = get_cli_kernel_options( + f'{target_config_dir}/config.boot') + grub_util.update_kernel_cmdline_options(' '.join(cmdline_options), + root_dir=root_dir, + version=image_name) + except OSError as e: # if no space error, remove image dir and cleanup if e.errno == ENOSPC: -- cgit v1.2.3 From b5406516ccd845eeb3b33529b31ca81a319f16d7 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Fri, 18 Apr 2025 19:51:53 +0200 Subject: syslog: T7367: use generic systemd syslog.service over rsyslog.service --- python/vyos/defaults.py | 2 +- src/conf_mode/system_host-name.py | 2 +- src/conf_mode/system_syslog.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 2b08ff68e..7efccded6 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -43,7 +43,7 @@ directories = { } systemd_services = { - 'rsyslog' : 'rsyslog.service', + 'syslog' : 'syslog.service', 'snmpd' : 'snmpd.service', } diff --git a/src/conf_mode/system_host-name.py b/src/conf_mode/system_host-name.py index fef034d1c..de4accda2 100755 --- a/src/conf_mode/system_host-name.py +++ b/src/conf_mode/system_host-name.py @@ -175,7 +175,7 @@ def apply(config): # Restart services that use the hostname if hostname_new != hostname_old: - tmp = systemd_services['rsyslog'] + tmp = systemd_services['syslog'] call(f'systemctl restart {tmp}') # If SNMP is running, restart it too diff --git a/src/conf_mode/system_syslog.py b/src/conf_mode/system_syslog.py index 414bd4b6b..bdab09f3c 100755 --- a/src/conf_mode/system_syslog.py +++ b/src/conf_mode/system_syslog.py @@ -35,7 +35,7 @@ rsyslog_conf = '/run/rsyslog/rsyslog.conf' logrotate_conf = '/etc/logrotate.d/vyos-rsyslog' systemd_socket = 'syslog.socket' -systemd_service = systemd_services['rsyslog'] +systemd_service = systemd_services['syslog'] def get_config(config=None): if config: -- cgit v1.2.3 From b124f0b3b05bced1f916e9519d986d03f2b95c51 Mon Sep 17 00:00:00 2001 From: Yoshiaki Suyama Date: Sun, 16 Mar 2025 01:16:55 +0900 Subject: interface: T4627: support IPv6 Interface Identifier (token) for SLAAC Add common IPv6 CLI option (use ethernet as example): set interfaces ethernet eth0 ipv6 address interface-identifier Co-authored-by: Christian Breunig --- .../ipv6-address-interface-identifier.xml.i | 15 +++++++++++ .../include/interface/ipv6-address.xml.i | 1 + python/vyos/configdict.py | 8 ++++++ python/vyos/configverify.py | 3 +++ python/vyos/ifconfig/interface.py | 31 ++++++++++++++++++++++ smoketest/scripts/cli/base_interfaces_test.py | 12 +++++++++ 6 files changed, 70 insertions(+) create mode 100644 interface-definitions/include/interface/ipv6-address-interface-identifier.xml.i (limited to 'python') diff --git a/interface-definitions/include/interface/ipv6-address-interface-identifier.xml.i b/interface-definitions/include/interface/ipv6-address-interface-identifier.xml.i new file mode 100644 index 000000000..d173dfdb8 --- /dev/null +++ b/interface-definitions/include/interface/ipv6-address-interface-identifier.xml.i @@ -0,0 +1,15 @@ + + + + SLAAC interface identifier + + ::h:h:h:h + Interface identifier + + + ::([0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){0,3}) + + Interface identifier format must start with :: and may contain up four hextets (::h:h:h:h) + + + diff --git a/interface-definitions/include/interface/ipv6-address.xml.i b/interface-definitions/include/interface/ipv6-address.xml.i index e1bdf02fd..ff35b858c 100644 --- a/interface-definitions/include/interface/ipv6-address.xml.i +++ b/interface-definitions/include/interface/ipv6-address.xml.i @@ -6,6 +6,7 @@ #include #include + #include #include diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 78b98a3eb..ecab5fcb0 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -517,6 +517,14 @@ def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pk else: dict['ipv6']['address'].update({'eui64_old': eui64}) + interface_identifier = leaf_node_changed(config, base + [ifname, 'ipv6', 'address', 'interface-identifier']) + if interface_identifier: + tmp = dict_search('ipv6.address', dict) + if not tmp: + dict.update({'ipv6': {'address': {'interface_identifier_old': interface_identifier}}}) + else: + dict['ipv6']['address'].update({'interface_identifier_old': interface_identifier}) + for vif, vif_config in dict.get('vif', {}).items(): # Add subinterface name to dictionary dict['vif'][vif].update({'ifname' : f'{ifname}.{vif}'}) diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 4084425b1..c93d9faac 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -92,6 +92,9 @@ def verify_mtu_ipv6(config): tmp = dict_search('ipv6.address.eui64', config) if tmp != None: raise ConfigError(error_msg) + tmp = dict_search('ipv6.address.interface_identifier', config) + if tmp != None: raise ConfigError(error_msg) + def verify_vrf(config): """ Common helper function used by interface implementations to perform diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 979b62578..9a45ae66e 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -937,6 +937,20 @@ class Interface(Control): prefixlen = prefix.split('/')[1] self.del_addr(f'{eui64}/{prefixlen}') + def set_ipv6_interface_identifier(self, identifier): + """ + Set the interface identifier for IPv6 autoconf. + """ + cmd = f'ip token set {identifier} dev {self.ifname}' + self._cmd(cmd) + + def del_ipv6_interface_identifier(self): + """ + Delete the interface identifier for IPv6 autoconf. + """ + cmd = f'ip token delete dev {self.ifname}' + self._cmd(cmd) + def set_ipv6_forwarding(self, forwarding): """ Configure IPv6 interface-specific Host/Router behaviour. @@ -1792,6 +1806,23 @@ class Interface(Control): value = '0' if (tmp != None) else '1' self.set_ipv6_forwarding(value) + # Delete old interface identifier + # This should be before setting the accept_ra value + old = dict_search('ipv6.address.interface_identifier_old', config) + now = dict_search('ipv6.address.interface_identifier', config) + if old and not now: + # accept_ra of ra is required to delete the interface identifier + self.set_ipv6_accept_ra('2') + self.del_ipv6_interface_identifier() + + # Set IPv6 Interface identifier + # This should be before setting the accept_ra value + tmp = dict_search('ipv6.address.interface_identifier', config) + if tmp: + # accept_ra is required to set the interface identifier + self.set_ipv6_accept_ra('2') + self.set_ipv6_interface_identifier(tmp) + # IPv6 router advertisements tmp = dict_search('ipv6.address.autoconf', config) value = '2' if (tmp != None) else '1' diff --git a/smoketest/scripts/cli/base_interfaces_test.py b/smoketest/scripts/cli/base_interfaces_test.py index 3e2653a2f..5348b0cc3 100644 --- a/smoketest/scripts/cli/base_interfaces_test.py +++ b/smoketest/scripts/cli/base_interfaces_test.py @@ -14,6 +14,7 @@ import re +from json import loads from netifaces import AF_INET from netifaces import AF_INET6 from netifaces import ifaddresses @@ -1067,6 +1068,7 @@ class BasicInterfaceTest: dad_transmits = '10' accept_dad = '0' source_validation = 'strict' + interface_identifier = '::fffe' for interface in self._interfaces: path = self._base_path + [interface] @@ -1089,6 +1091,9 @@ class BasicInterfaceTest: if cli_defined(self._base_path + ['ipv6'], 'source-validation'): self.cli_set(path + ['ipv6', 'source-validation', source_validation]) + if cli_defined(self._base_path + ['ipv6', 'address'], 'interface-identifier'): + self.cli_set(path + ['ipv6', 'address', 'interface-identifier', interface_identifier]) + self.cli_commit() for interface in self._interfaces: @@ -1120,6 +1125,13 @@ class BasicInterfaceTest: self.assertIn('fib saddr . iif oif 0', line) self.assertIn('drop', line) + if cli_defined(self._base_path + ['ipv6', 'address'], 'interface-identifier'): + tmp = cmd(f'ip -j token show dev {interface}') + tmp = loads(tmp)[0] + self.assertEqual(tmp['token'], interface_identifier) + self.assertEqual(tmp['ifname'], interface) + + def test_dhcpv6_client_options(self): if not self._test_ipv6_dhcpc6: self.skipTest(MSG_TESTCASE_UNSUPPORTED) -- cgit v1.2.3 From c984fe04a3ce46c266e0b1854af906d5b317f397 Mon Sep 17 00:00:00 2001 From: aapostoliuk Date: Tue, 22 Apr 2025 12:23:12 +0300 Subject: ospf: T7383: Fixed unconfigured redistribution of nhrp into ospf Fixed unconfigured redistribution of nhrp into ospf. --- python/vyos/frrender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index 8d469e3e2..524167d8b 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -92,7 +92,7 @@ def get_frrender_dict(conf, argv=None) -> dict: if dict_search(f'area.{area_num}.area_type.nssa', ospf) is None: del default_values['area'][area_num]['area_type']['nssa'] - for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'rip', 'static']: + for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'nhrp', 'rip', 'static']: if dict_search(f'redistribute.{protocol}', ospf) is None: del default_values['redistribute'][protocol] if not bool(default_values['redistribute']): -- cgit v1.2.3 From e9fb2078d5ea82e1d9186ee8ef1dd982591954d0 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 19 Apr 2025 15:18:44 +0200 Subject: interface: T7375: SLAAC assigned address is not cleared when removing SLAAC --- python/vyos/ifconfig/interface.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 979b62578..85f994e08 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -909,7 +909,10 @@ class Interface(Control): tmp = self.get_interface('ipv6_autoconf') if tmp == autoconf: return None - return self.set_interface('ipv6_autoconf', autoconf) + rc = self.set_interface('ipv6_autoconf', autoconf) + if autoconf == '0': + self.flush_ipv6_slaac_addrs() + return rc def add_ipv6_eui64_address(self, prefix): """ @@ -1310,6 +1313,34 @@ class Interface(Control): # flush all addresses self._cmd(cmd) + def flush_ipv6_slaac_addrs(self): + """ + Flush all IPv6 addresses installed in response to router advertisement + messages from this interface. + + Will raise an exception on error. + """ + netns = get_interface_namespace(self.ifname) + netns_cmd = f'ip netns exec {netns}' if netns else '' + tmp = get_interface_address(self.ifname) + if 'addr_info' not in tmp: + return + + # Parse interface IP addresses. Example data: + # {'family': 'inet6', 'local': '2001:db8:1111:0:250:56ff:feb3:38c5', + # 'prefixlen': 64, 'scope': 'global', 'dynamic': True, + # 'mngtmpaddr': True, 'protocol': 'kernel_ra', + # 'valid_life_time': 2591987, 'preferred_life_time': 14387} + for addr_info in tmp['addr_info']: + if 'protocol' not in addr_info: + continue + if (addr_info['protocol'] == 'kernel_ra' and + addr_info['scope'] == 'global'): + # Flush IPv6 addresses installed by router advertisement + ra_addr = f"{addr_info['local']}/{addr_info['prefixlen']}" + cmd = f'{netns_cmd} ip -6 addr del dev {self.ifname} {ra_addr}' + self._cmd(cmd) + def add_to_bridge(self, bridge_dict): """ Adds the interface to the bridge with the passed port config. -- cgit v1.2.3 From 542e3db626ba1184743c4956a340260d0a529c92 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 19 Apr 2025 15:50:37 +0200 Subject: interface: T7375: remove superfluous "ifname = self.ifname" assignment We can reference "self.ifname" in any Python f-ormatted string directly. No need for an interim temporary variable. --- python/vyos/ifconfig/interface.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) (limited to 'python') diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 85f994e08..85de0947a 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -1351,8 +1351,6 @@ class Interface(Control): # drop all interface addresses first self.flush_addrs() - ifname = self.ifname - for bridge, bridge_config in bridge_dict.items(): # add interface to bridge - use Section.klass to get BridgeIf class Section.klass(bridge)(bridge, create=True).add_port(self.ifname) @@ -1368,7 +1366,7 @@ class Interface(Control): bridge_vlan_filter = Section.klass(bridge)(bridge, create=True).get_vlan_filter() if int(bridge_vlan_filter): - cur_vlan_ids = get_vlan_ids(ifname) + cur_vlan_ids = get_vlan_ids(self.ifname) add_vlan = [] native_vlan_id = None allowed_vlan_ids= [] @@ -1391,15 +1389,15 @@ class Interface(Control): # Remove redundant VLANs from the system for vlan in list_diff(cur_vlan_ids, add_vlan): - cmd = f'bridge vlan del dev {ifname} vid {vlan} master' + cmd = f'bridge vlan del dev {self.ifname} vid {vlan} master' self._cmd(cmd) for vlan in allowed_vlan_ids: - cmd = f'bridge vlan add dev {ifname} vid {vlan} master' + cmd = f'bridge vlan add dev {self.ifname} vid {vlan} master' self._cmd(cmd) # Setting native VLAN to system if native_vlan_id: - cmd = f'bridge vlan add dev {ifname} vid {native_vlan_id} pvid untagged master' + cmd = f'bridge vlan add dev {self.ifname} vid {native_vlan_id} pvid untagged master' self._cmd(cmd) def set_dhcp(self, enable: bool, vrf_changed: bool=False): @@ -1478,12 +1476,11 @@ class Interface(Control): if enable not in [True, False]: raise ValueError() - ifname = self.ifname config_base = directories['dhcp6_client_dir'] - config_file = f'{config_base}/dhcp6c.{ifname}.conf' - script_file = f'/etc/wide-dhcpv6/dhcp6c.{ifname}.script' # can not live under /run b/c of noexec mount option - systemd_override_file = f'/run/systemd/system/dhcp6c@{ifname}.service.d/10-override.conf' - systemd_service = f'dhcp6c@{ifname}.service' + config_file = f'{config_base}/dhcp6c.{self.ifname}.conf' + script_file = f'/etc/wide-dhcpv6/dhcp6c.{self.ifname}.script' # can not live under /run b/c of noexec mount option + systemd_override_file = f'/run/systemd/system/dhcp6c@{self.ifname}.service.d/10-override.conf' + systemd_service = f'dhcp6c@{self.ifname}.service' # Rendered client configuration files require additional settings config = deepcopy(self.config) -- cgit v1.2.3 From bad519f9f1004e9855e5805473e2e3e8d1fb36ec Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 19 Apr 2025 15:59:55 +0200 Subject: interface: T7375: routes received via SLAAC are not cleared on exit When using SLAAC for IPv6 addresses we will also receive a default route via a RA (Router Advertisement). When we disable SLAAC on a interface the Linux Kernel does not automatically flush all addresses nor the routes received. The Kernel wait's until the addresses/prefixes/routes expire using their lifestime setting. When removing SLAAC from an interface, also remove the auto generated IPv6 address and both the default router received and the connected IP prefix of the SLAAC advertisement. --- python/vyos/ifconfig/interface.py | 47 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) (limited to 'python') diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 85de0947a..baa45f5bd 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -22,6 +22,7 @@ from copy import deepcopy from glob import glob from ipaddress import IPv4Network +from ipaddress import IPv6Interface from netifaces import ifaddresses # this is not the same as socket.AF_INET/INET6 from netifaces import AF_INET @@ -911,7 +912,8 @@ class Interface(Control): return None rc = self.set_interface('ipv6_autoconf', autoconf) if autoconf == '0': - self.flush_ipv6_slaac_addrs() + flushed = self.flush_ipv6_slaac_addrs() + self.flush_ipv6_slaac_routes(flushed) return rc def add_ipv6_eui64_address(self, prefix): @@ -1313,12 +1315,13 @@ class Interface(Control): # flush all addresses self._cmd(cmd) - def flush_ipv6_slaac_addrs(self): + def flush_ipv6_slaac_addrs(self) -> list: """ Flush all IPv6 addresses installed in response to router advertisement messages from this interface. Will raise an exception on error. + Will return a list of flushed IPv6 addresses. """ netns = get_interface_namespace(self.ifname) netns_cmd = f'ip netns exec {netns}' if netns else '' @@ -1331,6 +1334,7 @@ class Interface(Control): # 'prefixlen': 64, 'scope': 'global', 'dynamic': True, # 'mngtmpaddr': True, 'protocol': 'kernel_ra', # 'valid_life_time': 2591987, 'preferred_life_time': 14387} + flushed = [] for addr_info in tmp['addr_info']: if 'protocol' not in addr_info: continue @@ -1338,8 +1342,47 @@ class Interface(Control): addr_info['scope'] == 'global'): # Flush IPv6 addresses installed by router advertisement ra_addr = f"{addr_info['local']}/{addr_info['prefixlen']}" + flushed.append(ra_addr) cmd = f'{netns_cmd} ip -6 addr del dev {self.ifname} {ra_addr}' self._cmd(cmd) + return flushed + + def flush_ipv6_slaac_routes(self, ra_addrs: list=[]) -> None: + """ + Flush IPv6 default routes installed in response to router advertisement + messages from this interface. + + Will raise an exception on error. + """ + # Do not flush default route if interface uses DHCPv6 in addition to SLAAC + if 'address' in self.config and 'dhcpv6' in self.config['address']: + return None + + # Find IPv6 connected prefixes for flushed SLAAC addresses + connected = [] + for addr in ra_addrs: + connected.append(str(IPv6Interface(addr).network)) + + netns = get_interface_namespace(self.ifname) + netns_cmd = f'ip netns exec {netns}' if netns else '' + + tmp = self._cmd(f'{netns_cmd} ip -j -6 route show dev {self.ifname}') + tmp = json.loads(tmp) + # Parse interface routes. Example data: + # {'dst': 'default', 'gateway': 'fe80::250:56ff:feb3:cdba', + # 'protocol': 'ra', 'metric': 1024, 'flags': [], 'expires': 1398, + # 'metrics': [{'hoplimit': 64}], 'pref': 'medium'} + for route in tmp: + # If it's a default route received from RA, delete it + if (dict_search('dst', route) == 'default' and + dict_search('protocol', route) == 'ra'): + self._cmd(f'{netns_cmd} ip -6 route del default via {route["gateway"]} dev {self.ifname}') + # Remove connected prefixes received from RA + if dict_search('dst', route) in connected: + # If it's a connected prefix, delete it + self._cmd(f'{netns_cmd} ip -6 route del {route["dst"]} dev {self.ifname}') + + return None def add_to_bridge(self, bridge_dict): """ -- cgit v1.2.3 From de44c6aef249b5c3350a5114a38eee3a761f7de0 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 20 Apr 2025 20:59:57 +0200 Subject: interface: T7379: do not request SLAAC default route when only DHCPv6 is set When an interface runs in DHCPv6 only mode, there is no reason to have a default installed that was received via SLAAC. If SLAAC is needed, it should be turned on explicitly. This bug was only triggered during system boot where a DHCPv6 client address and a default route to a link-local address was shown in the system. If DHCPv6 was enabled only on an interface while VyOS was already running - no default route got installed. --- python/vyos/ifconfig/interface.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) (limited to 'python') diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index baa45f5bd..337e3ec63 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -913,7 +913,7 @@ class Interface(Control): rc = self.set_interface('ipv6_autoconf', autoconf) if autoconf == '0': flushed = self.flush_ipv6_slaac_addrs() - self.flush_ipv6_slaac_routes(flushed) + self.flush_ipv6_slaac_routes(ra_addrs=flushed) return rc def add_ipv6_eui64_address(self, prefix): @@ -1326,7 +1326,7 @@ class Interface(Control): netns = get_interface_namespace(self.ifname) netns_cmd = f'ip netns exec {netns}' if netns else '' tmp = get_interface_address(self.ifname) - if 'addr_info' not in tmp: + if not tmp or 'addr_info' not in tmp: return # Parse interface IP addresses. Example data: @@ -1354,13 +1354,9 @@ class Interface(Control): Will raise an exception on error. """ - # Do not flush default route if interface uses DHCPv6 in addition to SLAAC - if 'address' in self.config and 'dhcpv6' in self.config['address']: - return None - # Find IPv6 connected prefixes for flushed SLAAC addresses connected = [] - for addr in ra_addrs: + for addr in ra_addrs if isinstance(ra_addrs, list) else []: connected.append(str(IPv6Interface(addr).network)) netns = get_interface_namespace(self.ifname) @@ -1865,9 +1861,7 @@ class Interface(Control): # IPv6 router advertisements tmp = dict_search('ipv6.address.autoconf', config) - value = '2' if (tmp != None) else '1' - if 'dhcpv6' in new_addr: - value = '2' + value = '2' if (tmp != None) else '0' self.set_ipv6_accept_ra(value) # IPv6 address autoconfiguration -- cgit v1.2.3 From 427ebbb1e103ff45774bdf79bd5b1cddeff2f686 Mon Sep 17 00:00:00 2001 From: Alex Bukharov Date: Wed, 23 Apr 2025 00:40:06 +1000 Subject: T6773: RFC-2136 support for Kea DHCP4 server (#4153) --- data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 | 30 ++++ data/templates/dhcp-server/kea-dhcp4.conf.j2 | 13 ++ .../include/dhcp/ddns-dns-server.xml.i | 19 +++ .../include/dhcp/ddns-settings.xml.i | 172 +++++++++++++++++++++ interface-definitions/service_dhcp-server.xml.in | 121 ++++++++++++++- python/vyos/kea.py | 52 +++++++ python/vyos/template.py | 70 +++++++++ smoketest/config-tests/basic-vyos | 17 ++ smoketest/configs/basic-vyos | 60 ++++++- smoketest/scripts/cli/test_service_dhcp-server.py | 130 ++++++++++++++++ src/conf_mode/service_dhcp-server.py | 34 ++++ .../kea-dhcp-ddns-server.service.d/override.conf | 7 + 12 files changed, 714 insertions(+), 11 deletions(-) create mode 100644 data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 create mode 100644 interface-definitions/include/dhcp/ddns-dns-server.xml.i create mode 100644 interface-definitions/include/dhcp/ddns-settings.xml.i create mode 100644 src/etc/systemd/system/kea-dhcp-ddns-server.service.d/override.conf (limited to 'python') diff --git a/data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 b/data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 new file mode 100644 index 000000000..7b0394a88 --- /dev/null +++ b/data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 @@ -0,0 +1,30 @@ +{ + "DhcpDdns": { + "ip-address": "127.0.0.1", + "port": 53001, + "control-socket": { + "socket-type": "unix", + "socket-name": "/run/kea/kea-ddns-ctrl-socket" + }, + "tsig-keys": {{ dynamic_dns_update | kea_dynamic_dns_update_tsig_key_json }}, + "forward-ddns" : { + "ddns-domains": {{ dynamic_dns_update | kea_dynamic_dns_update_domains('forward_domain') }} + }, + "reverse-ddns" : { + "ddns-domains": {{ dynamic_dns_update | kea_dynamic_dns_update_domains('reverse_domain') }} + }, + "loggers": [ + { + "name": "kea-dhcp-ddns", + "output_options": [ + { + "output": "stdout", + "pattern": "%-5p %m\n" + } + ], + "severity": "INFO", + "debuglevel": 0 + } + ] + } +} diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2 index 8d9ffb194..d08ca0eaa 100644 --- a/data/templates/dhcp-server/kea-dhcp4.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp4.conf.j2 @@ -36,6 +36,19 @@ "space": "ubnt" } ], +{% if dynamic_dns_update is vyos_defined %} + "dhcp-ddns": { + "enable-updates": true, + "server-ip": "127.0.0.1", + "server-port": 53001, + "sender-ip": "", + "sender-port": 0, + "max-queue-size": 1024, + "ncr-protocol": "UDP", + "ncr-format": "JSON" + }, + {{ dynamic_dns_update | kea_dynamic_dns_update_main_json }} +{% endif %} "hooks-libraries": [ {% if high_availability is vyos_defined %} { diff --git a/interface-definitions/include/dhcp/ddns-dns-server.xml.i b/interface-definitions/include/dhcp/ddns-dns-server.xml.i new file mode 100644 index 000000000..ba9f186d0 --- /dev/null +++ b/interface-definitions/include/dhcp/ddns-dns-server.xml.i @@ -0,0 +1,19 @@ + + + + DNS server specification + + u32:1-999999 + Number for this DNS server + + + + + DNS server number must be between 1 and 999999 + + + #include + #include + + + diff --git a/interface-definitions/include/dhcp/ddns-settings.xml.i b/interface-definitions/include/dhcp/ddns-settings.xml.i new file mode 100644 index 000000000..3e202685e --- /dev/null +++ b/interface-definitions/include/dhcp/ddns-settings.xml.i @@ -0,0 +1,172 @@ + + + + Enable or disable updates for this scope + + enable disable + + + enable + Enable updates for this scope + + + disable + Disable updates for this scope + + + (enable|disable) + + Set it to either enable or disable + + + + + Always update both forward and reverse DNS data, regardless of the client's request + + enable disable + + + enable + Force update both forward and reverse DNS records + + + disable + Respect client request settings + + + (enable|disable) + + Set it to either enable or disable + + + + + Perform a DDNS update, even if the client instructs the server not to + + enable disable + + + enable + Force DDNS updates regardless of client request + + + disable + Respect client request settings + + + (enable|disable) + + Set it to either enable or disable + + + + + Replace client name mode + + never always when-present when-not-present + + + never + Use the name the client sent. If the client sent no name, do not generate + one + + + always + Replace the name the client sent. If the client sent no name, generate one + for the client + + + when-present + Replace the name the client sent. If the client sent no name, do not + generate one + + + when-not-present + Use the name the client sent. If the client sent no name, generate one for + the client + + + (never|always|when-present|when-not-present) + + Invalid replace client name mode + + + + + The prefix used in the generation of an FQDN + + + + Invalid generated prefix + + + + + The suffix used when generating an FQDN, or when qualifying a partial name + + + + Invalid qualifying suffix + + + + + Update DNS record on lease renew + + enable disable + + + enable + Update DNS record on lease renew + + + disable + Do not update DNS record on lease renew + + + (enable|disable) + + Set it to either enable or disable + + + + + DNS conflict resolution behavior + + enable disable + + + enable + Enable DNS conflict resolution + + + disable + Disable DNS conflict resolution + + + (enable|disable) + + Set it to either enable or disable + + + + + Calculate TTL of the DNS record as a percentage of the lease lifetime + + + + Invalid qualifying suffix + + + + + A regular expression describing the invalid character set in the host name + + + + + A string of zero or more characters with which to replace each invalid character in + the host name + + + diff --git a/interface-definitions/service_dhcp-server.xml.in b/interface-definitions/service_dhcp-server.xml.in index c0ab7c048..78f1cea4e 100644 --- a/interface-definitions/service_dhcp-server.xml.in +++ b/interface-definitions/service_dhcp-server.xml.in @@ -10,12 +10,111 @@ #include - + Dynamically update Domain Name System (RFC4702) - - + + #include + + + TSIG key definition for DNS updates + + #include + + Invalid TSIG key name. May only contain letters, numbers, hyphen and underscore + + + + + TSIG key algorithm + + md5 sha1 sha224 sha256 sha384 sha512 + + + md5 + MD5 HMAC algorithm + + + sha1 + SHA1 HMAC algorithm + + + sha224 + SHA224 HMAC algorithm + + + sha256 + SHA256 HMAC algorithm + + + sha384 + SHA384 HMAC algorithm + + + sha512 + SHA512 HMAC algorithm + + + (md5|sha1|sha224|sha256|sha384|sha512) + + Invalid TSIG key algorithm + + + + + TSIG key secret (base64-encoded) + + + + + + + + + + Forward DNS domain name + + + + Invalid forward DNS domain name + + + + + TSIG key name for forward DNS updates + + #include + + Invalid TSIG key name. May only contain letters, numbers, numbers, hyphen and underscore + + + #include + + + + + Reverse DNS domain name + + + + Invalid reverse DNS domain name + + + + + TSIG key name for reverse DNS updates + + #include + + Invalid TSIG key name. May only contain letters, numbers, numbers, hyphen and underscore + + + #include + + + + DHCP high availability configuration @@ -105,6 +204,14 @@ Invalid shared network name. May only contain letters, numbers and .-_ + + + Dynamically update Domain Name System (RFC4702) + + + #include + + Option to make DHCP server authoritative for this physical network @@ -132,6 +239,14 @@ #include #include #include + + + Dynamically update Domain Name System (RFC4702) + + + #include + + IP address to exclude from DHCP lease range diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 2b0cac7e6..5eecbbaad 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -20,6 +20,7 @@ import socket from datetime import datetime from datetime import timezone +from vyos import ConfigError from vyos.template import is_ipv6 from vyos.template import netmask_from_cidr from vyos.utils.dict import dict_search_args @@ -221,6 +222,9 @@ def kea_parse_subnet(subnet, config): reservations.append(reservation) out['reservations'] = reservations + if 'dynamic_dns_update' in config: + out.update(kea_parse_ddns_settings(config['dynamic_dns_update'])) + return out @@ -350,6 +354,54 @@ def kea6_parse_subnet(subnet, config): return out +def kea_parse_tsig_algo(algo_spec): + translate = { + 'md5': 'HMAC-MD5', + 'sha1': 'HMAC-SHA1', + 'sha224': 'HMAC-SHA224', + 'sha256': 'HMAC-SHA256', + 'sha384': 'HMAC-SHA384', + 'sha512': 'HMAC-SHA512' + } + if algo_spec not in translate: + raise ConfigError(f'Unsupported TSIG algorithm: {algo_spec}') + return translate[algo_spec] + +def kea_parse_enable_disable(value): + return True if value == 'enable' else False + +def kea_parse_ddns_settings(config): + data = {} + + if send_updates := config.get('send_updates'): + data['ddns-send-updates'] = kea_parse_enable_disable(send_updates) + + if override_client_update := config.get('override_client_update'): + data['ddns-override-client-update'] = kea_parse_enable_disable(override_client_update) + + if override_no_update := config.get('override_no_update'): + data['ddns-override-no-update'] = kea_parse_enable_disable(override_no_update) + + if update_on_renew := config.get('update_on_renew'): + data['ddns-update-on-renew'] = kea_parse_enable_disable(update_on_renew) + + if conflict_resolution := config.get('conflict_resolution'): + data['ddns-use-conflict-resolution'] = kea_parse_enable_disable(conflict_resolution) + + if 'replace_client_name' in config: + data['ddns-replace-client-name'] = config['replace_client_name'] + if 'generated_prefix' in config: + data['ddns-generated-prefix'] = config['generated_prefix'] + if 'qualifying_suffix' in config: + data['ddns-qualifying-suffix'] = config['qualifying_suffix'] + if 'ttl_percent' in config: + data['ddns-ttl-percent'] = int(config['ttl_percent']) / 100 + if 'hostname_char_set' in config: + data['hostname-char-set'] = config['hostname_char_set'] + if 'hostname_char_replacement' in config: + data['hostname-char-replacement'] = config['hostname_char_replacement'] + + return data def _ctrl_socket_command(inet, command, args=None): path = kea_ctrl_socket.format(inet=inet) diff --git a/python/vyos/template.py b/python/vyos/template.py index 7ba85a046..d79e1183f 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -859,10 +859,77 @@ def kea_high_availability_json(config): return dumps(data) +@register_filter('kea_dynamic_dns_update_main_json') +def kea_dynamic_dns_update_main_json(config): + from vyos.kea import kea_parse_ddns_settings + from json import dumps + + data = kea_parse_ddns_settings(config) + + if len(data) == 0: + return '' + + return dumps(data, indent=8)[1:-1] + ',' + +@register_filter('kea_dynamic_dns_update_tsig_key_json') +def kea_dynamic_dns_update_tsig_key_json(config): + from vyos.kea import kea_parse_tsig_algo + from json import dumps + out = [] + + if 'tsig_key' not in config: + return dumps(out) + + tsig_keys = config['tsig_key'] + + for tsig_key_name, tsig_key_config in tsig_keys.items(): + tsig_key = { + 'name': tsig_key_name, + 'algorithm': kea_parse_tsig_algo(tsig_key_config['algorithm']), + 'secret': tsig_key_config['secret'] + } + out.append(tsig_key) + + return dumps(out, indent=12) + +@register_filter('kea_dynamic_dns_update_domains') +def kea_dynamic_dns_update_domains(config, type_key): + from json import dumps + out = [] + + if type_key not in config: + return dumps(out) + + domains = config[type_key] + + for domain_name, domain_config in domains.items(): + domain = { + 'name': domain_name, + + } + if 'key_name' in domain_config: + domain['key-name'] = domain_config['key_name'] + + if 'dns_server' in domain_config: + dns_servers = [] + for dns_server_config in domain_config['dns_server'].values(): + dns_server = { + 'ip-address': dns_server_config['address'] + } + if 'port' in dns_server_config: + dns_server['port'] = int(dns_server_config['port']) + dns_servers.append(dns_server) + domain['dns-servers'] = dns_servers + + out.append(domain) + + return dumps(out, indent=12) + @register_filter('kea_shared_network_json') def kea_shared_network_json(shared_networks): from vyos.kea import kea_parse_options from vyos.kea import kea_parse_subnet + from vyos.kea import kea_parse_ddns_settings from json import dumps out = [] @@ -877,6 +944,9 @@ def kea_shared_network_json(shared_networks): 'user-context': {} } + if 'dynamic_dns_update' in config: + network.update(kea_parse_ddns_settings(config['dynamic_dns_update'])) + if 'option' in config: network['option-data'] = kea_parse_options(config['option']) diff --git a/smoketest/config-tests/basic-vyos b/smoketest/config-tests/basic-vyos index 4793e069e..aaf450e80 100644 --- a/smoketest/config-tests/basic-vyos +++ b/smoketest/config-tests/basic-vyos @@ -28,7 +28,21 @@ set protocols static arp interface eth2.200.201 address 100.64.201.20 mac '00:50 set protocols static arp interface eth2.200.202 address 100.64.202.30 mac '00:50:00:00:00:30' set protocols static arp interface eth2.200.202 address 100.64.202.40 mac '00:50:00:00:00:40' set protocols static route 0.0.0.0/0 next-hop 100.64.0.1 +set service dhcp-server dynamic-dns-update send-updates 'enable' +set service dhcp-server dynamic-dns-update conflict-resolution 'enable' +set service dhcp-server dynamic-dns-update tsig-key domain-lan-updates algorithm 'sha256' +set service dhcp-server dynamic-dns-update tsig-key domain-lan-updates secret 'SXQncyBXZWRuZXNkYXkgbWFoIGR1ZGVzIQ==' +set service dhcp-server dynamic-dns-update tsig-key reverse-0-168-192 algorithm 'sha256' +set service dhcp-server dynamic-dns-update tsig-key reverse-0-168-192 secret 'VGhhbmsgR29kIGl0J3MgRnJpZGF5IQ==' +set service dhcp-server dynamic-dns-update forward-domain domain.lan dns-server 1 address '192.168.0.1' +set service dhcp-server dynamic-dns-update forward-domain domain.lan dns-server 2 address '100.100.0.1' +set service dhcp-server dynamic-dns-update forward-domain domain.lan key-name 'domain-lan-updates' +set service dhcp-server dynamic-dns-update reverse-domain 0.168.192.in-addr.arpa dns-server 1 address '192.168.0.1' +set service dhcp-server dynamic-dns-update reverse-domain 0.168.192.in-addr.arpa dns-server 2 address '100.100.0.1' +set service dhcp-server dynamic-dns-update reverse-domain 0.168.192.in-addr.arpa key-name 'reverse-0-168-192' set service dhcp-server shared-network-name LAN authoritative +set service dhcp-server shared-network-name LAN dynamic-dns-update send-updates 'enable' +set service dhcp-server shared-network-name LAN dynamic-dns-update ttl-percent '75' set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 option default-router '192.168.0.1' set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 option domain-name 'vyos.net' set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 option domain-search 'vyos.net' @@ -46,6 +60,9 @@ set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 static-map set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 static-mapping TEST2-2 ip-address '192.168.0.21' set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 static-mapping TEST2-2 mac '00:01:02:03:04:22' set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 subnet-id '1' +set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 dynamic-dns-update send-updates 'enable' +set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 dynamic-dns-update generated-prefix 'myhost' +set service dhcp-server shared-network-name LAN subnet 192.168.0.0/24 dynamic-dns-update qualifying-suffix 'lan1.domain.lan' set service dhcpv6-server shared-network-name LAN6 subnet fe88::/56 interface 'eth0' set service dhcpv6-server shared-network-name LAN6 subnet fe88::/56 option domain-search 'vyos.net' set service dhcpv6-server shared-network-name LAN6 subnet fe88::/56 option name-server 'fe88::1' diff --git a/smoketest/configs/basic-vyos b/smoketest/configs/basic-vyos index a6cd3b6e1..5f7a71237 100644 --- a/smoketest/configs/basic-vyos +++ b/smoketest/configs/basic-vyos @@ -99,33 +99,77 @@ protocols { } service { dhcp-server { + dynamic-dns-update { + send-updates enable + forward-domain domain.lan { + dns-server 1 { + address 192.168.0.1 + } + dns-server 2 { + address 100.100.0.1 + } + key-name domain-lan-updates + } + reverse-domain 0.168.192.in-addr.arpa { + dns-server 1 { + address 192.168.0.1 + } + dns-server 2 { + address 100.100.0.1 + } + key-name reverse-0-168-192 + } + tsig-key domain-lan-updates { + algorithm sha256 + secret SXQncyBXZWRuZXNkYXkgbWFoIGR1ZGVzIQ== + } + tsig-key reverse-0-168-192 { + algorithm sha256 + secret VGhhbmsgR29kIGl0J3MgRnJpZGF5IQ== + } + conflict-resolution enable + } shared-network-name LAN { authoritative + dynamic-dns-update { + send-updates enable + ttl-percent 75 + } subnet 192.168.0.0/24 { - default-router 192.168.0.1 - dns-server 192.168.0.1 - domain-name vyos.net - domain-search vyos.net + dynamic-dns-update { + send-updates enable + generated-prefix myhost + qualifying-suffix lan1.domain.lan + } + option { + default-router 192.168.0.1 + domain-name vyos.net + domain-search vyos.net + name-server 192.168.0.1 + } range LANDynamic { start 192.168.0.30 stop 192.168.0.240 } static-mapping TEST1-1 { ip-address 192.168.0.11 - mac-address 00:01:02:03:04:05 + mac 00:01:02:03:04:05 } static-mapping TEST1-2 { + disable ip-address 192.168.0.12 - mac-address 00:01:02:03:04:05 + mac 00:01:02:03:04:05 } static-mapping TEST2-1 { ip-address 192.168.0.21 - mac-address 00:01:02:03:04:21 + mac 00:01:02:03:04:21 } static-mapping TEST2-2 { + disable ip-address 192.168.0.21 - mac-address 00:01:02:03:04:22 + mac 00:01:02:03:04:22 } + subnet-id 1 } } } diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index 935be6227..e421f04d2 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -32,7 +32,10 @@ from vyos.template import inc_ip from vyos.template import dec_ip PROCESS_NAME = 'kea-dhcp4' +D2_PROCESS_NAME = 'kea-dhcp-ddns' +CTRL_PROCESS_NAME = 'kea-ctrl-agent' KEA4_CONF = '/run/kea/kea-dhcp4.conf' +KEA4_D2_CONF = '/run/kea/kea-dhcp-ddns.conf' KEA4_CTRL = '/run/kea/dhcp4-ctrl-socket' HOSTSD_CLIENT = '/usr/bin/vyos-hostsd-client' base_path = ['service', 'dhcp-server'] @@ -1116,6 +1119,133 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): # Check for running process self.verify_service_running() + def test_dhcp_dynamic_dns_update(self): + shared_net_name = 'SMOKE-1DDNS' + + range_0_start = inc_ip(subnet, 10) + range_0_stop = inc_ip(subnet, 20) + + self.cli_set(base_path + ['listen-interface', interface]) + + ddns = base_path + ['dynamic-dns-update'] + + self.cli_set(ddns + ['send-updates', 'enable']) + self.cli_set(ddns + ['conflict-resolution', 'enable']) + self.cli_set(ddns + ['override-no-update', 'enable']) + self.cli_set(ddns + ['override-client-update', 'enable']) + self.cli_set(ddns + ['replace-client-name', 'always']) + self.cli_set(ddns + ['update-on-renew', 'enable']) + + self.cli_set(ddns + ['tsig-key', 'domain-lan-updates', 'algorithm', 'sha256']) + self.cli_set(ddns + ['tsig-key', 'domain-lan-updates', 'secret', 'SXQncyBXZWRuZXNkYXkgbWFoIGR1ZGVzIQ==']) + self.cli_set(ddns + ['tsig-key', 'reverse-0-168-192', 'algorithm', 'sha256']) + self.cli_set(ddns + ['tsig-key', 'reverse-0-168-192', 'secret', 'VGhhbmsgR29kIGl0J3MgRnJpZGF5IQ==']) + self.cli_set(ddns + ['forward-domain', 'domain.lan', 'dns-server', '1', 'address', '192.168.0.1']) + self.cli_set(ddns + ['forward-domain', 'domain.lan', 'dns-server', '2', 'address', '100.100.0.1']) + self.cli_set(ddns + ['forward-domain', 'domain.lan', 'key-name', 'domain-lan-updates']) + self.cli_set(ddns + ['reverse-domain', '0.168.192.in-addr.arpa', 'dns-server', '1', 'address', '192.168.0.1']) + self.cli_set(ddns + ['reverse-domain', '0.168.192.in-addr.arpa', 'dns-server', '1', 'port', '1053']) + self.cli_set(ddns + ['reverse-domain', '0.168.192.in-addr.arpa', 'dns-server', '2', 'address', '100.100.0.1']) + self.cli_set(ddns + ['reverse-domain', '0.168.192.in-addr.arpa', 'dns-server', '2', 'port', '1153']) + self.cli_set(ddns + ['reverse-domain', '0.168.192.in-addr.arpa', 'key-name', 'reverse-0-168-192']) + + shared = base_path + ['shared-network-name', shared_net_name] + + self.cli_set(shared + ['dynamic-dns-update', 'send-updates', 'enable']) + self.cli_set(shared + ['dynamic-dns-update', 'conflict-resolution', 'enable']) + self.cli_set(shared + ['dynamic-dns-update', 'ttl-percent', '75']) + + pool = shared + [ 'subnet', subnet] + + self.cli_set(pool + ['subnet-id', '1']) + + self.cli_set(pool + ['range', '0', 'start', range_0_start]) + self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) + + self.cli_set(pool + ['dynamic-dns-update', 'send-updates', 'enable']) + self.cli_set(pool + ['dynamic-dns-update', 'generated-prefix', 'myfunnyprefix']) + self.cli_set(pool + ['dynamic-dns-update', 'qualifying-suffix', 'suffix.lan']) + self.cli_set(pool + ['dynamic-dns-update', 'hostname-char-set', 'xXyYzZ']) + self.cli_set(pool + ['dynamic-dns-update', 'hostname-char-replacement', '_xXx_']) + + self.cli_commit() + + config = read_file(KEA4_CONF) + d2_config = read_file(KEA4_D2_CONF) + + obj = loads(config) + d2_obj = loads(d2_config) + + # Verify global DDNS parameters in the main config file + self.verify_config_value( + obj, + ['Dhcp4'], 'dhcp-ddns', + {'enable-updates': True, 'server-ip': '127.0.0.1', 'server-port': 53001, 'sender-ip': '', 'sender-port': 0, + 'max-queue-size': 1024, 'ncr-protocol': 'UDP', 'ncr-format': 'JSON'}) + + self.verify_config_value(obj, ['Dhcp4'], 'ddns-send-updates', True) + self.verify_config_value(obj, ['Dhcp4'], 'ddns-use-conflict-resolution', True) + self.verify_config_value(obj, ['Dhcp4'], 'ddns-override-no-update', True) + self.verify_config_value(obj, ['Dhcp4'], 'ddns-override-client-update', True) + self.verify_config_value(obj, ['Dhcp4'], 'ddns-replace-client-name', 'always') + self.verify_config_value(obj, ['Dhcp4'], 'ddns-update-on-renew', True) + + # Verify scoped DDNS parameters in the main config file + self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'name', shared_net_name) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'ddns-send-updates', True) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'ddns-use-conflict-resolution', True) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks'], 'ddns-ttl-percent', 0.75) + + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'subnet', subnet) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'id', 1) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'ddns-send-updates', True) + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'ddns-generated-prefix', 'myfunnyprefix') + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'ddns-qualifying-suffix', 'suffix.lan') + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'hostname-char-set', 'xXyYzZ') + self.verify_config_value(obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'hostname-char-replacement', '_xXx_') + + # Verify keys and domains configuration in the D2 config + self.verify_config_object( + d2_obj, + ['DhcpDdns', 'tsig-keys'], + {'name': 'domain-lan-updates', 'algorithm': 'HMAC-SHA256', 'secret': 'SXQncyBXZWRuZXNkYXkgbWFoIGR1ZGVzIQ=='} + ) + self.verify_config_object( + d2_obj, + ['DhcpDdns', 'tsig-keys'], + {'name': 'reverse-0-168-192', 'algorithm': 'HMAC-SHA256', 'secret': 'VGhhbmsgR29kIGl0J3MgRnJpZGF5IQ=='} + ) + + self.verify_config_value(d2_obj, ['DhcpDdns', 'forward-ddns', 'ddns-domains', 0], 'name', 'domain.lan') + self.verify_config_value(d2_obj, ['DhcpDdns', 'forward-ddns', 'ddns-domains', 0], 'key-name', 'domain-lan-updates') + self.verify_config_object( + d2_obj, + ['DhcpDdns', 'forward-ddns', 'ddns-domains', 0, 'dns-servers'], + {'ip-address': '192.168.0.1'} + ) + self.verify_config_object( + d2_obj, + ['DhcpDdns', 'forward-ddns', 'ddns-domains', 0, 'dns-servers'], + {'ip-address': '100.100.0.1'} + ) + + self.verify_config_value(d2_obj, ['DhcpDdns', 'reverse-ddns', 'ddns-domains', 0], 'name', '0.168.192.in-addr.arpa') + self.verify_config_value(d2_obj, ['DhcpDdns', 'reverse-ddns', 'ddns-domains', 0], 'key-name', 'reverse-0-168-192') + self.verify_config_object( + d2_obj, + ['DhcpDdns', 'reverse-ddns', 'ddns-domains', 0, 'dns-servers'], + {'ip-address': '192.168.0.1', 'port': 1053} + ) + self.verify_config_object( + d2_obj, + ['DhcpDdns', 'reverse-ddns', 'ddns-domains', 0, 'dns-servers'], + {'ip-address': '100.100.0.1', 'port': 1153} + ) + + # Check for running process + self.assertTrue(process_named_running(PROCESS_NAME)) + self.assertTrue(process_named_running(D2_PROCESS_NAME)) + def test_dhcp_on_interface_with_vrf(self): self.cli_set(['interfaces', 'ethernet', 'eth1', 'address', '10.1.1.1/30']) self.cli_set(['interfaces', 'ethernet', 'eth1', 'vrf', 'SMOKE-DHCP']) diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index e46d916fd..99c7e6a1f 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -43,6 +43,7 @@ airbag.enable() ctrl_socket = '/run/kea/dhcp4-ctrl-socket' config_file = '/run/kea/kea-dhcp4.conf' +config_file_d2 = '/run/kea/kea-dhcp-ddns.conf' lease_file = '/config/dhcp/dhcp4-leases.csv' lease_file_glob = '/config/dhcp/dhcp4-leases*' user_group = '_kea' @@ -170,6 +171,15 @@ def get_config(config=None): return dhcp +def verify_ddns_domain_servers(domain_type, domain): + if 'dns_server' in domain: + invalid_servers = [] + for server_no, server_config in domain['dns_server'].items(): + if 'address' not in server_config: + invalid_servers.append(server_no) + if len(invalid_servers) > 0: + raise ConfigError(f'{domain_type} DNS servers {", ".join(invalid_servers)} in DDNS configuration need to have an IP address') + return None def verify(dhcp): # bail out early - looks like removal from running config @@ -422,6 +432,22 @@ def verify(dhcp): if not interface_exists(interface): raise ConfigError(f'listen-interface "{interface}" does not exist') + if 'dynamic_dns_update' in dhcp: + ddns = dhcp['dynamic_dns_update'] + if 'tsig_key' in ddns: + invalid_keys = [] + for tsig_key_name, tsig_key_config in ddns['tsig_key'].items(): + if not ('algorithm' in tsig_key_config and 'secret' in tsig_key_config): + invalid_keys.append(tsig_key_name) + if len(invalid_keys) > 0: + raise ConfigError(f'Both algorithm and secret need to be set for TSIG keys: {", ".join(invalid_keys)}') + + if 'forward_domain' in ddns: + verify_ddns_domain_servers('Forward', ddns['forward_domain']) + + if 'reverse_domain' in ddns: + verify_ddns_domain_servers('Reverse', ddns['reverse_domain']) + return None @@ -485,6 +511,14 @@ def generate(dhcp): user=user_group, group=user_group, ) + if 'dynamic_dns_update' in dhcp: + render( + config_file_d2, + 'dhcp-server/kea-dhcp-ddns.conf.j2', + dhcp, + user=user_group, + group=user_group + ) return None diff --git a/src/etc/systemd/system/kea-dhcp-ddns-server.service.d/override.conf b/src/etc/systemd/system/kea-dhcp-ddns-server.service.d/override.conf new file mode 100644 index 000000000..cdfdea8eb --- /dev/null +++ b/src/etc/systemd/system/kea-dhcp-ddns-server.service.d/override.conf @@ -0,0 +1,7 @@ +[Unit] +After= +After=vyos-router.service + +[Service] +ExecStart= +ExecStart=/usr/sbin/kea-dhcp-ddns -c /run/kea/kea-dhcp-ddns.conf -- cgit v1.2.3 From fa293316e679645e6397697428cee96e32e346fc Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Wed, 23 Apr 2025 15:11:16 +0300 Subject: T7181: VPP Static and dynamic NAT New CLI for static and dynamic NAT: ``` set vpp nat44 interface outside # multi set vpp nat44 interface inside # multi set vpp nat44 address-pool translation interface # multi set vpp nat44 address-pool translation address
# multi set vpp nat44 address-pool twice-nat interface # multi set vpp nat44 address-pool twice-nat address
# multi set vpp nat44 static rule external address
set vpp nat44 static rule external port set vpp nat44 static rule local address
set vpp nat44 static rule local port set vpp nat44 static rule protocol set vpp nat44 static rule options twice-nat set vpp nat44 static rule options self-twice-nat set vpp nat44 static rule options out-to-in-only set vpp nat44 static rule options twice-nat-address
set vpp nat44 exclude rule protocol set vpp nat44 exclude rule local-port set vpp nat44 exclude rule local-address
set vpp nat44 exclude rule external-interface ``` Settings: ``` set vpp settings nat44 session-limit # default 64512 set vpp settings nat44 timeout udp # default 300 set vpp settings nat44 timeout tcp-established # default 7440 set vpp settings nat44 timeout tcp-transitory # default 240 set vpp settings nat44 timeout icmp # default 60 set vpp settings nat44 workers set vpp settings nat44 no-forwarding ``` --- data/config-mode-dependencies/vyos-vpp.json | 17 +- .../include/vpp/nat_address_range.xml.i | 20 + .../include/vpp/nat_interface.xml.i | 11 + .../include/vpp/nat_protocol.xml.i | 30 ++ interface-definitions/vpp.xml.in | 293 +++++++++---- op-mode-definitions/show_vpp_nat44.xml.in | 12 + python/vyos/vpp/control_vpp.py | 50 ++- python/vyos/vpp/nat/nat44.py | 210 +++++----- src/conf_mode/vpp.py | 48 ++- src/conf_mode/vpp_nat.py | 458 +++++++++++++++++++++ src/conf_mode/vpp_nat_source.py | 119 ------ src/conf_mode/vpp_nat_static.py | 200 --------- src/op_mode/show_vpp_nat44.py | 91 +++- 13 files changed, 1043 insertions(+), 516 deletions(-) create mode 100644 interface-definitions/include/vpp/nat_address_range.xml.i create mode 100644 interface-definitions/include/vpp/nat_interface.xml.i create mode 100644 interface-definitions/include/vpp/nat_protocol.xml.i create mode 100644 src/conf_mode/vpp_nat.py delete mode 100644 src/conf_mode/vpp_nat_source.py delete mode 100644 src/conf_mode/vpp_nat_static.py (limited to 'python') diff --git a/data/config-mode-dependencies/vyos-vpp.json b/data/config-mode-dependencies/vyos-vpp.json index 307e763b9..46c1ee9f2 100644 --- a/data/config-mode-dependencies/vyos-vpp.json +++ b/data/config-mode-dependencies/vyos-vpp.json @@ -10,43 +10,42 @@ "vpp_interfaces_loopback": ["vpp_interfaces_loopback"], "vpp_interfaces_vxlan": ["vpp_interfaces_vxlan"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], - "vpp_nat_source": ["vpp_nat_source"], - "vpp_nat_static": ["vpp_nat_static"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_bonding": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], - "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ethernet": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], - "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_geneve": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], - "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_gre": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], - "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ipip": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], - "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_loopback": { - "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_vxlan": { "vpp_interfaces_bridge": ["vpp_interfaces_bridge"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], - "vpp_nat_source": ["vpp_nat_source"], + "vpp_nat": ["vpp_nat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] } } diff --git a/interface-definitions/include/vpp/nat_address_range.xml.i b/interface-definitions/include/vpp/nat_address_range.xml.i new file mode 100644 index 000000000..48648f91a --- /dev/null +++ b/interface-definitions/include/vpp/nat_address_range.xml.i @@ -0,0 +1,20 @@ + + + + IP address or range + + ipv4 + IPv4 address + + + ipv4range + IPv4 address range + + + + + + + + + diff --git a/interface-definitions/include/vpp/nat_interface.xml.i b/interface-definitions/include/vpp/nat_interface.xml.i new file mode 100644 index 000000000..20a7356bf --- /dev/null +++ b/interface-definitions/include/vpp/nat_interface.xml.i @@ -0,0 +1,11 @@ + + + + Add IP address from an interface + + + + + + + diff --git a/interface-definitions/include/vpp/nat_protocol.xml.i b/interface-definitions/include/vpp/nat_protocol.xml.i new file mode 100644 index 000000000..b88fd2b62 --- /dev/null +++ b/interface-definitions/include/vpp/nat_protocol.xml.i @@ -0,0 +1,30 @@ + + + + Protocol + + tcp udp icmp all + + + all + All protocols (TCP, UDP, and ICMP) + + + icmp + Internet Control Message Protocol (ICMP) + + + tcp + Transmission Control Protocol (TCP) + + + udp + User Datagram Protocol (UDP) + + + (tcp|udp|icmp|all) + + + all + + diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 263f9cb91..b1ae348da 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -788,6 +788,98 @@ + + + NAT settings + + + + + NAT44 session timeouts + + + + + ICMP timeout + + u32 + Timeout in seconds (default: 60) + + + 60 + + + + TCP established timeout + + u32 + TCP established timeout in seconds (default: 7440) + + + 7440 + + + + TCP transitory timeout + + u32 + Timeout in seconds (default: 240) + + + 240 + + + + UDP timeout + + u32 + Timeout in seconds (default: 60) + + + 60 + + + + + + Maximum number of sessions per thread + + u32 + Number of sessions + + + + + Number of sessions must be between 1 and 4294967295 + + 64512 + + + + List of NAT workers + + <id> + Worker id + + + <idN>-<idM> + Worker id range (use '-' as delimiter) + + + + + Not a valid value or range + + + + + + Do not forward packets which do not match existing NAT translations (static or dynamic) + + + + + Physical memory settings @@ -842,73 +934,65 @@ - + NAT44 + 320 - + - Source NAT setting - 320 + NAT interface setting - + NAT inside interface - any + - + NAT outside interface - any + + + + + + NAT address pool + + - Outside NAT IP (source NAT only) + NAT translation pool - - - IP address, subnet, or range - - masquerade - - - ipv4 - IPv4 address to match - - - ipv4range - IPv4 address range to match - - - masquerade - NAT to the primary address of outbound-interface - - - - - (masquerade) - - - + #include + #include + + + + + NAT twice-nat pool + + + #include + #include - + - Static NAT settings - 321 + Static NAT rules @@ -920,6 +1004,7 @@ + #include NAT external parameters @@ -940,50 +1025,6 @@ #include - - - Protocol to NAT - - tcp udp icmp all - - - all - All protocols (TCP, UDP, and ICMP) - - - icmp - Internet control message protocol - - - tcp - Transmission control protocol - - - udp - user datagram protocol - - - (tcp|udp|icmp|all) - - - all - - - - NAT outside interface - - - - - - - - NAT inside interface - - - - - NAT local parameters @@ -1004,6 +1045,96 @@ #include + + + NAT static mapping options + + + + + Rewrite source IP addresses on packets sent from outside to inside + + + + + + Rewrite source IP addresses on packets sent only from a local address to an external address + + + + + + Only apply rule for traffic from outside to inside interfaces + + + + + + Force use of specific IP address from twice-nat address pool + + ipv4 + IPv4 address + + + + + + + + + #include + + + + + + + Exclude packets matching these rules from NAT + + + + + Rule number + + u32 + Number of rule + + + + + + IP address of the internal (local) device + + ipv4 + IPv4 address + + + + + + + + + Port number used by connection on internal device + + u32:1-65535 + Numeric IP port + + + + + Port number must be in range 1 to 65535 + + + #include + + + External interface + + + + + #include diff --git a/op-mode-definitions/show_vpp_nat44.xml.in b/op-mode-definitions/show_vpp_nat44.xml.in index f2e77267a..96d53fba9 100644 --- a/op-mode-definitions/show_vpp_nat44.xml.in +++ b/op-mode-definitions/show_vpp_nat44.xml.in @@ -27,6 +27,18 @@ sudo ${vyos_op_scripts_dir}/show_vpp_nat44.py show_summary + + + Show VPP NAT44 pool addresses + + sudo ${vyos_op_scripts_dir}/show_vpp_nat44.py show_addresses + + + + Show VPP NAT44 interfaces + + sudo ${vyos_op_scripts_dir}/show_vpp_nat44.py show_interfaces + diff --git a/python/vyos/vpp/control_vpp.py b/python/vyos/vpp/control_vpp.py index 9d45bbd91..5db0ea560 100644 --- a/python/vyos/vpp/control_vpp.py +++ b/python/vyos/vpp/control_vpp.py @@ -1,5 +1,5 @@ # -# Copyright (C) 2023 VyOS Inc. +# Copyright (C) 2023-2025 VyOS Inc. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -439,6 +439,54 @@ class VPPControl: return iface.interface_dev_type return None + @_Decorators.api_call + def enable_disable_nat44_forwarding(self, enable: bool) -> None: + """Enable/disable NAT44 forwarding + + Args: + enable (bool): True if enable, False if disable + """ + self.__vpp_api_client.api.nat44_forwarding_enable_disable(enable=enable) + + @_Decorators.api_call + def set_nat_timeouts( + self, icmp: int, udp: int, tcp_established: int, tcp_transitory: int + ) -> None: + """Set NAT timeouts + + Args: + tcp_established (int): TCP established timeout + tcp_transitory (int): TCP transitory timeout + udp (int): UDP timeout + icmp (int): ICMP timeout + """ + self.__vpp_api_client.api.nat_set_timeouts( + icmp=icmp, + udp=udp, + tcp_established=tcp_established, + tcp_transitory=tcp_transitory, + ) + + @_Decorators.api_call + def set_nat44_session_limit(self, session_limit: int) -> None: + """Set NAT44 session limit + + Args: + session_limit (int): Maximum number of sessions per thread + """ + self.__vpp_api_client.api.nat44_set_session_limit( + session_limit=session_limit, + ) + + @_Decorators.api_call + def set_nat_workers(self, workers: int) -> None: + """Set NAT44 session limit + + Args: + workers (int): Bitmask of workers list + """ + self.__vpp_api_client.api.nat_set_workers(worker_mask=workers) + @property def connected(self) -> bool: """Check if VPP API is connected diff --git a/python/vyos/vpp/nat/nat44.py b/python/vyos/vpp/nat/nat44.py index 97c4518e6..e01e05153 100644 --- a/python/vyos/vpp/nat/nat44.py +++ b/python/vyos/vpp/nat/nat44.py @@ -18,16 +18,20 @@ from vyos.vpp import VPPControl +# NAT44 flags +NAT_IS_NONE = 0x00 +NAT_IS_TWICE_NAT = 0x01 +NAT_IS_SELF_TWICE_NAT = 0x02 +NAT_IS_OUT2IN_ONLY = 0x04 +NAT_IS_ADDR_ONLY = 0x08 +NAT_IS_OUTSIDE = 0x10 +NAT_IS_INSIDE = 0x20 + +NO_INTERFACE = 0xFFFFFFFF + + class Nat44: - def __init__( - self, - interface_in: str, - interface_out: str, - translation_pool: str, - ): - self.interface_in = interface_in - self.interface_out = interface_out - self.translation_pool = translation_pool + def __init__(self): self.vpp = VPPControl() def enable_nat44_ed(self): @@ -40,6 +44,10 @@ class Nat44: """ self.vpp.api.nat44_ed_plugin_enable_disable(enable=True) + def disable_nat44_ed(self): + """Disable NAT44 endpoint dependent plugin""" + self.vpp.api.nat44_ed_plugin_enable_disable(enable=False) + def enable_nat44_ei(self): """Enable NAT44 endpoint independent plugin Example: @@ -49,148 +57,164 @@ class Nat44: """ self.vpp.api.nat44_ei_plugin_enable_disable(enable=True) - def enable_nat44_forwarding(self): - """Enable NAT44 forwarding - Example: - from vyos.vpp.nat import Nat44 - nat44 = Nat44() - nat44.enable_nat44_forwarding() - """ - self.vpp.api.nat44_forwarding_enable_disable(enable=True) - - def disable_nat44_forwarding(self): - """Disable NAT44 forwarding - Example: - from vyos.vpp.nat import Nat44 - nat44 = Nat44() - nat44.disable_nat44_forwarding() - """ - self.vpp.api.nat44_forwarding_enable_disable(enable=False) - - def add_nat44_out_interface(self): - """Add NAT44 output interface - Example: - from vyos.vpp.nat import Nat44 - nat44 = Nat44('eth0') - nat44.add_nat44_out_interface() - """ - self.vpp.api.nat44_ed_add_del_output_interface( - sw_if_index=self.vpp.get_sw_if_index(self.interface_out), - is_add=True, - ) - - def delete_nat44_out_interface(self): - """Delete NAT44 output interface""" - self.vpp.api.nat44_ed_add_del_output_interface( - sw_if_index=self.vpp.get_sw_if_index(self.interface_out), - is_add=False, - ) - - def add_nat44_interface_inside(self): + def add_nat44_interface_inside(self, interface_in): """Add NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=0x20, - sw_if_index=self.vpp.get_sw_if_index(self.interface_in), + flags=NAT_IS_INSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_in), is_add=True, ) - def delete_nat44_interface_inside(self): + def delete_nat44_interface_inside(self, interface_in): """Delete NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=0x20, - sw_if_index=self.vpp.get_sw_if_index(self.interface_in), + flags=NAT_IS_INSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_in), is_add=False, ) - def add_nat44_interface_outside(self): + def add_nat44_interface_outside(self, interface_out): """Add NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=0x10, - sw_if_index=self.vpp.get_sw_if_index(self.interface_out), + flags=NAT_IS_OUTSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_out), is_add=True, ) - def delete_nat44_interface_outside(self): + def delete_nat44_interface_outside(self, interface_out): """Delete NAT44 interface""" self.vpp.api.nat44_interface_add_del_feature( - flags=0x10, - sw_if_index=self.vpp.get_sw_if_index(self.interface_out), + flags=NAT_IS_OUTSIDE, + sw_if_index=self.vpp.get_sw_if_index(interface_out), is_add=False, ) - def add_nat44_address_range(self): + def add_nat44_address_range(self, addresses, twice_nat): """Add NAT44 address range""" - if '-' not in self.translation_pool and self.translation_pool != 'masquerade': - first_ip_address = last_ip_address = self.translation_pool + if '-' not in addresses: + first_ip_address = last_ip_address = addresses else: - first_ip_address, last_ip_address = self.translation_pool.split('-') + first_ip_address, last_ip_address = addresses.split('-') self.vpp.api.nat44_add_del_address_range( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, first_ip_address=first_ip_address, last_ip_address=last_ip_address, is_add=True, ) - def delete_nat44_address_range(self): + def delete_nat44_address_range(self, addresses, twice_nat): """Delete NAT44 address range""" - if '-' not in self.translation_pool and self.translation_pool != 'masquerade': - first_ip_address = last_ip_address = self.translation_pool + if '-' not in addresses: + first_ip_address = last_ip_address = addresses else: - first_ip_address, last_ip_address = self.translation_pool.split('-') + first_ip_address, last_ip_address = addresses.split('-') self.vpp.api.nat44_add_del_address_range( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, first_ip_address=first_ip_address, last_ip_address=last_ip_address, is_add=False, ) - def enable_ipfix(self): - """Enable NAT44 IPFIX logging""" - self.vpp.api.nat44_ei_ipfix_enable_disable(enable=True) - - -class Nat44Static(Nat44): - def __init__(self): - self.vpp = VPPControl() - - def add_inside_interface(self, interface_in): - self.interface_in = interface_in - self.add_nat44_interface_inside() - - def delete_inside_interface(self, interface_in): - self.interface_in = interface_in - self.delete_nat44_interface_inside() - - def add_outside_interface(self, interface_out): - self.interface_out = interface_out - self.add_nat44_interface_outside() + def add_nat44_interface_address(self, interface, twice_nat): + """Add NAT44 interface address""" + self.vpp.api.nat44_add_del_interface_addr( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, + sw_if_index=self.vpp.get_sw_if_index(interface), + is_add=True, + ) - def delete_outside_interface(self, interface_out): - self.interface_out = interface_out - self.delete_nat44_interface_outside() + def delete_nat44_interface_address(self, interface, twice_nat): + """Delete NAT44 interface address""" + self.vpp.api.nat44_add_del_interface_addr( + flags=NAT_IS_TWICE_NAT if twice_nat else NAT_IS_NONE, + sw_if_index=self.vpp.get_sw_if_index(interface), + is_add=False, + ) def add_nat44_static_mapping( - self, local_ip, external_ip, local_port, external_port, protocol + self, + local_ip, + external_ip, + local_port, + external_port, + protocol, + twice_nat, + self_twice_nat, + out2in, + pool_ip, ): """Add NAT44 static mapping""" + flags = NAT_IS_ADDR_ONLY if not (protocol or local_port) else NAT_IS_NONE + flags |= NAT_IS_TWICE_NAT if twice_nat else 0 + flags |= NAT_IS_SELF_TWICE_NAT if self_twice_nat else 0 + flags |= NAT_IS_OUT2IN_ONLY if out2in else 0 self.vpp.api.nat44_add_del_static_mapping_v2( local_ip_address=local_ip, external_ip_address=external_ip, protocol=protocol, local_port=local_port, external_port=external_port, - flags=0x08 if not (protocol or local_port) else 0x00, + match_pool=True if pool_ip else False, + pool_ip_address=pool_ip if pool_ip else '', + flags=flags, is_add=True, ) def delete_nat44_static_mapping( - self, local_ip, external_ip, local_port, external_port, protocol + self, + local_ip, + external_ip, + local_port, + external_port, + protocol, + twice_nat, + self_twice_nat, + out2in, + pool_ip, ): """Delete NAT44 static mapping""" + flags = NAT_IS_ADDR_ONLY if not (protocol or local_port) else NAT_IS_NONE + flags |= NAT_IS_TWICE_NAT if twice_nat else 0 + flags |= NAT_IS_SELF_TWICE_NAT if self_twice_nat else 0 + flags |= NAT_IS_OUT2IN_ONLY if out2in else 0 self.vpp.api.nat44_add_del_static_mapping_v2( local_ip_address=local_ip, external_ip_address=external_ip, protocol=protocol, local_port=local_port, external_port=external_port, - flags=0x08 if not (protocol or local_port) else 0x00, + match_pool=True if pool_ip else False, + pool_ip_address=pool_ip if pool_ip else '', + flags=flags, is_add=False, ) + + def add_nat44_identity_mapping(self, ip_address, protocol, port, interface): + """Add NAT44 identity mapping""" + self.vpp.api.nat44_add_del_identity_mapping( + ip_address=ip_address, + protocol=protocol, + port=port, + sw_if_index=( + self.vpp.get_sw_if_index(interface) if interface else NO_INTERFACE + ), + flags=NAT_IS_ADDR_ONLY if not (protocol or port) else NAT_IS_NONE, + is_add=True, + ) + + def delete_nat44_identity_mapping(self, ip_address, protocol, port, interface): + """Delete NAT44 identity mapping""" + self.vpp.api.nat44_add_del_identity_mapping( + ip_address=ip_address, + protocol=protocol, + port=port, + sw_if_index=( + self.vpp.get_sw_if_index(interface) if interface else NO_INTERFACE + ), + flags=NAT_IS_ADDR_ONLY if not (protocol or port) else NAT_IS_NONE, + is_add=False, + ) + + def enable_ipfix(self): + """Enable NAT44 IPFIX logging""" + self.vpp.api.nat44_ei_ipfix_enable_disable(enable=True) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index ca7d19160..7f9adc68b 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -136,10 +136,8 @@ def get_config(config=None): set_dependents('ethernet', conf, removed_iface) # NAT dependency - if conf.exists(['vpp', 'nat44', 'source']): - set_dependents('vpp_nat_source', conf) - if conf.exists(['vpp', 'nat44', 'static']): - set_dependents('vpp_nat_static', conf) + if conf.exists(['vpp', 'nat44']): + set_dependents('vpp_nat', conf) if not conf.exists(base): return { @@ -424,6 +422,7 @@ def verify(config): 'Only one multipoint GRE tunnel is allowed from the same source address' ) + workers = 0 if 'cpu' in config['settings']: if ( 'corelist_workers' in config['settings']['cpu'] @@ -496,6 +495,22 @@ def verify(config): if not all(el in cpus_available for el in all_core_numbers): raise ConfigError('"cpu corelist-workers" is not correct') + workers = len(all_core_numbers) + + if 'workers' in config['settings']['nat44']: + nat_workers = [] + for worker_range in config['settings']['nat44']['workers']: + worker_numbers = worker_range.split('-') + if int(worker_numbers[0]) > int(worker_numbers[-1]): + raise ConfigError( + f'Range for "nat44 workers {worker_range}" is not correct' + ) + nat_workers.extend( + range(int(worker_numbers[0]), int(worker_numbers[-1]) + 1) + ) + if not all(el in list(range(workers)) for el in nat_workers): + raise ConfigError('"nat44 workers" is not correct') + verify_memory(config['settings']) if 'host_resources' in config['settings']: if ( @@ -716,6 +731,31 @@ def apply(config): # Syncronize routes via LCP vpp_control.lcp_resync() + # NAT44 settings + nat44_settings = config['settings'].get('nat44', {}) + + enable_forwarding = True + if 'no_forwarding' in nat44_settings: + enable_forwarding = False + vpp_control.enable_disable_nat44_forwarding(enable_forwarding) + + vpp_control.set_nat_timeouts( + icmp=int(nat44_settings.get('timeout').get('icmp')), + udp=int(nat44_settings.get('timeout').get('udp')), + tcp_established=int(nat44_settings.get('timeout').get('tcp_established')), + tcp_transitory=int(nat44_settings.get('timeout').get('tcp_transitory')), + ) + + vpp_control.set_nat44_session_limit(int(nat44_settings.get('session_limit'))) + + if nat44_settings.get('workers'): + bitmask = 0 + for worker_range in nat44_settings['workers']: + worker_numbers = worker_range.split('-') + for wid in range(int(worker_numbers[0]), int(worker_numbers[-1]) + 1): + bitmask |= 1 << wid + vpp_control.set_nat_workers(bitmask) + # Save persistent config if 'persist_config' in config and config['persist_config']: persist_config.write('eth_ifaces', config['persist_config']) diff --git a/src/conf_mode/vpp_nat.py b/src/conf_mode/vpp_nat.py new file mode 100644 index 000000000..d38599388 --- /dev/null +++ b/src/conf_mode/vpp_nat.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ipaddress + +from vyos import ConfigError + +from vyos.configdiff import Diff +from vyos.configdict import node_changed +from vyos.config import Config +from vyos.utils.network import get_interface_address + +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.nat.nat44 import Nat44 + + +protocol_map = { + 'all': 0, + 'icmp': 1, + 'tcp': 6, + 'udp': 17, +} + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat44'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + return config + + config_changed = node_changed( + conf, + base, + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_static_rules = node_changed( + conf, + base + ['static', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_exclude_rules = node_changed( + conf, + base + ['exclude', 'rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + if not config_changed: + changed_static_rules = list(config.get('static', {}).get('rule', {}).keys()) + changed_exclude_rules = list(config.get('exclude', {}).get('rule', {}).keys()) + + config.update( + { + 'changed_static_rules': changed_static_rules, + 'changed_exclude_rules': changed_exclude_rules, + 'vpp_ifaces': cli_ifaces_list(conf), + } + ) + + if effective_config: + config.update({'effective': effective_config}) + + return config + + +def convert_range_to_list_ips(address_range) -> list: + """Converts IP range to a list of IPs . + + Example: + % ip = IPOperations('192.0.0.1-192.0.2.5') + % ip.convert_prefix_to_list_ips() + ['192.0.2.1', '192.0.2.2', '192.0.2.3', '192.0.2.4', '192.0.2.5'] + """ + if '-' in address_range: + start_ip, end_ip = address_range.split('-') + start_ip = ipaddress.ip_address(start_ip) + end_ip = ipaddress.ip_address(end_ip) + return [ + str(ipaddress.ip_address(ip)) + for ip in range(int(start_ip), int(end_ip) + 1) + ] + else: + return [address_range] + + +def verify(config): + if 'remove' in config: + return None + + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for NAT44') + + required_keys = {'inside', 'outside'} + missing_keys = required_keys - set(config['interface'].keys()) + if missing_keys: + raise ConfigError( + f'Both inside and outside interfaces must be configured. Please add: {", ".join(missing_keys)}' + ) + + for interface in config['interface']['inside']: + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for inside NAT interface' + ) + for interface in config['interface']['outside']: + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for outside NAT interface' + ) + + if not config.get('address_pool', {}).get('translation') and not config.get( + 'static', {} + ).get('rule'): + raise ConfigError('"address-pool translation" or "static rule" is required') + + addresses_translation = [] + addresses_twice_nat = [] + if 'address_pool' in config: + address_pool = config.get('address_pool') + if 'translation' in address_pool: + if not address_pool['translation'].get('address') and not address_pool[ + 'translation' + ].get('interface'): + raise ConfigError( + '"address-pool translation" requires address or interface' + ) + + for address_range in address_pool['translation'].get('address', []): + addresses = convert_range_to_list_ips(address_range) + for address in addresses: + if address in addresses_translation: + raise ConfigError( + f'Address {address} is already in use in "address-pool translation address"' + ) + addresses_translation.append(address) + + for interface in address_pool['translation'].get('interface', []): + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for "address-pool translation interface"' + ) + iface_address = get_interface_address(interface).get('addr_info', [])[0].get('local') + addresses_translation.append(iface_address) + + if 'twice_nat' in address_pool: + if not address_pool['twice_nat'].get('address') and not address_pool[ + 'twice_nat' + ].get('interface'): + raise ConfigError( + '"address-pool twice-nat" requires address or interface' + ) + + for address_range in address_pool['twice_nat'].get('address', []): + addresses = convert_range_to_list_ips(address_range) + for address in addresses: + if address in addresses_twice_nat: + raise ConfigError( + f'Address {address} is already in use in "address-pool twice-nat address"' + ) + addresses_twice_nat.append(address) + + for interface in address_pool['twice_nat'].get('interface', []): + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for "address-pool twice-nat interface"' + ) + iface_address = get_interface_address(interface).get('addr_info', [])[0].get('local') + addresses_twice_nat.append(iface_address) + + if 'static' in config: + addresses_with_ports = set() + addresses_without_ports = set() + local_addresses = set() + + for rule, rule_config in config['static']['rule'].items(): + error_msg = f'Configuration error in static rule {rule}:' + + if not rule_config.get('local', {}).get('address'): + raise ConfigError(f'{error_msg} local settings require address') + + if not rule_config.get('external', {}).get('address'): + raise ConfigError(f'{error_msg} external settings require address') + + has_local_port = 'port' in rule_config.get('local', {}) + has_external_port = 'port' in rule_config.get('external', {}) + + if not has_external_port == has_local_port: + raise ConfigError( + f'{error_msg} source and destination ports must either ' + 'both be specified, or neither must be specified' + ) + + ext_address = rule_config['external']['address'] + port = rule_config['external'].get('port') + local_address = rule_config['local']['address'] + + if port: + pair = (ext_address, port) + if ( + pair in addresses_with_ports + or ext_address in addresses_without_ports + ): + raise ConfigError( + f'{error_msg} external address/port is already in use!' + ) + addresses_with_ports.add(pair) + if ext_address not in addresses_translation: + raise ConfigError( + f'{error_msg} external address {ext_address} is not in "address-pool translation"' + ) + + else: + if ext_address in addresses_without_ports or any( + addr == ext_address for addr, _ in addresses_with_ports + ): + raise ConfigError( + f'{error_msg} external address is already in use!' + ) + addresses_without_ports.add(ext_address) + + if local_address in local_addresses: + raise ConfigError( + f'{error_msg} local address {local_address} is already in use' + ) + local_addresses.add(local_address) + + options = rule_config.get('options', {}) + if all(key in options for key in ('twice_nat', 'self_twice_nat')): + raise ConfigError( + f'{error_msg} cannot set both options "twice-nat" and "self-twice-nat"' + ) + if any(key in options for key in ('twice_nat', 'self_twice_nat')): + if not has_local_port or rule_config['protocol'] == 'all': + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat options require port and protocol to be set' + ) + if not config.get('address_pool', {}).get('twice_nat'): + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat options require "address-pool twice-nat" to be set' + ) + if 'twice_nat_address' in options: + if not any(key in options for key in ('twice_nat', 'self_twice_nat')): + raise ConfigError( + f'{error_msg} twice-nat/self-twice-nat option required when twice-nat-address is set' + ) + tn_address = options['twice_nat_address'] + if tn_address not in addresses_twice_nat: + raise ConfigError( + f'{error_msg} twice-nat-address {tn_address} is not in "address-pool twice-nat"' + ) + + if 'exclude' in config: + for rule, rule_config in config['exclude']['rule'].items(): + keys = {'local_address', 'external_interface'} + if not any(key in rule_config for key in keys): + raise ConfigError( + f'Local-address or external-interface must be specified for exclude rule {rule}' + ) + if all(key in rule_config for key in keys): + raise ConfigError( + f'Cannot set both address and interface for exclude rule {rule}' + ) + if ( + 'external_interface' in rule_config + and rule_config.get('external_interface') not in config['vpp_ifaces'] + ): + raise ConfigError( + f'{rule_config["external_interface"]} must be a VPP interface for exclude rule {rule}' + ) + + +def generate(config): + pass + + +def apply(config): + n = Nat44() + + if 'remove' in config: + n.disable_nat44_ed() + return None + + if 'effective' in config: + remove_config = config.get('effective') + # Delete inside interfaces + for interface in remove_config['interface']['inside']: + if interface not in config.get('interface', {}).get('inside', []): + n.delete_nat44_interface_inside(interface) + # Delete outside interfaces + for interface in remove_config['interface']['outside']: + if interface not in config.get('interface', {}).get('outside', []): + n.delete_nat44_interface_outside(interface) + # Delete address pool + address_pool = config.get('address_pool', {}) + for address in ( + remove_config.get('address_pool', {}) + .get('translation', {}) + .get('address', []) + ): + if address not in address_pool.get('translation', {}).get('address', []): + n.delete_nat44_address_range(address, twice_nat=False) + for interface in ( + remove_config.get('address_pool', {}) + .get('translation', {}) + .get('interface', []) + ): + if interface not in address_pool.get('translation', {}).get( + 'interface', [] + ): + n.delete_nat44_interface_address(interface, twice_nat=False) + for address in ( + remove_config.get('address_pool', {}) + .get('twice_nat', {}) + .get('address', []) + ): + if address not in address_pool.get('twice_nat', {}).get('address', []): + n.delete_nat44_address_range(address, twice_nat=True) + for interface in ( + remove_config.get('address_pool', {}) + .get('twice_nat', {}) + .get('interface', []) + ): + if interface not in address_pool.get('twice_nat', {}).get('interface', []): + n.delete_nat44_interface_address(interface, twice_nat=True) + # Delete NAT static mapping rules + for rule in config['changed_static_rules']: + if rule in remove_config.get('static', {}).get('rule', {}): + rule_config = remove_config['static']['rule'][rule] + n.delete_nat44_static_mapping( + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + twice_nat='twice_nat' in rule_config.get('options', {}), + self_twice_nat='self_twice_nat' in rule_config.get('options', {}), + out2in='out_to_in_only' in rule_config.get('options', {}), + pool_ip=rule_config.get('options', {}).get('twice_nat_address'), + ) + # Delete NAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in remove_config.get('exclude', {}).get('rule', {}): + rule_config = remove_config['exclude']['rule'][rule] + n.delete_nat44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + interface=rule_config.get('external_interface'), + ) + + # Add NAT44 + n.enable_nat44_ed() + # Add inside interfaces + for interface in config['interface']['inside']: + n.add_nat44_interface_inside(interface) + # Add outside interfaces + for interface in config['interface']['outside']: + n.add_nat44_interface_outside(interface) + # Add translation pool + for address in ( + config.get('address_pool', {}).get('translation', {}).get('address', []) + ): + n.add_nat44_address_range(address, twice_nat=False) + for interface in ( + config.get('address_pool', {}).get('translation', {}).get('interface', []) + ): + n.add_nat44_interface_address(interface, twice_nat=False) + for address in ( + config.get('address_pool', {}).get('twice_nat', {}).get('address', []) + ): + n.add_nat44_address_range(address, twice_nat=True) + for interface in ( + config.get('address_pool', {}).get('twice_nat', {}).get('interface', []) + ): + n.add_nat44_interface_address(interface, twice_nat=True) + # Add NAT static mapping rules + for rule in config['changed_static_rules']: + if rule in config.get('static', {}).get('rule', {}): + rule_config = config['static']['rule'][rule] + n.add_nat44_static_mapping( + local_ip=rule_config.get('local').get('address'), + external_ip=rule_config.get('external', {}).get('address', ''), + local_port=int(rule_config.get('local', {}).get('port', 0)), + external_port=int(rule_config.get('external', {}).get('port', 0)), + protocol=protocol_map[rule_config.get('protocol', 'all')], + twice_nat='twice_nat' in rule_config.get('options', {}), + self_twice_nat='self_twice_nat' in rule_config.get('options', {}), + out2in='out_to_in_only' in rule_config.get('options', {}), + pool_ip=rule_config.get('options', {}).get('twice_nat_address'), + ) + # Add NAT exclude rules + for rule in config['changed_exclude_rules']: + if rule in config.get('exclude', {}).get('rule', {}): + rule_config = config['exclude']['rule'][rule] + n.add_nat44_identity_mapping( + ip_address=rule_config.get('local_address'), + protocol=protocol_map[rule_config.get('protocol', 'all')], + port=int(rule_config.get('local_port', 0)), + interface=rule_config.get('external_interface'), + ) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_nat_source.py b/src/conf_mode/vpp_nat_source.py deleted file mode 100644 index 40b16a3c8..000000000 --- a/src/conf_mode/vpp_nat_source.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2025 VyOS Inc. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -from vyos.config import Config -from vyos import ConfigError -from vyos.vpp.nat.nat44 import Nat44 - - -def get_config(config=None) -> dict: - if config: - conf = config - else: - conf = Config() - - base = ['vpp', 'nat44', 'source'] - - # Get config_dict with default values - config = conf.get_config_dict( - base, - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True, - with_defaults=True, - with_recursive_defaults=True, - ) - - # Get effective config as we need full dicitonary per interface delete - effective_config = conf.get_config_dict( - base, - key_mangling=('-', '_'), - effective=True, - get_first_key=True, - no_tag_node_value_mangle=True, - ) - - if not config: - config['remove'] = True - - if effective_config: - config.update({'effective': effective_config}) - - return config - - -def verify(config): - if 'remove' in config: - return None - - required_keys = {'inside_interface', 'outside_interface'} - if not all(key in config for key in required_keys): - missing_keys = required_keys - set(config.keys()) - raise ConfigError( - f"Required options are missing: {', '.join(missing_keys).replace('_', '-')}" - ) - - if not config.get('translation', {}).get('address'): - raise ConfigError('Translation requires address') - - if config.get('translation', {}).get('address') == 'masquerade': - raise ConfigError('Masquerade is not implemented') - - -def generate(config): - pass - - -def apply(config): - # Delete NAT source - if 'effective' in config: - remove_config = config.get('effective') - interface_in = remove_config.get('inside_interface') - interface_out = remove_config.get('outside_interface') - translation_address = remove_config.get('translation', {}).get('address') - - n = Nat44(interface_in, interface_out, translation_address) - n.delete_nat44_out_interface() - n.delete_nat44_interface_inside() - n.delete_nat44_address_range() - - if 'remove' in config: - return None - - # Add NAT44 - interface_in = config.get('inside_interface') - interface_out = config.get('outside_interface') - translation_address = config.get('translation', {}).get('address') - - n = Nat44(interface_in, interface_out, translation_address) - n.enable_nat44_ed() - n.enable_nat44_forwarding() - n.add_nat44_out_interface() - # n.add_nat44_interface_inside() - n.add_nat44_address_range() - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/conf_mode/vpp_nat_static.py b/src/conf_mode/vpp_nat_static.py deleted file mode 100644 index b890ea150..000000000 --- a/src/conf_mode/vpp_nat_static.py +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (C) 2025 VyOS Inc. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -from vyos.configdiff import Diff -from vyos.configdiff import get_config_diff -from vyos.configdict import node_changed -from vyos.config import Config -from vyos import ConfigError -from vyos.vpp.nat.nat44 import Nat44Static - - -protocol_map = { - 'all': 0, - 'icmp': 1, - 'tcp': 6, - 'udp': 17, -} - - -def get_config(config=None) -> dict: - if config: - conf = config - else: - conf = Config() - - base = ['vpp', 'nat44', 'static'] - - # Get config_dict with default values - config = conf.get_config_dict( - base, - key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True, - with_defaults=True, - with_recursive_defaults=True, - ) - - # Get effective config as we need full dictionary per interface delete - effective_config = conf.get_config_dict( - base, - key_mangling=('-', '_'), - effective=True, - get_first_key=True, - no_tag_node_value_mangle=True, - ) - - if not config: - config['remove'] = True - - in_iface_add = [] - in_iface_del = [] - out_iface_add = [] - out_iface_del = [] - - changed_rules = node_changed( - conf, - base + ['rule'], - key_mangling=('-', '_'), - recursive=True, - expand_nodes=Diff.DELETE | Diff.ADD, - ) - diff = get_config_diff(conf) - - for rule in changed_rules: - base_rule = base + ['rule', rule] - tmp = node_changed( - conf, - base_rule, - key_mangling=('-', '_'), - recursive=True, - expand_nodes=Diff.DELETE | Diff.ADD, - ) - - if 'inside_interface' in tmp: - new, old = diff.get_value_diff(base_rule + ['inside-interface']) - in_iface_add.append(new) if new else None - in_iface_del.append(old) if old else None - if 'outside_interface' in tmp: - new, old = diff.get_value_diff(base_rule + ['outside-interface']) - out_iface_add.append(new) if new else None - out_iface_del.append(old) if old else None - - final_in_iface_add = list(set(in_iface_add) - set(in_iface_del)) - final_in_iface_del = list(set(in_iface_del) - set(in_iface_add)) - final_out_iface_add = list(set(out_iface_add) - set(out_iface_del)) - final_out_iface_del = list(set(out_iface_del) - set(out_iface_add)) - - config.update( - { - 'in_iface_add': final_in_iface_add, - 'in_iface_del': final_in_iface_del, - 'out_iface_add': final_out_iface_add, - 'out_iface_del': final_out_iface_del, - 'changed_rules': changed_rules, - } - ) - - if effective_config: - config.update({'effective': effective_config}) - - return config - - -def verify(config): - if 'remove' in config: - return None - - required_keys = {'inside_interface', 'outside_interface'} - for rule, rule_config in config['rule'].items(): - missing_keys = required_keys - rule_config.keys() - if missing_keys: - raise ConfigError( - f"Required options are missing: {', '.join(missing_keys).replace('_', '-')} in rule {rule}" - ) - - if not rule_config.get('local', {}).get('address'): - raise ConfigError(f'Local settings require address in rule {rule}') - - if not rule_config.get('external', {}).get('address'): - raise ConfigError(f'External settings require address in rule {rule}') - - has_local_port = 'port' in rule_config.get('local', {}) - has_external_port = 'port' in rule_config.get('external', {}) - - if not has_external_port == has_local_port: - raise ConfigError( - 'Source and destination ports must either both be specified, or neither must be specified' - ) - - -def generate(config): - pass - - -def apply(config): - n = Nat44Static() - - # Delete inside interfaces - for interface in config['in_iface_del']: - n.delete_inside_interface(interface) - # Delete outside interfaces - for interface in config['out_iface_del']: - n.delete_outside_interface(interface) - # Delete NAT static mapping rules - for rule in config['changed_rules']: - if rule in config.get('effective', {}).get('rule', {}): - rule_config = config['effective']['rule'][rule] - n.delete_nat44_static_mapping( - local_ip=rule_config.get('local').get('address'), - external_ip=rule_config.get('external', {}).get('address', ''), - local_port=int(rule_config.get('local', {}).get('port', 0)), - external_port=int(rule_config.get('external', {}).get('port', 0)), - protocol=protocol_map[rule_config.get('protocol', 'all')], - ) - - if 'remove' in config: - return None - - # Add NAT44 static mapping rules - n.enable_nat44_ed() - for interface in config['in_iface_add']: - n.add_inside_interface(interface) - for interface in config['out_iface_add']: - n.add_outside_interface(interface) - for rule in config['changed_rules']: - if rule in config.get('rule', {}): - rule_config = config['rule'][rule] - n.add_nat44_static_mapping( - local_ip=rule_config.get('local').get('address'), - external_ip=rule_config.get('external', {}).get('address', ''), - local_port=int(rule_config.get('local', {}).get('port', 0)), - external_port=int(rule_config.get('external', {}).get('port', 0)), - protocol=protocol_map[rule_config.get('protocol', 'all')], - ) - - -if __name__ == '__main__': - try: - c = get_config() - verify(c) - generate(c) - apply(c) - except ConfigError as e: - print(e) - exit(1) diff --git a/src/op_mode/show_vpp_nat44.py b/src/op_mode/show_vpp_nat44.py index fcf28f76a..d0569ac49 100644 --- a/src/op_mode/show_vpp_nat44.py +++ b/src/op_mode/show_vpp_nat44.py @@ -33,6 +33,15 @@ protocol_map = { 17: 'udp', } +# NAT flags +flags_map = { + 'twice-nat': 0x01, + 'self-twice-nat': 0x02, + 'out2in-only': 0x04, + 'out': 0x10, + 'in': 0x20, +} + def _verify(func): """Decorator checks if config for VPP NAT44 exists""" @@ -50,6 +59,16 @@ def _verify(func): return _wrapper +def decode_bitmask(bitmask: int) -> list: + """Decode a bitmask into a list of flag names""" + return [name for name, value in flags_map.items() if bitmask & value] + + +def _get_raw_output(data_dump): + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + def _get_raw_output_sessions(vpp_api): users: list[dict] = vpp_api.nat44_user_dump() sessions_list: list[dict] = [] @@ -108,12 +127,30 @@ def _get_formatted_output_sessions(sessions_list): print('\n') -def _get_raw_output_static_rules(vpp_api): - nat_static_dump = vpp_api.nat44_static_mapping_dump() - rules_list = [ - json.loads(json.dumps(rule._asdict(), default=str)) for rule in nat_static_dump - ] - return rules_list +def _get_formatted_output_addresses(addresses): + twice_nat_address = [] + translation_address = [] + for address_info in addresses: + address = address_info.get('ip_address') + if address_info.get('flags') & flags_map['twice-nat']: + twice_nat_address.append(address) + else: + translation_address.append(address) + + print('NAT44 pool addresses:') + for addr in translation_address: + print(f' {addr}') + print('NAT44 twice-nat pool addresses:') + for addr in twice_nat_address: + print(f' {addr}') + + +def _get_formatted_output_interfaces(vpp, interfaces): + print('NAT44 interfaces:') + for interface in interfaces: + name = vpp.get_interface_name(interface['sw_if_index']) + iface_type = decode_bitmask(interface['flags']) + print(f' {name} {" ".join(iface_type)}') def _get_formatted_output_rules(rules_list): @@ -124,8 +161,16 @@ def _get_formatted_output_rules(rules_list): local_address = rule.get('local_ip_address') local_port = rule.get('local_port') or '' protocol = protocol_map[rule.get('protocol', 0)] - - values = [external_address, external_port, local_address, local_port, protocol] + options = ' '.join(decode_bitmask(rule.get('flags'))) + + values = [ + external_address, + external_port, + local_address, + local_port, + protocol, + options, + ] data_entries.append(values) headers = [ 'External address', @@ -133,6 +178,7 @@ def _get_formatted_output_rules(rules_list): 'Local address', 'Local port', 'Protocol', + 'Options', ] out = sorted(data_entries, key=lambda x: x[2]) return tabulate(out, headers=headers, tablefmt='simple') @@ -159,7 +205,8 @@ def show_summary(raw: bool): @_verify def show_static(raw: bool): vpp = VPPControl() - rules_list: list[dict] = _get_raw_output_static_rules(vpp.api) + nat_static_dump = vpp.api.nat44_static_mapping_dump() + rules_list: list[dict] = _get_raw_output(nat_static_dump) if raw: return rules_list @@ -168,6 +215,32 @@ def show_static(raw: bool): return _get_formatted_output_rules(rules_list) +@_verify +def show_addresses(raw: bool): + vpp = VPPControl() + addresses_dump = vpp.api.nat44_address_dump() + addresses: list[dict] = _get_raw_output(addresses_dump) + + if raw: + return addresses + + else: + return _get_formatted_output_addresses(addresses) + + +@_verify +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.nat44_interface_dump() + interfaces: list[dict] = _get_raw_output(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + if __name__ == '__main__': try: res = vyos.opmode.run(sys.modules[__name__]) -- cgit v1.2.3 From 2b009d420f28a61353cc373abdea957df0555d18 Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Wed, 23 Apr 2025 16:32:43 +0200 Subject: wireguard: T7387: Optimise wireguard peer handling --- python/vyos/ifconfig/wireguard.py | 160 +++++++++++++-------- python/vyos/utils/network.py | 15 ++ smoketest/scripts/cli/test_interfaces_wireguard.py | 27 +++- src/conf_mode/interfaces_wireguard.py | 16 +-- 4 files changed, 140 insertions(+), 78 deletions(-) (limited to 'python') diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index f5217aecb..3a28723b3 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -22,12 +22,13 @@ from tempfile import NamedTemporaryFile from hurry.filesize import size from hurry.filesize import alternative +from vyos.base import Warning from vyos.configquery import ConfigTreeQuery from vyos.ifconfig import Interface from vyos.ifconfig import Operational from vyos.template import is_ipv6 from vyos.template import is_ipv4 - +from vyos.utils.network import get_wireguard_peers class WireGuardOperational(Operational): def _dump(self): """Dump wireguard data in a python friendly way.""" @@ -251,92 +252,131 @@ class WireGuardIf(Interface): """Get a synthetic MAC address.""" return self.get_mac_synthetic() + def get_peer_public_keys(self, config, disabled=False): + """Get list of configured peer public keys""" + if 'peer' not in config: + return [] + + public_keys = [] + + for _, peer_config in config['peer'].items(): + if disabled == ('disable' in peer_config): + public_keys.append(peer_config['public_key']) + + return public_keys + def update(self, config): """General helper function which works on a dictionary retrived by get_config_dict(). It's main intention is to consolidate the scattered interface setup code and provide a single point of entry when workin on any interface.""" - tmp_file = NamedTemporaryFile('w') - tmp_file.write(config['private_key']) - tmp_file.flush() # Wireguard base command is identical for every peer base_cmd = f'wg set {self.ifname}' + interface_cmd = base_cmd if 'port' in config: interface_cmd += ' listen-port {port}' if 'fwmark' in config: interface_cmd += ' fwmark {fwmark}' - interface_cmd += f' private-key {tmp_file.name}' - interface_cmd = interface_cmd.format(**config) - # T6490: execute command to ensure interface configured - self._cmd(interface_cmd) + with NamedTemporaryFile('w') as tmp_file: + tmp_file.write(config['private_key']) + tmp_file.flush() - # If no PSK is given remove it by using /dev/null - passing keys via - # the shell (usually bash) is considered insecure, thus we use a file - no_psk_file = '/dev/null' + interface_cmd += f' private-key {tmp_file.name}' + interface_cmd = interface_cmd.format(**config) + # T6490: execute command to ensure interface configured + self._cmd(interface_cmd) + + current_peer_public_keys = get_wireguard_peers(self.ifname) + + if 'rebuild_required' in config: + # Remove all existing peers that no longer exist in config + current_public_keys = self.get_peer_public_keys(config) + cmd_remove_peers = [f' peer {public_key} remove' + for public_key in current_peer_public_keys + if public_key not in current_public_keys] + if cmd_remove_peers: + self._cmd(base_cmd + ''.join(cmd_remove_peers)) if 'peer' in config: + # Group removal of disabled peers in one command + current_disabled_peers = self.get_peer_public_keys(config, disabled=True) + cmd_disabled_peers = [f' peer {public_key} remove' + for public_key in current_disabled_peers] + if cmd_disabled_peers: + self._cmd(base_cmd + ''.join(cmd_disabled_peers)) + + peer_cmds = [] + peer_domain_cmds = [] + peer_psk_files = [] + for peer, peer_config in config['peer'].items(): # T4702: No need to configure this peer when it was explicitly # marked as disabled - also active sessions are terminated as # the public key was already removed when entering this method! if 'disable' in peer_config: - # remove peer if disabled, no error report even if peer not exists - cmd = base_cmd + ' peer {public_key} remove' - self._cmd(cmd.format(**peer_config)) continue - psk_file = no_psk_file - # start of with a fresh 'wg' command - peer_cmd = base_cmd + ' peer {public_key}' + peer_cmd = ' peer {public_key}' - try: - cmd = peer_cmd - - if 'preshared_key' in peer_config: - psk_file = '/tmp/tmp.wireguard.psk' - with open(psk_file, 'w') as f: - f.write(peer_config['preshared_key']) - cmd += f' preshared-key {psk_file}' - - # Persistent keepalive is optional - if 'persistent_keepalive' in peer_config: - cmd += ' persistent-keepalive {persistent_keepalive}' - - # Multiple allowed-ip ranges can be defined - ensure we are always - # dealing with a list - if isinstance(peer_config['allowed_ips'], str): - peer_config['allowed_ips'] = [peer_config['allowed_ips']] - cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) - - self._cmd(cmd.format(**peer_config)) - - cmd = peer_cmd - - # Ensure peer is created even if dns not working - if {'address', 'port'} <= set(peer_config): - if is_ipv6(peer_config['address']): - cmd += ' endpoint [{address}]:{port}' - elif is_ipv4(peer_config['address']): - cmd += ' endpoint {address}:{port}' - else: - # don't set endpoint if address uses domain name - continue - elif {'host_name', 'port'} <= set(peer_config): - cmd += ' endpoint {host_name}:{port}' - - self._cmd(cmd.format(**peer_config), env={ + cmd = peer_cmd + + if 'preshared_key' in peer_config: + with NamedTemporaryFile(mode='w', delete=False) as tmp_file: + tmp_file.write(peer_config['preshared_key']) + tmp_file.flush() + cmd += f' preshared-key {tmp_file.name}' + peer_psk_files.append(tmp_file.name) + else: + # If no PSK is given remove it by using /dev/null - passing keys via + # the shell (usually bash) is considered insecure, thus we use a file + cmd += f' preshared-key /dev/null' + + # Persistent keepalive is optional + if 'persistent_keepalive' in peer_config: + cmd += ' persistent-keepalive {persistent_keepalive}' + + # Multiple allowed-ip ranges can be defined - ensure we are always + # dealing with a list + if isinstance(peer_config['allowed_ips'], str): + peer_config['allowed_ips'] = [peer_config['allowed_ips']] + cmd += ' allowed-ips ' + ','.join(peer_config['allowed_ips']) + + peer_cmds.append(cmd.format(**peer_config)) + + cmd = peer_cmd + + # Ensure peer is created even if dns not working + if {'address', 'port'} <= set(peer_config): + if is_ipv6(peer_config['address']): + cmd += ' endpoint [{address}]:{port}' + elif is_ipv4(peer_config['address']): + cmd += ' endpoint {address}:{port}' + else: + # don't set endpoint if address uses domain name + continue + elif {'host_name', 'port'} <= set(peer_config): + cmd += ' endpoint {host_name}:{port}' + else: + continue + + peer_domain_cmds.append(cmd.format(**peer_config)) + + try: + if peer_cmds: + self._cmd(base_cmd + ''.join(peer_cmds)) + + if peer_domain_cmds: + self._cmd(base_cmd + ''.join(peer_domain_cmds), env={ 'WG_ENDPOINT_RESOLUTION_RETRIES': config['max_dns_retry']}) - except: - # todo: logging - pass - finally: - # PSK key file is not required to be stored persistently as its backed by CLI - if psk_file != no_psk_file and os.path.exists(psk_file): - os.remove(psk_file) + except Exception as e: + Warning(f'Failed to apply Wireguard peers on {self.ifname}: {e}') + finally: + for tmp in peer_psk_files: + os.unlink(tmp) # call base class super().update(config) diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 2f666f0ee..7332bb63d 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -396,6 +396,21 @@ def is_wireguard_key_pair(private_key: str, public_key:str) -> bool: else: return False +def get_wireguard_peers(ifname: str) -> list: + """ + Return list of configured Wireguard peers for interface + :param ifname: Interface name + :type ifname: str + :return: list of public keys + :rtype: list + """ + if not interface_exists(ifname): + return [] + + from vyos.utils.process import cmd + peers = cmd(f'wg show {ifname} peers') + return peers.splitlines() + def is_subnet_connected(subnet, primary=False): """ Verify is the given IPv4/IPv6 subnet is connected to any interface on this diff --git a/smoketest/scripts/cli/test_interfaces_wireguard.py b/smoketest/scripts/cli/test_interfaces_wireguard.py index f8cd18cf2..7bc82c187 100755 --- a/smoketest/scripts/cli/test_interfaces_wireguard.py +++ b/smoketest/scripts/cli/test_interfaces_wireguard.py @@ -154,13 +154,15 @@ class WireGuardInterfaceTest(BasicInterfaceTest.TestCase): tmp = read_file(f'/sys/class/net/{intf}/threaded') self.assertTrue(tmp, "1") - def test_wireguard_peer_pubkey_change(self): + def test_wireguard_peer_change(self): # T5707 changing WireGuard CLI public key of a peer - it's not removed + # Also check if allowed-ips update - def get_peers(interface) -> list: + def get_peers(interface) -> list[tuple]: tmp = cmd(f'sudo wg show {interface} dump') first_line = True peers = [] + allowed_ips = [] for line in tmp.split('\n'): if not line: continue # Skip empty lines and last line @@ -170,24 +172,27 @@ class WireGuardInterfaceTest(BasicInterfaceTest.TestCase): first_line = False else: peers.append(items[0]) - return peers + allowed_ips.append(items[3]) + return peers, allowed_ips interface = 'wg1337' port = '1337' privkey = 'iJi4lb2HhkLx2KSAGOjji2alKkYsJjSPkHkrcpxgEVU=' pubkey_1 = 'srQ8VF6z/LDjKCzpxBzFpmaNUOeuHYzIfc2dcmoc/h4=' pubkey_2 = '8pbMHiQ7NECVP7F65Mb2W8+4ldGG2oaGvDSpSEsOBn8=' + allowed_ips_1 = '10.205.212.10/32' + allowed_ips_2 = '10.205.212.11/32' self.cli_set(base_path + [interface, 'address', '172.16.0.1/24']) self.cli_set(base_path + [interface, 'port', port]) self.cli_set(base_path + [interface, 'private-key', privkey]) self.cli_set(base_path + [interface, 'peer', 'VyOS', 'public-key', pubkey_1]) - self.cli_set(base_path + [interface, 'peer', 'VyOS', 'allowed-ips', '10.205.212.10/32']) + self.cli_set(base_path + [interface, 'peer', 'VyOS', 'allowed-ips', allowed_ips_1]) self.cli_commit() - peers = get_peers(interface) + peers, _ = get_peers(interface) self.assertIn(pubkey_1, peers) self.assertNotIn(pubkey_2, peers) @@ -196,10 +201,20 @@ class WireGuardInterfaceTest(BasicInterfaceTest.TestCase): self.cli_commit() # Verify config - peers = get_peers(interface) + peers, _ = get_peers(interface) self.assertNotIn(pubkey_1, peers) self.assertIn(pubkey_2, peers) + # Update allowed-ips + self.cli_delete(base_path + [interface, 'peer', 'VyOS', 'allowed-ips', allowed_ips_1]) + self.cli_set(base_path + [interface, 'peer', 'VyOS', 'allowed-ips', allowed_ips_2]) + self.cli_commit() + + # Verify config + _, allowed_ips = get_peers(interface) + self.assertNotIn(allowed_ips_1, allowed_ips) + self.assertIn(allowed_ips_2, allowed_ips) + def test_wireguard_hostname(self): # T4930: Test dynamic endpoint support interface = 'wg1234' diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index 192937dba..289414c83 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -145,19 +145,11 @@ def generate(wireguard): def apply(wireguard): check_kmod('wireguard') - if 'rebuild_required' in wireguard or 'deleted' in wireguard: - wg = WireGuardIf(**wireguard) - # WireGuard only supports peer removal based on the configured public-key, - # by deleting the entire interface this is the shortcut instead of parsing - # out all peers and removing them one by one. - # - # Peer reconfiguration will always come with a short downtime while the - # WireGuard interface is recreated (see below) - wg.remove() + wg = WireGuardIf(**wireguard) - # Create the new interface if required - if 'deleted' not in wireguard: - wg = WireGuardIf(**wireguard) + if 'deleted' in wireguard: + wg.remove() + else: wg.update(wireguard) domain_resolver_usage = '/run/use-vyos-domain-resolver-interfaces-wireguard-' + wireguard['ifname'] -- cgit v1.2.3 From cbb6c944fea616547cec43f7f1ed6ea3cc4beb54 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Tue, 22 Apr 2025 16:31:09 +0200 Subject: vyos.utils: T7122: fix IPv6 support in check_port_availability() Commit 4523e9c897b3 ("wireguard: T3763: Added check for listening port availability") added a function to check if a port is free to use or already occupied by a different running service. This has been done by trying to bind a socket to said given port. Unfortunately there is no support for IPv6 address-fdamily in both socketserver.TCPServer or socketserver.UDPServer. This must be done manually by deriving TCPServer and setting self.address_family for IPv6. The new implementation gets rid of both TCPServer and UDPServer and replaces it with a simple socket binding to a given IPv4/IPv6 address or any interface/ address if unspecified. In addition build time tests are added for the function to check for proper behavior during build time of vyos-1x. --- python/vyos/utils/network.py | 60 ++++++++++++++++++++++----------- src/conf_mode/interfaces_wireguard.py | 2 +- src/conf_mode/load-balancing_haproxy.py | 2 +- src/tests/test_utils_network.py | 11 +++++- 4 files changed, 52 insertions(+), 23 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 2f666f0ee..67d247fba 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -256,40 +256,60 @@ def mac2eui64(mac, prefix=None): except: # pylint: disable=bare-except return -def check_port_availability(ipaddress, port, protocol): +def check_port_availability(address: str=None, port: int=0, protocol: str='tcp') -> bool: """ - Check if port is available and not used by any service - Return False if a port is busy or IP address does not exists + Check if given port is available and not used by any service. + Should be used carefully for services that can start listening dynamically, because IP address may be dynamic too + + Args: + address: IPv4 or IPv6 address - if None, checks on all interfaces + port: TCP/UDP port number. + + + Returns: + False if a port is busy or IP address does not exists + True if a port is free and IP address exists """ - from socketserver import TCPServer, UDPServer + import socket from ipaddress import ip_address + # treat None as "any address" + address = address or '::' + # verify arguments try: - ipaddress = ip_address(ipaddress).compressed - except: - raise ValueError(f'The {ipaddress} is not a valid IPv4 or IPv6 address') + address = ip_address(address).compressed + except ValueError: + raise ValueError(f'{address} is not a valid IPv4 or IPv6 address') if port not in range(1, 65536): - raise ValueError(f'The port number {port} is not in the 1-65535 range') + raise ValueError(f'Port {port} is not in range 1-65535') if protocol not in ['tcp', 'udp']: - raise ValueError(f'The protocol {protocol} is not supported. Only tcp and udp are allowed') + raise ValueError(f'{protocol} is not supported - only tcp and udp are allowed') - # check port availability + protocol = socket.SOCK_STREAM if protocol == 'tcp' else socket.SOCK_DGRAM try: - if protocol == 'tcp': - server = TCPServer((ipaddress, port), None, bind_and_activate=True) - if protocol == 'udp': - server = UDPServer((ipaddress, port), None, bind_and_activate=True) - server.server_close() - except Exception as e: - # errno.h: - #define EADDRINUSE 98 /* Address already in use */ - if e.errno == 98: + addr_info = socket.getaddrinfo(address, port, socket.AF_UNSPEC, protocol) + except socket.gaierror as e: + print(f'Invalid address: {address}') + return False + + for family, socktype, proto, canonname, sockaddr in addr_info: + try: + with socket.socket(family, socktype, proto) as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(sockaddr) + # port is free to use + return True + except OSError: + # port is already in use return False - return True + # if we reach this point, no socket was tested and we assume the port is + # already in use - better safe then sorry + return False + def is_listen_port_bind_service(port: int, service: str) -> bool: """Check if listen port bound to expected program name diff --git a/src/conf_mode/interfaces_wireguard.py b/src/conf_mode/interfaces_wireguard.py index 192937dba..3ca6ecdca 100755 --- a/src/conf_mode/interfaces_wireguard.py +++ b/src/conf_mode/interfaces_wireguard.py @@ -97,7 +97,7 @@ def verify(wireguard): if 'port' in wireguard and 'port_changed' in wireguard: listen_port = int(wireguard['port']) - if check_port_availability('0.0.0.0', listen_port, 'udp') is not True: + if check_port_availability(None, listen_port, protocol='udp') is not True: raise ConfigError(f'UDP port {listen_port} is busy or unavailable and ' 'cannot be used for the interface!') diff --git a/src/conf_mode/load-balancing_haproxy.py b/src/conf_mode/load-balancing_haproxy.py index 5fd1beec9..16c9300c2 100644 --- a/src/conf_mode/load-balancing_haproxy.py +++ b/src/conf_mode/load-balancing_haproxy.py @@ -72,7 +72,7 @@ def verify(lb): raise ConfigError(f'"{front} service port" must be configured!') # Check if bind address:port are used by another service - tmp_address = front_config.get('address', '0.0.0.0') + tmp_address = front_config.get('address', None) tmp_port = front_config['port'] if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \ not is_listen_port_bind_service(int(tmp_port), 'haproxy'): diff --git a/src/tests/test_utils_network.py b/src/tests/test_utils_network.py index d68dec16f..92fde447d 100644 --- a/src/tests/test_utils_network.py +++ b/src/tests/test_utils_network.py @@ -1,4 +1,4 @@ -# Copyright (C) 2020-2024 VyOS maintainers and contributors +# Copyright (C) 2020-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -43,3 +43,12 @@ class TestVyOSUtilsNetwork(TestCase): self.assertFalse(vyos.utils.network.is_loopback_addr('::2')) self.assertFalse(vyos.utils.network.is_loopback_addr('192.0.2.1')) + + def test_check_port_availability(self): + self.assertTrue(vyos.utils.network.check_port_availability('::1', 8080)) + self.assertTrue(vyos.utils.network.check_port_availability('127.0.0.1', 8080)) + self.assertTrue(vyos.utils.network.check_port_availability(None, 8080, protocol='udp')) + # We do not have 192.0.2.1 configured on this system + self.assertFalse(vyos.utils.network.check_port_availability('192.0.2.1', 443)) + # We do not have 2001:db8::1 configured on this system + self.assertFalse(vyos.utils.network.check_port_availability('2001:db8::1', 80, protocol='udp')) -- cgit v1.2.3 From a41398d9289457794f3f2e66e1dded7804f3c080 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 28 Apr 2025 21:51:07 +0200 Subject: T7122: remove trailing chars and add new line for every template.render() call --- python/vyos/template.py | 2 ++ 1 file changed, 2 insertions(+) (limited to 'python') diff --git a/python/vyos/template.py b/python/vyos/template.py index d79e1183f..513490963 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -150,6 +150,8 @@ def render( # As we are opening the file with 'w', we are performing the rendering before # calling open() to not accidentally erase the file if rendering fails rendered = render_to_string(template, content, formater, location) + # Remove any trailing character and always add a new line at the end + rendered = rendered.rstrip() + "\n" # Write to file with open(destination, "w") as file: -- cgit v1.2.3 From b93427874a0e502f83c3cc450663e079af214ea9 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 28 Apr 2025 22:06:40 +0200 Subject: pki: T7122: place certbot behind reverse-proxy if cert used by haproxy If we detect that an ACME issued certificate is consumed by haproxy service, we will move the certbot webserver to localhost and a highport, to proxy the request via haproxy which is already using port 80. --- python/vyos/defaults.py | 4 ++++ src/conf_mode/pki.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 7efccded6..1e6be6241 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -47,6 +47,10 @@ systemd_services = { 'snmpd' : 'snmpd.service', } +internal_ports = { + 'certbot_haproxy' : 65080, # Certbot running behing haproxy +} + config_status = '/tmp/vyos-config-status' api_config_state = '/run/http-api-state' frr_debug_enable = '/tmp/vyos.frr.debug' diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 521af61df..c1ff80d8a 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -27,6 +27,7 @@ from vyos.configdict import node_changed from vyos.configdiff import Diff from vyos.configdiff import get_config_diff from vyos.defaults import directories +from vyos.defaults import internal_ports from vyos.pki import encode_certificate from vyos.pki import is_ca_certificate from vyos.pki import load_certificate @@ -42,6 +43,7 @@ from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args from vyos.utils.dict import dict_search_recursive from vyos.utils.file import read_file +from vyos.utils.network import check_port_availability from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_active @@ -128,8 +130,13 @@ def certbot_request(name: str, config: dict, dry_run: bool=True): f'--standalone --agree-tos --no-eff-email --expand --server {config["url"]} '\ f'--email {config["email"]} --key-type rsa --rsa-key-size {config["rsa_key_size"]} '\ f'{domains}' - if 'listen_address' in config: + # When ACME is used behind a reverse proxy, we always bind to localhost + # whatever the CLI listen-address is configured for. + if 'used_by' in config and 'haproxy' in config['used_by']: + tmp += f' --http-01-address 127.0.0.1 --http-01-port {internal_ports["certbot_haproxy"]}' + elif 'listen_address' in config: tmp += f' --http-01-address {config["listen_address"]}' + # verify() does not need to actually request a cert but only test for plausability if dry_run: tmp += ' --dry-run' @@ -223,7 +230,7 @@ def get_config(config=None): continue path = search['path'] - path_str = ' '.join(path + found_path) + path_str = ' '.join(path + found_path).replace('_','-') #print(f'PKI: Updating config: {path_str} {item_name}') if path[0] == 'interfaces': @@ -234,6 +241,24 @@ def get_config(config=None): if not D.node_changed_presence(path): set_dependents(path[1], conf) + # Check PKI certificates if they are generated by ACME. If they are, traverse + # the current configutration and determine the service where the certificate + # is used by. This is needed to check if we might need to start ACME behing + # a reverse proxy. + if 'certificate' in pki: + for name, cert_config in pki['certificate'].items(): + if 'acme' not in cert_config: + continue + if not dict_search('system.load_balancing.haproxy', pki): + continue + used_by = [] + for cert_list, cli_path in dict_search_recursive( + pki['system']['load_balancing']['haproxy'], 'certificate'): + if name in cert_list: + used_by.append('haproxy') + if used_by: + pki['certificate'][name]['acme'].update({'used_by': used_by}) + return pki def is_valid_certificate(raw_data): @@ -325,6 +350,14 @@ def verify(pki): raise ConfigError(f'An email address is required to request '\ f'certificate for "{name}" via ACME!') + listen_address = None + if 'listen_address' in cert_conf['acme']: + listen_address = cert_conf['acme']['listen_address'] + + if 'used_by' not in cert_conf['acme']: + if not check_port_availability(listen_address, 80): + raise ConfigError(f'Port 80 is not available for ACME challenge for certificate "{name}"!') + if 'certbot_renew' not in pki: # Only run the ACME command if something on this entity changed, # as this is time intensive -- cgit v1.2.3 From 528248af9628c3170b4eac399f0bb339072d8eae Mon Sep 17 00:00:00 2001 From: "Nataliia S." <81954790+natali-rs1985@users.noreply.github.com> Date: Thu, 1 May 2025 20:15:32 +0300 Subject: T7390: VPP CGNAT implementation (#30) CLI: ``` set vpp nat cgnat interface outside # multi set vpp nat cgnat interface inside # multi set vpp nat cgnat rule outside-prefix set vpp nat cgnat rule inside-prefix set vpp nat cgnat timeout udp # default 300 set vpp nat cgnat timeout tcp-established # default 7440 set vpp nat cgnat timeout tcp-transitory # default 240 set vpp nat cgnat timeout icmp # default 60 ``` OP mode: ``` show vpp nat cgnat interfaces show vpp nat cgnat mappings show vpp nat cgnat sessions clear vpp cgnat inside-address
port external-address
port ``` --- data/config-mode-dependencies/vyos-vpp.json | 8 ++ data/templates/vpp/startup.conf.j2 | 1 + interface-definitions/vpp.xml.in | 127 ++++++++++++++++- op-mode-definitions/vpp_nat_cgnat.xml.in | 100 +++++++++++++ python/vyos/vpp/nat/__init__.py | 3 +- python/vyos/vpp/nat/det44.py | 106 ++++++++++++++ src/conf_mode/vpp.py | 2 + src/conf_mode/vpp_nat.py | 12 +- src/conf_mode/vpp_nat_cgnat.py | 211 ++++++++++++++++++++++++++++ src/op_mode/vpp_nat_cgnat.py | 140 ++++++++++++++++++ 10 files changed, 705 insertions(+), 5 deletions(-) create mode 100644 op-mode-definitions/vpp_nat_cgnat.xml.in create mode 100644 python/vyos/vpp/nat/det44.py create mode 100644 src/conf_mode/vpp_nat_cgnat.py create mode 100644 src/op_mode/vpp_nat_cgnat.py (limited to 'python') diff --git a/data/config-mode-dependencies/vyos-vpp.json b/data/config-mode-dependencies/vyos-vpp.json index 46c1ee9f2..0f1d8af34 100644 --- a/data/config-mode-dependencies/vyos-vpp.json +++ b/data/config-mode-dependencies/vyos-vpp.json @@ -11,41 +11,49 @@ "vpp_interfaces_vxlan": ["vpp_interfaces_vxlan"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_bonding": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ethernet": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_geneve": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_gre": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ipip": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_loopback": { "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_vxlan": { "vpp_interfaces_bridge": ["vpp_interfaces_bridge"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], "vpp_nat": ["vpp_nat"], + "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] } } diff --git a/data/templates/vpp/startup.conf.j2 b/data/templates/vpp/startup.conf.j2 index 1cba80f4e..589afa67b 100644 --- a/data/templates/vpp/startup.conf.j2 +++ b/data/templates/vpp/startup.conf.j2 @@ -96,6 +96,7 @@ plugins { # plugin cnat_plugin.so { enable } plugin nat_plugin.so { enable } plugin nat44_ei_plugin.so { enable } + plugin det44_plugin.so { enable } # plugin nat44_ei_plugin.so { enable } # plugin nat64_plugin.so { enable } # plugin nat66_plugin.so { enable } diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index b1ae348da..368405a02 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -833,10 +833,10 @@ UDP timeout u32 - Timeout in seconds (default: 60) + Timeout in seconds (default: 300) - 60 + 300 @@ -934,6 +934,129 @@ + + + Network Address Translation (NAT) settings + + + + + Carrier-grade NAT (CGNAT) + 321 + + + + + CGNAT interface setting + + + + + CGNAT inside interface + + + + + + + + + CGNAT outside interface + + + + + + + + + + + Rule number for CGNAT + + u32 + Number of rule + + + + + + Inside IPv4 prefix + + ipv4net + IPv4 prefix + + + + + + + + + Outside IPv4 prefix + + ipv4net + IPv4 prefix + + + + + + + #include + + + + + Timeouts for CGNAT sessions + + + + + ICMP timeout + + u32 + Timeout in seconds (default: 60) + + + 60 + + + + TCP established timeout + + u32 + TCP established timeout in seconds (default: 7440) + + + 7440 + + + + TCP transitory timeout + + u32 + Timeout in seconds (default: 240) + + + 240 + + + + UDP timeout + + u32 + Timeout in seconds (default: 300) + + + 300 + + + + + + + NAT44 diff --git a/op-mode-definitions/vpp_nat_cgnat.xml.in b/op-mode-definitions/vpp_nat_cgnat.xml.in new file mode 100644 index 000000000..8af10711f --- /dev/null +++ b/op-mode-definitions/vpp_nat_cgnat.xml.in @@ -0,0 +1,100 @@ + + + + + + + + + Show VPP NAT information + + + + + Show VPP CGNAT information + + + + + Show VPP CGNAT mappings + + sudo ${vyos_op_scripts_dir}/vpp_nat_cgnat.py show_mappings + + + + Show VPP CGNAT sessions + + sudo ${vyos_op_scripts_dir}/vpp_nat_cgnat.py show_sessions + + + + Show VPP CGNAT interfaces + + sudo ${vyos_op_scripts_dir}/vpp_nat_cgnat.py show_interfaces + + + + + + + + + + + + + + Terminate VPP NAT sessions + + + + + Terminate VPP CGNAT sessions + + + + + Inside IP address + + <x.x.x.x> + + + + + + Port + + 0-65535 + + + + + + External IP address + + <x.x.x.x> + + + + + + Port + + 0-65535 + + + sudo ${vyos_op_scripts_dir}/vpp_nat_cgnat.py clear_session --address $5 --port $7 --ext-address $9 --ext-port ${11} + + + + + + + + + + + + + + diff --git a/python/vyos/vpp/nat/__init__.py b/python/vyos/vpp/nat/__init__.py index bbd011435..16685c9cc 100644 --- a/python/vyos/vpp/nat/__init__.py +++ b/python/vyos/vpp/nat/__init__.py @@ -1,3 +1,4 @@ from .nat44 import Nat44 +from .det44 import Det44 -__all__ = ['Nat44'] +__all__ = ['Nat44', 'Det44'] diff --git a/python/vyos/vpp/nat/det44.py b/python/vyos/vpp/nat/det44.py new file mode 100644 index 000000000..1046d7cf9 --- /dev/null +++ b/python/vyos/vpp/nat/det44.py @@ -0,0 +1,106 @@ +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +class Det44: + def __init__(self): + self.vpp = VPPControl() + + def enable_det44_plugin(self): + """Enable DET44 plugin + Example: + from vyos.vpp.nat import Det44 + det44 = Det44() + det44.enable_det44_plugin() + https://github.com/FDio/vpp/blob/stable/2410/src/plugins/nat/det44/det44.api + """ + self.vpp.api.det44_plugin_enable_disable(enable=True) + + def disable_det44_plugin(self): + """Disable DET44 plugin""" + self.vpp.api.det44_plugin_enable_disable(enable=False) + + def add_det44_interface_outside(self, interface_out): + """Add DET44 outside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_out), + is_inside=False, + is_add=True, + ) + + def delete_det44_interface_outside(self, interface_out): + """Delete DET44 outside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_out), + is_inside=False, + is_add=False, + ) + + def add_det44_interface_inside(self, interface_in): + """Add DET44 inside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_in), + is_inside=True, + is_add=True, + ) + + def delete_det44_interface_inside(self, interface_in): + """Delete DET44 inside interface""" + self.vpp.api.det44_interface_add_del_feature( + sw_if_index=self.vpp.get_sw_if_index(interface_in), + is_inside=True, + is_add=False, + ) + + def add_det44_mapping(self, in_addr, in_plen, out_addr, out_plen): + """Add DET44 mapping""" + self.vpp.api.det44_add_del_map( + in_addr=in_addr, + in_plen=in_plen, + out_addr=out_addr, + out_plen=out_plen, + is_add=True, + ) + + def delete_det44_mapping(self, in_addr, in_plen, out_addr, out_plen): + """Delete DET44 mapping""" + self.vpp.api.det44_add_del_map( + in_addr=in_addr, + in_plen=in_plen, + out_addr=out_addr, + out_plen=out_plen, + is_add=False, + ) + + def set_det44_timeouts( + self, icmp: int, udp: int, tcp_established: int, tcp_transitory: int + ): + """Set DET44 timeouts + Args: + tcp_established (int): TCP established timeout + tcp_transitory (int): TCP transitory timeout + udp (int): UDP timeout + icmp (int): ICMP timeout + """ + self.vpp.api.det44_set_timeouts( + icmp=icmp, + udp=udp, + tcp_established=tcp_established, + tcp_transitory=tcp_transitory, + ) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index 7f9adc68b..3718f3897 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -138,6 +138,8 @@ def get_config(config=None): # NAT dependency if conf.exists(['vpp', 'nat44']): set_dependents('vpp_nat', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) if not conf.exists(base): return { diff --git a/src/conf_mode/vpp_nat.py b/src/conf_mode/vpp_nat.py index d38599388..a6bc70032 100644 --- a/src/conf_mode/vpp_nat.py +++ b/src/conf_mode/vpp_nat.py @@ -186,7 +186,11 @@ def verify(config): raise ConfigError( f'{interface} must be a VPP interface for "address-pool translation interface"' ) - iface_address = get_interface_address(interface).get('addr_info', [])[0].get('local') + iface_address = ( + get_interface_address(interface) + .get('addr_info', [])[0] + .get('local') + ) addresses_translation.append(iface_address) if 'twice_nat' in address_pool: @@ -211,7 +215,11 @@ def verify(config): raise ConfigError( f'{interface} must be a VPP interface for "address-pool twice-nat interface"' ) - iface_address = get_interface_address(interface).get('addr_info', [])[0].get('local') + iface_address = ( + get_interface_address(interface) + .get('addr_info', [])[0] + .get('local') + ) addresses_twice_nat.append(iface_address) if 'static' in config: diff --git a/src/conf_mode/vpp_nat_cgnat.py b/src/conf_mode/vpp_nat_cgnat.py new file mode 100644 index 000000000..05260c476 --- /dev/null +++ b/src/conf_mode/vpp_nat_cgnat.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos import ConfigError +from vyos.config import Config, config_dict_merge +from vyos.configdict import node_changed +from vyos.configdiff import Diff +from vyos.vpp.utils import cli_ifaces_list + +from vyos.vpp.nat.det44 import Det44 + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'nat', 'cgnat'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + # Get effective config as we need full dictionary to delete + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + # Get default values which we need to conditionally update into the + # dictionary retrieved. + default_values = conf.get_config_defaults(**config.kwargs, recursive=True) + config = config_dict_merge(default_values, config) + + config_changed = node_changed( + conf, + base, + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_rules = node_changed( + conf, + base + ['rule'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + if not config_changed: + changed_rules = list(config.get('rule', {}).keys()) + + config.update( + { + 'changed_rules': changed_rules, + 'vpp_ifaces': cli_ifaces_list(conf), + } + ) + + if effective_config: + config.update({'effective': effective_config}) + + return config + + +def verify(config): + if 'remove' in config: + return None + + if 'interface' not in config: + raise ConfigError('Interfaces must be configured for CGNAT') + if 'rule' not in config: + raise ConfigError('Rules must be configured for CGNAT') + + required_keys = {'inside', 'outside'} + missing_keys = required_keys - set(config['interface'].keys()) + if missing_keys: + raise ConfigError( + f'Both inside and outside interfaces must be configured. ' + f'Please add: {", ".join(missing_keys)}' + ) + + conflict_ifaces = set(config['interface']['inside']).intersection( + set(config['interface']['outside']) + ) + if conflict_ifaces: + raise ConfigError( + f'Interface cannot be both inside and outside. ' + f'Please choose a side for: {", ".join(conflict_ifaces)} ' + ) + + for interface in config['interface']['inside']: + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for inside CGNAT interface' + ) + for interface in config['interface']['outside']: + if interface not in config['vpp_ifaces']: + raise ConfigError( + f'{interface} must be a VPP interface for outside CGNAT interface' + ) + + required_keys = {'outside_prefix', 'inside_prefix'} + for rule in config['rule']: + missing_keys = required_keys - set(config['rule'][rule].keys()) + if missing_keys: + raise ConfigError( + f'Both inside-prefix and outside-prefix must be configured in rule {rule}. ' + f'Please add: {", ".join(missing_keys).replace("_", "-")}' + ) + + +def generate(config): + pass + + +def apply(config): + cgnat = Det44() + + if 'remove' in config: + cgnat.disable_det44_plugin() + return None + + if 'effective' in config: + remove_config = config.get('effective') + # Delete inside interfaces + for interface in remove_config['interface']['inside']: + if interface not in config.get('interface', {}).get('inside', []): + cgnat.delete_det44_interface_inside(interface) + # Delete outside interfaces + for interface in remove_config['interface']['outside']: + if interface not in config.get('interface', {}).get('outside', []): + cgnat.delete_det44_interface_outside(interface) + # Delete CGNAT rules + for rule in config['changed_rules']: + if rule in remove_config.get('rule', {}): + rule_config = remove_config['rule'][rule] + in_addr, in_plen = rule_config['inside_prefix'].split('/') + out_addr, out_plen = rule_config['outside_prefix'].split('/') + cgnat.delete_det44_mapping( + in_addr=in_addr, + in_plen=int(in_plen), + out_addr=out_addr, + out_plen=int(out_plen), + ) + + # Add DET44 + cgnat.enable_det44_plugin() + # Add inside interfaces + for interface in config['interface']['inside']: + cgnat.add_det44_interface_inside(interface) + # Add outside interfaces + for interface in config['interface']['outside']: + cgnat.add_det44_interface_outside(interface) + # Add CGNAT rules + for rule in config['changed_rules']: + if rule in config.get('rule', {}): + rule_config = config['rule'][rule] + in_addr, in_plen = rule_config['inside_prefix'].split('/') + out_addr, out_plen = rule_config['outside_prefix'].split('/') + cgnat.add_det44_mapping( + in_addr=in_addr, + in_plen=int(in_plen), + out_addr=out_addr, + out_plen=int(out_plen), + ) + # Set CGNAT timeouts + cgnat.set_det44_timeouts( + icmp=int(config['timeout']['icmp']), + udp=int(config['timeout']['udp']), + tcp_established=int(config['timeout']['tcp_established']), + tcp_transitory=int(config['timeout']['tcp_transitory']), + ) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/op_mode/vpp_nat_cgnat.py b/src/op_mode/vpp_nat_cgnat.py new file mode 100644 index 000000000..5112c70be --- /dev/null +++ b/src/op_mode/vpp_nat_cgnat.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +from tabulate import tabulate + +import vyos.opmode +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +def _verify(func): + """Decorator checks if config for VPP NAT CGNAT exists""" + from functools import wraps + + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + base = 'vpp nat cgnat' + if not config.exists(base): + raise vyos.opmode.UnconfiguredSubsystem(f'{base} is not configured') + + return func(*args, **kwargs) + + return _wrapper + + +def _get_raw_output(data_dump): + data = [json.loads(json.dumps(d._asdict(), default=str)) for d in data_dump] + return data + + +def _get_formatted_output_interfaces(vpp, interfaces): + print('CGNAT interfaces:') + for interface in interfaces: + name = vpp.get_interface_name(interface['sw_if_index']) + iface_type = 'in' if interface['is_inside'] else 'out' + print(f' {name} {iface_type}') + + +def _get_formatted_output_mappings(rules_list): + data_entries = [] + for rule in rules_list: + in_addr = rule.get('in_addr') + in_plen = str(rule.get('in_plen')) + out_addr = rule.get('out_addr') + out_plen = str(rule.get('out_plen')) + sharing_ratio = rule.get('sharing_ratio') + ports_per_host = rule.get('ports_per_host') + ses_num = rule.get('ses_num') + + values = [ + f'{in_addr}/{in_plen}', + f'{out_addr}/{out_plen}', + sharing_ratio, + ports_per_host, + ses_num, + ] + data_entries.append(values) + headers = [ + 'Inside', + 'Outside', + 'Sharing ratio', + 'Ports per host', + 'Sessions', + ] + out = sorted(data_entries, key=lambda x: x[0]) + return tabulate(out, headers=headers, tablefmt='simple') + + +@_verify +def show_sessions(raw: bool): + vpp = VPPControl() + out = vpp.cli_cmd('show det44 sessions').reply + out = out.replace('NAT44 deterministic', 'CGNAT') + return out + + +@_verify +def show_mappings(raw: bool): + vpp = VPPControl() + nat_static_dump = vpp.api.det44_map_dump() + rules_list: list[dict] = _get_raw_output(nat_static_dump) + + if raw: + return rules_list + + else: + return _get_formatted_output_mappings(rules_list) + + +@_verify +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.det44_interface_dump() + interfaces: list[dict] = _get_raw_output(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +@_verify +def clear_session(address: str, port: str, ext_address: str, ext_port: str): + vpp = VPPControl() + vpp.api.det44_close_session_in( + in_addr=address, + in_port=int(port), + ext_addr=ext_address, + ext_port=int(ext_port), + ) + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) -- cgit v1.2.3 From aff2835d7b6226e4b89f51e3f6133da26f3a07bf Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 4 May 2025 11:23:54 +0200 Subject: vyos.template: T7122: add Jinja2 clever function helper to read vyos.defaults Add a new category if Jinja2 operands. We already have filters and tests, but sometimes we would like to call a Python function without and data "|" piped to it - that's what they call a clever-function. {{ get_default_port(NAME) }} can be used to retrieve the value from vyos.defaults.internal_ports[NAME] within Jinja2. We no longer need to extend the dictionary with arbitrary data retrieved from vyos.defaults, we can now simply register another clever-function to the Jinja2 backend. --- python/vyos/template.py | 44 ++++++++++++++++++++++++++++++++++++++++++-- src/tests/test_template.py | 9 +++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) (limited to 'python') diff --git a/python/vyos/template.py b/python/vyos/template.py index 513490963..11e1cc50f 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -36,6 +36,7 @@ DEFAULT_TEMPLATE_DIR = directories["templates"] # Holds template filters registered via register_filter() _FILTERS = {} _TESTS = {} +_CLEVER_FUNCTIONS = {} # reuse Environments with identical settings to improve performance @functools.lru_cache(maxsize=2) @@ -58,6 +59,7 @@ def _get_environment(location=None): ) env.filters.update(_FILTERS) env.tests.update(_TESTS) + env.globals.update(_CLEVER_FUNCTIONS) return env @@ -77,7 +79,7 @@ def register_filter(name, func=None): "Filters can only be registered before rendering the first template" ) if name in _FILTERS: - raise ValueError(f"A filter with name {name!r} was registered already") + raise ValueError(f"A filter with name {name!r} was already registered") _FILTERS[name] = func return func @@ -97,10 +99,30 @@ def register_test(name, func=None): "Tests can only be registered before rendering the first template" ) if name in _TESTS: - raise ValueError(f"A test with name {name!r} was registered already") + raise ValueError(f"A test with name {name!r} was already registered") _TESTS[name] = func return func +def register_clever_function(name, func=None): + """Register a function to be available as test in templates under given name. + + It can also be used as a decorator, see below in this module for examples. + + :raise RuntimeError: + when trying to register a test after a template has been rendered already + :raise ValueError: when trying to register a name which was taken already + """ + if func is None: + return functools.partial(register_clever_function, name) + if _get_environment.cache_info().currsize: + raise RuntimeError( + "Clever functions can only be registered before rendering the" \ + "first template") + if name in _CLEVER_FUNCTIONS: + raise ValueError(f"A clever function with name {name!r} was already "\ + "registered") + _CLEVER_FUNCTIONS[name] = func + return func def render_to_string(template, content, formater=None, location=None): """Render a template from the template directory, raise on any errors. @@ -1052,3 +1074,21 @@ def vyos_defined(value, test_value=None, var_type=None): else: # Valid value and is matching optional argument if provided - return true return True + +@register_clever_function('get_default_port') +def get_default_port(service): + """ + Jinja2 plugin to retrieve common service port number from vyos.defaults + class form a Jinja2 template. This removes the need to hardcode, or pass in + the data using the general dictionary. + + Added to remove code complexity and make it easier to read. + + Example: + {{ get_default_port('certbot_haproxy') }} + """ + from vyos.defaults import internal_ports + if service not in internal_ports: + raise RuntimeError(f'Service "{service}" not found in internal ' \ + 'vyos.defaults.internal_ports dict!') + return internal_ports[service] diff --git a/src/tests/test_template.py b/src/tests/test_template.py index 6377f6da5..7cae867a0 100644 --- a/src/tests/test_template.py +++ b/src/tests/test_template.py @@ -190,3 +190,12 @@ class TestVyOSTemplate(TestCase): for group_name, group_config in data['ike_group'].items(): ciphers = vyos.template.get_esp_ike_cipher(group_config) self.assertIn(IKEv2_DEFAULT, ','.join(ciphers)) + + def test_get_default_port(self): + from vyos.defaults import internal_ports + + with self.assertRaises(RuntimeError): + vyos.template.get_default_port('UNKNOWN') + + self.assertEqual(vyos.template.get_default_port('certbot_haproxy'), + internal_ports['certbot_haproxy']) -- cgit v1.2.3 From 40a99b1d00d822ef6f1a3772768919d14c3500d9 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 4 May 2025 12:17:06 +0200 Subject: vyos.base: T7122: add new Message() helper wrapper for print() This will wrap the messages at 72 characters in the same way as Warning() and DeprecationWarning() would do. We now have simple wrappers for it! Example: vyos@vyos# commit [ pki ] Updating configuration: "load-balancing haproxy service frontend ssl certificate LE_cloud" Add/replace automatically imported CA certificate for "LE_cloud" --- python/vyos/base.py | 21 +++++++++++++-------- src/conf_mode/pki.py | 5 +++-- 2 files changed, 16 insertions(+), 10 deletions(-) (limited to 'python') diff --git a/python/vyos/base.py b/python/vyos/base.py index ca96d96ce..3173ddc20 100644 --- a/python/vyos/base.py +++ b/python/vyos/base.py @@ -1,4 +1,4 @@ -# Copyright 2018-2022 VyOS maintainers and contributors +# Copyright 2018-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -15,8 +15,7 @@ from textwrap import fill - -class BaseWarning: +class UserMessage: def __init__(self, header, message, **kwargs): self.message = message self.kwargs = kwargs @@ -33,7 +32,6 @@ class BaseWarning: messages = self.message.split('\n') isfirstmessage = True initial_indent = self.textinitindent - print('') for mes in messages: mes = fill(mes, initial_indent=initial_indent, subsequent_indent=self.standardindent, **self.kwargs) @@ -44,17 +42,24 @@ class BaseWarning: print('', flush=True) +class Message(): + def __init__(self, message, **kwargs): + self.Message = UserMessage('', message, **kwargs) + self.Message.print() + class Warning(): def __init__(self, message, **kwargs): - self.BaseWarn = BaseWarning('WARNING: ', message, **kwargs) - self.BaseWarn.print() + print('') + self.UserMessage = UserMessage('WARNING: ', message, **kwargs) + self.UserMessage.print() class DeprecationWarning(): def __init__(self, message, **kwargs): # Reformat the message and trim it to 72 characters in length - self.BaseWarn = BaseWarning('DEPRECATION WARNING: ', message, **kwargs) - self.BaseWarn.print() + print('') + self.UserMessage = UserMessage('DEPRECATION WARNING: ', message, **kwargs) + self.UserMessage.print() class ConfigError(Exception): diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 98922595c..6957248d2 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -19,6 +19,7 @@ import os from sys import argv from sys import exit +from vyos.base import Message from vyos.config import Config from vyos.config import config_dict_merge from vyos.configdep import set_dependents @@ -231,7 +232,7 @@ def get_config(config=None): path = search['path'] path_str = ' '.join(path + found_path).replace('_','-') - print(f'PKI: Updating config: {path_str} {item_name}') + Message(f'Updating configuration: "{path_str} {item_name}"') if path[0] == 'interfaces': ifname = found_path[0] @@ -528,7 +529,7 @@ def generate(pki): if not ca_cert_present: tmp = dict_search_args(pki, 'ca', f'{autochain_prefix}{cert}', 'certificate') if not bool(tmp) or tmp != cert_chain_base64: - print(f'Adding/replacing automatically imported CA certificate for "{cert}" ...') + Message(f'Add/replace automatically imported CA certificate for "{cert}"...') add_cli_node(['pki', 'ca', f'{autochain_prefix}{cert}', 'certificate'], value=cert_chain_base64) return None -- cgit v1.2.3 From 59d86826a2ffb2df6a0ce603c879e541a4fe88ba Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sun, 4 May 2025 22:08:13 +0200 Subject: haproxy: T7122: add ACME/certbot bootstrap support When both the CLI PKI node for an ACME-issued certificate and HAProxy are configured during initial setup, the certbot challenge cannot be served via the reverse proxy because HAProxy has not yet been configured at all. This commit introduces a special case to handle this bootstrap scenario, ensuring that the certbot challenge can still be served correctly in standalone mode on port 80 despite initial config dependencies/priorities between PKI and HAProxy. --- python/vyos/defaults.py | 1 + src/conf_mode/load-balancing_haproxy.py | 10 +++++----- src/conf_mode/pki.py | 5 ++++- 3 files changed, 10 insertions(+), 6 deletions(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 1e6be6241..c1e5ddc04 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -43,6 +43,7 @@ directories = { } systemd_services = { + 'haproxy' : 'haproxy.service', 'syslog' : 'syslog.service', 'snmpd' : 'snmpd.service', } diff --git a/src/conf_mode/load-balancing_haproxy.py b/src/conf_mode/load-balancing_haproxy.py index 0e959480c..504a90596 100644 --- a/src/conf_mode/load-balancing_haproxy.py +++ b/src/conf_mode/load-balancing_haproxy.py @@ -19,6 +19,7 @@ import os from sys import exit from shutil import rmtree +from vyos.defaults import systemd_services from vyos.config import Config from vyos.configverify import verify_pki_certificate from vyos.configverify import verify_pki_ca_certificate @@ -39,7 +40,6 @@ airbag.enable() load_balancing_dir = '/run/haproxy' load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg' -systemd_service = 'haproxy.service' systemd_override = '/run/systemd/system/haproxy.service.d/10-override.conf' def get_config(config=None): @@ -191,11 +191,11 @@ def generate(lb): return None def apply(lb): + action = 'stop' + if lb: + action = 'reload-or-restart' call('systemctl daemon-reload') - if not lb: - call(f'systemctl stop {systemd_service}') - else: - call(f'systemctl reload-or-restart {systemd_service}') + call(f'systemctl {action} {systemd_services["haproxy"]}') return None diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index f53e5db8b..7ee1705c0 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -29,6 +29,7 @@ from vyos.configdiff import Diff from vyos.configdiff import get_config_diff from vyos.defaults import directories from vyos.defaults import internal_ports +from vyos.defaults import systemd_services from vyos.pki import encode_certificate from vyos.pki import is_ca_certificate from vyos.pki import load_certificate @@ -48,6 +49,7 @@ from vyos.utils.network import check_port_availability from vyos.utils.process import call from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_active +from vyos.utils.process import is_systemd_service_running from vyos import ConfigError from vyos import airbag airbag.enable() @@ -133,7 +135,8 @@ def certbot_request(name: str, config: dict, dry_run: bool=True): f'{domains}' # When ACME is used behind a reverse proxy, we always bind to localhost # whatever the CLI listen-address is configured for. - if 'used_by' in config and 'haproxy' in config['used_by']: + if ('haproxy' in dict_search('used_by', config) and + is_systemd_service_running(systemd_services['haproxy'])): tmp += f' --http-01-address 127.0.0.1 --http-01-port {internal_ports["certbot_haproxy"]}' elif 'listen_address' in config: tmp += f' --http-01-address {config["listen_address"]}' -- cgit v1.2.3 From 7e035196049ff0525b01020ac12689612bdda2cb Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Mon, 5 May 2025 12:04:02 -0400 Subject: Bridge: T7430: Add BPDU Guard and Root Guard support This will add support for BPDU Guard and Root Guard to the bridge interface. Verification will come from: show log spanning-tree --- interface-definitions/interfaces_bridge.xml.in | 12 ++++++++++ op-mode-definitions/show-log.xml.in | 6 +++++ python/vyos/ifconfig/bridge.py | 10 ++++++++ python/vyos/ifconfig/interface.py | 32 +++++++++++++++++++++++++ smoketest/scripts/cli/test_interfaces_bridge.py | 25 +++++++++++++++++++ src/conf_mode/interfaces_bridge.py | 3 +++ 6 files changed, 88 insertions(+) (limited to 'python') diff --git a/interface-definitions/interfaces_bridge.xml.in b/interface-definitions/interfaces_bridge.xml.in index 667ae3b19..b360f34f1 100644 --- a/interface-definitions/interfaces_bridge.xml.in +++ b/interface-definitions/interfaces_bridge.xml.in @@ -201,6 +201,18 @@ + + + Enable BPDU Guard + + + + + + Enable Root Guard + + + diff --git a/op-mode-definitions/show-log.xml.in b/op-mode-definitions/show-log.xml.in index ee2e2bf70..b09d3c68b 100755 --- a/op-mode-definitions/show-log.xml.in +++ b/op-mode-definitions/show-log.xml.in @@ -831,6 +831,12 @@ journalctl --no-hostname --boot --unit snmpd.service + + + Show log for Spanning Tree Protocol (STP) + + journalctl --dmesg --no-hostname --boot --grep='br[0-9].*(stp|bpdu|blocking|disabled|forwarding|listening|root port)' + Show log for Secure Shell (SSH) diff --git a/python/vyos/ifconfig/bridge.py b/python/vyos/ifconfig/bridge.py index f81026965..69dae42d3 100644 --- a/python/vyos/ifconfig/bridge.py +++ b/python/vyos/ifconfig/bridge.py @@ -376,6 +376,16 @@ class BridgeIf(Interface): if 'priority' in interface_config: lower.set_path_priority(interface_config['priority']) + # set BPDU guard + tmp = dict_search('bpdu_guard', interface_config) + value = '1' if (tmp != None) else '0' + lower.set_bpdu_guard(value) + + # set root guard + tmp = dict_search('root_guard', interface_config) + value = '1' if (tmp != None) else '0' + lower.set_root_guard(value) + if 'enable_vlan' in config: add_vlan = [] native_vlan_id = None diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 003a273c0..91b3a0c28 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -217,6 +217,16 @@ class Interface(Control): 'location': '/sys/class/net/{ifname}/brport/priority', 'errormsg': '{ifname} is not a bridge port member' }, + 'bpdu_guard': { + 'validate': assert_boolean, + 'location': '/sys/class/net/{ifname}/brport/bpdu_guard', + 'errormsg': '{ifname} is not a bridge port member' + }, + 'root_guard': { + 'validate': assert_boolean, + 'location': '/sys/class/net/{ifname}/brport/root_block', + 'errormsg': '{ifname} is not a bridge port member' + }, 'proxy_arp': { 'validate': assert_boolean, 'location': '/proc/sys/net/ipv4/conf/{ifname}/proxy_arp', @@ -1106,6 +1116,28 @@ class Interface(Control): """ self.set_interface('path_priority', priority) + def set_bpdu_guard(self, state): + """ + Set BPDU guard state for a bridge port. When enabled, the port will be + disabled if it receives a BPDU packet. + + Example: + >>> from vyos.ifconfig import Interface + >>> Interface('eth0').set_bpdu_guard(1) + """ + self.set_interface('bpdu_guard', state) + + def set_root_guard(self, state): + """ + Set root guard state for a bridge port. When enabled, the port will be + disabled if it receives a superior BPDU that would make it a root port. + + Example: + >>> from vyos.ifconfig import Interface + >>> Interface('eth0').set_root_guard(1) + """ + self.set_interface('root_guard', state) + def set_port_isolation(self, on_or_off): """ Controls whether a given port will be isolated, which means it will be diff --git a/smoketest/scripts/cli/test_interfaces_bridge.py b/smoketest/scripts/cli/test_interfaces_bridge.py index 4041b3ef3..c18be7e99 100755 --- a/smoketest/scripts/cli/test_interfaces_bridge.py +++ b/smoketest/scripts/cli/test_interfaces_bridge.py @@ -508,6 +508,31 @@ class BridgeInterfaceTest(BasicInterfaceTest.TestCase): self.cli_delete(['interfaces', 'vxlan', vxlan_if]) self.cli_delete(['interfaces', 'ethernet', 'eth0', 'address', eth0_addr]) + def test_bridge_root_bpdu_guard(self): + # Test if both bpdu_guard and root_guard configured + self.cli_set(['interfaces', 'bridge', 'br0', 'stp']) + self.cli_set(['interfaces', 'bridge', 'br0', 'member', 'interface', 'eth0', 'bpdu-guard']) + self.cli_set(['interfaces', 'bridge', 'br0', 'member', 'interface', 'eth0', 'root-guard']) + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_discard() + + # Test if bpdu_guard configured + self.cli_set(['interfaces', 'bridge', 'br0', 'stp']) + self.cli_set(['interfaces', 'bridge', 'br0', 'member', 'interface', 'eth0', 'bpdu-guard']) + self.cli_commit() + + tmp = read_file(f'/sys/class/net/eth0/brport/bpdu_guard') + self.assertEqual(tmp, '1') + + # Test if root_guard configured + self.cli_delete(['interfaces', 'bridge', 'br0']) + self.cli_set(['interfaces', 'bridge', 'br0', 'stp']) + self.cli_set(['interfaces', 'bridge', 'br0', 'member', 'interface', 'eth0', 'root-guard']) + self.cli_commit() + + tmp = read_file(f'/sys/class/net/eth0/brport/root_block') + self.assertEqual(tmp, '1') if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/interfaces_bridge.py b/src/conf_mode/interfaces_bridge.py index 95dcc543e..956030289 100755 --- a/src/conf_mode/interfaces_bridge.py +++ b/src/conf_mode/interfaces_bridge.py @@ -167,6 +167,9 @@ def verify(bridge): if 'has_vrf' in interface_config: raise ConfigError(error_msg + 'it has a VRF assigned!') + if 'bpdu_guard' in interface_config and 'root_guard' in interface_config: + raise ConfigError(error_msg + 'bpdu_guard and root_guard cannot be configured at the same time!') + if 'enable_vlan' in bridge: if 'has_vlan' in interface_config: raise ConfigError(error_msg + 'it has VLAN subinterface(s) assigned!') -- cgit v1.2.3 From ff29f2317a1feb2f471c9c9e2763f62a7d09d236 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 5 May 2025 21:28:16 +0200 Subject: frr: T7431: missing logging options after rewrite to frrender class In src/etc/systemd/system/frr.service.d/override.conf#L6-L11 the log entry is added on restart - but not during normal operation of frrender.py Logging should be added persistent when rendering the FRR configuration using FRRender class. --- python/vyos/frrender.py | 3 +++ 1 file changed, 3 insertions(+) (limited to 'python') diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index 524167d8b..73d6dd5f0 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -697,6 +697,9 @@ class FRRender: debug('FRR: START CONFIGURATION RENDERING') # we can not reload an empty file, thus we always embed the marker output = '!\n' + # Enable FRR logging + output += 'log syslog\n' + output += 'log facility local7\n' # Enable SNMP agentx support # SNMP AgentX support cannot be disabled once enabled if 'snmp' in config_dict: -- cgit v1.2.3 From 02c63e7ded23ea90d55638f768ff943671c2c574 Mon Sep 17 00:00:00 2001 From: Mark Hayes Date: Fri, 25 Apr 2025 11:10:07 -0400 Subject: T7386: firewall: allow mix of IPv4 and IPv6 addresses/prefixes/ranges in remote groups --- data/templates/firewall/nftables-defines.j2 | 9 +++ .../include/firewall/common-rule-ipv6.xml.i | 4 +- python/vyos/firewall.py | 21 +++--- python/vyos/utils/network.py | 16 +++++ smoketest/scripts/cli/test_firewall.py | 76 +++++++++++++++++++++- src/op_mode/firewall.py | 17 +++-- src/services/vyos-domain-resolver | 29 +++++++-- 7 files changed, 151 insertions(+), 21 deletions(-) (limited to 'python') diff --git a/data/templates/firewall/nftables-defines.j2 b/data/templates/firewall/nftables-defines.j2 index 3147b4c37..a1d1fa4f6 100644 --- a/data/templates/firewall/nftables-defines.j2 +++ b/data/templates/firewall/nftables-defines.j2 @@ -44,6 +44,15 @@ } {% endfor %} {% endif %} +{% if group.remote_group is vyos_defined and is_l3 and is_ipv6 %} +{% for name, name_config in group.remote_group.items() %} + set R6_{{ name }} { + type {{ ip_type }} + flags interval + auto-merge + } +{% endfor %} +{% endif %} {% if group.mac_group is vyos_defined %} {% for group_name, group_conf in group.mac_group.items() %} {% set includes = group_conf.include if group_conf.include is vyos_defined else [] %} diff --git a/interface-definitions/include/firewall/common-rule-ipv6.xml.i b/interface-definitions/include/firewall/common-rule-ipv6.xml.i index bb176fe71..65ec415fb 100644 --- a/interface-definitions/include/firewall/common-rule-ipv6.xml.i +++ b/interface-definitions/include/firewall/common-rule-ipv6.xml.i @@ -16,6 +16,7 @@ #include #include #include + #include @@ -39,6 +40,7 @@ #include #include #include + #include - \ No newline at end of file + diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 9c320c82d..64022db84 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -319,7 +319,10 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if group_name[0] == '!': operator = '!=' group_name = group_name[1:] - output.append(f'{ip_name} {prefix}addr {operator} @R_{group_name}') + if ip_name == 'ip': + output.append(f'{ip_name} {prefix}addr {operator} @R_{group_name}') + elif ip_name == 'ip6': + output.append(f'{ip_name} {prefix}addr {operator} @R6_{group_name}') if 'mac_group' in group: group_name = group['mac_group'] operator = '' @@ -471,14 +474,14 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): output.append('gre version 1') if gre_key: - # The offset of the key within the packet shifts depending on the C-flag. - # nftables cannot handle complex enough expressions to match multiple + # The offset of the key within the packet shifts depending on the C-flag. + # nftables cannot handle complex enough expressions to match multiple # offsets based on bitfields elsewhere. - # We enforce a specific match for the checksum flag in validation, so the - # gre_flags dict will always have a 'checksum' key when gre_key is populated. - if not gre_flags['checksum']: + # We enforce a specific match for the checksum flag in validation, so the + # gre_flags dict will always have a 'checksum' key when gre_key is populated. + if not gre_flags['checksum']: # No "unset" child node means C is set, we offset key lookup +32 bits - output.append(f'@th,64,32 == {gre_key}') + output.append(f'@th,64,32 == {gre_key}') else: output.append(f'@th,32,32 == {gre_key}') @@ -637,7 +640,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): return " ".join(output) def parse_gre_flags(flags, force_keyed=False): - flag_map = { # nft does not have symbolic names for these. + flag_map = { # nft does not have symbolic names for these. 'checksum': 1<<0, 'routing': 1<<1, 'key': 1<<2, @@ -648,7 +651,7 @@ def parse_gre_flags(flags, force_keyed=False): include = 0 exclude = 0 for fl_name, fl_state in flags.items(): - if not fl_state: + if not fl_state: include |= flag_map[fl_name] else: # 'unset' child tag exclude |= flag_map[fl_name] diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 67d247fba..20b6a3c9e 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -635,3 +635,19 @@ def is_valid_ipv4_address_or_range(addr: str) -> bool: return ip_network(addr).version == 4 except: return False + +def is_valid_ipv6_address_or_range(addr: str) -> bool: + """ + Validates if the provided address is a valid IPv4, CIDR or IPv4 range + :param addr: address to test + :return: bool: True if provided address is valid + """ + from ipaddress import ip_network + try: + if '-' in addr: # If we are checking a range, validate both address's individually + split = addr.split('-') + return is_valid_ipv6_address_or_range(split[0]) and is_valid_ipv6_address_or_range(split[1]) + else: + return ip_network(addr).version == 6 + except: + return False diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index 69de0c326..851a15f16 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -1295,7 +1295,7 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): ['R_group01'], ['type ipv4_addr'], ['flags interval'], - ['meta l4proto', 'daddr @R_group01', "ipv4-INP-filter-10"] + ['meta l4proto', 'daddr @R_group01', 'ipv4-INP-filter-10'] ] self.verify_nftables(nftables_search, 'ip vyos_filter') @@ -1314,5 +1314,79 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): self.cli_discard() + def test_ipv6_remote_group(self): + # Setup base config for test + self.cli_set(['firewall', 'group', 'remote-group', 'group01', 'url', 'http://127.0.0.1:80/list.txt']) + self.cli_set(['firewall', 'group', 'remote-group', 'group01', 'description', 'Example Group 01']) + self.cli_set(['firewall', 'ipv6', 'input', 'filter', 'rule', '10', 'action', 'drop']) + self.cli_set(['firewall', 'ipv6', 'input', 'filter', 'rule', '10', 'protocol', 'tcp']) + self.cli_set(['firewall', 'ipv6', 'input', 'filter', 'rule', '10', 'destination', 'group', 'remote-group', 'group01']) + + self.cli_commit() + + # Test remote-group had been loaded correctly in nft + nftables_search = [ + ['R6_group01'], + ['type ipv6_addr'], + ['flags interval'], + ['meta l4proto', 'daddr @R6_group01', 'ipv6-INP-filter-10'] + ] + self.verify_nftables(nftables_search, 'ip6 vyos_filter') + + # Test remote-group cannot be configured without a URL + self.cli_delete(['firewall', 'group', 'remote-group', 'group01', 'url']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_discard() + + # Test remote-group cannot be set alongside address in rules + self.cli_set(['firewall', 'ipv6', 'input', 'filter', 'rule', '10', 'destination', 'address', '2001:db8::1']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_discard() + + + def test_remote_group(self): + # Setup base config for test adding remote group to both ipv4 and ipv6 rules + self.cli_set(['firewall', 'group', 'remote-group', 'group01', 'url', 'http://127.0.0.1:80/list.txt']) + self.cli_set(['firewall', 'group', 'remote-group', 'group01', 'description', 'Example Group 01']) + self.cli_set(['firewall', 'ipv4', 'output', 'filter', 'rule', '10', 'action', 'drop']) + self.cli_set(['firewall', 'ipv4', 'output', 'filter', 'rule', '10', 'protocol', 'tcp']) + self.cli_set(['firewall', 'ipv4', 'output', 'filter', 'rule', '10', 'destination', 'group', 'remote-group', 'group01']) + self.cli_set(['firewall', 'ipv4', 'input', 'filter', 'rule', '10', 'action', 'drop']) + self.cli_set(['firewall', 'ipv4', 'input', 'filter', 'rule', '10', 'protocol', 'tcp']) + self.cli_set(['firewall', 'ipv4', 'input', 'filter', 'rule', '10', 'source', 'group', 'remote-group', 'group01']) + self.cli_set(['firewall', 'ipv6', 'output', 'filter', 'rule', '10', 'action', 'drop']) + self.cli_set(['firewall', 'ipv6', 'output', 'filter', 'rule', '10', 'protocol', 'tcp']) + self.cli_set(['firewall', 'ipv6', 'output', 'filter', 'rule', '10', 'destination', 'group', 'remote-group', 'group01']) + self.cli_set(['firewall', 'ipv6', 'input', 'filter', 'rule', '10', 'action', 'drop']) + self.cli_set(['firewall', 'ipv6', 'input', 'filter', 'rule', '10', 'protocol', 'tcp']) + self.cli_set(['firewall', 'ipv6', 'input', 'filter', 'rule', '10', 'source', 'group', 'remote-group', 'group01']) + + self.cli_commit() + + # Test remote-group had been loaded correctly in nft ip table + nftables_v4_search = [ + ['R_group01'], + ['type ipv4_addr'], + ['flags interval'], + ['meta l4proto', 'daddr @R_group01', 'ipv4-OUT-filter-10'], + ['meta l4proto', 'saddr @R_group01', 'ipv4-INP-filter-10'], + ] + self.verify_nftables(nftables_v4_search, 'ip vyos_filter') + + # Test remote-group had been loaded correctly in nft ip6 table + nftables_v6_search = [ + ['R6_group01'], + ['type ipv6_addr'], + ['flags interval'], + ['meta l4proto', 'daddr @R6_group01', 'ipv6-OUT-filter-10'], + ['meta l4proto', 'saddr @R6_group01', 'ipv6-INP-filter-10'], + ] + self.verify_nftables(nftables_v6_search, 'ip6 vyos_filter') + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/op_mode/firewall.py b/src/op_mode/firewall.py index ac47e3273..2ad968c96 100755 --- a/src/op_mode/firewall.py +++ b/src/op_mode/firewall.py @@ -648,12 +648,14 @@ def show_firewall_group(name=None): references = find_references(group_type, remote_name) row = [remote_name, textwrap.fill(remote_conf.get('description') or '', 50), group_type, '\n'.join(references) or 'N/D'] members = get_nftables_remote_group_members("ipv4", 'vyos_filter', f'R_{remote_name}') + members6 = get_nftables_remote_group_members("ipv6", 'vyos_filter', f'R6_{remote_name}') if 'url' in remote_conf: # display only the url if no members are found for both views - if not members: + if not members and not members6: if args.detail: - header_tail = ['Remote URL'] + header_tail = ['IPv6 Members', 'Remote URL'] + row.append('N/D') row.append('N/D') row.append(remote_conf['url']) else: @@ -662,8 +664,15 @@ def show_firewall_group(name=None): else: # display all table elements in detail view if args.detail: - header_tail = ['Remote URL'] - row += [' '.join(members)] + header_tail = ['IPv6 Members', 'Remote URL'] + if members: + row.append(' '.join(members)) + else: + row.append('N/D') + if members6: + row.append(' '.join(members6)) + else: + row.append('N/D') row.append(remote_conf['url']) rows.append(row) else: diff --git a/src/services/vyos-domain-resolver b/src/services/vyos-domain-resolver index 4419fc4a7..fb18724af 100755 --- a/src/services/vyos-domain-resolver +++ b/src/services/vyos-domain-resolver @@ -28,7 +28,7 @@ from vyos.utils.commit import commit_in_progress from vyos.utils.dict import dict_search_args from vyos.utils.kernel import WIREGUARD_REKEY_AFTER_TIME from vyos.utils.file import makedir, chmod_775, write_file, read_file -from vyos.utils.network import is_valid_ipv4_address_or_range +from vyos.utils.network import is_valid_ipv4_address_or_range, is_valid_ipv6_address_or_range from vyos.utils.process import cmd from vyos.utils.process import run from vyos.xml_ref import get_defaults @@ -143,10 +143,11 @@ def update_remote_group(config): for set_name, remote_config in remote_groups.items(): if 'url' not in remote_config: continue - nft_set_name = f'R_{set_name}' + nft_ip_set_name = f'R_{set_name}' + nft_ip6_set_name = f'R6_{set_name}' # Create list file if necessary - list_file = os.path.join(firewall_config_dir, f"{nft_set_name}.txt") + list_file = os.path.join(firewall_config_dir, f"{nft_ip_set_name}.txt") if not os.path.exists(list_file): write_file(list_file, '', user="root", group="vyattacfg", mode=0o644) @@ -159,16 +160,32 @@ def update_remote_group(config): # Read list file ip_list = [] + ip6_list = [] + invalid_list = [] for line in read_file(list_file).splitlines(): line_first_word = line.strip().partition(' ')[0] if is_valid_ipv4_address_or_range(line_first_word): ip_list.append(line_first_word) + elif is_valid_ipv6_address_or_range(line_first_word): + ip6_list.append(line_first_word) + else: + if line_first_word[0].isalnum(): + invalid_list.append(line_first_word) - # Load tables + # Load ip tables for table in ipv4_tables: - if (table, nft_set_name) in valid_sets: - conf_lines += nft_output(table, nft_set_name, ip_list) + if (table, nft_ip_set_name) in valid_sets: + conf_lines += nft_output(table, nft_ip_set_name, ip_list) + + # Load ip6 tables + for table in ipv6_tables: + if (table, nft_ip6_set_name) in valid_sets: + conf_lines += nft_output(table, nft_ip6_set_name, ip6_list) + + invalid_str = ", ".join(invalid_list) + if invalid_str: + logger.info(f'Invalid address for set {set_name}: {invalid_str}') count += 1 -- cgit v1.2.3 From 151afffe15ce89755f3c3f81a9d2c647e487f647 Mon Sep 17 00:00:00 2001 From: "Nataliia S." <81954790+natali-rs1985@users.noreply.github.com> Date: Tue, 13 May 2025 17:33:35 +0300 Subject: T7419: VPP ACL implementation (#31) CLI: ``` set vpp acl ip tag-name rule action set vpp acl ip tag-name rule source prefix set vpp acl ip tag-name rule source port set vpp acl ip tag-name rule destination prefix set vpp acl ip tag-name rule destination port set vpp acl ip tag-name rule protocol set vpp acl ip tag-name rule tcp-flags set vpp acl ip tag-name rule tcp-flags not set vpp acl ip interface input acl-tag tag-name set vpp acl ip interface output acl-tag tag-name set vpp acl macip tag-name rule prefix set vpp acl macip tag-name rule mac-address set vpp acl macip tag-name rule mac-mask set vpp acl macip tag-name rule action set vpp acl macip interface tag-name ``` OP mode ``` show vpp acl ip tag-name show vpp acl ip interface show vpp acl macip tag-name show vpp acl macip interface ``` --- data/config-mode-dependencies/vyos-vpp.json | 8 + data/templates/vpp/startup.conf.j2 | 2 + .../include/vpp/acl_common_interface_ip_rule.xml.i | 25 ++ .../include/vpp/acl_port_range.xml.i | 18 + interface-definitions/include/vpp/acl_prefix.xml.i | 20 ++ .../include/vpp/acl_tcp_flags.xml.i | 50 +++ interface-definitions/vpp.xml.in | 254 ++++++++++++- op-mode-definitions/vpp_acl.xml.in | 65 ++++ python/vyos/vpp/acl/__init__.py | 3 + python/vyos/vpp/acl/acl.py | 106 ++++++ src/conf_mode/vpp.py | 4 + src/conf_mode/vpp_acl.py | 393 +++++++++++++++++++++ src/conf_mode/vpp_interfaces_bonding.py | 10 + src/conf_mode/vpp_interfaces_gre.py | 10 + src/conf_mode/vpp_interfaces_ipip.py | 10 + src/conf_mode/vpp_interfaces_loopback.py | 10 + src/conf_mode/vpp_interfaces_vxlan.py | 10 + src/op_mode/vpp_acl.py | 342 ++++++++++++++++++ 18 files changed, 1338 insertions(+), 2 deletions(-) create mode 100644 interface-definitions/include/vpp/acl_common_interface_ip_rule.xml.i create mode 100644 interface-definitions/include/vpp/acl_port_range.xml.i create mode 100644 interface-definitions/include/vpp/acl_prefix.xml.i create mode 100644 interface-definitions/include/vpp/acl_tcp_flags.xml.i create mode 100644 op-mode-definitions/vpp_acl.xml.in create mode 100644 python/vyos/vpp/acl/__init__.py create mode 100644 python/vyos/vpp/acl/acl.py create mode 100644 src/conf_mode/vpp_acl.py create mode 100644 src/op_mode/vpp_acl.py (limited to 'python') diff --git a/data/config-mode-dependencies/vyos-vpp.json b/data/config-mode-dependencies/vyos-vpp.json index 0f1d8af34..2a5613559 100644 --- a/data/config-mode-dependencies/vyos-vpp.json +++ b/data/config-mode-dependencies/vyos-vpp.json @@ -10,41 +10,48 @@ "vpp_interfaces_loopback": ["vpp_interfaces_loopback"], "vpp_interfaces_vxlan": ["vpp_interfaces_vxlan"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_bonding": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ethernet": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_geneve": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_gre": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_ipip": { "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] }, "vpp_interfaces_loopback": { + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] @@ -52,6 +59,7 @@ "vpp_interfaces_vxlan": { "vpp_interfaces_bridge": ["vpp_interfaces_bridge"], "vpp_interfaces_xconnect": ["vpp_interfaces_xconnect"], + "vpp_acl": ["vpp_acl"], "vpp_nat": ["vpp_nat"], "vpp_nat_cgnat": ["vpp_nat_cgnat"], "vpp_kernel_interface": ["vpp_kernel-interfaces"] diff --git a/data/templates/vpp/startup.conf.j2 b/data/templates/vpp/startup.conf.j2 index 589afa67b..79a551338 100644 --- a/data/templates/vpp/startup.conf.j2 +++ b/data/templates/vpp/startup.conf.j2 @@ -114,6 +114,8 @@ plugins { plugin crypto_openssl_plugin.so { enable } {% endif %} # plugin wireguard_plugin.so { enable } + # ACL + plugin acl_plugin.so { enable } } linux-cp { diff --git a/interface-definitions/include/vpp/acl_common_interface_ip_rule.xml.i b/interface-definitions/include/vpp/acl_common_interface_ip_rule.xml.i new file mode 100644 index 000000000..a719a6223 --- /dev/null +++ b/interface-definitions/include/vpp/acl_common_interface_ip_rule.xml.i @@ -0,0 +1,25 @@ + + + + ACL rule (tag) number + + u32 + Number + + + + + Number must be between 1 and 4294967295 + + + + + ACL tag name + + vpp acl ip tag-name + + + + + + diff --git a/interface-definitions/include/vpp/acl_port_range.xml.i b/interface-definitions/include/vpp/acl_port_range.xml.i new file mode 100644 index 000000000..26bd2de45 --- /dev/null +++ b/interface-definitions/include/vpp/acl_port_range.xml.i @@ -0,0 +1,18 @@ + + + + Port number or range + + u32:1-65535 + Numeric IP port + + + range + Numbered port range (e.g., 1001-1005) + + + + + + + diff --git a/interface-definitions/include/vpp/acl_prefix.xml.i b/interface-definitions/include/vpp/acl_prefix.xml.i new file mode 100644 index 000000000..790153c8f --- /dev/null +++ b/interface-definitions/include/vpp/acl_prefix.xml.i @@ -0,0 +1,20 @@ + + + + IP prefix + + ipv4net + IPv4 prefix + + + ipv6net + IPv6 prefix + + + + + + + + + diff --git a/interface-definitions/include/vpp/acl_tcp_flags.xml.i b/interface-definitions/include/vpp/acl_tcp_flags.xml.i new file mode 100644 index 000000000..da17f3fe5 --- /dev/null +++ b/interface-definitions/include/vpp/acl_tcp_flags.xml.i @@ -0,0 +1,50 @@ + + + + Synchronise flag + + + + + + Acknowledge flag + + + + + + Finish flag + + + + + + Reset flag + + + + + + Urgent flag + + + + + + Push flag + + + + + + Explicit Congestion Notification flag + + + + + + Congestion Window Reduced flag + + + + diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index 368405a02..dab0ea308 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -942,7 +942,7 @@ Carrier-grade NAT (CGNAT) - 321 + 331 @@ -1060,7 +1060,7 @@ NAT44 - 320 + 330 @@ -1265,6 +1265,256 @@ + + + Access Control Lists (ACLs) + 332 + + + + + Access Control List by IPv4/IPv6 + + + + + ACL tag name + + txt + Tag name + + + + #include + + + Rule number for Access control element (ACE) + + u32 + Number of ACE + + + + + Rule number must be between 1 and 4294967295 + + + #include + + + Rule action + + permit deny permit-reflect + + + permit + Permit matching traffic + + + deny + Deny matching traffic + + + permit-reflect + Permit the matching outbound traffic and allow the reverse traffic + + + (permit|deny|permit-reflect) + + + + + + Source parameters + + + #include + #include + + + + + Destination parameters + + + #include + #include + + + + + Protocol + + + all + + + all + All IP protocols + + + <protocol> + IP protocol name + + + + + + all + + + + TCP flags + + + #include + + + Match flags not set + + + #include + + + + + + + + + + + Apply an ACL to an interface + + + + + + + + Input direction + + + #include + + + + + Output direction + + + #include + + + + + + + + + Access Control List by mac address + + + + + ACL tag name + + txt + ACL name + + + + #include + + + Rule number for Access control element (ACE) + + u32 + Number of ACE + + + + + Rule number must be between 1 and 4294967295 + + + #include + + + Rule action + + permit deny + + + permit + Permit matching traffic + + + deny + Deny matching traffic + + + (permit|deny) + + + + #include + + + Source IP prefix + + + + + Source MAC address + + macaddr + MAC address + + + + + + + + + Source MAC mask (default ff:ff:ff:ff:ff:ff) + + macaddr + MAC mask + + + + + + ff:ff:ff:ff:ff:ff + + + + + + + + Apply an ACL to an input interface + + + + + + + + ACL tag name + + vpp acl macip tag-name + + + + + + + + + VPP kernel interface settings diff --git a/op-mode-definitions/vpp_acl.xml.in b/op-mode-definitions/vpp_acl.xml.in new file mode 100644 index 000000000..a901a537d --- /dev/null +++ b/op-mode-definitions/vpp_acl.xml.in @@ -0,0 +1,65 @@ + + + + + + + + + Show VPP ACL information + + + + + Show VPP ACL by IPv4/IPv6 + + + + + Show specified VPP ACL + + vpp acl ip tag-name + + + sudo ${vyos_op_scripts_dir}/vpp_acl.py show_ip_acls --tag-name="$6" + + + + Show VPP ACL interfaces + + sudo ${vyos_op_scripts_dir}/vpp_acl.py show_interfaces + + + sudo ${vyos_op_scripts_dir}/vpp_acl.py show_ip_acls + + + + Show VPP ACL by macip + + + + + Show specified VPP ACL + + vpp acl macip tag-name + + + sudo ${vyos_op_scripts_dir}/vpp_acl.py show_macip_acls --tag-name="$6" + + + + Show VPP ACL interfaces + + sudo ${vyos_op_scripts_dir}/vpp_acl.py show_macip_interfaces + + + sudo ${vyos_op_scripts_dir}/vpp_acl.py show_macip_acls + + + sudo ${vyos_op_scripts_dir}/vpp_acl.py show_all_acls + + + + + + diff --git a/python/vyos/vpp/acl/__init__.py b/python/vyos/vpp/acl/__init__.py new file mode 100644 index 000000000..6bd6cdb23 --- /dev/null +++ b/python/vyos/vpp/acl/__init__.py @@ -0,0 +1,3 @@ +from .acl import Acl + +__all__ = ['Acl'] diff --git a/python/vyos/vpp/acl/acl.py b/python/vyos/vpp/acl/acl.py new file mode 100644 index 000000000..9da241f1c --- /dev/null +++ b/python/vyos/vpp/acl/acl.py @@ -0,0 +1,106 @@ +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.vpp import VPPControl + + +NO_ACL_INDEX = 0xFFFFFFFF + + +class Acl: + def __init__(self): + self.vpp = VPPControl() + + def get_acl_index_by_tag(self, tag): + """Get ACL index by tag name + https://github.com/FDio/vpp/blob/21c641f9356da5137760cdc799127064c8c1fd31/src/plugins/acl/acl.api + """ + for acl in self.vpp.api.acl_dump(acl_index=NO_ACL_INDEX): + if acl.tag == tag: + return acl.acl_index + return NO_ACL_INDEX + + def add_replace_acl(self, tag, rules): + """Add new ACL or replace existing one""" + self.vpp.api.acl_add_replace( + tag=tag, + acl_index=self.get_acl_index_by_tag(tag), + count=len(rules), + r=rules, + ) + + def delete_acl(self, tag): + """Delete existing ACL""" + self.vpp.api.acl_del(acl_index=self.get_acl_index_by_tag(tag)) + + def add_acl_interface(self, interface, input_tags, output_tags): + """Add or replace ACLs on interface""" + acls = [] + for tag in input_tags: + acl_index = self.get_acl_index_by_tag(tag) + acls.append(acl_index) + for tag in output_tags: + acl_index = self.get_acl_index_by_tag(tag) + acls.append(acl_index) + self.vpp.api.acl_interface_set_acl_list( + sw_if_index=self.vpp.get_sw_if_index(interface), + count=len(acls), + n_input=len(input_tags), + acls=acls, + ) + + def delete_acl_interface(self, interface): + """Delete ACLs from interface""" + self.vpp.api.acl_interface_set_acl_list( + sw_if_index=self.vpp.get_sw_if_index(interface), + count=0, + ) + + def get_macip_acl_index_by_tag(self, tag): + """Get macip ACL by tag name""" + for acl in self.vpp.api.macip_acl_dump(): + if acl.tag == tag: + return acl.acl_index + return NO_ACL_INDEX + + def add_replace_acl_macip(self, tag, rules): + """Add or replace existing macip ACL""" + self.vpp.api.macip_acl_add_replace( + tag=tag, + acl_index=self.get_macip_acl_index_by_tag(tag), + count=len(rules), + r=rules, + ) + + def delete_acl_macip(self, tag): + """Delete existing macip ACL""" + self.vpp.api.macip_acl_del(acl_index=self.get_macip_acl_index_by_tag(tag)) + + def add_acl_macip_interface(self, interface, tag): + """Add or replace macip ACLs on interface""" + self.vpp.api.macip_acl_interface_add_del( + sw_if_index=self.vpp.get_sw_if_index(interface), + acl_index=self.get_macip_acl_index_by_tag(tag), + is_add=True, + ) + + def delete_acl_macip_interface(self, interface): + """Delete macip ACLs from interface""" + self.vpp.api.macip_acl_interface_add_del( + sw_if_index=self.vpp.get_sw_if_index(interface), + is_add=False, + ) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index 3718f3897..ed0e80c2c 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -141,6 +141,10 @@ def get_config(config=None): if conf.exists(['vpp', 'nat', 'cgnat']): set_dependents('vpp_nat_cgnat', conf) + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + if not conf.exists(base): return { 'removed_ifaces': removed_ifaces, diff --git a/src/conf_mode/vpp_acl.py b/src/conf_mode/vpp_acl.py new file mode 100644 index 000000000..28eae485b --- /dev/null +++ b/src/conf_mode/vpp_acl.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import ipaddress + +from vyos import ConfigError + +from vyos.configdiff import Diff +from vyos.configdict import node_changed +from vyos.config import Config +from vyos.utils.network import get_protocol_by_name + +from vyos.vpp.utils import cli_ifaces_list +from vyos.vpp.acl import Acl + + +# TCP flag names to bit values +TCP_FLAGS = { + 'FIN': 0x01, + 'SYN': 0x02, + 'RST': 0x04, + 'PSH': 0x08, + 'ACK': 0x10, + 'URG': 0x20, + 'ECN': 0x40, + 'CWR': 0x80, +} + +# ACL action flags +action_map = { + 'deny': 0, + 'permit': 1, + 'permit-reflect': 2, +} + + +def get_tcp_mask_value(set_flags, unset_flags): + mask = 0 + value = 0 + + for flag in set_flags + unset_flags: + bit = TCP_FLAGS.get(flag.upper()) + mask |= bit + if flag in set_flags: + value |= bit + + return mask, value + + +def get_port_first_last(port_range, protocol): + first_port = 0 + last_port = 65535 + if not port_range: + if protocol in ['icmp', 'ipv6-icmp']: + last_port = 255 + elif '-' not in port_range: + first_port = last_port = port_range + else: + first_port, last_port = port_range.split('-') + return int(first_port), int(last_port) + + +def create_ip_rules_list(rules): + rules_list = [] + for rule in rules.values(): + r = { + 'is_permit': action_map[rule.get('action')], + 'src_prefix': rule.get('source', {}).get('prefix', ''), + 'dst_prefix': rule.get('destination', {}).get('prefix', ''), + 'proto': ( + int(get_protocol_by_name(rule.get('protocol'))) + if rule.get('protocol') != 'all' + else 0 + ), + } + + tcp_flags = rule.get('tcp_flags', {}) + set_flags = [flag for flag in tcp_flags if flag != 'not'] + unet_flags = list(tcp_flags.get('not', {}).keys()) + tcp_mask, tcp_value = get_tcp_mask_value(set_flags, unet_flags) + r['tcp_flags_mask'] = tcp_mask + r['tcp_flags_value'] = tcp_value + + src_ports = rule.get('source', {}).get('port') + src_first_port, src_last_port = get_port_first_last( + src_ports, rule.get('protocol') + ) + r['srcport_or_icmptype_first'] = src_first_port + r['srcport_or_icmptype_last'] = src_last_port + + dst_ports = rule.get('destination', {}).get('port') + dst_first_port, dst_last_port = get_port_first_last( + dst_ports, rule.get('protocol') + ) + r['dstport_or_icmpcode_first'] = dst_first_port + r['dstport_or_icmpcode_last'] = dst_last_port + + rules_list.append(r) + + return rules_list + + +def create_macip_rules_list(rules): + rules_list = [] + for rule in rules.values(): + r = { + 'is_permit': action_map[rule.get('action')], + 'src_prefix': rule.get('prefix', ''), + 'src_mac': rule.get('mac_address', ''), + 'src_mac_mask': rule.get('mac_mask', ''), + } + rules_list.append(r) + + return rules_list + + +def get_config(config=None) -> dict: + if config: + conf = config + else: + conf = Config() + + base = ['vpp', 'acl'] + + # Get config_dict with default values + config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + with_defaults=True, + with_recursive_defaults=True, + ) + + # Get effective config as we need full dictionary for deletion + effective_config = conf.get_config_dict( + base, + key_mangling=('-', '_'), + effective=True, + get_first_key=True, + no_tag_node_value_mangle=True, + ) + + if not config: + config['remove'] = True + + changed_ip_ifaces = node_changed( + conf, + base + ['ip', 'interface'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + changed_macip_ifaces = node_changed( + conf, + base + ['macip', 'interface'], + key_mangling=('-', '_'), + recursive=True, + expand_nodes=Diff.DELETE | Diff.ADD, + ) + + config.update( + { + 'changed_ip_ifaces': changed_ip_ifaces, + 'changed_macip_ifaces': changed_macip_ifaces, + 'vpp_ifaces': cli_ifaces_list(conf), + } + ) + + if effective_config: + config.update({'effective': effective_config}) + + return config + + +def verify(config): + if 'remove' in config: + return None + + for acl_type in ['ip', 'macip']: + if acl_type in config: + acl = config.get(acl_type) + if 'tag_name' not in acl: + raise ConfigError(f'"tag-name" is required for "acl {acl_type}"') + + for acl_name, acl_config in acl.get('tag_name').items(): + if 'rule' not in acl_config: + raise ConfigError(f'Rules must be configured for ACL {acl_name}') + + for rule, rule_config in acl_config.get('rule').items(): + err_msg = f'Configuration error for {acl_type} ACL {acl_name} in rule {rule}:' + if 'action' not in rule_config: + raise ConfigError(f'{err_msg} action must be defined') + + for iface, iface_config in acl.get('interface', {}).items(): + if iface not in config.get('vpp_ifaces'): + raise ConfigError( + f'{iface} must be a VPP interface for ACL interface' + ) + + if 'ip' in config: + acl = config.get('ip') + for acl_name, acl_config in acl.get('tag_name').items(): + for rule, rule_config in acl_config.get('rule').items(): + err_msg = ( + f'Configuration error for {acl_type} ACL {acl_name} in rule {rule}:' + ) + + # verify IPv4 and IPv6 address family + src_prefix = rule_config.get('source', {}).get('prefix') + dst_prefix = rule_config.get('destination', {}).get('prefix') + src = ipaddress.ip_network(src_prefix) if src_prefix else None + dst = ipaddress.ip_network(dst_prefix) if dst_prefix else None + + if src and dst: + if src.version != dst.version: + raise ConfigError( + f'{err_msg} source and destination prefixes must be from the same IP family' + ) + elif src or dst: + family = src.version if src else dst.version + if family == 6: + raise ConfigError( + f'{err_msg} both source and destination prefixes must be defined for IPv6' + ) + + # verify protocol + protocol = rule_config.get('protocol') + if protocol != 'all': + proto = get_protocol_by_name(protocol) + if not isinstance(proto, int) and ( + not proto.isdigit() or int(proto) > 147 + ): + raise ConfigError( + f'{err_msg} protocol name {protocol} is not valid' + ) + + # verify TCP flags + if 'tcp_flags' in rule_config: + if rule_config.get('protocol') != 'tcp': + raise ConfigError( + f'{err_msg} protocol must be tcp when specifying tcp flags' + ) + + not_flags = rule_config.get('tcp_flags').get('not', []) + if not_flags: + duplicates = [ + flag + for flag in rule_config.get('tcp_flags') + if flag in not_flags + ] + if duplicates: + raise ConfigError( + f'{err_msg} cannot match a tcp flag as set and not set: {duplicates}' + ) + + for iface, iface_config in acl.get('interface', {}).items(): + if not any(key in iface_config for key in ('input', 'output')): + raise ConfigError( + f'Please specify direction input/output for interface {iface}' + ) + + for direction in ['input', 'output']: + if direction in iface_config: + iface_acl = iface_config.get(direction) + if 'acl_tag' not in iface_acl: + raise ConfigError( + f'"acl-tag" is required for {direction} interface {iface}' + ) + + used_names = [] + for tag, tag_conf in iface_acl.get('acl_tag').items(): + if 'tag_name' not in tag_conf: + raise ConfigError( + f'"tag-name" is required for {direction} interface {iface} with acl-tag {tag}' + ) + name = tag_conf.get('tag_name') + if name not in acl.get('tag_name').keys(): + raise ConfigError( + f'ACL with tag-name {name} does not exist. ' + f'Cannot use it for {direction} interface {iface}' + ) + if name in used_names: + raise ConfigError( + f'ACL with tag-name {name} is already used for {direction} interface {iface}' + ) + used_names.append(name) + + if 'macip' in config: + acl = config.get('macip') + for iface, iface_config in acl.get('interface', {}).items(): + if 'tag_name' not in iface_config: + raise ConfigError(f'"tag-name" is required for interface {iface}') + name = iface_config.get('tag_name') + if name not in acl.get('tag_name').keys(): + raise ConfigError( + f'ACL with tag-name {name} does not exist. Cannot use it for interface {iface}' + ) + + +def generate(config): + pass + + +def apply(config): + acl = Acl() + + if 'effective' in config: + # Delete ACL ip + if 'ip' in config.get('effective'): + remove_config_ip = config.get('effective').get('ip') + + # Delete ACL interfaces + for interface in config.get('changed_ip_ifaces'): + acl.delete_acl_interface(interface) + + # Delete ACLs + for acl_name in remove_config_ip.get('tag_name'): + if acl_name not in config.get('ip', {}).get('tag_name', {}): + acl.delete_acl(acl_name) + + # Delete ACL macip + if 'macip' in config.get('effective'): + remove_config_macip = config.get('effective').get('macip') + + # Delete ACL interfaces + for interface in config.get('changed_macip_ifaces'): + acl.delete_acl_macip_interface(interface) + + # Delete ACL macip + for acl_name in remove_config_macip.get('tag_name'): + if acl_name not in config.get('macip', {}).get('tag_name', {}): + acl.delete_acl_macip(acl_name) + + if 'remove' in config: + return None + + # Add or replace ACL ip + config_ip = config.get('ip', {}) + for acl_name in config_ip.get('tag_name', {}): + rules = create_ip_rules_list( + config_ip.get('tag_name').get(acl_name).get('rule') + ) + acl.add_replace_acl(acl_name, rules) + + for iface, iface_config in config_ip.get('interface', {}).items(): + input_tags = [ + v['tag_name'] + for v in iface_config.get('input', {}).get('acl_tag', {}).values() + ] + output_tags = [ + v['tag_name'] + for v in iface_config.get('output', {}).get('acl_tag', {}).values() + ] + acl.add_acl_interface(iface, input_tags, output_tags) + + # Add or replace ACL macip + config_macip = config.get('macip', {}) + for acl_name in config_macip.get('tag_name', {}): + rules = create_macip_rules_list( + config_macip.get('tag_name').get(acl_name).get('rule') + ) + acl.add_replace_acl_macip(acl_name, rules) + + for iface, iface_config in config_macip.get('interface', {}).items(): + acl.add_acl_macip_interface(iface, iface_config.get('tag_name')) + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) diff --git a/src/conf_mode/vpp_interfaces_bonding.py b/src/conf_mode/vpp_interfaces_bonding.py index 7ab1abb24..2b17acb24 100644 --- a/src/conf_mode/vpp_interfaces_bonding.py +++ b/src/conf_mode/vpp_interfaces_bonding.py @@ -139,6 +139,16 @@ def get_config(config=None) -> dict: if conf.exists(['vpp', 'kernel-interfaces', iface]): set_dependents('vpp_kernel_interface', conf, iface) + # NAT dependency + if conf.exists(['vpp', 'nat44']): + set_dependents('vpp_nat', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + config['ifname'] = ifname return config diff --git a/src/conf_mode/vpp_interfaces_gre.py b/src/conf_mode/vpp_interfaces_gre.py index 823f3ff4d..f6da2d55d 100644 --- a/src/conf_mode/vpp_interfaces_gre.py +++ b/src/conf_mode/vpp_interfaces_gre.py @@ -110,6 +110,16 @@ def get_config(config=None) -> dict: if conf.exists(['vpp', 'kernel-interfaces', iface]): set_dependents('vpp_kernel_interface', conf, iface) + # NAT dependency + if conf.exists(['vpp', 'nat44']): + set_dependents('vpp_nat', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + config['ifname'] = ifname return config diff --git a/src/conf_mode/vpp_interfaces_ipip.py b/src/conf_mode/vpp_interfaces_ipip.py index c52e7c997..f40a4e243 100644 --- a/src/conf_mode/vpp_interfaces_ipip.py +++ b/src/conf_mode/vpp_interfaces_ipip.py @@ -109,6 +109,16 @@ def get_config(config=None) -> dict: if iface in config.get('vpp_kernel_interfaces'): set_dependents('vpp_kernel_interface', conf, iface) + # NAT dependency + if conf.exists(['vpp', 'nat44']): + set_dependents('vpp_nat', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + config['ifname'] = ifname return config diff --git a/src/conf_mode/vpp_interfaces_loopback.py b/src/conf_mode/vpp_interfaces_loopback.py index f0bcdfb18..2b37b5e1c 100644 --- a/src/conf_mode/vpp_interfaces_loopback.py +++ b/src/conf_mode/vpp_interfaces_loopback.py @@ -99,6 +99,16 @@ def get_config(config=None) -> dict: if conf.exists(['vpp', 'kernel-interfaces', iface]): set_dependents('vpp_kernel_interface', conf, iface) + # NAT dependency + if conf.exists(['vpp', 'nat44']): + set_dependents('vpp_nat', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + config['ifname'] = ifname return config diff --git a/src/conf_mode/vpp_interfaces_vxlan.py b/src/conf_mode/vpp_interfaces_vxlan.py index 963cd90ec..065a8eafb 100644 --- a/src/conf_mode/vpp_interfaces_vxlan.py +++ b/src/conf_mode/vpp_interfaces_vxlan.py @@ -116,6 +116,16 @@ def get_config(config=None) -> dict: if conf.exists(['vpp', 'kernel-interfaces', iface]): set_dependents('vpp_kernel_interface', conf, iface) + # NAT dependency + if conf.exists(['vpp', 'nat44']): + set_dependents('vpp_nat', conf) + if conf.exists(['vpp', 'nat', 'cgnat']): + set_dependents('vpp_nat_cgnat', conf) + + # ACL dependency + if conf.exists(['vpp', 'acl']): + set_dependents('vpp_acl', conf) + config['ifname'] = ifname return config diff --git a/src/op_mode/vpp_acl.py b/src/op_mode/vpp_acl.py new file mode 100644 index 000000000..7afe96433 --- /dev/null +++ b/src/op_mode/vpp_acl.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json +import sys +import typing +from tabulate import tabulate + +import vyos.opmode +from vyos.config import Config +from vyos.configquery import ConfigTreeQuery + +from vyos.vpp import VPPControl + + +NO_ACL_INDEX = 0xFFFFFFFF + +# ACL action flags +action_map = { + 0: 'deny', + 1: 'permit', + 2: 'permit-reflect', +} + +# TCP flag names to bit values +TCP_FLAGS = { + 'FIN': 0x01, + 'SYN': 0x02, + 'RST': 0x04, + 'PSH': 0x08, + 'ACK': 0x10, + 'URG': 0x20, + 'ECN': 0x40, + 'CWR': 0x80, +} + + +def _verify(target): + """Decorator checks if config for VPP NAT CGNAT exists""" + from functools import wraps + + if target not in ['ip', 'macip', 'no_target']: + raise ValueError('Invalid target') + + def _verify_target(func): + @wraps(func) + def _wrapper(*args, **kwargs): + config = ConfigTreeQuery() + path = 'vpp acl' + if target == 'ip': + path += ' ip' + elif target == 'macip': + path += ' macip' + if not config.exists(path): + raise vyos.opmode.UnconfiguredSubsystem(f'"{path}" is not configured') + return func(*args, **kwargs) + + return _wrapper + + return _verify_target + + +def _get_acl_tag_by_index(vpp, acl_index): + acl = vpp.api.acl_dump(acl_index=acl_index) + if acl: + return acl[0].tag + + return None + + +def _get_macip_acl_tag_by_index(vpp, acl_index): + acl = vpp.api.macip_acl_dump(acl_index=acl_index) + if acl: + return acl[0].tag + + return None + + +def _get_tcp_flag_states(value, mask): + set_flags = [] + unset_flags = [] + for flag, bit in TCP_FLAGS.items(): + if mask & bit: # This flag is being checked + if value & bit: + set_flags.append(flag) + else: + unset_flags.append(flag) + return sorted(set_flags), sorted(unset_flags) + + +def _get_raw_output_acls(data_dump): + out = [] + for data in data_dump: + rules = [json.loads(json.dumps(d._asdict(), default=str)) for d in data.r] + out.append( + { + 'acl_index': data.acl_index, + 'tag': data.tag, + 'count': data.count, + 'r': rules, + } + ) + return out + + +def _get_raw_output_interfaces(data_dump): + ifaces_list = [] + for iface in data_dump: + if iface.count != 0: + ifaces_list.append(json.loads(json.dumps(iface._asdict(), default=str))) + return ifaces_list + + +def _get_formatted_output_interfaces(vpp, interfaces): + data_entries = [] + for interface in interfaces: + name = vpp.get_interface_name(interface.get('sw_if_index')) + input_acls = [] + for acl_index in interface.get('acls')[: interface.get('n_input')]: + input_acls.append(_get_acl_tag_by_index(vpp, int(acl_index))) + output_acls = [] + for acl_index in interface.get('acls')[interface.get('n_input') :]: + output_acls.append(_get_acl_tag_by_index(vpp, int(acl_index))) + values = [ + name, + '\n'.join(input_acls), + '\n'.join(output_acls), + ] + data_entries.append(values) + + headers = ['Interface', 'Input ACLs', 'Output ACLs'] + return tabulate(data_entries, headers=headers, tablefmt='simple') + + +def _get_formatted_output_macip_interfaces(vpp, interfaces): + data_entries = [] + for interface in interfaces: + name = vpp.get_interface_name(interface.get('sw_if_index')) + acl = _get_macip_acl_tag_by_index(vpp, int(interface.get('acls')[0])) + data_entries.append([name, acl]) + + headers = ['Interface', 'ACL'] + return tabulate(data_entries, headers=headers, tablefmt='simple') + + +def _get_formatted_output_acls(acls_list): + conf = Config() + + for acl in acls_list: + acl_index = acl.get('acl_index') + tag = acl.get('tag') + rules = acl.get('r') + print( + '\n---------------------------------\n' + f'IP ACL "tag-name {tag}" acl_index {acl_index}\n' + ) + + path = ['vpp', 'acl', 'ip', 'tag-name', tag, 'rule'] + conf_rules = conf.list_nodes(path) + data_entries = [] + for rule_index, rule in enumerate(rules): + srcport_first = str(rule.get('srcport_or_icmptype_first')) + srcport_last = str(rule.get('srcport_or_icmptype_last')) + dstport_first = str(rule.get('dstport_or_icmpcode_first')) + dstport_last = str(rule.get('dstport_or_icmpcode_last')) + set_flags, unset_flags = _get_tcp_flag_states( + rule.get('tcp_flags_value'), rule.get('tcp_flags_mask') + ) + + values = [ + conf_rules[rule_index], + action_map.get(rule.get('is_permit')), + rule.get('src_prefix'), + ( + f'{srcport_first}-{srcport_last}' + if srcport_first != srcport_last + else srcport_first + ), + rule.get('dst_prefix'), + ( + f'{dstport_first}-{dstport_last}' + if dstport_first != dstport_last + else dstport_first + ), + rule.get('proto'), + '\n'.join(set_flags), + '\n'.join(unset_flags), + ] + data_entries.append(values) + + headers = [ + 'Rule', + 'Action', + 'Src prefix', + 'Src port', + 'Dst prefix', + 'Dst port', + 'Proto', + 'TCP flags set', + 'TCP flags not set', + ] + print(tabulate(data_entries, headers=headers, tablefmt='simple')) + print('\n') + + +def _get_formatted_output_macip_acls(acls_list): + conf = Config() + + for acl in acls_list: + acl_index = acl.get('acl_index') + tag = acl.get('tag') + rules = acl.get('r') + print( + '\n---------------------------------\n' + f'MACIP ACL "tag-name {tag}" acl_index {acl_index}\n' + ) + + path = ['vpp', 'acl', 'macip', 'tag-name', tag, 'rule'] + conf_rules = conf.list_nodes(path) + data_entries = [] + for rule_index, rule in enumerate(rules): + values = [ + conf_rules[rule_index], + action_map.get(rule.get('is_permit')), + rule.get('src_prefix'), + rule.get('src_mac'), + rule.get('src_mac_mask'), + ] + data_entries.append(values) + + headers = [ + 'Rule', + 'Action', + 'IP prefix', + 'MAC address', + 'MAC mask', + ] + print(tabulate(data_entries, headers=headers, tablefmt='simple')) + print('\n') + + +def _find_acl_by_tag(acls, tag_name): + return [acl for acl in acls if acl['tag'] == tag_name] + + +@_verify('ip') +def show_ip_acls(raw: bool, tag_name: typing.Optional[str]): + vpp = VPPControl() + acls_dump = vpp.api.acl_dump(acl_index=NO_ACL_INDEX) + acls: list[dict] = _get_raw_output_acls(acls_dump) + + if tag_name: + acls = _find_acl_by_tag(acls, tag_name) + + if raw: + return acls + + else: + return _get_formatted_output_acls(acls) + + +@_verify('macip') +def show_macip_acls(raw: bool, tag_name: typing.Optional[str]): + vpp = VPPControl() + acls_dump = vpp.api.macip_acl_dump(acl_index=NO_ACL_INDEX) + acls: list[dict] = _get_raw_output_acls(acls_dump) + + if tag_name: + acls = _find_acl_by_tag(acls, tag_name) + + if raw: + return acls + + else: + return _get_formatted_output_macip_acls(acls) + + +@_verify('ip') +def show_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.acl_interface_list_dump() + interfaces: list[dict] = _get_raw_output_interfaces(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_interfaces(vpp, interfaces) + + +@_verify('macip') +def show_macip_interfaces(raw: bool): + vpp = VPPControl() + interfaces_dump = vpp.api.macip_acl_interface_list_dump() + interfaces: list[dict] = _get_raw_output_interfaces(interfaces_dump) + + if raw: + return interfaces + + else: + return _get_formatted_output_macip_interfaces(vpp, interfaces) + + +@_verify('no_target') +def show_all_acls(raw: bool): + conf = Config() + acls_all = {} + path = ['vpp', 'acl'] + if conf.exists(path + ['ip']): + ip_acls = show_ip_acls(raw, tag_name=None) + acls_all['ip'] = ip_acls + if conf.exists(path + ['macip']): + macip_acls = show_macip_acls(raw, tag_name=None) + acls_all['macip'] = macip_acls + + if raw: + return acls_all + + +if __name__ == '__main__': + try: + res = vyos.opmode.run(sys.modules[__name__]) + if res: + print(res) + except (ValueError, vyos.opmode.Error) as e: + print(e) + sys.exit(1) -- cgit v1.2.3 From 1f6939dae267e163faa591cb67004c237ad10e16 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Thu, 8 May 2025 10:33:04 +0000 Subject: T7348: Add config CPU thread-count for accel-ppp services Accel-ppp services should not use all CPU cores to process requests. At the moment accel-ppp services use all available CPU cores to process requests from the subscribers (establish/update session/etc). During mass connection of sessions, this can lead to the fact that it utilizes all CPU, and for other services like FRR, there is not enough CPU time to process their own stable work. services: - L2TP - SSTP - PPPoE - IPoE - PPtP Add this option configurable and use all cores if not set: ``` set service pppoe-server thread-count < all | half | x > ``` The defaultValue `all` --- .../include/accel-ppp/thread-count.xml.i | 27 ++++++++++++++++++++++ interface-definitions/service_ipoe-server.xml.in | 1 + interface-definitions/service_pppoe-server.xml.in | 1 + interface-definitions/vpn_l2tp.xml.in | 1 + interface-definitions/vpn_pptp.xml.in | 1 + interface-definitions/vpn_sstp.xml.in | 1 + python/vyos/configdict.py | 12 +++++++++- python/vyos/utils/cpu.py | 6 +++++ 8 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 interface-definitions/include/accel-ppp/thread-count.xml.i (limited to 'python') diff --git a/interface-definitions/include/accel-ppp/thread-count.xml.i b/interface-definitions/include/accel-ppp/thread-count.xml.i new file mode 100644 index 000000000..84d9224d0 --- /dev/null +++ b/interface-definitions/include/accel-ppp/thread-count.xml.i @@ -0,0 +1,27 @@ + + + + Number of working threads + + all half + + + all + Use all available CPU cores + + + half + Use half of available CPU cores + + + u32:1-512 + Thread count + + + + (all|half) + + + all + + diff --git a/interface-definitions/service_ipoe-server.xml.in b/interface-definitions/service_ipoe-server.xml.in index fe9d32bbd..3093151ea 100644 --- a/interface-definitions/service_ipoe-server.xml.in +++ b/interface-definitions/service_ipoe-server.xml.in @@ -237,6 +237,7 @@ #include #include #include + #include #include #include #include diff --git a/interface-definitions/service_pppoe-server.xml.in b/interface-definitions/service_pppoe-server.xml.in index 32215e9d2..81a4a95e3 100644 --- a/interface-definitions/service_pppoe-server.xml.in +++ b/interface-definitions/service_pppoe-server.xml.in @@ -175,6 +175,7 @@ #include #include + #include #include #include #include diff --git a/interface-definitions/vpn_l2tp.xml.in b/interface-definitions/vpn_l2tp.xml.in index c00e82534..d28f86653 100644 --- a/interface-definitions/vpn_l2tp.xml.in +++ b/interface-definitions/vpn_l2tp.xml.in @@ -137,6 +137,7 @@ #include #include #include + #include #include #include #include diff --git a/interface-definitions/vpn_pptp.xml.in b/interface-definitions/vpn_pptp.xml.in index 8aec0cb1c..3e985486d 100644 --- a/interface-definitions/vpn_pptp.xml.in +++ b/interface-definitions/vpn_pptp.xml.in @@ -53,6 +53,7 @@ #include #include #include + #include #include #include #include diff --git a/interface-definitions/vpn_sstp.xml.in b/interface-definitions/vpn_sstp.xml.in index 5fd5c95ca..851a202dc 100644 --- a/interface-definitions/vpn_sstp.xml.in +++ b/interface-definitions/vpn_sstp.xml.in @@ -50,6 +50,7 @@ #include #include #include + #include #include #include #include diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index ff0a15933..a34b0176a 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -661,6 +661,7 @@ def get_accel_dict(config, base, chap_secrets, with_pki=False): Return a dictionary with the necessary interface config keys. """ from vyos.utils.cpu import get_core_count + from vyos.utils.cpu import get_half_cpus from vyos.template import is_ipv4 dict = config.get_config_dict(base, key_mangling=('-', '_'), @@ -670,7 +671,16 @@ def get_accel_dict(config, base, chap_secrets, with_pki=False): with_pki=with_pki) # set CPUs cores to process requests - dict.update({'thread_count' : get_core_count()}) + match dict.get('thread_count'): + case 'all': + dict['thread_count'] = get_core_count() + case 'half': + dict['thread_count'] = get_half_cpus() + case str(x) if x.isdigit(): + dict['thread_count'] = int(x) + case _: + dict['thread_count'] = get_core_count() + # we need to store the path to the secrets file dict.update({'chap_secrets_file' : chap_secrets}) diff --git a/python/vyos/utils/cpu.py b/python/vyos/utils/cpu.py index 8ace77d15..6f21eb526 100644 --- a/python/vyos/utils/cpu.py +++ b/python/vyos/utils/cpu.py @@ -26,6 +26,7 @@ It has special cases for x86_64 and MAY work correctly on other architectures, but nothing is certain. """ +import os import re def _read_cpuinfo(): @@ -114,3 +115,8 @@ def get_available_cpus(): out = json.loads(cmd('lscpu --extended -b --json')) return out['cpus'] + + +def get_half_cpus(): + """ return 1/2 of the numbers of available CPUs """ + return max(1, os.cpu_count() // 2) -- cgit v1.2.3 From ea6eff90407043e1d64a0cd5424ec6e44b04b1d4 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Fri, 16 May 2025 10:17:21 +0000 Subject: T7414: Fix conntrack ignore rules for using several ports If we use several port for the `conntrack ignore` there have to be used curly braces for nftables Incorrect format: dport 500,4500 Correct format: dport { 500, 4500 } --- python/vyos/template.py | 2 +- smoketest/scripts/cli/test_system_conntrack.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/template.py b/python/vyos/template.py index 11e1cc50f..aa215db95 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -728,7 +728,7 @@ def conntrack_rule(rule_conf, rule_id, action, ipv6=False): if port[0] == '!': operator = '!=' port = port[1:] - output.append(f'th {prefix}port {operator} {port}') + output.append(f'th {prefix}port {operator} {{ {port} }}') if 'group' in side_conf: group = side_conf['group'] diff --git a/smoketest/scripts/cli/test_system_conntrack.py b/smoketest/scripts/cli/test_system_conntrack.py index 72deb7525..f6bb3cf7c 100755 --- a/smoketest/scripts/cli/test_system_conntrack.py +++ b/smoketest/scripts/cli/test_system_conntrack.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright (C) 2021-2025 VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as @@ -195,6 +195,8 @@ class TestSystemConntrack(VyOSUnitTestSHIM.TestCase): def test_conntrack_ignore(self): address_group = 'conntracktest' address_group_member = '192.168.0.1' + port_single = '53' + ports_multi = '500,4500' ipv6_address_group = 'conntracktest6' ipv6_address_group_member = 'dead:beef::1' @@ -211,6 +213,14 @@ class TestSystemConntrack(VyOSUnitTestSHIM.TestCase): self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '2', 'destination', 'group', 'address-group', address_group]) self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '2', 'protocol', 'all']) + self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '3', 'source', 'address', '192.0.2.1']) + self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '3', 'destination', 'port', ports_multi]) + self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '3', 'protocol', 'udp']) + + self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '4', 'source', 'address', '192.0.2.1']) + self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '4', 'destination', 'port', port_single]) + self.cli_set(base_path + ['ignore', 'ipv4', 'rule', '4', 'protocol', 'udp']) + self.cli_set(base_path + ['ignore', 'ipv6', 'rule', '11', 'source', 'address', 'fe80::1']) self.cli_set(base_path + ['ignore', 'ipv6', 'rule', '11', 'destination', 'address', 'fe80::2']) self.cli_set(base_path + ['ignore', 'ipv6', 'rule', '11', 'destination', 'port', '22']) @@ -226,7 +236,9 @@ class TestSystemConntrack(VyOSUnitTestSHIM.TestCase): nftables_search = [ ['ip saddr 192.0.2.1', 'ip daddr 192.0.2.2', 'tcp dport 22', 'tcp flags & syn == syn', 'notrack'], - ['ip saddr 192.0.2.1', 'ip daddr @A_conntracktest', 'notrack'] + ['ip saddr 192.0.2.1', 'ip daddr @A_conntracktest', 'notrack'], + ['ip saddr 192.0.2.1', 'udp dport { 500, 4500 }', 'notrack'], + ['ip saddr 192.0.2.1', 'udp dport 53', 'notrack'] ] nftables6_search = [ -- cgit v1.2.3 From 923c7f07cc123a79d9c940aa53bcb063da5da943 Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Mon, 19 May 2025 14:46:53 +0300 Subject: ipoe_server: T6997: Do not require to create client ip pool when dhcp-relay is used --- python/vyos/accel_ppp_util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/accel_ppp_util.py b/python/vyos/accel_ppp_util.py index ae75e6654..49c0e3ede 100644 --- a/python/vyos/accel_ppp_util.py +++ b/python/vyos/accel_ppp_util.py @@ -221,10 +221,12 @@ def verify_accel_ppp_ip_pool(vpn_config): for interface, interface_config in vpn_config['interface'].items(): if dict_search('client_subnet', interface_config): break + if dict_search('external_dhcp.dhcp_relay', interface_config): + break else: raise ConfigError( 'Local auth and noauth mode requires local client-ip-pool \ - or client-ipv6-pool or client-subnet to be configured!') + or client-ipv6-pool or client-subnet or dhcp-relay to be configured!') else: raise ConfigError( "Local auth mode requires local client-ip-pool \ -- cgit v1.2.3 From 5918dcce154a6a0e44a31b3ff5bf20b625250502 Mon Sep 17 00:00:00 2001 From: sarthurdev <965089+sarthurdev@users.noreply.github.com> Date: Wed, 21 May 2025 14:01:00 +0200 Subject: flowtable: T7350: Prevent interface deletion if referenced on flowtable --- python/vyos/configdict.py | 15 +++++++++++++++ smoketest/scripts/cli/test_firewall.py | 6 ++++++ src/conf_mode/interfaces_ethernet.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) (limited to 'python') diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index a34b0176a..040eb49ba 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -703,3 +703,18 @@ def get_accel_dict(config, base, chap_secrets, with_pki=False): dict['authentication']['radius']['server'][server]['acct_port'] = '0' return dict + +def get_flowtable_interfaces(config): + """ + Return all interfaces used in flowtables + """ + ft_base = ['firewall', 'flowtable'] + + if not config.exists(ft_base): + return [] + + ifaces = [] + for ft_name in config.list_nodes(ft_base): + ifaces += config.return_values(ft_base + [ft_name, 'interface']) + + return ifaces diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index 851a15f16..bbe4de9df 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -1113,6 +1113,12 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): self.verify_nftables_chain([['accept']], 'ip vyos_conntrack', 'FW_CONNTRACK') self.verify_nftables_chain([['accept']], 'ip6 vyos_conntrack', 'FW_CONNTRACK') + # Test interface deletion + self.cli_delete(['interfaces', 'ethernet', 'eth0', 'vif', '10']) + + with self.assertRaises(ConfigSessionError): + self.cli_commit() + def test_zone_flow_offload(self): self.cli_set(['firewall', 'flowtable', 'smoketest', 'interface', 'eth0']) self.cli_set(['firewall', 'flowtable', 'smoketest', 'offload', 'hardware']) diff --git a/src/conf_mode/interfaces_ethernet.py b/src/conf_mode/interfaces_ethernet.py index 41c89fdf8..13d3e82e9 100755 --- a/src/conf_mode/interfaces_ethernet.py +++ b/src/conf_mode/interfaces_ethernet.py @@ -22,6 +22,7 @@ from vyos.base import Warning from vyos.config import Config from vyos.configdict import get_interface_dict from vyos.configdict import is_node_changed +from vyos.configdict import get_flowtable_interfaces from vyos.configverify import verify_address from vyos.configverify import verify_dhcpv6 from vyos.configverify import verify_interface_exists @@ -168,6 +169,8 @@ def get_config(config=None): tmp = is_node_changed(conf, base + [ifname, 'evpn']) if tmp: ethernet.update({'frr_dict' : get_frrender_dict(conf)}) + ethernet['flowtable_interfaces'] = get_flowtable_interfaces(conf) + return ethernet def verify_speed_duplex(ethernet: dict, ethtool: Ethtool): @@ -269,7 +272,38 @@ def verify_allowedbond_changes(ethernet: dict): f' on interface "{ethernet["ifname"]}".' \ f' Interface is a bond member') +def verify_flowtable(ethernet: dict): + ifname = ethernet['ifname'] + + if 'deleted' in ethernet and ifname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{ifname}", still referenced on a flowtable') + + if 'vif_remove' in ethernet: + for vif in ethernet['vif_remove']: + vifname = f'{ifname}.{vif}' + + if vifname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifname}", still referenced on a flowtable') + + if 'vif_s_remove' in ethernet: + for vifs in ethernet['vif_s_remove']: + vifsname = f'{ifname}.{vifs}' + + if vifsname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifsname}", still referenced on a flowtable') + + if 'vif_s' in ethernet: + for vifs, vifs_conf in ethernet['vif_s'].items(): + if 'vif_c_delete' in vifs_conf: + for vifc in vifs_conf['vif_c_delete']: + vifcname = f'{ifname}.{vifs}.{vifc}' + + if vifcname in ethernet['flowtable_interfaces']: + raise ConfigError(f'Cannot delete interface "{vifcname}", still referenced on a flowtable') + def verify(ethernet): + verify_flowtable(ethernet) + if 'deleted' in ethernet: return None if 'is_bond_member' in ethernet: -- cgit v1.2.3 From 8de29b3c35e4dc81ff5e4f92e666d760d251f6f0 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 16 Apr 2025 11:16:36 -0500 Subject: T7363: add check for config mode that is independent from Cstore The environment variable _OFR_CONFIGURE is used by bash completion to setup the config mode environment. We check this setting to coordinate vyconf config mode and CLI config mode, independent of the legacy backend Cstore check. --- python/vyos/utils/session.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 python/vyos/utils/session.py (limited to 'python') diff --git a/python/vyos/utils/session.py b/python/vyos/utils/session.py new file mode 100644 index 000000000..28559dc59 --- /dev/null +++ b/python/vyos/utils/session.py @@ -0,0 +1,25 @@ +# Copyright 2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +# pylint: disable=import-outside-toplevel + + +def in_config_session(): + """Vyatta bash completion uses the following environment variable for + indication of the config mode environment, independent of legacy backend + initialization of Cstore""" + from os import environ + + return '_OFR_CONFIGURE' in environ -- cgit v1.2.3 From 0cf87061b4216af395651aa5b935a5c320737de3 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 16 Apr 2025 11:21:59 -0500 Subject: T7363: use legacy environment variable to indicate config mode In the absence of Cstore, the env var remains as the sole indication of config mode for the legacy CLI, and its emulation here. --- python/vyos/configsession.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index a3be29881..6490e6feb 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright (C) 2019-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; @@ -120,6 +120,10 @@ def inject_vyos_env(env): env['vyos_sbin_dir'] = '/usr/sbin' env['vyos_validators_dir'] = '/usr/libexec/vyos/validators' + # with the retirement of the Cstore backend, this will remain as the + # sole indication of legacy CLI config mode, as checked by VyconfSession + env['_OFR_CONFIGURE'] = 'ok' + # if running the vyos-configd daemon, inject the vyshim env var if is_systemd_service_running('vyos-configd.service'): env['vyshim'] = '/usr/sbin/vyshim' -- cgit v1.2.3 From 553450438afedd12b6c44fe21eb4372c62d1a5a2 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 16 Apr 2025 15:02:45 -0500 Subject: T7363: add pid aware initialization --- python/vyos/configsession.py | 35 +++++++++++++++++++---------------- python/vyos/vyconf_session.py | 32 +++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 23 deletions(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 6490e6feb..a070736cd 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -179,26 +179,29 @@ class ConfigSession(object): self._vyconf_session = None def __del__(self): - try: - output = ( - subprocess.check_output( - [CLI_SHELL_API, 'teardownSession'], env=self.__session_env + if self._vyconf_session is None: + try: + output = ( + subprocess.check_output( + [CLI_SHELL_API, 'teardownSession'], env=self.__session_env + ) + .decode() + .strip() ) - .decode() - .strip() - ) - if output: + if output: + print( + 'cli-shell-api teardownSession output for sesion {0}: {1}'.format( + self.__session_id, output + ), + file=sys.stderr, + ) + except Exception as e: print( - 'cli-shell-api teardownSession output for sesion {0}: {1}'.format( - self.__session_id, output - ), + 'Could not tear down session {0}: {1}'.format(self.__session_id, e), file=sys.stderr, ) - except Exception as e: - print( - 'Could not tear down session {0}: {1}'.format(self.__session_id, e), - file=sys.stderr, - ) + else: + self._vyconf_session.teardown() def __run_command(self, cmd_list): p = subprocess.Popen( diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index 506095625..cbf2fc873 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -15,6 +15,7 @@ # # +import os import tempfile import shutil from functools import wraps @@ -24,6 +25,7 @@ from vyos.proto import vyconf_client from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError from vyos.component_version import append_system_version +from vyos.utils.session import in_config_session def output(o): @@ -35,14 +37,35 @@ def output(o): class VyconfSession: - def __init__(self, token: str = None, on_error: Type[Exception] = None): + def __init__( + self, token: str = None, pid: int = None, on_error: Type[Exception] = None + ): + self.pid = os.getpid() if pid is None else pid if token is None: - out = vyconf_client.send_request('setup_session') + # CLI applications with arg pid=getppid() allow coordination + # with the ambient session; other uses (such as ConfigSession) + # may default to self pid + out = vyconf_client.send_request('session_of_pid', client_pid=self.pid) + if out.output is None: + out = vyconf_client.send_request('setup_session', client_pid=self.pid) self.__token = out.output else: + out = vyconf_client.send_request( + 'session_update_pid', token=token, client_pid=self.pid + ) + if out.status: + raise ValueError(f'No existing session for token: {token}') self.__token = token self.on_error = on_error + self.in_config_session = in_config_session() + + def __del__(self): + if not self.in_config_session: + self.teardown() + + def teardown(self): + vyconf_client.send_request('teardown', token=self.__token) @staticmethod def raise_exception(f): @@ -116,8 +139,3 @@ class VyconfSession: path = [] out = vyconf_client.send_request('show_config', token=self.__token, path=path) return output(out), out.status - - def __del__(self): - out = vyconf_client.send_request('teardown', token=self.__token) - if out.status: - print(f'Could not tear down session {self.__token}: {output(out)}') -- cgit v1.2.3 From 48f9544c72386bccb177ae213c13d4f68053bc06 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 2 May 2025 16:31:21 -0500 Subject: T7352: add util for enabling vyconf backend for smoketests --- python/vyos/configsession.py | 4 +- python/vyos/utils/backend.py | 88 +++++++++++++++++++++++++++++++++++++++ src/helpers/set_vyconf_backend.py | 39 +++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 python/vyos/utils/backend.py create mode 100755 src/helpers/set_vyconf_backend.py (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index a070736cd..8131e0ea3 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -22,9 +22,9 @@ from vyos.defaults import directories from vyos.utils.process import is_systemd_service_running from vyos.utils.dict import dict_to_paths from vyos.utils.boot import boot_configuration_complete +from vyos.utils.backend import vyconf_backend from vyos.vyconf_session import VyconfSession -vyconf_backend = False CLI_SHELL_API = '/bin/cli-shell-api' SET = '/opt/vyatta/sbin/my_set' @@ -173,7 +173,7 @@ class ConfigSession(object): self.__run_command([CLI_SHELL_API, 'setupSession']) - if vyconf_backend and boot_configuration_complete(): + if vyconf_backend() and boot_configuration_complete(): self._vyconf_session = VyconfSession(on_error=ConfigSessionError) else: self._vyconf_session = None diff --git a/python/vyos/utils/backend.py b/python/vyos/utils/backend.py new file mode 100644 index 000000000..400ea9b69 --- /dev/null +++ b/python/vyos/utils/backend.py @@ -0,0 +1,88 @@ +# Copyright 2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +# N.B. the following is a temporary addition for running smoketests under +# vyconf and is not to be called explicitly, at the risk of catastophe. + +# pylint: disable=wrong-import-position + +from pathlib import Path + +from vyos.utils.io import ask_yes_no +from vyos.utils.process import call + +VYCONF_SENTINEL = '/run/vyconf_backend' + +MSG_ENABLE_VYCONF = 'This will enable the vyconf backend for testing. Proceed?' +MSG_DISABLE_VYCONF = ( + 'This will restore the legacy backend; it requires a reboot. Proceed?' +) + +# read/set immutable file attribute without popen: +# https://www.geeklab.info/2021/04/chattr-and-lsattr-in-python/ +import fcntl # pylint: disable=C0411 # noqa: E402 +from array import array # pylint: disable=C0411 # noqa: E402 + +# FS constants - see /uapi/linux/fs.h in kernel source +# or +FS_IOC_GETFLAGS = 0x80086601 +FS_IOC_SETFLAGS = 0x40086602 +FS_IMMUTABLE_FL = 0x010 + + +def chattri(filename: str, value: bool): + with open(filename, 'r') as f: + arg = array('L', [0]) + fcntl.ioctl(f.fileno(), FS_IOC_GETFLAGS, arg, True) + if value: + arg[0] = arg[0] | FS_IMMUTABLE_FL + else: + arg[0] = arg[0] & ~FS_IMMUTABLE_FL + fcntl.ioctl(f.fileno(), FS_IOC_SETFLAGS, arg, True) + + +def lsattri(filename: str) -> bool: + with open(filename, 'r') as f: + arg = array('L', [0]) + fcntl.ioctl(f.fileno(), FS_IOC_GETFLAGS, arg, True) + return bool(arg[0] & FS_IMMUTABLE_FL) + + +# End: read/set immutable file attribute without popen + + +def vyconf_backend() -> bool: + return Path(VYCONF_SENTINEL).exists() and lsattri(VYCONF_SENTINEL) + + +def set_vyconf_backend(value: bool, no_prompt: bool = False): + vyconfd_service = 'vyconfd.service' + match value: + case True: + if vyconf_backend(): + return + if not no_prompt and not ask_yes_no(MSG_ENABLE_VYCONF): + return + Path(VYCONF_SENTINEL).touch() + chattri(VYCONF_SENTINEL, True) + call(f'systemctl restart {vyconfd_service}') + case False: + if not vyconf_backend(): + return + if not no_prompt and not ask_yes_no(MSG_DISABLE_VYCONF): + return + chattri(VYCONF_SENTINEL, False) + Path(VYCONF_SENTINEL).unlink() + call('/sbin/shutdown -r now') diff --git a/src/helpers/set_vyconf_backend.py b/src/helpers/set_vyconf_backend.py new file mode 100755 index 000000000..6747e51c3 --- /dev/null +++ b/src/helpers/set_vyconf_backend.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# + +# N.B. only for use within testing framework; explicit invocation will leave +# system in inconsistent state. + +from argparse import ArgumentParser + +from vyos.utils.backend import set_vyconf_backend + + +parser = ArgumentParser() +parser.add_argument('--disable', action='store_true', + help='enable/disable vyconf backend') +parser.add_argument('--no-prompt', action='store_true', + help='confirm without prompt') + +args = parser.parse_args() + +match args.disable: + case False: + set_vyconf_backend(True, no_prompt=args.no_prompt) + case True: + set_vyconf_backend(False, no_prompt=args.no_prompt) -- cgit v1.2.3 From bbd3871fbb4cb72771bfae3800d73176ec7b1c77 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 9 May 2025 11:18:57 -0500 Subject: T7363: distinguish config mode from op mode --- python/vyos/configsession.py | 5 +++ python/vyos/vyconf_session.py | 72 +++++++++++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 17 deletions(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 8131e0ea3..6b4d71e76 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -24,6 +24,7 @@ from vyos.utils.dict import dict_to_paths from vyos.utils.boot import boot_configuration_complete from vyos.utils.backend import vyconf_backend from vyos.vyconf_session import VyconfSession +from vyos.base import Warning as Warn CLI_SHELL_API = '/bin/cli-shell-api' @@ -201,6 +202,10 @@ class ConfigSession(object): file=sys.stderr, ) else: + if self._vyconf_session.session_changed(): + Warn('Exiting with uncommitted changes') + self._vyconf_session.discard() + self._vyconf_session.exit_config_mode() self._vyconf_session.teardown() def __run_command(self, cmd_list): diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index cbf2fc873..47d9e1445 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -28,12 +28,8 @@ from vyos.component_version import append_system_version from vyos.utils.session import in_config_session -def output(o): - out = '' - for res in (o.output, o.error, o.warning): - if res is not None: - out = out + res - return out +class VyconfSessionError(Exception): + pass class VyconfSession: @@ -57,8 +53,15 @@ class VyconfSession: raise ValueError(f'No existing session for token: {token}') self.__token = token - self.on_error = on_error self.in_config_session = in_config_session() + if self.in_config_session: + out = vyconf_client.send_request( + 'enter_configuration_mode', token=self.__token + ) + if out.status: + raise VyconfSessionError(self.output(out)) + + self.on_error = on_error def __del__(self): if not self.in_config_session: @@ -67,6 +70,32 @@ class VyconfSession: def teardown(self): vyconf_client.send_request('teardown', token=self.__token) + def exit_config_mode(self): + if self.session_changed(): + return 'Uncommited changes', Errnum.UNCOMMITED_CHANGES + out = vyconf_client.send_request('exit_configuration_mode', token=self.__token) + return self.output(out), out.status + + def in_session(self) -> bool: + return self.in_config_session + + def session_changed(self) -> bool: + out = vyconf_client.send_request('session_changed', token=self.__token) + return not bool(out.status) + + @staticmethod + def config_mode(f): + @wraps(f) + def wrapped(self, *args, **kwargs): + msg = 'operation not available outside of config mode' + if not self.in_config_session: + if self.on_error is None: + raise VyconfSessionError(msg) + raise self.on_error(msg) + return f(self, *args, **kwargs) + + return wrapped + @staticmethod def raise_exception(f): @wraps(f) @@ -80,31 +109,40 @@ class VyconfSession: return wrapped + @staticmethod + def output(o): + out = '' + for res in (o.output, o.error, o.warning): + if res is not None: + out = out + res + return out + @raise_exception + @config_mode def set(self, path: list[str]) -> tuple[str, int]: out = vyconf_client.send_request('set', token=self.__token, path=path) - return output(out), out.status + return self.output(out), out.status @raise_exception + @config_mode def delete(self, path: list[str]) -> tuple[str, int]: out = vyconf_client.send_request('delete', token=self.__token, path=path) - return output(out), out.status + return self.output(out), out.status @raise_exception + @config_mode def commit(self) -> tuple[str, int]: out = vyconf_client.send_request('commit', token=self.__token) return output(out), out.status @raise_exception + @config_mode def discard(self) -> tuple[str, int]: out = vyconf_client.send_request('discard', token=self.__token) - return output(out), out.status - - def session_changed(self) -> bool: - out = vyconf_client.send_request('session_changed', token=self.__token) - return not bool(out.status) + return self.output(out), out.status @raise_exception + @config_mode def load_config(self, file: str, migrate: bool = False) -> tuple[str, int]: # pylint: disable=consider-using-with if migrate: @@ -124,18 +162,18 @@ class VyconfSession: if tmp: tmp.close() - return output(out), out.status + return self.output(out), out.status @raise_exception def save_config(self, file: str, append_version: bool = False) -> tuple[str, int]: out = vyconf_client.send_request('save', token=self.__token, location=file) if append_version: append_system_version(file) - return output(out), out.status + return self.output(out), out.status @raise_exception def show_config(self, path: list[str] = None) -> tuple[str, int]: if path is None: path = [] out = vyconf_client.send_request('show_config', token=self.__token, path=path) - return output(out), out.status + return self.output(out), out.status -- cgit v1.2.3 From d6e286a7567e61f235e2170e3da2dc55fe8c928d Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 11 May 2025 19:12:48 -0500 Subject: T7121: add missing default version string on init from internal cache --- python/vyos/configtree.py | 1 + 1 file changed, 1 insertion(+) (limited to 'python') diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index ff40fbad0..faf124480 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -194,6 +194,7 @@ class ConfigTree(object): raise ValueError('Failed to read internal rep: {0}'.format(msg)) else: self.__config = config + self.__version = '' elif config_string is not None: config_section, version_section = extract_version(config_string) config_section = escape_backslash(config_section) -- cgit v1.2.3 From 5071d8cb0e66dc2663beadde24ed7f56cee8d147 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 4 May 2025 17:33:50 -0500 Subject: T7363: add initialization of Config from VyconfSession --- python/vyos/config.py | 11 +++++++++-- python/vyos/configsession.py | 5 ++++- python/vyos/configsource.py | 33 ++++++++++++++++++++++++++++++++- python/vyos/defaults.py | 3 ++- python/vyos/vyconf_session.py | 6 ++++++ 5 files changed, 53 insertions(+), 5 deletions(-) (limited to 'python') diff --git a/python/vyos/config.py b/python/vyos/config.py index 546eeceab..9ae0467d4 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -1,4 +1,4 @@ -# Copyright 2017-2024 VyOS maintainers and contributors +# Copyright 2017-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -62,6 +62,7 @@ while functions prefixed "effective" return values from the running config. In operational mode, all functions return values from the running config. """ +import os import re import json from typing import Union @@ -73,8 +74,11 @@ from vyos.xml_ref import ext_dict_merge from vyos.xml_ref import relative_defaults from vyos.utils.dict import get_sub_dict from vyos.utils.dict import mangle_dict_keys +from vyos.utils.boot import boot_configuration_complete +from vyos.utils.backend import vyconf_backend from vyos.configsource import ConfigSource from vyos.configsource import ConfigSourceSession +from vyos.configsource import ConfigSourceVyconfSession class ConfigDict(dict): _from_defaults = {} @@ -132,7 +136,10 @@ class Config(object): """ def __init__(self, session_env=None, config_source=None): if config_source is None: - self._config_source = ConfigSourceSession(session_env) + if vyconf_backend() and boot_configuration_complete(): + self._config_source = ConfigSourceVyconfSession(session_env) + else: + self._config_source = ConfigSourceSession(session_env) else: if not isinstance(config_source, ConfigSource): raise TypeError("config_source not of type ConfigSource") diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 6b4d71e76..1b19c68b4 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -169,13 +169,16 @@ class ConfigSession(object): for k, v in env_list: session_env[k] = v + session_env['CONFIGSESSION_PID'] = str(session_id) + self.__session_env = session_env self.__session_env['COMMIT_VIA'] = app self.__run_command([CLI_SHELL_API, 'setupSession']) if vyconf_backend() and boot_configuration_complete(): - self._vyconf_session = VyconfSession(on_error=ConfigSessionError) + self._vyconf_session = VyconfSession(pid=session_id, + on_error=ConfigSessionError) else: self._vyconf_session = None diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py index 65cef5333..6c121a202 100644 --- a/python/vyos/configsource.py +++ b/python/vyos/configsource.py @@ -1,5 +1,5 @@ -# Copyright 2020-2023 VyOS maintainers and contributors +# Copyright 2020-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -20,6 +20,9 @@ import subprocess from vyos.configtree import ConfigTree from vyos.utils.boot import boot_configuration_complete +from vyos.vyconf_session import VyconfSession +from vyos.vyconf_session import VyconfSessionError +from vyos.defaults import directories class VyOSError(Exception): """ @@ -310,6 +313,34 @@ class ConfigSourceSession(ConfigSource): except VyOSError: return False +class ConfigSourceVyconfSession(ConfigSource): + def __init__(self, session_env=None): + super().__init__() + + if session_env: + self.__session_env = session_env + else: + self.__session_env = None + + if session_env and 'CONFIGSESSION_PID' in session_env: + self.pid = int(session_env['CONFIGSESSION_PID']) + else: + self.pid = os.getppid() + + self._vyconf_session = VyconfSession(pid=self.pid) + try: + out = self._vyconf_session.get_config() + except VyconfSessionError as e: + raise ConfigSourceError(f'Init error in {type(self)}: {e}') + + session_dir = directories['vyconf_session_dir'] + + self.running_cache_path = os.path.join(session_dir, f'running_cache_{out}') + self.session_cache_path = os.path.join(session_dir, f'session_cache_{out}') + + self._running_config = ConfigTree(internal=self.running_cache_path) + self._session_config = ConfigTree(internal=self.session_cache_path) + class ConfigSourceString(ConfigSource): def __init__(self, running_config_text=None, session_config_text=None): super().__init__() diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index c1e5ddc04..fbde0298b 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -39,7 +39,8 @@ directories = { 'completion_dir' : f'{base_dir}/completion', 'ca_certificates' : '/usr/local/share/ca-certificates/vyos', 'ppp_nexthop_dir' : '/run/ppp_nexthop', - 'proto_path' : '/usr/share/vyos/vyconf' + 'proto_path' : '/usr/share/vyos/vyconf', + 'vyconf_session_dir' : f'{base_dir}/vyconf/session' } systemd_services = { diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index 47d9e1445..077952a9f 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -83,6 +83,12 @@ class VyconfSession: out = vyconf_client.send_request('session_changed', token=self.__token) return not bool(out.status) + def get_config(self): + out = vyconf_client.send_request('get_config', token=self.__token) + if out.status: + raise VyconfSessionError(self.output(out)) + return out.output + @staticmethod def config_mode(f): @wraps(f) -- cgit v1.2.3 From eefae2ae49a5f7719542afcfceb07ff15a2fc0fd Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 11 May 2025 15:53:23 -0500 Subject: T7363: populate ConfigSourceVyconfSession methods --- python/vyos/configsource.py | 74 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) (limited to 'python') diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py index 6c121a202..e4ced6305 100644 --- a/python/vyos/configsource.py +++ b/python/vyos/configsource.py @@ -17,12 +17,16 @@ import os import re import subprocess +from typing import Union from vyos.configtree import ConfigTree from vyos.utils.boot import boot_configuration_complete from vyos.vyconf_session import VyconfSession from vyos.vyconf_session import VyconfSessionError from vyos.defaults import directories +from vyos.xml_ref import is_tag +from vyos.xml_ref import is_leaf +from vyos.xml_ref import is_multi class VyOSError(Exception): """ @@ -341,6 +345,76 @@ class ConfigSourceVyconfSession(ConfigSource): self._running_config = ConfigTree(internal=self.running_cache_path) self._session_config = ConfigTree(internal=self.session_cache_path) + # N.B. level not yet implemented pending integration with legacy CLI + # cf. T7374 + self._level = [] + + def get_level(self): + return self._level + + def set_level(self): + pass + + def session_changed(self): + """ + Returns: + True if the config session has uncommited changes, False otherwise. + """ + try: + return self._vyconf_session.session_changed() + except VyconfSessionError: + # no actionable session info on error + return False + + def in_session(self): + """ + Returns: + True if called from a configuration session, False otherwise. + """ + return self._vyconf_session.in_session() + + def show_config(self, path: Union[str,list] = None, default: str = None, + effective: bool = False): + """ + Args: + path (str|list): Configuration tree path, or empty + default (str): Default value to return + + Returns: + str: working configuration + """ + + if path is None: + path = [] + if isinstance(path, str): + path = path.split() + + ct = self._running_config if effective else self._session_config + with_node = True if self.is_tag(path) else False + ct_at_path = ct.get_subtree(path, with_node=with_node) if path else ct + + res = ct_at_path.to_string().strip() + + return res if res else default + + def is_tag(self, path): + try: + return is_tag(path) + except ValueError: + return False + + def is_leaf(self, path): + try: + return is_leaf(path) + except ValueError: + return False + + def is_multi(self, path): + try: + return is_multi(path) + except ValueError: + return False + class ConfigSourceString(ConfigSource): def __init__(self, running_config_text=None, session_config_text=None): super().__init__() -- cgit v1.2.3 From 5328e901c1384a9ee0ea95f4d7bcb048bfa94316 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 15 Apr 2025 12:44:28 -0500 Subject: T7365: normalize formatting --- python/vyos/utils/commit.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/commit.py b/python/vyos/utils/commit.py index 105aed8c2..2b014c939 100644 --- a/python/vyos/utils/commit.py +++ b/python/vyos/utils/commit.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright 2023-2025 VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -13,8 +13,11 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. If not, see . +# pylint: disable=import-outside-toplevel + + def commit_in_progress(): - """ Not to be used in normal op mode scripts! """ + """Not to be used in normal op mode scripts!""" # The CStore backend locks the config by opening a file # The file is not removed after commit, so just checking @@ -36,7 +39,9 @@ def commit_in_progress(): from vyos.defaults import commit_lock if getuser() != 'root': - raise OSError('This functions needs to be run as root to return correct results!') + raise OSError( + 'This functions needs to be run as root to return correct results!' + ) for proc in process_iter(): try: @@ -45,7 +50,7 @@ def commit_in_progress(): for f in files: if f.path == commit_lock: return True - except NoSuchProcess as err: + except NoSuchProcess: # Process died before we could examine it pass # Default case @@ -53,8 +58,9 @@ def commit_in_progress(): def wait_for_commit_lock(): - """ Not to be used in normal op mode scripts! """ + """Not to be used in normal op mode scripts!""" from time import sleep + # Very synchronous approach to multiprocessing while commit_in_progress(): sleep(1) -- cgit v1.2.3 From 23b67254ed4a3a7d382a95f409338db431cc3556 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 14 Apr 2025 17:58:49 -0500 Subject: T7365: add POSIX-type lock to vyconf_session.commit for compatibility We maintain compatibility with the legacy commit lock file until all other references are resolved; this requires a POSIX-type lock instead of the BSD-type lock of vyos.utils.locking. --- python/vyos/utils/commit.py | 37 +++++++++++++++++++++++++++++++++++++ python/vyos/vyconf_session.py | 11 ++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/utils/commit.py b/python/vyos/utils/commit.py index 2b014c939..9167c78d2 100644 --- a/python/vyos/utils/commit.py +++ b/python/vyos/utils/commit.py @@ -15,6 +15,8 @@ # pylint: disable=import-outside-toplevel +from typing import IO + def commit_in_progress(): """Not to be used in normal op mode scripts!""" @@ -64,3 +66,38 @@ def wait_for_commit_lock(): # Very synchronous approach to multiprocessing while commit_in_progress(): sleep(1) + + +# For transitional compatibility with the legacy commit locking mechanism, +# we require a lockf/fcntl (POSIX-type) lock, hence the following in place +# of vyos.utils.locking + + +def acquire_commit_lock_file() -> tuple[IO, str]: + import fcntl + from pathlib import Path + from vyos.defaults import commit_lock + + try: + # pylint: disable=consider-using-with + lock_fd = Path(commit_lock).open('w') + except IOError as e: + out = f'Critical error opening commit lock file {e}' + return None, out + + try: + fcntl.lockf(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return lock_fd, '' + except IOError: + out = 'Configuration system locked by another commit in progress' + lock_fd.close() + return None, out + + +def release_commit_lock_file(file_descr): + import fcntl + + if file_descr is None: + return + fcntl.lockf(file_descr, fcntl.LOCK_UN) + file_descr.close() diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index 077952a9f..4250f0cfb 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -26,6 +26,9 @@ from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError from vyos.component_version import append_system_version from vyos.utils.session import in_config_session +from vyos.proto.vyconf_proto import Errnum +from vyos.utils.commit import acquire_commit_lock_file +from vyos.utils.commit import release_commit_lock_file class VyconfSessionError(Exception): @@ -138,8 +141,14 @@ class VyconfSession: @raise_exception @config_mode def commit(self) -> tuple[str, int]: + lock_fd, out = acquire_commit_lock_file() + if lock_fd is None: + return out, Errnum.COMMIT_IN_PROGRESS + out = vyconf_client.send_request('commit', token=self.__token) - return output(out), out.status + release_commit_lock_file(lock_fd) + + return self.output(out), out.status @raise_exception @config_mode -- cgit v1.2.3 From c6c3fff4f59b99a3bedeb382546aac4ebc2718ca Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Thu, 22 May 2025 20:02:18 -0500 Subject: T7363: retain generated files as imports for nosetests --- .gitignore | 2 - python/vyos/proto/vycall_pb2.py | 29 +++ python/vyos/proto/vyconf_pb2.py | 93 ++++++++++ python/vyos/proto/vyconf_proto.py | 377 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 python/vyos/proto/vycall_pb2.py create mode 100644 python/vyos/proto/vyconf_pb2.py create mode 100644 python/vyos/proto/vyconf_proto.py (limited to 'python') diff --git a/.gitignore b/.gitignore index 839d2afff..7084332a8 100644 --- a/.gitignore +++ b/.gitignore @@ -152,9 +152,7 @@ data/reftree.cache data/configd-include.json # autogenerated vyos-commitd protobuf files -python/vyos/proto/*pb2.py python/vyos/proto/*.desc -python/vyos/proto/vyconf_proto.py # We do not use pip Pipfile diff --git a/python/vyos/proto/vycall_pb2.py b/python/vyos/proto/vycall_pb2.py new file mode 100644 index 000000000..95214d2a6 --- /dev/null +++ b/python/vyos/proto/vycall_pb2.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: vycall.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cvycall.proto\"&\n\x06Status\x12\x0f\n\x07success\x18\x01 \x02(\x08\x12\x0b\n\x03out\x18\x02 \x02(\t\"Y\n\x04\x43\x61ll\x12\x13\n\x0bscript_name\x18\x01 \x02(\t\x12\x11\n\ttag_value\x18\x02 \x01(\t\x12\x11\n\targ_value\x18\x03 \x01(\t\x12\x16\n\x05reply\x18\x04 \x01(\x0b\x32\x07.Status\"~\n\x06\x43ommit\x12\x12\n\nsession_id\x18\x01 \x02(\t\x12\x0f\n\x07\x64ry_run\x18\x04 \x02(\x08\x12\x0e\n\x06\x61tomic\x18\x05 \x02(\x08\x12\x12\n\nbackground\x18\x06 \x02(\x08\x12\x15\n\x04init\x18\x07 \x01(\x0b\x32\x07.Status\x12\x14\n\x05\x63\x61lls\x18\x08 \x03(\x0b\x32\x05.Call') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'vycall_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _STATUS._serialized_start=16 + _STATUS._serialized_end=54 + _CALL._serialized_start=56 + _CALL._serialized_end=145 + _COMMIT._serialized_start=147 + _COMMIT._serialized_end=273 +# @@protoc_insertion_point(module_scope) diff --git a/python/vyos/proto/vyconf_pb2.py b/python/vyos/proto/vyconf_pb2.py new file mode 100644 index 000000000..3d5042888 --- /dev/null +++ b/python/vyos/proto/vyconf_pb2.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: vyconf.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cvyconf.proto\"\x89\x15\n\x07Request\x12!\n\x06prompt\x18\x01 \x01(\x0b\x32\x0f.Request.PromptH\x00\x12.\n\rsetup_session\x18\x02 \x01(\x0b\x32\x15.Request.SetupSessionH\x00\x12\x1b\n\x03set\x18\x03 \x01(\x0b\x32\x0c.Request.SetH\x00\x12!\n\x06\x64\x65lete\x18\x04 \x01(\x0b\x32\x0f.Request.DeleteH\x00\x12!\n\x06rename\x18\x05 \x01(\x0b\x32\x0f.Request.RenameH\x00\x12\x1d\n\x04\x63opy\x18\x06 \x01(\x0b\x32\r.Request.CopyH\x00\x12#\n\x07\x63omment\x18\x07 \x01(\x0b\x32\x10.Request.CommentH\x00\x12!\n\x06\x63ommit\x18\x08 \x01(\x0b\x32\x0f.Request.CommitH\x00\x12%\n\x08rollback\x18\t \x01(\x0b\x32\x11.Request.RollbackH\x00\x12\x1f\n\x05merge\x18\n \x01(\x0b\x32\x0e.Request.MergeH\x00\x12\x1d\n\x04save\x18\x0b \x01(\x0b\x32\r.Request.SaveH\x00\x12*\n\x0bshow_config\x18\x0c \x01(\x0b\x32\x13.Request.ShowConfigH\x00\x12!\n\x06\x65xists\x18\r \x01(\x0b\x32\x0f.Request.ExistsH\x00\x12&\n\tget_value\x18\x0e \x01(\x0b\x32\x11.Request.GetValueH\x00\x12(\n\nget_values\x18\x0f \x01(\x0b\x32\x12.Request.GetValuesH\x00\x12.\n\rlist_children\x18\x10 \x01(\x0b\x32\x15.Request.ListChildrenH\x00\x12)\n\x0brun_op_mode\x18\x11 \x01(\x0b\x32\x12.Request.RunOpModeH\x00\x12#\n\x07\x63onfirm\x18\x12 \x01(\x0b\x32\x10.Request.ConfirmH\x00\x12\x43\n\x18\x65nter_configuration_mode\x18\x13 \x01(\x0b\x32\x1f.Request.EnterConfigurationModeH\x00\x12\x41\n\x17\x65xit_configuration_mode\x18\x14 \x01(\x0b\x32\x1e.Request.ExitConfigurationModeH\x00\x12%\n\x08validate\x18\x15 \x01(\x0b\x32\x11.Request.ValidateH\x00\x12%\n\x08teardown\x18\x16 \x01(\x0b\x32\x11.Request.TeardownH\x00\x12\x30\n\x0ereload_reftree\x18\x17 \x01(\x0b\x32\x16.Request.ReloadReftreeH\x00\x12\x1d\n\x04load\x18\x18 \x01(\x0b\x32\r.Request.LoadH\x00\x12#\n\x07\x64iscard\x18\x19 \x01(\x0b\x32\x10.Request.DiscardH\x00\x12\x32\n\x0fsession_changed\x18\x1a \x01(\x0b\x32\x17.Request.SessionChangedH\x00\x12/\n\x0esession_of_pid\x18\x1b \x01(\x0b\x32\x15.Request.SessionOfPidH\x00\x12\x37\n\x12session_update_pid\x18\x1c \x01(\x0b\x32\x19.Request.SessionUpdatePidH\x00\x12(\n\nget_config\x18\x1d \x01(\x0b\x32\x12.Request.GetConfigH\x00\x1a\x08\n\x06Prompt\x1aP\n\x0cSetupSession\x12\x11\n\tClientPid\x18\x01 \x02(\x05\x12\x19\n\x11\x43lientApplication\x18\x02 \x01(\t\x12\x12\n\nOnBehalfOf\x18\x03 \x01(\x05\x1a!\n\x0cSessionOfPid\x12\x11\n\tClientPid\x18\x01 \x02(\x05\x1a%\n\x10SessionUpdatePid\x12\x11\n\tClientPid\x18\x01 \x02(\x05\x1a\x1a\n\tGetConfig\x12\r\n\x05\x64ummy\x18\x01 \x01(\x05\x1a\x1e\n\x08Teardown\x12\x12\n\nOnBehalfOf\x18\x01 \x01(\x05\x1a\x46\n\x08Validate\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1a\x13\n\x03Set\x12\x0c\n\x04Path\x18\x01 \x03(\t\x1a\x16\n\x06\x44\x65lete\x12\x0c\n\x04Path\x18\x01 \x03(\t\x1a\x18\n\x07\x44iscard\x12\r\n\x05\x64ummy\x18\x01 \x01(\x05\x1a\x1f\n\x0eSessionChanged\x12\r\n\x05\x64ummy\x18\x01 \x01(\x05\x1a\x35\n\x06Rename\x12\x11\n\tEditLevel\x18\x01 \x03(\t\x12\x0c\n\x04\x46rom\x18\x02 \x02(\t\x12\n\n\x02To\x18\x03 \x02(\t\x1a\x33\n\x04\x43opy\x12\x11\n\tEditLevel\x18\x01 \x03(\t\x12\x0c\n\x04\x46rom\x18\x02 \x02(\t\x12\n\n\x02To\x18\x03 \x02(\t\x1a(\n\x07\x43omment\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12\x0f\n\x07\x43omment\x18\x02 \x02(\t\x1aR\n\x06\x43ommit\x12\x0f\n\x07\x43onfirm\x18\x01 \x01(\x08\x12\x16\n\x0e\x43onfirmTimeout\x18\x02 \x01(\x05\x12\x0f\n\x07\x43omment\x18\x03 \x01(\t\x12\x0e\n\x06\x44ryRun\x18\x04 \x01(\x08\x1a\x1c\n\x08Rollback\x12\x10\n\x08Revision\x18\x01 \x02(\x05\x1a?\n\x04Load\x12\x10\n\x08Location\x18\x01 \x02(\t\x12%\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x15.Request.ConfigFormat\x1a@\n\x05Merge\x12\x10\n\x08Location\x18\x01 \x02(\t\x12%\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x15.Request.ConfigFormat\x1a?\n\x04Save\x12\x10\n\x08Location\x18\x01 \x02(\t\x12%\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x15.Request.ConfigFormat\x1a\x41\n\nShowConfig\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12%\n\x06\x66ormat\x18\x02 \x01(\x0e\x32\x15.Request.ConfigFormat\x1a\x16\n\x06\x45xists\x12\x0c\n\x04Path\x18\x01 \x03(\t\x1a\x46\n\x08GetValue\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1aG\n\tGetValues\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1aJ\n\x0cListChildren\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1aG\n\tRunOpMode\x12\x0c\n\x04Path\x18\x01 \x03(\t\x12,\n\routput_format\x18\x02 \x01(\x0e\x32\x15.Request.OutputFormat\x1a\t\n\x07\x43onfirm\x1a\x46\n\x16\x45nterConfigurationMode\x12\x11\n\tExclusive\x18\x01 \x02(\x08\x12\x19\n\x11OverrideExclusive\x18\x02 \x02(\x08\x1a\x17\n\x15\x45xitConfigurationMode\x1a#\n\rReloadReftree\x12\x12\n\nOnBehalfOf\x18\x01 \x01(\x05\"#\n\x0c\x43onfigFormat\x12\t\n\x05\x43URLY\x10\x00\x12\x08\n\x04JSON\x10\x01\")\n\x0cOutputFormat\x12\x0c\n\x08OutPlain\x10\x00\x12\x0b\n\x07OutJSON\x10\x01\x42\x05\n\x03msg\";\n\x0fRequestEnvelope\x12\r\n\x05token\x18\x01 \x01(\t\x12\x19\n\x07request\x18\x02 \x02(\x0b\x32\x08.Request\"S\n\x08Response\x12\x17\n\x06status\x18\x01 \x02(\x0e\x32\x07.Errnum\x12\x0e\n\x06output\x18\x02 \x01(\t\x12\r\n\x05\x65rror\x18\x03 \x01(\t\x12\x0f\n\x07warning\x18\x04 \x01(\t*\xd2\x01\n\x06\x45rrnum\x12\x0b\n\x07SUCCESS\x10\x00\x12\x08\n\x04\x46\x41IL\x10\x01\x12\x10\n\x0cINVALID_PATH\x10\x02\x12\x11\n\rINVALID_VALUE\x10\x03\x12\x16\n\x12\x43OMMIT_IN_PROGRESS\x10\x04\x12\x18\n\x14\x43ONFIGURATION_LOCKED\x10\x05\x12\x12\n\x0eINTERNAL_ERROR\x10\x06\x12\x15\n\x11PERMISSION_DENIED\x10\x07\x12\x17\n\x13PATH_ALREADY_EXISTS\x10\x08\x12\x16\n\x12UNCOMMITED_CHANGES\x10\t') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'vyconf_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _ERRNUM._serialized_start=2863 + _ERRNUM._serialized_end=3073 + _REQUEST._serialized_start=17 + _REQUEST._serialized_end=2714 + _REQUEST_PROMPT._serialized_start=1237 + _REQUEST_PROMPT._serialized_end=1245 + _REQUEST_SETUPSESSION._serialized_start=1247 + _REQUEST_SETUPSESSION._serialized_end=1327 + _REQUEST_SESSIONOFPID._serialized_start=1329 + _REQUEST_SESSIONOFPID._serialized_end=1362 + _REQUEST_SESSIONUPDATEPID._serialized_start=1364 + _REQUEST_SESSIONUPDATEPID._serialized_end=1401 + _REQUEST_GETCONFIG._serialized_start=1403 + _REQUEST_GETCONFIG._serialized_end=1429 + _REQUEST_TEARDOWN._serialized_start=1431 + _REQUEST_TEARDOWN._serialized_end=1461 + _REQUEST_VALIDATE._serialized_start=1463 + _REQUEST_VALIDATE._serialized_end=1533 + _REQUEST_SET._serialized_start=1535 + _REQUEST_SET._serialized_end=1554 + _REQUEST_DELETE._serialized_start=1556 + _REQUEST_DELETE._serialized_end=1578 + _REQUEST_DISCARD._serialized_start=1580 + _REQUEST_DISCARD._serialized_end=1604 + _REQUEST_SESSIONCHANGED._serialized_start=1606 + _REQUEST_SESSIONCHANGED._serialized_end=1637 + _REQUEST_RENAME._serialized_start=1639 + _REQUEST_RENAME._serialized_end=1692 + _REQUEST_COPY._serialized_start=1694 + _REQUEST_COPY._serialized_end=1745 + _REQUEST_COMMENT._serialized_start=1747 + _REQUEST_COMMENT._serialized_end=1787 + _REQUEST_COMMIT._serialized_start=1789 + _REQUEST_COMMIT._serialized_end=1871 + _REQUEST_ROLLBACK._serialized_start=1873 + _REQUEST_ROLLBACK._serialized_end=1901 + _REQUEST_LOAD._serialized_start=1903 + _REQUEST_LOAD._serialized_end=1966 + _REQUEST_MERGE._serialized_start=1968 + _REQUEST_MERGE._serialized_end=2032 + _REQUEST_SAVE._serialized_start=2034 + _REQUEST_SAVE._serialized_end=2097 + _REQUEST_SHOWCONFIG._serialized_start=2099 + _REQUEST_SHOWCONFIG._serialized_end=2164 + _REQUEST_EXISTS._serialized_start=2166 + _REQUEST_EXISTS._serialized_end=2188 + _REQUEST_GETVALUE._serialized_start=2190 + _REQUEST_GETVALUE._serialized_end=2260 + _REQUEST_GETVALUES._serialized_start=2262 + _REQUEST_GETVALUES._serialized_end=2333 + _REQUEST_LISTCHILDREN._serialized_start=2335 + _REQUEST_LISTCHILDREN._serialized_end=2409 + _REQUEST_RUNOPMODE._serialized_start=2411 + _REQUEST_RUNOPMODE._serialized_end=2482 + _REQUEST_CONFIRM._serialized_start=1799 + _REQUEST_CONFIRM._serialized_end=1808 + _REQUEST_ENTERCONFIGURATIONMODE._serialized_start=2495 + _REQUEST_ENTERCONFIGURATIONMODE._serialized_end=2565 + _REQUEST_EXITCONFIGURATIONMODE._serialized_start=2567 + _REQUEST_EXITCONFIGURATIONMODE._serialized_end=2590 + _REQUEST_RELOADREFTREE._serialized_start=2592 + _REQUEST_RELOADREFTREE._serialized_end=2627 + _REQUEST_CONFIGFORMAT._serialized_start=2629 + _REQUEST_CONFIGFORMAT._serialized_end=2664 + _REQUEST_OUTPUTFORMAT._serialized_start=2666 + _REQUEST_OUTPUTFORMAT._serialized_end=2707 + _REQUESTENVELOPE._serialized_start=2716 + _REQUESTENVELOPE._serialized_end=2775 + _RESPONSE._serialized_start=2777 + _RESPONSE._serialized_end=2860 +# @@protoc_insertion_point(module_scope) diff --git a/python/vyos/proto/vyconf_proto.py b/python/vyos/proto/vyconf_proto.py new file mode 100644 index 000000000..404ef2f27 --- /dev/null +++ b/python/vyos/proto/vyconf_proto.py @@ -0,0 +1,377 @@ +from enum import IntEnum +from dataclasses import dataclass +from dataclasses import field + +class Errnum(IntEnum): + SUCCESS = 0 + FAIL = 1 + INVALID_PATH = 2 + INVALID_VALUE = 3 + COMMIT_IN_PROGRESS = 4 + CONFIGURATION_LOCKED = 5 + INTERNAL_ERROR = 6 + PERMISSION_DENIED = 7 + PATH_ALREADY_EXISTS = 8 + UNCOMMITED_CHANGES = 9 + +class ConfigFormat(IntEnum): + CURLY = 0 + JSON = 1 + +class OutputFormat(IntEnum): + OutPlain = 0 + OutJSON = 1 + +@dataclass +class Prompt: + pass + +@dataclass +class SetupSession: + ClientPid: int = 0 + ClientApplication: str = None + OnBehalfOf: int = None + +@dataclass +class SessionOfPid: + ClientPid: int = 0 + +@dataclass +class SessionUpdatePid: + ClientPid: int = 0 + +@dataclass +class GetConfig: + dummy: int = None + +@dataclass +class Teardown: + OnBehalfOf: int = None + +@dataclass +class Validate: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class Set: + Path: list[str] = field(default_factory=list) + +@dataclass +class Delete: + Path: list[str] = field(default_factory=list) + +@dataclass +class Discard: + dummy: int = None + +@dataclass +class SessionChanged: + dummy: int = None + +@dataclass +class Rename: + EditLevel: list[str] = field(default_factory=list) + From: str = "" + To: str = "" + +@dataclass +class Copy: + EditLevel: list[str] = field(default_factory=list) + From: str = "" + To: str = "" + +@dataclass +class Comment: + Path: list[str] = field(default_factory=list) + Comment: str = "" + +@dataclass +class Commit: + Confirm: bool = None + ConfirmTimeout: int = None + Comment: str = None + DryRun: bool = None + +@dataclass +class Rollback: + Revision: int = 0 + +@dataclass +class Load: + Location: str = "" + format: ConfigFormat = None + +@dataclass +class Merge: + Location: str = "" + format: ConfigFormat = None + +@dataclass +class Save: + Location: str = "" + format: ConfigFormat = None + +@dataclass +class ShowConfig: + Path: list[str] = field(default_factory=list) + format: ConfigFormat = None + +@dataclass +class Exists: + Path: list[str] = field(default_factory=list) + +@dataclass +class GetValue: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class GetValues: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class ListChildren: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class RunOpMode: + Path: list[str] = field(default_factory=list) + output_format: OutputFormat = None + +@dataclass +class Confirm: + pass + +@dataclass +class EnterConfigurationMode: + Exclusive: bool = False + OverrideExclusive: bool = False + +@dataclass +class ExitConfigurationMode: + pass + +@dataclass +class ReloadReftree: + OnBehalfOf: int = None + +@dataclass +class Request: + prompt: Prompt = None + setup_session: SetupSession = None + set: Set = None + delete: Delete = None + rename: Rename = None + copy: Copy = None + comment: Comment = None + commit: Commit = None + rollback: Rollback = None + merge: Merge = None + save: Save = None + show_config: ShowConfig = None + exists: Exists = None + get_value: GetValue = None + get_values: GetValues = None + list_children: ListChildren = None + run_op_mode: RunOpMode = None + confirm: Confirm = None + enter_configuration_mode: EnterConfigurationMode = None + exit_configuration_mode: ExitConfigurationMode = None + validate: Validate = None + teardown: Teardown = None + reload_reftree: ReloadReftree = None + load: Load = None + discard: Discard = None + session_changed: SessionChanged = None + session_of_pid: SessionOfPid = None + session_update_pid: SessionUpdatePid = None + get_config: GetConfig = None + +@dataclass +class RequestEnvelope: + token: str = None + request: Request = None + +@dataclass +class Response: + status: Errnum = None + output: str = None + error: str = None + warning: str = None + +def set_request_prompt(token: str = None): + reqi = Prompt () + req = Request(prompt=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_setup_session(token: str = None, client_pid: int = 0, client_application: str = None, on_behalf_of: int = None): + reqi = SetupSession (client_pid, client_application, on_behalf_of) + req = Request(setup_session=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_session_of_pid(token: str = None, client_pid: int = 0): + reqi = SessionOfPid (client_pid) + req = Request(session_of_pid=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_session_update_pid(token: str = None, client_pid: int = 0): + reqi = SessionUpdatePid (client_pid) + req = Request(session_update_pid=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_get_config(token: str = None, dummy: int = None): + reqi = GetConfig (dummy) + req = Request(get_config=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_teardown(token: str = None, on_behalf_of: int = None): + reqi = Teardown (on_behalf_of) + req = Request(teardown=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_validate(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = Validate (path, output_format) + req = Request(validate=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_set(token: str = None, path: list[str] = []): + reqi = Set (path) + req = Request(set=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_delete(token: str = None, path: list[str] = []): + reqi = Delete (path) + req = Request(delete=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_discard(token: str = None, dummy: int = None): + reqi = Discard (dummy) + req = Request(discard=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_session_changed(token: str = None, dummy: int = None): + reqi = SessionChanged (dummy) + req = Request(session_changed=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_rename(token: str = None, edit_level: list[str] = [], from_: str = "", to: str = ""): + reqi = Rename (edit_level, from_, to) + req = Request(rename=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_copy(token: str = None, edit_level: list[str] = [], from_: str = "", to: str = ""): + reqi = Copy (edit_level, from_, to) + req = Request(copy=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_comment(token: str = None, path: list[str] = [], comment: str = ""): + reqi = Comment (path, comment) + req = Request(comment=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_commit(token: str = None, confirm: bool = None, confirm_timeout: int = None, comment: str = None, dry_run: bool = None): + reqi = Commit (confirm, confirm_timeout, comment, dry_run) + req = Request(commit=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_rollback(token: str = None, revision: int = 0): + reqi = Rollback (revision) + req = Request(rollback=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_load(token: str = None, location: str = "", format: ConfigFormat = None): + reqi = Load (location, format) + req = Request(load=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_merge(token: str = None, location: str = "", format: ConfigFormat = None): + reqi = Merge (location, format) + req = Request(merge=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_save(token: str = None, location: str = "", format: ConfigFormat = None): + reqi = Save (location, format) + req = Request(save=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_show_config(token: str = None, path: list[str] = [], format: ConfigFormat = None): + reqi = ShowConfig (path, format) + req = Request(show_config=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_exists(token: str = None, path: list[str] = []): + reqi = Exists (path) + req = Request(exists=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_get_value(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = GetValue (path, output_format) + req = Request(get_value=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_get_values(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = GetValues (path, output_format) + req = Request(get_values=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_list_children(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = ListChildren (path, output_format) + req = Request(list_children=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_run_op_mode(token: str = None, path: list[str] = [], output_format: OutputFormat = None): + reqi = RunOpMode (path, output_format) + req = Request(run_op_mode=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_confirm(token: str = None): + reqi = Confirm () + req = Request(confirm=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_enter_configuration_mode(token: str = None, exclusive: bool = False, override_exclusive: bool = False): + reqi = EnterConfigurationMode (exclusive, override_exclusive) + req = Request(enter_configuration_mode=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_exit_configuration_mode(token: str = None): + reqi = ExitConfigurationMode () + req = Request(exit_configuration_mode=reqi) + req_env = RequestEnvelope(token, req) + return req_env + +def set_request_reload_reftree(token: str = None, on_behalf_of: int = None): + reqi = ReloadReftree (on_behalf_of) + req = Request(reload_reftree=reqi) + req_env = RequestEnvelope(token, req) + return req_env -- cgit v1.2.3 From 1fa28abc7035984af01fa4332f0ed6ed8f4fc044 Mon Sep 17 00:00:00 2001 From: Adam Smith Date: Tue, 31 Dec 2024 13:43:55 -0500 Subject: T7432: RPKI VRF Support --- data/templates/frr/rpki.frr.j2 | 28 ++++--- .../include/rpki/protocol-common-config.xml.i | 87 ++++++++++++++++++++++ interface-definitions/protocols_rpki.xml.in | 86 +-------------------- interface-definitions/vrf.xml.in | 9 +++ op-mode-definitions/include/rpki/vrf.xml.i | 11 +++ op-mode-definitions/rpki.xml.in | 57 +++++++++++--- python/vyos/frrender.py | 17 ++++- src/conf_mode/protocols_rpki.py | 17 ++++- 8 files changed, 203 insertions(+), 109 deletions(-) create mode 100644 interface-definitions/include/rpki/protocol-common-config.xml.i create mode 100644 op-mode-definitions/include/rpki/vrf.xml.i (limited to 'python') diff --git a/data/templates/frr/rpki.frr.j2 b/data/templates/frr/rpki.frr.j2 index edf0ccaa2..e35f99766 100644 --- a/data/templates/frr/rpki.frr.j2 +++ b/data/templates/frr/rpki.frr.j2 @@ -1,8 +1,8 @@ -! +{% macro rpki_config(rpki) %} {# as FRR does not support deleting the entire rpki section we leave it in place even when it's empty #} rpki -{% if cache is vyos_defined %} -{% for peer, peer_config in cache.items() %} +{% if rpki.cache is vyos_defined %} +{% for peer, peer_config in rpki.cache.items() %} {# port is mandatory and preference uses a default value #} {% if peer_config.ssh.username is vyos_defined %} rpki cache ssh {{ peer | replace('_', '-') }} {{ peer_config.port }} {{ peer_config.ssh.username }} {{ peer_config.ssh.private_key_file }} {{ peer_config.ssh.public_key_file }}{{ ' source ' ~ peer_config.source_address if peer_config.source_address is vyos_defined }} preference {{ peer_config.preference }} @@ -11,14 +11,24 @@ rpki {% endif %} {% endfor %} {% endif %} -{% if expire_interval is vyos_defined %} - rpki expire_interval {{ expire_interval }} +{% if rpki.expire_interval is vyos_defined %} + rpki expire_interval {{ rpki.expire_interval }} {% endif %} -{% if polling_period is vyos_defined %} - rpki polling_period {{ polling_period }} +{% if rpki.polling_period is vyos_defined %} + rpki polling_period {{ rpki.polling_period }} {% endif %} -{% if retry_interval is vyos_defined %} - rpki retry_interval {{ retry_interval }} +{% if rpki.retry_interval is vyos_defined %} + rpki retry_interval {{ rpki.retry_interval }} {% endif %} exit +{# j2lint: disable=jinja-statements-delimeter #} +{%- endmacro -%} +! +{% if rpki.vrf is vyos_defined %} +vrf {{ rpki.vrf }} + {{ rpki_config(rpki) | indent(width=1) }} +exit-vrf +{% else %} +{{ rpki_config(rpki) }} +{% endif %} ! diff --git a/interface-definitions/include/rpki/protocol-common-config.xml.i b/interface-definitions/include/rpki/protocol-common-config.xml.i new file mode 100644 index 000000000..0b3356604 --- /dev/null +++ b/interface-definitions/include/rpki/protocol-common-config.xml.i @@ -0,0 +1,87 @@ + + + + RPKI cache server address + + ipv4 + IP address of RPKI server + + + ipv6 + IPv6 address of RPKI server + + + hostname + Fully qualified domain name of RPKI server + + + + + + + + #include + + + Preference of the cache server + + u32:1-255 + Preference of the cache server + + + + + + + #include + + + RPKI SSH connection settings + + + #include + #include + + + + + + + Interval to wait before expiring the cache + + u32:600-172800 + Interval in seconds + + + + + + 7200 + + + + Cache polling interval + + u32:1-86400 + Interval in seconds + + + + + + 300 + + + + Retry interval to connect to the cache server + + u32:1-7200 + Interval in seconds + + + + + + 600 + + diff --git a/interface-definitions/protocols_rpki.xml.in b/interface-definitions/protocols_rpki.xml.in index 9e2e84717..a298cdbfd 100644 --- a/interface-definitions/protocols_rpki.xml.in +++ b/interface-definitions/protocols_rpki.xml.in @@ -8,91 +8,7 @@ 819 - - - RPKI cache server address - - ipv4 - IP address of RPKI server - - - ipv6 - IPv6 address of RPKI server - - - hostname - Fully qualified domain name of RPKI server - - - - - - - - #include - - - Preference of the cache server - - u32:1-255 - Preference of the cache server - - - - - - - #include - - - RPKI SSH connection settings - - - #include - #include - - - - - - - Interval to wait before expiring the cache - - u32:600-172800 - Interval in seconds - - - - - - 7200 - - - - Cache polling interval - - u32:1-86400 - Interval in seconds - - - - - - 300 - - - - Retry interval to connect to the cache server - - u32:1-7200 - Interval in seconds - - - - - - 600 - + #include diff --git a/interface-definitions/vrf.xml.in b/interface-definitions/vrf.xml.in index a20be995a..03128cb99 100644 --- a/interface-definitions/vrf.xml.in +++ b/interface-definitions/vrf.xml.in @@ -95,6 +95,15 @@ #include + + + Resource Public Key Infrastructure (RPKI) + 820 + + + #include + + Static Routing diff --git a/op-mode-definitions/include/rpki/vrf.xml.i b/op-mode-definitions/include/rpki/vrf.xml.i new file mode 100644 index 000000000..5b6518fee --- /dev/null +++ b/op-mode-definitions/include/rpki/vrf.xml.i @@ -0,0 +1,11 @@ + + + + Virtual Routing and Forwarding (VRF) + + vrf name + + + ${vyos_op_scripts_dir}/vtysh_wrapper.sh $@ + + diff --git a/op-mode-definitions/rpki.xml.in b/op-mode-definitions/rpki.xml.in index 9e0f83e20..4753cfb93 100644 --- a/op-mode-definitions/rpki.xml.in +++ b/op-mode-definitions/rpki.xml.in @@ -15,19 +15,28 @@ ${vyos_op_scripts_dir}/vtysh_wrapper.sh $@ + + #include + - + Show RPKI cache connections - vtysh -c "show rpki cache-connection" - - + ${vyos_op_scripts_dir}/vtysh_wrapper.sh $@ + + #include + + + Show RPKI cache servers information - vtysh -c "show rpki cache-server" - + ${vyos_op_scripts_dir}/vtysh_wrapper.sh $@ + + #include + + Lookup IP prefix and optionally ASN in prefix table @@ -45,27 +54,53 @@ ${vyos_op_scripts_dir}/vtysh_wrapper.sh $(echo $@ | sed -e "s/as-number //g") + + + + Virtual Routing and Forwarding (VRF) + + vrf name + + + ${vyos_op_scripts_dir}/vtysh_wrapper.sh $(echo $@ | sed -e "s/as-number //g") + + + #include - + Show RPKI-validated prefixes - vtysh -c "show rpki prefix-table" - + ${vyos_op_scripts_dir}/vtysh_wrapper.sh $@ + + #include + + - + Reset RPKI vtysh -c "rpki reset" - + + + + Reset RPKI in VRF + + vrf name + + + vtysh -c "rpki reset vrf $4" + + + diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index 73d6dd5f0..d9e409cb4 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -543,6 +543,21 @@ def get_frrender_dict(conf, argv=None) -> dict: elif conf.exists_effective(ospfv3_vrf_path): vrf['name'][vrf_name]['protocols'].update({'ospfv3' : {'deleted' : ''}}) + # We need to check the CLI if the RPKI node is present and thus load in all the default + # values present on the CLI - that's why we have if conf.exists() + rpki_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'rpki'] + if 'rpki' in vrf_config.get('protocols', []): + rpki = conf.get_config_dict(rpki_vrf_path, key_mangling=('-', '_'), get_first_key=True, + with_pki=True, with_recursive_defaults=True) + rpki_ssh_key_base = '/run/frr/id_rpki' + for cache, cache_config in rpki.get('cache',{}).items(): + if 'ssh' in cache_config: + cache_config['ssh']['public_key_file'] = f'{rpki_ssh_key_base}_{cache}.pub' + cache_config['ssh']['private_key_file'] = f'{rpki_ssh_key_base}_{cache}' + vrf['name'][vrf_name]['protocols'].update({'rpki' : rpki}) + elif conf.exists_effective(rpki_vrf_path): + vrf['name'][vrf_name]['protocols'].update({'rpki' : {'deleted' : ''}}) + # We need to check the CLI if the static node is present and thus load in all the default # values present on the CLI - that's why we have if conf.exists() static_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'static'] @@ -675,7 +690,7 @@ class FRRender: output += render_to_string('frr/ripngd.frr.j2', config_dict['ripng']) output += '\n' if 'rpki' in config_dict and 'deleted' not in config_dict['rpki']: - output += render_to_string('frr/rpki.frr.j2', config_dict['rpki']) + output += render_to_string('frr/rpki.frr.j2', {'rpki': config_dict['rpki']}) output += '\n' if 'segment_routing' in config_dict and 'deleted' not in config_dict['segment_routing']: output += render_to_string('frr/zebra.segment_routing.frr.j2', config_dict['segment_routing']) diff --git a/src/conf_mode/protocols_rpki.py b/src/conf_mode/protocols_rpki.py index ef0250e3d..054aa1c0e 100755 --- a/src/conf_mode/protocols_rpki.py +++ b/src/conf_mode/protocols_rpki.py @@ -18,6 +18,7 @@ import os from glob import glob from sys import exit +from sys import argv from vyos.config import Config from vyos.configverify import has_frr_protocol_in_dict @@ -39,13 +40,18 @@ def get_config(config=None): conf = config else: conf = Config() - return get_frrender_dict(conf) + return get_frrender_dict(conf, argv) def verify(config_dict): if not has_frr_protocol_in_dict(config_dict, 'rpki'): return None - rpki = config_dict['rpki'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] + + # eqivalent of the C foo ? 'a' : 'b' statement + rpki = vrf and config_dict['vrf']['name'][vrf]['protocols']['rpki'] or config_dict['rpki'] if 'cache' in rpki: preferences = [] @@ -79,7 +85,12 @@ def generate(config_dict): if not has_frr_protocol_in_dict(config_dict, 'rpki'): return None - rpki = config_dict['rpki'] + vrf = None + if 'vrf_context' in config_dict: + vrf = config_dict['vrf_context'] + + # eqivalent of the C foo ? 'a' : 'b' statement + rpki = vrf and config_dict['vrf']['name'][vrf]['protocols']['rpki'] or config_dict['rpki'] if 'cache' in rpki: for cache, cache_config in rpki['cache'].items(): -- cgit v1.2.3 From 7b3ce52e3babdf3a17910ce5322e571d5c91bb38 Mon Sep 17 00:00:00 2001 From: IDerr Date: Wed, 28 May 2025 16:42:26 +0200 Subject: T7395: Add support for renew in REST Server --- data/templates/https/nginx.default.j2 | 2 +- python/vyos/configsession.py | 5 +++++ src/services/api/rest/models.py | 14 ++++++++++++++ src/services/api/rest/routers.py | 21 +++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/data/templates/https/nginx.default.j2 b/data/templates/https/nginx.default.j2 index 692ccbff7..47280c9f0 100644 --- a/data/templates/https/nginx.default.j2 +++ b/data/templates/https/nginx.default.j2 @@ -48,7 +48,7 @@ server { ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK'; # proxy settings for HTTP API, if enabled; 503, if not - location ~ ^/(retrieve|configure|config-file|image|import-pki|container-image|generate|show|reboot|reset|poweroff|traceroute|info|docs|openapi.json|redoc|graphql) { + location ~ ^/(retrieve|configure|config-file|image|import-pki|container-image|generate|show|reboot|reset|poweroff|traceroute|info|docs|openapi.json|redoc|graphql|renew) { {% if api is vyos_defined %} proxy_pass http://unix:/run/api.sock; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 1b19c68b4..b6394d139 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -68,6 +68,7 @@ GENERATE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'generate'] SHOW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'show'] RESET = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reset'] REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot'] +RENEW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'renew'] POWEROFF = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'poweroff'] OP_CMD_ADD = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'add'] OP_CMD_DELETE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'delete'] @@ -384,6 +385,10 @@ class ConfigSession(object): out = self.__run_command(RESET + path) return out + def renew(self, path): + out = self.__run_command(RENEW + path) + return out + def poweroff(self, path): out = self.__run_command(POWEROFF + path) return out diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py index dda50010f..da60e1220 100644 --- a/src/services/api/rest/models.py +++ b/src/services/api/rest/models.py @@ -251,6 +251,20 @@ class RebootModel(ApiModel): } +class RenewModel(ApiModel): + op: StrictStr + path: List[StrictStr] + + class Config: + json_schema_extra = { + 'example': { + 'key': 'id_key', + 'op': 'renew', + 'path': ['op', 'mode', 'path'], + } + } + + class ResetModel(ApiModel): op: StrictStr path: List[StrictStr] diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index e52c77fda..d3df91ef5 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -66,6 +66,7 @@ from .models import GenerateModel from .models import ShowModel from .models import RebootModel from .models import ResetModel +from .models import RenewModel from .models import ImportPkiModel from .models import PoweroffModel from .models import TracerouteModel @@ -657,6 +658,26 @@ def reboot_op(data: RebootModel): return success(res) +@router.post('/renew') +def renew_op(data: RenewModel): + state = SessionState() + session = state.session + + op = data.op + path = data.path + + try: + if op == 'renew': + res = session.renew(path) + else: + return error(400, f"'{op}' is not a valid operation") + except ConfigSessionError as e: + return error(400, str(e)) + except Exception: + LOG.critical(traceback.format_exc()) + return error(500, 'An internal error occured. Check the logs for details.') + + return success(res) @router.post('/reset') def reset_op(data: ResetModel): -- cgit v1.2.3 From c8d4ef91d39216218a10d6e643bdb15a2530628c Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 28 May 2025 14:53:38 -0500 Subject: http-api: T7498: allow passing config string in body of 'merge' request --- python/vyos/configsession.py | 9 +++++++++ src/services/api/rest/models.py | 2 +- src/services/api/rest/routers.py | 8 ++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 1b19c68b4..7be2e665c 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -36,6 +36,7 @@ DISCARD = '/opt/vyatta/sbin/my_discard' SHOW_CONFIG = ['/bin/cli-shell-api', 'showConfig'] LOAD_CONFIG = ['/bin/cli-shell-api', 'loadFile'] MIGRATE_LOAD_CONFIG = ['/usr/libexec/vyos/vyos-load-config.py'] +MERGE_CONFIG = ['/usr/libexec/vyos/vyos-merge-config.py'] SAVE_CONFIG = ['/usr/libexec/vyos/vyos-save-config.py'] INSTALL_IMAGE = [ '/usr/libexec/vyos/op_mode/image_installer.py', @@ -338,6 +339,14 @@ class ConfigSession(object): return out + def merge_config(self, file_path): + if self._vyconf_session is None: + out = self.__run_command(MERGE_CONFIG + [file_path]) + else: + out, _ = 'unimplemented' + + return out + def save_config(self, file_path): if self._vyconf_session is None: out = self.__run_command(SAVE_CONFIG + [file_path]) diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py index 9ca985a91..fa2d07d28 100644 --- a/src/services/api/rest/models.py +++ b/src/services/api/rest/models.py @@ -140,7 +140,7 @@ class ConfigFileModel(ApiModel): json_schema_extra = { 'example': { 'key': 'id_key', - 'op': 'save | load', + 'op': 'save | load | merge', 'file': 'filename', 'string': 'config_string' } diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 8679329da..fba08a65a 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -507,7 +507,7 @@ def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): else: path = '/config/config.boot' msg = session.save_config(path) - elif op == 'load': + elif op in ('load', 'merge'): if data.file: path = data.file elif data.string: @@ -517,7 +517,11 @@ def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): else: return error(400, 'Missing required field "file | string"') - session.migrate_and_load_config(path) + match op: + case 'load': + session.migrate_and_load_config(path) + case 'merge': + session.merge_config(path) config = Config(session_env=env) d = get_config_diff(config) -- cgit v1.2.3 From b22062658ca2a78755bbe4b09bebe843bf0342ed Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 28 May 2025 18:41:52 -0500 Subject: config-mgmt: T7500: fix typo preventing commit-confirm hard rollback --- python/vyos/config_mgmt.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index dd8910afb..308eb1cdb 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -151,7 +151,9 @@ class ConfigMgmt: self.num_revisions = 0 self.locations = d.get('commit_archive', {}).get('location', []) self.source_address = d.get('commit_archive', {}).get('source_address', '') - self.reboot_unconfirmed = bool(d.get('commit_confirm') == 'reboot') + self.reboot_unconfirmed = bool( + d.get('commit_confirm', {}).get('action') == 'reboot' + ) self.config_dict = d if config.exists(['system', 'host-name']): -- cgit v1.2.3 From 4b4bbd73b84c2c478c7752f58e7f66ec6d90459e Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Tue, 20 May 2025 19:57:24 +0200 Subject: ssh: T6013: rename trusted-user-ca-key -> truster-user-ca The current implementation for SSH CA based authentication uses "set service ssh trusted-user-ca-key ca-certificate " to define an X.509 certificate from "set pki ca ..." - fun fact, native OpenSSH does not support X.509 certificates and only runs with OpenSSH ssh-keygen generated RSA or EC keys. This commit changes the bahavior to support antive certificates generated using ssh-keygen and loaded to our PKI tree. As the previous implementation did not work at all, no migrations cript is used. --- data/templates/ssh/sshd_config.j2 | 6 +- interface-definitions/service_ssh.xml.in | 16 +- python/vyos/configverify.py | 19 ++ python/vyos/defaults.py | 8 +- python/vyos/template.py | 20 +- python/vyos/utils/file.py | 21 ++- smoketest/scripts/cli/base_vyostest_shim.py | 6 +- smoketest/scripts/cli/test_service_ssh.py | 278 +++++++++++++++------------- src/conf_mode/pki.py | 5 +- src/conf_mode/service_ssh.py | 54 ++---- src/conf_mode/system_login.py | 23 ++- src/tests/test_template.py | 5 + 12 files changed, 269 insertions(+), 192 deletions(-) (limited to 'python') diff --git a/data/templates/ssh/sshd_config.j2 b/data/templates/ssh/sshd_config.j2 index dce679936..1315bf2cb 100644 --- a/data/templates/ssh/sshd_config.j2 +++ b/data/templates/ssh/sshd_config.j2 @@ -111,17 +111,17 @@ ClientAliveInterval {{ client_keepalive_interval }} RekeyLimit {{ rekey.data }}M {{ rekey.time + 'M' if rekey.time is vyos_defined }} {% endif %} -{% if trusted_user_ca_key is vyos_defined %} +{% if trusted_user_ca is vyos_defined %} # Specifies a file containing public keys of certificate authorities that are # trusted to sign user certificates for authentication -TrustedUserCAKeys /etc/ssh/trusted_user_ca_key +TrustedUserCAKeys {{ get_default_config_file('sshd_user_ca') }} # The default is "none", i.e. not to use a principals file - in this case, the # username of the user must appear in a certificate's principals list for it # to be accepted. ".ssh/authorized_principals" means a per-user configuration, # relative to $HOME. {% set filename = 'none' %} -{% if trusted_user_ca_key.has_principals is vyos_defined %} +{% if has_principals is vyos_defined %} {% set filename = '.ssh/authorized_principals' %} {% endif %} AuthorizedPrincipalsFile {{ filename }} diff --git a/interface-definitions/service_ssh.xml.in b/interface-definitions/service_ssh.xml.in index 14d358c78..c659a7db7 100644 --- a/interface-definitions/service_ssh.xml.in +++ b/interface-definitions/service_ssh.xml.in @@ -275,14 +275,18 @@ - + - Trusted user CA key + OpenSSH trusted user CA + + pki openssh + + + txt + OpenSSH certificate name from PKI subsystem + - - #include - - + #include diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index d5f443f15..07eb29a68 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -527,6 +527,25 @@ def verify_pki_dh_parameters(config: dict, dh_name: str, min_key_size: int=0): if dh_bits < min_key_size: raise ConfigError(f'Minimum DH key-size is {min_key_size} bits!') +def verify_pki_openssh_key(config: dict, key_name: str): + """ + Common helper function user by PKI consumers to perform recurring + validation functions on OpenSSH keys + """ + if 'pki' not in config: + raise ConfigError('PKI is not configured!') + + if 'openssh' not in config['pki']: + raise ConfigError('PKI does not contain any OpenSSH keys!') + + if key_name not in config['pki']['openssh']: + raise ConfigError(f'OpenSSH key "{key_name}" not found in configuration!') + + if 'public' in config['pki']['openssh'][key_name]: + if not {'key', 'type'} <= set(config['pki']['openssh'][key_name]['public']): + raise ConfigError('Both public key and type must be defined for '\ + f'OpenSSH public key "{key_name}"!') + def verify_eapol(config: dict): """ Common helper function used by interface implementations to perform diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index fbde0298b..e42d92112 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -53,6 +53,10 @@ internal_ports = { 'certbot_haproxy' : 65080, # Certbot running behing haproxy } +config_files = { + 'sshd_user_ca' : '/run/sshd/trusted_user_ca', +} + config_status = '/tmp/vyos-config-status' api_config_state = '/run/http-api-state' frr_debug_enable = '/tmp/vyos.frr.debug' @@ -69,8 +73,8 @@ config_default = os.path.join(directories['data'], 'config.boot.default') rt_symbolic_names = { # Standard routing tables for Linux & reserved IDs for VyOS - 'default': 253, # Confusingly, a final fallthru, not the default. - 'main': 254, # The actual global table used by iproute2 unless told otherwise. + 'default': 253, # Confusingly, a final fallthru, not the default. + 'main': 254, # The actual global table used by iproute2 unless told otherwise. 'local': 255, # Special kernel loopback table. } diff --git a/python/vyos/template.py b/python/vyos/template.py index aa215db95..bf7928914 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -1079,7 +1079,7 @@ def vyos_defined(value, test_value=None, var_type=None): def get_default_port(service): """ Jinja2 plugin to retrieve common service port number from vyos.defaults - class form a Jinja2 template. This removes the need to hardcode, or pass in + class from a Jinja2 template. This removes the need to hardcode, or pass in the data using the general dictionary. Added to remove code complexity and make it easier to read. @@ -1092,3 +1092,21 @@ def get_default_port(service): raise RuntimeError(f'Service "{service}" not found in internal ' \ 'vyos.defaults.internal_ports dict!') return internal_ports[service] + +@register_clever_function('get_default_config_file') +def get_default_config_file(filename): + """ + Jinja2 plugin to retrieve a common configuration file path from + vyos.defaults class from a Jinja2 template. This removes the need to + hardcode, or pass in the data using the general dictionary. + + Added to remove code complexity and make it easier to read. + + Example: + {{ get_default_config_file('certbot_haproxy') }} + """ + from vyos.defaults import config_files + if filename not in config_files: + raise RuntimeError(f'Configuration file "{filename}" not found in '\ + 'internal vyos.defaults.config_files dict!') + return config_files[filename] diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index eaebb57a3..cc46d77d1 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -28,22 +28,28 @@ def file_is_persistent(path): absolute = os.path.abspath(os.path.dirname(path)) return re.match(location,absolute) -def read_file(fname, defaultonfailure=None): +def read_file(fname, defaultonfailure=None, sudo=False): """ read the content of a file, stripping any end characters (space, newlines) should defaultonfailure be not None, it is returned on failure to read """ try: - """ Read a file to string """ - with open(fname, 'r') as f: - data = f.read().strip() - return data + # Some files can only be read by root - emulate sudo cat call + if sudo: + from vyos.utils.process import cmd + data = cmd(['sudo', 'cat', fname]) + else: + # If not sudo, just read the file + with open(fname, 'r') as f: + data = f.read() + return data.strip() except Exception as e: if defaultonfailure is not None: return defaultonfailure raise e -def write_file(fname, data, defaultonfailure=None, user=None, group=None, mode=None, append=False): +def write_file(fname, data, defaultonfailure=None, user=None, group=None, + mode=None, append=False, trailing_newline=False): """ Write content of data to given fname, should defaultonfailure be not None, it is returned on failure to read. @@ -60,6 +66,9 @@ def write_file(fname, data, defaultonfailure=None, user=None, group=None, mode=N bytes = 0 with open(fname, 'w' if not append else 'a') as f: bytes = f.write(data) + if trailing_newline and not data.endswith('\n'): + f.write('\n') + bytes += 1 chown(fname, user, group) chmod(fname, mode) return bytes diff --git a/smoketest/scripts/cli/base_vyostest_shim.py b/smoketest/scripts/cli/base_vyostest_shim.py index f0674f187..9b64d5c0e 100644 --- a/smoketest/scripts/cli/base_vyostest_shim.py +++ b/smoketest/scripts/cli/base_vyostest_shim.py @@ -152,12 +152,14 @@ class VyOSUnitTestSHIM: return out @staticmethod - def ssh_send_cmd(command, username, password, hostname='localhost'): + def ssh_send_cmd(command, username, password, key_filename=None, + hostname='localhost'): """ SSH command execution helper """ # Try to login via SSH ssh_client = paramiko.SSHClient() ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh_client.connect(hostname=hostname, username=username, password=password) + ssh_client.connect(hostname=hostname, username=username, + password=password, key_filename=key_filename) _, stdout, stderr = ssh_client.exec_command(command) output = stdout.read().decode().strip() error = stderr.read().decode().strip() diff --git a/smoketest/scripts/cli/test_service_ssh.py b/smoketest/scripts/cli/test_service_ssh.py index db83f14c3..551991d69 100755 --- a/smoketest/scripts/cli/test_service_ssh.py +++ b/smoketest/scripts/cli/test_service_ssh.py @@ -24,10 +24,12 @@ from pwd import getpwall from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError +from vyos.defaults import config_files from vyos.utils.process import cmd from vyos.utils.process import is_systemd_service_running from vyos.utils.process import process_named_running from vyos.utils.file import read_file +from vyos.utils.file import write_file from vyos.xml_ref import default_value PROCESS_NAME = 'sshd' @@ -38,27 +40,101 @@ pki_path = ['pki'] key_rsa = '/etc/ssh/ssh_host_rsa_key' key_dsa = '/etc/ssh/ssh_host_dsa_key' key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' -trusted_user_ca_key = '/etc/ssh/trusted_user_ca_key' -authorized_principals_dir = '/etc/ssh/authorized_principals' - +trusted_user_ca = config_files['sshd_user_ca'] +test_command = 'uname -a' def get_config_value(key): tmp = read_file(SSHD_CONF) tmp = re.findall(f'\n?{key}\s+(.*)', tmp) return tmp +trusted_user_ca_path = base_path + ['trusted-user-ca'] +# CA and signed user key generated using: +# ssh-keygen -f vyos-ssh-ca.key +# ssh-keygen -f vyos_testca -C "vyos_tesca@vyos.net" +# ssh-keygen -s vyos-ssh-ca.key -I vyos_testca@vyos.net -n vyos,vyos_testca -V +520w vyos_testca.pub +ca_cert_data = """ +AAAAB3NzaC1yc2EAAAADAQABAAABgQCTBa7+TTefsMLTHuuLPUmmm7SGAuoK03oZEIi2/O +sww1uhCdKrm7bFvSUFpWvq3gX8TSS+yO5kNKz3BTMBu7oq01/Ewjyw0jR+fUog76x7mCzd +2iI4QmPj4lNHSUFquaELt2aBwY4f7LtjxRCCgtWgirq/Qk+P27uJKErvndyYc95v9no15z +lQFSdUid6tF8IjYljK8pXP0JshFp3XnFV2Rg80j7O66mRtVFC4tt2vluyIFeIID+5fL03v +LXbT/2zNdoH6QiI9NGWkxhS7zFYziVd/rzG5xlEB1ezs2Sz4zjMPgV3GiMINb6tjEWNJhM +KtDWIt+3UDpx+2T9PrhDBDFMlneiHCD6MxRv2sLbicevSj0PV7/fRnwoHs6hDKCU5eS2Mc +CTxXr4jaboLZ6q3sbGHCHZo/PuA8Sl9iZCM4GCxx5bgvRRmGpgZv4PfFzA2b/wTHkKnf6E +kuthoAJufmNxPaZQRQKF34SdmTKgSJTCY1gqwCH2iNg0PVKU+vN8c= +""" -ca_root_cert_data = """ -MIIBcTCCARagAwIBAgIUDcAf1oIQV+6WRaW7NPcSnECQ/lUwCgYIKoZIzj0EAwIw -HjEcMBoGA1UEAwwTVnlPUyBzZXJ2ZXIgcm9vdCBDQTAeFw0yMjAyMTcxOTQxMjBa -Fw0zMjAyMTUxOTQxMjBaMB4xHDAaBgNVBAMME1Z5T1Mgc2VydmVyIHJvb3QgQ0Ew -WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ0y24GzKQf4aM2Ir12tI9yITOIzAUj -ZXyJeCmYI6uAnyAMqc4Q4NKyfq3nBi4XP87cs1jlC1P2BZ8MsjL5MdGWozIwMDAP -BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRwC/YaieMEnjhYa7K3Flw/o0SFuzAK -BggqhkjOPQQDAgNJADBGAiEAh3qEj8vScsjAdBy5shXzXDVVOKWCPTdGrPKnu8UW -a2cCIQDlDgkzWmn5ujc5ATKz1fj+Se/aeqwh4QyoWCVTFLIxhQ== +cert_user_key = """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAYEArnIlFpMwSQax7+qH3+/gbv65mem6Ur+gepNYC8TYaE91xJxMoE5M +Pyh1s8Kr/WYNF6aN43qdDnjvGy38oFng4lEfxG475AqpTIGmP4GvEOlnNLhjCcOHrOFuzg +uRtDDvn0/TPhdqLTlbvgZ326WO7xQkCX11qmdGUUtC9Byd7p+EmnTe0oP8N6MeyYY78qa4 +HnzMd6EPb3vyWdASpPZjQE0OJCeAx6Mne2kOnKxUcW1UlczOa1PPIQMU+Rp1PWDtkdiYAd +nbTbIdxDN8Bn3mC3JXD642EcwXSJ1+kov/8u8bBuYNt3t3nf/krSebx4Ge7ObYnURj31j0 +8L8Vv3fgv+T7pY8iyMh8dYfrZPAWQGN1pe8ZkDaM1QGKJncF+8N0UB4EVFBHNLt7W8+oHt +LPMqYw13djZHg5Q1NxSxc1srOmEBZrWCBZgDGGiqtKo+lF+oVvqvBh/hncOBlDX5RFM8qw +Qt4mem9TEZZrIvC9q1dcVpQUrt8BvBOSnGnBb7yTAAAFkEdBIUlHQSFJAAAAB3NzaC1yc2 +EAAAGBAK5yJRaTMEkGse/qh9/v4G7+uZnpulK/oHqTWAvE2GhPdcScTKBOTD8odbPCq/1m +DRemjeN6nQ547xst/KBZ4OJRH8RuO+QKqUyBpj+BrxDpZzS4YwnDh6zhbs4LkbQw759P0z +4Xai05W74Gd9ulju8UJAl9dapnRlFLQvQcne6fhJp03tKD/DejHsmGO/KmuB58zHehD297 +8lnQEqT2Y0BNDiQngMejJ3tpDpysVHFtVJXMzmtTzyEDFPkadT1g7ZHYmAHZ202yHcQzfA +Z95gtyVw+uNhHMF0idfpKL//LvGwbmDbd7d53/5K0nm8eBnuzm2J1EY99Y9PC/Fb934L/k ++6WPIsjIfHWH62TwFkBjdaXvGZA2jNUBiiZ3BfvDdFAeBFRQRzS7e1vPqB7SzzKmMNd3Y2 +R4OUNTcUsXNbKzphAWa1ggWYAxhoqrSqPpRfqFb6rwYf4Z3DgZQ1+URTPKsELeJnpvUxGW +ayLwvatXXFaUFK7fAbwTkpxpwW+8kwAAAAMBAAEAAAGAEeZQe+0vyoPPWkjRwbQBbszgX9 +9QaRE/TD82N5mZLbWJkK+2WnSY9O9tNGbIncBiSNz5ji/p/FmDCgzr8SAyfRvJ4K6sTTfy +1eYvwtscYDsy2ywDAuDMrnvrPLqJ1tghSP2N4BR9ppT4yZosTkjB+TIzMxjBLB0GEBgNj1 +19rxswe2YmlFSgBVgi3pbRgT0uLfgBmvzXHUoLPL/8ScT7u4Csmh/GN7Xmuo5gcMnArcAu +1Q17g3PJZcpv1Ser2VfKnVAwrURCLW8dlji5xat/3E/PLsrLvszVS6U0hFf3MaOixprxsz +wc0n2Y4lAgkgkCZQ0Ty9TSXI/8TQWL8cPFej1TK15NWXlfElZxI+lhwcsnWmNy3mXD746/ +YZLH+OCs9isvewZWryQEkdVCU42MM/7L4Hoeqh2diGDV9wtKDW5FjHq/VRNOMVt59eCFlv +eujh89/KY6wPxHoDoY3+olhggiKDGw1wUUpEXKNQhhTjx1g0xn7AFYz+Bp2svM9EdhAAAA +wQDBq+zeOhsS/VrrVRkmOYYXnBSe0WcckjcYOly/8FLTPkq19aVY5eOmo6teegqvkWscGP +Wisl7DW+kFNolIvwc6shf/8+PXC1KlADd9S1uoXvSmVoe3wSsIKRCsUuLZiiJkv4nqQ/BK +T6ijvNG2Wu3YGsP8Tj+OcTebqk1vDItaickhKtFxCx6PBcV+RrDeK1TT6uAHd1AsGikTva +V/BDMmtoDz7qFQbj9Vj2np88MakxYfm7u4DzKu082GHDBC44sAAADBAN8ATvmmfxqk5GFg ++2rbIW+qMJ2GwWXiTFLjH7u4HEhsmHbHYsQ0v+cGu2dKfBUVWoq/N2ltDQ0QYTgkmsxKvm +I8AjVhLHhFB1DtPBMHibsF/rtBRgsItR+PveUtRYOmeY1PzJ3ygVNJpPJ87st0T4JVNQiE ++bFEhnJ/RcTHxzAAt8+gTn0PTen3+hn9Jk2YFHWFb51YDw2h00LL9XT9Enz4xkc6gTPL3M +0IKULJWnyYGOLueSsQxJiaAUcsZg8W2QAAAMEAyEJ45HtbUqZ5xd2K5ZfY8cd1dC9uAx6a +cSdENUvMW4yE3QEJ4xdonDUn9OQYR7GpseQWuXBrTO2PSsse7P6eHUsRhaUkFOvLzHSVzO +bI9HDJAq6+KCPhm2eixfBiMs2meEle8MvNiiONwaY3JnPnGdsTpEjcm6oulyC52xRvHhvc +nCuoRTqX7xcIka4jCXInYBS7GhlF5iAmIAAVkvfWjjNwZ3S0mnGUUOYgknidBhK+x0zCWt +IXOeoIfjb/C4NLAAAAE3Z5b3NfdGVzY2FAdnlvcy5uZXQBAgMEBQYH +-----END OPENSSH PRIVATE KEY----- """ +cert_user_signed = """ +ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb2 +0AAAAglE+kjRPqsck/y2ywO+owv1FTeU6QFNPywFqD8aoEcA8AAAADAQABAAABgQCuciUWk +zBJBrHv6off7+Bu/rmZ6bpSv6B6k1gLxNhoT3XEnEygTkw/KHWzwqv9Zg0Xpo3jep0OeO8b +LfygWeDiUR/EbjvkCqlMgaY/ga8Q6Wc0uGMJw4es4W7OC5G0MO+fT9M+F2otOVu+BnfbpY7 +vFCQJfXWqZ0ZRS0L0HJ3un4SadN7Sg/w3ox7JhjvyprgefMx3oQ9ve/JZ0BKk9mNATQ4kJ4 +DHoyd7aQ6crFRxbVSVzM5rU88hAxT5GnU9YO2R2JgB2dtNsh3EM3wGfeYLclcPrjYRzBdIn +X6Si//y7xsG5g23e3ed/+StJ5vHgZ7s5tidRGPfWPTwvxW/d+C/5PuljyLIyHx1h+tk8BZA +Y3Wl7xmQNozVAYomdwX7w3RQHgRUUEc0u3tbz6ge0s8ypjDXd2NkeDlDU3FLFzWys6YQFmt +YIFmAMYaKq0qj6UX6hW+q8GH+Gdw4GUNflEUzyrBC3iZ6b1MRlmsi8L2rV1xWlBSu3wG8E5 +KcacFvvJMAAAAAAAAAAAAAAAEAAAAUdnlvc190ZXN0Y2FAdnlvcy5uZXQAAAAXAAAABHZ5b +3MAAAALdnlvc190ZXN0Y2EAAAAAaDg66AAAAAB69w9WAAAAAAAAAIIAAAAVcGVybWl0LVgx +MS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGV +ybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LX +VzZXItcmMAAAAAAAAAAAAAAZcAAAAHc3NoLXJzYQAAAAMBAAEAAAGBAJMFrv5NN5+wwtMe6 +4s9SaabtIYC6grTehkQiLb86zDDW6EJ0qubtsW9JQWla+reBfxNJL7I7mQ0rPcFMwG7uirT +X8TCPLDSNH59SiDvrHuYLN3aIjhCY+PiU0dJQWq5oQu3ZoHBjh/su2PFEIKC1aCKur9CT4/ +bu4koSu+d3Jhz3m/2ejXnOVAVJ1SJ3q0XwiNiWMrylc/QmyEWndecVXZGDzSPs7rqZG1UUL +i23a+W7IgV4ggP7l8vTe8tdtP/bM12gfpCIj00ZaTGFLvMVjOJV3+vMbnGUQHV7OzZLPjOM +w+BXcaIwg1vq2MRY0mEwq0NYi37dQOnH7ZP0+uEMEMUyWd6IcIPozFG/awtuJx69KPQ9Xv9 +9GfCgezqEMoJTl5LYxwJPFeviNpugtnqrexsYcIdmj8+4DxKX2JkIzgYLHHluC9FGYamBm/ +g98XMDZv/BMeQqd/oSS62GgAm5+Y3E9plBFAoXfhJ2ZMqBIlMJjWCrAIfaI2DQ9UpT683xw +AAAZQAAAAMcnNhLXNoYTItNTEyAAABgINZAr9M9ZYWDhhf5uWNkUBKq12OlJ3ImvHg5161P +BAAL6crGS3WzyAs9LerxFcdMJ0gzMgUixR59MgGMAzfN+DjoSmgcLVT0eVoI5GMBkdiq8T5 +h3qjeXTc5BfLJiACbu7tOPhuIsIDreDnCVYmGr2z+rAPaqMETJa4L0submx4DqnahSY0ZSH +WjTrjWCSPIdySh9HUXbpq3tYdNlqmpSY5YzvDmMC46kGMF10G5ycc58asWfUMwLMGsTEt2t +R5DKRDw/iJch3r+L0xLMCSmEXnu6/Gl7Yq1XJdWm9cA1SvDyxEuB4yKIDkunXrPiuPn3zyv +z1a/bY0hvuF+fyL+tRCbmrfOLreHuYh9aFg6e22MoKhrez5wP8Eoy1T+rlQrmlgCRDShBgj +wMMhc+2fdrzTR07Ctnmv339p/SY5wBruzNM9R1mzyEuuJDE6OkKBTI8kuQu6ypGv+bLqSSt +wujcNqOI4Vz61HiOsRSTUa7tA5q4hBwFqq7FB8+N0Ylfa5A== vyos_tesca@vyos.net +""" class TestServiceSSH(VyOSUnitTestSHIM.TestCase): @classmethod @@ -208,23 +284,12 @@ class TestServiceSSH(VyOSUnitTestSHIM.TestCase): # run natively. # # We also try to login as an invalid user - this is not allowed to work. - test_user = 'ssh_test' test_pass = 'v2i57DZs8idUwMN3VC92' - test_command = 'uname -a' self.cli_set(base_path) - self.cli_set( - [ - 'system', - 'login', - 'user', - test_user, - 'authentication', - 'plaintext-password', - test_pass, - ] - ) + self.cli_set(['system', 'login', 'user', test_user, 'authentication', + 'plaintext-password', test_pass]) # commit changes self.cli_commit() @@ -237,9 +302,8 @@ class TestServiceSSH(VyOSUnitTestSHIM.TestCase): # Login with invalid credentials with self.assertRaises(paramiko.ssh_exception.AuthenticationException): - output, error = self.ssh_send_cmd( - test_command, 'invalid_user', 'invalid_password' - ) + output, error = self.ssh_send_cmd(test_command, 'invalid_user', + 'invalid_password') self.cli_delete(['system', 'login', 'user', test_user]) self.cli_commit() @@ -360,126 +424,74 @@ class TestServiceSSH(VyOSUnitTestSHIM.TestCase): tmp_sshd_conf = read_file(SSHD_CONF) self.assertIn(expected, tmp_sshd_conf) - def test_ssh_trusted_user_ca_key(self): + def test_ssh_trusted_user_ca(self): ca_cert_name = 'test_ca' - - # set pki ca certificate - # set service ssh trusted-user-ca-key ca-certificate - self.cli_set( - pki_path - + [ - 'ca', - ca_cert_name, - 'certificate', - ca_root_cert_data.replace('\n', ''), - ] - ) - self.cli_set( - base_path + ['trusted-user-ca-key', 'ca-certificate', ca_cert_name] - ) + public_key_type = 'ssh-rsa' + public_key_data = ca_cert_data.replace('\n', '') + test_user = 'vyos_testca' + principal = 'vyos' + user_auth_base = ['system', 'login', 'user', test_user] + + # create user account + self.cli_set(user_auth_base) + self.cli_set(pki_path + ['openssh', ca_cert_name, 'public', + 'key', public_key_data]) + self.cli_set(pki_path + ['openssh', ca_cert_name, 'public', + 'type', public_key_type]) + self.cli_set(trusted_user_ca_path, value=ca_cert_name) self.cli_commit() - trusted_user_ca_key_config = get_config_value('TrustedUserCAKeys') - self.assertIn(trusted_user_ca_key, trusted_user_ca_key_config) + trusted_user_ca_config = get_config_value('TrustedUserCAKeys') + self.assertIn(trusted_user_ca, trusted_user_ca_config) + authorize_principals_file_config = get_config_value('AuthorizedPrincipalsFile') self.assertIn('none', authorize_principals_file_config) - with open(trusted_user_ca_key, 'r') as file: - ca_key_contents = file.read() - self.assertIn(ca_root_cert_data, ca_key_contents) - - self.cli_delete( - base_path + ['trusted-user-ca-key', 'ca-certificate', ca_cert_name] - ) - self.cli_delete(['pki', 'ca', ca_cert_name]) - self.cli_commit() + ca_key_contents = read_file(trusted_user_ca).lstrip().rstrip() + self.assertIn(f'{public_key_type} {public_key_data}', ca_key_contents) - # Verify the CA key is removed - trusted_user_ca_key_config = get_config_value('TrustedUserCAKeys') - self.assertNotIn(trusted_user_ca_key, trusted_user_ca_key_config) - authorize_principals_file_config = get_config_value('AuthorizedPrincipalsFile') - self.assertNotIn('none', authorize_principals_file_config) + # Verify functionality by logging into the system using signed user key + key_filename = f'/tmp/{test_user}' + write_file(key_filename, cert_user_key, mode=0o600) + write_file(f'{key_filename}-cert.pub', cert_user_signed.replace('\n', '')) - def test_ssh_trusted_user_ca_key_and_bind_user_with_principal(self): - ca_cert_name = 'test_ca' - bind_user = 'test_user' - principals = ['test_principal_alice', 'test_principal_bob'] - test_user = 'ssh_test' - test_pass = 'v2i57DZs8idUwMN3VC92' + # Login with proper credentials + output, error = self.ssh_send_cmd(test_command, test_user, password=None, + key_filename=key_filename) + # Verify login + self.assertFalse(error) + self.assertEqual(output, cmd(test_command)) - # Create a test user - self.cli_set( - [ - 'system', - 'login', - 'user', - test_user, - 'authentication', - 'plaintext-password', - test_pass, - ] - ) - - # set pki ca certificate - # set service ssh trusted-user-ca-key ca-certificate - # set service ssh trusted-user-ca-key bind-user principal - self.cli_set( - pki_path - + [ - 'ca', - ca_cert_name, - 'certificate', - ca_root_cert_data.replace('\n', ''), - ] - ) - self.cli_set( - base_path + ['trusted-user-ca-key', 'ca-certificate', ca_cert_name] - ) - for principal in principals: - self.cli_set( - base_path - + [ - 'trusted-user-ca-key', - 'bind-user', - bind_user, - 'principal', - principal, - ] - ) + # Enable user principal name - logins only allowed if certificate contains + # said principal name + self.cli_set(user_auth_base + ['authentication', 'principal', principal]) self.cli_commit() - trusted_user_ca_key_config = get_config_value('TrustedUserCAKeys') - self.assertIn(trusted_user_ca_key, trusted_user_ca_key_config) - authorized_principals_file = f'{authorized_principals_dir}/{bind_user}' - self.assertTrue(os.path.exists(authorized_principals_file)) - - with open(authorized_principals_file, 'r') as file: - authorized_principals = file.read() - for principal in principals: - self.assertIn(principal, authorized_principals) - - for principal in principals: - self.cli_delete( - base_path - + [ - 'trusted-user-ca-key', - 'bind-user', - bind_user, - 'principal', - principal, - ] - ) - - self.cli_delete( - base_path + ['trusted-user-ca-key', 'ca-certificate', ca_cert_name] - ) + # Verify generated SSH principals + authorized_principals_file = f'/home/{test_user}/.ssh/authorized_principals' + authorized_principals = read_file(authorized_principals_file, sudo=True) + self.assertIn(principal, authorized_principals) + + # Login with proper credentials + output, error = self.ssh_send_cmd(test_command, test_user, password=None, + key_filename=key_filename) + # Verify login + self.assertFalse(error) + self.assertEqual(output, cmd(test_command)) + + self.cli_delete(trusted_user_ca_path) + self.cli_delete(user_auth_base) self.cli_delete(['pki', 'ca', ca_cert_name]) - self.cli_delete(['system', 'login', 'user', test_user]) self.cli_commit() - # Verify the authorized principals file is removed - self.assertFalse(os.path.exists(authorized_principals_file)) + # Verify the CA key is removed + trusted_user_ca_config = get_config_value('TrustedUserCAKeys') + self.assertNotIn(trusted_user_ca, trusted_user_ca_config) + self.assertFalse(os.path.exists(trusted_user_ca)) + authorize_principals_file_config = get_config_value('AuthorizedPrincipalsFile') + self.assertNotIn('none', authorize_principals_file_config) + self.assertFalse(os.path.exists(f'/home/{test_user}/.ssh/authorized_principals')) if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/pki.py b/src/conf_mode/pki.py index 14fe86d56..7d01b6642 100755 --- a/src/conf_mode/pki.py +++ b/src/conf_mode/pki.py @@ -64,7 +64,7 @@ sync_search = [ 'path': ['service', 'https'], }, { - 'keys': ['ca_certificate'], + 'keys': ['key'], 'path': ['service', 'ssh'], }, { @@ -418,7 +418,8 @@ def verify(pki): if 'country' in default_values: country = default_values['country'] if len(country) != 2 or not country.isalpha(): - raise ConfigError(f'Invalid default country value. Value must be 2 alpha characters.') + raise ConfigError('Invalid default country value. '\ + 'Value must be 2 alpha characters.') if 'changed' in pki: # if the list is getting longer, we can move to a dict() and also embed the diff --git a/src/conf_mode/service_ssh.py b/src/conf_mode/service_ssh.py index f3c76508b..3d38d940a 100755 --- a/src/conf_mode/service_ssh.py +++ b/src/conf_mode/service_ssh.py @@ -23,14 +23,14 @@ from syslog import LOG_INFO from vyos.config import Config from vyos.configdict import is_node_changed from vyos.configverify import verify_vrf -from vyos.configverify import verify_pki_ca_certificate +from vyos.configverify import verify_pki_openssh_key +from vyos.defaults import config_files from vyos.utils.process import call from vyos.template import render from vyos import ConfigError from vyos import airbag -from vyos.pki import find_chain -from vyos.pki import encode_certificate -from vyos.pki import load_certificate +from vyos.pki import encode_public_key +from vyos.pki import load_openssh_public_key from vyos.utils.dict import dict_search_recursive from vyos.utils.file import write_file @@ -45,7 +45,7 @@ key_rsa = '/etc/ssh/ssh_host_rsa_key' key_dsa = '/etc/ssh/ssh_host_dsa_key' key_ed25519 = '/etc/ssh/ssh_host_ed25519_key' -trusted_user_ca_key = '/etc/ssh/trusted_user_ca_key' +trusted_user_ca = config_files['sshd_user_ca'] def get_config(config=None): if config: @@ -79,9 +79,9 @@ def get_config(config=None): get_first_key=True) for value, _ in dict_search_recursive(tmp, 'principal'): - # Only enable principal handling if SSH trusted-user-ca-key is set - if 'trusted_user_ca_key' in ssh: - ssh['trusted_user_ca_key'].update({'has_principals': {}}) + # Only enable principal handling if SSH trusted-user-ca is set + if 'trusted_user_ca' in ssh: + ssh['has_principals'] = {} # We do only need to execute this code path once as we need to know # if any one of the local users has a principal set or not - this # accounts for the entire system. @@ -97,16 +97,8 @@ def verify(ssh): if 'rekey' in ssh and 'data' not in ssh['rekey']: raise ConfigError('Rekey data is required!') - if 'trusted_user_ca_key' in ssh: - if 'ca_certificate' not in ssh['trusted_user_ca_key']: - raise ConfigError('CA certificate is mandatory when using ' \ - 'trusted-user-ca-key') - - ca_key_name = ssh['trusted_user_ca_key']['ca_certificate'] - verify_pki_ca_certificate(ssh, ca_key_name) - pki_ca_cert = ssh['pki']['ca'][ca_key_name] - if 'certificate' not in pki_ca_cert or not pki_ca_cert['certificate']: - raise ConfigError(f"CA certificate '{ca_key_name}' is not valid or missing") + if 'trusted_user_ca' in ssh: + verify_pki_openssh_key(ssh, ssh['trusted_user_ca']) verify_vrf(ssh) return None @@ -131,23 +123,17 @@ def generate(ssh): syslog(LOG_INFO, 'SSH ed25519 host key not found, generating new key!') call(f'ssh-keygen -q -N "" -t ed25519 -f {key_ed25519}') - if 'trusted_user_ca_key' in ssh: - ca_key_name = ssh['trusted_user_ca_key']['ca_certificate'] - pki_ca_cert = ssh['pki']['ca'][ca_key_name] - - loaded_ca_cert = load_certificate(pki_ca_cert['certificate']) - loaded_ca_certs = { - load_certificate(c['certificate']) - for c in ssh['pki']['ca'].values() - if 'certificate' in c - } - - ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs) - write_file(trusted_user_ca_key, - '\n'.join(encode_certificate(c) for c in ca_full_chain)) + if 'trusted_user_ca' in ssh: + key_name = ssh['trusted_user_ca'] + openssh_cert = ssh['pki']['openssh'][key_name] + loaded_ca_cert = load_openssh_public_key(openssh_cert['public']['key'], + openssh_cert['public']['type']) + tmp = encode_public_key(loaded_ca_cert, encoding='OpenSSH', + key_format='OpenSSH') + write_file(trusted_user_ca, tmp, trailing_newline=True) else: - if os.path.exists(trusted_user_ca_key): - os.unlink(trusted_user_ca_key) + if os.path.exists(trusted_user_ca): + os.unlink(trusted_user_ca) render(config_file, 'ssh/sshd_config.j2', ssh) diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py index 481fdd16e..22b6fcc98 100755 --- a/src/conf_mode/system_login.py +++ b/src/conf_mode/system_login.py @@ -375,14 +375,15 @@ def apply(login): chown(home_dir, user=user, recursive=True) # Generate 2FA/MFA One-Time-Pad configuration + google_auth_file = f'{home_dir}/.google_authenticator' if dict_search('authentication.otp.key', user_config): enable_otp = True - render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', + render(google_auth_file, 'login/pam_otp_ga.conf.j2', user_config, permission=0o400, user=user, group='users') else: # delete configuration as it's not enabled for the user - if os.path.exists(f'{home_dir}/.google_authenticator'): - os.remove(f'{home_dir}/.google_authenticator') + if os.path.exists(google_auth_file): + os.unlink(google_auth_file) # Lock/Unlock local user account lock_unlock = '--unlock' @@ -396,6 +397,22 @@ def apply(login): # Disable user to prevent re-login call(f'usermod -s /sbin/nologin {user}') + home_dir = getpwnam(user).pw_dir + # Remove SSH authorized keys file + authorized_keys_file = f'{home_dir}/.ssh/authorized_keys' + if os.path.exists(authorized_keys_file): + os.unlink(authorized_keys_file) + + # Remove SSH authorized principals file + principals_file = f'{home_dir}/.ssh/authorized_principals' + if os.path.exists(principals_file): + os.unlink(principals_file) + + # Remove Google Authenticator file + google_auth_file = f'{home_dir}/.google_authenticator' + if os.path.exists(google_auth_file): + os.unlink(google_auth_file) + # Logout user if he is still logged in if user in list(set([tmp[0] for tmp in users()])): print(f'{user} is logged in, forcing logout!') diff --git a/src/tests/test_template.py b/src/tests/test_template.py index 7cae867a0..4660c0038 100644 --- a/src/tests/test_template.py +++ b/src/tests/test_template.py @@ -192,10 +192,15 @@ class TestVyOSTemplate(TestCase): self.assertIn(IKEv2_DEFAULT, ','.join(ciphers)) def test_get_default_port(self): + from vyos.defaults import config_files from vyos.defaults import internal_ports + with self.assertRaises(RuntimeError): + vyos.template.get_default_config_file('UNKNOWN') with self.assertRaises(RuntimeError): vyos.template.get_default_port('UNKNOWN') + self.assertEqual(vyos.template.get_default_config_file('sshd_user_ca'), + config_files['sshd_user_ca']) self.assertEqual(vyos.template.get_default_port('certbot_haproxy'), internal_ports['certbot_haproxy']) -- cgit v1.2.3 From b73fc84c4ab50a4007ecdddc9417e2012d2ea11a Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 28 May 2025 19:49:31 -0500 Subject: http-api: T3955: add commit-confirm to endpoints /configure /config-file --- python/vyos/config_mgmt.py | 6 ++-- python/vyos/configsession.py | 21 ++++++++++- python/vyos/defaults.py | 2 ++ src/services/api/rest/models.py | 10 +++++- src/services/api/rest/routers.py | 76 +++++++++++++++++++++++++++++++++++----- 5 files changed, 101 insertions(+), 14 deletions(-) (limited to 'python') diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 308eb1cdb..9522dc08f 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -44,6 +44,7 @@ from vyos.utils.io import ask_yes_no from vyos.utils.boot import boot_configuration_complete from vyos.utils.process import is_systemd_service_active from vyos.utils.process import rc_cmd +from vyos.defaults import DEFAULT_COMMIT_CONFIRM_MINUTES SAVE_CONFIG = '/usr/libexec/vyos/vyos-save-config.py' config_json = '/run/vyatta/config/config.json' @@ -56,7 +57,6 @@ commit_hooks = { 'commit_archive': '02vyos-commit-archive', } -DEFAULT_TIME_MINUTES = 10 timer_name = 'commit-confirm' config_file = os.path.join(directories['config'], 'config.boot') @@ -183,7 +183,7 @@ class ConfigMgmt: # Console script functions # def commit_confirm( - self, minutes: int = DEFAULT_TIME_MINUTES, no_prompt: bool = False + self, minutes: int = DEFAULT_COMMIT_CONFIRM_MINUTES, no_prompt: bool = False ) -> Tuple[str, int]: """Commit with reload/reboot to saved config in 'minutes' minutes if 'confirm' call is not issued. @@ -807,7 +807,7 @@ def run(): '-t', dest='minutes', type=int, - default=DEFAULT_TIME_MINUTES, + default=DEFAULT_COMMIT_CONFIRM_MINUTES, help="Minutes until reboot, unless 'confirm'", ) commit_confirm.add_argument( diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 4e0dd23a4..f0d636b89 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -25,6 +25,7 @@ from vyos.utils.boot import boot_configuration_complete from vyos.utils.backend import vyconf_backend from vyos.vyconf_session import VyconfSession from vyos.base import Warning as Warn +from vyos.defaults import DEFAULT_COMMIT_CONFIRM_MINUTES CLI_SHELL_API = '/bin/cli-shell-api' @@ -32,6 +33,8 @@ SET = '/opt/vyatta/sbin/my_set' DELETE = '/opt/vyatta/sbin/my_delete' COMMENT = '/opt/vyatta/sbin/my_comment' COMMIT = '/opt/vyatta/sbin/my_commit' +COMMIT_CONFIRM = ['/usr/bin/config-mgmt', 'commit_confirm', '-y'] +CONFIRM = ['/usr/bin/config-mgmt', 'confirm'] DISCARD = '/opt/vyatta/sbin/my_discard' SHOW_CONFIG = ['/bin/cli-shell-api', 'showConfig'] LOAD_CONFIG = ['/bin/cli-shell-api', 'loadFile'] @@ -300,6 +303,22 @@ class ConfigSession(object): return out + def commit_confirm(self, minutes: int = DEFAULT_COMMIT_CONFIRM_MINUTES): + if self._vyconf_session is None: + out = self.__run_command(COMMIT_CONFIRM + [f'-t {minutes}']) + else: + out = 'unimplemented' + + return out + + def confirm(self): + if self._vyconf_session is None: + out = self.__run_command(CONFIRM) + else: + out = 'unimplemented' + + return out + def discard(self): if self._vyconf_session is None: self.__run_command([DISCARD]) @@ -344,7 +363,7 @@ class ConfigSession(object): if self._vyconf_session is None: out = self.__run_command(MERGE_CONFIG + [file_path]) else: - out, _ = 'unimplemented' + out = 'unimplemented' return out diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index e42d92112..b57dcac89 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -82,3 +82,5 @@ rt_global_vrf = rt_symbolic_names['main'] rt_global_table = rt_symbolic_names['main'] vyconfd_conf = '/etc/vyos/vyconfd.conf' + +DEFAULT_COMMIT_CONFIRM_MINUTES = 10 diff --git a/src/services/api/rest/models.py b/src/services/api/rest/models.py index 47c7a65b3..c5cb4af48 100644 --- a/src/services/api/rest/models.py +++ b/src/services/api/rest/models.py @@ -26,6 +26,7 @@ from typing import Self from pydantic import BaseModel from pydantic import StrictStr +from pydantic import StrictInt from pydantic import field_validator from pydantic import model_validator from fastapi.responses import HTMLResponse @@ -71,6 +72,8 @@ class BaseConfigureModel(BasePathModel): class ConfigureModel(ApiModel, BaseConfigureModel): + confirm_time: StrictInt = 0 + class Config: json_schema_extra = { 'example': { @@ -81,8 +84,12 @@ class ConfigureModel(ApiModel, BaseConfigureModel): } +class ConfirmModel(ApiModel): + op: StrictStr + class ConfigureListModel(ApiModel): commands: List[BaseConfigureModel] + confirm_time: StrictInt = 0 class Config: json_schema_extra = { @@ -135,12 +142,13 @@ class ConfigFileModel(ApiModel): op: StrictStr file: StrictStr = None string: StrictStr = None + confirm_time: StrictInt = 0 class Config: json_schema_extra = { 'example': { 'key': 'id_key', - 'op': 'save | load | merge', + 'op': 'save | load | merge | confirm', 'file': 'filename', 'string': 'config_string' } diff --git a/src/services/api/rest/routers.py b/src/services/api/rest/routers.py index 4866ec5d8..a2e6b4178 100644 --- a/src/services/api/rest/routers.py +++ b/src/services/api/rest/routers.py @@ -51,6 +51,7 @@ from .models import error from .models import responses from .models import ApiModel from .models import ConfigureModel +from .models import ConfirmModel from .models import ConfigureListModel from .models import ConfigSectionModel from .models import ConfigSectionListModel @@ -302,8 +303,24 @@ def call_commit(s: SessionState): LOG.warning(f'ConfigSessionError: {e}') +def call_commit_confirm(s: SessionState): + env = s.session.get_session_env() + env['IN_COMMIT_CONFIRM'] = 't' + try: + s.session.commit() + except ConfigSessionError as e: + s.session.discard() + if s.debug: + LOG.warning(f'ConfigSessionError:\n {traceback.format_exc()}') + else: + LOG.warning(f'ConfigSessionError: {e}') + finally: + del env['IN_COMMIT_CONFIRM'] + + def _configure_op( data: Union[ + ConfirmModel, ConfigureModel, ConfigureListModel, ConfigSectionModel, @@ -320,6 +337,11 @@ def _configure_op( session = state.session env = session.get_session_env() + # A non-zero confirm_time will start commit-confirm timer on commit + confirm_time = 0 + if isinstance(data, (ConfigureModel, ConfigureListModel, ConfigFileModel)): + confirm_time = data.confirm_time + # Allow users to pass just one command if not isinstance(data, (ConfigureListModel, ConfigSectionListModel)): data = [data] @@ -339,10 +361,16 @@ def _configure_op( try: for c in data: op = c.op - if not isinstance(c, BaseConfigSectionTreeModel): + if not isinstance(c, (ConfirmModel, BaseConfigSectionTreeModel)): path = c.path - if isinstance(c, BaseConfigureModel): + if isinstance(c, ConfirmModel): + if op == 'confirm': + msg = session.confirm() + else: + raise ConfigSessionError(f"'{op}' is not a valid operation") + + elif isinstance(c, BaseConfigureModel): if c.value: value = c.value else: @@ -388,16 +416,26 @@ def _configure_op( else: raise ConfigSessionError(f"'{op}' is not a valid operation") # end for + config = Config(session_env=env) d = get_config_diff(config) + if confirm_time: + out = session.commit_confirm(minutes=confirm_time) + msg = msg + out if msg else out + env['IN_COMMIT_CONFIRM'] = 't' + if d.is_node_changed(['service', 'https']): - background_tasks.add_task(call_commit, state) - msg = self_ref_msg + if confirm_time: + background_tasks.add_task(call_commit_confirm, state) + else: + background_tasks.add_task(call_commit, state) + out = self_ref_msg + msg = msg + out if msg else out else: # capture non-fatal warnings out = session.commit() - msg = out if out else msg + msg = msg + out if msg else out LOG.info(f"Configuration modified via HTTP API using key '{state.id}'") except ConfigSessionError as e: @@ -414,6 +452,8 @@ def _configure_op( # Don't give the details away to the outer world error_msg = 'An internal error occured. Check the logs for details.' finally: + if 'IN_COMMIT_CONFIRM' in env: + del env['IN_COMMIT_CONFIRM'] lock.release() if status != 200: @@ -433,7 +473,7 @@ def create_path_import_pki_no_prompt(path): @router.post('/configure') def configure_op( - data: Union[ConfigureModel, ConfigureListModel], + data: Union[ConfigureModel, ConfigureListModel, ConfirmModel], request: Request, background_tasks: BackgroundTasks, ): @@ -501,6 +541,8 @@ def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): op = data.op msg = None + lock.acquire() + try: if op == 'save': if data.file: @@ -527,11 +569,23 @@ def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): config = Config(session_env=env) d = get_config_diff(config) + if data.confirm_time: + out = session.commit_confirm(minutes=data.confirm_time) + msg = msg + out if msg else out + env['IN_COMMIT_CONFIRM'] = 't' + if d.is_node_changed(['service', 'https']): - background_tasks.add_task(call_commit, state) - msg = self_ref_msg + if data.confirm_time: + background_tasks.add_task(call_commit_confirm, state) + else: + background_tasks.add_task(call_commit, state) + out = self_ref_msg + msg = msg + out if msg else out else: - session.commit() + out = session.commit() + msg = msg + out if msg else out + elif op == 'confirm': + msg = session.confirm() else: return error(400, f"'{op}' is not a valid operation") except ConfigSessionError as e: @@ -539,6 +593,10 @@ def config_file_op(data: ConfigFileModel, background_tasks: BackgroundTasks): except Exception: LOG.critical(traceback.format_exc()) return error(500, 'An internal error occured. Check the logs for details.') + finally: + if 'IN_COMMIT_CONFIRM' in env: + del env['IN_COMMIT_CONFIRM'] + lock.release() return success(msg) -- cgit v1.2.3 From 712c10a3f3715c563d8646113ab2cc1f011529ed Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 30 May 2025 11:53:08 -0500 Subject: config-mgmt: T7508: use recursive defaults to read commit-confirm action --- python/vyos/config_mgmt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 308eb1cdb..186fdd223 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -144,7 +144,7 @@ class ConfigMgmt: ['system', 'config-management'], key_mangling=('-', '_'), get_first_key=True, - with_defaults=True, + with_recursive_defaults=True, ) self.max_revisions = int(d.get('commit_revisions', 0)) -- cgit v1.2.3 From 983d296eafe97a21cf2c326aa1267edfd5308d37 Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Thu, 5 Jun 2025 11:32:41 +0300 Subject: T7515: Fix VPP NAT44 timeouts --- python/vyos/vpp/control_vpp.py | 19 ------------------- python/vyos/vpp/nat/nat44.py | 9 +++++++++ smoketest/scripts/cli/test_vpp.py | 12 +++++------- src/conf_mode/vpp.py | 7 ------- src/conf_mode/vpp_nat.py | 15 +++++++++++++++ 5 files changed, 29 insertions(+), 33 deletions(-) (limited to 'python') diff --git a/python/vyos/vpp/control_vpp.py b/python/vyos/vpp/control_vpp.py index 5db0ea560..5959b68b5 100644 --- a/python/vyos/vpp/control_vpp.py +++ b/python/vyos/vpp/control_vpp.py @@ -448,25 +448,6 @@ class VPPControl: """ self.__vpp_api_client.api.nat44_forwarding_enable_disable(enable=enable) - @_Decorators.api_call - def set_nat_timeouts( - self, icmp: int, udp: int, tcp_established: int, tcp_transitory: int - ) -> None: - """Set NAT timeouts - - Args: - tcp_established (int): TCP established timeout - tcp_transitory (int): TCP transitory timeout - udp (int): UDP timeout - icmp (int): ICMP timeout - """ - self.__vpp_api_client.api.nat_set_timeouts( - icmp=icmp, - udp=udp, - tcp_established=tcp_established, - tcp_transitory=tcp_transitory, - ) - @_Decorators.api_call def set_nat44_session_limit(self, session_limit: int) -> None: """Set NAT44 session limit diff --git a/python/vyos/vpp/nat/nat44.py b/python/vyos/vpp/nat/nat44.py index e01e05153..6b02688aa 100644 --- a/python/vyos/vpp/nat/nat44.py +++ b/python/vyos/vpp/nat/nat44.py @@ -215,6 +215,15 @@ class Nat44: is_add=False, ) + def set_nat_timeouts(self, icmp, udp, tcp_established, tcp_transitory): + """Set NAT timeouts""" + self.vpp.api.nat_set_timeouts( + icmp=icmp, + udp=udp, + tcp_established=tcp_established, + tcp_transitory=tcp_transitory, + ) + def enable_ipfix(self): """Enable NAT44 IPFIX logging""" self.vpp.api.nat44_ei_ipfix_enable_disable(enable=True) diff --git a/smoketest/scripts/cli/test_vpp.py b/smoketest/scripts/cli/test_vpp.py index 7a150f7e8..d8f7f3473 100755 --- a/smoketest/scripts/cli/test_vpp.py +++ b/smoketest/scripts/cli/test_vpp.py @@ -1373,13 +1373,11 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.assertIn(f'{exclude_local_addr}:{exclude_local_port} vrf 0', out) # Check timeouts - # skipped due to https://vyos.dev/T7515 - # - # _, out = rc_cmd('sudo vppctl show nat44 ei timeouts') - # self.assertIn(f'udp timeout: {timeout_udp}sec', out) - # self.assertIn(f'tcp established timeout: {timeout_tcp_est}sec', out) - # self.assertIn(f'tcp transitory timeout: {timeout_tcp_trans}sec', out) - # self.assertIn(f'icmp timeout: {timeout_icmp}sec', out) + _, out = rc_cmd('sudo vppctl show nat timeouts') + self.assertIn(f'udp timeout: {timeout_udp}sec', out) + self.assertIn(f'tcp-established timeout: {timeout_tcp_est}sec', out) + self.assertIn(f'tcp-transitory timeout: {timeout_tcp_trans}sec', out) + self.assertIn(f'icmp timeout: {timeout_icmp}sec', out) # Summary _, out = rc_cmd('sudo vppctl show nat44 summary') diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index ed0e80c2c..029adbdec 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -745,13 +745,6 @@ def apply(config): enable_forwarding = False vpp_control.enable_disable_nat44_forwarding(enable_forwarding) - vpp_control.set_nat_timeouts( - icmp=int(nat44_settings.get('timeout').get('icmp')), - udp=int(nat44_settings.get('timeout').get('udp')), - tcp_established=int(nat44_settings.get('timeout').get('tcp_established')), - tcp_transitory=int(nat44_settings.get('timeout').get('tcp_transitory')), - ) - vpp_control.set_nat44_session_limit(int(nat44_settings.get('session_limit'))) if nat44_settings.get('workers'): diff --git a/src/conf_mode/vpp_nat.py b/src/conf_mode/vpp_nat.py index a6bc70032..77b9400e3 100644 --- a/src/conf_mode/vpp_nat.py +++ b/src/conf_mode/vpp_nat.py @@ -104,6 +104,14 @@ def get_config(config=None) -> dict: } ) + if conf.exists(['vpp', 'settings', 'nat44', 'timeout']): + timeouts = conf.get_config_dict( + ['vpp', 'settings', 'nat44', 'timeout'], + key_mangling=('-', '_'), + with_defaults=True, + ) + config.update(timeouts) + if effective_config: config.update({'effective': effective_config}) @@ -453,6 +461,13 @@ def apply(config): port=int(rule_config.get('local_port', 0)), interface=rule_config.get('external_interface'), ) + if 'timeout' in config: + n.set_nat_timeouts( + icmp=int(config.get('timeout').get('icmp')), + udp=int(config.get('timeout').get('udp')), + tcp_established=int(config.get('timeout').get('tcp_established')), + tcp_transitory=int(config.get('timeout').get('tcp_transitory')), + ) if __name__ == '__main__': -- cgit v1.2.3 From 372fe641f0f7b0c068c8da75c2782cbec7a5d8b2 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Mon, 26 May 2025 19:18:59 -0500 Subject: T7365: call commit hooks in vyconf session --- python/vyos/defaults.py | 4 ++++ python/vyos/utils/commit.py | 27 +++++++++++++++++++++++++++ python/vyos/vyconf_session.py | 6 +++++- 3 files changed, 36 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index b57dcac89..f84b14040 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -84,3 +84,7 @@ rt_global_table = rt_symbolic_names['main'] vyconfd_conf = '/etc/vyos/vyconfd.conf' DEFAULT_COMMIT_CONFIRM_MINUTES = 10 + +commit_hooks = {'pre': '/etc/commit/pre-hooks.d', + 'post': '/etc/commit/post-hooks.d' + } diff --git a/python/vyos/utils/commit.py b/python/vyos/utils/commit.py index 9167c78d2..fc259dadb 100644 --- a/python/vyos/utils/commit.py +++ b/python/vyos/utils/commit.py @@ -101,3 +101,30 @@ def release_commit_lock_file(file_descr): return fcntl.lockf(file_descr, fcntl.LOCK_UN) file_descr.close() + + +def call_commit_hooks(which: str): + import re + import os + from pathlib import Path + from vyos.defaults import commit_hooks + from vyos.utils.process import rc_cmd + + if which not in list(commit_hooks): + raise ValueError(f'no entry {which} in commit_hooks') + + hook_dir = commit_hooks[which] + file_list = list(Path(hook_dir).glob('*')) + regex = re.compile('^[a-zA-Z0-9._-]+$') + hook_list = sorted([str(f) for f in file_list if regex.match(f.name)]) + err = False + out = '' + for runf in hook_list: + try: + e, o = rc_cmd(runf) + except FileNotFoundError: + continue + err = err | bool(e) + out = out + o + + return out, int(err) diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index 4250f0cfb..747aaf932 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -29,6 +29,7 @@ from vyos.utils.session import in_config_session from vyos.proto.vyconf_proto import Errnum from vyos.utils.commit import acquire_commit_lock_file from vyos.utils.commit import release_commit_lock_file +from vyos.utils.commit import call_commit_hooks class VyconfSessionError(Exception): @@ -145,10 +146,13 @@ class VyconfSession: if lock_fd is None: return out, Errnum.COMMIT_IN_PROGRESS + pre_out, _ = call_commit_hooks('pre') out = vyconf_client.send_request('commit', token=self.__token) + post_out, _ = call_commit_hooks('post') + release_commit_lock_file(lock_fd) - return self.output(out), out.status + return pre_out + self.output(out) + post_out, out.status @raise_exception @config_mode -- cgit v1.2.3 From 2461baedaba130105c2578156ea13ca54ccb7603 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Sun, 25 May 2025 18:00:07 -0500 Subject: T7365: add env var used by post-commit scripts --- python/vyos/vyconf_session.py | 1 + 1 file changed, 1 insertion(+) (limited to 'python') diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index 747aaf932..3cf847b6c 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -148,6 +148,7 @@ class VyconfSession: pre_out, _ = call_commit_hooks('pre') out = vyconf_client.send_request('commit', token=self.__token) + os.environ['COMMIT_STATUS'] = 'FAILURE' if out.status else 'SUCCESS' post_out, _ = call_commit_hooks('post') release_commit_lock_file(lock_fd) -- cgit v1.2.3 From 00cb7fd19587771129c9923a781488929c03f3f8 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 23 May 2025 10:40:40 -0500 Subject: T7488: add utility for automatic rollback of section on apply stage err --- python/vyos/configsession.py | 6 ++- src/helpers/reset_section.py | 102 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100755 src/helpers/reset_section.py (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index f0d636b89..7af2cb333 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -146,7 +146,7 @@ class ConfigSession(object): The write API of VyOS. """ - def __init__(self, session_id, app=APP): + def __init__(self, session_id, app=APP, shared=False): """ Creates a new config session. @@ -187,7 +187,11 @@ class ConfigSession(object): else: self._vyconf_session = None + self.shared = shared + def __del__(self): + if self.shared: + return if self._vyconf_session is None: try: output = ( diff --git a/src/helpers/reset_section.py b/src/helpers/reset_section.py new file mode 100755 index 000000000..22b608f00 --- /dev/null +++ b/src/helpers/reset_section.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2025 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# + + +import argparse +import sys +import os + +from vyos.configsession import ConfigSession +from vyos.config import Config +from vyos.configdiff import get_config_diff +from vyos.xml_ref import is_leaf + + +def type_str_to_list(value): + if isinstance(value, str): + return value.split() + raise argparse.ArgumentTypeError('path must be a whitespace separated string') + + +parser = argparse.ArgumentParser() +parser.add_argument('path', type=type_str_to_list, help='section to reload/rollback') +parser.add_argument('--pid', help='pid of config session') + +group = parser.add_mutually_exclusive_group() +group.add_argument('--reload', action='store_true', help='retry proposed commit') +group.add_argument( + '--rollback', action='store_true', default=True, help='rollback to stable commit' +) + +args = parser.parse_args() + +path = args.path +reload = args.reload +rollback = args.rollback +pid = args.pid + +try: + if is_leaf(path): + sys.exit('path is leaf node: neither allowed nor useful') +except ValueError: + sys.exit('nonexistent path: neither allowed nor useful') + +test = Config() +if not test.in_session(): + sys.exit('reset_section not available outside of a config session') + +diff = get_config_diff(test) +if not diff.is_node_changed(path): + # No discrepancies at path after commit, hence no error to revert. + sys.exit() + +del diff +del test + + +session_id = int(pid) if pid else os.getppid() + +# check hint left by vyshim when ConfigError is from apply stage +hint_name = f'/tmp/apply_{session_id}' +if not os.path.exists(hint_name): + # no apply error; exit + sys.exit() +else: + # cleanup hint and continue with reset + os.unlink(hint_name) + +session = ConfigSession(session_id, shared=True) + +session_env = session.get_session_env() +config = Config(session_env) + +effective = not bool(reload) + +d = config.get_config_dict(path, effective=effective, get_first_key=True) + +session.discard() + +session.delete(path) +session.commit() + +if not d: + # nothing more to do in either case of reload/rollback + sys.exit() + +session.set_section(path, d) +session.commit() -- cgit v1.2.3 From 8dbc3c5e67cc1fd043a78dd3446a1a733ebd814f Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Fri, 13 Jun 2025 12:20:40 +0300 Subject: firewall: T6951: Add a configuration command for ethertypes that bridge firewalls should always accept --- data/templates/firewall/nftables.j2 | 14 ++--- .../include/firewall/global-options.xml.i | 49 ++++++++++++++++-- .../include/version/firewall-version.xml.i | 2 +- python/vyos/template.py | 23 +++++++++ .../config-tests/firewall-bridged-global-options | 21 ++++++++ smoketest/configs/firewall-bridged-global-options | 60 ++++++++++++++++++++++ smoketest/scripts/cli/test_firewall.py | 8 ++- src/migration-scripts/firewall/18-to-19 | 35 +++++++++++++ src/tests/test_template.py | 4 ++ 9 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 smoketest/config-tests/firewall-bridged-global-options create mode 100644 smoketest/configs/firewall-bridged-global-options create mode 100644 src/migration-scripts/firewall/18-to-19 (limited to 'python') diff --git a/data/templates/firewall/nftables.j2 b/data/templates/firewall/nftables.j2 index bf051bb57..39ef72059 100755 --- a/data/templates/firewall/nftables.j2 +++ b/data/templates/firewall/nftables.j2 @@ -410,15 +410,11 @@ table bridge vyos_filter { {% for prior, conf in bridge.output.items() %} chain VYOS_OUTPUT_{{ prior }} { type filter hook output priority {{ prior }}; policy accept; -{% if global_options.apply_to_bridged_traffic is vyos_defined %} -{% if 'invalid_connections' in global_options.apply_to_bridged_traffic %} - ct state invalid udp sport 67 udp dport 68 counter accept - ct state invalid ether type arp counter accept - ct state invalid ether type 8021q counter accept - ct state invalid ether type 8021ad counter accept - ct state invalid ether type 0x8863 counter accept - ct state invalid ether type 0x8864 counter accept - ct state invalid ether type 0x0842 counter accept +{% if global_options.apply_to_bridged_traffic.accept_invalid is vyos_defined %} +{% if 'ethernet_type' in global_options.apply_to_bridged_traffic.accept_invalid %} +{% for ether_type in global_options.apply_to_bridged_traffic.accept_invalid.ethernet_type %} + {{ ether_type | nft_accept_invalid() }} +{% endfor %} {% endif %} {% endif %} {% if global_options.state_policy is vyos_defined %} diff --git a/interface-definitions/include/firewall/global-options.xml.i b/interface-definitions/include/firewall/global-options.xml.i index 794da4f9d..e19f3a7c5 100644 --- a/interface-definitions/include/firewall/global-options.xml.i +++ b/interface-definitions/include/firewall/global-options.xml.i @@ -49,12 +49,53 @@ Apply configured firewall rules to traffic switched by bridges - + - Accept ARP, 802.1q, 802.1ad, DHCP, PPPoE and WoL despite being marked as invalid connections - + Accept connections despite they are marked as invalid - + + + + Ethernet type + + arp dhcp pppoe 802.1q 802.1ad pppoe-discovery wol + + + arp + Adress Resolution Protocol (ARP) + + + dhcp + Dynamic Host Configuration Protocol (DHCP) + + + pppoe + Point to Point over Ethernet (PPPoE) Session + + + pppoe-discovery + PPPoE Discovery + + + 802.1q + Customer VLAN tag type (802.1Q) + + + 802.1ad + Service VLAN tag type (802.1ad) + + + wol + Wake-on-LAN magic packet + + + (arp|dhcp|pppoe|pppoe-discovery|802.1q|802.1ad|wol) + + + + + + Apply configured IPv4 firewall rules diff --git a/interface-definitions/include/version/firewall-version.xml.i b/interface-definitions/include/version/firewall-version.xml.i index 1a8098297..1f3b779d5 100644 --- a/interface-definitions/include/version/firewall-version.xml.i +++ b/interface-definitions/include/version/firewall-version.xml.i @@ -1,3 +1,3 @@ - + diff --git a/python/vyos/template.py b/python/vyos/template.py index bf7928914..bf2f13183 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -674,6 +674,29 @@ def nft_nested_group(out_list, includes, groups, key): add_includes(name) return out_list +@register_filter('nft_accept_invalid') +def nft_accept_invalid(ether_type): + ether_type_mapping = { + 'dhcp': 'udp sport 67 udp dport 68', + 'arp': 'arp', + 'pppoe-discovery': '0x8863', + 'pppoe': '0x8864', + '802.1q': '8021q', + '802.1ad': '8021ad', + 'wol': '0x0842', + } + if ether_type not in ether_type_mapping: + raise RuntimeError(f'Ethernet type "{ether_type}" not found in ' \ + 'available ethernet types!') + out = 'ct state invalid ' + + if ether_type != 'dhcp': + out += 'ether type ' + + out += f'{ether_type_mapping[ether_type]} counter accept' + + return out + @register_filter('nat_rule') def nat_rule(rule_conf, rule_id, nat_type, ipv6=False): from vyos.nat import parse_nat_rule diff --git a/smoketest/config-tests/firewall-bridged-global-options b/smoketest/config-tests/firewall-bridged-global-options new file mode 100644 index 000000000..1d960d6c1 --- /dev/null +++ b/smoketest/config-tests/firewall-bridged-global-options @@ -0,0 +1,21 @@ +set firewall bridge prerouting filter rule 10 action 'accept' +set firewall bridge prerouting filter rule 10 ethernet-type 'arp' +set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type 'dhcp' +set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type 'arp' +set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type 'pppoe-discovery' +set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type 'pppoe' +set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type '802.1q' +set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type '802.1ad' +set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type 'wol' +set firewall global-options state-policy established action 'accept' +set firewall global-options state-policy invalid action 'drop' +set firewall global-options state-policy related action 'accept' +set interfaces ethernet eth0 duplex 'auto' +set interfaces ethernet eth0 speed 'auto' +set interfaces ethernet eth1 duplex 'auto' +set interfaces ethernet eth1 speed 'auto' +set system console device ttyS0 speed '115200' +set system domain-name 'vyos-ci-test.net' +set system host-name 'vyos' +set system login user vyos authentication encrypted-password '$6$O5gJRlDYQpj$MtrCV9lxMnZPMbcxlU7.FI793MImNHznxGoMFgm3Q6QP3vfKJyOSRCt3Ka/GzFQyW1yZS4NS616NLHaIPPFHc0' +set system login user vyos authentication plaintext-password '' diff --git a/smoketest/configs/firewall-bridged-global-options b/smoketest/configs/firewall-bridged-global-options new file mode 100644 index 000000000..a7e1428d8 --- /dev/null +++ b/smoketest/configs/firewall-bridged-global-options @@ -0,0 +1,60 @@ +firewall { + bridge { + prerouting { + filter { + rule 10 { + action "accept" + ethernet-type "arp" + } + } + } + } + global-options { + apply-to-bridged-traffic { + invalid-connections { + } + } + state-policy { + established { + action "accept" + } + invalid { + action "drop" + } + related { + action "accept" + } + } + } +} +interfaces { + ethernet eth0 { + duplex "auto" + speed "auto" + } + ethernet eth1 { + duplex auto + speed auto + } +} +system { + console { + device ttyS0 { + speed 115200 + } + } + domain-name vyos-ci-test.net + host-name vyos + login { + user vyos { + authentication { + encrypted-password $6$O5gJRlDYQpj$MtrCV9lxMnZPMbcxlU7.FI793MImNHznxGoMFgm3Q6QP3vfKJyOSRCt3Ka/GzFQyW1yZS4NS616NLHaIPPFHc0 + plaintext-password "" + } + } + } +} + +// Warning: Do not remove the following line. +// vyos-config-version: "bgp@6:broadcast-relay@1:cluster@2:config-management@1:conntrack@6:conntrack-sync@2:container@2:dhcp-relay@2:dhcp-server@11:dhcpv6-server@6:dns-dynamic@4:dns-forwarding@4:firewall@18:flow-accounting@2:https@7:ids@2:interfaces@33:ipoe-server@4:ipsec@13:isis@3:l2tp@9:lldp@3:mdns@1:monitoring@2:nat@8:nat66@3:nhrp@1:ntp@3:openconnect@3:openvpn@4:ospf@2:pim@1:policy@9:pppoe-server@11:pptp@5:qos@3:quagga@12:reverse-proxy@3:rip@1:rpki@2:salt@1:snmp@3:ssh@2:sstp@6:system@29:vpp@1:vrf@3:vrrp@4:vyos-accel-ppp@2:wanloadbalance@4:webproxy@2" +// Release version: 2025.06.17-0020-rolling diff --git a/smoketest/scripts/cli/test_firewall.py b/smoketest/scripts/cli/test_firewall.py index 2d850dfdf..455c704d0 100755 --- a/smoketest/scripts/cli/test_firewall.py +++ b/smoketest/scripts/cli/test_firewall.py @@ -728,7 +728,13 @@ class TestFirewall(VyOSUnitTestSHIM.TestCase): self.cli_set(['firewall', 'group', 'ipv6-address-group', 'AGV6', 'address', '2001:db1::1']) self.cli_set(['firewall', 'global-options', 'state-policy', 'established', 'action', 'accept']) self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'ipv4']) - self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'invalid-connections']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'accept-invalid', 'ethernet-type', 'dhcp']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'accept-invalid', 'ethernet-type', 'arp']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'accept-invalid', 'ethernet-type', 'pppoe']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'accept-invalid', 'ethernet-type', 'pppoe-discovery']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'accept-invalid', 'ethernet-type', '802.1q']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'accept-invalid', 'ethernet-type', '802.1ad']) + self.cli_set(['firewall', 'global-options', 'apply-to-bridged-traffic', 'accept-invalid', 'ethernet-type', 'wol']) self.cli_set(['firewall', 'bridge', 'name', name, 'default-action', 'accept']) self.cli_set(['firewall', 'bridge', 'name', name, 'default-log']) diff --git a/src/migration-scripts/firewall/18-to-19 b/src/migration-scripts/firewall/18-to-19 new file mode 100644 index 000000000..3564e0e01 --- /dev/null +++ b/src/migration-scripts/firewall/18-to-19 @@ -0,0 +1,35 @@ +# Copyright (C) 2024-2025 VyOS maintainers and contributors +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see . + +# From +# set firewall global-options apply-to-bridged-traffic invalid-connections +# To +# set firewall global-options apply-to-bridged-traffic accept-invalid ethernet-type + +from vyos.configtree import ConfigTree + +base = ['firewall', 'global-options', 'apply-to-bridged-traffic'] + +def migrate(config: ConfigTree) -> None: + if not config.exists(base + ['invalid-connections']): + # Nothing to do + return + + ether_types = ['dhcp', 'arp', 'pppoe-discovery', 'pppoe', '802.1q', '802.1ad', 'wol'] + + for ether_type in ether_types: + config.set(base + ['accept-invalid', 'ethernet-type'], value=ether_type, replace=False) + + config.delete(base + ['invalid-connections']) diff --git a/src/tests/test_template.py b/src/tests/test_template.py index 4660c0038..09315d398 100644 --- a/src/tests/test_template.py +++ b/src/tests/test_template.py @@ -199,8 +199,12 @@ class TestVyOSTemplate(TestCase): vyos.template.get_default_config_file('UNKNOWN') with self.assertRaises(RuntimeError): vyos.template.get_default_port('UNKNOWN') + with self.assertRaises(RuntimeError): + vyos.template.nft_accept_invalid('UNKNOWN') self.assertEqual(vyos.template.get_default_config_file('sshd_user_ca'), config_files['sshd_user_ca']) self.assertEqual(vyos.template.get_default_port('certbot_haproxy'), internal_ports['certbot_haproxy']) + self.assertEqual(vyos.template.nft_accept_invalid('arp'), + 'ct state invalid ether type arp counter accept') -- cgit v1.2.3 From 858716418d281791214eb01e4495f99e46dd1f59 Mon Sep 17 00:00:00 2001 From: factor2431 Date: Wed, 18 Jun 2025 15:56:10 +0800 Subject: T7554: fix wireguard fwmark parsing --- python/vyos/ifconfig/wireguard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 3a28723b3..6b5e52412 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -52,7 +52,7 @@ class WireGuardOperational(Operational): 'private_key': None if private_key == '(none)' else private_key, 'public_key': None if public_key == '(none)' else public_key, 'listen_port': int(listen_port), - 'fw_mark': None if fw_mark == 'off' else int(fw_mark), + 'fw_mark': None if fw_mark == 'off' else int(fw_mark, 16), 'peers': {}, } else: -- cgit v1.2.3 From 516ea43279d750272be398a27948049581b22630 Mon Sep 17 00:00:00 2001 From: zdc Date: Wed, 18 Jun 2025 11:51:34 +0300 Subject: XDP/Mellanox: T7223: Fixed interfaces initialization For the XDP driver and Mellanox NIC, we create `defunct_*` interfaces to hide original interfaces from CLI, when they are replaced with VPP-enabled pairs. But sometimes IP addresses are not flushed from these `defunct_*` interfaces, which leads to broken routing afterward. This commit introduces an additional flush operation for `defunct_*` interfaces to ensure that they do not conflict with VPP interfaces. --- python/vyos/vpp/control_host.py | 12 ++++++++++++ src/conf_mode/vpp.py | 2 ++ 2 files changed, 14 insertions(+) (limited to 'python') diff --git a/python/vyos/vpp/control_host.py b/python/vyos/vpp/control_host.py index ead9f6968..1ac004fbb 100644 --- a/python/vyos/vpp/control_host.py +++ b/python/vyos/vpp/control_host.py @@ -21,6 +21,8 @@ from re import fullmatch as re_fullmatch from subprocess import run from time import sleep +from pyroute2 import IPRoute + from vyos.vpp.utils import EthtoolGDrvinfo @@ -361,3 +363,13 @@ def set_status(iface_name: str, status: str) -> None: status (str): status - "up" or "down" """ run(['ip', 'link', 'set', iface_name, status]) + + +def flush_ip(iface_name: str) -> None: + """Flush IP addresses from an interface + + Args: + iface_name (str): name of an interface + """ + iproute = IPRoute() + iproute.flush_addr(label=iface_name) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index 76aa7c5af..d5778c778 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -693,6 +693,7 @@ def apply(config): if 'promisc' in iface_config['xdp_options']: control_host.set_promisc(f'defunct_{iface}', 'on') control_host.set_status(f'defunct_{iface}', 'up') + control_host.flush_ip(f'defunct_{iface}') # Rename Mellanox interfaces to hide them and create LCP properly if ( iface in Section.interfaces() @@ -700,6 +701,7 @@ def apply(config): ): control_host.rename_iface(iface, f'defunct_{iface}') control_host.set_status(f'defunct_{iface}', 'up') + control_host.flush_ip(f'defunct_{iface}') # Create lcp if iface not in Section.interfaces(): vpp_control.lcp_pair_add(iface, iface) -- cgit v1.2.3 From e5f3f1b2bb6cadaa7586beff100f83e4ff9159db Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 13 Jun 2025 07:31:40 -0500 Subject: T7561: simplify op-mode-definitions XML cache and add interface methods The original implementation of the op-mode XML cache generation resulted in a structure that was difficult to use, for example, in documentation generation. The source of complication is that, unlike the XML of interface-definitions, path names are not unique: the same path may occur as both a regular node and as a tag node. Here we simplify the underlying structure by enriching path names with type information, thus disambiguating paths. An interface to the cache is provided by explicit generator and lookup functions. --- python/vyos/xml_ref/__init__.py | 28 ++++- python/vyos/xml_ref/generate_op_cache.py | 134 ++++++++++++++++++----- python/vyos/xml_ref/op_definition.py | 181 +++++++++++++++++++++++++++---- 3 files changed, 294 insertions(+), 49 deletions(-) (limited to 'python') diff --git a/python/vyos/xml_ref/__init__.py b/python/vyos/xml_ref/__init__.py index 99d8432d2..cd50a3ec2 100644 --- a/python/vyos/xml_ref/__init__.py +++ b/python/vyos/xml_ref/__init__.py @@ -14,6 +14,8 @@ # along with this library. If not, see . from typing import Optional, Union, TYPE_CHECKING +from typing import Callable +from typing import Any from vyos.xml_ref import definition from vyos.xml_ref import op_definition @@ -89,6 +91,7 @@ def from_source(d: dict, path: list) -> bool: def ext_dict_merge(source: dict, destination: Union[dict, 'ConfigDict']): return definition.ext_dict_merge(source, destination) + def load_op_reference(op_cache=[]): if op_cache: return op_cache[0] @@ -108,5 +111,26 @@ def load_op_reference(op_cache=[]): return op_xml -def get_op_ref_path(path: list) -> list[op_definition.PathData]: - return load_op_reference()._get_op_ref_path(path) + +def walk_op_data(func: Callable[[tuple, dict], Any]): + return load_op_reference().walk(func) + + +def walk_op_node_data(): + return load_op_reference().walk_node_data() + + +def lookup_op_data( + path: list, tag_values: bool = False, last_node_type: str = '' +) -> (dict, list[str]): + return load_op_reference().lookup( + path, tag_values=tag_values, last_node_type=last_node_type + ) + + +def lookup_op_node_data( + path: list, tag_values: bool = False, last_node_type: str = '' +) -> list[op_definition.NodeData]: + return load_op_reference().lookup_node_data( + path, tag_values=tag_values, last_node_type=last_node_type + ) diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index 95779d066..c00e676df 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -14,10 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import io import re import sys -import json import glob +import atexit from argparse import ArgumentParser from os.path import join @@ -25,23 +26,40 @@ from os.path import abspath from os.path import dirname from xml.etree import ElementTree as ET from xml.etree.ElementTree import Element +from functools import cmp_to_key from typing import TypeAlias from typing import Optional +from op_definition import NodeData +from op_definition import OpKey # pylint: disable=unused-import # noqa: F401 +from op_definition import OpData # pylint: disable=unused-import # noqa: F401 +from op_definition import key_name +from op_definition import key_type + _here = dirname(__file__) sys.path.append(join(_here, '..')) -from defaults import directories - -from op_definition import PathData +# pylint: disable=wrong-import-position,wrong-import-order +from defaults import directories # noqa: E402 -xml_op_cache_json = 'xml_op_cache.json' -xml_op_tmp = join('/tmp', xml_op_cache_json) op_ref_cache = abspath(join(_here, 'op_cache.py')) OptElement: TypeAlias = Optional[Element] -DEBUG = False + + +# It is expected that the node_data help txt contained in top-level nodes, +# shared across files, e.g.'show', will reveal inconsistencies; to list +# differences, use --check-xml-consistency +CHECK_XML_CONSISTENCY = False +err_buf = io.StringIO() + + +def write_err_buf(): + err_buf.seek(0) + out = err_buf.read() + print(out) + err_buf.close() def translate_exec(s: str) -> str: @@ -74,8 +92,44 @@ def translate_op_script(s: str) -> str: return s -def insert_node(n: Element, l: list[PathData], path=None) -> None: - # pylint: disable=too-many-locals,too-many-branches +def compare_keys(a, b): + match key_type(a), key_type(b): + case None, None: + if key_name(a) == key_name(b): + return 0 + return -1 if key_name(a) < key_name(b) else 1 + case None, _: + return -1 + case _, None: + return 1 + case _, _: + if key_name(a) == key_name(b): + if key_type(a) == key_type(b): + return 0 + return -1 if key_type(a) < key_type(b) else 1 + return -1 if key_name(a) < key_name(b) else 1 + + +def sort_func(obj: dict, key_func): + if not obj or not isinstance(obj, dict): + return obj + k_list = list(obj.keys()) + if not isinstance(k_list[0], tuple): + return obj + k_list = sorted(k_list, key=key_func) + v_list = map(lambda t: sort_func(obj[t], key_func), k_list) + return dict(zip(k_list, v_list)) + + +def sort_op_data(obj): + key_func = cmp_to_key(compare_keys) + return sort_func(obj, key_func) + + +def insert_node( + n: Element, d: dict, path: list[str] = None, parent: NodeData = None +) -> None: + # pylint: disable=too-many-locals,too-many-branches,too-many-statements prop: OptElement = n.find('properties') children: OptElement = n.find('children') command: OptElement = n.find('command') @@ -124,31 +178,48 @@ def insert_node(n: Element, l: list[PathData], path=None) -> None: if comp_scripts: comp_help['script'] = comp_scripts - cur_node_dict = {} - cur_node_dict['name'] = name - cur_node_dict['type'] = node_type - cur_node_dict['comp_help'] = comp_help - cur_node_dict['help'] = help_text - cur_node_dict['command'] = command_text - cur_node_dict['path'] = path - cur_node_dict['children'] = [] - l.append(cur_node_dict) + cur_node_data = NodeData() + cur_node_data.name = name + cur_node_data.node_type = node_type + cur_node_data.comp_help = comp_help + cur_node_data.help_text = help_text + cur_node_data.command = command_text + cur_node_data.path = path + + value = {('node_data', None): cur_node_data} + key = (name, node_type) + + cur_value = d.setdefault(key, value) + + if parent and key not in parent.children: + parent.children.append(key) + + if ( + CHECK_XML_CONSISTENCY + and cur_value[('node_data', None)] != value[('node_data', None)] + ): + err_buf.write( + f"prev: {cur_value[('node_data', None)]}; new: {value[('node_data', None)]}\n" + ) if children is not None: inner_nodes = children.iterfind('*') for inner_n in inner_nodes: inner_path = path[:] - insert_node(inner_n, cur_node_dict['children'], inner_path) + insert_node(inner_n, d[key], inner_path, cur_node_data) -def parse_file(file_path, l): +def parse_file(file_path, d): tree = ET.parse(file_path) root = tree.getroot() for n in root.iterfind('*'): - insert_node(n, l) + insert_node(n, d) def main(): + # pylint: disable=global-statement + global CHECK_XML_CONSISTENCY + parser = ArgumentParser(description='generate dict from xml defintions') parser.add_argument( '--xml-dir', @@ -156,21 +227,30 @@ def main(): required=True, help='transcluded xml op-mode-definition file', ) + parser.add_argument( + '--check-xml-consistency', + action='store_true', + help='check consistency of node data across files', + ) args = vars(parser.parse_args()) + if args['check_xml_consistency']: + CHECK_XML_CONSISTENCY = True + atexit.register(write_err_buf) + xml_dir = abspath(args['xml_dir']) - l = [] + d = {} - for fname in glob.glob(f'{xml_dir}/*.xml'): - parse_file(fname, l) + for fname in sorted(glob.glob(f'{xml_dir}/*.xml')): + parse_file(fname, d) - with open(xml_op_tmp, 'w') as f: - json.dump(l, f, indent=2) + d = sort_op_data(d) with open(op_ref_cache, 'w') as f: - f.write(f'op_reference = {str(l)}') + f.write('from vyos.xml_ref.op_definition import NodeData\n') + f.write(f'op_reference = {str(d)}') if __name__ == '__main__': diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index 914f3a105..f61181262 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -13,37 +13,178 @@ # You should have received a copy of the GNU Lesser General Public License # along with this library. If not, see . -from typing import TypedDict from typing import TypeAlias -from typing import Optional from typing import Union +from typing import Iterator +from dataclasses import dataclass +from dataclasses import field +from itertools import filterfalse -class NodeData(TypedDict): - node_type: Optional[str] - help_text: Optional[str] - comp_help: Optional[dict[str, list]] - command: Optional[str] - path: Optional[list[str]] +@dataclass +class NodeData: + name: str = '' + node_type: str = 'node' + help_text: str = '' + comp_help: dict[str, list] = field(default_factory=dict) + command: str = '' + path: list[str] = field(default_factory=list) + children: list[tuple] = field(default_factory=list) -PathData: TypeAlias = dict[str, Union[NodeData|list['PathData']]] +OpKey: TypeAlias = tuple[str, str] +OpData: TypeAlias = dict[OpKey, Union[NodeData, 'OpData']] + + +def key_name(k: OpKey): + return k[0] + + +def key_type(k: OpKey): + return k[1] + + +def key_names(l: list): # noqa: E741 + return list(map(lambda t: t[0], l)) + + +def keys_of_name(s: str, l: list): # noqa: E741 + filter(lambda t: t[0] == s, l) + + +def is_tag_node(t: tuple): + return t[1] == 'tagNode' + + +def subdict_of_name(s: str, d: dict) -> dict: + res = {} + for t, v in d.items(): + if not isinstance(t, tuple): + break + if key_name(t) == s: + res[t] = v + + return res + + +def next_keys(d: dict) -> list: + key_set = set() + for k in list(d.keys()): + if isinstance(d[k], dict): + key_set |= set(d[k].keys()) + return list(key_set) + + +def tuple_paths(d: dict) -> Iterator[list[tuple]]: + def func(d, path): + if isinstance(d, dict): + if not d: + yield path + for k, v in d.items(): + if isinstance(k, tuple) and key_name(k) != 'node_data': + for r in func(v, path + [k]): + yield r + else: + yield path + else: + yield path + + for r in func(d, []): + yield r + + +def match_tuple_paths( + path: list[str], paths: list[list[tuple[str, str]]] +) -> list[list[tuple[str, str]]]: + return list(filter(lambda p: key_names(p) == path, paths)) + + +def get_node_data_at_path(d: dict, tpath): + if not tpath: + return {} + # operates on actual paths, not names: + if not isinstance(tpath[0], tuple): + raise ValueError('must be path of tuples') + while tpath and d: + d = d.get(tpath[0], {}) + tpath = tpath[1:] + + return d.get(('node_data', None), {}) class OpXml: def __init__(self): self.op_ref = {} - def define(self, op_ref: list[PathData]) -> None: + def define(self, op_ref: dict) -> None: self.op_ref = op_ref - def _get_op_ref_path(self, path: list[str]) -> list[PathData]: - def _get_path_list(path: list[str], l: list[PathData]) -> list[PathData]: - if not path: - return l - for d in l: - if path[0] in list(d): - return _get_path_list(path[1:], d[path[0]]) - return [] - l = self.op_ref - return _get_path_list(path, l) + def walk(self, func): + def walk_op_data(obj, func): + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(k, tuple): + res = func(k, v) + yield res + yield from walk_op_data(v, func) + + return walk_op_data(self.op_ref, func) + + @staticmethod + def get_node_data_func(k, v): + if key_name(k) == 'node_data': + return v + return None + + def walk_node_data(self): + return filterfalse(lambda x: x is None, self.walk(self.get_node_data_func)) + + def lookup( + self, path: list[str], tag_values: bool = False, last_node_type: str = '' + ) -> (OpData, list[str]): + path = path[:] + + ref_path = [] + + def prune_tree(d: dict, p: list[str]): + p = p[:] + if not d or not isinstance(d, dict) or not p: + return d + op_data: dict = subdict_of_name(p[0], d) + op_keys = list(op_data.keys()) + ref_path.append(p[0]) + if len(p) < 2: + # check last node_type + if last_node_type: + keys = list(filter(lambda t: t[1] == last_node_type, op_keys)) + values = list(map(lambda t: op_data[t], keys)) + return dict(zip(keys, values)) + return op_data + + if p[1] not in key_names(next_keys(op_data)): + # check if tag_values + if tag_values: + p = p[2:] + keys = list(filter(is_tag_node, op_keys)) + values = list(map(lambda t: prune_tree(op_data[t], p), keys)) + return dict(zip(keys, values)) + return {} + + p = p[1:] + op_data = list(map(lambda t: prune_tree(op_data[t], p), op_keys)) + + return dict(zip(op_keys, op_data)) + + return prune_tree(self.op_ref, path), ref_path + + def lookup_node_data( + self, path: list[str], tag_values: bool = False, last_node_type: str = '' + ) -> list[NodeData]: + res = [] + d, ref_path = self.lookup(path, tag_values, last_node_type) + paths = list(tuple_paths(d)) + paths = match_tuple_paths(ref_path, paths) + for p in paths: + res.append(get_node_data_at_path(d, p)) + + return res -- cgit v1.2.3 From 5d24019a6464d83ce821e490527f0c239395e7a5 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 20 Jun 2025 10:02:03 -0500 Subject: T7561: minimize risk of collision with possible node names --- python/vyos/xml_ref/generate_op_cache.py | 6 +++--- python/vyos/xml_ref/op_definition.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) (limited to 'python') diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index c00e676df..a81871eea 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -186,7 +186,7 @@ def insert_node( cur_node_data.command = command_text cur_node_data.path = path - value = {('node_data', None): cur_node_data} + value = {('__node_data', None): cur_node_data} key = (name, node_type) cur_value = d.setdefault(key, value) @@ -196,10 +196,10 @@ def insert_node( if ( CHECK_XML_CONSISTENCY - and cur_value[('node_data', None)] != value[('node_data', None)] + and cur_value[('__node_data', None)] != value[('__node_data', None)] ): err_buf.write( - f"prev: {cur_value[('node_data', None)]}; new: {value[('node_data', None)]}\n" + f"prev: {cur_value[('__node_data', None)]}; new: {value[('__node_data', None)]}\n" ) if children is not None: diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index f61181262..2f47ba1a6 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -81,7 +81,7 @@ def tuple_paths(d: dict) -> Iterator[list[tuple]]: if not d: yield path for k, v in d.items(): - if isinstance(k, tuple) and key_name(k) != 'node_data': + if isinstance(k, tuple) and key_name(k) != '__node_data': for r in func(v, path + [k]): yield r else: @@ -109,7 +109,7 @@ def get_node_data_at_path(d: dict, tpath): d = d.get(tpath[0], {}) tpath = tpath[1:] - return d.get(('node_data', None), {}) + return d.get(('__node_data', None), {}) class OpXml: @@ -132,7 +132,7 @@ class OpXml: @staticmethod def get_node_data_func(k, v): - if key_name(k) == 'node_data': + if key_name(k) == '__node_data': return v return None -- cgit v1.2.3 From 4f29a02c84d87553303f45b7b0be6d39126011f4 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 20 Jun 2025 12:08:25 -0500 Subject: T7561: refine xml consistency report to ignore children and file fields --- python/vyos/xml_ref/generate_op_cache.py | 23 +++++++++++++---------- python/vyos/xml_ref/op_definition.py | 24 +++++++++++++++++++++++- 2 files changed, 36 insertions(+), 11 deletions(-) (limited to 'python') diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index a81871eea..4c98cf18d 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import os import io import re import sys @@ -35,6 +36,8 @@ from op_definition import OpKey # pylint: disable=unused-import # noqa: F401 from op_definition import OpData # pylint: disable=unused-import # noqa: F401 from op_definition import key_name from op_definition import key_type +from op_definition import node_data_difference +from op_definition import get_node_data _here = dirname(__file__) @@ -93,6 +96,7 @@ def translate_op_script(s: str) -> str: def compare_keys(a, b): + # pylint: disable=too-many-return-statements match key_type(a), key_type(b): case None, None: if key_name(a) == key_name(b): @@ -127,7 +131,7 @@ def sort_op_data(obj): def insert_node( - n: Element, d: dict, path: list[str] = None, parent: NodeData = None + n: Element, d: dict, path: list[str] = None, parent: NodeData = None, file: str = '' ) -> None: # pylint: disable=too-many-locals,too-many-branches,too-many-statements prop: OptElement = n.find('properties') @@ -185,6 +189,7 @@ def insert_node( cur_node_data.help_text = help_text cur_node_data.command = command_text cur_node_data.path = path + cur_node_data.file = file value = {('__node_data', None): cur_node_data} key = (name, node_type) @@ -194,26 +199,24 @@ def insert_node( if parent and key not in parent.children: parent.children.append(key) - if ( - CHECK_XML_CONSISTENCY - and cur_value[('__node_data', None)] != value[('__node_data', None)] - ): - err_buf.write( - f"prev: {cur_value[('__node_data', None)]}; new: {value[('__node_data', None)]}\n" - ) + if CHECK_XML_CONSISTENCY: + out = node_data_difference(get_node_data(cur_value), get_node_data(value)) + if out: + err_buf.write(out) if children is not None: inner_nodes = children.iterfind('*') for inner_n in inner_nodes: inner_path = path[:] - insert_node(inner_n, d[key], inner_path, cur_node_data) + insert_node(inner_n, d[key], inner_path, cur_node_data, file) def parse_file(file_path, d): tree = ET.parse(file_path) root = tree.getroot() + file = os.path.basename(file_path) for n in root.iterfind('*'): - insert_node(n, d) + insert_node(n, d, file=file) def main(): diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index 2f47ba1a6..46ccc3ef4 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -18,17 +18,20 @@ from typing import Union from typing import Iterator from dataclasses import dataclass from dataclasses import field +from dataclasses import fields from itertools import filterfalse @dataclass class NodeData: + # pylint: disable=too-many-instance-attributes name: str = '' node_type: str = 'node' help_text: str = '' comp_help: dict[str, list] = field(default_factory=dict) command: str = '' path: list[str] = field(default_factory=list) + file: str = '' children: list[tuple] = field(default_factory=list) @@ -99,6 +102,10 @@ def match_tuple_paths( return list(filter(lambda p: key_names(p) == path, paths)) +def get_node_data(d: dict) -> NodeData: + return d.get(('__node_data', None), {}) + + def get_node_data_at_path(d: dict, tpath): if not tpath: return {} @@ -109,7 +116,22 @@ def get_node_data_at_path(d: dict, tpath): d = d.get(tpath[0], {}) tpath = tpath[1:] - return d.get(('__node_data', None), {}) + return get_node_data(d) + + +def node_data_difference(a: NodeData, b: NodeData): + out = '' + for fld in fields(NodeData): + if fld.name in ('children', 'file'): + continue + a_fld = getattr(a, fld.name) + b_fld = getattr(b, fld.name) + if a_fld != b_fld: + out += f'prev: {a.file} {a.path} {fld.name}: {a_fld}\n' + out += f'new: {b.file} {b.path} {fld.name}: {b_fld}\n' + out += '\n' + + return out class OpXml: -- cgit v1.2.3 From ecc08e77849295605700ac409b2e93bf1357969c Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 20 Jun 2025 15:23:04 -0500 Subject: T7561: add option --check-path-ambiguity to show duplicate paths --- python/vyos/xml_ref/generate_op_cache.py | 11 +++++++++++ python/vyos/xml_ref/op_definition.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) (limited to 'python') diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index 4c98cf18d..4c22672b2 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -38,6 +38,7 @@ from op_definition import key_name from op_definition import key_type from op_definition import node_data_difference from op_definition import get_node_data +from op_definition import collapse _here = dirname(__file__) @@ -235,6 +236,11 @@ def main(): action='store_true', help='check consistency of node data across files', ) + parser.add_argument( + '--check-path-ambiguity', + action='store_true', + help='attempt to reduce to unique paths, reporting if error', + ) args = vars(parser.parse_args()) @@ -251,6 +257,11 @@ def main(): d = sort_op_data(d) + if args['check_path_ambiguity']: + # when the following passes with no output, return value will be + # the full dictionary indexed by str, not tuple + _ = collapse(d) + with open(op_ref_cache, 'w') as f: f.write('from vyos.xml_ref.op_definition import NodeData\n') f.write(f'op_reference = {str(d)}') diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index 46ccc3ef4..71076bdb7 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -134,6 +134,36 @@ def node_data_difference(a: NodeData, b: NodeData): return out +def collapse(d: OpData, acc: dict = None) -> dict: + if acc is None: + acc = {} + if not isinstance(d, dict): + return d + for k, v in d.items(): + if isinstance(k, tuple): + # reduce + name = key_name(k) + if name != '__node_data': + new_data = get_node_data(v) + if name in list(acc.keys()): + prev_data = acc[name].get('__node_data', {}) + if prev_data: + out = f'prev: {prev_data.file} {prev_data.path}\n' + else: + out = '\n' + out += f'new: {new_data.file} {new_data.path}\n' + print(out) + else: + acc[name] = {} + acc[name]['__node_data'] = new_data + acc[name].update(collapse(v)) + else: + name = k + acc[name] = v + + return acc + + class OpXml: def __init__(self): self.op_ref = {} -- cgit v1.2.3 From 3472a92cc13084db8486290e8926579e2f753cb4 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Fri, 20 Jun 2025 19:00:43 -0500 Subject: T7561: generate json if no ambiguous paths in (a subset of) XML files --- .gitignore | 1 + python/vyos/xml_ref/generate_op_cache.py | 27 +++++++++++++++++++++++---- python/vyos/xml_ref/op_definition.py | 28 +++++++++++++++++++--------- 3 files changed, 43 insertions(+), 13 deletions(-) (limited to 'python') diff --git a/.gitignore b/.gitignore index 7084332a8..abdc5eb7d 100644 --- a/.gitignore +++ b/.gitignore @@ -146,6 +146,7 @@ data/component-versions.json python/vyos/xml_ref/cache.py python/vyos/xml_ref/pkg_cache/*_cache.py python/vyos/xml_ref/op_cache.py +python/vyos/xml_ref/op_cache.json python/vyos/xml_ref/pkg_cache/*_op_cache.py data/reftree.cache # autogenerated vyos-configd JSON definition diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index 4c22672b2..117b080b4 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -19,6 +19,7 @@ import io import re import sys import glob +import json import atexit from argparse import ArgumentParser @@ -48,6 +49,7 @@ from defaults import directories # noqa: E402 op_ref_cache = abspath(join(_here, 'op_cache.py')) +op_ref_json = abspath(join(_here, 'op_cache.json')) OptElement: TypeAlias = Optional[Element] @@ -241,6 +243,11 @@ def main(): action='store_true', help='attempt to reduce to unique paths, reporting if error', ) + parser.add_argument( + '--select', + type=str, + help='limit cache to a subset of XML files: "power_ctl | multicast-group | ..."', + ) args = vars(parser.parse_args()) @@ -252,15 +259,27 @@ def main(): d = {} + select = args['select'] + if select: + select = [item.strip() for item in select.split('|')] + for fname in sorted(glob.glob(f'{xml_dir}/*.xml')): - parse_file(fname, d) + file = os.path.basename(fname) + if not select or os.path.splitext(file)[0] in select: + parse_file(fname, d) d = sort_op_data(d) if args['check_path_ambiguity']: - # when the following passes with no output, return value will be - # the full dictionary indexed by str, not tuple - _ = collapse(d) + # when the following passes without error, return value will be the + # full dictionary indexed by str, not tuple + res, out, err = collapse(d) + if not err: + with open(op_ref_json, 'w') as f: + json.dump(res, f, indent=2) + else: + print('Found the following duplicate paths:\n') + print(out) with open(op_ref_cache, 'w') as f: f.write('from vyos.xml_ref.op_definition import NodeData\n') diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index 71076bdb7..8e922ecb2 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -19,6 +19,7 @@ from typing import Iterator from dataclasses import dataclass from dataclasses import field from dataclasses import fields +from dataclasses import asdict from itertools import filterfalse @@ -134,34 +135,43 @@ def node_data_difference(a: NodeData, b: NodeData): return out -def collapse(d: OpData, acc: dict = None) -> dict: +def collapse(d: OpData, acc: dict = None) -> tuple[dict, str, bool]: + err = False + inner_err = False + out = '' + inner_out = '' if acc is None: acc = {} if not isinstance(d, dict): return d for k, v in d.items(): if isinstance(k, tuple): - # reduce name = key_name(k) if name != '__node_data': new_data = get_node_data(v) if name in list(acc.keys()): + err = True prev_data = acc[name].get('__node_data', {}) if prev_data: - out = f'prev: {prev_data.file} {prev_data.path}\n' + out += f'prev: {prev_data["file"]} {prev_data["path"]}\n' else: - out = '\n' - out += f'new: {new_data.file} {new_data.path}\n' - print(out) + out += '\n' + out += f'new: {new_data.file} {new_data.path}\n\n' else: acc[name] = {} - acc[name]['__node_data'] = new_data - acc[name].update(collapse(v)) + acc[name]['__node_data'] = asdict(new_data) + inner, o, e = collapse(v) + inner_err |= e + inner_out += o + acc[name].update(inner) else: name = k acc[name] = v - return acc + err |= inner_err + out += inner_out + + return acc, out, err class OpXml: -- cgit v1.2.3 From 6281059dc5de3a906b63c59b0b71050ea0022446 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 23 Jun 2025 22:26:03 +0200 Subject: T7355: periodical cleanup of unused Python3 import statements --- python/vyos/config.py | 1 - src/conf_mode/nat.py | 1 - src/helpers/teardown-config-session.py | 3 --- 3 files changed, 5 deletions(-) (limited to 'python') diff --git a/python/vyos/config.py b/python/vyos/config.py index 9ae0467d4..f1086cd6e 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -62,7 +62,6 @@ while functions prefixed "effective" return values from the running config. In operational mode, all functions return values from the running config. """ -import os import re import json from typing import Union diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index a938021ba..564438237 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -23,7 +23,6 @@ from vyos.base import Warning from vyos.config import Config from vyos.configdep import set_dependents, call_dependents from vyos.template import render -from vyos.template import is_ip_network from vyos.utils.kernel import check_kmod from vyos.utils.dict import dict_search from vyos.utils.dict import dict_search_args diff --git a/src/helpers/teardown-config-session.py b/src/helpers/teardown-config-session.py index c94876924..8d13e34cb 100755 --- a/src/helpers/teardown-config-session.py +++ b/src/helpers/teardown-config-session.py @@ -13,11 +13,8 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# -# import sys -import os from vyos.vyconf_session import VyconfSession -- cgit v1.2.3 From 74941af39dc59c42d8ec6749169ee1c1663b78b7 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Mon, 23 Jun 2025 22:43:21 +0200 Subject: pki: T7574: add optional force argument to renew certbot-issued certificates Certbot renewal command in op-mode "renew certbot" only works if any of the certificates is up for renewal. There is no CLI option to forcefully renew a certificate. This is about adding a force option to the CLI and with this addition move the entire certbot renew handling to new-style op-mode commands. vyos@vyos:~$ renew certbot force - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Processing /config/auth/letsencrypt/renewal/vyos.conf - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Renewing an existing certificate for vyos.io - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Congratulations, all renewals succeeded: /config/auth/letsencrypt/live/vyos/fullchain.pem (success) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Hook 'post-hook' ran with output: Updating certificates in /etc/ssl/certs... 0 added, 0 removed; done. Running hooks in /etc/ca-certificates/update.d... done. --- op-mode-definitions/pki.xml.in | 16 ++++++++++++---- python/vyos/defaults.py | 4 ++-- .../systemd/system/certbot.service.d/10-override.conf | 7 ------- src/op_mode/pki.py | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) delete mode 100644 src/etc/systemd/system/certbot.service.d/10-override.conf (limited to 'python') diff --git a/op-mode-definitions/pki.xml.in b/op-mode-definitions/pki.xml.in index 43fb1fe2b..542b15e9d 100644 --- a/op-mode-definitions/pki.xml.in +++ b/op-mode-definitions/pki.xml.in @@ -576,12 +576,20 @@ - + - Start manual certbot renewal + Manual certbot renewal - systemctl start certbot.service - + ${vyos_op_scripts_dir}/pki.py renew_certbot + + + + Force manual certbot renewal + + ${vyos_op_scripts_dir}/pki.py renew_certbot --force + + + diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index f84b14040..63f3b5358 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -15,10 +15,10 @@ import os -base_dir = '/usr/libexec/vyos/' +base_dir = '/usr/libexec/vyos' directories = { - 'base' : base_dir, + 'base' : f'{base_dir}', 'data' : '/usr/share/vyos/', 'conf_mode' : f'{base_dir}/conf_mode', 'op_mode' : f'{base_dir}/op_mode', diff --git a/src/etc/systemd/system/certbot.service.d/10-override.conf b/src/etc/systemd/system/certbot.service.d/10-override.conf deleted file mode 100644 index 542f77eb2..000000000 --- a/src/etc/systemd/system/certbot.service.d/10-override.conf +++ /dev/null @@ -1,7 +0,0 @@ -[Unit] -After= -After=vyos-router.service - -[Service] -ExecStart= -ExecStart=/usr/bin/certbot renew --config-dir /config/auth/letsencrypt --no-random-sleep-on-renew --post-hook "/usr/libexec/vyos/vyos-certbot-renew-pki.sh" diff --git a/src/op_mode/pki.py b/src/op_mode/pki.py index 49a461e9e..d928bd325 100755 --- a/src/op_mode/pki.py +++ b/src/op_mode/pki.py @@ -1373,6 +1373,21 @@ def show_all(raw: bool): print('\n') show_crl(raw) +def renew_certbot(raw: bool, force: typing.Optional[bool] = False): + from vyos.defaults import directories + + certbot_config = directories['certbot'] + hook_dir = directories['base'] + + tmp = f'/usr/bin/certbot renew --no-random-sleep-on-renew ' \ + f'--config-dir "{certbot_config}" ' \ + f'--post-hook "{hook_dir}/vyos-certbot-renew-pki.sh"' + if force: + tmp += ' --force-renewal' + + out = cmd(tmp) + if not raw: + print(out) if __name__ == '__main__': try: -- cgit v1.2.3 From 6eb93578a33f27007c4ecda8b71efbd880a97653 Mon Sep 17 00:00:00 2001 From: "Nataliia S." <81954790+natali-rs1985@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:36:18 +0300 Subject: T7424: Refactor resource validation and broaden cases (#38) * T7424: Refactor and extend resource usage verification on commit for VPP CLI T7424: Fix ruff errors * T7424: Implement check for smoke tests runtime; reduce resource requirements for test environments T7424: Fix errors in calculating the skipped and reserved CPU cores; Adjust default main heap size value. * T7424: Refactor the CPU checks logic; Add total CPU usage check T7424: Fix CPU reserve and skip cores calculations; Add total CPU usage check T7424: Refactor smoketests to reflect new logic * T7424: Refactor the CPU and memory checks logic --------- Co-authored-by: oniko94 --- .../include/vpp_host_resources.xml.i | 4 +- interface-definitions/vpp.xml.in | 7 +- python/vyos/vpp/config_resource_checks/__init__.py | 0 python/vyos/vpp/config_resource_checks/cpu.py | 76 ++++++ python/vyos/vpp/config_resource_checks/memory.py | 152 ++++++++++++ .../config_resource_checks/resource_defaults.py | 62 +++++ python/vyos/vpp/config_verify.py | 228 +++++++++++++++++ smoketest/scripts/cli/test_vpp.py | 28 ++- src/conf_mode/vpp.py | 271 +++++++-------------- 9 files changed, 639 insertions(+), 189 deletions(-) create mode 100644 python/vyos/vpp/config_resource_checks/__init__.py create mode 100644 python/vyos/vpp/config_resource_checks/cpu.py create mode 100644 python/vyos/vpp/config_resource_checks/memory.py create mode 100644 python/vyos/vpp/config_resource_checks/resource_defaults.py (limited to 'python') diff --git a/interface-definitions/include/vpp_host_resources.xml.i b/interface-definitions/include/vpp_host_resources.xml.i index 7f988ce44..109982bef 100644 --- a/interface-definitions/include/vpp_host_resources.xml.i +++ b/interface-definitions/include/vpp_host_resources.xml.i @@ -15,7 +15,7 @@ - 1024 + 2048 @@ -28,7 +28,7 @@ - 3096 + 4096 diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index dab0ea308..fa66d6126 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -427,11 +427,11 @@ Skip cores - u32:0-512 + u32:1-512 Skip cores - + @@ -771,12 +771,14 @@ Main heap size #include + 3G Main heap page size #include + 2M @@ -785,6 +787,7 @@ + 2M diff --git a/python/vyos/vpp/config_resource_checks/__init__.py b/python/vyos/vpp/config_resource_checks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/vyos/vpp/config_resource_checks/cpu.py b/python/vyos/vpp/config_resource_checks/cpu.py new file mode 100644 index 000000000..6f360ae74 --- /dev/null +++ b/python/vyos/vpp/config_resource_checks/cpu.py @@ -0,0 +1,76 @@ +# Used for validating estimated CPU/physical cores use +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +from vyos.utils.cpu import get_available_cpus, get_core_count + +from vyos.vpp.config_resource_checks.resource_defaults import get_resource_defaults + + +# Get default value for reserved cpu cores +reserved_cpus = get_resource_defaults().get('reserved_cpu_cores') + + +def available_cores_count(cpu_settings: dict) -> int: + core_count = get_core_count() + + if cpu_settings.get('main_core'): + core_count -= 1 + + skip_cores = int(cpu_settings.get('skip_cores', 0)) + # The default settings assume that + # at least 2 CPU cores should remain reserved for system use + # (only in case of current runtime is not smoke test) + if skip_cores < reserved_cpus: + core_count -= reserved_cpus + else: + core_count -= skip_cores + + return core_count + + +def available_cores_list(skip_cores: int) -> list: + # Available cores are all CPU cores without first N skipped cores that will not be used + # Get all available physical cores - use set to filter out unique values + cpu_cores = set(map(lambda el: el['cpu'], get_available_cpus())) + cpu_cores = list(cpu_cores) + + return cpu_cores[skip_cores:] + + +def worker_cores_list(iface: str, worker_ranges: list) -> list: + all_core_numbers = [] + for worker_range in worker_ranges: + core_numbers = worker_range.split('-') + + if int(core_numbers[0]) > int(core_numbers[-1]): + raise ValueError( + f'Range for "{iface} workers {worker_range}" is not correct' + ) + + all_core_numbers.extend(range(int(core_numbers[0]), int(core_numbers[-1]) + 1)) + + # Check for duplicates + duplicates = set( + [str(x) for n, x in enumerate(all_core_numbers) if x in all_core_numbers[:n]] + ) + if duplicates: + raise ValueError( + f'Some workers in "{iface} workers" are duplicated: #{",".join(list(duplicates))}' + ) + + return all_core_numbers diff --git a/python/vyos/vpp/config_resource_checks/memory.py b/python/vyos/vpp/config_resource_checks/memory.py new file mode 100644 index 000000000..44f0c9358 --- /dev/null +++ b/python/vyos/vpp/config_resource_checks/memory.py @@ -0,0 +1,152 @@ +# Used for memory consumption calculations +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import re + +from vyos.utils.process import cmd +from vyos.vpp.utils import ( + human_memory_to_bytes, + human_page_memory_to_bytes, +) +from vyos.vpp.config_resource_checks.resource_defaults import get_resource_defaults + + +# Get default values for resource checks +defaults = get_resource_defaults() + + +def get_total_hugepages_free_memory() -> int: + """ + Returns the total amount of hugepage-backed free memory (in bytes) + as reported by /proc/meminfo + """ + info = {} + with open('/proc/meminfo', 'r') as meminfo: + for line in meminfo: + if line.startswith('Huge'): + key, value, *_ = line.strip().split() + info[key.rstrip(':')] = int(value) + + hugepages_free = info.get('HugePages_Free') + hugepage_size = info.get('Hugepagesize') * 1024 + + return hugepage_size * hugepages_free + + +def get_numa_count(): + """ + Run `numactl --hardware` and parse the 'available:' line. + """ + out = cmd('numactl --hardware') + # e.g. "available: 2 nodes (0-1)" + m = re.search(r'available:\s*(\d+)\s+nodes', out) + return int(m.group(1)) if m else 0 + + +def get_memory_from_kernel_settings(settings: dict) -> int: + hugepage_settings = settings.get('hugepage_size', {}) + + total_bytes = 0 + for size_str, info in hugepage_settings.items(): + count = int(info.get('hugepage_count', 0)) + page_bytes = human_memory_to_bytes(size_str) + total_bytes += count * page_bytes + + return total_bytes + + +def buffer_size(settings: dict) -> int: + numa_count = get_numa_count() + buffers_per_numa = int( + settings.get('buffers', {}).get( + 'buffers_per_numa', defaults.get('buffers_per_numa') + ) + ) + data_size = int( + settings.get('buffers', {}).get('data_size', defaults.get('data_size')) + ) + buffers_memory = buffers_per_numa * data_size * numa_count + return buffers_memory + + +def main_heap_page_size(settings: dict) -> int: + heap_page_size = settings.get('memory', {}).get( + 'main_heap_page_size', defaults.get('main_heap_page_size') + ) + return human_page_memory_to_bytes(heap_page_size) + + +def memory_main_heap(settings: dict) -> int: + heap_size = settings.get('memory', {}).get( + 'main_heap_size', defaults.get('main_heap_size') + ) + return human_memory_to_bytes(heap_size) + + +def ipv6_heap_size(settings: dict) -> int: + heap_size = settings.get('ipv6', {}).get( + 'heap_size', defaults.get('ipv6_heap_size') + ) + return human_memory_to_bytes(heap_size) + + +def total_heap_size(heap_size: int, heap_page_size: int) -> int: + return (heap_size + heap_page_size - 1) & ~(heap_page_size - 1) + + +def statseg_size(settings: dict) -> int: + statseg_memory = settings.get('statseg', {}).get( + 'size', defaults.get('statseg_heap_size') + ) + return human_memory_to_bytes(statseg_memory) + + +def statseg_page_size(settings: dict) -> int: + page_size = settings.get('statseg', {}).get('page_size', 'default') + return human_page_memory_to_bytes(page_size) + + +def total_statseg_size(_statseg_size: int, _statseg_page: int) -> int: + return (_statseg_size + _statseg_page - 1) & ~(_statseg_page - 1) + + +def total_memory_required(settings: dict) -> int: + mem_required = 0 + + mem_stats = { + 'memory_buffers': buffer_size(settings), + 'netlink_buffer_size': int( + settings.get('lcp', {}).get( + 'rx_buffer_size', defaults.get('netlink_rx_buffer_size') + ) + ), + 'heap_size': total_heap_size( + heap_size=memory_main_heap(settings), + heap_page_size=main_heap_page_size(settings), + ), + 'statseg_size': total_statseg_size( + _statseg_size=statseg_size(settings), + _statseg_page=statseg_page_size(settings), + ), + 'ipv6_heap_size': ipv6_heap_size(settings), + } + + for stat in mem_stats: + mem_required += mem_stats[stat] + + return mem_required diff --git a/python/vyos/vpp/config_resource_checks/resource_defaults.py b/python/vyos/vpp/config_resource_checks/resource_defaults.py new file mode 100644 index 000000000..3c618accd --- /dev/null +++ b/python/vyos/vpp/config_resource_checks/resource_defaults.py @@ -0,0 +1,62 @@ +# Default values for resource consumption checks +# +# Copyright (C) 2025 VyOS Inc. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import copy +import os + + +__default_resource_map = { + # Default amount of buffers per NUMA (populated CPU socket) + 'buffers_per_numa': 16384, + # Default size of buffer (in bytes) + 'data_size': 2048, + # Default hugepage size for VPP + 'hugepage_size': '2M', + # Default amount of memory allocated for VPP exclusive usage + 'main_heap_size': '3G', + # Default main heap page size + 'main_heap_page_size': '2M', + # Default size of buffers transferred via netlink + 'netlink_rx_buffer_size': 212992, + # Default amount of memory allocated for VPP stats segment usage + 'statseg_heap_size': '96M', + # Minimal amount of memory required to start VPP + 'min_memory': '8G', + # Minimal number of physical CPU cores required to start VPP + 'min_cpus': 4, + # Reserve at least 2 physical cores + 'reserved_cpu_cores': 2, + # Default heap size for IPv6 + 'ipv6_heap_size': '32M', +} + + +def get_resource_defaults() -> dict: + resource_map = copy.deepcopy(__default_resource_map) + # Check if current runtime is smoke tests + # Since CI/CD runners are limited in resources, reduce the checks for tests + if is_smoketest(): + resource_map.update(min_memory='6G', min_cpus=2, reserved_cpu_cores=0) + + return resource_map + + +def is_smoketest(): + if os.path.exists('/tmp/vyos.smoketests.hint'): + return True + return False diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py index 830d4af73..9cfbfb7cd 100644 --- a/python/vyos/vpp/config_verify.py +++ b/python/vyos/vpp/config_verify.py @@ -16,9 +16,19 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +import psutil + from vyos import ConfigError +from vyos.utils.cpu import get_core_count as total_core_count from vyos.vpp.control_host import get_eth_driver +from vyos.vpp.config_resource_checks import cpu as cpu_checks, memory as mem_checks +from vyos.vpp.config_resource_checks import resource_defaults +from vyos.vpp.utils import human_memory_to_bytes, bytes_to_human_memory + + +# Get default values for resource checks +defaults = resource_defaults.get_resource_defaults() def verify_vpp_remove_kernel_interface(config: dict): @@ -171,3 +181,221 @@ def verify_dev_driver(iface_name: str, driver_type: str) -> bool: raise ConfigError(f'"Driver type {driver_type} is wrong') return False + + +def verify_vpp_minimum_cpus(): + """ + Verify that the host system has enough physical CPU cores + Current minimal requirement is 4 + """ + min_cpus = defaults.get('min_cpus') + if total_core_count() < min_cpus: + raise ConfigError( + 'This system does not meet minimal requirements for VPP. ' + f'Minimum {min_cpus} CPU cores are required.' + ) + + +def verify_vpp_minimum_memory(): + """ + Verify that the host system has enough RAM + Calculate by retrieving the amount of physical memory + And the minimal requirement (currently 8 GB). Round before comparing - + To avoid situations like when a machine nominally has 8192 MB (8 giga/gibibytes) + But the OS sees only 7.75 GB, creating a fail condition for this check + """ + min_mem = defaults.get('min_memory') + total_memory = round(psutil.virtual_memory().total / (1024**3)) + min_memory = round(human_memory_to_bytes(min_mem) / (1024**3)) + + if total_memory < min_memory: + raise ConfigError( + 'This system does not meet minimal requirements for VPP. ' + f'Minimum {min_memory} GB of RAM are required.' + ) + + +def verify_vpp_memory(config: dict): + main_heap_size = mem_checks.memory_main_heap(config['settings']) + main_heap_page_size = mem_checks.main_heap_page_size(config['settings']) + + if main_heap_size < 51 << 20: + raise ConfigError('The main heap size must be greater than or equal to 51M') + + readable_heap_page = bytes_to_human_memory(main_heap_page_size, 'K') + + if main_heap_page_size > main_heap_size: + raise ConfigError( + f'The main heap size must be greater than or equal to page-size ({readable_heap_page})' + ) + + # Get available HupePage memory to compare with required memory for VPP + # (if it's smketests environment get system kernel settings for HugePages) + if not resource_defaults.is_smoketest(): + available_memory = mem_checks.get_total_hugepages_free_memory() + else: + available_memory = mem_checks.get_memory_from_kernel_settings( + config['kernel_memory_settings'] + ) + + memory_required = mem_checks.total_memory_required(config['settings']) + + # Check if there is a config currently active + # If yes, calculate how much memory it consumes + # and exclude it from required memory + if config.get('effective'): + memory_used = mem_checks.total_memory_required(config['effective']['settings']) + # If we want to reduce memory configs then there is nothing to check + if memory_used > memory_required: + return + memory_required -= memory_used + + if memory_required > available_memory: + raise ConfigError( + 'Not enough free memory to start VPP: ' + f'available: {round(available_memory / 1024 ** 3, 1)} GB, ' + f'required: {round(memory_required / 1024 ** 3, 1)} GB. ' + 'Please add kernel memory options for HugePages and reboot' + ) + + +def verify_vpp_settings_cpu_skip_cores(skip_cores: int): + cpu_cores = total_core_count() + + # The number of skipped cores must not be greater than + # available CPU cores in the system - 1 for main thread + if skip_cores > (cpu_cores - 1): + raise ConfigError( + f'The system does not have enough available CPUs to skip ' + f'(reduce "cpu skip-cores" to {cpu_cores} or less)' + ) + + +def verify_vpp_settings_cpu_and_corelist_workers(settings: dict): + """ + `set vpp settings cpu workers` and `set vpp settings cpu corelist-workers` + are mutually exclusive! + """ + if ( + 'corelist_workers' in settings or 'workers' in settings + ) and 'main_core' not in settings: + raise ConfigError('"cpu main-core" is required but not set!') + + if 'corelist_workers' in settings and 'workers' in settings: + raise ConfigError( + '"cpu corelist-workers" and "cpu workers" cannot be used at the same time!' + ) + + +def verify_vpp_cpu_main_core(cpu_settings: dict) -> None: + """Check that the main core is available""" + skip_cores = int(cpu_settings.get('skip_cores', 0)) + available_cores = cpu_checks.available_cores_list(skip_cores) + main_core = int(cpu_settings['main_core']) + + if main_core not in available_cores: + raise ConfigError( + 'Cannot set main core for VPP process: ' + f'CPU#{main_core} is not available.' + ) + + +def verify_vpp_settings_cpu_workers(cpu_settings: dict) -> int: + """ + Verify that the system has enough available CPU cores + to run a given amount of worker processes (1 worker/core) + """ + workers = int(cpu_settings.get('workers', 0)) + available_cores = cpu_checks.available_cores_count(cpu_settings) + + if workers > available_cores: + raise ConfigError( + f'Not enough free CPU cores for {workers} VPP workers ' + f'(reduce to {available_cores} or less)' + ) + + return workers + + +def verify_vpp_settings_cpu_corelist_workers(cpu_settings: dict) -> int: + """ + Verify that the CPU cores provided to the config are free and can be used by VPP + """ + workers = cpu_settings.get('corelist_workers') + main_core = int(cpu_settings.get('main_core')) + skip_cores = int(cpu_settings.get('skip_cores', 0)) + available_cores = cpu_checks.available_cores_list(skip_cores) + try: + all_core_nums = cpu_checks.worker_cores_list( + iface='cpu corelist', worker_ranges=workers + ) + except ValueError as e: + raise ConfigError(str(e)) + + error_msg = 'Cannot set VPP "cpu corelist-workers"' + + if main_core in all_core_nums: + raise ConfigError( + f'CPU#{main_core} is set as main core and should not ' + 'be included to the corelist-workers' + ) + + invalid_cores = [str(el) for el in all_core_nums if el not in available_cores] + if invalid_cores: + raise ConfigError( + f'{error_msg}: CPU# {",".join(invalid_cores)} are not available.' + ) + + if len(all_core_nums) > cpu_checks.available_cores_count(cpu_settings): + raise ConfigError(f'{error_msg}: Not enough free CPUs in the system.') + + return len(all_core_nums) + + +def verify_vpp_nat44_workers(workers: int, nat44_workers: list): + if workers < 1: + raise ConfigError( + '"nat44 workers" requires cpu workers or corelist-workers to be set!' + ) + try: + nat_workers = cpu_checks.worker_cores_list( + iface='nat44', worker_ranges=nat44_workers + ) + except ValueError as e: + raise ConfigError(str(e)) + + invalid_workers = [str(el) for el in nat_workers if el not in range(workers)] + if invalid_workers: + raise ConfigError( + f'Cannot set VPP "nat44 workers": worker(s) #{",".join(invalid_workers)} not available. ' + f'Available worker ids: {",".join(map(str, range(workers)))}' + ) + + +def verify_vpp_statseg_size(settings: dict): + statseg_size = mem_checks.statseg_size(settings) + + if 'size' in settings.get('statseg'): + if statseg_size < 1 << 20: + raise ConfigError('The statseg size must be greater than or equal to 1M') + + if 'page_size' in settings['statseg']: + statseg_page_size = mem_checks.statseg_page_size(settings) + if statseg_page_size > statseg_size: + readable_statseg_page = bytes_to_human_memory(statseg_page_size, 'K') + raise ConfigError( + f'The statseg size must be greater than or equal to page-size ({readable_statseg_page})' + ) + + +def verify_vpp_interfaces_dpdk_num_queues(qtype: str, num_queues: int, workers: int): + """ + Verify that VPP has enough workers to run the given amount of RX/TX queues + 1 queue per 1 worker is assumed as default + """ + + if num_queues > workers: + raise ConfigError( + f'The number of {qtype} queues cannot be greater than the number of configured VPP workers: ' + f'workers: {workers}, queues: {num_queues}' + ) diff --git a/smoketest/scripts/cli/test_vpp.py b/smoketest/scripts/cli/test_vpp.py index d8f7f3473..3dfbb4cc8 100755 --- a/smoketest/scripts/cli/test_vpp.py +++ b/smoketest/scripts/cli/test_vpp.py @@ -41,6 +41,7 @@ VPP_CONF = '/run/vpp/vpp.conf' base_path = ['vpp'] driver = 'dpdk' interface = 'eth1' +system_memory_path = ['system', 'option', 'kernel', 'memory'] def get_vpp_config(): @@ -96,6 +97,9 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): def setUp(self): self.cli_set(base_path + ['settings', 'interface', interface, 'driver', driver]) self.cli_set(base_path + ['settings', 'unix', 'poll-sleep-usec', '10']) + self.cli_set( + system_memory_path + ['hugepage-size', '2M', 'hugepage-count', '2048'] + ) def tearDown(self): try: @@ -110,6 +114,12 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): self.cli_delete(['interfaces', 'ethernet', interface, 'address']) self.cli_commit() + # delete kernel memory settings + self.cli_delete( + system_memory_path + ['hugepage-size', '2M', 'hugepage-count', '2048'] + ) + self.cli_commit() + self.assertFalse(os.path.exists(VPP_CONF)) self.assertFalse(process_named_running(PROCESS_NAME)) @@ -1084,15 +1094,25 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): dpdk_options = { 'num-rx-desc': '512', 'num-tx-desc': '512', - 'num-rx-queues': '3', - 'num-tx-queues': '3', + 'num-rx-queues': '1', + 'num-tx-queues': '1', } + main_core = '0' + workers = '1' base_interface_path = base_path + ['settings', 'interface', interface] for option, value in dpdk_options.items(): self.cli_set(base_interface_path + ['dpdk-options', option, value]) + # rx/tx queue configuration expect VPP workers to be set + # expect raise ConfigError + with self.assertRaises(ConfigSessionError): + self.cli_commit() + + self.cli_set(base_path + ['settings', 'cpu', 'main-core', main_core]) + self.cli_set(base_path + ['settings', 'cpu', 'workers', workers]) + # DPDK driver expect only dpdk-options and not xdp-options to be set # expect raise ConfigError self.cli_set(base_interface_path + ['xdp-options', 'no-syscall-lock']) @@ -1112,7 +1132,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): def test_11_vpp_cpu_settings(self): main_core = '2' - workers = '2' + workers = '1' skip_cores = '1' self.cli_set(base_path + ['settings', 'cpu', 'workers', workers]) @@ -1149,7 +1169,7 @@ class TestVPP(VyOSUnitTestSHIM.TestCase): def test_12_vpp_cpu_corelist_workers(self): main_core = '0' - corelist_workers = ['1', '2-3'] + corelist_workers = ['3'] for worker in corelist_workers: self.cli_set(base_path + ['settings', 'cpu', 'corelist-workers', worker]) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index d5778c778..757904dd5 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -18,7 +18,6 @@ from pathlib import Path -from psutil import virtual_memory from pyroute2 import IPRoute from vpp_papi import VPPIOError, VPPValueError @@ -28,7 +27,6 @@ from vyos.base import Warning from vyos.config import Config, config_dict_merge from vyos.configdep import set_dependents, call_dependents from vyos.configdict import node_changed, leaf_node_changed -from vyos.utils.cpu import get_core_count, get_available_cpus from vyos.ifconfig import Section from vyos.template import render from vyos.utils.boot import boot_configuration_complete @@ -38,14 +36,22 @@ from vyos.utils.system import sysctl_read, sysctl_apply from vyos.vpp import VPPControl from vyos.vpp import control_host from vyos.vpp.config_deps import deps_xconnect_dict -from vyos.vpp.config_verify import verify_dev_driver -from vyos.vpp.config_filter import iface_filter_eth -from vyos.vpp.utils import ( - EthtoolGDrvinfo, - human_page_memory_to_bytes, - human_memory_to_bytes, - bytes_to_human_memory, +from vyos.vpp.config_verify import ( + verify_dev_driver, + verify_vpp_minimum_cpus, + verify_vpp_minimum_memory, + verify_vpp_settings_cpu_and_corelist_workers, + verify_vpp_settings_cpu_corelist_workers, + verify_vpp_cpu_main_core, + verify_vpp_settings_cpu_skip_cores, + verify_vpp_settings_cpu_workers, + verify_vpp_nat44_workers, + verify_vpp_memory, + verify_vpp_statseg_size, + verify_vpp_interfaces_dpdk_num_queues, ) +from vyos.vpp.config_filter import iface_filter_eth +from vyos.vpp.utils import EthtoolGDrvinfo from vyos.vpp.configdb import JSONStorage airbag.enable() @@ -164,7 +170,7 @@ def get_config(config=None): # dictionary retrieved. default_values = conf.get_config_defaults(**config.kwargs, recursive=True) - # delete "xdp-options" from defaults if driver is DPDK + # delete 'xdp-options' from defaults if driver is DPDK for iface, iface_config in config.get('settings', {}).get('interface', {}).items(): if iface_config.get('driver') == 'dpdk': del default_values['settings']['interface'][iface]['xdp_options'] @@ -278,63 +284,21 @@ def get_config(config=None): eth_ifaces_persist[iface]['bus_id'] = control_host.get_bus_name(iface) eth_ifaces_persist[iface]['dev_id'] = control_host.get_dev_id(iface) + # Get kernel settings for hugepages + kernel_memory_settings = conf.get_config_dict( + ['system', 'option', 'kernel', 'memory'], + key_mangling=('-', '_'), + get_first_key=True, + no_tag_node_value_mangle=True, + ) + config['kernel_memory_settings'] = kernel_memory_settings + # Return to config dictionary config['persist_config'] = eth_ifaces_persist return config -def verify_memory(settings): - memory_available: int = virtual_memory().available - cpus: int = get_core_count() - - nr_hugepages = int(settings['host_resources']['nr_hugepages']) - hugepages_memory = nr_hugepages * 2 * 1024**2 - memory_required = hugepages_memory - - buffers_per_numa = int(settings.get('buffers', {}).get('buffers_per_numa', 16384)) - data_size = int(settings.get('buffers', {}).get('data_size', 2048)) - buffers_memory = buffers_per_numa * data_size * cpus - - memory_required += buffers_memory - - netlink_buffer_size = int( - settings.get('lcp', {}).get('netlink', {}).get('rx_buffer_size', 212992) - ) - memory_required += netlink_buffer_size - - memory_main_heap = human_memory_to_bytes( - settings.get('memory', {}).get('main_heap_size', '1G') - ) - - if memory_main_heap < 51 << 20: - # vpp is aborted when we try to use a smaller heap size - raise ConfigError('The main heap size must be greater than or equal to 51M') - - memory_main_heap_page_size = human_page_memory_to_bytes( - settings.get('memory', {}).get('main_heap_page_size', 'default') - ) - - if memory_main_heap_page_size > memory_main_heap: - raise ConfigError( - f'The main heap size must be greater than or equal to page-size({bytes_to_human_memory(memory_main_heap_page_size, "K")})' - ) - - memory_required += (memory_main_heap + memory_main_heap_page_size - 1) & ~( - memory_main_heap_page_size - 1 - ) - - statseg_size = human_memory_to_bytes(settings.get('statseg', {}).get('size', '96M')) - memory_required += statseg_size - - if memory_available < memory_required: - raise ConfigError( - 'Not enough free memory to start VPP:\n' - f'available: {round(memory_available / 1024 ** 3, 1)}GB\n' - f'required: {round(memory_required / 1024 ** 3, 1)}GB' - ) - - def verify(config): # bail out early - looks like removal from running config if not config or ('removed_ifaces' in config and 'settings' not in config): @@ -346,12 +310,64 @@ def verify(config): if 'interface' not in config['settings']: raise ConfigError('"settings interface" is required but not set!') + # check if the system meets minimal requirements + verify_vpp_minimum_cpus() + verify_vpp_minimum_memory() + # check if Ethernet interfaces exist ethernet_ifaces = Section.interfaces('ethernet') for iface in config['settings']['interface'].keys(): if iface not in ethernet_ifaces: raise ConfigError(f'Interface {iface} does not exist or is not Ethernet!') + # Resource usage checks + workers = 0 + + if 'cpu' in config['settings']: + cpu_settings = config['settings']['cpu'] + + # Check if there are enough CPU cores to skip according to config + if 'skip_cores' in cpu_settings: + skip_cores = int(cpu_settings['skip_cores']) + verify_vpp_settings_cpu_skip_cores(skip_cores) + + # Check whether the workers and corelist-workers are configured properly + verify_vpp_settings_cpu_and_corelist_workers(cpu_settings) + + # Check if there are enough CPU cores to add workers + if 'workers' in cpu_settings: + workers = verify_vpp_settings_cpu_workers(cpu_settings) + + if 'main_core' in cpu_settings: + verify_vpp_cpu_main_core(cpu_settings) + + # Check the CPU main core not falling to the corelist-workers + if 'corelist_workers' in cpu_settings: + workers = verify_vpp_settings_cpu_corelist_workers(cpu_settings) + + if 'workers' in config['settings']['nat44']: + verify_vpp_nat44_workers( + workers=workers, nat44_workers=config['settings']['nat44']['workers'] + ) + + # Check if available memory is enough for current VPP config + verify_vpp_memory(config) + + if 'host_resources' in config['settings']: + if ( + 'nr_hugepages' in config['settings']['host_resources'] + and 'max_map_count' in config['settings']['host_resources'] + ): + if int(config['settings']['host_resources']['max_map_count']) < 2 * int( + config['settings']['host_resources']['nr_hugepages'] + ): + raise ConfigError( + 'The max_map_count must be greater than or equal to (2 * nr_hugepages)' + ) + + if 'statseg' in config['settings']: + verify_vpp_statseg_size(config['settings']) + # ensure DPDK/XDP settings are properly configured for iface, iface_config in config['settings']['interface'].items(): # check if selected driver is supported, but only for new interfaces @@ -372,6 +388,19 @@ def verify(config): if iface_config['driver'] == 'dpdk' and 'xdp_options' in iface_config: raise ConfigError('XDP options are not applicable for DPDK driver!') + if iface_config['driver'] == 'dpdk' and 'dpdk_options' in iface_config: + if 'num_rx_queues' in iface_config['dpdk_options']: + rx_queues = int(iface_config['dpdk_options']['num_rx_queues']) + verify_vpp_interfaces_dpdk_num_queues( + qtype='receive', num_queues=rx_queues, workers=workers + ) + + if 'num_tx_queues' in iface_config['dpdk_options']: + tx_queues = int(iface_config['dpdk_options']['num_tx_queues']) + verify_vpp_interfaces_dpdk_num_queues( + qtype='transmit', num_queues=tx_queues, workers=workers + ) + # RX-mode verification rx_mode = iface_config.get('rx_mode') if rx_mode and rx_mode != 'polling': @@ -429,126 +458,6 @@ def verify(config): 'Only one multipoint GRE tunnel is allowed from the same source address' ) - workers = 0 - if 'cpu' in config['settings']: - if ( - 'corelist_workers' in config['settings']['cpu'] - or 'workers' in config['settings']['cpu'] - ) and 'main_core' not in config['settings']['cpu']: - raise ConfigError('"cpu main-core" is required but not set!') - - if ( - 'corelist_workers' in config['settings']['cpu'] - and 'workers' in config['settings']['cpu'] - ): - raise ConfigError( - '"cpu corelist-workers" and "cpu workers" cannot be used at the same time!' - ) - - cpus = int(get_core_count()) - skip_cores = 0 - - if 'skip_cores' in config['settings']['cpu']: - skip_cores = int(config['settings']['cpu']['skip_cores']) - # the number of skipped cores should not be more than all CPUs - 1 (for main core) - if skip_cores > cpus - 1: - raise ConfigError( - f'The system does not have enough available CPUs to skip ' - f'(reduce "cpu skip-cores" to {cpus - 1} or less)' - ) - - if 'workers' in config['settings']['cpu']: - # number of worker threads must be not more than - # available CPUs in the system - 1 for main thread - number of skipped cores - # or - 1 (at least) for system processes - workers = int(config['settings']['cpu']['workers']) - available_workers = cpus - 1 - (skip_cores or 1) - if workers > available_workers: - raise ConfigError( - f'The system does not have enough CPUs for {workers} VPP workers ' - f'(reduce to {available_workers} or less)' - ) - - cpus_available = list(map(lambda el: el['cpu'], get_available_cpus())) - # available CPUs are all CPUs without first N skipped cores that will not be used - cpus_available = cpus_available[skip_cores:] - - if 'main_core' in config['settings']['cpu']: - main_core = int(config['settings']['cpu']['main_core']) - - if main_core not in cpus_available: - raise ConfigError(f'"cpu main-core {main_core}" is not available!') - - # CPU main-core must be not included to corelist-workers - if config.get('settings').get('cpu', {}).get('corelist_workers'): - corelist_workers = config['settings']['cpu']['corelist_workers'] - - all_core_numbers = [] - for worker_range in corelist_workers: - core_numbers = worker_range.split('-') - if int(core_numbers[0]) > int(core_numbers[-1]): - raise ConfigError( - f'Range for "cpu corelist-workers {worker_range}" is not correct' - ) - all_core_numbers.extend( - range(int(core_numbers[0]), int(core_numbers[-1]) + 1) - ) - - if main_core in all_core_numbers: - raise ConfigError( - f'"cpu main-core {main_core}" must not be included in the corelist-workers!' - ) - - if not all(el in cpus_available for el in all_core_numbers): - raise ConfigError('"cpu corelist-workers" is not correct') - - workers = len(all_core_numbers) - - if 'workers' in config['settings']['nat44']: - nat_workers = [] - for worker_range in config['settings']['nat44']['workers']: - worker_numbers = worker_range.split('-') - if int(worker_numbers[0]) > int(worker_numbers[-1]): - raise ConfigError( - f'Range for "nat44 workers {worker_range}" is not correct' - ) - nat_workers.extend( - range(int(worker_numbers[0]), int(worker_numbers[-1]) + 1) - ) - if not all(el in list(range(workers)) for el in nat_workers): - raise ConfigError('"nat44 workers" is not correct') - - verify_memory(config['settings']) - if 'host_resources' in config['settings']: - if ( - 'nr_hugepages' in config['settings']['host_resources'] - and 'max_map_count' in config['settings']['host_resources'] - ): - if int(config['settings']['host_resources']['max_map_count']) < 2 * int( - config['settings']['host_resources']['nr_hugepages'] - ): - raise ConfigError( - 'The max_map_count must be greater than or equal to (2 * nr_hugepages)' - ) - - if 'statseg' in config['settings']: - _size = human_memory_to_bytes(config['settings']['statseg'].get('size', '96M')) - - if 'size' in config['settings']['statseg']: - if _size < 1 << 20: - raise ConfigError( - 'The statseg size must be greater than or equal to 1M' - ) - - if 'page_size' in config['settings']['statseg']: - _page_size = human_page_memory_to_bytes( - config['settings']['statseg']['page_size'] - ) - if _page_size > _size: - raise ConfigError( - f'The statseg size must be greater than or equal to page-size({bytes_to_human_memory(_page_size, "K")})' - ) - # Check if deleted interfaces are not xconnect memebrs for iface_config in config.get('removed_ifaces', []): if iface_config['iface_name'] in config.get('xconn_members', {}): -- cgit v1.2.3 From 1c94f3a6a6b92b629ca5a886c92ee6463e85e121 Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Wed, 25 Jun 2025 14:05:34 +0300 Subject: T7424: Add missing hugepage memory configuration for config load tests --- python/vyos/vpp/config_verify.py | 2 +- smoketest/config-tests/vpp | 1 + smoketest/configs/vpp | 7 +++++++ 3 files changed, 9 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py index 9cfbfb7cd..4505d205f 100644 --- a/python/vyos/vpp/config_verify.py +++ b/python/vyos/vpp/config_verify.py @@ -252,7 +252,7 @@ def verify_vpp_memory(config: dict): if memory_required > available_memory: raise ConfigError( - 'Not enough free memory to start VPP: ' + 'Not enough free hugepage memory to start VPP: ' f'available: {round(available_memory / 1024 ** 3, 1)} GB, ' f'required: {round(memory_required / 1024 ** 3, 1)} GB. ' 'Please add kernel memory options for HugePages and reboot' diff --git a/smoketest/config-tests/vpp b/smoketest/config-tests/vpp index 50d13990c..e42568965 100644 --- a/smoketest/config-tests/vpp +++ b/smoketest/config-tests/vpp @@ -15,6 +15,7 @@ set system console device ttyS0 speed '115200' set system host-name 'r16' set system login user vyos authentication encrypted-password '$6$rounds=656000$FZBlGpWsmSrV2Rvq$YPNAPtk4k6u99FAMxR6cw4DUPCgOomwCgRZRSO5rAoJK8RlMSCkVAFVF3ozL/3mZMfxcuCnwvd5HX6f9V5KzO.' set system name-server '203.0.113.1' +set system option kernel memory hugepage-size 2M hugepage-count '2048' set system option time-format '24-hour' set system syslog local facility all level 'info' set system syslog local facility local7 level 'debug' diff --git a/smoketest/configs/vpp b/smoketest/configs/vpp index e2223bc02..472c2919b 100644 --- a/smoketest/configs/vpp +++ b/smoketest/configs/vpp @@ -42,6 +42,13 @@ system { } name-server "203.0.113.1" option { + kernel { + memory { + hugepage-size 2M { + hugepage-count "2048" + } + } + } time-format "24-hour" } syslog { -- cgit v1.2.3 From ae000ac9ff6455a08d42b2931630c6f311de25c5 Mon Sep 17 00:00:00 2001 From: Daniil Baturin Date: Thu, 26 Jun 2025 14:36:17 +0100 Subject: build: T7580: add support for standalone and virtual tag nodes to the op mode cache generator --- python/vyos/xml_ref/generate_op_cache.py | 23 +++++++++++++++++++++-- python/vyos/xml_ref/op_definition.py | 3 +++ 2 files changed, 24 insertions(+), 2 deletions(-) (limited to 'python') diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index 117b080b4..9e9d9674c 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -140,9 +140,16 @@ def insert_node( prop: OptElement = n.find('properties') children: OptElement = n.find('children') command: OptElement = n.find('command') - # name is not None as required by schema - name: str = n.get('name', 'schema_error') + standalone: OptElement = n.find('standalone') node_type: str = n.tag + + if node_type == 'virtualTagNode': + name = '__virtual_tag' + else: + name = n.get('name') + if not name: + raise ValueError("Node name is required for all node types except ") + if path is None: path = [] @@ -156,6 +163,16 @@ def insert_node( if command_text is not None: command_text = translate_command(command_text, path) + try: + standalone_command = translate_command(standalone.find('command').text, path) + except AttributeError: + standalone_command = None + + try: + standalone_help_text = translate_command(standalone.find('help').text, path) + except AttributeError: + standalone_help_text = None + comp_help = {} if prop is not None: che = prop.findall('completionHelp') @@ -191,6 +208,8 @@ def insert_node( cur_node_data.comp_help = comp_help cur_node_data.help_text = help_text cur_node_data.command = command_text + cur_node_data.standalone_help_text = standalone_help_text + cur_node_data.standalone_command = standalone_command cur_node_data.path = path cur_node_data.file = file diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index 8e922ecb2..6a8368118 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -15,6 +15,7 @@ from typing import TypeAlias from typing import Union +from typing import Optional from typing import Iterator from dataclasses import dataclass from dataclasses import field @@ -31,6 +32,8 @@ class NodeData: help_text: str = '' comp_help: dict[str, list] = field(default_factory=dict) command: str = '' + standalone_help_text: Optional[str] = None + standalone_command: Optional[str] = None path: list[str] = field(default_factory=list) file: str = '' children: list[tuple] = field(default_factory=list) -- cgit v1.2.3 From c741a290261eb53d5f9ca4849109f19ced8fda9f Mon Sep 17 00:00:00 2001 From: Andrew Topp Date: Fri, 27 Jun 2025 00:23:13 +1000 Subject: vrf: T7544: Ensure correct quoting for VRF ifnames in nftables * For VRF create/delete: * Simple dquoting, as before, was parsed away by the shell * Just escaping the double quotes could cause issues with the shell mangling VRF names (however unlikely) * Wrapping original quotes in shell-escaped single quotes is a quick & easy way to guard against both improper shell parsing and string names being taken as nft keywords. * Firewall configuration: * Firewall "interface name" rules support VRF ifnames and used them unquoted, fixed for nft_rule template tags (parse_rule) * Went through and quoted all iif/oifname usage by zones and interface groups. VRF ifnames weren't available for all cases, but there is no harm in completeness. * For this, also created a simple quoted_join template filter to replace any use of |join(',') * PBR calls nft but doesn't mind the "vni" name - table IDs used instead I may have missed some niche nft use-cases that would be exposed to this problem. --- data/templates/firewall/nftables-defines.j2 | 2 +- data/templates/firewall/nftables-zone.j2 | 36 ++++++++++++++--------------- python/vyos/firewall.py | 4 ++-- python/vyos/ifconfig/interface.py | 4 ++-- python/vyos/template.py | 4 ++++ src/conf_mode/vrf.py | 4 ++-- 6 files changed, 29 insertions(+), 25 deletions(-) (limited to 'python') diff --git a/data/templates/firewall/nftables-defines.j2 b/data/templates/firewall/nftables-defines.j2 index a1d1fa4f6..c4b6b7eba 100644 --- a/data/templates/firewall/nftables-defines.j2 +++ b/data/templates/firewall/nftables-defines.j2 @@ -111,7 +111,7 @@ flags interval auto-merge {% if group_conf.interface is vyos_defined or includes %} - elements = { {{ group_conf.interface | nft_nested_group(includes, group.interface_group, 'interface') | join(",") }} } + elements = { {{ group_conf.interface | nft_nested_group(includes, group.interface_group, 'interface') | quoted_join(",") }} } {% endif %} } {% endfor %} diff --git a/data/templates/firewall/nftables-zone.j2 b/data/templates/firewall/nftables-zone.j2 index 645a38706..66f7e0b1c 100644 --- a/data/templates/firewall/nftables-zone.j2 +++ b/data/templates/firewall/nftables-zone.j2 @@ -9,11 +9,11 @@ {% for zone_name, zone_conf in zone.items() %} {% if 'local_zone' not in zone_conf %} {% if 'interface' in zone_conf.member %} - oifname { {{ zone_conf.member.interface | join(',') }} } counter jump VZONE_{{ zone_name }} + oifname { {{ zone_conf.member.interface | quoted_join(',') }} } counter jump VZONE_{{ zone_name }} {% endif %} {% if 'vrf' in zone_conf.member %} {% for vrf_name in zone_conf.member.vrf %} - oifname { {{ zone_conf['vrf_interfaces'][vrf_name] }} } counter jump VZONE_{{ zone_name }} + oifname { "{{ zone_conf['vrf_interfaces'][vrf_name] }}" } counter jump VZONE_{{ zone_name }} {% endfor %} {% endif %} {% endif %} @@ -49,12 +49,12 @@ {% for from_zone, from_conf in zone_conf.from.items() if from_conf.firewall[fw_name] is vyos_defined %} {% if 'interface' in zone[from_zone].member %} - iifname { {{ zone[from_zone].member.interface | join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} - iifname { {{ zone[from_zone].member.interface | join(",") }} } counter return + iifname { {{ zone[from_zone].member.interface | quoted_join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} + iifname { {{ zone[from_zone].member.interface | quoted_join(",") }} } counter return {% endif %} {% if 'vrf' in zone[from_zone].member %} - iifname { {{ zone[from_zone].member.vrf | join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} - iifname { {{ zone[from_zone].member.vrf | join(",") }} } counter return + iifname { {{ zone[from_zone].member.vrf | quoted_join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} + iifname { {{ zone[from_zone].member.vrf | quoted_join(",") }} } counter return {% endif %} {% endfor %} {% endif %} @@ -65,13 +65,13 @@ {% if zone_conf.from_local is vyos_defined %} {% for from_zone, from_conf in zone_conf.from_local.items() if from_conf.firewall[fw_name] is vyos_defined %} {% if 'interface' in zone[from_zone].member %} - oifname { {{ zone[from_zone].member.interface | join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} - oifname { {{ zone[from_zone].member.interface | join(",") }} } counter return + oifname { {{ zone[from_zone].member.interface | quoted_join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} + oifname { {{ zone[from_zone].member.interface | quoted_join(",") }} } counter return {% endif %} {% if 'vrf' in zone[from_zone].member %} {% for vrf_name in zone[from_zone].member.vrf %} - oifname { {{ zone[from_zone]['vrf_interfaces'][vrf_name] }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} - oifname { {{ zone[from_zone]['vrf_interfaces'][vrf_name] }} } counter return + oifname { "{{ zone[from_zone]['vrf_interfaces'][vrf_name] }}" } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} + oifname { "{{ zone[from_zone]['vrf_interfaces'][vrf_name] }}" } counter return {% endfor %} {% endif %} {% endfor %} @@ -81,29 +81,29 @@ {% else %} chain VZONE_{{ zone_name }} { {% if 'interface' in zone_conf.member %} - iifname { {{ zone_conf.member.interface | join(",") }} } counter {{ zone_conf | nft_intra_zone_action(ipv6) }} + iifname { {{ zone_conf.member.interface | quoted_join(",") }} } counter {{ zone_conf | nft_intra_zone_action(ipv6) }} {% endif %} {% if 'vrf' in zone_conf.member %} - iifname { {{ zone_conf.member.vrf | join(",") }} } counter {{ zone_conf | nft_intra_zone_action(ipv6) }} + iifname { {{ zone_conf.member.vrf | quoted_join(",") }} } counter {{ zone_conf | nft_intra_zone_action(ipv6) }} {% endif %} {% if zone_conf.intra_zone_filtering is vyos_defined %} {% if 'interface' in zone_conf.member %} - iifname { {{ zone_conf.member.interface | join(",") }} } counter return + iifname { {{ zone_conf.member.interface | quoted_join(",") }} } counter return {% endif %} {% if 'vrf' in zone_conf.member %} - iifname { {{ zone_conf.member.vrf | join(",") }} } counter return + iifname { {{ zone_conf.member.vrf | quoted_join(",") }} } counter return {% endif %} {% endif %} {% if zone_conf.from is vyos_defined %} {% for from_zone, from_conf in zone_conf.from.items() if from_conf.firewall[fw_name] is vyos_defined %} {% if zone[from_zone].local_zone is not defined %} {% if 'interface' in zone[from_zone].member %} - iifname { {{ zone[from_zone].member.interface | join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} - iifname { {{ zone[from_zone].member.interface | join(",") }} } counter return + iifname { {{ zone[from_zone].member.interface | quoted_join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} + iifname { {{ zone[from_zone].member.interface | quoted_join(",") }} } counter return {% endif %} {% if 'vrf' in zone[from_zone].member %} - iifname { {{ zone[from_zone].member.vrf | join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} - iifname { {{ zone[from_zone].member.vrf | join(",") }} } counter return + iifname { {{ zone[from_zone].member.vrf | quoted_join(",") }} } counter jump NAME{{ suffix }}_{{ from_conf.firewall[fw_name] }} + iifname { {{ zone[from_zone].member.vrf | quoted_join(",") }} } counter return {% endif %} {% endif %} {% endfor %} diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 64022db84..0643107a9 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -361,7 +361,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if iiface[0] == '!': operator = '!=' iiface = iiface[1:] - output.append(f'iifname {operator} {{{iiface}}}') + output.append(f'iifname {operator} {{"{iiface}"}}') elif 'group' in rule_conf['inbound_interface']: iiface = rule_conf['inbound_interface']['group'] if iiface[0] == '!': @@ -376,7 +376,7 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name): if oiface[0] == '!': operator = '!=' oiface = oiface[1:] - output.append(f'oifname {operator} {{{oiface}}}') + output.append(f'oifname {operator} {{"{oiface}"}}') elif 'group' in rule_conf['outbound_interface']: oiface = rule_conf['outbound_interface']['group'] if oiface[0] == '!': diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 91b3a0c28..33c6830bc 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -423,11 +423,11 @@ class Interface(Control): self._cmd(f'nft {nft_command}') def _del_interface_from_ct_iface_map(self): - nft_command = f'delete element inet vrf_zones ct_iface_map {{ "{self.ifname}" }}' + nft_command = f'delete element inet vrf_zones ct_iface_map {{ \'"{self.ifname}"\' }}' self._nft_check_and_run(nft_command) def _add_interface_to_ct_iface_map(self, vrf_table_id: int): - nft_command = f'add element inet vrf_zones ct_iface_map {{ "{self.ifname}" : {vrf_table_id} }}' + nft_command = f'add element inet vrf_zones ct_iface_map {{ \'"{self.ifname}"\' : {vrf_table_id} }}' self._nft_check_and_run(nft_command) def get_ifindex(self): diff --git a/python/vyos/template.py b/python/vyos/template.py index bf2f13183..c6e35e9c7 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -582,6 +582,10 @@ def snmp_auth_oid(type): } return OIDs[type] +@register_filter('quoted_join') +def quoted_join(input_list, join_str, quote='"'): + return str(join_str).join(f'{quote}{elem}{quote}' for elem in input_list) + @register_filter('nft_action') def nft_action(vyos_action): if vyos_action == 'accept': diff --git a/src/conf_mode/vrf.py b/src/conf_mode/vrf.py index 6e9d4147a..00a202df4 100755 --- a/src/conf_mode/vrf.py +++ b/src/conf_mode/vrf.py @@ -240,7 +240,7 @@ def apply(vrf): vrf_iface.set_dhcpv6(False) # Remove nftables conntrack zone map item - nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ "{tmp}" }}' + nft_del_element = f'delete element inet vrf_zones ct_iface_map {{ \'"{tmp}"\' }}' # Check if deleting is possible first to avoid raising errors _, err = popen(f'nft --check {nft_del_element}') if not err: @@ -320,7 +320,7 @@ def apply(vrf): state = 'down' if 'disable' in config else 'up' vrf_if.set_admin_state(state) # Add nftables conntrack zone map item - nft_add_element = f'add element inet vrf_zones ct_iface_map {{ "{name}" : {table} }}' + nft_add_element = f'add element inet vrf_zones ct_iface_map {{ \'"{name}"\' : {table} }}' cmd(f'nft {nft_add_element}') # Only call into nftables as long as there is nothing setup to avoid wasting -- cgit v1.2.3 From 8f2eac38f1b7bb132bf7782596899315745a713a Mon Sep 17 00:00:00 2001 From: Daniil Baturin Date: Wed, 25 Jun 2025 18:15:29 +0100 Subject: build: T7578: fail the package build if there are non-unique op mode nodes to ensure that the JSON cache is usable for command lookup --- Makefile | 2 +- python/vyos/xml_ref/generate_op_cache.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/Makefile b/Makefile index d3248c8d2..e85ccd7f4 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ op_mode_definitions: $(op_xml_obj) find $(BUILD_DIR)/op-mode-definitions/ -type f -name "*.xml" | xargs -I {} $(CURDIR)/scripts/build-command-op-templates {} $(CURDIR)/schema/op-mode-definition.rng $(OP_TMPL_DIR) || exit 1 - $(CURDIR)/python/vyos/xml_ref/generate_op_cache.py --xml-dir $(BUILD_DIR)/op-mode-definitions || exit 1 + $(CURDIR)/python/vyos/xml_ref/generate_op_cache.py --check-path-ambiguity --xml-dir $(BUILD_DIR)/op-mode-definitions || exit 1 # XXX: tcpdump, ping, traceroute and mtr must be able to recursivly call themselves as the # options are provided from the scripts themselves diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index 117b080b4..29697dc58 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -280,6 +280,7 @@ def main(): else: print('Found the following duplicate paths:\n') print(out) + sys.exit(1) with open(op_ref_cache, 'w') as f: f.write('from vyos.xml_ref.op_definition import NodeData\n') -- cgit v1.2.3 From 1478516ae437f19ebeb7d6ff9b83dd74f8e76758 Mon Sep 17 00:00:00 2001 From: Christian Breunig Date: Sat, 28 Jun 2025 20:51:21 +0200 Subject: T7591: remove copyright years from source files The legal team says years are not necessary so we can go ahead with it, since it will simplify backporting. Automatically removed using: git ls-files | grep -v libvyosconfig | xargs sed -i -E \ 's/^# Copyright (19|20)[0-9]{2}(-[0-9]{4})? VyOS maintainers.*/# Copyright VyOS maintainers and contributors /g' In addition we will error-out during "make" if someone re-adds a legacy copyright notice --- Makefile | 9 ++++++++- debian/copyright | 6 +++--- python/vyos/accel_ppp.py | 2 +- python/vyos/accel_ppp_util.py | 2 +- python/vyos/airbag.py | 2 +- python/vyos/base.py | 2 +- python/vyos/component_version.py | 2 +- python/vyos/compose_config.py | 2 +- python/vyos/config.py | 2 +- python/vyos/config_mgmt.py | 2 +- python/vyos/configdep.py | 2 +- python/vyos/configdict.py | 2 +- python/vyos/configdiff.py | 2 +- python/vyos/configquery.py | 2 +- python/vyos/configsession.py | 2 +- python/vyos/configsource.py | 2 +- python/vyos/configtree.py | 2 +- python/vyos/configverify.py | 2 +- python/vyos/debug.py | 2 +- python/vyos/defaults.py | 2 +- python/vyos/ethtool.py | 2 +- python/vyos/firewall.py | 2 +- python/vyos/frrender.py | 2 +- python/vyos/ifconfig/__init__.py | 2 +- python/vyos/ifconfig/afi.py | 2 +- python/vyos/ifconfig/bond.py | 2 +- python/vyos/ifconfig/bridge.py | 2 +- python/vyos/ifconfig/control.py | 2 +- python/vyos/ifconfig/dummy.py | 2 +- python/vyos/ifconfig/ethernet.py | 2 +- python/vyos/ifconfig/geneve.py | 2 +- python/vyos/ifconfig/input.py | 2 +- python/vyos/ifconfig/interface.py | 2 +- python/vyos/ifconfig/l2tpv3.py | 2 +- python/vyos/ifconfig/loopback.py | 2 +- python/vyos/ifconfig/macsec.py | 2 +- python/vyos/ifconfig/macvlan.py | 2 +- python/vyos/ifconfig/operational.py | 2 +- python/vyos/ifconfig/pppoe.py | 2 +- python/vyos/ifconfig/section.py | 2 +- python/vyos/ifconfig/sstpc.py | 2 +- python/vyos/ifconfig/tunnel.py | 2 +- python/vyos/ifconfig/veth.py | 2 +- python/vyos/ifconfig/vrrp.py | 2 +- python/vyos/ifconfig/vti.py | 2 +- python/vyos/ifconfig/vtun.py | 2 +- python/vyos/ifconfig/vxlan.py | 2 +- python/vyos/ifconfig/wireguard.py | 2 +- python/vyos/ifconfig/wireless.py | 2 +- python/vyos/ifconfig/wwan.py | 2 +- python/vyos/iflag.py | 2 +- python/vyos/include/__init__.py | 2 +- python/vyos/include/uapi/__init__.py | 2 +- python/vyos/include/uapi/linux/__init__.py | 2 +- python/vyos/include/uapi/linux/fib_rules.py | 2 +- python/vyos/include/uapi/linux/icmpv6.py | 2 +- python/vyos/include/uapi/linux/if_arp.py | 2 +- python/vyos/include/uapi/linux/lwtunnel.py | 2 +- python/vyos/include/uapi/linux/neighbour.py | 2 +- python/vyos/include/uapi/linux/rtnetlink.py | 2 +- python/vyos/initialsetup.py | 2 +- python/vyos/ioctl.py | 2 +- python/vyos/ipsec.py | 2 +- python/vyos/kea.py | 2 +- python/vyos/limericks.py | 2 +- python/vyos/load_config.py | 2 +- python/vyos/logger.py | 2 +- python/vyos/migrate.py | 2 +- python/vyos/nat.py | 2 +- python/vyos/opmode.py | 2 +- python/vyos/pki.py | 2 +- python/vyos/priority.py | 2 +- python/vyos/progressbar.py | 2 +- python/vyos/proto/generate_dataclass.py | 2 +- python/vyos/proto/vyconf_client.py | 2 +- python/vyos/qos/__init__.py | 2 +- python/vyos/qos/base.py | 2 +- python/vyos/qos/cake.py | 2 +- python/vyos/qos/droptail.py | 2 +- python/vyos/qos/fairqueue.py | 2 +- python/vyos/qos/fqcodel.py | 2 +- python/vyos/qos/limiter.py | 2 +- python/vyos/qos/netem.py | 2 +- python/vyos/qos/priority.py | 2 +- python/vyos/qos/randomdetect.py | 2 +- python/vyos/qos/ratelimiter.py | 2 +- python/vyos/qos/roundrobin.py | 2 +- python/vyos/qos/trafficshaper.py | 2 +- python/vyos/raid.py | 2 +- python/vyos/remote.py | 2 +- python/vyos/snmpv3_hashgen.py | 2 +- python/vyos/system/__init__.py | 2 +- python/vyos/system/compat.py | 2 +- python/vyos/system/disk.py | 2 +- python/vyos/system/grub.py | 2 +- python/vyos/system/grub_util.py | 2 +- python/vyos/system/image.py | 2 +- python/vyos/system/raid.py | 2 +- python/vyos/template.py | 2 +- python/vyos/tpm.py | 2 +- python/vyos/utils/__init__.py | 2 +- python/vyos/utils/assertion.py | 2 +- python/vyos/utils/auth.py | 2 +- python/vyos/utils/backend.py | 2 +- python/vyos/utils/boot.py | 2 +- python/vyos/utils/commit.py | 2 +- python/vyos/utils/config.py | 2 +- python/vyos/utils/configfs.py | 2 +- python/vyos/utils/convert.py | 2 +- python/vyos/utils/cpu.py | 2 +- python/vyos/utils/dict.py | 2 +- python/vyos/utils/disk.py | 2 +- python/vyos/utils/error.py | 2 +- python/vyos/utils/file.py | 2 +- python/vyos/utils/io.py | 2 +- python/vyos/utils/kernel.py | 2 +- python/vyos/utils/list.py | 2 +- python/vyos/utils/locking.py | 2 +- python/vyos/utils/misc.py | 2 +- python/vyos/utils/network.py | 2 +- python/vyos/utils/permission.py | 2 +- python/vyos/utils/process.py | 2 +- python/vyos/utils/serial.py | 2 +- python/vyos/utils/session.py | 2 +- python/vyos/utils/strip_config.py | 2 +- python/vyos/utils/system.py | 2 +- python/vyos/utils/vti_updown_db.py | 2 +- python/vyos/version.py | 2 +- python/vyos/vyconf_session.py | 2 +- python/vyos/wanloadbalance.py | 2 +- python/vyos/xml_ref/__init__.py | 2 +- python/vyos/xml_ref/definition.py | 2 +- python/vyos/xml_ref/generate_cache.py | 2 +- python/vyos/xml_ref/generate_op_cache.py | 2 +- python/vyos/xml_ref/op_definition.py | 2 +- python/vyos/xml_ref/update_cache.py | 2 +- schema/interface_definition.rnc | 2 +- schema/interface_definition.rng | 10 +++++----- schema/op-mode-definition.rnc | 2 +- schema/op-mode-definition.rng | 10 +++++----- scripts/build-command-op-templates | 2 +- scripts/build-command-templates | 2 +- scripts/generate-configd-include-json.py | 2 +- scripts/override-default | 2 +- scripts/transclude-template | 2 +- smoketest/bin/vyos-configtest | 2 +- smoketest/bin/vyos-configtest-pki | 2 +- smoketest/bin/vyos-smoketest | 2 +- smoketest/scripts/cli/base_accel_ppp_test.py | 2 +- smoketest/scripts/cli/base_interfaces_test.py | 2 +- smoketest/scripts/cli/base_vyostest_shim.py | 2 +- smoketest/scripts/cli/test_backslash_escape.py | 2 +- smoketest/scripts/cli/test_cgnat.py | 2 +- smoketest/scripts/cli/test_config_dependency.py | 2 +- smoketest/scripts/cli/test_configd_init.py | 2 +- smoketest/scripts/cli/test_container.py | 2 +- smoketest/scripts/cli/test_firewall.py | 2 +- smoketest/scripts/cli/test_high-availability_virtual-server.py | 2 +- smoketest/scripts/cli/test_high-availability_vrrp.py | 2 +- smoketest/scripts/cli/test_interfaces_bonding.py | 2 +- smoketest/scripts/cli/test_interfaces_bridge.py | 2 +- smoketest/scripts/cli/test_interfaces_dummy.py | 2 +- smoketest/scripts/cli/test_interfaces_ethernet.py | 2 +- smoketest/scripts/cli/test_interfaces_geneve.py | 2 +- smoketest/scripts/cli/test_interfaces_input.py | 2 +- smoketest/scripts/cli/test_interfaces_l2tpv3.py | 2 +- smoketest/scripts/cli/test_interfaces_loopback.py | 2 +- smoketest/scripts/cli/test_interfaces_macsec.py | 2 +- smoketest/scripts/cli/test_interfaces_openvpn.py | 2 +- smoketest/scripts/cli/test_interfaces_pppoe.py | 2 +- smoketest/scripts/cli/test_interfaces_pseudo-ethernet.py | 2 +- smoketest/scripts/cli/test_interfaces_tunnel.py | 2 +- smoketest/scripts/cli/test_interfaces_virtual-ethernet.py | 2 +- smoketest/scripts/cli/test_interfaces_vti.py | 2 +- smoketest/scripts/cli/test_interfaces_vxlan.py | 2 +- smoketest/scripts/cli/test_interfaces_wireguard.py | 2 +- smoketest/scripts/cli/test_interfaces_wireless.py | 2 +- smoketest/scripts/cli/test_load-balancing_haproxy.py | 2 +- smoketest/scripts/cli/test_load-balancing_wan.py | 2 +- smoketest/scripts/cli/test_nat.py | 2 +- smoketest/scripts/cli/test_nat64.py | 2 +- smoketest/scripts/cli/test_nat66.py | 2 +- smoketest/scripts/cli/test_netns.py | 2 +- smoketest/scripts/cli/test_op-mode_show.py | 2 +- smoketest/scripts/cli/test_pki.py | 2 +- smoketest/scripts/cli/test_policy.py | 2 +- smoketest/scripts/cli/test_policy_local-route.py | 2 +- smoketest/scripts/cli/test_policy_route.py | 2 +- smoketest/scripts/cli/test_protocols_babel.py | 2 +- smoketest/scripts/cli/test_protocols_bfd.py | 2 +- smoketest/scripts/cli/test_protocols_bgp.py | 2 +- smoketest/scripts/cli/test_protocols_igmp-proxy.py | 2 +- smoketest/scripts/cli/test_protocols_isis.py | 2 +- smoketest/scripts/cli/test_protocols_mpls.py | 2 +- smoketest/scripts/cli/test_protocols_nhrp.py | 2 +- smoketest/scripts/cli/test_protocols_openfabric.py | 2 +- smoketest/scripts/cli/test_protocols_ospf.py | 2 +- smoketest/scripts/cli/test_protocols_ospfv3.py | 2 +- smoketest/scripts/cli/test_protocols_pim.py | 2 +- smoketest/scripts/cli/test_protocols_pim6.py | 2 +- smoketest/scripts/cli/test_protocols_rip.py | 2 +- smoketest/scripts/cli/test_protocols_ripng.py | 2 +- smoketest/scripts/cli/test_protocols_rpki.py | 2 +- smoketest/scripts/cli/test_protocols_segment-routing.py | 2 +- smoketest/scripts/cli/test_protocols_static.py | 2 +- smoketest/scripts/cli/test_protocols_static_arp.py | 2 +- smoketest/scripts/cli/test_qos.py | 2 +- smoketest/scripts/cli/test_service_broadcast-relay.py | 2 +- smoketest/scripts/cli/test_service_dhcp-relay.py | 2 +- smoketest/scripts/cli/test_service_dhcp-server.py | 2 +- smoketest/scripts/cli/test_service_dhcpv6-relay.py | 2 +- smoketest/scripts/cli/test_service_dhcpv6-server.py | 2 +- smoketest/scripts/cli/test_service_dns_dynamic.py | 2 +- smoketest/scripts/cli/test_service_dns_forwarding.py | 2 +- smoketest/scripts/cli/test_service_https.py | 2 +- smoketest/scripts/cli/test_service_ipoe-server.py | 2 +- smoketest/scripts/cli/test_service_lldp.py | 2 +- smoketest/scripts/cli/test_service_mdns_repeater.py | 2 +- smoketest/scripts/cli/test_service_monitoring_network_event.py | 2 +- smoketest/scripts/cli/test_service_monitoring_prometheus.py | 2 +- smoketest/scripts/cli/test_service_monitoring_telegraf.py | 2 +- smoketest/scripts/cli/test_service_monitoring_zabbix-agent.py | 2 +- smoketest/scripts/cli/test_service_ndp-proxy.py | 2 +- smoketest/scripts/cli/test_service_ntp.py | 2 +- smoketest/scripts/cli/test_service_pppoe-server.py | 2 +- smoketest/scripts/cli/test_service_router-advert.py | 2 +- smoketest/scripts/cli/test_service_salt-minion.py | 2 +- smoketest/scripts/cli/test_service_snmp.py | 2 +- smoketest/scripts/cli/test_service_ssh.py | 2 +- smoketest/scripts/cli/test_service_stunnel.py | 2 +- smoketest/scripts/cli/test_service_tftp-server.py | 2 +- smoketest/scripts/cli/test_service_webproxy.py | 2 +- smoketest/scripts/cli/test_system_acceleration_qat.py | 2 +- smoketest/scripts/cli/test_system_conntrack.py | 2 +- smoketest/scripts/cli/test_system_flow-accounting.py | 2 +- smoketest/scripts/cli/test_system_frr.py | 2 +- smoketest/scripts/cli/test_system_ip.py | 2 +- smoketest/scripts/cli/test_system_ipv6.py | 2 +- smoketest/scripts/cli/test_system_login.py | 2 +- smoketest/scripts/cli/test_system_logs.py | 2 +- smoketest/scripts/cli/test_system_option.py | 2 +- smoketest/scripts/cli/test_system_resolvconf.py | 2 +- smoketest/scripts/cli/test_system_sflow.py | 2 +- smoketest/scripts/cli/test_system_syslog.py | 2 +- smoketest/scripts/cli/test_vpn_ipsec.py | 2 +- smoketest/scripts/cli/test_vpn_l2tp.py | 2 +- smoketest/scripts/cli/test_vpn_openconnect.py | 2 +- smoketest/scripts/cli/test_vpn_pptp.py | 2 +- smoketest/scripts/cli/test_vpn_sstp.py | 2 +- smoketest/scripts/cli/test_vrf.py | 2 +- smoketest/scripts/system/test_config_mount.py | 2 +- smoketest/scripts/system/test_iproute2.py | 2 +- smoketest/scripts/system/test_kernel_options.py | 2 +- smoketest/scripts/system/test_module_load.py | 2 +- src/activation-scripts/20-ethernet_offload.py | 2 +- src/completion/list_bgp_neighbors.sh | 2 +- src/completion/list_container_sysctl_parameters.sh | 2 +- src/completion/list_ddclient_protocols.sh | 2 +- src/completion/list_disks.py | 2 +- src/completion/list_esi.sh | 2 +- src/completion/list_images.py | 2 +- src/completion/list_ipoe.py | 2 +- src/completion/list_ipsec_profile_tunnels.py | 2 +- src/completion/list_login_ttys.py | 2 +- src/completion/list_openconnect_users.py | 2 +- src/completion/list_openvpn_clients.py | 2 +- src/completion/list_openvpn_users.py | 2 +- src/completion/list_sysctl_parameters.sh | 2 +- src/completion/list_vni.sh | 2 +- src/completion/qos/list_traffic_match_group.py | 2 +- src/conf_mode/container.py | 2 +- src/conf_mode/firewall.py | 2 +- src/conf_mode/high-availability.py | 2 +- src/conf_mode/interfaces_bonding.py | 2 +- src/conf_mode/interfaces_bridge.py | 2 +- src/conf_mode/interfaces_dummy.py | 2 +- src/conf_mode/interfaces_ethernet.py | 2 +- src/conf_mode/interfaces_geneve.py | 2 +- src/conf_mode/interfaces_input.py | 2 +- src/conf_mode/interfaces_l2tpv3.py | 2 +- src/conf_mode/interfaces_loopback.py | 2 +- src/conf_mode/interfaces_macsec.py | 2 +- src/conf_mode/interfaces_openvpn.py | 2 +- src/conf_mode/interfaces_pppoe.py | 2 +- src/conf_mode/interfaces_pseudo-ethernet.py | 2 +- src/conf_mode/interfaces_sstpc.py | 2 +- src/conf_mode/interfaces_tunnel.py | 2 +- src/conf_mode/interfaces_virtual-ethernet.py | 2 +- src/conf_mode/interfaces_vti.py | 2 +- src/conf_mode/interfaces_vxlan.py | 2 +- src/conf_mode/interfaces_wireguard.py | 2 +- src/conf_mode/interfaces_wireless.py | 2 +- src/conf_mode/interfaces_wwan.py | 2 +- src/conf_mode/load-balancing_haproxy.py | 2 +- src/conf_mode/load-balancing_wan.py | 2 +- src/conf_mode/nat.py | 2 +- src/conf_mode/nat64.py | 2 +- src/conf_mode/nat66.py | 2 +- src/conf_mode/nat_cgnat.py | 2 +- src/conf_mode/netns.py | 2 +- src/conf_mode/pki.py | 2 +- src/conf_mode/policy.py | 2 +- src/conf_mode/policy_local-route.py | 2 +- src/conf_mode/policy_route.py | 2 +- src/conf_mode/protocols_babel.py | 2 +- src/conf_mode/protocols_bfd.py | 2 +- src/conf_mode/protocols_bgp.py | 2 +- src/conf_mode/protocols_eigrp.py | 2 +- src/conf_mode/protocols_failover.py | 2 +- src/conf_mode/protocols_igmp-proxy.py | 2 +- src/conf_mode/protocols_isis.py | 2 +- src/conf_mode/protocols_mpls.py | 2 +- src/conf_mode/protocols_nhrp.py | 2 +- src/conf_mode/protocols_openfabric.py | 2 +- src/conf_mode/protocols_ospf.py | 2 +- src/conf_mode/protocols_ospfv3.py | 2 +- src/conf_mode/protocols_pim.py | 2 +- src/conf_mode/protocols_pim6.py | 2 +- src/conf_mode/protocols_rip.py | 2 +- src/conf_mode/protocols_ripng.py | 2 +- src/conf_mode/protocols_rpki.py | 2 +- src/conf_mode/protocols_segment-routing.py | 2 +- src/conf_mode/protocols_static.py | 2 +- src/conf_mode/protocols_static_arp.py | 2 +- src/conf_mode/protocols_static_neighbor-proxy.py | 2 +- src/conf_mode/qos.py | 2 +- src/conf_mode/service_aws_glb.py | 2 +- src/conf_mode/service_broadcast-relay.py | 2 +- src/conf_mode/service_config-sync.py | 2 +- src/conf_mode/service_conntrack-sync.py | 2 +- src/conf_mode/service_console-server.py | 2 +- src/conf_mode/service_dhcp-relay.py | 2 +- src/conf_mode/service_dhcp-server.py | 2 +- src/conf_mode/service_dhcpv6-relay.py | 2 +- src/conf_mode/service_dhcpv6-server.py | 2 +- src/conf_mode/service_dns_dynamic.py | 2 +- src/conf_mode/service_dns_forwarding.py | 2 +- src/conf_mode/service_event-handler.py | 2 +- src/conf_mode/service_https.py | 2 +- src/conf_mode/service_ipoe-server.py | 2 +- src/conf_mode/service_lldp.py | 2 +- src/conf_mode/service_mdns_repeater.py | 2 +- src/conf_mode/service_monitoring_network_event.py | 2 +- src/conf_mode/service_monitoring_prometheus.py | 2 +- src/conf_mode/service_monitoring_telegraf.py | 2 +- src/conf_mode/service_monitoring_zabbix-agent.py | 2 +- src/conf_mode/service_ndp-proxy.py | 2 +- src/conf_mode/service_ntp.py | 2 +- src/conf_mode/service_pppoe-server.py | 2 +- src/conf_mode/service_router-advert.py | 2 +- src/conf_mode/service_salt-minion.py | 2 +- src/conf_mode/service_sla.py | 2 +- src/conf_mode/service_snmp.py | 2 +- src/conf_mode/service_ssh.py | 2 +- src/conf_mode/service_stunnel.py | 2 +- src/conf_mode/service_suricata.py | 2 +- src/conf_mode/service_tftp-server.py | 2 +- src/conf_mode/service_webproxy.py | 2 +- src/conf_mode/system_acceleration.py | 2 +- src/conf_mode/system_config-management.py | 2 +- src/conf_mode/system_conntrack.py | 2 +- src/conf_mode/system_console.py | 2 +- src/conf_mode/system_flow-accounting.py | 2 +- src/conf_mode/system_frr.py | 2 +- src/conf_mode/system_host-name.py | 2 +- src/conf_mode/system_ip.py | 2 +- src/conf_mode/system_ipv6.py | 2 +- src/conf_mode/system_lcd.py | 2 +- src/conf_mode/system_login.py | 2 +- src/conf_mode/system_login_banner.py | 2 +- src/conf_mode/system_logs.py | 2 +- src/conf_mode/system_option.py | 2 +- src/conf_mode/system_proxy.py | 2 +- src/conf_mode/system_sflow.py | 2 +- src/conf_mode/system_sysctl.py | 2 +- src/conf_mode/system_syslog.py | 2 +- src/conf_mode/system_task-scheduler.py | 2 +- src/conf_mode/system_timezone.py | 2 +- src/conf_mode/system_update-check.py | 2 +- src/conf_mode/system_wireless.py | 2 +- src/conf_mode/vpn_ipsec.py | 2 +- src/conf_mode/vpn_l2tp.py | 2 +- src/conf_mode/vpn_openconnect.py | 2 +- src/conf_mode/vpn_pptp.py | 2 +- src/conf_mode/vpn_sstp.py | 2 +- src/conf_mode/vrf.py | 2 +- src/etc/dhcp/dhclient-exit-hooks.d/99-ipsec-dhclient-hook | 2 +- src/etc/ipsec.d/vti-up-down | 2 +- src/etc/netplug/netplug | 2 +- src/etc/netplug/vyos-netplug-dhcp-client | 2 +- src/etc/ppp/ip-up.d/96-vyos-sstpc-callback | 2 +- src/etc/ppp/ip-up.d/99-vyos-pppoe-callback | 2 +- src/etc/ppp/ip-up.d/99-vyos-pppoe-wlb | 2 +- src/etc/telegraf/custom_scripts/vyos_services_input_filter.py | 2 +- .../vmware-tools/scripts/resume-vm-default.d/ether-resume.py | 2 +- src/helpers/add-system-version.py | 2 +- src/helpers/config_dependency.py | 2 +- src/helpers/geoip-update.py | 2 +- src/helpers/priority.py | 2 +- src/helpers/read-saved-value.py | 2 +- src/helpers/reset_section.py | 2 +- src/helpers/run-config-activation.py | 2 +- src/helpers/run-config-migration.py | 2 +- src/helpers/set_vyconf_backend.py | 2 +- src/helpers/show_commit_data.py | 2 +- src/helpers/strip-private.py | 2 +- src/helpers/teardown-config-session.py | 2 +- src/helpers/test_commit.py | 2 +- src/helpers/vyconf_cli.py | 2 +- src/helpers/vyos-boot-config-loader.py | 2 +- src/helpers/vyos-check-wwan.py | 2 +- src/helpers/vyos-config-encrypt.py | 2 +- src/helpers/vyos-failover.py | 2 +- src/helpers/vyos-interface-rescan.py | 2 +- src/helpers/vyos-load-balancer.py | 2 +- src/helpers/vyos-load-config.py | 2 +- src/helpers/vyos-merge-config.py | 2 +- src/helpers/vyos-save-config.py | 2 +- src/helpers/vyos_config_sync.py | 2 +- src/helpers/vyos_net_name | 2 +- src/init/vyos-router | 2 +- src/migration-scripts/bgp/0-to-1 | 2 +- src/migration-scripts/bgp/1-to-2 | 2 +- src/migration-scripts/bgp/2-to-3 | 2 +- src/migration-scripts/bgp/3-to-4 | 2 +- src/migration-scripts/bgp/4-to-5 | 2 +- src/migration-scripts/bgp/5-to-6 | 2 +- src/migration-scripts/cluster/1-to-2 | 2 +- src/migration-scripts/config-management/0-to-1 | 2 +- src/migration-scripts/conntrack-sync/1-to-2 | 2 +- src/migration-scripts/conntrack/1-to-2 | 2 +- src/migration-scripts/conntrack/2-to-3 | 2 +- src/migration-scripts/conntrack/3-to-4 | 2 +- src/migration-scripts/conntrack/4-to-5 | 2 +- src/migration-scripts/conntrack/5-to-6 | 2 +- src/migration-scripts/container/0-to-1 | 2 +- src/migration-scripts/container/1-to-2 | 2 +- src/migration-scripts/container/2-to-3 | 2 +- src/migration-scripts/dhcp-relay/1-to-2 | 2 +- src/migration-scripts/dhcp-server/10-to-11 | 2 +- src/migration-scripts/dhcp-server/4-to-5 | 2 +- src/migration-scripts/dhcp-server/5-to-6 | 2 +- src/migration-scripts/dhcp-server/6-to-7 | 2 +- src/migration-scripts/dhcp-server/7-to-8 | 2 +- src/migration-scripts/dhcp-server/8-to-9 | 2 +- src/migration-scripts/dhcp-server/9-to-10 | 2 +- src/migration-scripts/dhcpv6-server/0-to-1 | 2 +- src/migration-scripts/dhcpv6-server/1-to-2 | 2 +- src/migration-scripts/dhcpv6-server/2-to-3 | 2 +- src/migration-scripts/dhcpv6-server/3-to-4 | 2 +- src/migration-scripts/dhcpv6-server/4-to-5 | 2 +- src/migration-scripts/dhcpv6-server/5-to-6 | 2 +- src/migration-scripts/dns-dynamic/0-to-1 | 2 +- src/migration-scripts/dns-dynamic/1-to-2 | 2 +- src/migration-scripts/dns-dynamic/2-to-3 | 2 +- src/migration-scripts/dns-dynamic/3-to-4 | 2 +- src/migration-scripts/dns-forwarding/0-to-1 | 2 +- src/migration-scripts/dns-forwarding/1-to-2 | 2 +- src/migration-scripts/dns-forwarding/2-to-3 | 2 +- src/migration-scripts/dns-forwarding/3-to-4 | 2 +- src/migration-scripts/firewall/10-to-11 | 2 +- src/migration-scripts/firewall/11-to-12 | 2 +- src/migration-scripts/firewall/12-to-13 | 2 +- src/migration-scripts/firewall/13-to-14 | 2 +- src/migration-scripts/firewall/14-to-15 | 2 +- src/migration-scripts/firewall/15-to-16 | 2 +- src/migration-scripts/firewall/16-to-17 | 2 +- src/migration-scripts/firewall/17-to-18 | 2 +- src/migration-scripts/firewall/18-to-19 | 2 +- src/migration-scripts/firewall/5-to-6 | 2 +- src/migration-scripts/firewall/6-to-7 | 2 +- src/migration-scripts/firewall/7-to-8 | 2 +- src/migration-scripts/firewall/8-to-9 | 2 +- src/migration-scripts/firewall/9-to-10 | 2 +- src/migration-scripts/flow-accounting/0-to-1 | 2 +- src/migration-scripts/flow-accounting/1-to-2 | 2 +- src/migration-scripts/https/0-to-1 | 2 +- src/migration-scripts/https/1-to-2 | 2 +- src/migration-scripts/https/2-to-3 | 2 +- src/migration-scripts/https/3-to-4 | 2 +- src/migration-scripts/https/4-to-5 | 2 +- src/migration-scripts/https/5-to-6 | 2 +- src/migration-scripts/https/6-to-7 | 2 +- src/migration-scripts/ids/0-to-1 | 2 +- src/migration-scripts/ids/1-to-2 | 2 +- src/migration-scripts/interfaces/0-to-1 | 2 +- src/migration-scripts/interfaces/1-to-2 | 2 +- src/migration-scripts/interfaces/10-to-11 | 2 +- src/migration-scripts/interfaces/11-to-12 | 2 +- src/migration-scripts/interfaces/12-to-13 | 2 +- src/migration-scripts/interfaces/13-to-14 | 2 +- src/migration-scripts/interfaces/14-to-15 | 2 +- src/migration-scripts/interfaces/15-to-16 | 2 +- src/migration-scripts/interfaces/16-to-17 | 2 +- src/migration-scripts/interfaces/17-to-18 | 2 +- src/migration-scripts/interfaces/18-to-19 | 2 +- src/migration-scripts/interfaces/19-to-20 | 2 +- src/migration-scripts/interfaces/2-to-3 | 2 +- src/migration-scripts/interfaces/20-to-21 | 2 +- src/migration-scripts/interfaces/21-to-22 | 2 +- src/migration-scripts/interfaces/22-to-23 | 2 +- src/migration-scripts/interfaces/23-to-24 | 2 +- src/migration-scripts/interfaces/24-to-25 | 2 +- src/migration-scripts/interfaces/25-to-26 | 2 +- src/migration-scripts/interfaces/26-to-27 | 2 +- src/migration-scripts/interfaces/27-to-28 | 2 +- src/migration-scripts/interfaces/28-to-29 | 2 +- src/migration-scripts/interfaces/29-to-30 | 2 +- src/migration-scripts/interfaces/3-to-4 | 2 +- src/migration-scripts/interfaces/30-to-31 | 2 +- src/migration-scripts/interfaces/31-to-32 | 2 +- src/migration-scripts/interfaces/32-to-33 | 2 +- src/migration-scripts/interfaces/4-to-5 | 2 +- src/migration-scripts/interfaces/5-to-6 | 2 +- src/migration-scripts/interfaces/6-to-7 | 2 +- src/migration-scripts/interfaces/7-to-8 | 2 +- src/migration-scripts/interfaces/8-to-9 | 2 +- src/migration-scripts/interfaces/9-to-10 | 2 +- src/migration-scripts/ipoe-server/1-to-2 | 2 +- src/migration-scripts/ipoe-server/2-to-3 | 2 +- src/migration-scripts/ipoe-server/3-to-4 | 2 +- src/migration-scripts/ipsec/10-to-11 | 2 +- src/migration-scripts/ipsec/11-to-12 | 2 +- src/migration-scripts/ipsec/12-to-13 | 2 +- src/migration-scripts/ipsec/4-to-5 | 2 +- src/migration-scripts/ipsec/5-to-6 | 2 +- src/migration-scripts/ipsec/6-to-7 | 2 +- src/migration-scripts/ipsec/7-to-8 | 2 +- src/migration-scripts/ipsec/8-to-9 | 2 +- src/migration-scripts/ipsec/9-to-10 | 2 +- src/migration-scripts/isis/0-to-1 | 2 +- src/migration-scripts/isis/1-to-2 | 2 +- src/migration-scripts/isis/2-to-3 | 2 +- src/migration-scripts/l2tp/0-to-1 | 2 +- src/migration-scripts/l2tp/1-to-2 | 2 +- src/migration-scripts/l2tp/2-to-3 | 2 +- src/migration-scripts/l2tp/3-to-4 | 2 +- src/migration-scripts/l2tp/4-to-5 | 2 +- src/migration-scripts/l2tp/5-to-6 | 2 +- src/migration-scripts/l2tp/6-to-7 | 2 +- src/migration-scripts/l2tp/7-to-8 | 2 +- src/migration-scripts/l2tp/8-to-9 | 2 +- src/migration-scripts/lldp/0-to-1 | 2 +- src/migration-scripts/lldp/1-to-2 | 2 +- src/migration-scripts/lldp/2-to-3 | 2 +- src/migration-scripts/monitoring/0-to-1 | 4 ++-- src/migration-scripts/monitoring/1-to-2 | 2 +- src/migration-scripts/nat/4-to-5 | 2 +- src/migration-scripts/nat/5-to-6 | 2 +- src/migration-scripts/nat/6-to-7 | 2 +- src/migration-scripts/nat/7-to-8 | 2 +- src/migration-scripts/nat66/0-to-1 | 2 +- src/migration-scripts/nat66/1-to-2 | 4 ++-- src/migration-scripts/nat66/2-to-3 | 2 +- src/migration-scripts/nhrp/0-to-1 | 2 +- src/migration-scripts/ntp/0-to-1 | 2 +- src/migration-scripts/ntp/1-to-2 | 2 +- src/migration-scripts/ntp/2-to-3 | 2 +- src/migration-scripts/openconnect/0-to-1 | 2 +- src/migration-scripts/openconnect/1-to-2 | 2 +- src/migration-scripts/openconnect/2-to-3 | 2 +- src/migration-scripts/openvpn/0-to-1 | 2 +- src/migration-scripts/openvpn/1-to-2 | 2 +- src/migration-scripts/openvpn/2-to-3 | 2 +- src/migration-scripts/openvpn/3-to-4 | 2 +- src/migration-scripts/ospf/0-to-1 | 2 +- src/migration-scripts/ospf/1-to-2 | 2 +- src/migration-scripts/pim/0-to-1 | 2 +- src/migration-scripts/policy/0-to-1 | 2 +- src/migration-scripts/policy/1-to-2 | 2 +- src/migration-scripts/policy/2-to-3 | 2 +- src/migration-scripts/policy/3-to-4 | 2 +- src/migration-scripts/policy/4-to-5 | 2 +- src/migration-scripts/policy/5-to-6 | 2 +- src/migration-scripts/policy/6-to-7 | 2 +- src/migration-scripts/policy/7-to-8 | 2 +- src/migration-scripts/policy/8-to-9 | 2 +- src/migration-scripts/pppoe-server/0-to-1 | 2 +- src/migration-scripts/pppoe-server/1-to-2 | 2 +- src/migration-scripts/pppoe-server/10-to-11 | 2 +- src/migration-scripts/pppoe-server/2-to-3 | 2 +- src/migration-scripts/pppoe-server/3-to-4 | 2 +- src/migration-scripts/pppoe-server/4-to-5 | 2 +- src/migration-scripts/pppoe-server/5-to-6 | 2 +- src/migration-scripts/pppoe-server/6-to-7 | 2 +- src/migration-scripts/pppoe-server/7-to-8 | 2 +- src/migration-scripts/pppoe-server/8-to-9 | 2 +- src/migration-scripts/pppoe-server/9-to-10 | 2 +- src/migration-scripts/pptp/0-to-1 | 2 +- src/migration-scripts/pptp/1-to-2 | 2 +- src/migration-scripts/pptp/2-to-3 | 2 +- src/migration-scripts/pptp/3-to-4 | 2 +- src/migration-scripts/pptp/4-to-5 | 2 +- src/migration-scripts/qos/1-to-2 | 2 +- src/migration-scripts/qos/2-to-3 | 2 +- src/migration-scripts/quagga/10-to-11 | 2 +- src/migration-scripts/quagga/11-to-12 | 2 +- src/migration-scripts/quagga/2-to-3 | 2 +- src/migration-scripts/quagga/3-to-4 | 2 +- src/migration-scripts/quagga/4-to-5 | 2 +- src/migration-scripts/quagga/5-to-6 | 2 +- src/migration-scripts/quagga/6-to-7 | 2 +- src/migration-scripts/quagga/7-to-8 | 2 +- src/migration-scripts/quagga/8-to-9 | 2 +- src/migration-scripts/quagga/9-to-10 | 2 +- src/migration-scripts/reverse-proxy/0-to-1 | 2 +- src/migration-scripts/reverse-proxy/1-to-2 | 2 +- src/migration-scripts/reverse-proxy/2-to-3 | 2 +- src/migration-scripts/rip/0-to-1 | 2 +- src/migration-scripts/rpki/0-to-1 | 2 +- src/migration-scripts/rpki/1-to-2 | 2 +- src/migration-scripts/salt/0-to-1 | 2 +- src/migration-scripts/snmp/0-to-1 | 2 +- src/migration-scripts/snmp/1-to-2 | 2 +- src/migration-scripts/snmp/2-to-3 | 2 +- src/migration-scripts/ssh/0-to-1 | 2 +- src/migration-scripts/ssh/1-to-2 | 2 +- src/migration-scripts/sstp/0-to-1 | 2 +- src/migration-scripts/sstp/1-to-2 | 2 +- src/migration-scripts/sstp/2-to-3 | 2 +- src/migration-scripts/sstp/3-to-4 | 2 +- src/migration-scripts/sstp/4-to-5 | 2 +- src/migration-scripts/sstp/5-to-6 | 2 +- src/migration-scripts/system/10-to-11 | 2 +- src/migration-scripts/system/11-to-12 | 2 +- src/migration-scripts/system/12-to-13 | 2 +- src/migration-scripts/system/13-to-14 | 2 +- src/migration-scripts/system/14-to-15 | 2 +- src/migration-scripts/system/15-to-16 | 2 +- src/migration-scripts/system/16-to-17 | 2 +- src/migration-scripts/system/17-to-18 | 2 +- src/migration-scripts/system/18-to-19 | 2 +- src/migration-scripts/system/19-to-20 | 2 +- src/migration-scripts/system/20-to-21 | 2 +- src/migration-scripts/system/21-to-22 | 2 +- src/migration-scripts/system/22-to-23 | 2 +- src/migration-scripts/system/23-to-24 | 2 +- src/migration-scripts/system/24-to-25 | 2 +- src/migration-scripts/system/25-to-26 | 2 +- src/migration-scripts/system/26-to-27 | 2 +- src/migration-scripts/system/27-to-28 | 2 +- src/migration-scripts/system/28-to-29 | 2 +- src/migration-scripts/system/6-to-7 | 2 +- src/migration-scripts/system/7-to-8 | 2 +- src/migration-scripts/system/8-to-9 | 2 +- src/migration-scripts/vrf/0-to-1 | 2 +- src/migration-scripts/vrf/1-to-2 | 2 +- src/migration-scripts/vrf/2-to-3 | 2 +- src/migration-scripts/vrrp/1-to-2 | 2 +- src/migration-scripts/vrrp/2-to-3 | 2 +- src/migration-scripts/vrrp/3-to-4 | 2 +- src/migration-scripts/wanloadbalance/3-to-4 | 2 +- src/migration-scripts/webproxy/1-to-2 | 2 +- src/op_mode/accelppp.py | 2 +- src/op_mode/bgp.py | 2 +- src/op_mode/bonding.py | 2 +- src/op_mode/bridge.py | 2 +- src/op_mode/cgnat.py | 2 +- src/op_mode/clear_conntrack.py | 2 +- src/op_mode/config_mgmt.py | 2 +- src/op_mode/connect_disconnect.py | 2 +- src/op_mode/conntrack.py | 2 +- src/op_mode/conntrack_sync.py | 2 +- src/op_mode/container.py | 2 +- src/op_mode/cpu.py | 2 +- src/op_mode/dhcp.py | 2 +- src/op_mode/dns.py | 2 +- src/op_mode/evpn.py | 2 +- src/op_mode/execute_bandwidth_test.sh | 2 +- src/op_mode/execute_port-scan.py | 2 +- src/op_mode/file.py | 2 +- src/op_mode/firewall.py | 2 +- src/op_mode/flow_accounting_op.py | 2 +- src/op_mode/force_mtu_host.sh | 2 +- src/op_mode/force_root-partition-auto-resize.sh | 2 +- src/op_mode/format_disk.py | 2 +- src/op_mode/generate_interfaces_debug_archive.py | 2 +- src/op_mode/generate_ipsec_debug_archive.py | 2 +- src/op_mode/generate_openconnect_otp_key.py | 2 +- src/op_mode/generate_ovpn_client_file.py | 2 +- src/op_mode/generate_psk.py | 2 +- src/op_mode/generate_public_key_command.py | 2 +- src/op_mode/generate_service_rule-resequence.py | 2 +- src/op_mode/generate_ssh_server_key.py | 2 +- src/op_mode/generate_system_login_user.py | 2 +- src/op_mode/generate_tech-support_archive.py | 2 +- src/op_mode/igmp-proxy.py | 2 +- src/op_mode/ikev2_profile_generator.py | 2 +- src/op_mode/image_info.py | 2 +- src/op_mode/image_installer.py | 2 +- src/op_mode/image_manager.py | 2 +- src/op_mode/interfaces.py | 2 +- src/op_mode/interfaces_wireguard.py | 2 +- src/op_mode/interfaces_wireless.py | 2 +- src/op_mode/ipoe-control.py | 2 +- src/op_mode/ipsec.py | 2 +- src/op_mode/kernel_modules.py | 2 +- src/op_mode/lldp.py | 2 +- src/op_mode/load-balancing_haproxy.py | 2 +- src/op_mode/load-balancing_wan.py | 2 +- src/op_mode/log.py | 2 +- src/op_mode/maya_date.py | 2 +- src/op_mode/memory.py | 2 +- src/op_mode/mtr.py | 2 +- src/op_mode/mtr_execute.py | 2 +- src/op_mode/multicast.py | 2 +- src/op_mode/nat.py | 2 +- src/op_mode/neighbor.py | 2 +- src/op_mode/ntp.py | 2 +- src/op_mode/openconnect-control.py | 2 +- src/op_mode/openconnect.py | 2 +- src/op_mode/openvpn.py | 2 +- src/op_mode/otp.py | 4 ++-- src/op_mode/ping.py | 2 +- src/op_mode/pki.py | 2 +- src/op_mode/policy_route.py | 2 +- src/op_mode/powerctrl.py | 2 +- src/op_mode/ppp-server-ctrl.py | 2 +- src/op_mode/qos.py | 2 +- src/op_mode/raid.py | 2 +- src/op_mode/reset_openvpn.py | 2 +- src/op_mode/reset_vpn.py | 2 +- src/op_mode/reset_wireguard.py | 2 +- src/op_mode/restart.py | 2 +- src/op_mode/restart_dhcp_relay.py | 2 +- src/op_mode/restart_frr.py | 2 +- src/op_mode/route.py | 2 +- src/op_mode/secure_boot.py | 2 +- src/op_mode/serial.py | 2 +- src/op_mode/sflow.py | 2 +- src/op_mode/show-bond.py | 2 +- src/op_mode/show_acceleration.py | 2 +- src/op_mode/show_configuration_json.py | 2 +- src/op_mode/show_openconnect_otp.py | 2 +- src/op_mode/show_openvpn.py | 2 +- src/op_mode/show_openvpn_mfa.py | 2 +- src/op_mode/show_sensors.py | 2 +- src/op_mode/show_techsupport_report.py | 2 +- src/op_mode/show_usb_serial.py | 2 +- src/op_mode/show_users.py | 2 +- src/op_mode/show_virtual_server.py | 2 +- src/op_mode/show_wwan.py | 2 +- src/op_mode/snmp.py | 2 +- src/op_mode/snmp_ifmib.py | 2 +- src/op_mode/snmp_v3.py | 2 +- src/op_mode/ssh.py | 2 +- src/op_mode/storage.py | 2 +- src/op_mode/stp.py | 2 +- src/op_mode/system.py | 2 +- src/op_mode/tcpdump.py | 2 +- src/op_mode/tech_support.py | 2 +- src/op_mode/toggle_help_binding.sh | 2 +- src/op_mode/traceroute.py | 2 +- src/op_mode/uptime.py | 2 +- src/op_mode/version.py | 2 +- src/op_mode/vpn_ike_sa.py | 2 +- src/op_mode/vpn_ipsec.py | 2 +- src/op_mode/vrf.py | 2 +- src/op_mode/vrrp.py | 2 +- src/op_mode/webproxy_update_blacklist.sh | 2 +- src/op_mode/wireguard_client.py | 2 +- src/op_mode/zone.py | 2 +- src/services/api/graphql/bindings.py | 2 +- src/services/api/graphql/generate/generate_schema.py | 2 +- src/services/api/graphql/generate/schema_from_composite.py | 2 +- .../api/graphql/generate/schema_from_config_session.py | 2 +- src/services/api/graphql/generate/schema_from_op_mode.py | 2 +- src/services/api/graphql/graphql/auth_token_mutation.py | 2 +- src/services/api/graphql/graphql/directives.py | 2 +- src/services/api/graphql/graphql/mutations.py | 2 +- src/services/api/graphql/graphql/queries.py | 2 +- src/services/api/graphql/libs/key_auth.py | 2 +- src/services/api/graphql/libs/op_mode.py | 2 +- src/services/api/graphql/libs/token_auth.py | 2 +- src/services/api/graphql/routers.py | 2 +- src/services/api/graphql/session/composite/system_status.py | 2 +- .../session/override/remove_firewall_address_group_members.py | 2 +- src/services/api/graphql/session/session.py | 2 +- src/services/api/rest/models.py | 2 +- src/services/api/rest/routers.py | 2 +- src/services/api/session.py | 2 +- src/services/vyos-commitd | 2 +- src/services/vyos-configd | 2 +- src/services/vyos-conntrack-logger | 2 +- src/services/vyos-domain-resolver | 2 +- src/services/vyos-hostsd | 2 +- src/services/vyos-http-api-server | 2 +- src/services/vyos-network-event-logger | 2 +- src/shim/vyshim.c | 2 +- src/system/grub_update.py | 2 +- src/system/keepalived-fifo.py | 2 +- src/system/normalize-ip | 2 +- src/system/on-dhcp-event.sh | 2 +- src/system/on-dhcpv6-event.sh | 2 +- src/system/sync-dhcp-lease-to-hosts.py | 2 +- src/system/uacctd_stop.py | 2 +- src/system/vyos-config-cloud-init.py | 2 +- src/system/vyos-event-handler.py | 2 +- src/system/vyos-system-update-check.py | 2 +- src/tests/helper.py | 2 +- src/tests/test_config_diff.py | 2 +- src/tests/test_config_parser.py | 2 +- src/tests/test_configd_inspect.py | 2 +- src/tests/test_configverify.py | 2 +- src/tests/test_dependency_graph.py | 2 +- src/tests/test_dict_search.py | 2 +- src/tests/test_find_device_file.py | 2 +- src/tests/test_initial_setup.py | 2 +- src/tests/test_op_mode.py | 2 +- src/tests/test_task_scheduler.py | 2 +- src/tests/test_template.py | 2 +- src/tests/test_utils.py | 2 +- src/tests/test_utils_network.py | 2 +- src/utils/vyos-commands-to-config | 2 +- src/utils/vyos-config-file-query | 2 +- src/utils/vyos-hostsd-client | 2 +- src/utils/vyos-show-config | 2 +- src/validators/as-number-list | 2 +- src/validators/base64 | 2 +- src/validators/bgp-extended-community | 2 +- src/validators/bgp-large-community | 2 +- src/validators/bgp-large-community-list | 2 +- src/validators/bgp-rd-rt | 2 +- src/validators/bgp-regular-community | 2 +- src/validators/ddclient-protocol | 2 +- src/validators/ether-type | 2 +- src/validators/ip-protocol | 2 +- src/validators/psk-secret | 2 +- src/validators/script | 2 +- src/validators/sysctl | 2 +- src/validators/timezone | 2 +- src/validators/vrf-name | 2 +- src/validators/wireless-phy | 2 +- 833 files changed, 853 insertions(+), 846 deletions(-) (limited to 'python') diff --git a/Makefile b/Makefile index e85ccd7f4..59ecb7b51 100644 --- a/Makefile +++ b/Makefile @@ -89,7 +89,14 @@ vyshim: $(MAKE) -C $(SHIM_DIR) .PHONY: all -all: clean libvyosconfig interface_definitions op_mode_definitions test j2lint vyshim generate-configd-include-json +all: clean copyright libvyosconfig interface_definitions op_mode_definitions test j2lint vyshim generate-configd-include-json + +.PHONY: copyright +copyright: + @if git grep -q -E "Copyright (19|20)[0-9]{2}(-[0-9]{4})? VyOS maintainers"; then \ + echo "Error: Legacy copyright notice found."; \ + exit 1; \ + fi .PHONY: clean clean: diff --git a/debian/copyright b/debian/copyright index 20704c47c..b3b55b1d1 100644 --- a/debian/copyright +++ b/debian/copyright @@ -3,13 +3,13 @@ Thu, 17 Aug 2017 20:17:04 -0400 It's original content from the GIT repository -Upstream Author: +Upstream Author: -Copyright: +Copyright: - Copyright (C) 2017 VyOS maintainers and contributors + Copyright VyOS maintainers and contributors All Rights Reserved. License: diff --git a/python/vyos/accel_ppp.py b/python/vyos/accel_ppp.py index bae695fc3..b1160dc76 100644 --- a/python/vyos/accel_ppp.py +++ b/python/vyos/accel_ppp.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/accel_ppp_util.py b/python/vyos/accel_ppp_util.py index 49c0e3ede..85e8a964c 100644 --- a/python/vyos/accel_ppp_util.py +++ b/python/vyos/accel_ppp_util.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/airbag.py b/python/vyos/airbag.py index 3c7a144b7..a869daae8 100644 --- a/python/vyos/airbag.py +++ b/python/vyos/airbag.py @@ -1,4 +1,4 @@ -# Copyright 2019-2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/base.py b/python/vyos/base.py index 3173ddc20..67f92564e 100644 --- a/python/vyos/base.py +++ b/python/vyos/base.py @@ -1,4 +1,4 @@ -# Copyright 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/component_version.py b/python/vyos/component_version.py index 81d986658..136bd36e8 100644 --- a/python/vyos/component_version.py +++ b/python/vyos/component_version.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/compose_config.py b/python/vyos/compose_config.py index 79a8718c5..1e7837858 100644 --- a/python/vyos/compose_config.py +++ b/python/vyos/compose_config.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/config.py b/python/vyos/config.py index f1086cd6e..6ba8834cb 100644 --- a/python/vyos/config.py +++ b/python/vyos/config.py @@ -1,4 +1,4 @@ -# Copyright 2017-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 23eb3666e..67d03e76c 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/configdep.py b/python/vyos/configdep.py index 747af8dbe..04de66493 100644 --- a/python/vyos/configdep.py +++ b/python/vyos/configdep.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py index 040eb49ba..d91d88d88 100644 --- a/python/vyos/configdict.py +++ b/python/vyos/configdict.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/configdiff.py b/python/vyos/configdiff.py index b6d4a5558..5e21a16e5 100644 --- a/python/vyos/configdiff.py +++ b/python/vyos/configdiff.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py index 4c4ead0a3..e8a3c0f99 100644 --- a/python/vyos/configquery.py +++ b/python/vyos/configquery.py @@ -1,4 +1,4 @@ -# Copyright 2021-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 7af2cb333..af87d83a0 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -1,4 +1,4 @@ -# Copyright (C) 2019-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py index e4ced6305..3931f1295 100644 --- a/python/vyos/configsource.py +++ b/python/vyos/configsource.py @@ -1,5 +1,5 @@ -# Copyright 2020-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index faf124480..9b3755841 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -1,5 +1,5 @@ # configtree -- a standalone VyOS config file manipulation library (Python bindings) -# Copyright (C) 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py index 07eb29a68..cc4419913 100644 --- a/python/vyos/configverify.py +++ b/python/vyos/configverify.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/debug.py b/python/vyos/debug.py index 6ce42b173..5b6e8172e 100644 --- a/python/vyos/debug.py +++ b/python/vyos/debug.py @@ -1,4 +1,4 @@ -# Copyright 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 63f3b5358..fbb5a0393 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -1,4 +1,4 @@ -# Copyright 2018-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ethtool.py b/python/vyos/ethtool.py index 4710a5d40..6c362163c 100644 --- a/python/vyos/ethtool.py +++ b/python/vyos/ethtool.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py index 64022db84..5bb7afecc 100755 --- a/python/vyos/firewall.py +++ b/python/vyos/firewall.py @@ -1,4 +1,4 @@ -# Copyright (C) 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py index d9e409cb4..f4ed69205 100644 --- a/python/vyos/frrender.py +++ b/python/vyos/frrender.py @@ -1,4 +1,4 @@ -# Copyright 2024-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/__init__.py b/python/vyos/ifconfig/__init__.py index 206b2bba1..7838fa9a2 100644 --- a/python/vyos/ifconfig/__init__.py +++ b/python/vyos/ifconfig/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2019-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/afi.py b/python/vyos/ifconfig/afi.py index fd263d220..a391cb8a0 100644 --- a/python/vyos/ifconfig/afi.py +++ b/python/vyos/ifconfig/afi.py @@ -1,4 +1,4 @@ -# Copyright 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/bond.py b/python/vyos/ifconfig/bond.py index a659b9bd2..8a97243c5 100644 --- a/python/vyos/ifconfig/bond.py +++ b/python/vyos/ifconfig/bond.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/bridge.py b/python/vyos/ifconfig/bridge.py index 69dae42d3..ba06e3757 100644 --- a/python/vyos/ifconfig/bridge.py +++ b/python/vyos/ifconfig/bridge.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/control.py b/python/vyos/ifconfig/control.py index a886c1b9e..e5672ed50 100644 --- a/python/vyos/ifconfig/control.py +++ b/python/vyos/ifconfig/control.py @@ -1,4 +1,4 @@ -# Copyright 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/dummy.py b/python/vyos/ifconfig/dummy.py index 29a1965a3..93066c965 100644 --- a/python/vyos/ifconfig/dummy.py +++ b/python/vyos/ifconfig/dummy.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py index 93727bdf6..864a9d0bc 100644 --- a/python/vyos/ifconfig/ethernet.py +++ b/python/vyos/ifconfig/ethernet.py @@ -1,4 +1,4 @@ -# Copyright 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/geneve.py b/python/vyos/ifconfig/geneve.py index f53ef4166..7c5b7c0fb 100644 --- a/python/vyos/ifconfig/geneve.py +++ b/python/vyos/ifconfig/geneve.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/input.py b/python/vyos/ifconfig/input.py index 201d3cacb..6cb1eb64c 100644 --- a/python/vyos/ifconfig/input.py +++ b/python/vyos/ifconfig/input.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 91b3a0c28..ca50d6ec1 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -1,4 +1,4 @@ -# Copyright 2019-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/l2tpv3.py b/python/vyos/ifconfig/l2tpv3.py index dfaa006aa..ea9294e99 100644 --- a/python/vyos/ifconfig/l2tpv3.py +++ b/python/vyos/ifconfig/l2tpv3.py @@ -1,4 +1,4 @@ -# Copyright 2019-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/loopback.py b/python/vyos/ifconfig/loopback.py index 13e8a2c50..f4fc2c906 100644 --- a/python/vyos/ifconfig/loopback.py +++ b/python/vyos/ifconfig/loopback.py @@ -1,4 +1,4 @@ -# Copyright 2019-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/macsec.py b/python/vyos/ifconfig/macsec.py index 3b4dc223f..4d76a1d46 100644 --- a/python/vyos/ifconfig/macsec.py +++ b/python/vyos/ifconfig/macsec.py @@ -1,4 +1,4 @@ -# Copyright 2020-2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py index fe948b920..7a26f9ef5 100644 --- a/python/vyos/ifconfig/macvlan.py +++ b/python/vyos/ifconfig/macvlan.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/operational.py b/python/vyos/ifconfig/operational.py index dc2742123..e60518948 100644 --- a/python/vyos/ifconfig/operational.py +++ b/python/vyos/ifconfig/operational.py @@ -1,4 +1,4 @@ -# Copyright 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/pppoe.py b/python/vyos/ifconfig/pppoe.py index 85ca3877e..4ca66cf4d 100644 --- a/python/vyos/ifconfig/pppoe.py +++ b/python/vyos/ifconfig/pppoe.py @@ -1,4 +1,4 @@ -# Copyright 2020-2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/section.py b/python/vyos/ifconfig/section.py index 50273cf67..4ea606495 100644 --- a/python/vyos/ifconfig/section.py +++ b/python/vyos/ifconfig/section.py @@ -1,4 +1,4 @@ -# Copyright 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/sstpc.py b/python/vyos/ifconfig/sstpc.py index d92ef23dc..e43a2f177 100644 --- a/python/vyos/ifconfig/sstpc.py +++ b/python/vyos/ifconfig/sstpc.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/tunnel.py b/python/vyos/ifconfig/tunnel.py index df904f7d5..f96364161 100644 --- a/python/vyos/ifconfig/tunnel.py +++ b/python/vyos/ifconfig/tunnel.py @@ -1,4 +1,4 @@ -# Copyright 2019-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/veth.py b/python/vyos/ifconfig/veth.py index 2c8709d20..f4075fa02 100644 --- a/python/vyos/ifconfig/veth.py +++ b/python/vyos/ifconfig/veth.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/vrrp.py b/python/vyos/ifconfig/vrrp.py index 3ee22706c..4949fe571 100644 --- a/python/vyos/ifconfig/vrrp.py +++ b/python/vyos/ifconfig/vrrp.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py index 78f5895f8..030aa1ed7 100644 --- a/python/vyos/ifconfig/vti.py +++ b/python/vyos/ifconfig/vti.py @@ -1,4 +1,4 @@ -# Copyright 2021-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/vtun.py b/python/vyos/ifconfig/vtun.py index ee790f275..e6963ce5d 100644 --- a/python/vyos/ifconfig/vtun.py +++ b/python/vyos/ifconfig/vtun.py @@ -1,4 +1,4 @@ -# Copyright 2020-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/vxlan.py b/python/vyos/ifconfig/vxlan.py index 58844885b..0f55acf10 100644 --- a/python/vyos/ifconfig/vxlan.py +++ b/python/vyos/ifconfig/vxlan.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py index 6b5e52412..c4e70056c 100644 --- a/python/vyos/ifconfig/wireguard.py +++ b/python/vyos/ifconfig/wireguard.py @@ -1,4 +1,4 @@ -# Copyright 2019-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/wireless.py b/python/vyos/ifconfig/wireless.py index 121f56bd5..69fd87347 100644 --- a/python/vyos/ifconfig/wireless.py +++ b/python/vyos/ifconfig/wireless.py @@ -1,4 +1,4 @@ -# Copyright 2020-2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ifconfig/wwan.py b/python/vyos/ifconfig/wwan.py index 004a64b39..2b5714b85 100644 --- a/python/vyos/ifconfig/wwan.py +++ b/python/vyos/ifconfig/wwan.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/iflag.py b/python/vyos/iflag.py index 3ce73c1bf..179f33497 100644 --- a/python/vyos/iflag.py +++ b/python/vyos/iflag.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/include/__init__.py b/python/vyos/include/__init__.py index 22e836531..ba196ffed 100644 --- a/python/vyos/include/__init__.py +++ b/python/vyos/include/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/include/uapi/__init__.py b/python/vyos/include/uapi/__init__.py index 22e836531..ba196ffed 100644 --- a/python/vyos/include/uapi/__init__.py +++ b/python/vyos/include/uapi/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/include/uapi/linux/__init__.py b/python/vyos/include/uapi/linux/__init__.py index 22e836531..ba196ffed 100644 --- a/python/vyos/include/uapi/linux/__init__.py +++ b/python/vyos/include/uapi/linux/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/include/uapi/linux/fib_rules.py b/python/vyos/include/uapi/linux/fib_rules.py index 72f0b18cb..83544f69b 100644 --- a/python/vyos/include/uapi/linux/fib_rules.py +++ b/python/vyos/include/uapi/linux/fib_rules.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/include/uapi/linux/icmpv6.py b/python/vyos/include/uapi/linux/icmpv6.py index 47e0c723c..cc30b76fd 100644 --- a/python/vyos/include/uapi/linux/icmpv6.py +++ b/python/vyos/include/uapi/linux/icmpv6.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/include/uapi/linux/if_arp.py b/python/vyos/include/uapi/linux/if_arp.py index 90cb66ebd..80c16a83d 100644 --- a/python/vyos/include/uapi/linux/if_arp.py +++ b/python/vyos/include/uapi/linux/if_arp.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/include/uapi/linux/lwtunnel.py b/python/vyos/include/uapi/linux/lwtunnel.py index 6797a762b..c598513a5 100644 --- a/python/vyos/include/uapi/linux/lwtunnel.py +++ b/python/vyos/include/uapi/linux/lwtunnel.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/include/uapi/linux/neighbour.py b/python/vyos/include/uapi/linux/neighbour.py index d5caf44b9..8878353e3 100644 --- a/python/vyos/include/uapi/linux/neighbour.py +++ b/python/vyos/include/uapi/linux/neighbour.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/include/uapi/linux/rtnetlink.py b/python/vyos/include/uapi/linux/rtnetlink.py index e31272460..f3778fa65 100644 --- a/python/vyos/include/uapi/linux/rtnetlink.py +++ b/python/vyos/include/uapi/linux/rtnetlink.py @@ -1,4 +1,4 @@ -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/initialsetup.py b/python/vyos/initialsetup.py index cb6b9e459..bff3adf20 100644 --- a/python/vyos/initialsetup.py +++ b/python/vyos/initialsetup.py @@ -1,7 +1,7 @@ # initialsetup -- functions for setting common values in config file, # for use in installation and first boot scripts # -# Copyright (C) 2018-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; diff --git a/python/vyos/ioctl.py b/python/vyos/ioctl.py index 51574c1db..7f9ad226a 100644 --- a/python/vyos/ioctl.py +++ b/python/vyos/ioctl.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/ipsec.py b/python/vyos/ipsec.py index 28f77565a..81f3d0812 100644 --- a/python/vyos/ipsec.py +++ b/python/vyos/ipsec.py @@ -1,4 +1,4 @@ -# Copyright 2020-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 5eecbbaad..15c8564b0 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -1,4 +1,4 @@ -# Copyright 2023-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/limericks.py b/python/vyos/limericks.py index 3c6744816..0c02d5292 100644 --- a/python/vyos/limericks.py +++ b/python/vyos/limericks.py @@ -1,4 +1,4 @@ -# Copyright 2015, 2018 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/load_config.py b/python/vyos/load_config.py index b910a2f92..f65e887f0 100644 --- a/python/vyos/load_config.py +++ b/python/vyos/load_config.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/logger.py b/python/vyos/logger.py index f7cc964d5..207f95c1b 100644 --- a/python/vyos/logger.py +++ b/python/vyos/logger.py @@ -1,4 +1,4 @@ -# Copyright 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/migrate.py b/python/vyos/migrate.py index 9d1613676..c06f6a76c 100644 --- a/python/vyos/migrate.py +++ b/python/vyos/migrate.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 29f8e961b..7be957a0c 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py index 7b11d36dd..7f1fc6b4f 100644 --- a/python/vyos/opmode.py +++ b/python/vyos/opmode.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/pki.py b/python/vyos/pki.py index 55dc02631..4598c5daa 100644 --- a/python/vyos/pki.py +++ b/python/vyos/pki.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/priority.py b/python/vyos/priority.py index ab4e6d411..e61281d3c 100644 --- a/python/vyos/priority.py +++ b/python/vyos/priority.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/progressbar.py b/python/vyos/progressbar.py index 8d1042672..eb8ed474a 100644 --- a/python/vyos/progressbar.py +++ b/python/vyos/progressbar.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/proto/generate_dataclass.py b/python/vyos/proto/generate_dataclass.py index c6296c568..64485cd10 100755 --- a/python/vyos/proto/generate_dataclass.py +++ b/python/vyos/proto/generate_dataclass.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/proto/vyconf_client.py b/python/vyos/proto/vyconf_client.py index b385f0951..a3ba9864c 100644 --- a/python/vyos/proto/vyconf_client.py +++ b/python/vyos/proto/vyconf_client.py @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/__init__.py b/python/vyos/qos/__init__.py index a2980ccde..4bffda2d2 100644 --- a/python/vyos/qos/__init__.py +++ b/python/vyos/qos/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/base.py b/python/vyos/qos/base.py index b477b5b5e..487249714 100644 --- a/python/vyos/qos/base.py +++ b/python/vyos/qos/base.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/cake.py b/python/vyos/qos/cake.py index ca5a26917..626cedb8f 100644 --- a/python/vyos/qos/cake.py +++ b/python/vyos/qos/cake.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/droptail.py b/python/vyos/qos/droptail.py index 427d43d19..223ab1e64 100644 --- a/python/vyos/qos/droptail.py +++ b/python/vyos/qos/droptail.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/fairqueue.py b/python/vyos/qos/fairqueue.py index f41d098fb..8f4fe2d47 100644 --- a/python/vyos/qos/fairqueue.py +++ b/python/vyos/qos/fairqueue.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/fqcodel.py b/python/vyos/qos/fqcodel.py index cd2340aa2..d574226ef 100644 --- a/python/vyos/qos/fqcodel.py +++ b/python/vyos/qos/fqcodel.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/limiter.py b/python/vyos/qos/limiter.py index 3f5c11112..dce376d3e 100644 --- a/python/vyos/qos/limiter.py +++ b/python/vyos/qos/limiter.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/netem.py b/python/vyos/qos/netem.py index 8bdef300b..8fdd75387 100644 --- a/python/vyos/qos/netem.py +++ b/python/vyos/qos/netem.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/priority.py b/python/vyos/qos/priority.py index 66d27a639..5f373f696 100644 --- a/python/vyos/qos/priority.py +++ b/python/vyos/qos/priority.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/randomdetect.py b/python/vyos/qos/randomdetect.py index a3a39da36..63445bb62 100644 --- a/python/vyos/qos/randomdetect.py +++ b/python/vyos/qos/randomdetect.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/ratelimiter.py b/python/vyos/qos/ratelimiter.py index a4f80a1be..b0d7b3072 100644 --- a/python/vyos/qos/ratelimiter.py +++ b/python/vyos/qos/ratelimiter.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/roundrobin.py b/python/vyos/qos/roundrobin.py index 509c4069f..d07dc0f52 100644 --- a/python/vyos/qos/roundrobin.py +++ b/python/vyos/qos/roundrobin.py @@ -1,4 +1,4 @@ -# Copyright 2022 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/qos/trafficshaper.py b/python/vyos/qos/trafficshaper.py index 9f92ccd8b..3840e7d0e 100644 --- a/python/vyos/qos/trafficshaper.py +++ b/python/vyos/qos/trafficshaper.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/raid.py b/python/vyos/raid.py index 7fb794817..4ae63a100 100644 --- a/python/vyos/raid.py +++ b/python/vyos/raid.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/remote.py b/python/vyos/remote.py index c54fb6031..f6ab5c3f9 100644 --- a/python/vyos/remote.py +++ b/python/vyos/remote.py @@ -1,4 +1,4 @@ -# Copyright 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/snmpv3_hashgen.py b/python/vyos/snmpv3_hashgen.py index 324c3274d..57dba07a0 100644 --- a/python/vyos/snmpv3_hashgen.py +++ b/python/vyos/snmpv3_hashgen.py @@ -1,4 +1,4 @@ -# Copyright 2020 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/system/__init__.py b/python/vyos/system/__init__.py index 0c91330ba..42af8e3e8 100644 --- a/python/vyos/system/__init__.py +++ b/python/vyos/system/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/system/compat.py b/python/vyos/system/compat.py index d35bddea2..23a34d38a 100644 --- a/python/vyos/system/compat.py +++ b/python/vyos/system/compat.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/system/disk.py b/python/vyos/system/disk.py index c8908cd5c..268a3b195 100644 --- a/python/vyos/system/disk.py +++ b/python/vyos/system/disk.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/system/grub.py b/python/vyos/system/grub.py index de8303ee2..0f04fa5e9 100644 --- a/python/vyos/system/grub.py +++ b/python/vyos/system/grub.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/system/grub_util.py b/python/vyos/system/grub_util.py index ad95bb4f9..e534334e6 100644 --- a/python/vyos/system/grub_util.py +++ b/python/vyos/system/grub_util.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/system/image.py b/python/vyos/system/image.py index aae52e770..ed8a96fbb 100644 --- a/python/vyos/system/image.py +++ b/python/vyos/system/image.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/system/raid.py b/python/vyos/system/raid.py index 5b33d34da..c03764ad1 100644 --- a/python/vyos/system/raid.py +++ b/python/vyos/system/raid.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/template.py b/python/vyos/template.py index bf2f13183..9a9234490 100755 --- a/python/vyos/template.py +++ b/python/vyos/template.py @@ -1,4 +1,4 @@ -# Copyright 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/tpm.py b/python/vyos/tpm.py index a24f149fd..663490dec 100644 --- a/python/vyos/tpm.py +++ b/python/vyos/tpm.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/utils/__init__.py b/python/vyos/utils/__init__.py index 3759b2125..280cde17f 100644 --- a/python/vyos/utils/__init__.py +++ b/python/vyos/utils/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/assertion.py b/python/vyos/utils/assertion.py index c7fa220c3..aa0614743 100644 --- a/python/vyos/utils/assertion.py +++ b/python/vyos/utils/assertion.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/auth.py b/python/vyos/utils/auth.py index 5d0e3464a..6e816af71 100644 --- a/python/vyos/utils/auth.py +++ b/python/vyos/utils/auth.py @@ -1,6 +1,6 @@ # authutils -- miscelanneous functions for handling passwords and publis keys # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or modify it under the terms of # the GNU Lesser General Public License as published by the Free Software Foundation; diff --git a/python/vyos/utils/backend.py b/python/vyos/utils/backend.py index 400ea9b69..1234e9aa4 100644 --- a/python/vyos/utils/backend.py +++ b/python/vyos/utils/backend.py @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/boot.py b/python/vyos/utils/boot.py index 708bef14d..f804cd94e 100644 --- a/python/vyos/utils/boot.py +++ b/python/vyos/utils/boot.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/commit.py b/python/vyos/utils/commit.py index fc259dadb..4147c7fba 100644 --- a/python/vyos/utils/commit.py +++ b/python/vyos/utils/commit.py @@ -1,4 +1,4 @@ -# Copyright 2023-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/config.py b/python/vyos/utils/config.py index deda13c13..1f067e91e 100644 --- a/python/vyos/utils/config.py +++ b/python/vyos/utils/config.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/configfs.py b/python/vyos/utils/configfs.py index 8617f0129..307e1446c 100644 --- a/python/vyos/utils/configfs.py +++ b/python/vyos/utils/configfs.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/convert.py b/python/vyos/utils/convert.py index 2f587405d..ea07f9514 100644 --- a/python/vyos/utils/convert.py +++ b/python/vyos/utils/convert.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/cpu.py b/python/vyos/utils/cpu.py index 6f21eb526..0f47123a4 100644 --- a/python/vyos/utils/cpu.py +++ b/python/vyos/utils/cpu.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/dict.py b/python/vyos/utils/dict.py index 1a7a6b96f..e6ef943c6 100644 --- a/python/vyos/utils/dict.py +++ b/python/vyos/utils/dict.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/disk.py b/python/vyos/utils/disk.py index d4271ebe1..b822badde 100644 --- a/python/vyos/utils/disk.py +++ b/python/vyos/utils/disk.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/error.py b/python/vyos/utils/error.py index 8d4709bff..75ad813f3 100644 --- a/python/vyos/utils/error.py +++ b/python/vyos/utils/error.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index cc46d77d1..31c2361df 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py index 205210b66..0883376d1 100644 --- a/python/vyos/utils/io.py +++ b/python/vyos/utils/io.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/kernel.py b/python/vyos/utils/kernel.py index 05eac8a6a..4d8544670 100644 --- a/python/vyos/utils/kernel.py +++ b/python/vyos/utils/kernel.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/list.py b/python/vyos/utils/list.py index 63ef720ab..931084e7c 100644 --- a/python/vyos/utils/list.py +++ b/python/vyos/utils/list.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/locking.py b/python/vyos/utils/locking.py index 63cb1a816..f4cd6fd41 100644 --- a/python/vyos/utils/locking.py +++ b/python/vyos/utils/locking.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/misc.py b/python/vyos/utils/misc.py index d82655914..0ffd82696 100644 --- a/python/vyos/utils/misc.py +++ b/python/vyos/utils/misc.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 0a84be478..2182642dd 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/permission.py b/python/vyos/utils/permission.py index d938b494f..efd44bfeb 100644 --- a/python/vyos/utils/permission.py +++ b/python/vyos/utils/permission.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/process.py b/python/vyos/utils/process.py index 21335e6b3..86a2747af 100644 --- a/python/vyos/utils/process.py +++ b/python/vyos/utils/process.py @@ -1,4 +1,4 @@ -# Copyright 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/serial.py b/python/vyos/utils/serial.py index b646f881e..68aad676e 100644 --- a/python/vyos/utils/serial.py +++ b/python/vyos/utils/serial.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/session.py b/python/vyos/utils/session.py index 28559dc59..bc5240fc7 100644 --- a/python/vyos/utils/session.py +++ b/python/vyos/utils/session.py @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/strip_config.py b/python/vyos/utils/strip_config.py index 7a9c78c9f..17f6867cb 100644 --- a/python/vyos/utils/strip_config.py +++ b/python/vyos/utils/strip_config.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 # -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/system.py b/python/vyos/utils/system.py index 6c112334b..e2197daf2 100644 --- a/python/vyos/utils/system.py +++ b/python/vyos/utils/system.py @@ -1,4 +1,4 @@ -# Copyright 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/utils/vti_updown_db.py b/python/vyos/utils/vti_updown_db.py index b491fc6f2..f4dd24007 100644 --- a/python/vyos/utils/vti_updown_db.py +++ b/python/vyos/utils/vti_updown_db.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/version.py b/python/vyos/version.py index 86e96d0ec..01986e4da 100644 --- a/python/vyos/version.py +++ b/python/vyos/version.py @@ -1,4 +1,4 @@ -# Copyright 2017-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py index 3cf847b6c..4a2e6e393 100644 --- a/python/vyos/vyconf_session.py +++ b/python/vyos/vyconf_session.py @@ -1,4 +1,4 @@ -# Copyright 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/wanloadbalance.py b/python/vyos/wanloadbalance.py index 62e109f21..2381f7d1c 100644 --- a/python/vyos/wanloadbalance.py +++ b/python/vyos/wanloadbalance.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/xml_ref/__init__.py b/python/vyos/xml_ref/__init__.py index cd50a3ec2..41a25049e 100644 --- a/python/vyos/xml_ref/__init__.py +++ b/python/vyos/xml_ref/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/xml_ref/definition.py b/python/vyos/xml_ref/definition.py index 4e755ab72..015e7ee6e 100644 --- a/python/vyos/xml_ref/definition.py +++ b/python/vyos/xml_ref/definition.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/xml_ref/generate_cache.py b/python/vyos/xml_ref/generate_cache.py index 093697993..f0a3ec35b 100755 --- a/python/vyos/xml_ref/generate_cache.py +++ b/python/vyos/xml_ref/generate_cache.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py index 29697dc58..7a6974730 100755 --- a/python/vyos/xml_ref/generate_op_cache.py +++ b/python/vyos/xml_ref/generate_op_cache.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024-2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py index 8e922ecb2..f749e0585 100644 --- a/python/vyos/xml_ref/op_definition.py +++ b/python/vyos/xml_ref/op_definition.py @@ -1,4 +1,4 @@ -# Copyright 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/python/vyos/xml_ref/update_cache.py b/python/vyos/xml_ref/update_cache.py index 0842bcbe9..6643f9dc4 100755 --- a/python/vyos/xml_ref/update_cache.py +++ b/python/vyos/xml_ref/update_cache.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 or later as diff --git a/schema/interface_definition.rnc b/schema/interface_definition.rnc index 9434f5d18..a338b875f 100644 --- a/schema/interface_definition.rnc +++ b/schema/interface_definition.rnc @@ -1,6 +1,6 @@ # interface_definition.rnc: VyConf reference tree XML grammar # -# Copyright (C) 2014. 2017 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/schema/interface_definition.rng b/schema/interface_definition.rng index e3d582452..d653d1b01 100644 --- a/schema/interface_definition.rng +++ b/schema/interface_definition.rng @@ -2,19 +2,19 @@ - - - Host resources control - - - - - Maximum number of memory map areas a process may have - - u32:65535-2147483647 - Areas count - - - - - - 65535 - - - - Maximum shared memory segment size that can be created - - u32:0-18446744073709551612 - Size in bytes - - - - - - 2147483648 - - - - diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index dff56a228..a0cd0be45 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -449,7 +449,6 @@ - #include Interface diff --git a/python/vyos/vpp/config_resource_checks/memory.py b/python/vyos/vpp/config_resource_checks/memory.py index bce2df2bb..66b9fd9a3 100644 --- a/python/vyos/vpp/config_resource_checks/memory.py +++ b/python/vyos/vpp/config_resource_checks/memory.py @@ -51,14 +51,6 @@ def get_total_hugepages_memory() -> int: return hugepage_size * hugepages_total -def get_total_hugepages_count() -> int: - """ - Returns the total count of hugepages - """ - info = get_hugepages_info() - return info.get('HugePages_Total') - - def get_numa_count(): """ Run `numactl --hardware` and parse the 'available:' line. diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py index 1beb3141b..9c56c8b95 100644 --- a/python/vyos/vpp/config_verify.py +++ b/python/vyos/vpp/config_verify.py @@ -19,7 +19,6 @@ import psutil from vyos import ConfigError -from vyos.base import Warning from vyos.utils.cpu import get_core_count as total_core_count from vyos.vpp.control_host import get_eth_driver @@ -391,17 +390,3 @@ def verify_vpp_interfaces_dpdk_num_queues(qtype: str, num_queues: int, workers: f'The number of {qtype} queues cannot be greater than the number of configured VPP workers: ' f'workers: {workers}, queues: {num_queues}' ) - - -def verify_vpp_host_resources(config: dict): - max_map_count = int(config['settings']['host_resources']['max_map_count']) - - # Get HugePages total count - hugepages = mem_checks.get_total_hugepages_count() - - if max_map_count < 2 * hugepages: - Warning( - 'The max-map-count should be greater than or equal to (2 * HugePages_Total) ' - 'or VPP could work not properly. Please set up ' - f'"vpp settings host-resources max-map-count" to {2 * hugepages} or higher' - ) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index 6e02f9593..ace3c9f9f 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -35,7 +35,6 @@ from vyos.ifconfig import Section from vyos.template import render from vyos.utils.boot import boot_configuration_complete from vyos.utils.process import call -from vyos.utils.system import sysctl_read, sysctl_apply from vyos.vpp import VPPControl from vyos.vpp import control_host @@ -53,7 +52,6 @@ from vyos.vpp.config_verify import ( verify_vpp_memory, verify_vpp_statseg_size, verify_vpp_interfaces_dpdk_num_queues, - verify_vpp_host_resources, ) from vyos.vpp.config_filter import iface_filter_eth from vyos.vpp.utils import EthtoolGDrvinfo @@ -362,9 +360,6 @@ def verify(config): # Check if available memory is enough for current VPP config verify_vpp_memory(config) - if 'max_map_count' in config['settings'].get('host_resources', {}): - verify_vpp_host_resources(config) - if 'statseg' in config['settings']: verify_vpp_statseg_size(config['settings']) @@ -475,27 +470,6 @@ def generate(config): render(service_conf, 'vpp/startup.conf.j2', config['settings']) render(systemd_override, 'vpp/override.conf.j2', config) - # apply sysctl values - # default: https://github.com/FDio/vpp/blob/v23.10/src/vpp/conf/80-vpp.conf - # vm.nr_hugepages are now configured in section - # 'set system option kernel memory hugepage-size 2M hugepage-count ' - sysctl_config: dict[str, str] = { - 'vm.max_map_count': config['settings']['host_resources']['max_map_count'], - 'vm.hugetlb_shm_group': '0', - 'kernel.shmmax': config['settings']['host_resources']['shmmax'], - } - # we do not want to lower current values - for sysctl_key, sysctl_value in sysctl_config.items(): - # perform check only for quantitative params - if sysctl_key == 'vm.hugetlb_shm_group': - pass - current_value = sysctl_read(sysctl_key) - if int(current_value) > int(sysctl_value): - sysctl_config[sysctl_key] = current_value - - if not sysctl_apply(sysctl_config): - raise ConfigError('Cannot configure sysctl parameters for VPP') - return None -- cgit v1.2.3 From a92e14001b107401b81ee4e5230d37715ea51044 Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Wed, 13 Aug 2025 18:30:27 +0300 Subject: T7716: VPP: Change defaults and add a warning about maximum routes count for current configuration --- interface-definitions/vpp.xml.in | 3 ++ .../config_resource_checks/resource_defaults.py | 2 +- python/vyos/vpp/config_verify.py | 34 +++++++++++++++++++--- src/conf_mode/vpp.py | 3 ++ 4 files changed, 37 insertions(+), 5 deletions(-) (limited to 'python') diff --git a/interface-definitions/vpp.xml.in b/interface-definitions/vpp.xml.in index dff56a228..2443cebc3 100644 --- a/interface-definitions/vpp.xml.in +++ b/interface-definitions/vpp.xml.in @@ -778,6 +778,7 @@ Main heap page size #include + 2M @@ -905,12 +906,14 @@ Size of stats segment #include + 128M Stats page size #include + 2M diff --git a/python/vyos/vpp/config_resource_checks/resource_defaults.py b/python/vyos/vpp/config_resource_checks/resource_defaults.py index 797b45701..d8316fcaf 100644 --- a/python/vyos/vpp/config_resource_checks/resource_defaults.py +++ b/python/vyos/vpp/config_resource_checks/resource_defaults.py @@ -31,7 +31,7 @@ default_resource_map = { # Default size of buffers transferred via netlink 'netlink_rx_buffer_size': 212992, # Default amount of memory allocated for VPP stats segment usage - 'statseg_heap_size': '96M', + 'statseg_heap_size': '128M', # Minimal amount of memory required to start VPP 'min_memory': '8G', # Minimal number of physical CPU cores required to start VPP diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py index 1beb3141b..8902cdccb 100644 --- a/python/vyos/vpp/config_verify.py +++ b/python/vyos/vpp/config_verify.py @@ -216,8 +216,8 @@ def verify_vpp_memory(config: dict): main_heap_size = mem_checks.memory_main_heap(config['settings']) main_heap_page_size = mem_checks.main_heap_page_size(config['settings']) - if main_heap_size < 51 << 20: - raise ConfigError('The main heap size must be greater than or equal to 51M') + if main_heap_size < 1 << 30: + raise ConfigError('The main heap size must be greater than or equal to 1G') readable_heap_page = bytes_to_human_memory(main_heap_page_size, 'K') @@ -368,8 +368,8 @@ def verify_vpp_statseg_size(settings: dict): statseg_size = mem_checks.statseg_size(settings) if 'size' in settings.get('statseg'): - if statseg_size < 1 << 20: - raise ConfigError('The statseg size must be greater than or equal to 1M') + if statseg_size < 128 << 20: + raise ConfigError('The statseg size must be greater than or equal to 128M') if 'page_size' in settings['statseg']: statseg_page_size = mem_checks.statseg_page_size(settings) @@ -405,3 +405,29 @@ def verify_vpp_host_resources(config: dict): 'or VPP could work not properly. Please set up ' f'"vpp settings host-resources max-map-count" to {2 * hugepages} or higher' ) + + +def verify_routes_count(settings: dict, workers: int): + """ + Maximum routes count depending on main heap size, + statistics segment size and workers + """ + counters = 2 # 2 counters for each route + bytes = 16 # each counter consumes 16 bytes + statseg_scale = 2 + statseg_size = settings['statseg']['size'] + statseg_size_in_bytes = human_memory_to_bytes(statseg_size) + main_heap = settings['memory']['main_heap_size'] + main_heap_in_gb = human_memory_to_bytes(main_heap) >> 30 + + formula = (workers + 1) * counters * bytes * statseg_scale + routes_count_statseg = statseg_size_in_bytes / formula + routes_count_statseg = round(routes_count_statseg / 1_000_000, 2) + routes_count_mh = main_heap_in_gb * 2 + routes_count_min = min(routes_count_statseg, routes_count_mh) + Warning( + f'NOTE: Current dataplane capacity (estimated): {routes_count_min} M IPv4 routes. ' + 'Exceeding these values will lead to a dataplane out-of-memory condition and a crash. ' + 'Extensive use of features like ACLs, NAT and others may reduce the numbers above. ' + 'Please read the documentation for details: https://docs.vyos.io/' + ) diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index 6e02f9593..90e71e522 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -54,6 +54,7 @@ from vyos.vpp.config_verify import ( verify_vpp_statseg_size, verify_vpp_interfaces_dpdk_num_queues, verify_vpp_host_resources, + verify_routes_count, ) from vyos.vpp.config_filter import iface_filter_eth from vyos.vpp.utils import EthtoolGDrvinfo @@ -465,6 +466,8 @@ def verify(config): f'Interface {iface_config["iface_name"]} is an xconnect member and cannot be removed' ) + verify_routes_count(config['settings'], workers) + def generate(config): if not config or ('removed_ifaces' in config and 'settings' not in config): -- cgit v1.2.3 From 974f9a5d5e13241e2b4ea54152bf835db6b7feff Mon Sep 17 00:00:00 2001 From: David Vølker Date: Fri, 15 Aug 2025 09:31:49 +0200 Subject: kea: T6211: add VRF support for KEA dhcp server --- data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 | 5 + data/templates/dhcp-server/kea-dhcp4.conf.j2 | 5 + data/templates/dhcp-server/kea-dhcp6.conf.j2 | 5 + .../include/dhcp/dhcp-server-common-config.xml.i | 342 ++++++++++++++++++ .../include/dhcp/dhcpv6-server-common-config.xml.i | 276 +++++++++++++++ interface-definitions/service_dhcp-server.xml.in | 341 +----------------- interface-definitions/service_dhcpv6-server.xml.in | 275 +------------- interface-definitions/vrf.xml.in | 25 ++ op-mode-definitions/dhcp.xml.in | 277 ++++++++++++++- op-mode-definitions/monitor-log.xml.in | 16 + op-mode-definitions/show-log.xml.in | 16 + python/vyos/kea.py | 30 +- smoketest/scripts/cli/test_service_dhcp-server.py | 2 +- smoketest/scripts/cli/test_vrf.py | 393 +++++++++++++++++++++ src/conf_mode/service_dhcp-server.py | 84 ++++- src/conf_mode/service_dhcpv6-server.py | 73 +++- src/etc/sudoers.d/vyos | 3 + .../systemd/system/kea-dhcp-ddns-server@.service | 22 ++ src/etc/systemd/system/kea-dhcp4-server@.service | 22 ++ src/etc/systemd/system/kea-dhcp6-server@.service | 22 ++ src/op_mode/dhcp.py | 119 +++++-- src/system/on-dhcp-event.sh | 2 +- src/system/sync-dhcp-lease-to-hosts.py | 6 +- 23 files changed, 1661 insertions(+), 700 deletions(-) create mode 100644 interface-definitions/include/dhcp/dhcp-server-common-config.xml.i create mode 100644 interface-definitions/include/dhcp/dhcpv6-server-common-config.xml.i create mode 100644 src/etc/systemd/system/kea-dhcp-ddns-server@.service create mode 100644 src/etc/systemd/system/kea-dhcp4-server@.service create mode 100644 src/etc/systemd/system/kea-dhcp6-server@.service (limited to 'python') diff --git a/data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 b/data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 index 7b0394a88..e734a37f3 100644 --- a/data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp-ddns.conf.j2 @@ -3,8 +3,13 @@ "ip-address": "127.0.0.1", "port": 53001, "control-socket": { +{% if vrf_context is vyos_defined %} + "socket-type": "unix", + "socket-name": "/run/kea/kea-ddns-{{ vrf_context }}-ctrl-socket" +{% else %} "socket-type": "unix", "socket-name": "/run/kea/kea-ddns-ctrl-socket" +{% endif %} }, "tsig-keys": {{ dynamic_dns_update | kea_dynamic_dns_update_tsig_key_json }}, "forward-ddns" : { diff --git a/data/templates/dhcp-server/kea-dhcp4.conf.j2 b/data/templates/dhcp-server/kea-dhcp4.conf.j2 index d08ca0eaa..60c9cf9b2 100644 --- a/data/templates/dhcp-server/kea-dhcp4.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp4.conf.j2 @@ -15,8 +15,13 @@ "service-sockets-retry-wait-time": 5000 }, "control-socket": { +{% if vrf_context is vyos_defined %} + "socket-type": "unix", + "socket-name": "/run/kea/dhcp4-{{ vrf_context }}-ctrl-socket" +{% else %} "socket-type": "unix", "socket-name": "/run/kea/dhcp4-ctrl-socket" +{% endif %} }, "lease-database": { "type": "memfile", diff --git a/data/templates/dhcp-server/kea-dhcp6.conf.j2 b/data/templates/dhcp-server/kea-dhcp6.conf.j2 index 4745d693c..4352bbae7 100644 --- a/data/templates/dhcp-server/kea-dhcp6.conf.j2 +++ b/data/templates/dhcp-server/kea-dhcp6.conf.j2 @@ -10,8 +10,13 @@ "service-sockets-retry-wait-time": 5000 }, "control-socket": { +{% if vrf_context is vyos_defined %} + "socket-type": "unix", + "socket-name": "/run/kea/dhcp6-{{ vrf_context }}-ctrl-socket" +{% else %} "socket-type": "unix", "socket-name": "/run/kea/dhcp6-ctrl-socket" +{% endif %} }, "lease-database": { "type": "memfile", diff --git a/interface-definitions/include/dhcp/dhcp-server-common-config.xml.i b/interface-definitions/include/dhcp/dhcp-server-common-config.xml.i new file mode 100644 index 000000000..60eb01489 --- /dev/null +++ b/interface-definitions/include/dhcp/dhcp-server-common-config.xml.i @@ -0,0 +1,342 @@ + +#include + + + Dynamically update Domain Name System (RFC4702) + + + #include + + + TSIG key definition for DNS updates + + #include + + Invalid TSIG key name. May only contain letters, numbers, hyphen and underscore + + + + + TSIG key algorithm + + md5 sha1 sha224 sha256 sha384 sha512 + + + md5 + MD5 HMAC algorithm + + + sha1 + SHA1 HMAC algorithm + + + sha224 + SHA224 HMAC algorithm + + + sha256 + SHA256 HMAC algorithm + + + sha384 + SHA384 HMAC algorithm + + + sha512 + SHA512 HMAC algorithm + + + (md5|sha1|sha224|sha256|sha384|sha512) + + Invalid TSIG key algorithm + + + + + TSIG key secret (base64-encoded) + + + + + + + + + + Forward DNS domain name + + + + Invalid forward DNS domain name + + + + + TSIG key name for forward DNS updates + + #include + + Invalid TSIG key name. May only contain letters, numbers, numbers, hyphen and underscore + + + #include + + + + + Reverse DNS domain name + + + + Invalid reverse DNS domain name + + + + + TSIG key name for reverse DNS updates + + #include + + Invalid TSIG key name. May only contain letters, numbers, numbers, hyphen and underscore + + + #include + + + + + + + DHCP high availability configuration + + + #include + + + Configure high availability mode + + active-active active-passive + + + active-active + Both server attend DHCP requests + + + active-passive + Only primary server attends DHCP requests + + + (active-active|active-passive) + + Invalid DHCP high availability mode + + active-active + + + + IPv4 remote address used for connection + + ipv4 + IPv4 address of high availability peer + + + + + + + + + Peer name used to identify connection + + #include + + Invalid failover peer name. May only contain letters, numbers and .-_ + + + + + High availability hierarchy + + primary secondary + + + primary + Configure this server to be the primary node + + + secondary + Configure this server to be the secondary node + + + (primary|secondary) + + Invalid DHCP high availability peer status + + + #include + #include + + + + + Updating /etc/hosts file (per client lease) + + + +#include +#include + + + Name of DHCP shared network + + #include + + Invalid shared network name. May only contain letters, numbers and .-_ + + + + + Dynamically update Domain Name System (RFC4702) + + + #include + + + + + Option to make DHCP server authoritative for this physical network + + + + #include + #include + #include + #include + + + DHCP subnet for shared network + + ipv4net + IPv4 address and prefix length + + + + + Invalid IPv4 subnet definition + + + #include + #include + #include + #include + + + Dynamically update Domain Name System (RFC4702) + + + #include + + + + + IP address to exclude from DHCP lease range + + ipv4 + IPv4 address to exclude from lease range + + + + + + + + + + Ignore client identifier for lease lookups + + + + + + Lease timeout in seconds + + u32 + DHCP lease time in seconds + + + + + DHCP lease time must be between 0 and 4294967295 (49 days) + + 86400 + + + + DHCP lease range + + #include + + Invalid range name, may only be alphanumeric, dot and hyphen + + + #include + + + First IP address for DHCP lease range + + ipv4 + IPv4 start address of pool + + + + + + + + + Last IP address for DHCP lease range + + ipv4 + IPv4 end address of pool + + + + + + + + + + + Hostname for static mapping reservation + + + + Invalid static mapping hostname + + + #include + #include + #include + #include + #include + #include + + + + + Unique ID mapped to leases in the lease file + + u32 + Unique subnet ID + + + + + + + + + + + diff --git a/interface-definitions/include/dhcp/dhcpv6-server-common-config.xml.i b/interface-definitions/include/dhcp/dhcpv6-server-common-config.xml.i new file mode 100644 index 000000000..798ecac4b --- /dev/null +++ b/interface-definitions/include/dhcp/dhcpv6-server-common-config.xml.i @@ -0,0 +1,276 @@ + +#include +#include + + + Do not install routes for delegated prefixes + + + + + + Additional global parameters for DHCPv6 server + + + #include + + + + + Preference of this DHCPv6 server compared with others + + u32:0-255 + DHCPv6 server preference (0-255) + + + + + Preference must be between 0 and 255 + + + + + DHCPv6 shared network name + + #include + + Invalid DHCPv6 shared network name. May only contain letters, numbers and .-_ + + + #include + #include + #include + #include + + + IPv6 DHCP subnet for this shared network + + ipv6net + IPv6 address and prefix length + + + + + + + #include + #include + + + Parameters setting ranges for assigning IPv6 addresses + + #include + + Invalid range name, may only be alphanumeric, dot and hyphen + + + #include + + + IPv6 prefix defining range of addresses to assign + + ipv6net + IPv6 address and prefix length + + + + + + + + + First in range of consecutive IPv6 addresses to assign + + ipv6 + IPv6 address + + + + + + + + + Last in range of consecutive IPv6 addresses + + ipv6 + IPv6 address + + + + + + + + + + + Parameters relating to the lease time + + + + + Default time (in seconds) that will be assigned to a lease + + u32:1-4294967295 + DHCPv6 valid lifetime + + + + + + + + + Maximum time (in seconds) that will be assigned to a lease + + u32:1-4294967295 + Maximum lease time in seconds + + + + + + + + + Minimum time (in seconds) that will be assigned to a lease + + u32:1-4294967295 + Minimum lease time in seconds + + + + + + + + + + + Parameters relating to IPv6 prefix delegation + + + + + IPv6 prefix to be used in prefix delegation + + ipv6 + IPv6 prefix used in prefix delegation + + + + + + + + + Length in bits of prefix + + u32:32-64 + Prefix length (32-64) + + + + + Prefix length must be between 32 and 64 + + + + + Length in bits of prefixes to be delegated + + u32:32-64 + Delegated prefix length (32-64) + + + + + Delegated prefix length must be between 32 and 96 + + + + + IPv6 prefix to be excluded from prefix delegation + + ipv6 + IPv6 prefix excluded from prefix delegation + + + + + + + + + Length in bits of excluded prefix + + u32:33-64 + Excluded prefix length (33-128) + + + + + Prefix length must be between 33 and 128 + + + + + + + + + Hostname for static mapping reservation + + + + Invalid static mapping hostname + + + #include + #include + #include + #include + + + Client IPv6 address for this static mapping + + ipv6 + IPv6 address for this static mapping + + + + + + + + + Client IPv6 prefix for this static mapping + + ipv6net + IPv6 prefix for this static mapping + + + + + + + + + + + Unique ID mapped to leases in the lease file + + u32 + Unique subnet ID + + + + + + + + + + + diff --git a/interface-definitions/service_dhcp-server.xml.in b/interface-definitions/service_dhcp-server.xml.in index 78f1cea4e..4d1172e03 100644 --- a/interface-definitions/service_dhcp-server.xml.in +++ b/interface-definitions/service_dhcp-server.xml.in @@ -9,346 +9,7 @@ 911 - #include - - - Dynamically update Domain Name System (RFC4702) - - - #include - - - TSIG key definition for DNS updates - - #include - - Invalid TSIG key name. May only contain letters, numbers, hyphen and underscore - - - - - TSIG key algorithm - - md5 sha1 sha224 sha256 sha384 sha512 - - - md5 - MD5 HMAC algorithm - - - sha1 - SHA1 HMAC algorithm - - - sha224 - SHA224 HMAC algorithm - - - sha256 - SHA256 HMAC algorithm - - - sha384 - SHA384 HMAC algorithm - - - sha512 - SHA512 HMAC algorithm - - - (md5|sha1|sha224|sha256|sha384|sha512) - - Invalid TSIG key algorithm - - - - - TSIG key secret (base64-encoded) - - - - - - - - - - Forward DNS domain name - - - - Invalid forward DNS domain name - - - - - TSIG key name for forward DNS updates - - #include - - Invalid TSIG key name. May only contain letters, numbers, numbers, hyphen and underscore - - - #include - - - - - Reverse DNS domain name - - - - Invalid reverse DNS domain name - - - - - TSIG key name for reverse DNS updates - - #include - - Invalid TSIG key name. May only contain letters, numbers, numbers, hyphen and underscore - - - #include - - - - - - - DHCP high availability configuration - - - #include - - - Configure high availability mode - - active-active active-passive - - - active-active - Both server attend DHCP requests - - - active-passive - Only primary server attends DHCP requests - - - (active-active|active-passive) - - Invalid DHCP high availability mode - - active-active - - - - IPv4 remote address used for connection - - ipv4 - IPv4 address of high availability peer - - - - - - - - - Peer name used to identify connection - - #include - - Invalid failover peer name. May only contain letters, numbers and .-_ - - - - - High availability hierarchy - - primary secondary - - - primary - Configure this server to be the primary node - - - secondary - Configure this server to be the secondary node - - - (primary|secondary) - - Invalid DHCP high availability peer status - - - #include - #include - - - - - Updating /etc/hosts file (per client lease) - - - - #include - #include - - - Name of DHCP shared network - - #include - - Invalid shared network name. May only contain letters, numbers and .-_ - - - - - Dynamically update Domain Name System (RFC4702) - - - #include - - - - - Option to make DHCP server authoritative for this physical network - - - - #include - #include - #include - #include - - - DHCP subnet for shared network - - ipv4net - IPv4 address and prefix length - - - - - Invalid IPv4 subnet definition - - - #include - #include - #include - #include - - - Dynamically update Domain Name System (RFC4702) - - - #include - - - - - IP address to exclude from DHCP lease range - - ipv4 - IPv4 address to exclude from lease range - - - - - - - - - - Ignore client identifier for lease lookups - - - - - - Lease timeout in seconds - - u32 - DHCP lease time in seconds - - - - - DHCP lease time must be between 0 and 4294967295 (49 days) - - 86400 - - - - DHCP lease range - - #include - - Invalid range name, may only be alphanumeric, dot and hyphen - - - #include - - - First IP address for DHCP lease range - - ipv4 - IPv4 start address of pool - - - - - - - - - Last IP address for DHCP lease range - - ipv4 - IPv4 end address of pool - - - - - - - - - - - Hostname for static mapping reservation - - - - Invalid static mapping hostname - - - #include - #include - #include - #include - #include - #include - - - - - Unique ID mapped to leases in the lease file - - u32 - Unique subnet ID - - - - - - - - - - + #include diff --git a/interface-definitions/service_dhcpv6-server.xml.in b/interface-definitions/service_dhcpv6-server.xml.in index a6763a345..d2b96061d 100644 --- a/interface-definitions/service_dhcpv6-server.xml.in +++ b/interface-definitions/service_dhcpv6-server.xml.in @@ -8,280 +8,7 @@ 900 - #include - #include - - - Do not install routes for delegated prefixes - - - - - - Additional global parameters for DHCPv6 server - - - #include - - - - - Preference of this DHCPv6 server compared with others - - u32:0-255 - DHCPv6 server preference (0-255) - - - - - Preference must be between 0 and 255 - - - - - DHCPv6 shared network name - - #include - - Invalid DHCPv6 shared network name. May only contain letters, numbers and .-_ - - - #include - #include - #include - #include - - - IPv6 DHCP subnet for this shared network - - ipv6net - IPv6 address and prefix length - - - - - - - #include - #include - - - Parameters setting ranges for assigning IPv6 addresses - - #include - - Invalid range name, may only be alphanumeric, dot and hyphen - - - #include - - - IPv6 prefix defining range of addresses to assign - - ipv6net - IPv6 address and prefix length - - - - - - - - - First in range of consecutive IPv6 addresses to assign - - ipv6 - IPv6 address - - - - - - - - - Last in range of consecutive IPv6 addresses - - ipv6 - IPv6 address - - - - - - - - - - - Parameters relating to the lease time - - - - - Default time (in seconds) that will be assigned to a lease - - u32:1-4294967295 - DHCPv6 valid lifetime - - - - - - - - - Maximum time (in seconds) that will be assigned to a lease - - u32:1-4294967295 - Maximum lease time in seconds - - - - - - - - - Minimum time (in seconds) that will be assigned to a lease - - u32:1-4294967295 - Minimum lease time in seconds - - - - - - - - - - - Parameters relating to IPv6 prefix delegation - - - - - IPv6 prefix to be used in prefix delegation - - ipv6 - IPv6 prefix used in prefix delegation - - - - - - - - - Length in bits of prefix - - u32:32-64 - Prefix length (32-64) - - - - - Prefix length must be between 32 and 64 - - - - - Length in bits of prefixes to be delegated - - u32:32-64 - Delegated prefix length (32-64) - - - - - Delegated prefix length must be between 32 and 96 - - - - - IPv6 prefix to be excluded from prefix delegation - - ipv6 - IPv6 prefix excluded from prefix delegation - - - - - - - - - Length in bits of excluded prefix - - u32:33-64 - Excluded prefix length (33-128) - - - - - Prefix length must be between 33 and 128 - - - - - - - - - Hostname for static mapping reservation - - - - Invalid static mapping hostname - - - #include - #include - #include - #include - - - Client IPv6 address for this static mapping - - ipv6 - IPv6 address for this static mapping - - - - - - - - - Client IPv6 prefix for this static mapping - - ipv6net - IPv6 prefix for this static mapping - - - - - - - - - - - Unique ID mapped to leases in the lease file - - u32 - Unique subnet ID - - - - - - - - - - + #include diff --git a/interface-definitions/vrf.xml.in b/interface-definitions/vrf.xml.in index 03128cb99..3fa95076e 100644 --- a/interface-definitions/vrf.xml.in +++ b/interface-definitions/vrf.xml.in @@ -116,6 +116,31 @@ + + + Enable services in the vrf itself + + + + + Dynamic Host Configuration Protocol (DHCP) for DHCP server + 912 + + + #include + + + + + DHCP for IPv6 (DHCPv6) server + 901 + + + #include + + + + Routing table associated with this instance diff --git a/op-mode-definitions/dhcp.xml.in b/op-mode-definitions/dhcp.xml.in index 2da3bb5dc..da14359f2 100644 --- a/op-mode-definitions/dhcp.xml.in +++ b/op-mode-definitions/dhcp.xml.in @@ -13,6 +13,22 @@ ${vyos_op_scripts_dir}/dhcp.py clear_dhcp_server_lease --family inet --address $4 + + + Clear DHCP server lease for vrf + + vrf name + + + + + + DHCP server lease + + ${vyos_op_scripts_dir}/dhcp.py clear_dhcp_server_lease --family inet --vrf $4 --address $6 + + + @@ -26,6 +42,22 @@ ${vyos_op_scripts_dir}/dhcp.py clear_dhcp_server_lease --family inet6 --address $4 + + + Clear DHCPv6 server lease for vrf + + vrf name + + + + + + DHCPv6 server lease + + ${vyos_op_scripts_dir}/dhcp.py clear_dhcp_server_lease --family inet6 --vrf $4 --address $6 + + + @@ -70,7 +102,7 @@ Show DHCP server leases - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf '' @@ -79,7 +111,7 @@ local remote - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --origin $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf '' --origin $6 @@ -88,7 +120,7 @@ service dhcp-server shared-network-name - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --pool $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf '' --pool $6 @@ -97,7 +129,7 @@ end hostname ip mac pool remaining start state - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --sort $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf '' --sort $6 @@ -106,7 +138,7 @@ abandoned active all backup expired free released reset - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --state $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf '' --state $6 @@ -114,7 +146,7 @@ Show DHCP server static mappings - ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --vrf '' @@ -123,7 +155,7 @@ service dhcp-server shared-network-name - ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --pool $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --vrf '' --pool $6 @@ -132,7 +164,7 @@ ip mac duid pool - ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --sort $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --vrf '' --sort $6 @@ -140,7 +172,7 @@ Show DHCP server statistics - ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet --vrf '' @@ -149,10 +181,107 @@ service dhcp-server shared-network-name - ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet --pool $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet --vrf '' --pool $6 + + + Show DHCP (Dynamic Host Configuration Protocol) information for vrf + + vrf name + + + + + + Show DHCP server leases + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf $5 + + + + Show DHCP server leases granted by local or remote DHCP server + + local remote + + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf $5 --origin $8 + + + + Show DHCP server leases for a specific pool + + service dhcp-server shared-network-name + + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf $5 --pool $8 + + + + Show DHCP server leases sorted by the specified key + + end hostname ip mac pool remaining start state + + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf $5 --sort $8 + + + + Show DHCP server leases with a specific state (can be multiple, comma-separated) + + abandoned active all backup expired free released reset + + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet --vrf $5 --state $8 + + + + + + Show DHCP server static mappings + + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --vrf $5 + + + + Show DHCP server static mappings for a specific pool + + service dhcp-server shared-network-name + + + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --vrf $5 --pool $8 + + + + Show DHCP server static mappings sorted by the specified key + + ip mac duid pool + + + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet --vrf $5 --sort $8 + + + + + + Show DHCP server statistics + + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet --vrf $5 + + + + Show DHCP server statistics for a specific pool + + service dhcp-server shared-network-name + + + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet --vrf $5 --pool $8 + + + + + @@ -171,7 +300,7 @@ Show DHCPv6 server leases - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf '' @@ -180,7 +309,7 @@ service dhcpv6-server shared-network-name - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --pool $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf '' --pool $6 @@ -189,7 +318,7 @@ end duid ip last_communication pool remaining state type - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --sort $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf '' --sort $6 @@ -198,7 +327,7 @@ abandoned active all backup expired free released reset - ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --state $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf '' --state $6 @@ -206,7 +335,7 @@ Show DHCPv6 server static mappings - ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --vrf '' @@ -215,7 +344,7 @@ service dhcp-server shared-network-name - ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --pool $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --vrf '' --pool $6 @@ -224,7 +353,7 @@ ip mac duid pool - ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --sort $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --vrf '' --sort $6 @@ -232,7 +361,7 @@ Show DHCPv6 server statistics - ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6 + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6 --vrf '' @@ -241,10 +370,98 @@ service dhcpv6-server shared-network-name - ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6 --pool $6 + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6 --vrf '' --pool $6 + + + Show DHCPv6 (IPv6 Dynamic Host Configuration Protocol) information for vrf + + vrf name + + + + + + Show DHCPv6 server leases + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf $5 + + + + Show DHCPv6 server leases for a specific pool + + service dhcpv6-server shared-network-name + + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf $5 --pool $8 + + + + Show DHCPv6 server leases sorted by the specified key + + end duid ip last_communication pool remaining state type + + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf $5 --sort $8 + + + + Show DHCPv6 server leases with a specific state (can be multiple, comma-separated) + + abandoned active all backup expired free released reset + + + ${vyos_op_scripts_dir}/dhcp.py show_server_leases --family inet6 --vrf $5 --state $8 + + + + + + Show DHCPv6 server static mappings + + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --vrf $5 + + + + Show DHCPv6 server static mappings for a specific pool + + service dhcp-server shared-network-name + + + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --vrf $5 --pool $8 + + + + Show DHCPv6 server static mappings sorted by the specified key + + ip mac duid pool + + + ${vyos_op_scripts_dir}/dhcp.py show_server_static_mappings --family inet6 --vrf $5 --sort $8 + + + + + + Show DHCPv6 server statistics + + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6 --vrf $5 + + + + Show DHCPv6 server statistics for a specific pool + + service dhcpv6-server shared-network-name + + + ${vyos_op_scripts_dir}/dhcp.py show_server_pool_statistics --family inet6 --vrf $5 --pool $8 + + + + + @@ -263,6 +480,17 @@ Restart DHCP server ${vyos_op_scripts_dir}/restart.py restart_service --name dhcp + + + + Restart DHCP server on vrf + + vrf name + + + ${vyos_op_scripts_dir}/restart.py restart_service --name dhcp --vrf $5 + + @@ -282,6 +510,17 @@ Restart DHCPv6 server ${vyos_op_scripts_dir}/restart.py restart_service --name dhcpv6 + + + + Restart DHCPv6 server on vrf + + vrf name + + + ${vyos_op_scripts_dir}/restart.py restart_service --name dhcpv6 --vrf $5 + + diff --git a/op-mode-definitions/monitor-log.xml.in b/op-mode-definitions/monitor-log.xml.in index 721460be5..86c07fb02 100644 --- a/op-mode-definitions/monitor-log.xml.in +++ b/op-mode-definitions/monitor-log.xml.in @@ -45,6 +45,14 @@ Monitor last lines of DHCP server log journalctl --no-hostname --follow --boot --unit kea-dhcp4-server.service + + + + Monitor last lines of DHCP server log on specific vrf + + journalctl --no-hostname --follow --boot --unit "kea-dhcp4-server@$6.service" + + @@ -75,6 +83,14 @@ Monitor last lines of DHCPv6 server log journalctl --no-hostname --follow --boot --unit kea-dhcp6-server.service + + + + Monitor last lines of DHCPv6 server log on specific vrf + + journalctl --no-hostname --follow --boot --unit "kea-dhcp6-server@$6.service" + + diff --git a/op-mode-definitions/show-log.xml.in b/op-mode-definitions/show-log.xml.in index 499e7f84b..b301f3401 100755 --- a/op-mode-definitions/show-log.xml.in +++ b/op-mode-definitions/show-log.xml.in @@ -105,6 +105,14 @@ Show log for DHCP server journalctl --no-hostname --boot --unit kea-dhcp4-server.service + + + + Monitor last lines of DHCP server log on specific vrf + + journalctl --no-hostname --follow --boot --unit "kea-dhcp4-server@$6.service" + + @@ -135,6 +143,14 @@ Show log for DHCPv6 server journalctl --no-hostname --boot --unit kea-dhcp6-server.service + + + + Monitor last lines of DHCPv6 server log on specific vrf + + journalctl --no-hostname --boot --unit "kea-dhcp6-server@$6.service" + + diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 5eecbbaad..4a8a8b4c8 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -60,7 +60,7 @@ kea6_options = { 'capwap_controller': 'capwap-ac-v6', } -kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket' +kea_ctrl_socket = '/run/kea/dhcp{inet}{vrf_append}-ctrl-socket' def _format_hex_string(in_str): @@ -403,8 +403,13 @@ def kea_parse_ddns_settings(config): return data -def _ctrl_socket_command(inet, command, args=None): - path = kea_ctrl_socket.format(inet=inet) +def _ctrl_socket_command(inet, vrf_name, command, args=None): + if vrf_name: + vrf_append = f'-{vrf_name}' + else: + vrf_append = '' + + path = kea_ctrl_socket.format(inet=inet, vrf_append=vrf_append) if not os.path.exists(path): return None @@ -430,8 +435,8 @@ def _ctrl_socket_command(inet, command, args=None): return json.loads(result.decode('utf-8')) -def kea_get_leases(inet): - leases = _ctrl_socket_command(inet, f'lease{inet}-get-all') +def kea_get_leases(inet, vrf_name): + leases = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-get-all') if not leases or 'result' not in leases or leases['result'] != 0: return [] @@ -441,6 +446,7 @@ def kea_get_leases(inet): def kea_add_lease( inet, + vrf_name, ip_address, host_name=None, mac_address=None, @@ -466,7 +472,7 @@ def kea_add_lease( if inet == '6' and iaid: args['iaid'] = iaid - result = _ctrl_socket_command(inet, f'lease{inet}-add', args) + result = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-add', args) if result and 'result' in result: return result['result'] == 0 @@ -474,10 +480,10 @@ def kea_add_lease( return False -def kea_delete_lease(inet, ip_address): +def kea_delete_lease(inet, ip_address, vrf_name=''): args = {'ip-address': ip_address} - result = _ctrl_socket_command(inet, f'lease{inet}-del', args) + result = _ctrl_socket_command(inet, vrf_name, f'lease{inet}-del', args) if result and 'result' in result: return result['result'] == 0 @@ -485,8 +491,8 @@ def kea_delete_lease(inet, ip_address): return False -def kea_get_active_config(inet): - config = _ctrl_socket_command(inet, 'config-get') +def kea_get_active_config(inet, vrf_name): + config = _ctrl_socket_command(inet, vrf_name, 'config-get') if not config or 'result' not in config or config['result'] != 0: return None @@ -580,12 +586,12 @@ def kea_get_static_mappings(config, inet, pools=[]) -> list: return mappings -def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list: +def kea_get_server_leases(config, inet, vrf_name, pools=[], state=[], origin=None) -> list: """ Get DHCP server leases from active Kea DHCPv4 or DHCPv6 configuration :return list """ - leases = kea_get_leases(inet) + leases = kea_get_leases(inet, vrf_name) data = [] for lease in leases: diff --git a/smoketest/scripts/cli/test_service_dhcp-server.py b/smoketest/scripts/cli/test_service_dhcp-server.py index e421f04d2..d275ad2ea 100755 --- a/smoketest/scripts/cli/test_service_dhcp-server.py +++ b/smoketest/scripts/cli/test_service_dhcp-server.py @@ -1415,7 +1415,7 @@ class TestServiceDHCPServer(VyOSUnitTestSHIM.TestCase): client = f'client{seq}' mac = f'00:50:00:00:00:{seq:02}' ip = inc_ip(subnet, seq) - kea_add_lease(4, ip, host_name=client, mac_address=mac) + kea_add_lease(4, '', ip, host_name=client, mac_address=mac) # 2. Verify that leases are not available in vyos-hostsd tag_regex = re.escape(f'dhcp-server-{subnet.rsplit(".", 1)[0]}') diff --git a/smoketest/scripts/cli/test_vrf.py b/smoketest/scripts/cli/test_vrf.py index 30980f9ec..bba75f98d 100755 --- a/smoketest/scripts/cli/test_vrf.py +++ b/smoketest/scripts/cli/test_vrf.py @@ -34,6 +34,8 @@ from vyos.utils.network import is_intf_addr_assigned from vyos.utils.network import interface_exists from vyos.utils.process import cmd from vyos.utils.system import sysctl_read +from vyos.template import inc_ip +from vyos.utils.process import process_named_running base_path = ['vrf'] vrfs = ['red', 'green', 'blue', 'foo-bar', 'baz_foo'] @@ -72,6 +74,42 @@ class VRFTest(VyOSUnitTestSHIM.TestCase): for vrf in vrfs: self.assertFalse(interface_exists(vrf)) + def walk_path(self, obj, path): + current = obj + + for i, key in enumerate(path): + if isinstance(key, str): + self.assertTrue(isinstance(current, dict), msg=f'Failed path: {path}') + self.assertTrue(key in current, msg=f'Failed path: {path}') + elif isinstance(key, int): + self.assertTrue(isinstance(current, list), msg=f'Failed path: {path}') + self.assertTrue(0 <= key < len(current), msg=f'Failed path: {path}') + else: + assert False, 'Invalid type' + + current = current[key] + + return current + + def verify_config_object(self, obj, path, value): + base_obj = self.walk_path(obj, path) + self.assertTrue(isinstance(base_obj, list)) + self.assertTrue(any(True for v in base_obj if v == value)) + + def verify_config_value(self, obj, path, key, value): + base_obj = self.walk_path(obj, path) + if isinstance(base_obj, list): + self.assertTrue(any(True for v in base_obj if key in v and v[key] == value)) + elif isinstance(base_obj, dict): + self.assertTrue(key in base_obj) + self.assertEqual(base_obj[key], value) + + def verify_kea_service_running(self, process_name): + tmp = cmd('tail -n 100 /var/log/messages') + self.assertTrue( + process_named_running(process_name), msg=f'Service not running, log: {tmp}' + ) + def test_vrf_vni_and_table_id(self): base_table = '1000' table = base_table @@ -601,5 +639,360 @@ class VRFTest(VyOSUnitTestSHIM.TestCase): self.cli_delete(['nat']) + def test_dhcp_single_pool(self): + # Prepare the vrf and options + table = '100' + vrf = 'dhcp_smoke' + interface = 'dum8888' + subnet = '192.0.2.0/25' + router = inc_ip(subnet, 1) + dns_1 = inc_ip(subnet, 2) + dns_2 = inc_ip(subnet, 3) + domain_name = 'vyos.net' + + # declare fiels + process_name = 'kea-dhcp4' + kea4_conf = f'/run/kea/kea-{vrf}-dhcp4.conf' + + # create interface + cidr_mask = subnet.split('/')[-1] + self.cli_set( + ['interfaces', 'dummy', interface, 'address', f'{router}/{cidr_mask}'] + ) + self.cli_set(['interfaces', 'dummy', interface, 'vrf', f'{vrf}']) + + # create the vrf with table + base = base_path + ['name', vrf] + self.cli_set(base + ['table', table]) + + # set the dhcp scope + base = base_path + ['name', vrf, 'service', 'dhcp-server'] + shared_net_name = 'SMOKE-1' + + range_0_start = inc_ip(subnet, 10) + range_0_stop = inc_ip(subnet, 20) + range_1_start = inc_ip(subnet, 40) + range_1_stop = inc_ip(subnet, 50) + + self.cli_set(base + ['listen-interface', interface]) + + self.cli_set(base + ['shared-network-name', shared_net_name, 'ping-check']) + + pool = base + ['shared-network-name', shared_net_name, 'subnet', subnet] + self.cli_set(pool + ['subnet-id', '1']) + self.cli_set(pool + ['ignore-client-id']) + self.cli_set(pool + ['ping-check']) + # we use the first subnet IP address as default gateway + self.cli_set(pool + ['option', 'default-router', router]) + self.cli_set(pool + ['option', 'name-server', dns_1]) + self.cli_set(pool + ['option', 'name-server', dns_2]) + self.cli_set(pool + ['option', 'domain-name', domain_name]) + + # check validate() - No DHCP address range or active static-mapping set + with self.assertRaises(ConfigSessionError): + self.cli_commit() + self.cli_set(pool + ['range', '0', 'start', range_0_start]) + self.cli_set(pool + ['range', '0', 'stop', range_0_stop]) + self.cli_set(pool + ['range', '1', 'start', range_1_start]) + self.cli_set(pool + ['range', '1', 'stop', range_1_stop]) + + # commit changes + self.cli_commit() + + config = read_file(kea4_conf) + obj = loads(config) + + self.verify_config_value( + obj, ['Dhcp4', 'interfaces-config'], 'interfaces', [interface] + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks'], 'name', shared_net_name + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'subnet', subnet + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'id', 1 + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'match-client-id', False + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'valid-lifetime', 86400 + ) + self.verify_config_value( + obj, ['Dhcp4', 'shared-networks', 0, 'subnet4'], 'max-valid-lifetime', 86400 + ) + + # Verify ping-check + self.verify_config_value( + obj, + ['Dhcp4', 'shared-networks', 0, 'user-context'], + 'enable-ping-check', + True, + ) + + self.verify_config_value( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'user-context'], + 'enable-ping-check', + True, + ) + + # Verify options + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], + {'name': 'domain-name', 'data': domain_name}, + ) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], + {'name': 'domain-name-servers', 'data': f'{dns_1}, {dns_2}'}, + ) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'option-data'], + {'name': 'routers', 'data': router}, + ) + + # Verify pools + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools'], + {'pool': f'{range_0_start} - {range_0_stop}'}, + ) + self.verify_config_object( + obj, + ['Dhcp4', 'shared-networks', 0, 'subnet4', 0, 'pools'], + {'pool': f'{range_1_start} - {range_1_stop}'}, + ) + + # Check for running process + self.verify_kea_service_running(process_name) + + # perform cleanup + self.cli_delete(['interfaces', 'dummy', interface, 'address']) + self.cli_delete(['interfaces', 'dummy', interface, 'vrf']) + self.cli_delete(base) + self.cli_commit() + + def test_dhcpv6_single_pool(self): + # Prepare the vrf and other options + table = '100' + vrf = 'dhcp_smoke' + subnet = '2001:db8:f00::/64' + dns_1 = '2001:db8::1' + dns_2 = '2001:db8::2' + domain = 'vyos.net' + nis_servers = ['2001:db8:ffff::1', '2001:db8:ffff::2'] + interface = 'dum8888' + interface_addr = inc_ip(subnet, 1) + '/64' + + # declare fiels + process_name = 'kea-dhcp6' + kea6_conf = f'/run/kea/kea-{vrf}-dhcp6.conf' + + # create interface + self.cli_set(['interfaces', 'dummy', interface, 'address', f'{interface_addr}']) + self.cli_set(['interfaces', 'dummy', interface, 'vrf', f'{vrf}']) + + # create the vrf with table + base = base_path + ['name', vrf] + self.cli_set(base + ['table', table]) + + # set the dhcp scope + base = base_path + ['name', vrf, 'service', 'dhcpv6-server'] + + shared_net_name = 'SMOKE-1' + search_domains = ['foo.vyos.net', 'bar.vyos.net'] + lease_time = '1200' + max_lease_time = '72000' + min_lease_time = '600' + preference = '10' + sip_server = 'sip.vyos.net' + sntp_server = inc_ip(subnet, 100) + range_start = inc_ip(subnet, 256) # ::100 + range_stop = inc_ip(subnet, 65535) # ::ffff + + pool = base + ['shared-network-name', shared_net_name, 'subnet', subnet] + + self.cli_set(base + ['preference', preference]) + self.cli_set(pool + ['interface', interface]) + self.cli_set(pool + ['subnet-id', '1']) + # we use the first subnet IP address as default gateway + self.cli_set(pool + ['lease-time', 'default', lease_time]) + self.cli_set(pool + ['lease-time', 'maximum', max_lease_time]) + self.cli_set(pool + ['lease-time', 'minimum', min_lease_time]) + self.cli_set(pool + ['option', 'capwap-controller', dns_1]) + self.cli_set(pool + ['option', 'name-server', dns_1]) + self.cli_set(pool + ['option', 'name-server', dns_2]) + self.cli_set(pool + ['option', 'name-server', dns_2]) + self.cli_set(pool + ['option', 'nis-domain', domain]) + self.cli_set(pool + ['option', 'nisplus-domain', domain]) + self.cli_set(pool + ['option', 'sip-server', sip_server]) + self.cli_set(pool + ['option', 'sntp-server', sntp_server]) + self.cli_set(pool + ['range', '1', 'start', range_start]) + self.cli_set(pool + ['range', '1', 'stop', range_stop]) + + for server in nis_servers: + self.cli_set(pool + ['option', 'nis-server', server]) + self.cli_set(pool + ['option', 'nisplus-server', server]) + + for search_domain in search_domains: + self.cli_set(pool + ['option', 'domain-search', search_domain]) + + client_base = 1 + for client in ['client1', 'client2', 'client3']: + duid = f'00:01:00:01:12:34:56:78:aa:bb:cc:dd:ee:{client_base:02}' + self.cli_set(pool + ['static-mapping', client, 'duid', duid]) + self.cli_set( + pool + + [ + 'static-mapping', + client, + 'ipv6-address', + inc_ip(subnet, client_base), + ] + ) + self.cli_set( + pool + + [ + 'static-mapping', + client, + 'ipv6-prefix', + inc_ip(subnet, client_base << 64) + '/64', + ] + ) + client_base += 1 + + # cannot have both mac-address and duid set + with self.assertRaises(ConfigSessionError): + self.cli_set( + pool + ['static-mapping', 'client1', 'mac', '00:50:00:00:00:11'] + ) + self.cli_commit() + self.cli_delete(pool + ['static-mapping', 'client1', 'mac']) + + # commit changes + self.cli_commit() + + config = read_file(kea6_conf) + obj = loads(config) + + self.verify_config_value( + obj, ['Dhcp6', 'shared-networks'], 'name', shared_net_name + ) + self.verify_config_value( + obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'subnet', subnet + ) + self.verify_config_value( + obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'interface', interface + ) + self.verify_config_value( + obj, ['Dhcp6', 'shared-networks', 0, 'subnet6'], 'id', 1 + ) + self.verify_config_value( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6'], + 'valid-lifetime', + int(lease_time), + ) + self.verify_config_value( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6'], + 'min-valid-lifetime', + int(min_lease_time), + ) + self.verify_config_value( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6'], + 'max-valid-lifetime', + int(max_lease_time), + ) + + # Verify options + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'capwap-ac-v6', 'data': dns_1}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'dns-servers', 'data': f'{dns_1}, {dns_2}'}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'domain-search', 'data': ', '.join(search_domains)}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'nis-domain-name', 'data': domain}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'nis-servers', 'data': ', '.join(nis_servers)}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'nisp-domain-name', 'data': domain}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'nisp-servers', 'data': ', '.join(nis_servers)}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'sntp-servers', 'data': sntp_server}, + ) + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'option-data'], + {'name': 'sip-server-dns', 'data': sip_server}, + ) + + # Verify pools + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'pools'], + {'pool': f'{range_start} - {range_stop}'}, + ) + + client_base = 1 + for client in ['client1', 'client2', 'client3']: + duid = f'00:01:00:01:12:34:56:78:aa:bb:cc:dd:ee:{client_base:02}' + ip = inc_ip(subnet, client_base) + prefix = inc_ip(subnet, client_base << 64) + '/64' + + self.verify_config_object( + obj, + ['Dhcp6', 'shared-networks', 0, 'subnet6', 0, 'reservations'], + { + 'hostname': client, + 'duid': duid, + 'ip-addresses': [ip], + 'prefixes': [prefix], + }, + ) + + client_base += 1 + + # Check for running process + self.verify_kea_service_running(process_name) + + # perform cleanup + self.cli_delete(['interfaces', 'dummy', interface, 'address']) + self.cli_delete(['interfaces', 'dummy', interface, 'vrf']) + self.cli_delete(base) + self.cli_commit() + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/src/conf_mode/service_dhcp-server.py b/src/conf_mode/service_dhcp-server.py index 99c7e6a1f..ef95d5d80 100755 --- a/src/conf_mode/service_dhcp-server.py +++ b/src/conf_mode/service_dhcp-server.py @@ -16,11 +16,13 @@ import os +from sys import exit +from sys import argv + from glob import glob from ipaddress import ip_address from ipaddress import ip_network from netaddr import IPRange -from sys import exit from vyos.config import Config from vyos.pki import wrap_certificate @@ -41,16 +43,53 @@ from vyos import airbag airbag.enable() -ctrl_socket = '/run/kea/dhcp4-ctrl-socket' -config_file = '/run/kea/kea-dhcp4.conf' -config_file_d2 = '/run/kea/kea-dhcp-ddns.conf' -lease_file = '/config/dhcp/dhcp4-leases.csv' -lease_file_glob = '/config/dhcp/dhcp4-leases*' +ctrl_socket = '' +config_file = '' +config_file_d2 = '' +lease_file = '' +lease_file_glob = '' + +ca_cert_file = '' +cert_file = '' +cert_key_file = '' + user_group = '_kea' -ca_cert_file = '/run/kea/kea-failover-ca.pem' -cert_file = '/run/kea/kea-failover.pem' -cert_key_file = '/run/kea/kea-failover-key.pem' + +def _override_for_vrf(vrf_name): + """ + This function is intended to override global vars when vrf is enabled + """ + global ctrl_socket, config_file, config_file_d2, lease_file, lease_file_glob + global ca_cert_file, cert_file, cert_key_file + + ctrl_socket = f'/run/kea/dhcp4-{vrf_name}-ctrl-socket' + config_file = f'/run/kea/kea-{vrf_name}-dhcp4.conf' + config_file_d2 = f'/run/kea/kea-{vrf_name}-dhcp-ddns.conf' + lease_file = f'/config/dhcp/dhcp4-{vrf_name}-leases.csv' + lease_file_glob = f'/config/dhcp/dhcp4-{vrf_name}-leases*' + + ca_cert_file = f'/run/kea/kea-{vrf_name}-failover-ca.pem' + cert_file = f'/run/kea/kea-{vrf_name}-failover.pem' + cert_key_file = f'/run/kea/kea-{vrf_name}-failover-key.pem' + + +def _reset_vars(): + """ + This function is intended to reset global vars when vrf is not enabled + """ + global ctrl_socket, config_file, config_file_d2, lease_file, lease_file_glob + global ca_cert_file, cert_file, cert_key_file + + ctrl_socket = '/run/kea/dhcp4-ctrl-socket' + config_file = '/run/kea/kea-dhcp4.conf' + config_file_d2 = '/run/kea/kea-dhcp-ddns.conf' + lease_file = '/config/dhcp/dhcp4-leases.csv' + lease_file_glob = '/config/dhcp/dhcp4-leases*' + + ca_cert_file = '/run/kea/kea-failover-ca.pem' + cert_file = '/run/kea/kea-failover.pem' + cert_key_file = '/run/kea/kea-failover-key.pem' def dhcp_slice_range(exclude_list, range_dict): @@ -125,7 +164,19 @@ def get_config(config=None): conf = config else: conf = Config() - base = ['service', 'dhcp-server'] + + # if running in vrf, set base diffrently + if argv and len(argv) > 1: + vrf_name = argv[1] + base = ['vrf', 'name', vrf_name, 'service', 'dhcp-server'] + + # vrf is defined, override other vars aswell + _override_for_vrf(vrf_name) + else: + base = ['service', 'dhcp-server'] + + # vrf is not defined reset vars + _reset_vars() if not conf.exists(base): return None @@ -137,6 +188,10 @@ def get_config(config=None): with_recursive_defaults=True, ) + # add vrf context if present + if argv and len(argv) > 1: + dhcp['vrf_context'] = argv[1] + if 'shared_network_name' in dhcp: for network, network_config in dhcp['shared_network_name'].items(): if 'subnet' in network_config: @@ -524,7 +579,12 @@ def generate(dhcp): def apply(dhcp): - services = ['kea-dhcp4-server', 'kea-dhcp-ddns-server'] + # if running in vrf, set base diffrently + if argv and len(argv) > 1: + vrf_name = argv[1] + services = [f'kea-dhcp4-server@{vrf_name}', f'kea-dhcp-ddns-server@{vrf_name}'] + else: + services = ['kea-dhcp4-server', 'kea-dhcp-ddns-server'] if not dhcp or 'disable' in dhcp: for service in services: @@ -538,7 +598,7 @@ def apply(dhcp): for service in services: action = 'restart' - if service == 'kea-dhcp-ddns-server' and 'dynamic_dns_update' not in dhcp: + if 'kea-dhcp-ddns-server' in service and 'dynamic_dns_update' not in dhcp: action = 'stop' call(f'systemctl {action} {service}.service') diff --git a/src/conf_mode/service_dhcpv6-server.py b/src/conf_mode/service_dhcpv6-server.py index 7af88007c..01f3abfe9 100755 --- a/src/conf_mode/service_dhcpv6-server.py +++ b/src/conf_mode/service_dhcpv6-server.py @@ -16,10 +16,12 @@ import os +from sys import exit +from sys import argv + from glob import glob from ipaddress import ip_address from ipaddress import ip_network -from sys import exit from vyos.config import Config from vyos.template import render @@ -32,26 +34,71 @@ from vyos.utils.dict import dict_search from vyos.utils.network import is_subnet_connected from vyos import ConfigError from vyos import airbag + airbag.enable() -config_file = '/run/kea/kea-dhcp6.conf' -ctrl_socket = '/run/kea/dhcp6-ctrl-socket' -lease_file = '/config/dhcp/dhcp6-leases.csv' -lease_file_glob = '/config/dhcp/dhcp6-leases*' + +config_file = '' +ctrl_socket = '' +lease_file = '' +lease_file_glob = '' + user_group = '_kea' + +def _override_for_vrf(vrf_name): + """ + This function is intended to override some of the global vars + """ + global ctrl_socket, config_file, lease_file, lease_file_glob + + config_file = f'/run/kea/kea-{vrf_name}-dhcp6.conf' + ctrl_socket = f'/run/kea/dhcp6-{vrf_name}-ctrl-socket' + lease_file = f'/config/dhcp/dhcp6-{vrf_name}-leases.csv' + lease_file_glob = f'/config/dhcp/dhcp6-{vrf_name}-leases*' + + +def _reset_vars(): + """ + This function is intended to reset global vars when vrf is not enabled + """ + global ctrl_socket, config_file, lease_file, lease_file_glob + + config_file = '/run/kea/kea-dhcp6.conf' + ctrl_socket = '/run/kea/dhcp6-ctrl-socket' + lease_file = '/config/dhcp/dhcp6-leases.csv' + lease_file_glob = '/config/dhcp/dhcp6-leases*' + + def get_config(config=None): if config: conf = config else: conf = Config() - base = ['service', 'dhcpv6-server'] + + # if running in vrf, set base diffrently + if argv and len(argv) > 1: + vrf_name = argv[1] + base = ['vrf', 'name', vrf_name, 'service', 'dhcpv6-server'] + + # vrf is defined, override other vars aswell + _override_for_vrf(vrf_name) + else: + base = ['service', 'dhcpv6-server'] + + # vrf is not defined reset vars + _reset_vars() if not conf.exists(base): return None - dhcpv6 = conf.get_config_dict(base, key_mangling=('-', '_'), - get_first_key=True, - no_tag_node_value_mangle=True) + dhcpv6 = conf.get_config_dict( + base, key_mangling=('-', '_'), get_first_key=True, no_tag_node_value_mangle=True + ) + + # add vrf context if present + if argv and len(argv) > 1: + dhcpv6['vrf_context'] = argv[1] + return dhcpv6 def verify(dhcpv6): @@ -239,8 +286,14 @@ def generate(dhcpv6): return None def apply(dhcpv6): + # if running in vrf, set base diffrently + if argv and len(argv) > 1: + vrf_name = argv[1] + service_name = f'kea-dhcp6-server@{vrf_name}.service' + else: + service_name = 'kea-dhcp6-server.service' + # bail out early - looks like removal from running config - service_name = 'kea-dhcp6-server.service' if not dhcpv6 or 'disable' in dhcpv6: # DHCP server is removed in the commit call(f'systemctl stop {service_name}') diff --git a/src/etc/sudoers.d/vyos b/src/etc/sudoers.d/vyos index 198b9b9aa..2dd8850c4 100644 --- a/src/etc/sudoers.d/vyos +++ b/src/etc/sudoers.d/vyos @@ -47,6 +47,8 @@ Cmnd_Alias DIAGNOSTICS = /bin/ip vrf exec * /bin/ping *, \ /usr/libexec/vyos/op_mode/* Cmnd_Alias KEA_IP6_ROUTES = /sbin/ip -6 route replace *,\ /sbin/ip -6 route del * +Cmnd_Alias KEA_DHCP = /bin/ip vrf exec * /usr/sbin/kea-dhcp*,\ + /usr/bin/chown * /run/kea/* %operator ALL=NOPASSWD: DATE, IPTABLES, ETHTOOL, IPFLUSH, HWINFO, \ PPPOE_CMDS, PCAPTURE, /usr/sbin/wanpipemon, \ DMIDECODE, DISK, CONNTRACK, IP6TABLES, \ @@ -62,3 +64,4 @@ Cmnd_Alias KEA_IP6_ROUTES = /sbin/ip -6 route replace *,\ %sudo ALL=NOPASSWD: /usr/bin/mokutil _kea ALL=NOPASSWD: KEA_IP6_ROUTES +_kea ALL=NOPASSWD: KEA_DHCP diff --git a/src/etc/systemd/system/kea-dhcp-ddns-server@.service b/src/etc/systemd/system/kea-dhcp-ddns-server@.service new file mode 100644 index 000000000..b6360e573 --- /dev/null +++ b/src/etc/systemd/system/kea-dhcp-ddns-server@.service @@ -0,0 +1,22 @@ +[Unit] +Description=Kea DDNS Service +Documentation=man:kea-dhcp-ddns(8) +Wants=network-online.target +After=vyos-router.service + +[Service] +User=_kea +AmbientCapabilities=CAP_NET_BIND_SERVICE +Environment="KEA_LOCKFILE_DIR=/run/lock/kea" +ConfigurationDirectory=kea +RuntimeDirectory=kea lock/kea +RuntimeDirectoryPreserve=yes +LogsDirectory=kea +LogsDirectoryMode=0750 +StateDirectory=kea +ExecStart=sudo /bin/ip vrf exec %i /usr/sbin/kea-dhcp-ddns -c /run/kea/kea-%i-dhcp-ddns.conf +ExecStartPost=/bin/sh -c 'sleep 10 && sudo /usr/bin/chown _kea:_kea /run/kea/kea-%i-dhcp-ddns* && sudo /usr/bin/chown _kea:_kea /run/kea/logger_lockfile' +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/etc/systemd/system/kea-dhcp4-server@.service b/src/etc/systemd/system/kea-dhcp4-server@.service new file mode 100644 index 000000000..f63364275 --- /dev/null +++ b/src/etc/systemd/system/kea-dhcp4-server@.service @@ -0,0 +1,22 @@ +[Unit] +Description=Kea IPv4 DHCP daemon +Documentation=man:kea-dhcp4(8) +Wants=network-online.target +After=vyos-router.service + +[Service] +User=_kea +AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_RAW +Environment="KEA_LOCKFILE_DIR=/run/lock/kea" +ConfigurationDirectory=kea +RuntimeDirectory=kea lock/kea +RuntimeDirectoryPreserve=yes +LogsDirectory=kea +LogsDirectoryMode=0750 +StateDirectory=kea +ExecStart=sudo /bin/ip vrf exec %i /usr/sbin/kea-dhcp4 -c /run/kea/kea-%i-dhcp4.conf +ExecStartPost=/bin/sh -c 'sleep 10 && sudo /usr/bin/chown _kea:_kea /run/kea/dhcp4-%i-ctrl* && sudo /usr/bin/chown _kea:_kea /run/kea/kea-%i-dhcp4* && sudo /usr/bin/chown _kea:_kea /run/kea/logger_lockfile' +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/etc/systemd/system/kea-dhcp6-server@.service b/src/etc/systemd/system/kea-dhcp6-server@.service new file mode 100644 index 000000000..1d2330f74 --- /dev/null +++ b/src/etc/systemd/system/kea-dhcp6-server@.service @@ -0,0 +1,22 @@ +[Unit] +Description=Kea IPv6 DHCP daemon +Documentation=man:kea-dhcp6(8) +Wants=network-online.target +After=vyos-router.service + +[Service] +User=_kea +AmbientCapabilities=CAP_NET_BIND_SERVICE +Environment="KEA_LOCKFILE_DIR=/run/lock/kea" +ConfigurationDirectory=kea +RuntimeDirectory=kea lock/kea +RuntimeDirectoryPreserve=yes +LogsDirectory=kea +LogsDirectoryMode=0750 +StateDirectory=kea +ExecStart=sudo /bin/ip vrf exec %i /usr/sbin/kea-dhcp6 -c /run/kea/kea-%i-dhcp6.conf +ExecStartPost=/bin/sh -c 'sleep 10 && sudo /usr/bin/chown _kea:_kea /run/kea/dhcp6-%i-ctrl* && sudo /usr/bin/chown _kea:_kea /run/kea/kea-%i-dhcp6* && sudo /usr/bin/chown _kea:_kea /run/kea/logger_lockfile' +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index 725bfc75b..f4b7ac582 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -83,12 +83,12 @@ ArgOrigin = typing.Literal['local', 'remote'] def _get_raw_server_leases( - config, family='inet', pool=None, sorted=None, state=[], origin=None + config, family='inet', vrf='', pool=None, sorted=None, state=[], origin=None ) -> list: inet_suffix = '6' if family == 'inet6' else '4' pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) - mappings = kea_get_server_leases(config, inet_suffix, pools, state, origin) + mappings = kea_get_server_leases(config, inet_suffix, vrf, pools, state, origin) if sorted: if sorted == 'ip': @@ -166,9 +166,13 @@ def _get_formatted_server_leases(raw_data, family='inet'): return output -def _get_pool_size(pool, family='inet'): +def _get_pool_size(pool, family='inet', vrf=''): v = 'v6' if family == 'inet6' else '' - base = f'service dhcp{v}-server shared-network-name {pool}' + # if vrf is set get the correct base for config + if vrf: + base = f'vrf name {vrf} service dhcp{v}-server shared-network-name {pool}' + else: + base = f'service dhcp{v}-server shared-network-name {pool}' size = 0 subnets = config.list_nodes(f'{base} subnet') for subnet in subnets: @@ -185,14 +189,14 @@ def _get_pool_size(pool, family='inet'): return size -def _get_raw_server_pool_statistics(config, family='inet', pool=None): +def _get_raw_server_pool_statistics(config, family='inet', vrf='', pool=None): inet_suffix = '6' if family == 'inet6' else '4' pools = [pool] if pool else kea_get_dhcp_pools(config, inet_suffix) stats = [] for p in pools: - size = _get_pool_size(family=family, pool=p) - leases = len(_get_raw_server_leases(config, family=family, pool=p)) + size = _get_pool_size(family=family, vrf=vrf, pool=p) + leases = len(_get_raw_server_leases(config, family=family, vrf=vrf, pool=p)) use_percentage = round(leases / size * 100) if size != 0 else 0 pool_stats = { 'pool': p, @@ -269,11 +273,20 @@ def _verify_server(func): def _wrapper(*args, **kwargs): config = ConfigTreeQuery() family = kwargs.get('family') + vrf = kwargs.get('vrf') v = 'v6' if family == 'inet6' else '' - unconf_message = f'DHCP{v} server is not configured' + # Check if config does not exist - if not config.exists(f'service dhcp{v}-server'): - raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + if vrf: + unconf_message = f'DHCP{v} server is not configured for VRF {vrf}' + if not config.exists(f'vrf name {vrf} service dhcp{v}-server'): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + else: + unconf_message = f'DHCP{v} server is not configured' + if not config.exists(f'service dhcp{v}-server'): + raise vyos.opmode.UnconfiguredSubsystem(unconf_message) + + # return return func(*args, **kwargs) return _wrapper @@ -303,25 +316,42 @@ def _verify_client(func): @_verify_server def show_server_pool_statistics( - raw: bool, family: ArgFamily, pool: typing.Optional[str] + raw: bool, family: ArgFamily, vrf: str, pool: typing.Optional[str] ): v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') - pool_data = _get_raw_server_pool_statistics(active_config, family=family, pool=pool) + pool_data = _get_raw_server_pool_statistics( + active_config, family=family, vrf=vrf, pool=pool + ) if raw: return pool_data else: @@ -332,6 +362,7 @@ def show_server_pool_statistics( def show_server_leases( raw: bool, family: ArgFamily, + vrf: str, pool: typing.Optional[str], sorted: typing.Optional[str], state: typing.Optional[ArgState], @@ -340,18 +371,33 @@ def show_server_leases( v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') sort_valid = sort_valid_inet6 if family == 'inet6' else sort_valid_inet if sorted and sorted not in sort_valid: @@ -363,6 +409,7 @@ def show_server_leases( lease_data = _get_raw_server_leases( config=active_config, family=family, + vrf=vrf, pool=pool, sorted=sorted, state=state, @@ -378,24 +425,40 @@ def show_server_leases( def show_server_static_mappings( raw: bool, family: ArgFamily, + vrf: str, pool: typing.Optional[str], sorted: typing.Optional[str], ): v = 'v6' if family == 'inet6' else '' inet_suffix = '6' if family == 'inet6' else '4' - if not is_systemd_service_running(f'kea-dhcp{inet_suffix}-server.service'): + if vrf: + service = f'kea-dhcp{inet_suffix}-server@{vrf}.service' + else: + service = f'kea-dhcp{inet_suffix}-server.service' + + if not is_systemd_service_running(service): Warning(stale_warn_msg) try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: - raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') + if vrf: + raise vyos.opmode.DataUnavailable( + f'Cannot fetch DHCP server configuration for VRF {vrf}' + ) + else: + raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') active_pools = kea_get_dhcp_pools(active_config, inet_suffix) if pool and active_pools and pool not in active_pools: - raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') + if vrf: + raise vyos.opmode.IncorrectValue( + f'DHCP{v} pool "{pool}" does not exist for VRF {vrf}!' + ) + else: + raise vyos.opmode.IncorrectValue(f'DHCP{v} pool "{pool}" does not exist!') if sorted and sorted not in mapping_sort_valid: raise vyos.opmode.IncorrectValue(f'DHCP{v} sort "{sorted}" is invalid!') @@ -409,13 +472,13 @@ def show_server_static_mappings( return _get_formatted_server_static_mappings(static_mappings) -def _lease_valid(inet, address): - leases = kea_get_leases(inet) +def _lease_valid(inet, vrf, address): + leases = kea_get_leases(inet, vrf) return any(lease['ip-address'] == address for lease in leases) @_verify_server -def clear_dhcp_server_lease(family: ArgFamily, address: str): +def clear_dhcp_server_lease(family: ArgFamily, vrf: str, address: str): v = 'v6' if family == 'inet6' else '' inet = '6' if family == 'inet6' else '4' @@ -423,7 +486,7 @@ def clear_dhcp_server_lease(family: ArgFamily, address: str): print(f'Lease not found on DHCP{v} server') return None - if not kea_delete_lease(inet, address): + if not kea_delete_lease(inet, vrf, address): print(f'Failed to clear lease for "{address}"') return None diff --git a/src/system/on-dhcp-event.sh b/src/system/on-dhcp-event.sh index 47c276270..556d7dbcb 100755 --- a/src/system/on-dhcp-event.sh +++ b/src/system/on-dhcp-event.sh @@ -30,7 +30,7 @@ get_subnet_domain_name () { from vyos.kea import kea_get_active_config from vyos.utils.dict import dict_search_args -config = kea_get_active_config('4') +config = kea_get_active_config('4', '') shared_networks = dict_search_args(config, 'arguments', f'Dhcp4', 'shared-networks') found = False diff --git a/src/system/sync-dhcp-lease-to-hosts.py b/src/system/sync-dhcp-lease-to-hosts.py index 5c8b18faf..8baa6992a 100755 --- a/src/system/sync-dhcp-lease-to-hosts.py +++ b/src/system/sync-dhcp-lease-to-hosts.py @@ -33,17 +33,17 @@ logs_handler = logging.StreamHandler() logger.addHandler(logs_handler) -def _get_all_server_leases(inet_suffix='4') -> list: +def _get_all_server_leases(inet_suffix='4', vrf='') -> list: mappings = [] try: - active_config = kea_get_active_config(inet_suffix) + active_config = kea_get_active_config(inet_suffix, vrf) except Exception: raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server configuration') try: pools = kea_get_dhcp_pools(active_config, inet_suffix) mappings = kea_get_server_leases( - active_config, inet_suffix, pools, state=[], origin=None + active_config, inet_suffix, vrf, pools, state=[], origin=None ) except Exception: raise vyos.opmode.DataUnavailable('Cannot fetch DHCP server leases') -- cgit v1.2.3 From b89730b5893712b8b000b0409c4ced690b201fe7 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 6 Aug 2025 20:56:32 -0500 Subject: T7718: add python wrappers for validate_tree methods --- python/vyos/configtree.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) (limited to 'python') diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index ba3f1e368..aeeb329c5 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -614,6 +614,44 @@ def reference_tree_cache_to_json(cache_path, render_file, libpath=LIBPATH): raise ConfigTreeError(msg) +# validate_tree_filter c_ptr rt_cache validator_dir +def validate_tree_filter( + config_tree, + cache_path='/usr/share/vyos/reftree.cache', + validator_dir='/usr/libexec/vyos/validators', + libpath=LIBPATH, +): + try: + __lib = cdll.LoadLibrary(libpath) + __validate_tree_filter = __lib.validate_tree_filter + __validate_tree_filter.argtypes = [c_void_p, c_char_p, c_char_p] + __get_error = __lib.get_error + __get_error.argtypes = [] + __get_error.restype = c_char_p + res = __validate_tree_filter( + config_tree._get_config(), cache_path.encode(), validator_dir.encode() + ) + except Exception as e: + raise ConfigTreeError(e) + + msg = __get_error().decode() + tree = ConfigTree(address=res) + + return tree, msg + + +def validate_tree( + config_tree, + cache_path='/usr/share/vyos/reftree.cache', + validator_dir='/usr/libexec/vyos/validators', +): + _, out = validate_tree_filter( + config_tree, cache_path=cache_path, validator_dir=validator_dir + ) + + return out + + class DiffTree: def __init__(self, left, right, path=[], libpath=LIBPATH): if left is None: -- cgit v1.2.3 From 84f8c540967721030be20c510314e417391ca7c2 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 19 Aug 2025 20:57:03 -0500 Subject: T7493: add load_config_obj to load into vyconfd --- python/vyos/configsession.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 50f93f890..55b13d472 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -17,6 +17,9 @@ import os import re import sys import subprocess +from tempfile import NamedTemporaryFile +from typing import TypeAlias +from typing import Union from vyos.defaults import directories from vyos.utils.process import is_systemd_service_running @@ -26,6 +29,10 @@ from vyos.utils.backend import vyconf_backend from vyos.vyconf_session import VyconfSession from vyos.base import Warning as Warn from vyos.defaults import DEFAULT_COMMIT_CONFIRM_MINUTES +from vyos.configtree import ConfigTree + +# type of config file path or configtree +ConfigObj: TypeAlias = Union[str, ConfigTree] CLI_SHELL_API = '/bin/cli-shell-api' @@ -238,6 +245,9 @@ class ConfigSession(object): def get_session_env(self): return self.__session_env + def vyconf_backend(self) -> bool: + return bool(self._vyconf_session) + def set(self, path, value=None): if not value: value = [] @@ -339,11 +349,13 @@ class ConfigSession(object): if format == 'raw': return config_data - def load_config(self, file_path): + def load_config(self, file_path, cached: bool = False): if self._vyconf_session is None: out = self.__run_command(LOAD_CONFIG + [file_path]) else: - out, _ = self._vyconf_session.load_config(file_name=file_path) + out, _ = self._vyconf_session.load_config( + file_name=file_path, cached=cached + ) return out @@ -356,6 +368,14 @@ class ConfigSession(object): except LoadConfigError as e: raise ConfigSessionError(e) from e + def load_config_obj(self, config_obj: ConfigObj): + if isinstance(config_obj, ConfigTree): + with NamedTemporaryFile() as f: + config_obj.write_cache(f.name) + self.load_config(f.name, cached=True) + else: + self.load_config(config_obj) + def migrate_and_load_config(self, file_path): if self._vyconf_session is None: out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path]) -- cgit v1.2.3 From 3b1e60b4a8f21e16e720ed32a1dda6e57139ac49 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 19 Aug 2025 20:57:25 -0500 Subject: T7493: use method load_config_obj in revert_soft --- python/vyos/config_mgmt.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'python') diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py index 51c6f2241..b549b3005 100644 --- a/python/vyos/config_mgmt.py +++ b/python/vyos/config_mgmt.py @@ -307,7 +307,11 @@ Proceed ?""" session = ConfigSession(os.getpid(), app='config-mgmt') try: - session.load_explicit(revert_ct) + if session.vyconf_backend(): + session.load_config_obj(revert_ct) + else: + session.load_explicit(revert_ct) + session.commit() except ConfigSessionError as e: raise ConfigMgmtError(e) from e -- cgit v1.2.3 From f30d561fd5323634ab2d5b13884a72325b7fe218 Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Tue, 19 Aug 2025 20:57:44 -0500 Subject: T7493: config_mgmt console_scripts available in vyconf context --- python/vyos/configsession.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) (limited to 'python') diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 55b13d472..cd0088b60 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -319,18 +319,12 @@ class ConfigSession(object): return out def commit_confirm(self, minutes: int = DEFAULT_COMMIT_CONFIRM_MINUTES): - if self._vyconf_session is None: - out = self.__run_command(COMMIT_CONFIRM + [f'-t {minutes}']) - else: - out = 'unimplemented' + out = self.__run_command(COMMIT_CONFIRM + [f'-t {minutes}']) return out def confirm(self): - if self._vyconf_session is None: - out = self.__run_command(CONFIRM) - else: - out = 'unimplemented' + out = self.__run_command(CONFIRM) return out -- cgit v1.2.3 From ae336bdf8a57e28c0bc544ee6b7d57ab048123df Mon Sep 17 00:00:00 2001 From: Nataliia Solomko Date: Tue, 19 Aug 2025 11:47:47 +0300 Subject: T7670: VPP: Rely on all types of memory to verify memory resources VPP can use 2M hugepages and 1G hugepages at the same time --- python/vyos/vpp/config_resource_checks/memory.py | 108 ++++++++++++++++------- python/vyos/vpp/config_verify.py | 66 +++++++++----- python/vyos/vpp/utils.py | 11 ++- src/conf_mode/vpp.py | 14 ++- 4 files changed, 140 insertions(+), 59 deletions(-) (limited to 'python') diff --git a/python/vyos/vpp/config_resource_checks/memory.py b/python/vyos/vpp/config_resource_checks/memory.py index 66b9fd9a3..9391e21e1 100644 --- a/python/vyos/vpp/config_resource_checks/memory.py +++ b/python/vyos/vpp/config_resource_checks/memory.py @@ -16,7 +16,9 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +import os import re +import psutil from vyos.utils.process import cmd from vyos.vpp.utils import ( @@ -26,29 +28,55 @@ from vyos.vpp.utils import ( from vyos.vpp.config_resource_checks.resource_defaults import default_resource_map +def classify_page_size(page_size_bytes: int) -> str: + """ + Returns one of: '4K', '2M', '1G' based on page size. + """ + if page_size_bytes == 1 << 30: + return '1G' + if page_size_bytes == 2 << 20: + return '2M' + return '4K' + + def get_hugepages_info() -> dict: """ - Returns the information about HugePages for default hugepage size - retrieved from /proc/meminfo + Returns the information about HugePages + retrieved from /sys/kernel/mm/hugepages """ + base_path = '/sys/kernel/mm/hugepages' info = {} - with open('/proc/meminfo', 'r') as meminfo: - for line in meminfo: - if line.startswith('Huge'): - key, value, *_ = line.strip().split() - info[key.rstrip(':')] = int(value) + + for entry in os.listdir(base_path): + page_size_kb = entry[10:] + page_size = human_page_memory_to_bytes(page_size_kb) + key = classify_page_size(page_size) + info[key] = {} + + with open(os.path.join(base_path, entry, 'nr_hugepages')) as f: + count = int(f.read().strip()) + info[key]['pages'] = count + info[key]['memory'] = page_size * count + return info -def get_total_hugepages_memory() -> int: +def get_available_memory() -> dict: + memory = {size: info.get('memory') for size, info in get_hugepages_info().items()} + memory['4K'] = psutil.virtual_memory().available + + return memory + + +def get_vpp_used_memory() -> int: """ - Returns the total amount of hugepage memory (in bytes) + Returns memory currently used by VPP in bytes (RSS value) """ - info = get_hugepages_info() - hugepages_total = info.get('HugePages_Total') - hugepage_size = info.get('Hugepagesize') * 1024 - - return hugepage_size * hugepages_total + try: + out = cmd('ps -o rss= -p $(pidof vpp)') + except OSError: + out = 0 + return int(out) << 10 def get_numa_count(): @@ -61,6 +89,11 @@ def get_numa_count(): return int(m.group(1)) if m else 0 +def buffer_page_size(settings: dict) -> int: + page_size = settings.get('buffers', {}).get('page_size', 'default') + return human_page_memory_to_bytes(page_size) + + def buffer_size(settings: dict) -> int: numa_count = get_numa_count() buffers_per_numa = int( @@ -110,7 +143,7 @@ def statseg_size(settings: dict) -> int: def statseg_page_size(settings: dict) -> int: - page_size = settings.get('statseg', {}).get('page_size', 'default') + page_size = settings.get('statseg', {}).get('page_size') return human_page_memory_to_bytes(page_size) @@ -118,28 +151,39 @@ def total_statseg_size(_statseg_size: int, _statseg_page: int) -> int: return (_statseg_size + _statseg_page - 1) & ~(_statseg_page - 1) -def total_memory_required(settings: dict) -> int: - mem_required = 0 +def total_memory_required(settings: dict) -> dict: + memory = {'2M': 0, '1G': 0, '4K': 0} mem_stats = { - 'memory_buffers': buffer_size(settings), - 'netlink_buffer_size': int( - settings.get('lcp', {}).get( - 'rx_buffer_size', default_resource_map.get('netlink_rx_buffer_size') - ) + 'memory_buffers': (buffer_size(settings), buffer_page_size(settings)), + 'netlink_buffer_size': ( + int( + settings.get('lcp', {}) + .get('netlink', {}) + .get( + 'rx_buffer_size', default_resource_map.get('netlink_rx_buffer_size') + ) + ), + 0, ), - 'heap_size': total_heap_size( - heap_size=memory_main_heap(settings), - heap_page_size=main_heap_page_size(settings), + 'heap_size': ( + total_heap_size( + heap_size=memory_main_heap(settings), + heap_page_size=main_heap_page_size(settings), + ), + main_heap_page_size(settings), ), - 'statseg_size': total_statseg_size( - _statseg_size=statseg_size(settings), - _statseg_page=statseg_page_size(settings), + 'statseg_size': ( + total_statseg_size( + _statseg_size=statseg_size(settings), + _statseg_page=statseg_page_size(settings), + ), + statseg_page_size(settings), ), - 'ipv6_heap_size': ipv6_heap_size(settings), + 'ipv6_heap_size': (ipv6_heap_size(settings), 0), } - for stat in mem_stats: - mem_required += mem_stats[stat] + for memory_size, page_size in mem_stats.values(): + memory[classify_page_size(page_size)] += memory_size - return mem_required + return memory diff --git a/python/vyos/vpp/config_verify.py b/python/vyos/vpp/config_verify.py index c6f43e340..cf0b923bb 100644 --- a/python/vyos/vpp/config_verify.py +++ b/python/vyos/vpp/config_verify.py @@ -211,9 +211,9 @@ def verify_vpp_minimum_memory(): ) -def verify_vpp_memory(config: dict): - main_heap_size = mem_checks.memory_main_heap(config['settings']) - main_heap_page_size = mem_checks.main_heap_page_size(config['settings']) +def verify_vpp_main_heap_size(settings: dict): + main_heap_size = mem_checks.memory_main_heap(settings) + main_heap_page_size = mem_checks.main_heap_page_size(settings) if main_heap_size < 1 << 30: raise ConfigError('The main heap size must be greater than or equal to 1G') @@ -225,28 +225,54 @@ def verify_vpp_memory(config: dict): f'The main heap size must be greater than or equal to page-size ({readable_heap_page})' ) - available_memory = mem_checks.get_total_hugepages_memory() - memory_required = mem_checks.total_memory_required(config['settings']) - if main_heap_size > available_memory: - available_memory_in_mb = bytes_to_human_memory(available_memory, 'M') - raise ConfigError( - f'"memory main-heap-size" must not be greater than hugepages memory. Reduce to {available_memory_in_mb} or less' +def verify_vpp_memory(config: dict): + memory_required = mem_checks.total_memory_required(config['settings']) + memory_available = mem_checks.get_available_memory() + + # Check if there is a config currently active + # If yes, calculate how much memory it consumes (only for 4k pages) + # and exclude it from required memory + if config.get('effective'): + memory_effective = mem_checks.total_memory_required( + config['effective']['settings'] ) - memory_required = round(memory_required / 1024**3, 1) - available_memory = round(available_memory / 1024**3, 1) - - # Allow 10% error margin - allowed_margin = memory_required * 0.1 + # If we want to reduce memory configs then we don't need + # to check 4K memory type + if memory_effective['4K'] >= memory_required['4K']: + del memory_required['4K'] + else: + # Get memory currently used by VPP and add it to available memory + memory_used = mem_checks.get_vpp_used_memory() + memory_available['4K'] += memory_used + + memory_required_gb = {k: round(v / 1024**3, 1) for k, v in memory_required.items()} + memory_available_gb = { + k: round(v / 1024**3, 1) for k, v in memory_available.items() + } + + errors = {} + for page_size, req_gb in memory_required_gb.items(): + avail_gb = memory_available_gb.get(page_size, 0) + + if req_gb > avail_gb: + label = 'System' if page_size == '4K' else f'{page_size} HugePages' + errors[page_size] = ( + f'{label} memory: available {avail_gb} GB, ' + f'required {memory_required_gb[page_size]} GB' + ) - # Compare HugePage memory with required memory for VPP - if memory_required > available_memory + allowed_margin: + if errors: raise ConfigError( - f'Not enough free hugepage memory to start VPP: ' - f'available: {available_memory} GB, required: {memory_required} GB. ' - 'Please add kernel memory options for HugePages ' - '"set system option kernel memory hugepage-size ..." and reboot' + 'Not enough free memory to start VPP! '.ljust(72) + + '. '.join([line.ljust(72) for line in errors.values()]) + + ( + 'To add HugePages memory please use command '.ljust(72) + + '"set system option kernel memory hugepage-size ..." and reboot!' + if any(k in errors for k in ('2M', '1G')) + else '' + ) ) diff --git a/python/vyos/vpp/utils.py b/python/vyos/vpp/utils.py index 8b3a8c038..4e42232cd 100644 --- a/python/vyos/vpp/utils.py +++ b/python/vyos/vpp/utils.py @@ -23,7 +23,7 @@ from pathlib import Path from struct import pack -mem_shift = {'K': 10, 'k': 10, 'M': 20, 'm': 20, 'G': 30, 'g': 30} +mem_shift = {'K': 10, 'KB': 10, 'M': 20, 'MB': 20, 'G': 30, 'GB': 30} def iftunnel_transform(iface: str) -> str: @@ -302,15 +302,19 @@ def get_hugepage_sizes() -> list[int]: def human_memory_to_bytes(value: str) -> int: """ - Convert a human-readable vpp memory format (K, M, G) to a byte value. + Convert a human-readable vpp memory format (K, M, G, xB) to a byte value. :param value: The string memory size in vpp human-readable format. :return: A int representing the value. """ + value = value.strip().upper() try: return int(value) except ValueError: - return int(value[:-1]) << mem_shift[value[-1]] + for unit in sorted(mem_shift.keys(), key=len, reverse=True): + if value.endswith(unit): + num = value[: -len(unit)] + return int(num) << mem_shift[unit] def bytes_to_human_memory(value: int, unit: str) -> str | None: @@ -321,6 +325,7 @@ def bytes_to_human_memory(value: int, unit: str) -> str | None: :param unit: The unit to convert to ('K', 'M', 'G'). :return: A string representing the value in the specified unit, or None if zero. """ + unit = unit.upper() val = value >> mem_shift[unit] return f'{val}{unit}' if val else None diff --git a/src/conf_mode/vpp.py b/src/conf_mode/vpp.py index 966158ab7..cd4b47647 100755 --- a/src/conf_mode/vpp.py +++ b/src/conf_mode/vpp.py @@ -53,6 +53,7 @@ from vyos.vpp.config_verify import ( verify_vpp_statseg_size, verify_vpp_interfaces_dpdk_num_queues, verify_routes_count, + verify_vpp_main_heap_size, ) from vyos.vpp.config_filter import iface_filter_eth from vyos.vpp.utils import EthtoolGDrvinfo @@ -191,7 +192,12 @@ def get_config(config=None): # add running config if effective_config: - config['effective'] = effective_config + default_values_effective = conf.get_config_defaults( + **effective_config.kwargs, recursive=True + ) + config['effective'] = config_dict_merge( + default_values_effective, effective_config + ) if 'settings' in config: if 'interface' in config['settings']: @@ -358,12 +364,12 @@ def verify(config): workers=workers, nat44_workers=config['settings']['nat44']['workers'] ) + verify_vpp_main_heap_size(config['settings']) + verify_vpp_statseg_size(config['settings']) + # Check if available memory is enough for current VPP config verify_vpp_memory(config) - if 'statseg' in config['settings']: - verify_vpp_statseg_size(config['settings']) - # ensure DPDK/XDP settings are properly configured for iface, iface_config in config['settings']['interface'].items(): # check if selected driver is supported, but only for new interfaces -- cgit v1.2.3 From 5176bb18f499f614e24a119fa360fcde9240cbac Mon Sep 17 00:00:00 2001 From: l0crian1 Date: Sun, 24 Aug 2025 18:27:15 -0400 Subject: T7741: Fixes for 'show interfaces kernel' - Added dict_set_nested helper to set a value in a nested dictionary - Modified get_interface_vrf to accept string or dict as argument - Simplified logic using new helper functions --- python/vyos/utils/dict.py | 34 +++++++++++++++++++++++++++++++++- python/vyos/utils/network.py | 5 ++++- src/op_mode/interfaces.py | 35 +++++++++++++++++++++-------------- 3 files changed, 58 insertions(+), 16 deletions(-) (limited to 'python') diff --git a/python/vyos/utils/dict.py b/python/vyos/utils/dict.py index ff21f0677..a43430eed 100644 --- a/python/vyos/utils/dict.py +++ b/python/vyos/utils/dict.py @@ -211,6 +211,39 @@ def dict_set(key_path, value, dict_object): dynamic_dict = dynamic_dict[path_list[i]] dynamic_dict[path_list[len(path_list)-1]] = value +def dict_set_nested(key_path, value, dict_object): + """ + Set value to Python dictionary (dict_object) using a path to the key + delimited by dot ('.'). The key will be added if it does not exist. + Missing keys along the path will be created as nested dictionaries. + + Parameters + ---------- + key_path : str + Dot-delimited path to the key (e.g. "this.is.a.path"). + value : any + The value to set at the final key in the path. + dict_object : dict + Dictionary to modify. Will be updated in place. + + Examples + -------- + d = {} + dict_set_nested("this.is.a.path", 42, d) + # {'this': {'is': {'a': {'path': 42}}}} + + d = {"existing": {"branch": {}}} + dict_set_nested("existing.branch.leaf", "value", d) + # {'existing': {'branch': {'leaf': 'value'}}} + """ + path_list = key_path.split(".") + dynamic_dict = dict_object + for i in range(0, len(path_list) - 1): + if path_list[i] not in dynamic_dict or not isinstance(dynamic_dict[path_list[i]], dict): + dynamic_dict[path_list[i]] = {} + dynamic_dict = dynamic_dict[path_list[i]] + dynamic_dict[path_list[-1]] = value + def dict_delete(key_path, dict_object): """ Delete key in Python dictionary (dict_object) using path to key delimited by dot (.). """ @@ -370,4 +403,3 @@ class FixedDict(dict): if k not in self._allowed: raise ConfigError(f'Option "{k}" has no defined default') super().__setitem__(k, v) - diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 0e2cc58cf..93f780d8b 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -81,7 +81,10 @@ def get_interface_vrf(interface): """ Returns VRF of given interface """ from vyos.utils.dict import dict_search from vyos.utils.network import get_interface_config - tmp = get_interface_config(interface) + if isinstance(interface, str): + tmp = get_interface_config(interface) + elif isinstance(interface, dict): + tmp = interface if dict_search('linkinfo.info_slave_kind', tmp) == 'vrf': return tmp['master'] return 'default' diff --git a/src/op_mode/interfaces.py b/src/op_mode/interfaces.py index b6bbcbbc2..faeb846eb 100755 --- a/src/op_mode/interfaces.py +++ b/src/op_mode/interfaces.py @@ -29,8 +29,10 @@ import vyos.opmode from vyos.ifconfig import Section from vyos.ifconfig import Interface from vyos.ifconfig import VRRP -from vyos.utils.process import cmd +from vyos.utils.dict import dict_set_nested +from vyos.utils.network import get_interface_vrf from vyos.utils.network import interface_exists +from vyos.utils.process import cmd from vyos.utils.process import rc_cmd from vyos.utils.process import call from vyos.configquery import op_mode_config_dict @@ -334,11 +336,18 @@ def _format_kernel_data(data, detail): # Sort interfaces by name for interface in sorted(data, key=lambda x: x.get('ifname', '')): + interface_name = interface.get('ifname', '') + + # Skip VRF interfaces if interface.get('linkinfo', {}).get('info_kind') == 'vrf': continue - elif interface.get('ifname').startswith(('tunl', 'gre', 'erspan', 'pim6reg')): + # Skip spawned interfaces + elif interface_name.startswith(('tunl', 'gre', 'erspan', 'pim6reg')): continue + master = interface.get('master', 'default') + vrf = get_interface_vrf(interface) + # Get the device model; ex. Intel Corporation Ethernet Controller I225-V dev_model = interface.get('parentdev', '') if 'parentdev' in interface: @@ -349,7 +358,6 @@ def _format_kernel_data(data, detail): # Get the IP addresses on interface ip_list = [] has_global = False - vrf = 'default' for ip in interface['addr_info']: if ip.get('scope') in ('global', 'host'): @@ -358,25 +366,24 @@ def _format_kernel_data(data, detail): prefixlen = ip.get('prefixlen', '') ip_list.append(f"{local}/{prefixlen}") - if interface.get('ifname').startswith('pod-'): - podman_vrf[interface.get('ifname')] = {} - podman_vrf[interface.get('ifname')]['vrf'] = interface.get('master', 'default') - - if interface.get('master', '').startswith('pod-'): - vrf = podman_vrf.get(interface.get('master', '')).get('vrf', 'default') - elif interface.get('linkinfo', {}).get('info_slave_kind', '') == 'vrf': - vrf = interface.get('master', 'default') - # If no global IP address, add '-'; indicates no IP address on interface if not has_global: ip_list.append('-') + # Generate a mapping of podman interfaces to their VRF + if interface_name.startswith('pod-'): + dict_set_nested(f'{interface_name}.vrf', master, podman_vrf) + + # If the veth interface's master is a podman interface, the VRF is the VRF of the podman interface + if master.startswith('pod-'): + vrf = podman_vrf.get(master).get('vrf', 'default') + sl_status = ('A' if not 'UP' in interface['flags'] else 'u') + '/' + ('D' if interface['operstate'] == 'DOWN' else 'u') # Generate temporary dict to hold data - tmpInfo['ifname'] = interface.get('ifname', '') + tmpInfo['ifname'] = interface_name tmpInfo['ip'] = ip_list - tmpInfo['mac'] = interface.get('address', 'n/a') if is_interface_has_mac(interface.get('ifname', '')) else 'n/a' + tmpInfo['mac'] = interface.get('address', 'n/a') if is_interface_has_mac(interface_name) else 'n/a' tmpInfo['mtu'] = interface.get('mtu', '') tmpInfo['vrf'] = vrf tmpInfo['status'] = sl_status -- cgit v1.2.3 From 9f80e374f67282d5f6662acc8aec70eba0b040de Mon Sep 17 00:00:00 2001 From: John Estabrook Date: Wed, 27 Aug 2025 13:10:27 -0500 Subject: T7759: Revert "T7709: add atomic write for config file save" The utility write_file_atomic does not correctly update the file in the bind mount directory /config. Revert for investigation. This reverts commit c140f827f3117609908a3a18034027d3d78149ac. --- python/vyos/component_version.py | 10 ---------- python/vyos/utils/file.py | 36 ------------------------------------ src/helpers/vyos-save-config.py | 16 +++------------- 3 files changed, 3 insertions(+), 59 deletions(-) (limited to 'python') diff --git a/python/vyos/component_version.py b/python/vyos/component_version.py index 13fb8333f..136bd36e8 100644 --- a/python/vyos/component_version.py +++ b/python/vyos/component_version.py @@ -209,16 +209,6 @@ def version_info_prune_component(x: VersionInfo, y: VersionInfo) -> VersionInfo: x.component = {k: v for k, v in x.component.items() if k in y.component} -def add_system_version_string(config_str: str = None) -> str: - """Wrap config string with system version and return string.""" - version_info = version_info_from_system() - if config_str is not None: - version_info.update_config_body(config_str) - version_info.update_footer() - - return version_info.write_string() - - def add_system_version(config_str: str = None, out_file: str = None): """Wrap config string with system version and write to out_file. diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py index c363b5bdc..1e2de2b39 100644 --- a/python/vyos/utils/file.py +++ b/python/vyos/utils/file.py @@ -14,9 +14,6 @@ # License along with this library. If not, see . import os -import tempfile -from contextlib import contextmanager - from vyos.utils.permission import chown def makedir(path, user=None, group=None): @@ -188,36 +185,3 @@ def wait_for_file_write_complete(file_path, pre_hook=None, timeout=None, sleep_i """ Waits for a process to close a file after opening it in write mode. """ wait_for_inotify(file_path, event_type='IN_CLOSE_WRITE', pre_hook=pre_hook, timeout=timeout, sleep_interval=sleep_interval) - -def copy_chown(source, target): - import shutil - import stat - - shutil.copy2(source, target) - st = os.stat(source) - os.chown(target, st[stat.ST_UID], st[stat.ST_GID]) - - -@contextmanager -def write_file_atomic(file_path, mode='w'): - with open(file_path, mode) as _: - pass - temp_file = tempfile.NamedTemporaryFile( - delete=False, dir=os.path.dirname(file_path) - ) - if os.path.exists(file_path): - copy_chown(file_path, temp_file.name) - - file = open(temp_file.name, mode) - try: - yield file - finally: - file.flush() - os.fsync(file.fileno()) - file.close() - os.replace(temp_file.name, file_path) - if os.path.exists(temp_file.name): - try: - os.unlink(temp_file.name) - except Exception: - pass diff --git a/src/helpers/vyos-save-config.py b/src/helpers/vyos-save-config.py index fa1f5625d..adf62b71d 100755 --- a/src/helpers/vyos-save-config.py +++ b/src/helpers/vyos-save-config.py @@ -23,18 +23,15 @@ from argparse import ArgumentParser from vyos.config import Config from vyos.remote import urlc -from vyos.component_version import add_system_version_string +from vyos.component_version import add_system_version from vyos.defaults import directories -from vyos.utils.file import write_file_atomic DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') remote_save = None parser = ArgumentParser(description='Save configuration') parser.add_argument('file', type=str, nargs='?', help='Save configuration to file') -parser.add_argument( - '--write-json-file', type=str, help='Save JSON of configuration to file' -) +parser.add_argument('--write-json-file', type=str, help='Save JSON of configuration to file') args = parser.parse_args() file = args.file json_file = args.write_json_file @@ -59,14 +56,7 @@ write_file = save_file if remote_save is None else NamedTemporaryFile(delete=Fal # config_tree is None before boot configuration is complete; # automated saves should check boot_configuration_complete config_str = None if ct is None else ct.to_string() -versioned_config_str = add_system_version_string(config_str) - -try: - with write_file_atomic(write_file) as f: - f.write(versioned_config_str) -except OSError as e: - print(f'failed to write config file: {e}') - sys.exit(1) +add_system_version(config_str, write_file) if json_file is not None and ct is not None: try: -- cgit v1.2.3