diff options
Diffstat (limited to 'python')
| -rw-r--r-- | python/vyos/configsession.py | 34 | ||||
| -rw-r--r-- | python/vyos/configtree.py | 22 | ||||
| -rw-r--r-- | python/vyos/defaults.py | 4 | ||||
| -rw-r--r-- | python/vyos/derivedtree.py | 17 | ||||
| -rw-r--r-- | python/vyos/ethtool.py | 10 | ||||
| -rw-r--r-- | python/vyos/geoip.py | 6 | ||||
| -rw-r--r-- | python/vyos/ifconfig/interface.py | 19 | ||||
| -rw-r--r-- | python/vyos/ifconfig/wwan.py | 4 | ||||
| -rw-r--r-- | python/vyos/kea.py | 21 | ||||
| -rw-r--r-- | python/vyos/nat.py | 5 | ||||
| -rw-r--r-- | python/vyos/netlink/timestamp.py | 204 | ||||
| -rw-r--r-- | python/vyos/utils/commit.py | 57 | ||||
| -rw-r--r-- | python/vyos/utils/network.py | 57 | ||||
| -rw-r--r-- | python/vyos/utils/process.py | 50 |
14 files changed, 465 insertions, 45 deletions
diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py index 8fee8eca1..f2abd3a5b 100644 --- a/python/vyos/configsession.py +++ b/python/vyos/configsession.py @@ -16,6 +16,7 @@ import os import re import sys +import json import weakref import subprocess from tempfile import NamedTemporaryFile @@ -31,6 +32,9 @@ 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 +from vyos.configtree import ConfigTreeError +from vyos.configtree import delete_dict_from_masks +from vyos.derivedtree import subtree_from_list_of_partial_paths # type of config file path or configtree ConfigObj: TypeAlias = Union[str, ConfigTree] @@ -302,15 +306,33 @@ class ConfigSession(object): except (ValueError, ConfigSessionError) as e: raise ConfigSessionError(e) - def load_section_tree(self, mask: dict, d: dict): + def load_section_tree( + self, config_tree: ConfigTree, mask_dict: dict, config_dict: dict + ): + if ( + not mask_dict + or 'inclusive' not in mask_dict + or 'exclusive' not in mask_dict + ): + raise ConfigSessionError( + "Missing mask data can damage the config: expected keys 'inclusive' and 'exclusive'" + ) try: - if mask: - for p in dict_to_paths(mask): + mask_in = ConfigTree(internal_string=mask_dict['inclusive']) + + mask_ex_list = json.loads(mask_dict['exclusive']) + mask_ex = subtree_from_list_of_partial_paths(config_tree, mask_ex_list) + + delete_dict = delete_dict_from_masks(config_tree, mask_in, mask_ex) + + if delete_dict: + for p in dict_to_paths(delete_dict): self.delete(p) - if d: - for p in dict_to_paths(d): + + if config_dict: + for p in dict_to_paths(config_dict): self.set(p) - except (ValueError, ConfigSessionError) as e: + except (ValueError, ConfigSessionError, ConfigTreeError) as e: raise ConfigSessionError(e) def comment(self, path, value=None): diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py index d92137c14..47e56bc46 100644 --- a/python/vyos/configtree.py +++ b/python/vyos/configtree.py @@ -667,6 +667,28 @@ def mask_exclusive(left, right, libpath=LIBPATH): return tree +def delete_tree_from_masks( + config_tree: ConfigTree, include_mask: ConfigTree, exclude_mask: ConfigTree +): + masked_inc = mask_inclusive(config_tree, include_mask) + # Here we want the reversed stand-alone exclusion/inclusion. + # This simplifies definition of delete paths as (delete) + # difference between the two trees of config data. + masked_upper_bound = mask_exclusive(config_tree, include_mask) + masked_lower_bound = mask_inclusive(config_tree, exclude_mask) + masked_exc = union(masked_upper_bound, masked_lower_bound) + + ret = DiffTree(masked_inc, masked_exc) + return ret.delete + + +def delete_dict_from_masks( + config_tree: ConfigTree, include_mask: ConfigTree, exclude_mask: ConfigTree +): + ret = delete_tree_from_masks(config_tree, include_mask, exclude_mask) + return json.loads(ret.to_json()) + + def subtree_from_partial( config_tree: ConfigTree, path: list[str], diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py index 78721c0d2..22aa1f62a 100644 --- a/python/vyos/defaults.py +++ b/python/vyos/defaults.py @@ -106,3 +106,7 @@ reference_tree_cache = '/usr/share/vyos/reftree.cache' activation_list = os.path.join(directories['config'], 'activation-list') activation_init = os.path.join(directories['data'], 'activation-init') activation_hint = os.path.join(directories['data'], '.activation_hint') + +config_sync_exclusion_list = os.path.join( + directories['data'], 'config-sync-exclude.json' +) diff --git a/python/vyos/derivedtree.py b/python/vyos/derivedtree.py index 0a57921b2..dc46b8eb3 100644 --- a/python/vyos/derivedtree.py +++ b/python/vyos/derivedtree.py @@ -25,7 +25,10 @@ class DerivedTreeError(Exception): def subtree_from_list_of_partial_paths( - ctree: ConfigTree, paths: list[list[str]], accumulator: ConfigTree = None + ctree: ConfigTree, + paths: list[list[str]], + accumulator: ConfigTree = None, + reference_tree: ReferenceTree = None, ): """Return the union of subtrees of the ConfigTree argument matching each of the 'partial' paths. A partial path is one that may or may not @@ -33,19 +36,23 @@ def subtree_from_list_of_partial_paths( values that apply. An existing subtree may be passed as the initial value of accumulator. + + For testing or use outside of the canonical environment, an instance of + the ReferenceTree may be passed from an alternative cache location. """ - if accumulator: + if reference_tree is None: + reference_tree = ReferenceTree() + + if accumulator is not None: if not isinstance(accumulator, ConfigTree): raise TypeError("Argument 'accumulator' must be an instance of ConfigTree") else: accumulator = ConfigTree('') - rtree = ReferenceTree() - errors = [] for path in paths: try: - accumulator = subtree_from_partial(ctree, path, rtree, accumulator) + accumulator = subtree_from_partial(ctree, path, reference_tree, accumulator) except ConfigTreeError as e: errors.append(str(e)) continue diff --git a/python/vyos/ethtool.py b/python/vyos/ethtool.py index 79db25324..34c884e0b 100644 --- a/python/vyos/ethtool.py +++ b/python/vyos/ethtool.py @@ -20,6 +20,7 @@ from json import loads from vyos.utils.network import interface_exists from vyos.utils.process import popen from vyos.netlink import coalesce +from vyos.netlink import timestamp # These drivers do not support using ethtool to change the speed, duplex, or # flow control settings @@ -68,6 +69,7 @@ class Ethtool: _flow_control = None _channels = '' _coalesce = None + _hw_timestamp_filters = None def __init__(self, ifname): # Get driver used for interface @@ -129,6 +131,10 @@ class Ethtool: with contextlib.suppress(coalesce.CoalesceError, coalesce.GeneralNetlinkError): self._coalesce = coalesce.get_coalesce(ifname) + # Get supported hardware timestamp receive filters + with contextlib.suppress(timestamp.TsInfoError, timestamp.GeneralNetlinkError): + self._hw_timestamp_filters = timestamp.get_hw_timestamp_filters(ifname) + def check_auto_negotiation_supported(self): """ Check if the NIC supports changing auto-negotiation """ return self._base_settings['supports-auto-negotiation'] @@ -255,3 +261,7 @@ class Ethtool: """Get all 'coalesce' parameters for the interface""" return self._coalesce.copy() if self._coalesce else {} + + def get_hw_timestamp_filters(self): + """Get supported hardware timestamp receive filter names""" + return self._hw_timestamp_filters or set() diff --git a/python/vyos/geoip.py b/python/vyos/geoip.py index 619af0785..db2313ab9 100644 --- a/python/vyos/geoip.py +++ b/python/vyos/geoip.py @@ -217,6 +217,9 @@ def geoip_update(firewall=None, policy=None): if firewall: for codes, path in dict_search_recursive(firewall, 'country_code'): + if path[0] == 'policy': + continue + version = 6 if path[0] == 'ipv6' else 4 vprefix = '6' if version == 6 else '' set_name = f'GEOIP_CC{vprefix}_{path[1]}_{path[2]}_{path[4]}' @@ -224,6 +227,9 @@ def geoip_update(firewall=None, policy=None): if policy: for codes, path in dict_search_recursive(policy, 'country_code'): + if path[0] == 'firewall': + continue + version = 6 if path[0] == 'route6' else 4 vprefix = '6' if version == 6 else '' set_name = f'GEOIP_CC{vprefix}_{path[0]}_{path[1]}_{path[3]}' diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py index 136adb289..9cb76ee05 100644 --- a/python/vyos/ifconfig/interface.py +++ b/python/vyos/ifconfig/interface.py @@ -48,6 +48,7 @@ from vyos.utils.network import get_interface_namespace from vyos.utils.network import get_vrf_tableid from vyos.utils.network import is_netns_interface from vyos.utils.process import is_systemd_service_active +from vyos.utils.process import stop_systemd_unit from vyos.utils.process import run from vyos.utils.file import read_file from vyos.utils.file import write_file @@ -388,8 +389,8 @@ class Interface(Control): >>> i.remove() """ # Stop WPA supplicant if EAPoL was in use - if is_systemd_service_active(f'wpa_supplicant-wired@{self.ifname}'): - self._cmd(f'systemctl stop wpa_supplicant-wired@{self.ifname}') + netns = self.config['netns'] if 'netns' in self.config else None + stop_systemd_unit(f'wpa_supplicant-wired@{self.ifname}', netns=netns) # remove all assigned IP addresses from interface - this is a bit redundant # as the kernel will remove all addresses on interface deletion, but we @@ -1548,17 +1549,18 @@ class Interface(Control): # Reload systemd unit definitions as some options are dynamically generated self._cmd('systemctl daemon-reload') + netns = self.config['netns'] if 'netns' in self.config else None # When the DHCP client is restarted a brief outage will occur, as # 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 (vrf_changed or ('dhcp_options_changed' in self.config) or - (not is_systemd_service_active(systemd_service))): + (not is_systemd_service_active(systemd_service, netns=netns))): return self._cmd(f'systemctl restart {systemd_service}') else: - if is_systemd_service_active(systemd_service): - self._cmd(f'systemctl stop {systemd_service}') + netns = self.config['netns'] if 'netns' in self.config else None + stop_systemd_unit(systemd_service, netns=netns) # Smoketests occasionally fail if the lease is not removed from the Kernel fast enough: # AssertionError: 2 unexpectedly found in {17: [{'addr': '52:54:00:00:00:00', @@ -1607,15 +1609,16 @@ class Interface(Control): # Reload systemd unit definitions as some options are dynamically generated self._cmd('systemctl daemon-reload') + netns = self.config['netns'] if 'netns' in self.config else None # We must ignore any return codes. This is required to enable # DHCPv6-PD for interfaces which are yet not up and running. if (vrf_changed or ('dhcpv6_options_changed' in self.config) or - (not is_systemd_service_active(systemd_service))): + (not is_systemd_service_active(systemd_service, netns=netns))): return self._popen(f'systemctl restart {systemd_service}') else: - if is_systemd_service_active(systemd_service): - self._cmd(f'systemctl stop {systemd_service}') + netns = self.config['netns'] if 'netns' in self.config else None + stop_systemd_unit(systemd_service, netns=netns) if os.path.isfile(config_file): os.remove(config_file) if os.path.isfile(script_file): diff --git a/python/vyos/ifconfig/wwan.py b/python/vyos/ifconfig/wwan.py index 2b5714b85..ffd3d0a67 100644 --- a/python/vyos/ifconfig/wwan.py +++ b/python/vyos/ifconfig/wwan.py @@ -26,6 +26,10 @@ class WWANIf(Interface): }, } + def _create(self): + # we can not create this interface as it is managed by the Kernel + pass + def remove(self): """ Remove interface from config. Removing the interface deconfigures all diff --git a/python/vyos/kea.py b/python/vyos/kea.py index 5fe070c54..436c134c8 100644 --- a/python/vyos/kea.py +++ b/python/vyos/kea.py @@ -89,6 +89,14 @@ def _find_list_of_dict_index(lst, key='ip', value=''): return idx +def _read_posix_timezone(tz_name): + try: + with open(f'/usr/share/zoneinfo/{tz_name}', 'rb') as f: + return f.read().split(b'\n')[-2].decode('utf-8').replace(',', '\\,') + except (FileNotFoundError, IOError, IndexError) as e: + raise ConfigError(f'Failed to read timezone data for: {tz_name}') from e + + def kea_test_config(process: str, config_path: str) -> tuple[bool, str]: result, output = rc_cmd(f'{process} -t {config_path}') @@ -143,9 +151,7 @@ def kea_parse_options(config): ) if 'time_zone' in config: - with open('/usr/share/zoneinfo/' + config['time_zone'], 'rb') as f: - tz_string = f.read().split(b'\n')[-2].decode('utf-8') - + tz_string = _read_posix_timezone(config['time_zone']) options.append({'name': 'pcode', 'data': tz_string}) options.append({'name': 'tcode', 'data': config['time_zone']}) @@ -289,6 +295,11 @@ def kea6_parse_options(config): if hosts: options.append({'name': 'sip-server-dns', 'data': ', '.join(hosts)}) + if 'time_zone' in config: + tz_string = _read_posix_timezone(config['time_zone']) + options.append({'name': 'new-posix-timezone', 'data': tz_string}) + options.append({'name': 'new-tzdb-timezone', 'data': config['time_zone']}) + cisco_tftp = dict_search_args(config, 'vendor_option', 'cisco', 'tftp-server') if cisco_tftp: options.append( @@ -374,10 +385,10 @@ def kea6_parse_subnet(subnet, config): reservation['duid'] = host_config['duid'] if 'ipv6_address' in host_config: - reservation['ip-addresses'] = [host_config['ipv6_address']] + reservation['ip-addresses'] = host_config['ipv6_address'] if 'ipv6_prefix' in host_config: - reservation['prefixes'] = [host_config['ipv6_prefix']] + reservation['prefixes'] = host_config['ipv6_prefix'] if 'option' in host_config: reservation['option-data'] = kea6_parse_options(host_config['option']) diff --git a/python/vyos/nat.py b/python/vyos/nat.py index 7be957a0c..0a252bbd8 100644 --- a/python/vyos/nat.py +++ b/python/vyos/nat.py @@ -37,7 +37,7 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): operator = '!=' iiface = iiface[1:] output.append(f'iifname {operator} {{{iiface}}}') - else: + elif 'group' in rule_conf['inbound_interface']: iiface = rule_conf['inbound_interface']['group'] if iiface[0] == '!': operator = '!=' @@ -52,7 +52,7 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): operator = '!=' oiface = oiface[1:] output.append(f'oifname {operator} {{{oiface}}}') - else: + elif 'group' in rule_conf['outbound_interface']: oiface = rule_conf['outbound_interface']['group'] if oiface[0] == '!': operator = '!=' @@ -80,7 +80,6 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False): if redirect_port: translation_output.append(f'to {redirect_port}') else: - translation_prefix = nat_type[:1] translation_output = [f'{translation_prefix}nat'] diff --git a/python/vyos/netlink/timestamp.py b/python/vyos/netlink/timestamp.py new file mode 100644 index 000000000..d9f47678f --- /dev/null +++ b/python/vyos/netlink/timestamp.py @@ -0,0 +1,204 @@ +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 <http://www.gnu.org/licenses/>. + +from pyroute2.netlink import genlmsg +from pyroute2.netlink import nla +from pyroute2.netlink import NLM_F_REQUEST +from pyroute2.netlink.exceptions import NetlinkError +from pyroute2.netlink.generic import GenericNetlinkSocket +from pyroute2.netlink.generic.ethtool import ETHTOOL_GENL_NAME +from pyroute2.netlink.generic.ethtool import ETHTOOL_GENL_VERSION +from pyroute2.netlink.generic.ethtool import ethtoolheader + +# Netlink message type for tsinfo (from Linux kernel uapi/linux/ethtool_netlink.h) +ETHTOOL_MSG_TSINFO_GET = 0x19 + +# Operation not supported error code from the kernel (95 decimal) +EOPNOTSUPP = 0x5F + +# HWTSTAMP_FILTER_* types from <linux/net_tstamp.h> +# Each enum value N maps to bit (1 << N) in the rx_filters bitmask. +# 'ptp' is a combined mask of all PTP-related filter variants. +HWTSTAMP_FILTER = { + 'all': 1 << 1, # HWTSTAMP_FILTER_ALL + 'ntp': 1 << 15, # HWTSTAMP_FILTER_NTP_ALL + 'ptp': (1 << 3) # HWTSTAMP_FILTER_PTP_V1_L4_EVENT + | (1 << 4) # HWTSTAMP_FILTER_PTP_V1_L4_SYNC + | (1 << 5) # HWTSTAMP_FILTER_PTP_V1_L4_DELAY_REQ + | (1 << 6) # HWTSTAMP_FILTER_PTP_V2_L4_EVENT + | (1 << 7) # HWTSTAMP_FILTER_PTP_V2_L4_SYNC + | (1 << 8) # HWTSTAMP_FILTER_PTP_V2_L4_DELAY_REQ + | (1 << 9) # HWTSTAMP_FILTER_PTP_V2_L2_EVENT + | (1 << 10) # HWTSTAMP_FILTER_PTP_V2_L2_SYNC + | (1 << 11) # HWTSTAMP_FILTER_PTP_V2_L2_DELAY_REQ + | (1 << 12) # HWTSTAMP_FILTER_PTP_V2_EVENT + | (1 << 13) # HWTSTAMP_FILTER_PTP_V2_SYNC + | (1 << 14), # HWTSTAMP_FILTER_PTP_V2_DELAY_REQ +} + + +class ethtool_bitset_bit(nla): + """Single bit entry inside a verbose ethtool bitset.""" + + nla_map = ( + ('ETHTOOL_A_BITSET_BIT_UNSPEC', 'none'), + ('ETHTOOL_A_BITSET_BIT_INDEX', 'uint32'), + ('ETHTOOL_A_BITSET_BIT_NAME', 'asciiz'), + ('ETHTOOL_A_BITSET_BIT_VALUE', 'flag'), + ) + + +class ethtool_bitset_bits(nla): + """Container layer for nesting bit entries in a verbose ethtool bitset.""" + + ethtool_bitset_bit = ethtool_bitset_bit + nla_map = ( + ('ETHTOOL_A_BITSET_BIT_UNSPEC', 'none'), + ('ETHTOOL_A_BITSET_BIT', 'ethtool_bitset_bit'), + ) + + +class ethtool_bitset(nla): + """Ethtool verbose bitset NLA structure. + + The kernel returns the verbose form by default (ETHTOOL_A_BITSET_BITS nested entries). + ETHTOOL_A_BITSET_VALUE and ETHTOOL_A_BITSET_MASK are listed for positional completeness + only — they are not read by this implementation. + """ + + ethtool_bitset_bits = ethtool_bitset_bits + nla_map = ( + ('ETHTOOL_A_BITSET_UNSPEC', 'none'), + ('ETHTOOL_A_BITSET_NOMASK', 'flag'), + ('ETHTOOL_A_BITSET_SIZE', 'uint32'), + ('ETHTOOL_A_BITSET_BITS', 'ethtool_bitset_bits'), + ('ETHTOOL_A_BITSET_VALUE', 'binary'), + ('ETHTOOL_A_BITSET_MASK', 'binary'), + ) + + +class ethtool_tsinfo_msg(genlmsg): + """Netlink message structure for ETHTOOL_MSG_TSINFO_GET. + + nla_map indices are strictly positional — all attributes up to the last needed index must be + listed. Intermediate unused attributes are typed 'hex' to skip full NLA decoding. + Reference: https://docs.kernel.org/networking/ethtool-netlink.html#tsinfo-get + """ + + ethtoolheader = ethtoolheader + ethtool_bitset = ethtool_bitset + nla_map = ( + ('ETHTOOL_A_TSINFO_UNSPEC', 'none'), + ('ETHTOOL_A_TSINFO_HEADER', 'ethtoolheader'), + ('ETHTOOL_A_TSINFO_TIMESTAMPING', 'hex'), + ('ETHTOOL_A_TSINFO_TX_TYPES', 'hex'), + ('ETHTOOL_A_TSINFO_RX_FILTERS', 'ethtool_bitset'), + ) + + +GeneralNetlinkError = NetlinkError + + +class TsInfoError(Exception): + """Custom exception for timestamp info retrieval errors.""" + + pass + + +def _bitset_to_int(attr) -> int: + """Reconstruct an integer bitmask from a verbose ethtool_bitset NLA.""" + if attr is None: + return 0 + bits_attr = attr.get_attr('ETHTOOL_A_BITSET_BITS') + if bits_attr is None: + return 0 + bitmask = 0 + for bit in bits_attr.get_attrs('ETHTOOL_A_BITSET_BIT'): + index = bit.get_attr('ETHTOOL_A_BITSET_BIT_INDEX') + if index is not None: + bitmask |= 1 << index + return bitmask + + +class TsInfoNetlink(GenericNetlinkSocket): + """Hardware timestamp info queries using pyroute2 with netlink support.""" + + def __init__(self, ifname: str): + super().__init__() + self._bound = False + self._ifname = ifname + + def _ensure_bound(self): + """Bind netlink socket to the generic ethtool family structure if not already active.""" + if not self._bound: + self.bind(ETHTOOL_GENL_NAME, ethtool_tsinfo_msg) + self._bound = True + + def get_rx_filters(self) -> set: + """ + Query supported hardware timestamp receive filters for the interface. + + Returns: + A set of supported filter names ('all', 'ntp', 'ptp'), or an empty set + if the driver or interface lacks hardware timestamping support. + + Example: + >>> with TsInfoNetlink('eth0') as ts: + ... print(ts.get_rx_filters()) + {'ptp'} + """ + self._ensure_bound() + + msg = ethtool_tsinfo_msg() + msg['cmd'] = ETHTOOL_MSG_TSINFO_GET + msg['version'] = ETHTOOL_GENL_VERSION + msg['attrs'].append( + ( + 'ETHTOOL_A_TSINFO_HEADER', + {'attrs': [['ETHTOOL_A_HEADER_DEV_NAME', self._ifname]]}, + ) + ) + + try: + response = self.nlm_request( + msg, msg_type=self.prid, msg_flags=NLM_F_REQUEST + ) + except NetlinkError as e: + if e.code == EOPNOTSUPP: + return set() + raise + + if not response: + return set() + + bitmask = _bitset_to_int(response[0].get_attr('ETHTOOL_A_TSINFO_RX_FILTERS')) + return {name for name, mask in HWTSTAMP_FILTER.items() if bitmask & mask} + + +def get_hw_timestamp_filters(ifname: str) -> set: + """ + Get supported hardware timestamp receive filter names for an interface. + + Args: + ifname: Target network interface string (e.g., 'eth0'). + + Returns: + A set of supported human-readable filters, or an empty set on error/lack of support. + """ + try: + with TsInfoNetlink(ifname) as ts: + return ts.get_rx_filters() + except (NetlinkError, OSError): + return set() diff --git a/python/vyos/utils/commit.py b/python/vyos/utils/commit.py index 4147c7fba..1bbb236e4 100644 --- a/python/vyos/utils/commit.py +++ b/python/vyos/utils/commit.py @@ -18,6 +18,63 @@ from typing import IO +def _commit_lock_busy(lock_path: str) -> bool: + """Return True if another process holds a POSIX advisory lock on lock_path. + + Uses libc lockf(F_TEST): never acquires or releases a lock (no observer window + where this code holds LOCK_EX). Compatible with locks taken via fcntl.lockf / + fcntl F_SETLK on Linux. + """ + import ctypes + import errno + import os + + libc = ctypes.CDLL('libc.so.6', use_errno=True) + lockf_fn = libc.lockf + lockf_fn.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_long] + lockf_fn.restype = ctypes.c_int + + # Defined in glibc <unistd.h> / <fcntl.h> as F_TEST. + _F_TEST = 3 + + try: + fd = os.open(lock_path, os.O_RDONLY) + except FileNotFoundError: + # Lost a race with unlink or commit teardown. + return False + try: + os.lseek(fd, 0, os.SEEK_SET) + ctypes.set_errno(0) + ret = lockf_fn(fd, _F_TEST, 0) + err = ctypes.get_errno() + if ret == 0: + return False + if err in (errno.EACCES, errno.EAGAIN): + return True + raise OSError(err, os.strerror(err), lock_path) + finally: + os.close(fd) + +def commit_in_progress2(): + """ + Modern implementation of commit_in_progress() which is O(1) instead of O(n) + + The reason not everything is moved to this new implementation yet is to + give it heavy testing in vyos-netlinkd first. + """ + # Query advisory locks without acquiring them (see _commit_lock_busy). + # Requires read access to the lock file. + # If there is no read access otherwise os.open raises PermissionError. + + from pathlib import Path + from vyos.defaults import commit_lock + + lock_path = Path(commit_lock) + if not lock_path.exists(): + return False + + return _commit_lock_busy(str(lock_path)) + def commit_in_progress(): """Not to be used in normal op mode scripts!""" diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py index 366433146..8544b2163 100644 --- a/python/vyos/utils/network.py +++ b/python/vyos/utils/network.py @@ -238,7 +238,11 @@ def is_wwan_connected(interface): modem = interface.lstrip('wwan') - tmp = cmd(f'mmcli --modem {modem} --output-json') + try: + tmp = cmd(f'mmcli --modem {modem} --output-json') + except OSError: + return False + tmp = loads(tmp) # return True/False if interface is in connected state @@ -301,7 +305,9 @@ def mac2eui64(mac, prefix=None): except: # pylint: disable=bare-except return -def check_port_availability(address: str=None, port: int=0, protocol: str='tcp') -> bool: + +def check_port_availability(address: str = None, port: int = 0, + protocol: str = 'tcp', vrf: str = None) -> bool: """ Check if given port is available and not used by any service. @@ -311,7 +317,9 @@ def check_port_availability(address: str=None, port: int=0, protocol: str='tcp') Args: address: IPv4 or IPv6 address - if None, checks on all interfaces port: TCP/UDP port number. - + vrf: VRF name to test the bind in - when set, the socket is bound + to the VRF master device via SO_BINDTODEVICE so that the + port check runs in the correct L3 domain. Returns: False if a port is busy or IP address does not exists @@ -344,39 +352,68 @@ def check_port_availability(address: str=None, port: int=0, protocol: str='tcp') try: with socket.socket(family, socktype, proto) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # When a VRF is specified, bind the socket to the VRF master + # device so the address is resolved in that VRF's L3 domain. + if vrf: + s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, + (vrf + '\0').encode()) s.bind(sockaddr) - # port is free to use - return True + return True # port is free to use except OSError: - # port is already in use - return False + return False # port is already in use # 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: +def is_listen_port_bind_service(port: int, service: str, address: str = None) -> bool: """Check if listen port bound to expected program name :param port: Bind port :param service: Program name + :param address: IP address - if None, not consider this IP for filtering :return: bool Example: % is_listen_port_bind_service(443, 'nginx') True + % is_listen_port_bind_service(443, 'nginx', address='10.10.0.1') + False % is_listen_port_bind_service(443, 'ocserv-main') False """ from psutil import net_connections as connections from psutil import Process as process + from ipaddress import ip_address + + has_address = bool(address) + + if has_address: + try: + # Normalize address before comparison to handle IPv6 format variations: + # 0:0:0:0:0:0:0:1 vs ::1 + address = ip_address(address).compressed + except ValueError: + raise ValueError(f'{address} is not a valid IPv4 or IPv6 address') + for connection in connections(): addr = connection.laddr pid = connection.pid + + # The PID of the process that opened the socket may not be retrievable + if pid is None: + continue + pid_name = process(pid).name() pid_port = addr.port - if service == pid_name and port == pid_port: - return True + + if has_address: + if address == addr.ip and port == pid_port and service == pid_name: + return True + else: + if service == pid_name and port == pid_port: + return True + return False def is_ipv6_link_local(addr): diff --git a/python/vyos/utils/process.py b/python/vyos/utils/process.py index 94d6670dc..e78c0b969 100644 --- a/python/vyos/utils/process.py +++ b/python/vyos/utils/process.py @@ -15,13 +15,13 @@ import os import shlex +import time from subprocess import Popen from subprocess import PIPE from subprocess import STDOUT from subprocess import DEVNULL - def get_wrapper(vrf, netns): wrapper = None if vrf: @@ -90,9 +90,8 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, wrapper = get_wrapper(vrf, netns) if vrf or netns: if os.getuid() != 0: - raise OSError( - 'Permission denied: cannot execute commands in VRF and netns contexts as an unprivileged user' - ) + raise OSError('Permission denied: cannot execute commands in VRF ' \ + 'and netns contexts as an unprivileged user') if use_shell: command = f'{shlex.join(wrapper)} {command}' @@ -109,7 +108,7 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None, input = input.encode() if type(input) is str else input text = None - bufsize = -1 # default: means the system default of io.DEFAULT_BUFFER_SIZE will be used + bufsize = -1 # default: system default of io.DEFAULT_BUFFER_SIZE is used if not buffered: text = True # Treat output as strings (not bytes) bufsize = 1 # Enable line buffering @@ -279,7 +278,6 @@ def process_named_running(name: str, cmdline: str=None, timeout: int=0): return p.info['pid'] return None if timeout: - import time time_expire = time.time() + timeout while True: tmp = check_process(name, cmdline) @@ -293,13 +291,49 @@ def process_named_running(name: str, cmdline: str=None, timeout: int=0): return check_process(name, cmdline) return None -def is_systemd_service_active(service): +def is_systemd_service_active(service: str, vrf=None, netns=None) -> bool: """ Test is a specified systemd service is activated. Returns True if service is active, false otherwise. Copied from: https://unix.stackexchange.com/a/435317 """ - tmp = cmd(f'systemctl show --value -p ActiveState {service}') + tmp = cmd(f'systemctl show --value -p ActiveState {service}', + vrf=vrf, netns=netns) return bool((tmp == 'active')) +def stop_systemd_unit(service: str, retries: int=3, delay_s: float=0.250, + raise_on_failure: bool=True, vrf=None, netns=None) -> None: + """ + Stop systemd unit used during interface teardown (e.g. DHCP clients). + + Retries transient "systemctl stop| failures and verifies ActiveState is no + longer "active". Escalate to systemctl kill if stop attempts fail while + unit remains active. + """ + + if not is_systemd_service_active(service, vrf=vrf, netns=netns): + return None + + for _ in range(retries): + rc_cmd(f'systemctl stop {service}', vrf=vrf, netns=netns) + if not is_systemd_service_active(service, vrf=vrf, netns=netns): + # Service properly stopped - return early, this should be the default + return None + time.sleep(delay_s) + + rc_cmd(f'systemctl kill {service}', vrf=vrf, netns=netns) + time.sleep(delay_s) + if not is_systemd_service_active(service, vrf=vrf, netns=netns): + return None + + # This should not happen + if raise_on_failure: + code, out = rc_cmd(f'systemctl show --value -p ActiveState {service}', + vrf=vrf, netns=netns) + disp_state = out.strip() if code == 0 and out.strip() else 'unknown' + + raise RuntimeError(f'systemd unit {service} still has ActiveState={disp_state} ' \ + f'after {retries} stop attempts and systemctl kill') + return None + def is_systemd_service_running(service): """ Test is a specified systemd service is actually running. Returns True if service is running, false otherwise. |
