summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
Diffstat (limited to 'python')
-rw-r--r--python/vyos/configsession.py34
-rw-r--r--python/vyos/configtree.py22
-rw-r--r--python/vyos/defaults.py4
-rw-r--r--python/vyos/derivedtree.py17
-rw-r--r--python/vyos/ethtool.py10
-rw-r--r--python/vyos/geoip.py6
-rw-r--r--python/vyos/ifconfig/interface.py19
-rw-r--r--python/vyos/ifconfig/wwan.py4
-rw-r--r--python/vyos/kea.py21
-rw-r--r--python/vyos/nat.py5
-rw-r--r--python/vyos/netlink/timestamp.py204
-rw-r--r--python/vyos/utils/commit.py57
-rw-r--r--python/vyos/utils/network.py57
-rw-r--r--python/vyos/utils/process.py50
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.