summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
Diffstat (limited to 'python')
-rw-r--r--python/setup.py49
-rw-r--r--python/vyos/base.py21
-rw-r--r--python/vyos/component_version.py63
-rw-r--r--python/vyos/config.py12
-rw-r--r--python/vyos/config_mgmt.py359
-rw-r--r--python/vyos/configdep.py9
-rw-r--r--python/vyos/configdict.py37
-rw-r--r--python/vyos/configquery.py9
-rw-r--r--python/vyos/configsession.py155
-rw-r--r--python/vyos/configsource.py10
-rw-r--r--python/vyos/configtree.py257
-rw-r--r--python/vyos/configverify.py24
-rw-r--r--python/vyos/defaults.py20
-rw-r--r--python/vyos/ethtool.py93
-rwxr-xr-xpython/vyos/firewall.py146
-rw-r--r--python/vyos/frr.py551
-rw-r--r--python/vyos/frrender.py756
-rw-r--r--python/vyos/ifconfig/bond.py7
-rw-r--r--python/vyos/ifconfig/bridge.py18
-rw-r--r--python/vyos/ifconfig/control.py4
-rw-r--r--python/vyos/ifconfig/dummy.py5
-rw-r--r--python/vyos/ifconfig/ethernet.py193
-rw-r--r--python/vyos/ifconfig/geneve.py3
-rw-r--r--python/vyos/ifconfig/input.py5
-rw-r--r--python/vyos/ifconfig/interface.py268
-rw-r--r--python/vyos/ifconfig/l2tpv3.py1
-rw-r--r--python/vyos/ifconfig/loopback.py6
-rw-r--r--python/vyos/ifconfig/macsec.py3
-rw-r--r--python/vyos/ifconfig/macvlan.py8
-rw-r--r--python/vyos/ifconfig/pppoe.py9
-rw-r--r--python/vyos/ifconfig/sstpc.py1
-rw-r--r--python/vyos/ifconfig/tunnel.py9
-rw-r--r--python/vyos/ifconfig/veth.py3
-rw-r--r--python/vyos/ifconfig/vrrp.py65
-rw-r--r--python/vyos/ifconfig/vti.py1
-rw-r--r--python/vyos/ifconfig/vtun.py1
-rw-r--r--python/vyos/ifconfig/vxlan.py4
-rw-r--r--python/vyos/ifconfig/wireguard.py291
-rw-r--r--python/vyos/ifconfig/wireless.py1
-rw-r--r--python/vyos/ifconfig/wwan.py1
-rw-r--r--python/vyos/include/__init__.py15
-rw-r--r--python/vyos/include/uapi/__init__.py15
-rw-r--r--python/vyos/include/uapi/linux/__init__.py15
-rw-r--r--python/vyos/include/uapi/linux/fib_rules.py20
-rw-r--r--python/vyos/include/uapi/linux/icmpv6.py18
-rw-r--r--python/vyos/include/uapi/linux/if_arp.py176
-rw-r--r--python/vyos/include/uapi/linux/lwtunnel.py38
-rw-r--r--python/vyos/include/uapi/linux/neighbour.py34
-rw-r--r--python/vyos/include/uapi/linux/rtnetlink.py63
-rw-r--r--python/vyos/kea.py379
-rw-r--r--python/vyos/nat.py7
-rw-r--r--python/vyos/opmode.py168
-rw-r--r--python/vyos/pki.py37
-rw-r--r--python/vyos/proto/__init__.py0
-rwxr-xr-xpython/vyos/proto/generate_dataclass.py178
-rw-r--r--python/vyos/proto/vyconf_client.py89
-rw-r--r--python/vyos/qos/base.py174
-rw-r--r--python/vyos/qos/cake.py47
-rw-r--r--python/vyos/qos/priority.py19
-rw-r--r--python/vyos/qos/roundrobin.py13
-rw-r--r--python/vyos/qos/trafficshaper.py116
-rw-r--r--python/vyos/remote.py1
-rw-r--r--python/vyos/system/grub_util.py5
-rwxr-xr-xpython/vyos/template.py162
-rw-r--r--python/vyos/utils/auth.py70
-rw-r--r--python/vyos/utils/config.py66
-rw-r--r--python/vyos/utils/convert.py26
-rw-r--r--python/vyos/utils/cpu.py15
-rw-r--r--python/vyos/utils/kernel.py4
-rw-r--r--python/vyos/utils/network.py96
-rw-r--r--python/vyos/utils/process.py58
-rw-r--r--python/vyos/utils/system.py2
-rw-r--r--python/vyos/vyconf_session.py123
-rw-r--r--python/vyos/wanloadbalance.py153
-rw-r--r--python/vyos/xml_ref/definition.py28
-rwxr-xr-xpython/vyos/xml_ref/generate_cache.py6
-rwxr-xr-xpython/vyos/xml_ref/generate_op_cache.py95
77 files changed, 4424 insertions, 1585 deletions
diff --git a/python/setup.py b/python/setup.py
index 2d614e724..571b956ee 100644
--- a/python/setup.py
+++ b/python/setup.py
@@ -1,5 +1,14 @@
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 desc_out(f):
+ return os.path.splitext(f)[0] + '.desc'
def packages(directory):
return [
@@ -8,6 +17,43 @@ 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}/',
+ 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)
+
setup(
name = "vyos",
version = "1.3.0",
@@ -29,4 +75,7 @@ setup(
"config-mgmt = vyos.config_mgmt:run",
],
},
+ cmdclass={
+ 'build_py': GenerateProto,
+ },
)
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 <maintainers@vyos.io>
+# Copyright 2018-2025 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
@@ -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/python/vyos/component_version.py b/python/vyos/component_version.py
index 94215531d..81d986658 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.
@@ -202,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())
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/config_mgmt.py b/python/vyos/config_mgmt.py
index d518737ca..dd8910afb 100644
--- a/python/vyos/config_mgmt.py
+++ b/python/vyos/config_mgmt.py
@@ -33,6 +33,8 @@ from urllib.parse import urlunsplit
from vyos.config import Config
from vyos.configtree import ConfigTree
from vyos.configtree import ConfigTreeError
+from vyos.configsession import ConfigSession
+from vyos.configsession import ConfigSessionError
from vyos.configtree import show_diff
from vyos.load_config import load
from vyos.load_config import LoadConfigError
@@ -49,8 +51,10 @@ config_json = '/run/vyatta/config/config.json'
# created by vyatta-cfg-postinst
commit_post_hook_dir = '/etc/commit/post-hooks.d'
-commit_hooks = {'commit_revision': '01vyos-commit-revision',
- 'commit_archive': '02vyos-commit-archive'}
+commit_hooks = {
+ 'commit_revision': '01vyos-commit-revision',
+ 'commit_archive': '02vyos-commit-archive',
+}
DEFAULT_TIME_MINUTES = 10
timer_name = 'commit-confirm'
@@ -72,6 +76,7 @@ formatter = logging.Formatter('%(funcName)s: %(levelname)s:%(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
+
def save_config(target, json_out=None):
if json_out is None:
cmd = f'{SAVE_CONFIG} {target}'
@@ -81,6 +86,7 @@ def save_config(target, json_out=None):
if rc != 0:
logger.critical(f'save config failed: {out}')
+
def unsaved_commits(allow_missing_config=False) -> bool:
if get_full_version_data()['boot_via'] == 'livecd':
return False
@@ -92,6 +98,7 @@ def unsaved_commits(allow_missing_config=False) -> bool:
os.unlink(tmp_save)
return ret
+
def get_file_revision(rev: int):
revision = os.path.join(archive_dir, f'config.boot.{rev}.gz')
try:
@@ -102,12 +109,15 @@ def get_file_revision(rev: int):
return ''
return r
+
def get_config_tree_revision(rev: int):
c = get_file_revision(rev)
return ConfigTree(c)
+
def is_node_revised(path: list = [], rev1: int = 1, rev2: int = 0) -> bool:
from vyos.configtree import DiffTree
+
left = get_config_tree_revision(rev1)
right = get_config_tree_revision(rev2)
diff_tree = DiffTree(left, right)
@@ -115,9 +125,11 @@ def is_node_revised(path: list = [], rev1: int = 1, rev2: int = 0) -> bool:
return True
return False
+
class ConfigMgmtError(Exception):
pass
+
class ConfigMgmt:
def __init__(self, session_env=None, config=None):
if session_env:
@@ -128,15 +140,20 @@ class ConfigMgmt:
if config is None:
config = Config()
- d = config.get_config_dict(['system', 'config-management'],
- key_mangling=('-', '_'),
- get_first_key=True)
+ d = config.get_config_dict(
+ ['system', 'config-management'],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ with_defaults=True,
+ )
self.max_revisions = int(d.get('commit_revisions', 0))
self.num_revisions = 0
self.locations = d.get('commit_archive', {}).get('location', [])
- self.source_address = d.get('commit_archive',
- {}).get('source_address', '')
+ self.source_address = d.get('commit_archive', {}).get('source_address', '')
+ self.reboot_unconfirmed = bool(d.get('commit_confirm') == 'reboot')
+ self.config_dict = d
+
if config.exists(['system', 'host-name']):
self.hostname = config.return_value(['system', 'host-name'])
if config.exists(['system', 'domain-name']):
@@ -156,51 +173,73 @@ class ConfigMgmt:
# a call to compare without args is edit_level aware
edit_level = os.getenv('VYATTA_EDIT_LEVEL', '')
- self.edit_path = [l for l in edit_level.split('/') if l]
+ self.edit_path = [l for l in edit_level.split('/') if l] # noqa: E741
self.active_config = config._running_config
self.working_config = config._session_config
# Console script functions
#
- def commit_confirm(self, minutes: int=DEFAULT_TIME_MINUTES,
- no_prompt: bool=False) -> Tuple[str,int]:
- """Commit with reboot to saved config in 'minutes' minutes if
+ def commit_confirm(
+ self, minutes: int = DEFAULT_TIME_MINUTES, no_prompt: bool = False
+ ) -> Tuple[str, int]:
+ """Commit with reload/reboot to saved config in 'minutes' minutes if
'confirm' call is not issued.
"""
if is_systemd_service_active(f'{timer_name}.timer'):
msg = 'Another confirm is pending'
return msg, 1
- if unsaved_commits():
+ if self.reboot_unconfirmed and unsaved_commits():
W = '\nYou should save previous commits before commit-confirm !\n'
else:
W = ''
- prompt_str = f'''
+ if self.reboot_unconfirmed:
+ prompt_str = f"""
commit-confirm will automatically reboot in {minutes} minutes unless changes
-are confirmed.\n
-Proceed ?'''
+are confirmed.
+Proceed ?"""
+ else:
+ prompt_str = f"""
+commit-confirm will automatically reload previous config in {minutes} minutes
+unless changes are confirmed.
+Proceed ?"""
+
prompt_str = W + prompt_str
if not no_prompt and not ask_yes_no(prompt_str, default=True):
msg = 'commit-confirm canceled'
return msg, 1
- action = 'sg vyattacfg "/usr/bin/config-mgmt revert"'
+ if self.reboot_unconfirmed:
+ action = 'sg vyattacfg "/usr/bin/config-mgmt revert"'
+ else:
+ action = 'sg vyattacfg "/usr/bin/config-mgmt revert_soft"'
+
cmd = f'sudo systemd-run --quiet --on-active={minutes}m --unit={timer_name} {action}'
rc, out = rc_cmd(cmd)
if rc != 0:
raise ConfigMgmtError(out)
# start notify
- cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}'
+ if self.reboot_unconfirmed:
+ cmd = (
+ f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py --reboot {minutes}'
+ )
+ else:
+ cmd = f'sudo -b /usr/libexec/vyos/commit-confirm-notify.py {minutes}'
+
os.system(cmd)
- msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot'
+ if self.reboot_unconfirmed:
+ msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot'
+ else:
+ msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reload'
+
return msg, 0
- def confirm(self) -> Tuple[str,int]:
- """Do not reboot to saved config following 'commit-confirm'.
+ def confirm(self) -> Tuple[str, int]:
+ """Do not reboot/reload to saved/completed config following 'commit-confirm'.
Update commit log and archive.
"""
if not is_systemd_service_active(f'{timer_name}.timer'):
@@ -224,12 +263,15 @@ Proceed ?'''
self._add_log_entry(**entry)
self._update_archive()
- msg = 'Reboot timer stopped'
+ if self.reboot_unconfirmed:
+ msg = 'Reboot timer stopped'
+ else:
+ msg = 'Reload timer stopped'
+
return msg, 0
- def revert(self) -> Tuple[str,int]:
- """Reboot to saved config, dropping commits from 'commit-confirm'.
- """
+ def revert(self) -> Tuple[str, int]:
+ """Reboot to saved config, dropping commits from 'commit-confirm'."""
_ = self._read_tmp_log_entry()
# archived config will be reverted on boot
@@ -239,13 +281,39 @@ Proceed ?'''
return '', 0
- def rollback(self, rev: int, no_prompt: bool=False) -> Tuple[str,int]:
- """Reboot to config revision 'rev'.
- """
+ def revert_soft(self) -> Tuple[str, int]:
+ """Reload last revision, dropping commits from 'commit-confirm'."""
+ _ = self._read_tmp_log_entry()
+
+ # 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)
+
+ message = '[commit-confirm] Reverting to previous config now'
+ os.system('wall -n ' + message)
+
+ mask = os.umask(0o002)
+ session = ConfigSession(os.getpid(), app='config-mgmt')
+
+ try:
+ session.load_explicit(revert_ct)
+ session.commit()
+ except ConfigSessionError as e:
+ raise ConfigMgmtError(e) from e
+ finally:
+ os.umask(mask)
+ del session
+
+ return '', 0
+
+ def rollback(self, rev: int, no_prompt: bool = False) -> Tuple[str, int]:
+ """Reboot to config revision 'rev'."""
msg = ''
if not self._check_revision_number(rev):
- msg = f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}'
+ msg = (
+ f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}'
+ )
return msg, 1
prompt_str = 'Proceed with reboot ?'
@@ -274,15 +342,16 @@ Proceed ?'''
return msg, 0
def rollback_soft(self, rev: int):
- """Rollback without reboot (rollback-soft)
- """
+ """Rollback without reboot (rollback-soft)"""
msg = ''
if not self._check_revision_number(rev):
- msg = f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}'
+ msg = (
+ f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}'
+ )
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.')
@@ -292,9 +361,13 @@ Proceed ?'''
return msg, 0
- def compare(self, saved: bool=False, commands: bool=False,
- rev1: Optional[int]=None,
- rev2: Optional[int]=None) -> Tuple[str,int]:
+ def compare(
+ self,
+ saved: bool = False,
+ commands: bool = False,
+ rev1: Optional[int] = None,
+ rev2: Optional[int] = None,
+ ) -> Tuple[str, int]:
"""General compare function for config file revisions:
revision n vs. revision m; working version vs. active version;
or working version vs. saved version.
@@ -309,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:
@@ -317,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 = ''
@@ -335,7 +408,7 @@ Proceed ?'''
return msg, 0
- def wrap_compare(self, options) -> Tuple[str,int]:
+ def wrap_compare(self, options) -> Tuple[str, int]:
"""Interface to vyatta-cfg-run: args collected as 'options' to parse
for compare.
"""
@@ -343,7 +416,7 @@ Proceed ?'''
r1 = None
r2 = None
if 'commands' in options:
- cmnds=True
+ cmnds = True
options.remove('commands')
for i in options:
if not i.isnumeric():
@@ -358,8 +431,7 @@ Proceed ?'''
# Initialization and post-commit hooks for conf-mode
#
def initialize_revision(self):
- """Initialize config archive, logrotate conf, and commit log.
- """
+ """Initialize config archive, logrotate conf, and commit log."""
mask = os.umask(0o002)
os.makedirs(archive_dir, exist_ok=True)
json_dir = os.path.dirname(config_json)
@@ -371,8 +443,7 @@ Proceed ?'''
self._add_logrotate_conf()
- if (not os.path.exists(commit_log_file) or
- self._get_number_of_revisions() == 0):
+ if not os.path.exists(commit_log_file) or self._get_number_of_revisions() == 0:
user = self._get_user()
via = 'init'
comment = ''
@@ -399,8 +470,7 @@ Proceed ?'''
self._update_archive()
def commit_archive(self):
- """Upload config to remote archive.
- """
+ """Upload config to remote archive."""
from vyos.remote import upload
hostname = self.hostname
@@ -410,20 +480,23 @@ Proceed ?'''
source_address = self.source_address
if self.effective_locations:
- print("Archiving config...")
+ print('Archiving config...')
for location in self.effective_locations:
url = urlsplit(location)
- _, _, netloc = url.netloc.rpartition("@")
+ _, _, netloc = url.netloc.rpartition('@')
redacted_location = urlunsplit(url._replace(netloc=netloc))
- print(f" {redacted_location}", end=" ", flush=True)
- upload(archive_config_file, f'{location}/{remote_file}',
- source_host=source_address)
+ print(f' {redacted_location}', end=' ', flush=True)
+ upload(
+ archive_config_file,
+ f'{location}/{remote_file}',
+ source_host=source_address,
+ )
# op-mode functions
#
def get_raw_log_data(self) -> list:
"""Return list of dicts of log data:
- keys: [timestamp, user, commit_via, commit_comment]
+ keys: [timestamp, user, commit_via, commit_comment]
"""
log = self._get_log_entries()
res_l = []
@@ -435,20 +508,20 @@ Proceed ?'''
@staticmethod
def format_log_data(data: list) -> str:
- """Return formatted log data as str.
- """
+ """Return formatted log data as str."""
res_l = []
- for l_no, l in enumerate(data):
- time_d = datetime.fromtimestamp(int(l['timestamp']))
- time_str = time_d.strftime("%Y-%m-%d %H:%M:%S")
+ for l_no, l_val in enumerate(data):
+ time_d = datetime.fromtimestamp(int(l_val['timestamp']))
+ time_str = time_d.strftime('%Y-%m-%d %H:%M:%S')
- res_l.append([l_no, time_str,
- f"by {l['user']}", f"via {l['commit_via']}"])
+ res_l.append(
+ [l_no, time_str, f"by {l_val['user']}", f"via {l_val['commit_via']}"]
+ )
- if l['commit_comment'] != 'commit': # default comment
- res_l.append([None, l['commit_comment']])
+ if l_val['commit_comment'] != 'commit': # default comment
+ res_l.append([None, l_val['commit_comment']])
- ret = tabulate(res_l, tablefmt="plain")
+ ret = tabulate(res_l, tablefmt='plain')
return ret
@staticmethod
@@ -459,23 +532,25 @@ Proceed ?'''
'rollback'.
"""
res_l = []
- for l_no, l in enumerate(data):
- time_d = datetime.fromtimestamp(int(l['timestamp']))
- time_str = time_d.strftime("%Y-%m-%d %H:%M:%S")
+ for l_no, l_val in enumerate(data):
+ time_d = datetime.fromtimestamp(int(l_val['timestamp']))
+ time_str = time_d.strftime('%Y-%m-%d %H:%M:%S')
- res_l.append(['\t', l_no, time_str,
- f"{l['user']}", f"by {l['commit_via']}"])
+ res_l.append(
+ ['\t', l_no, time_str, f"{l_val['user']}", f"by {l_val['commit_via']}"]
+ )
- ret = tabulate(res_l, tablefmt="plain")
+ ret = tabulate(res_l, tablefmt='plain')
return ret
- def show_commit_diff(self, rev: int, rev2: Optional[int]=None,
- commands: bool=False) -> str:
+ def show_commit_diff(
+ self, rev: int, rev2: Optional[int] = None, commands: bool = False
+ ) -> str:
"""Show commit diff at revision number, compared to previous
revision, or to another revision.
"""
if rev2 is None:
- out, _ = self.compare(commands=commands, rev1=rev, rev2=(rev+1))
+ out, _ = self.compare(commands=commands, rev1=rev, rev2=(rev + 1))
return out
out, _ = self.compare(commands=commands, rev1=rev, rev2=rev2)
@@ -500,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)
@@ -519,8 +594,9 @@ Proceed ?'''
conf_file.chmod(0o644)
def _archive_active_config(self) -> bool:
- save_to_tmp = (boot_configuration_complete() or not
- os.path.isfile(archive_config_file))
+ save_to_tmp = boot_configuration_complete() or not os.path.isfile(
+ archive_config_file
+ )
mask = os.umask(0o113)
ext = os.getpid()
@@ -560,15 +636,14 @@ Proceed ?'''
@staticmethod
def _update_archive():
- cmd = f"sudo logrotate -f -s {logrotate_state} {logrotate_conf}"
+ cmd = f'sudo logrotate -f -s {logrotate_state} {logrotate_conf}'
rc, out = rc_cmd(cmd)
if rc != 0:
logger.critical(f'logrotate failure: {out}')
@staticmethod
def _get_log_entries() -> list:
- """Return lines of commit log as list of strings
- """
+ """Return lines of commit log as list of strings"""
entries = []
if os.path.exists(commit_log_file):
with open(commit_log_file) as f:
@@ -577,8 +652,8 @@ Proceed ?'''
return entries
def _get_number_of_revisions(self) -> int:
- l = self._get_log_entries()
- return len(l)
+ log_entries = self._get_log_entries()
+ return len(log_entries)
def _check_revision_number(self, rev: int) -> bool:
self.num_revisions = self._get_number_of_revisions()
@@ -599,9 +674,14 @@ Proceed ?'''
user = 'unknown'
return user
- def _new_log_entry(self, user: str='', commit_via: str='',
- commit_comment: str='', timestamp: Optional[int]=None,
- tmp_file: str=None) -> Optional[str]:
+ def _new_log_entry(
+ self,
+ user: str = '',
+ commit_via: str = '',
+ commit_comment: str = '',
+ timestamp: Optional[int] = None,
+ tmp_file: str = None,
+ ) -> Optional[str]:
# Format log entry and return str or write to file.
#
# Usage is within a post-commit hook, using env values. In case of
@@ -647,12 +727,12 @@ Proceed ?'''
logger.critical(f'Invalid log format {line}')
return {}
- timestamp, user, commit_via, commit_comment = (
- tuple(line.strip().strip('|').split('|')))
+ timestamp, user, commit_via, commit_comment = tuple(
+ line.strip().strip('|').split('|')
+ )
commit_comment = commit_comment.replace('%%', '|')
- d = dict(zip(keys, [user, commit_via,
- commit_comment, timestamp]))
+ d = dict(zip(keys, [user, commit_via, commit_comment, timestamp]))
return d
@@ -662,17 +742,28 @@ Proceed ?'''
entry = f.read()
os.unlink(tmp_log_entry)
except OSError as e:
- logger.critical(f'error on file {tmp_log_entry}: {e}')
+ logger.info(f'error on file {tmp_log_entry}: {e}')
+ # fail gracefully in corner case:
+ # delete commit-revisions; commit-confirm
+ return {}
return self._get_log_entry(entry)
- def _add_log_entry(self, user: str='', commit_via: str='',
- commit_comment: str='', timestamp: Optional[int]=None):
+ def _add_log_entry(
+ self,
+ user: str = '',
+ commit_via: str = '',
+ commit_comment: str = '',
+ timestamp: Optional[int] = None,
+ ):
mask = os.umask(0o113)
- entry = self._new_log_entry(user=user, commit_via=commit_via,
- commit_comment=commit_comment,
- timestamp=timestamp)
+ entry = self._new_log_entry(
+ user=user,
+ commit_via=commit_via,
+ commit_comment=commit_comment,
+ timestamp=timestamp,
+ )
log_entries = self._get_log_entries()
log_entries.insert(0, entry)
@@ -687,6 +778,7 @@ Proceed ?'''
os.umask(mask)
+
# entry_point for console script
#
def run():
@@ -706,43 +798,54 @@ def run():
parser = ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand')
- commit_confirm = subparsers.add_parser('commit_confirm',
- help="Commit with opt-out reboot to saved config")
- commit_confirm.add_argument('-t', dest='minutes', type=int,
- default=DEFAULT_TIME_MINUTES,
- help="Minutes until reboot, unless 'confirm'")
- commit_confirm.add_argument('-y', dest='no_prompt', action='store_true',
- help="Execute without prompt")
-
- subparsers.add_parser('confirm', help="Confirm commit")
- subparsers.add_parser('revert', help="Revert commit-confirm")
-
- rollback = subparsers.add_parser('rollback',
- help="Rollback to earlier config")
- rollback.add_argument('--rev', type=int,
- help="Revision number for rollback")
- rollback.add_argument('-y', dest='no_prompt', action='store_true',
- help="Excute without prompt")
-
- rollback_soft = subparsers.add_parser('rollback_soft',
- help="Rollback to earlier config")
- rollback_soft.add_argument('--rev', type=int,
- help="Revision number for rollback")
-
- compare = subparsers.add_parser('compare',
- help="Compare config files")
-
- compare.add_argument('--saved', action='store_true',
- help="Compare session config with saved config")
- compare.add_argument('--commands', action='store_true',
- help="Show difference between commands")
- compare.add_argument('--rev1', type=int, default=None,
- help="Compare revision with session config or other revision")
- compare.add_argument('--rev2', type=int, default=None,
- help="Compare revisions")
-
- wrap_compare = subparsers.add_parser('wrap_compare',
- help="Wrapper interface for vyatta-cfg-run")
+ commit_confirm = subparsers.add_parser(
+ 'commit_confirm', help='Commit with opt-out reboot to saved config'
+ )
+ commit_confirm.add_argument(
+ '-t',
+ dest='minutes',
+ type=int,
+ default=DEFAULT_TIME_MINUTES,
+ help="Minutes until reboot, unless 'confirm'",
+ )
+ commit_confirm.add_argument(
+ '-y', dest='no_prompt', action='store_true', help='Execute without prompt'
+ )
+
+ subparsers.add_parser('confirm', help='Confirm commit')
+ subparsers.add_parser('revert', help='Revert commit-confirm with reboot')
+ subparsers.add_parser('revert_soft', help='Revert commit-confirm with reload')
+
+ rollback = subparsers.add_parser('rollback', help='Rollback to earlier config')
+ rollback.add_argument('--rev', type=int, help='Revision number for rollback')
+ rollback.add_argument(
+ '-y', dest='no_prompt', action='store_true', help='Excute without prompt'
+ )
+
+ rollback_soft = subparsers.add_parser(
+ 'rollback_soft', help='Rollback to earlier config'
+ )
+ rollback_soft.add_argument('--rev', type=int, help='Revision number for rollback')
+
+ compare = subparsers.add_parser('compare', help='Compare config files')
+
+ compare.add_argument(
+ '--saved', action='store_true', help='Compare session config with saved config'
+ )
+ compare.add_argument(
+ '--commands', action='store_true', help='Show difference between commands'
+ )
+ compare.add_argument(
+ '--rev1',
+ type=int,
+ default=None,
+ help='Compare revision with session config or other revision',
+ )
+ compare.add_argument('--rev2', type=int, default=None, help='Compare revisions')
+
+ wrap_compare = subparsers.add_parser(
+ 'wrap_compare', help='Wrapper interface for vyatta-cfg-run'
+ )
wrap_compare.add_argument('--options', nargs=REMAINDER)
args = vars(parser.parse_args())
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/python/vyos/configdict.py b/python/vyos/configdict.py
index 5a353b110..ff0a15933 100644
--- a/python/vyos/configdict.py
+++ b/python/vyos/configdict.py
@@ -491,10 +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' : {}})
-
- # Changine interface VRF assignemnts require a DHCP restart, too
- dhcp = is_node_changed(config, base + [ifname, 'vrf'])
- 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
@@ -519,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}'})
@@ -543,6 +549,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
@@ -569,6 +577,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
@@ -597,6 +607,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)
@@ -622,6 +634,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 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):
"""
Common utility function to retrieve and mangle the Accel-PPP configuration
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 <maintainers@vyos.io>
+# Copyright 2021-2025 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
@@ -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/configsession.py b/python/vyos/configsession.py
index 7d51b94e4..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'
@@ -32,15 +36,33 @@ SHOW_CONFIG = ['/bin/cli-shell-api', 'showConfig']
LOAD_CONFIG = ['/bin/cli-shell-api', 'loadFile']
MIGRATE_LOAD_CONFIG = ['/usr/libexec/vyos/vyos-load-config.py']
SAVE_CONFIG = ['/usr/libexec/vyos/vyos-save-config.py']
-INSTALL_IMAGE = ['/usr/libexec/vyos/op_mode/image_installer.py',
- '--action', 'add', '--no-prompt', '--image-path']
+INSTALL_IMAGE = [
+ '/usr/libexec/vyos/op_mode/image_installer.py',
+ '--action',
+ 'add',
+ '--no-prompt',
+ '--image-path',
+]
IMPORT_PKI = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'import']
-IMPORT_PKI_NO_PROMPT = ['/usr/libexec/vyos/op_mode/pki.py',
- '--action', 'import', '--no-prompt']
-REMOVE_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py',
- '--action', 'delete', '--no-prompt', '--image-name']
-SET_DEFAULT_IMAGE = ['/usr/libexec/vyos/op_mode/image_manager.py',
- '--action', 'set', '--no-prompt', '--image-name']
+IMPORT_PKI_NO_PROMPT = [
+ '/usr/libexec/vyos/op_mode/pki.py',
+ 'import_pki',
+ '--no-prompt',
+]
+REMOVE_IMAGE = [
+ '/usr/libexec/vyos/op_mode/image_manager.py',
+ '--action',
+ 'delete',
+ '--no-prompt',
+ '--image-name',
+]
+SET_DEFAULT_IMAGE = [
+ '/usr/libexec/vyos/op_mode/image_manager.py',
+ '--action',
+ 'set',
+ '--no-prompt',
+ '--image-name',
+]
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']
@@ -48,9 +70,20 @@ REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot']
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']
+TRACEROUTE = [
+ '/usr/libexec/vyos/op_mode/mtr_execute.py',
+ 'mtr',
+ '--for-api',
+ '--report-mode',
+ '--report-cycles',
+ '1',
+ '--json',
+ '--host',
+]
# Default "commit via" string
-APP = "vyos-http-api"
+APP = 'vyos-http-api'
+
# When started as a service rather than from a user shell,
# the process lacks the VyOS-specific environment that comes
@@ -61,7 +94,7 @@ def inject_vyos_env(env):
env['VYATTA_USER_LEVEL_DIR'] = '/opt/vyatta/etc/shell/level/admin'
env['VYATTA_PROCESS_CLIENT'] = 'gui2_rest'
env['VYOS_HEADLESS_CLIENT'] = 'vyos_http_api'
- env['vyatta_bindir']= '/opt/vyatta/bin'
+ env['vyatta_bindir'] = '/opt/vyatta/bin'
env['vyatta_cfg_templates'] = '/opt/vyatta/share/vyatta-cfg/templates'
env['vyatta_configdir'] = directories['vyos_configdir']
env['vyatta_datadir'] = '/opt/vyatta/share'
@@ -78,7 +111,7 @@ def inject_vyos_env(env):
env['vyos_configdir'] = directories['vyos_configdir']
env['vyos_conf_scripts_dir'] = '/usr/libexec/vyos/conf_mode'
env['vyos_datadir'] = '/opt/vyatta/share'
- env['vyos_datarootdir']= '/opt/vyatta/share'
+ env['vyos_datarootdir'] = '/opt/vyatta/share'
env['vyos_libdir'] = '/opt/vyatta/lib'
env['vyos_libexec_dir'] = '/usr/libexec/vyos'
env['vyos_op_scripts_dir'] = '/usr/libexec/vyos/op_mode'
@@ -102,6 +135,7 @@ class ConfigSession(object):
"""
The write API of VyOS.
"""
+
def __init__(self, session_id, app=APP):
"""
Creates a new config session.
@@ -116,7 +150,9 @@ class ConfigSession(object):
and used the PID for the session identifier.
"""
- env_str = subprocess.check_output([CLI_SHELL_API, 'getSessionEnv', str(session_id)])
+ env_str = subprocess.check_output(
+ [CLI_SHELL_API, 'getSessionEnv', str(session_id)]
+ )
self.__session_id = session_id
# Extract actual variables from the chunk of shell it outputs
@@ -129,20 +165,44 @@ class ConfigSession(object):
session_env[k] = v
self.__session_env = session_env
- self.__session_env["COMMIT_VIA"] = app
+ 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)
+ else:
+ self._vyconf_session = None
+
def __del__(self):
try:
- output = subprocess.check_output([CLI_SHELL_API, 'teardownSession'], env=self.__session_env).decode().strip()
+ output = (
+ subprocess.check_output(
+ [CLI_SHELL_API, 'teardownSession'], env=self.__session_env
+ )
+ .decode()
+ .strip()
+ )
if output:
- print("cli-shell-api teardownSession output for sesion {0}: {1}".format(self.__session_id, output), file=sys.stderr)
+ print(
+ 'cli-shell-api teardownSession output for sesion {0}: {1}'.format(
+ self.__session_id, output
+ ),
+ file=sys.stderr,
+ )
except Exception as e:
- print("Could not tear down session {0}: {1}".format(self.__session_id, e), file=sys.stderr)
+ print(
+ 'Could not tear down session {0}: {1}'.format(self.__session_id, e),
+ file=sys.stderr,
+ )
def __run_command(self, cmd_list):
- p = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=self.__session_env)
+ p = subprocess.Popen(
+ cmd_list,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ env=self.__session_env,
+ )
(stdout_data, stderr_data) = p.communicate()
output = stdout_data.decode()
result = p.wait()
@@ -158,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:
@@ -172,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:
@@ -204,34 +270,67 @@ class ConfigSession(object):
def comment(self, path, value=None):
if not value:
- value = [""]
+ value = ['']
else:
value = [value]
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):
+ from vyos.load_config import load
+ from vyos.load_config import LoadConfigError
+
+ try:
+ load(file_path, switch='explicit')
+ except LoadConfigError as e:
+ 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):
@@ -285,3 +384,7 @@ class ConfigSession(object):
def show_container_image(self):
out = self.__run_command(SHOW + ['container', 'image'])
return out
+
+ def traceroute(self, host):
+ out = self.__run_command(TRACEROUTE + [host])
+ return out
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)}")
diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py
index bd77ab899..ff40fbad0 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-2024 VyOS maintainers and contributors
+# Copyright (C) 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 License as published by the Free Software Foundation;
@@ -19,35 +19,44 @@ 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):
"""Modify quoted strings containing backslashes not of escape sequences"""
+
def replace_method(match):
result = match.group().replace(search, replace)
return result
+
p = re.compile(r'("[^"]*[\\][^"]*"\n|\'[^\']*[\\][^\']*\'\n)')
return p.sub(replace_method, s)
+
def escape_backslash(string: str) -> str:
"""Escape single backslashes in quoted strings"""
result = replace_backslash(string, '\\', '\\\\')
return result
+
def unescape_backslash(string: str) -> str:
"""Unescape backslashes in quoted strings"""
result = replace_backslash(string, '\\\\', '\\')
return result
+
def extract_version(s):
- """ Extract the version string from the config string """
+ """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):
# Necessary type checking
if not isinstance(path, list):
- raise TypeError("Expected a list, got a {}".format(type(path)))
+ raise TypeError('Expected a list, got a {}'.format(type(path)))
else:
pass
@@ -57,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)
@@ -80,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
@@ -88,6 +109,10 @@ class ConfigTree(object):
self.__to_json_ast.argtypes = [c_void_p]
self.__to_json_ast.restype = c_char_p
+ self.__create_node = self.__lib.create_node
+ self.__create_node.argtypes = [c_void_p, c_char_p]
+ self.__create_node.restype = c_int
+
self.__set_add_value = self.__lib.set_add_value
self.__set_add_value.argtypes = [c_void_p, c_char_p, c_char_p]
self.__set_add_value.restype = c_int
@@ -137,9 +162,17 @@ class ConfigTree(object):
self.__is_tag.restype = c_int
self.__set_tag = self.__lib.set_tag
- self.__set_tag.argtypes = [c_void_p, c_char_p]
+ self.__set_tag.argtypes = [c_void_p, c_char_p, c_bool]
self.__set_tag.restype = c_int
+ self.__is_leaf = self.__lib.is_leaf
+ self.__is_leaf.argtypes = [c_void_p, c_char_p]
+ self.__is_leaf.restype = c_bool
+
+ self.__set_leaf = self.__lib.set_leaf
+ self.__set_leaf.argtypes = [c_void_p, c_char_p, c_bool]
+ self.__set_leaf.restype = c_int
+
self.__get_subtree = self.__lib.get_subtree
self.__get_subtree.argtypes = [c_void_p, c_char_p]
self.__get_subtree.restype = c_void_p
@@ -147,19 +180,34 @@ 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())
if config is None:
msg = self.__get_error().decode()
- raise ValueError("Failed to parse config: {0}".format(msg))
+ raise ValueError('Failed to parse config: {0}'.format(msg))
else:
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:
@@ -169,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()
@@ -178,15 +231,18 @@ 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)
if no_version:
return config_string
- config_string = "{0}\n{1}".format(config_string, self.__version)
+ config_string = '{0}\n{1}'.format(config_string, self.__version)
return config_string
- def to_commands(self, op="set"):
+ def to_commands(self, op='set'):
commands = self.__to_commands(self.__config, op.encode()).decode()
commands = unescape_backslash(commands)
return commands
@@ -197,6 +253,14 @@ class ConfigTree(object):
def to_json_ast(self):
return self.__to_json_ast(self.__config).decode()
+ def create_node(self, path):
+ check_path(path)
+ path_str = ' '.join(map(str, path)).encode()
+
+ res = self.__create_node(self.__config, path_str)
+ if res != 0:
+ raise ConfigTreeError(f'Path already exists: {path}')
+
def set(self, path, value=None, replace=True):
"""Set new entry in VyOS configuration.
path: configuration path e.g. 'system dns forwarding listen-address'
@@ -207,7 +271,7 @@ class ConfigTree(object):
"""
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
if value is None:
self.__set_valueless(self.__config, path_str)
@@ -218,25 +282,27 @@ class ConfigTree(object):
self.__set_add_value(self.__config, path_str, str(value).encode())
if self.__migration:
- self.migration_log.info(f"- op: set path: {path} value: {value} replace: {replace}")
+ self.migration_log.info(
+ f'- op: set path: {path} value: {value} replace: {replace}'
+ )
def delete(self, path):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res = self.__delete(self.__config, path_str)
- if (res != 0):
+ if res != 0:
raise ConfigTreeError(f"Path doesn't exist: {path}")
if self.__migration:
- self.migration_log.info(f"- op: delete path: {path}")
+ self.migration_log.info(f'- op: delete path: {path}')
def delete_value(self, path, value):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res = self.__delete_value(self.__config, path_str, value.encode())
- if (res != 0):
+ if res != 0:
if res == 1:
raise ConfigTreeError(f"Path doesn't exist: {path}")
elif res == 2:
@@ -245,11 +311,11 @@ class ConfigTree(object):
raise ConfigTreeError()
if self.__migration:
- self.migration_log.info(f"- op: delete_value path: {path} value: {value}")
+ self.migration_log.info(f'- op: delete_value path: {path} value: {value}')
def rename(self, path, new_name):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
newname_str = new_name.encode()
# Check if a node with intended new name already exists
@@ -257,42 +323,46 @@ class ConfigTree(object):
if self.exists(new_path):
raise ConfigTreeError()
res = self.__rename(self.__config, path_str, newname_str)
- if (res != 0):
+ if res != 0:
raise ConfigTreeError("Path [{}] doesn't exist".format(path))
if self.__migration:
- self.migration_log.info(f"- op: rename old_path: {path} new_path: {new_path}")
+ self.migration_log.info(
+ f'- op: rename old_path: {path} new_path: {new_path}'
+ )
def copy(self, old_path, new_path):
check_path(old_path)
check_path(new_path)
- oldpath_str = " ".join(map(str, old_path)).encode()
- newpath_str = " ".join(map(str, new_path)).encode()
+ oldpath_str = ' '.join(map(str, old_path)).encode()
+ newpath_str = ' '.join(map(str, new_path)).encode()
# Check if a node with intended new name already exists
if self.exists(new_path):
raise ConfigTreeError()
res = self.__copy(self.__config, oldpath_str, newpath_str)
- if (res != 0):
+ if res != 0:
msg = self.__get_error().decode()
raise ConfigTreeError(msg)
if self.__migration:
- self.migration_log.info(f"- op: copy old_path: {old_path} new_path: {new_path}")
+ self.migration_log.info(
+ f'- op: copy old_path: {old_path} new_path: {new_path}'
+ )
def exists(self, path):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res = self.__exists(self.__config, path_str)
- if (res == 0):
+ if res == 0:
return False
else:
return True
def list_nodes(self, path, path_must_exist=True):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res_json = self.__list_nodes(self.__config, path_str).decode()
res = json.loads(res_json)
@@ -307,7 +377,7 @@ class ConfigTree(object):
def return_value(self, path):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res_json = self.__return_value(self.__config, path_str).decode()
res = json.loads(res_json)
@@ -319,7 +389,7 @@ class ConfigTree(object):
def return_values(self, path):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res_json = self.__return_values(self.__config, path_str).decode()
res = json.loads(res_json)
@@ -331,45 +401,62 @@ class ConfigTree(object):
def is_tag(self, path):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res = self.__is_tag(self.__config, path_str)
- if (res >= 1):
+ if res >= 1:
return True
else:
return False
- def set_tag(self, path):
+ def set_tag(self, path, value=True):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
- res = self.__set_tag(self.__config, path_str)
- if (res == 0):
+ res = self.__set_tag(self.__config, path_str, value)
+ if res == 0:
+ return True
+ else:
+ raise ConfigTreeError("Path [{}] doesn't exist".format(path_str))
+
+ def is_leaf(self, path):
+ check_path(path)
+ path_str = ' '.join(map(str, path)).encode()
+
+ return self.__is_leaf(self.__config, path_str)
+
+ def set_leaf(self, path, value):
+ check_path(path)
+ path_str = ' '.join(map(str, path)).encode()
+
+ res = self.__set_leaf(self.__config, path_str, value)
+ if res == 0:
return True
else:
raise ConfigTreeError("Path [{}] doesn't exist".format(path_str))
def get_subtree(self, path, with_node=False):
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res = self.__get_subtree(self.__config, path_str, with_node)
subt = ConfigTree(address=res)
return subt
+
def show_diff(left, right, path=[], commands=False, libpath=LIBPATH):
if left is None:
left = ConfigTree(config_string='\n')
if right is None:
right = ConfigTree(config_string='\n')
if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)):
- raise TypeError("Arguments must be instances of ConfigTree")
+ raise TypeError('Arguments must be instances of ConfigTree')
if path:
if (not left.exists(path)) and (not right.exists(path)):
raise ConfigTreeError(f"Path {path} doesn't exist")
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
__lib = cdll.LoadLibrary(libpath)
__show_diff = __lib.show_diff
@@ -381,20 +468,21 @@ def show_diff(left, right, path=[], commands=False, libpath=LIBPATH):
res = __show_diff(commands, path_str, left._get_config(), right._get_config())
res = res.decode()
- if res == "#1@":
+ if res == '#1@':
msg = __get_error().decode()
raise ConfigTreeError(msg)
res = unescape_backslash(res)
return res
+
def union(left, right, libpath=LIBPATH):
if left is None:
left = ConfigTree(config_string='\n')
if right is None:
right = ConfigTree(config_string='\n')
if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)):
- raise TypeError("Arguments must be instances of ConfigTree")
+ raise TypeError('Arguments must be instances of ConfigTree')
__lib = cdll.LoadLibrary(libpath)
__tree_union = __lib.tree_union
@@ -404,14 +492,15 @@ def union(left, right, libpath=LIBPATH):
__get_error.argtypes = []
__get_error.restype = c_char_p
- res = __tree_union( left._get_config(), right._get_config())
+ res = __tree_union(left._get_config(), right._get_config())
tree = ConfigTree(address=res)
return tree
+
def mask_inclusive(left, right, libpath=LIBPATH):
if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)):
- raise TypeError("Arguments must be instances of ConfigTree")
+ raise TypeError('Arguments must be instances of ConfigTree')
try:
__lib = cdll.LoadLibrary(libpath)
@@ -433,21 +522,75 @@ def mask_inclusive(left, right, libpath=LIBPATH):
return tree
-def reference_tree_to_json(from_dir, to_file, libpath=LIBPATH):
+
+def reference_tree_to_json(from_dir, to_file, internal_cache='', libpath=LIBPATH):
try:
__lib = cdll.LoadLibrary(libpath)
__reference_tree_to_json = __lib.reference_tree_to_json
- __reference_tree_to_json.argtypes = [c_char_p, c_char_p]
+ __reference_tree_to_json.argtypes = [c_char_p, c_char_p, c_char_p]
+ __get_error = __lib.get_error
+ __get_error.argtypes = []
+ __get_error.restype = c_char_p
+ res = __reference_tree_to_json(
+ internal_cache.encode(), from_dir.encode(), to_file.encode()
+ )
+ except Exception as e:
+ raise ConfigTreeError(e)
+ if res == 1:
+ msg = __get_error().decode()
+ raise ConfigTreeError(msg)
+
+
+def merge_reference_tree_cache(cache_dir, primary_name, result_name, libpath=LIBPATH):
+ try:
+ __lib = cdll.LoadLibrary(libpath)
+ __merge_reference_tree_cache = __lib.merge_reference_tree_cache
+ __merge_reference_tree_cache.argtypes = [c_char_p, c_char_p, c_char_p]
__get_error = __lib.get_error
__get_error.argtypes = []
__get_error.restype = c_char_p
- res = __reference_tree_to_json(from_dir.encode(), to_file.encode())
+ res = __merge_reference_tree_cache(
+ cache_dir.encode(), primary_name.encode(), result_name.encode()
+ )
except Exception as e:
raise ConfigTreeError(e)
if res == 1:
msg = __get_error().decode()
raise ConfigTreeError(msg)
+
+def interface_definitions_to_cache(from_dir, cache_path, libpath=LIBPATH):
+ try:
+ __lib = cdll.LoadLibrary(libpath)
+ __interface_definitions_to_cache = __lib.interface_definitions_to_cache
+ __interface_definitions_to_cache.argtypes = [c_char_p, c_char_p]
+ __get_error = __lib.get_error
+ __get_error.argtypes = []
+ __get_error.restype = c_char_p
+ res = __interface_definitions_to_cache(from_dir.encode(), cache_path.encode())
+ except Exception as e:
+ raise ConfigTreeError(e)
+ if res == 1:
+ msg = __get_error().decode()
+ raise ConfigTreeError(msg)
+
+
+def reference_tree_cache_to_json(cache_path, render_file, libpath=LIBPATH):
+ try:
+ __lib = cdll.LoadLibrary(libpath)
+ __reference_tree_cache_to_json = __lib.reference_tree_cache_to_json
+ __reference_tree_cache_to_json.argtypes = [c_char_p, c_char_p]
+ __get_error = __lib.get_error
+ __get_error.argtypes = []
+ __get_error.restype = c_char_p
+ res = __reference_tree_cache_to_json(cache_path.encode(), render_file.encode())
+ except Exception as e:
+ raise ConfigTreeError(e)
+ if res == 1:
+ msg = __get_error().decode()
+ raise ConfigTreeError(msg)
+
+
class DiffTree:
def __init__(self, left, right, path=[], libpath=LIBPATH):
if left is None:
@@ -455,7 +598,7 @@ class DiffTree:
if right is None:
right = ConfigTree(config_string='\n')
if not (isinstance(left, ConfigTree) and isinstance(right, ConfigTree)):
- raise TypeError("Arguments must be instances of ConfigTree")
+ raise TypeError('Arguments must be instances of ConfigTree')
if path:
if not left.exists(path):
raise ConfigTreeError(f"Path {path} doesn't exist in lhs tree")
@@ -472,7 +615,7 @@ class DiffTree:
self.__diff_tree.restype = c_void_p
check_path(path)
- path_str = " ".join(map(str, path)).encode()
+ path_str = ' '.join(map(str, path)).encode()
res = self.__diff_tree(path_str, left._get_config(), right._get_config())
@@ -488,11 +631,11 @@ class DiffTree:
def to_commands(self):
add = self.add.to_commands()
- delete = self.delete.to_commands(op="delete")
- return delete + "\n" + add
+ delete = self.delete.to_commands(op='delete')
+ return delete + '\n' + add
+
def deep_copy(config_tree: ConfigTree) -> ConfigTree:
- """An inelegant, but reasonably fast, copy; replace with backend copy
- """
+ """An inelegant, but reasonably fast, copy; replace with backend copy"""
D = DiffTree(None, config_tree)
return D.add
diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py
index 92996f2ee..d5f443f15 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
@@ -356,6 +359,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 +371,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 +383,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):
@@ -420,7 +426,7 @@ def verify_common_route_maps(config):
continue
tmp = config[route_map]
# Check if the specified route-map exists, if not error out
- if dict_search(f'policy.route-map.{tmp}', config) == None:
+ if dict_search(f'policy.route_map.{tmp}', config) == None:
raise ConfigError(f'Specified route-map "{tmp}" does not exist!')
if 'redistribute' in config:
@@ -434,7 +440,7 @@ def verify_route_map(route_map_name, config):
recurring validation if a specified route-map exists!
"""
# Check if the specified route-map exists, if not error out
- if dict_search(f'policy.route-map.{route_map_name}', config) == None:
+ if dict_search(f'policy.route_map.{route_map_name}', config) == None:
raise ConfigError(f'Specified route-map "{route_map_name}" does not exist!')
def verify_prefix_list(prefix_list, config, version=''):
@@ -443,7 +449,7 @@ def verify_prefix_list(prefix_list, config, version=''):
recurring validation if a specified prefix-list exists!
"""
# Check if the specified prefix-list exists, if not error out
- if dict_search(f'policy.prefix-list{version}.{prefix_list}', config) == None:
+ if dict_search(f'policy.prefix_list{version}.{prefix_list}', config) == None:
raise ConfigError(f'Specified prefix-list{version} "{prefix_list}" does not exist!')
def verify_access_list(access_list, config, version=''):
@@ -452,7 +458,7 @@ def verify_access_list(access_list, config, version=''):
recurring validation if a specified prefix-list exists!
"""
# Check if the specified ACL exists, if not error out
- if dict_search(f'policy.access-list{version}.{access_list}', config) == None:
+ if dict_search(f'policy.access_list{version}.{access_list}', config) == None:
raise ConfigError(f'Specified access-list{version} "{access_list}" does not exist!')
def verify_pki_certificate(config: dict, cert_name: str, no_password_protected: bool=False):
@@ -537,3 +543,13 @@ def verify_eapol(config: dict):
if 'ca_certificate' in config['eapol']:
for ca_cert in config['eapol']['ca_certificate']:
verify_pki_ca_certificate(config, ca_cert)
+
+def has_frr_protocol_in_dict(config_dict: dict, protocol: str) -> bool:
+ vrf = None
+ if config_dict and 'vrf_context' in config_dict:
+ vrf = config_dict['vrf_context']
+ if vrf and protocol in (dict_search(f'vrf.name.{vrf}.protocols', config_dict) or []):
+ return True
+ if config_dict and protocol in config_dict:
+ return True
+ return False
diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py
index dec619d3e..c1e5ddc04 100644
--- a/python/vyos/defaults.py
+++ b/python/vyos/defaults.py
@@ -1,4 +1,4 @@
-# Copyright 2018-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2018-2025 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
@@ -36,11 +36,25 @@ directories = {
'isc_dhclient_dir' : '/run/dhclient',
'dhcp6_client_dir' : '/run/dhcp6c',
'vyos_configdir' : '/opt/vyatta/config',
- 'completion_dir' : f'{base_dir}/completion'
+ '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'
+}
+
+systemd_services = {
+ 'haproxy' : 'haproxy.service',
+ 'syslog' : 'syslog.service',
+ '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'
cfg_group = 'vyattacfg'
@@ -61,3 +75,5 @@ rt_symbolic_names = {
rt_global_vrf = rt_symbolic_names['main']
rt_global_table = rt_symbolic_names['main']
+
+vyconfd_conf = '/etc/vyos/vyconfd.conf'
diff --git a/python/vyos/ethtool.py b/python/vyos/ethtool.py
index 80bb56fa2..4710a5d40 100644
--- a/python/vyos/ethtool.py
+++ b/python/vyos/ethtool.py
@@ -23,7 +23,7 @@ from vyos.utils.process import popen
# flow control settings
_drivers_without_speed_duplex_flow = ['vmxnet3', 'virtio_net', 'xen_netfront',
'iavf', 'ice', 'i40e', 'hv_netvsc', 'veth', 'ixgbevf',
- 'tun']
+ 'tun', 'vif']
class Ethtool:
"""
@@ -56,11 +56,8 @@ class Ethtool:
# '100' : {'full': '', 'half': ''},
# '1000': {'full': ''}
# }
- _speed_duplex = {'auto': {'auto': ''}}
_ring_buffer = None
_driver_name = None
- _auto_negotiation = False
- _auto_negotiation_supported = None
_flow_control = None
def __init__(self, ifname):
@@ -74,56 +71,52 @@ class Ethtool:
self._driver_name = driver.group(1)
# Build a dictinary of supported link-speed and dupley settings.
- out, _ = popen(f'ethtool {ifname}')
- reading = False
- pattern = re.compile(r'\d+base.*')
- for line in out.splitlines()[1:]:
- line = line.lstrip()
- if 'Supported link modes:' in line:
- reading = True
- if 'Supported pause frame use:' in line:
- reading = False
- if reading:
- for block in line.split():
- if pattern.search(block):
- speed = block.split('base')[0]
- duplex = block.split('/')[-1].lower()
- if speed not in self._speed_duplex:
- self._speed_duplex.update({ speed : {}})
- if duplex not in self._speed_duplex[speed]:
- self._speed_duplex[speed].update({ duplex : ''})
- if 'Supports auto-negotiation:' in line:
- # Split the following string: Auto-negotiation: off
- # we are only interested in off or on
- tmp = line.split()[-1]
- self._auto_negotiation_supported = bool(tmp == 'Yes')
- # Only read in if Auto-negotiation is supported
- if self._auto_negotiation_supported and 'Auto-negotiation:' in line:
- # Split the following string: Auto-negotiation: off
- # we are only interested in off or on
- tmp = line.split()[-1]
- self._auto_negotiation = bool(tmp == 'on')
+ # [ {
+ # "ifname": "eth0",
+ # "supported-ports": [ "TP" ],
+ # "supported-link-modes": [ "10baseT/Half","10baseT/Full","100baseT/Half","100baseT/Full","1000baseT/Full" ],
+ # "supported-pause-frame-use": "Symmetric",
+ # "supports-auto-negotiation": true,
+ # "supported-fec-modes": [ ],
+ # "advertised-link-modes": [ "10baseT/Half","10baseT/Full","100baseT/Half","100baseT/Full","1000baseT/Full" ],
+ # "advertised-pause-frame-use": "Symmetric",
+ # "advertised-auto-negotiation": true,
+ # "advertised-fec-modes": [ ],
+ # "speed": 1000,
+ # "duplex": "Full",
+ # "auto-negotiation": false,
+ # "port": "Twisted Pair",
+ # "phyad": 1,
+ # "transceiver": "internal",
+ # "supports-wake-on": "pumbg",
+ # "wake-on": "g",
+ # "current-message-level": 7,
+ # "link-detected": true
+ # } ]
+ out, _ = popen(f'ethtool --json {ifname}')
+ self._base_settings = loads(out)[0]
# Now populate driver features
out, _ = popen(f'ethtool --json --show-features {ifname}')
- self._features = loads(out)
+ self._features = loads(out)[0]
# Get information about NIC ring buffers
- out, _ = popen(f'ethtool --json --show-ring {ifname}')
- self._ring_buffer = loads(out)
+ out, err = popen(f'ethtool --json --show-ring {ifname}')
+ if not bool(err):
+ self._ring_buffer = loads(out)[0]
# Get current flow control settings, but this is not supported by
# all NICs (e.g. vmxnet3 does not support is)
out, err = popen(f'ethtool --json --show-pause {ifname}')
if not bool(err):
- self._flow_control = loads(out)
+ self._flow_control = loads(out)[0]
def check_auto_negotiation_supported(self):
""" Check if the NIC supports changing auto-negotiation """
- return self._auto_negotiation_supported
+ return self._base_settings['supports-auto-negotiation']
def get_auto_negotiation(self):
- return self._auto_negotiation_supported and self._auto_negotiation
+ return self._base_settings['supports-auto-negotiation'] and self._base_settings['auto-negotiation']
def get_driver_name(self):
return self._driver_name
@@ -137,9 +130,9 @@ class Ethtool:
"""
active = False
fixed = True
- if feature in self._features[0]:
- active = bool(self._features[0][feature]['active'])
- fixed = bool(self._features[0][feature]['fixed'])
+ if feature in self._features:
+ active = bool(self._features[feature]['active'])
+ fixed = bool(self._features[feature]['fixed'])
return active, fixed
def get_generic_receive_offload(self):
@@ -165,14 +158,14 @@ class Ethtool:
# thus when it's impossible return None
if rx_tx not in ['rx', 'tx']:
ValueError('Ring-buffer type must be either "rx" or "tx"')
- return str(self._ring_buffer[0].get(f'{rx_tx}-max', None))
+ return str(self._ring_buffer.get(f'{rx_tx}-max', None))
def get_ring_buffer(self, rx_tx):
# Configuration of RX/TX ring-buffers is not supported on every device,
# thus when it's impossible return None
if rx_tx not in ['rx', 'tx']:
ValueError('Ring-buffer type must be either "rx" or "tx"')
- return str(self._ring_buffer[0].get(rx_tx, None))
+ return str(self._ring_buffer.get(rx_tx, None))
def check_speed_duplex(self, speed, duplex):
""" Check if the passed speed and duplex combination is supported by
@@ -184,12 +177,16 @@ class Ethtool:
if duplex not in ['auto', 'full', 'half']:
raise ValueError(f'Value "{duplex}" for duplex is invalid!')
+ if speed == 'auto' and duplex == 'auto':
+ return True
+
if self.get_driver_name() in _drivers_without_speed_duplex_flow:
return False
- if speed in self._speed_duplex:
- if duplex in self._speed_duplex[speed]:
- return True
+ # ['10baset/half', '10baset/full', '100baset/half', '100baset/full', '1000baset/full']
+ tmp = [x.lower() for x in self._base_settings['supported-link-modes']]
+ if f'{speed}baset/{duplex}' in tmp:
+ return True
return False
def check_flow_control(self):
@@ -201,4 +198,4 @@ class Ethtool:
raise ValueError('Interface does not support changing '\
'flow-control settings!')
- return 'on' if bool(self._flow_control[0]['autonegotiate']) else 'off'
+ return 'on' if bool(self._flow_control['autonegotiate']) else 'off'
diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py
index 64fed8177..64022db84 100755
--- a/python/vyos/firewall.py
+++ b/python/vyos/firewall.py
@@ -53,25 +53,32 @@ def conntrack_required(conf):
# Domain Resolver
-def fqdn_config_parse(firewall):
- firewall['ip_fqdn'] = {}
- firewall['ip6_fqdn'] = {}
-
- for domain, path in dict_search_recursive(firewall, 'fqdn'):
- hook_name = path[1]
- priority = path[2]
-
- fw_name = path[2]
- rule = path[4]
- suffix = path[5][0]
- set_name = f'{hook_name}_{priority}_{rule}_{suffix}'
-
- if (path[0] == 'ipv4') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'):
- firewall['ip_fqdn'][set_name] = domain
- elif (path[0] == 'ipv6') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'):
- if path[1] == 'name':
- set_name = f'name6_{priority}_{rule}_{suffix}'
- firewall['ip6_fqdn'][set_name] = domain
+def fqdn_config_parse(config, node):
+ config['ip_fqdn'] = {}
+ config['ip6_fqdn'] = {}
+
+ for domain, path in dict_search_recursive(config, 'fqdn'):
+ if node != 'nat':
+ hook_name = path[1]
+ priority = path[2]
+
+ rule = path[4]
+ suffix = path[5][0]
+ set_name = f'{hook_name}_{priority}_{rule}_{suffix}'
+
+ if (path[0] == 'ipv4') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'):
+ config['ip_fqdn'][set_name] = domain
+ elif (path[0] == 'ipv6') and (path[1] == 'forward' or path[1] == 'input' or path[1] == 'output' or path[1] == 'name'):
+ if path[1] == 'name':
+ set_name = f'name6_{priority}_{rule}_{suffix}'
+ config['ip6_fqdn'][set_name] = domain
+ else:
+ # Parse FQDN for NAT
+ nat_direction = path[0]
+ nat_rule = path[2]
+ suffix = path[3][0]
+ set_name = f'{nat_direction}_{nat_rule}_{suffix}'
+ config['ip_fqdn'][set_name] = domain
def fqdn_resolve(fqdn, ipv6=False):
try:
@@ -80,8 +87,6 @@ def fqdn_resolve(fqdn, ipv6=False):
except:
return None
-# End Domain Resolver
-
def find_nftables_rule(table, chain, rule_matches=[]):
# Find rule in table/chain that matches all criteria and return the handle
results = cmd(f'sudo nft --handle list chain {table} {chain}').split("\n")
@@ -228,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:
@@ -305,6 +313,16 @@ 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:]
+ 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 = ''
@@ -456,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}')
@@ -578,6 +596,12 @@ def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name):
if 'tcp_mss' in rule_conf['set']:
mss = rule_conf['set']['tcp_mss']
output.append(f'tcp option maxseg size set {mss}')
+ if 'ttl' in rule_conf['set']:
+ ttl = rule_conf['set']['ttl']
+ output.append(f'ip ttl set {ttl}')
+ if 'hop_limit' in rule_conf['set']:
+ hoplimit = rule_conf['set']['hop_limit']
+ output.append(f'ip6 hoplimit set {hoplimit}')
if 'action' in rule_conf:
if rule_conf['action'] == 'offload':
@@ -616,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,
@@ -627,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]
@@ -720,14 +744,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):
@@ -742,23 +766,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:
@@ -767,19 +809,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/python/vyos/frr.py b/python/vyos/frr.py
deleted file mode 100644
index 6fb81803f..000000000
--- a/python/vyos/frr.py
+++ /dev/null
@@ -1,551 +0,0 @@
-# Copyright 2020-2024 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/>.
-
-r"""
-A Library for interracting with the FRR daemon suite.
-It supports simple configuration manipulation and loading using the official tools
-supplied with FRR (vtysh and frr-reload)
-
-All configuration management and manipulation is done using strings and regex.
-
-
-Example Usage
-#####
-
-# Reading configuration from frr:
-```
->>> original_config = get_configuration()
->>> repr(original_config)
-'!\nfrr version 7.3.1\nfrr defaults traditional\nhostname debian\n......
-```
-
-
-# Modify a configuration section:
-```
->>> new_bgp_section = 'router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n'
->>> modified_config = replace_section(original_config, new_bgp_section, replace_re=r'router bgp \d+')
->>> repr(modified_config)
-'............router bgp 65000\n neighbor 192.0.2.1 remote-as 65000\n...........'
-```
-
-Remove a configuration section:
-```
->>> modified_config = remove_section(original_config, r'router ospf')
-```
-
-Test the new configuration:
-```
->>> try:
->>> mark_configuration(modified configuration)
->>> except ConfigurationNotValid as e:
->>> print('resulting configuration is not valid')
->>> sys.exit(1)
-```
-
-Apply the new configuration:
-```
->>> try:
->>> replace_configuration(modified_config)
->>> except CommitError as e:
->>> print('Exception while commiting the supplied configuration')
->>> print(e)
->>> exit(1)
-```
-"""
-
-import tempfile
-import re
-
-from vyos import ConfigError
-from vyos.utils.process import cmd
-from vyos.utils.process import popen
-from vyos.utils.process import STDOUT
-
-import logging
-from logging.handlers import SysLogHandler
-import os
-import sys
-
-LOG = logging.getLogger(__name__)
-DEBUG = False
-
-ch = SysLogHandler(address='/dev/log')
-ch2 = logging.StreamHandler(stream=sys.stdout)
-LOG.addHandler(ch)
-LOG.addHandler(ch2)
-
-_frr_daemons = ['zebra', 'staticd', 'bgpd', 'ospfd', 'ospf6d', 'ripd', 'ripngd',
- 'isisd', 'pimd', 'pim6d', 'ldpd', 'eigrpd', 'babeld', 'bfdd', 'fabricd']
-
-path_vtysh = '/usr/bin/vtysh'
-path_frr_reload = '/usr/lib/frr/frr-reload.py'
-path_config = '/run/frr'
-
-default_add_before = r'(ip prefix-list .*|route-map .*|line vty|end)'
-
-
-class FrrError(Exception):
- pass
-
-
-class ConfigurationNotValid(FrrError):
- """
- The configuratioin supplied to vtysh is not valid
- """
- pass
-
-
-class CommitError(FrrError):
- """
- Commiting the supplied configuration failed to commit by a unknown reason
- see commit error and/or run mark_configuration on the specified configuration
- to se error generated
-
- used by: reload_configuration()
- """
- pass
-
-
-class ConfigSectionNotFound(FrrError):
- """
- Removal of configuration failed because it is not existing in the supplied configuration
- """
- pass
-
-def init_debugging():
- global DEBUG
-
- DEBUG = os.path.exists('/tmp/vyos.frr.debug')
- if DEBUG:
- LOG.setLevel(logging.DEBUG)
-
-def get_configuration(daemon=None, marked=False):
- """ Get current running FRR configuration
- daemon: Collect only configuration for the specified FRR daemon,
- supplying daemon=None retrieves the complete configuration
- marked: Mark the configuration with "end" tags
-
- return: string containing the running configuration from frr
-
- """
- if daemon and daemon not in _frr_daemons:
- raise ValueError(f'The specified daemon type is not supported {repr(daemon)}')
-
- cmd = f"{path_vtysh} -c 'show run'"
- if daemon:
- cmd += f' -d {daemon}'
-
- output, code = popen(cmd, stderr=STDOUT)
- if code:
- raise OSError(code, output)
-
- config = output.replace('\r', '')
- # Remove first header lines from FRR config
- config = config.split("\n", 3)[-1]
- # Mark the configuration with end tags
- if marked:
- config = mark_configuration(config)
-
- return config
-
-
-def mark_configuration(config):
- """ Add end marks and Test the configuration for syntax faults
- If the configuration is valid a marked version of the configuration is returned,
- or else it failes with a ConfigurationNotValid Exception
-
- config: The configuration string to mark/test
- return: The marked configuration from FRR
- """
- output, code = popen(f"{path_vtysh} -m -f -", stderr=STDOUT, input=config)
-
- if code == 2:
- raise ConfigurationNotValid(str(output))
- elif code:
- raise OSError(code, output)
-
- config = output.replace('\r', '')
- return config
-
-
-def reload_configuration(config, daemon=None):
- """ Execute frr-reload with the new configuration
- This will try to reapply the supplied configuration inside FRR.
- The configuration needs to be a complete configuration from the integrated config or
- from a daemon.
-
- config: The configuration to apply
- daemon: Apply the conigutaion to the specified FRR daemon,
- supplying daemon=None applies to the integrated configuration
- return: None
- """
- if daemon and daemon not in _frr_daemons:
- raise ValueError(f'The specified daemon type is not supported {repr(daemon)}')
-
- f = tempfile.NamedTemporaryFile('w')
- f.write(config)
- f.flush()
-
- LOG.debug(f'reload_configuration: Reloading config using temporary file: {f.name}')
- cmd = f'{path_frr_reload} --reload'
- if daemon:
- cmd += f' --daemon {daemon}'
-
- if DEBUG:
- cmd += f' --debug --stdout'
-
- cmd += f' {f.name}'
-
- LOG.debug(f'reload_configuration: Executing command against frr-reload: "{cmd}"')
- output, code = popen(cmd, stderr=STDOUT)
- f.close()
-
- for i, e in enumerate(output.split('\n')):
- LOG.debug(f'frr-reload output: {i:3} {e}')
-
- if code == 1:
- raise ConfigError(output)
- elif code:
- raise OSError(code, output)
-
- return output
-
-
-def save_configuration():
- """ T3217: Save FRR configuration to /run/frr/config/frr.conf """
- return cmd(f'{path_vtysh} -n -w')
-
-
-def execute(command):
- """ Run commands inside vtysh
- command: str containing commands to execute inside a vtysh session
- """
- if not isinstance(command, str):
- raise ValueError(f'command needs to be a string: {repr(command)}')
-
- cmd = f"{path_vtysh} -c '{command}'"
-
- output, code = popen(cmd, stderr=STDOUT)
- if code:
- raise OSError(code, output)
-
- config = output.replace('\r', '')
- return config
-
-
-def configure(lines, daemon=False):
- """ run commands inside config mode vtysh
- lines: list or str conaining commands to execute inside a configure session
- only one command executed on each configure()
- Executing commands inside a subcontext uses the list to describe the context
- ex: ['router bgp 6500', 'neighbor 192.0.2.1 remote-as 65000']
- return: None
- """
- if isinstance(lines, str):
- lines = [lines]
- elif not isinstance(lines, list):
- raise ValueError('lines needs to be string or list of commands')
-
- if daemon and daemon not in _frr_daemons:
- raise ValueError(f'The specified daemon type is not supported {repr(daemon)}')
-
- cmd = f'{path_vtysh}'
- if daemon:
- cmd += f' -d {daemon}'
-
- cmd += " -c 'configure terminal'"
- for x in lines:
- cmd += f" -c '{x}'"
-
- output, code = popen(cmd, stderr=STDOUT)
- if code == 1:
- raise ConfigurationNotValid(f'Configuration FRR failed: {repr(output)}')
- elif code:
- raise OSError(code, output)
-
- config = output.replace('\r', '')
- return config
-
-
-def _replace_section(config, replacement, replace_re, before_re):
- r"""Replace a section of FRR config
- config: full original configuration
- replacement: replacement configuration section
- replace_re: The regex to replace
- example: ^router bgp \d+$.?*^!$
- this will replace everything between ^router bgp X$ and ^!$
- before_re: When replace_re is not existant, the config will be added before this tag
- example: ^line vty$
-
- return: modified configuration as a text file
- """
- # DEPRECATED, this is replaced by a new implementation
- # Check if block is configured, remove the existing instance else add a new one
- if re.findall(replace_re, config, flags=re.MULTILINE | re.DOTALL):
- # Section is in the configration, replace it
- return re.sub(replace_re, replacement, config, count=1,
- flags=re.MULTILINE | re.DOTALL)
- if before_re:
- if not re.findall(before_re, config, flags=re.MULTILINE | re.DOTALL):
- raise ConfigSectionNotFound(f"Config section {before_re} not found in config")
-
- # If no section is in the configuration, add it before the line vty line
- return re.sub(before_re, rf'{replacement}\n\g<1>', config, count=1,
- flags=re.MULTILINE | re.DOTALL)
-
- raise ConfigSectionNotFound(f"Config section {replacement} not found in config")
-
-
-def replace_section(config, replacement, from_re, to_re=r'!', before_re=r'line vty'):
- r"""Replace a section of FRR config
- config: full original configuration
- replacement: replacement configuration section
- from_re: Regex for the start of section matching
- example: 'router bgp \d+'
- to_re: Regex for stop of section matching
- default: '!'
- example: '!' or 'end'
- before_re: When from_re/to_re does not return a match, the config will
- be added before this tag
- default: ^line vty$
-
- startline and endline tags will be automatically added to the resulting from_re/to_re and before_re regex'es
- """
- # DEPRECATED, this is replaced by a new implementation
- return _replace_section(config, replacement, replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=rf'^({before_re})$')
-
-
-def remove_section(config, from_re, to_re='!'):
- # DEPRECATED, this is replaced by a new implementation
- return _replace_section(config, '', replace_re=rf'^{from_re}$.*?^{to_re}$', before_re=None)
-
-
-def _find_first_block(config, start_pattern, stop_pattern, start_at=0):
- '''Find start and stop line numbers for a config block
- config: (list) A list conaining the configuration that is searched
- start_pattern: (raw-str) The pattern searched for a a start of block tag
- stop_pattern: (raw-str) The pattern searched for to signify the end of the block
- start_at: (int) The index to start searching at in the <config>
-
- Returns:
- None: No complete block could be found
- set(int, int): A complete block found between the line numbers returned in the set
-
- The object <config> is searched from the start for the regex <start_pattern> until the first match is found.
- On a successful match it continues the search for the regex <stop_pattern> until it is found.
- After a successful run a set is returned containing the start and stop line numbers.
- '''
- LOG.debug(f'_find_first_block: find start={repr(start_pattern)} stop={repr(stop_pattern)} start_at={start_at}')
- _start = None
- for i, element in enumerate(config[start_at:], start=start_at):
- # LOG.debug(f'_find_first_block: running line {i:3} "{element}"')
- if not _start:
- if not re.match(start_pattern, element):
- LOG.debug(f'_find_first_block: no match {i:3} "{element}"')
- continue
- _start = i
- LOG.debug(f'_find_first_block: Found start {i:3} "{element}"')
- continue
-
- if not re.match(stop_pattern, element):
- LOG.debug(f'_find_first_block: no match {i:3} "{element}"')
- continue
-
- LOG.debug(f'_find_first_block: Found stop {i:3} "{element}"')
- return (_start, i)
-
- LOG.debug('_find_first_block: exit start={repr(start_pattern)} stop={repr(stop_pattern)} start_at={start_at}')
- return None
-
-
-def _find_first_element(config, pattern, start_at=0):
- '''Find the first element that matches the current pattern in config
- config: (list) A list containing the configuration that is searched
- start_pattern: (raw-str) The pattern searched for
- start_at: (int) The index to start searching at in the <config>
-
- return: Line index of the line containing the searched pattern
-
- TODO: for now it returns -1 on a no-match because 0 also returns as False
- TODO: that means that we can not use False matching to tell if its
- '''
- LOG.debug(f'_find_first_element: find start="{pattern}" start_at={start_at}')
- for i, element in enumerate(config[start_at:], start=0):
- if re.match(pattern + '$', element):
- LOG.debug(f'_find_first_element: Found stop {i:3} "{element}"')
- return i
- LOG.debug(f'_find_first_element: no match {i:3} "{element}"')
- LOG.debug(f'_find_first_element: Did not find any match, exiting')
- return -1
-
-
-def _find_elements(config, pattern, start_at=0):
- '''Find all instances of pattern and return a list containing all element indexes
- config: (list) A list containing the configuration that is searched
- start_pattern: (raw-str) The pattern searched for
- start_at: (int) The index to start searching at in the <config>
-
- return: A list of line indexes containing the searched pattern
- TODO: refactor this to return a generator instead
- '''
- return [i for i, element in enumerate(config[start_at:], start=0) if re.match(pattern + '$', element)]
-
-
-class FRRConfig:
- '''Main FRR Configuration manipulation object
- Using this object the user could load, manipulate and commit the configuration to FRR
- '''
- def __init__(self, config=[]):
- self.imported_config = ''
-
- if isinstance(config, list):
- self.config = config.copy()
- self.original_config = config.copy()
- elif isinstance(config, str):
- self.config = config.split('\n')
- self.original_config = self.config.copy()
- else:
- raise ValueError(
- 'The config element needs to be a string or list type object')
-
- if config:
- LOG.debug(f'__init__: frr library initiated with initial config')
- for i, e in enumerate(self.config):
- LOG.debug(f'__init__: initial {i:3} {e}')
-
- def load_configuration(self, daemon=None):
- '''Load the running configuration from FRR into the config object
- daemon: str with name of the FRR Daemon to load configuration from or
- None to load the consolidated config
-
- Using this overwrites the current loaded config objects and replaces the original loaded config
- '''
- init_debugging()
-
- self.imported_config = get_configuration(daemon=daemon)
- if daemon:
- LOG.debug(f'load_configuration: Configuration loaded from FRR daemon {daemon}')
- else:
- LOG.debug(f'load_configuration: Configuration loaded from FRR integrated config')
-
- self.original_config = self.imported_config.split('\n')
- self.config = self.original_config.copy()
-
- for i, e in enumerate(self.imported_config.split('\n')):
- LOG.debug(f'load_configuration: loaded {i:3} {e}')
- return
-
- def test_configuration(self):
- '''Test the current configuration against FRR
- This will exception if FRR failes to load the current configuration object
- '''
- LOG.debug('test_configation: Testing configuration')
- mark_configuration('\n'.join(self.config))
-
- def commit_configuration(self, daemon=None):
- '''
- Commit the current configuration to FRR daemon: str with name of the
- FRR daemon to commit to or None to use the consolidated config.
-
- Configuration is automatically saved after apply
- '''
- LOG.debug('commit_configuration: Commiting configuration')
- for i, e in enumerate(self.config):
- LOG.debug(f'commit_configuration: new_config {i:3} {e}')
-
- # https://github.com/FRRouting/frr/issues/10132
- # https://github.com/FRRouting/frr/issues/10133
- count = 0
- count_max = 5
- emsg = ''
- while count < count_max:
- count += 1
- try:
- reload_configuration('\n'.join(self.config), daemon=daemon)
- break
- except ConfigError as e:
- emsg = str(e)
- except:
- # we just need to re-try the commit of the configuration
- # for the listed FRR issues above
- pass
- if count >= count_max:
- if emsg:
- raise ConfigError(emsg)
- raise ConfigurationNotValid(f'Config commit retry counter ({count_max}) exceeded for {daemon} daemon!')
-
- # Save configuration to /run/frr/config/frr.conf
- save_configuration()
-
-
- def modify_section(self, start_pattern, replacement='!', stop_pattern=r'\S+', remove_stop_mark=False, count=0):
- if isinstance(replacement, str):
- replacement = replacement.split('\n')
- elif not isinstance(replacement, list):
- return ValueError("The replacement element needs to be a string or list type object")
- LOG.debug(f'modify_section: starting search for {repr(start_pattern)} until {repr(stop_pattern)}')
-
- _count = 0
- _next_start = 0
- while True:
- if count and count <= _count:
- # Break out of the loop after specified amount of matches
- LOG.debug(f'modify_section: reached limit ({_count}), exiting loop at line {_next_start}')
- break
- # While searching, always assume that the user wants to search for the exact pattern he entered
- # To be more specific the user needs a override, eg. a "pattern.*"
- _w = _find_first_block(
- self.config, start_pattern+'$', stop_pattern, start_at=_next_start)
- if not _w:
- # Reached the end, no more elements to remove
- LOG.debug(f'modify_section: No more config sections found, exiting')
- break
- start_element, end_element = _w
- LOG.debug(f'modify_section: found match between {start_element} and {end_element}')
- for i, e in enumerate(self.config[start_element:end_element+1 if remove_stop_mark else end_element],
- start=start_element):
- LOG.debug(f'modify_section: remove {i:3} {e}')
- del self.config[start_element:end_element +
- 1 if remove_stop_mark else end_element]
- if replacement:
- # Append the replacement config at the current position
- for i, e in enumerate(replacement, start=start_element):
- LOG.debug(f'modify_section: add {i:3} {e}')
- self.config[start_element:start_element] = replacement
- _count += 1
- _next_start = start_element + len(replacement)
-
- return _count
-
- def add_before(self, before_pattern, addition):
- '''Add config block before this element in the configuration'''
- if isinstance(addition, str):
- addition = addition.split('\n')
- elif not isinstance(addition, list):
- return ValueError("The replacement element needs to be a string or list type object")
-
- start = _find_first_element(self.config, before_pattern)
- if start < 0:
- return False
- for i, e in enumerate(addition, start=start):
- LOG.debug(f'add_before: add {i:3} {e}')
- self.config[start:start] = addition
- return True
-
- def __str__(self):
- return '\n'.join(self.config)
-
- def __repr__(self):
- return f'frr({repr(str(self))})'
diff --git a/python/vyos/frrender.py b/python/vyos/frrender.py
new file mode 100644
index 000000000..73d6dd5f0
--- /dev/null
+++ b/python/vyos/frrender.py
@@ -0,0 +1,756 @@
+# Copyright 2024-2025 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/>.
+
+"""
+Library used to interface with FRRs mgmtd introduced in version 10.0
+"""
+
+import os
+
+from time import sleep
+
+from vyos.defaults import frr_debug_enable
+from vyos.utils.dict import dict_search
+from vyos.utils.file import write_file
+from vyos.utils.process import cmd
+from vyos.utils.process import rc_cmd
+from vyos.template import render_to_string
+from vyos import ConfigError
+
+def debug(message):
+ if not os.path.exists(frr_debug_enable):
+ return
+ print(message)
+
+frr_protocols = ['babel', 'bfd', 'bgp', 'eigrp', 'isis', 'mpls', 'nhrp',
+ 'openfabric', 'ospf', 'ospfv3', 'pim', 'pim6', 'rip',
+ 'ripng', 'rpki', 'segment_routing', 'static']
+
+babel_daemon = 'babeld'
+bfd_daemon = 'bfdd'
+bgp_daemon = 'bgpd'
+isis_daemon = 'isisd'
+ldpd_daemon = 'ldpd'
+mgmt_daemon = 'mgmtd'
+openfabric_daemon = 'fabricd'
+ospf_daemon = 'ospfd'
+ospf6_daemon = 'ospf6d'
+pim_daemon = 'pimd'
+pim6_daemon = 'pim6d'
+rip_daemon = 'ripd'
+ripng_daemon = 'ripngd'
+zebra_daemon = 'zebra'
+nhrp_daemon = 'nhrpd'
+
+def get_frrender_dict(conf, argv=None) -> dict:
+ from copy import deepcopy
+ from vyos.config import config_dict_merge
+ 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 = {}
+
+ if argv and len(argv) > 1:
+ dict['vrf_context'] = argv[1]
+
+ def dict_helper_ospf_defaults(ospf, path):
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(path, key_mangling=('-', '_'),
+ get_first_key=True, recursive=True)
+
+ # We have to cleanup the default dict, as default values could enable features
+ # which are not explicitly enabled on the CLI. Example: default-information
+ # originate comes with a default metric-type of 2, which will enable the
+ # entire default-information originate tree, even when not set via CLI so we
+ # need to check this first and probably drop that key.
+ if dict_search('default_information.originate', ospf) is None:
+ del default_values['default_information']
+ if 'mpls_te' not in ospf:
+ del default_values['mpls_te']
+ if 'graceful_restart' not in ospf:
+ del default_values['graceful_restart']
+ for area_num in default_values.get('area', []):
+ 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', 'nhrp', 'rip', 'static']:
+ if dict_search(f'redistribute.{protocol}', ospf) is None:
+ del default_values['redistribute'][protocol]
+ if not bool(default_values['redistribute']):
+ del default_values['redistribute']
+
+ for interface in ospf.get('interface', []):
+ # We need to reload the defaults on every pass b/c of
+ # hello-multiplier dependency on dead-interval
+ # If hello-multiplier is set, we need to remove the default from
+ # dead-interval.
+ if 'hello_multiplier' in ospf['interface'][interface]:
+ del default_values['interface'][interface]['dead_interval']
+
+ ospf = config_dict_merge(default_values, ospf)
+ return ospf
+
+ def dict_helper_ospfv3_defaults(ospfv3, path):
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(path, key_mangling=('-', '_'),
+ get_first_key=True, recursive=True)
+
+ # We have to cleanup the default dict, as default values could enable features
+ # which are not explicitly enabled on the CLI. Example: default-information
+ # originate comes with a default metric-type of 2, which will enable the
+ # entire default-information originate tree, even when not set via CLI so we
+ # need to check this first and probably drop that key.
+ if dict_search('default_information.originate', ospfv3) is None:
+ del default_values['default_information']
+ if 'graceful_restart' not in ospfv3:
+ del default_values['graceful_restart']
+
+ for protocol in ['babel', 'bgp', 'connected', 'isis', 'kernel', 'ripng', 'static']:
+ if dict_search(f'redistribute.{protocol}', ospfv3) is None:
+ del default_values['redistribute'][protocol]
+ if not bool(default_values['redistribute']):
+ del default_values['redistribute']
+
+ default_values.pop('interface', {})
+
+ # merge in remaining default values
+ ospfv3 = config_dict_merge(default_values, ospfv3)
+ return ospfv3
+
+ def dict_helper_pim_defaults(pim, path):
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(path, key_mangling=('-', '_'),
+ get_first_key=True, recursive=True)
+
+ # We have to cleanup the default dict, as default values could enable features
+ # which are not explicitly enabled on the CLI.
+ for interface in pim.get('interface', []):
+ if 'igmp' not in pim['interface'][interface]:
+ del default_values['interface'][interface]['igmp']
+
+ pim = config_dict_merge(default_values, pim)
+ return pim
+
+ def dict_helper_nhrp_defaults(nhrp):
+ # NFLOG group numbers which are used in netfilter firewall rules and
+ # in the global config in FRR.
+ # https://docs.frrouting.org/en/latest/nhrpd.html#hub-functionality
+ # https://docs.frrouting.org/en/latest/nhrpd.html#multicast-functionality
+ # Use nflog group number for NHRP redirects = 1
+ # Use nflog group number from MULTICAST traffic = 2
+ nflog_redirect = 1
+ nflog_multicast = 2
+
+ nhrp = conf.merge_defaults(nhrp, recursive=True)
+
+ nhrp_tunnel = conf.get_config_dict(['interfaces', 'tunnel'],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ if nhrp_tunnel: nhrp.update({'if_tunnel': nhrp_tunnel})
+
+ for intf, intf_config in nhrp['tunnel'].items():
+ if 'multicast' in intf_config:
+ nhrp['multicast'] = nflog_multicast
+ if 'redirect' in intf_config:
+ nhrp['redirect'] = nflog_redirect
+
+ ##Add ipsec profile config to nhrp configuration to apply encryption
+ profile = conf.get_config_dict(['vpn', 'ipsec', 'profile'],
+ key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ for name, profile_conf in profile.items():
+ if 'disable' in profile_conf:
+ continue
+ if 'bind' in profile_conf and 'tunnel' in profile_conf['bind']:
+ interfaces = profile_conf['bind']['tunnel']
+ if isinstance(interfaces, str):
+ interfaces = [interfaces]
+ for interface in interfaces:
+ if dict_search(f'tunnel.{interface}', nhrp):
+ nhrp['tunnel'][interface][
+ 'security_profile'] = name
+ return nhrp
+
+ # Ethernet and bonding interfaces can participate in EVPN which is configured via FRR
+ tmp = {}
+ for if_type in ['ethernet', 'bonding']:
+ interface_path = ['interfaces', if_type]
+ if not conf.exists(interface_path):
+ continue
+ for interface in conf.list_nodes(interface_path):
+ evpn_path = interface_path + [interface, 'evpn']
+ if not conf.exists(evpn_path):
+ continue
+
+ evpn = conf.get_config_dict(evpn_path, key_mangling=('-', '_'))
+ tmp.update({interface : evpn})
+ # At least one participating EVPN interface found, add to result dict
+ if tmp: dict['interfaces'] = tmp
+
+ # Zebra prefix exchange for Kernel IP/IPv6 and routing protocols
+ for ip_version in ['ip', 'ipv6']:
+ ip_cli_path = ['system', ip_version]
+ ip_dict = conf.get_config_dict(ip_cli_path, key_mangling=('-', '_'),
+ get_first_key=True, with_recursive_defaults=True)
+ if ip_dict:
+ ip_dict['afi'] = ip_version
+ dict.update({ip_version : ip_dict})
+
+ # Enable SNMP agentx support
+ # SNMP AgentX support cannot be disabled once enabled
+ if conf.exists(['service', 'snmp']):
+ dict['snmp'] = {}
+
+ # We will always need the policy key
+ dict['policy'] = conf.get_config_dict(['policy'], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # We need to check the CLI if the BABEL node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ babel_cli_path = ['protocols', 'babel']
+ if conf.exists(babel_cli_path):
+ babel = conf.get_config_dict(babel_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+ dict.update({'babel' : babel})
+
+ # We need to check the CLI if the BFD node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ bfd_cli_path = ['protocols', 'bfd']
+ if conf.exists(bfd_cli_path):
+ bfd = conf.get_config_dict(bfd_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True)
+ dict.update({'bfd' : bfd})
+
+ # We need to check the CLI if the BGP node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ bgp_cli_path = ['protocols', 'bgp']
+ if conf.exists(bgp_cli_path):
+ bgp = conf.get_config_dict(bgp_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True)
+ bgp['dependent_vrfs'] = {}
+ dict.update({'bgp' : bgp})
+ elif conf.exists_effective(bgp_cli_path):
+ dict.update({'bgp' : {'deleted' : '', 'dependent_vrfs' : {}}})
+
+ # We need to check the CLI if the EIGRP node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ eigrp_cli_path = ['protocols', 'eigrp']
+ if conf.exists(eigrp_cli_path):
+ eigrp = conf.get_config_dict(eigrp_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True)
+ dict.update({'eigrp' : eigrp})
+ elif conf.exists_effective(eigrp_cli_path):
+ dict.update({'eigrp' : {'deleted' : ''}})
+
+ # We need to check the CLI if the ISIS node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ isis_cli_path = ['protocols', 'isis']
+ if conf.exists(isis_cli_path):
+ isis = conf.get_config_dict(isis_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True)
+ dict.update({'isis' : isis})
+ elif conf.exists_effective(isis_cli_path):
+ dict.update({'isis' : {'deleted' : ''}})
+
+ # We need to check the CLI if the MPLS node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ mpls_cli_path = ['protocols', 'mpls']
+ if conf.exists(mpls_cli_path):
+ mpls = conf.get_config_dict(mpls_cli_path, key_mangling=('-', '_'),
+ get_first_key=True)
+ dict.update({'mpls' : mpls})
+ elif conf.exists_effective(mpls_cli_path):
+ dict.update({'mpls' : {'deleted' : ''}})
+
+ # We need to check the CLI if the OPENFABRIC node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ openfabric_cli_path = ['protocols', 'openfabric']
+ if conf.exists(openfabric_cli_path):
+ openfabric = conf.get_config_dict(openfabric_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ dict.update({'openfabric' : openfabric})
+ elif conf.exists_effective(openfabric_cli_path):
+ dict.update({'openfabric' : {'deleted' : ''}})
+
+ # We need to check the CLI if the OSPF node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ ospf_cli_path = ['protocols', 'ospf']
+ if conf.exists(ospf_cli_path):
+ ospf = conf.get_config_dict(ospf_cli_path, key_mangling=('-', '_'),
+ get_first_key=True)
+ ospf = dict_helper_ospf_defaults(ospf, ospf_cli_path)
+ dict.update({'ospf' : ospf})
+ elif conf.exists_effective(ospf_cli_path):
+ dict.update({'ospf' : {'deleted' : ''}})
+
+ # We need to check the CLI if the OSPFv3 node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ ospfv3_cli_path = ['protocols', 'ospfv3']
+ if conf.exists(ospfv3_cli_path):
+ ospfv3 = conf.get_config_dict(ospfv3_cli_path, key_mangling=('-', '_'),
+ get_first_key=True)
+ ospfv3 = dict_helper_ospfv3_defaults(ospfv3, ospfv3_cli_path)
+ dict.update({'ospfv3' : ospfv3})
+ elif conf.exists_effective(ospfv3_cli_path):
+ dict.update({'ospfv3' : {'deleted' : ''}})
+
+ # We need to check the CLI if the PIM node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ pim_cli_path = ['protocols', 'pim']
+ if conf.exists(pim_cli_path):
+ pim = conf.get_config_dict(pim_cli_path, key_mangling=('-', '_'),
+ get_first_key=True)
+ pim = dict_helper_pim_defaults(pim, pim_cli_path)
+ dict.update({'pim' : pim})
+ elif conf.exists_effective(pim_cli_path):
+ dict.update({'pim' : {'deleted' : ''}})
+
+ # We need to check the CLI if the PIM6 node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ pim6_cli_path = ['protocols', 'pim6']
+ if conf.exists(pim6_cli_path):
+ pim6 = conf.get_config_dict(pim6_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+ dict.update({'pim6' : pim6})
+ elif conf.exists_effective(pim6_cli_path):
+ dict.update({'pim6' : {'deleted' : ''}})
+
+ # We need to check the CLI if the RIP node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ rip_cli_path = ['protocols', 'rip']
+ if conf.exists(rip_cli_path):
+ rip = conf.get_config_dict(rip_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+ dict.update({'rip' : rip})
+ elif conf.exists_effective(rip_cli_path):
+ dict.update({'rip' : {'deleted' : ''}})
+
+ # We need to check the CLI if the RIPng node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ ripng_cli_path = ['protocols', 'ripng']
+ if conf.exists(ripng_cli_path):
+ ripng = conf.get_config_dict(ripng_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ with_recursive_defaults=True)
+ dict.update({'ripng' : ripng})
+ elif conf.exists_effective(ripng_cli_path):
+ dict.update({'ripng' : {'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_cli_path = ['protocols', 'rpki']
+ if conf.exists(rpki_cli_path):
+ rpki = conf.get_config_dict(rpki_cli_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}'
+ dict.update({'rpki' : rpki})
+ elif conf.exists_effective(rpki_cli_path):
+ dict.update({'rpki' : {'deleted' : ''}})
+
+ # We need to check the CLI if the Segment Routing node is present and thus load in
+ # all the default values present on the CLI - that's why we have if conf.exists()
+ sr_cli_path = ['protocols', 'segment-routing']
+ if conf.exists(sr_cli_path):
+ sr = conf.get_config_dict(sr_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True)
+ dict.update({'segment_routing' : sr})
+ elif conf.exists_effective(sr_cli_path):
+ dict.update({'segment_routing' : {'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_cli_path = ['protocols', 'static']
+ if conf.exists(static_cli_path):
+ static = conf.get_config_dict(static_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ dict.update({'static' : static})
+ elif conf.exists_effective(static_cli_path):
+ dict.update({'static' : {'deleted' : ''}})
+
+ # We need to check the CLI if the NHRP node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ nhrp_cli_path = ['protocols', 'nhrp']
+ if conf.exists(nhrp_cli_path):
+ nhrp = conf.get_config_dict(nhrp_cli_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ nhrp = dict_helper_nhrp_defaults(nhrp)
+ dict.update({'nhrp' : nhrp})
+ elif conf.exists_effective(nhrp_cli_path):
+ dict.update({'nhrp' : {'deleted' : ''}})
+
+ # T3680 - get a list of all interfaces currently configured to use DHCP
+ tmp = get_dhcp_interfaces(conf)
+ if tmp:
+ if 'static' in dict:
+ dict['static'].update({'dhcp' : tmp})
+ else:
+ dict.update({'static' : {'dhcp' : tmp}})
+ tmp = get_pppoe_interfaces(conf)
+ if tmp:
+ if 'static' in dict:
+ dict['static'].update({'pppoe' : tmp})
+ else:
+ dict.update({'static' : {'pppoe' : tmp}})
+
+ # keep a re-usable list of dependent VRFs
+ dependent_vrfs_default = {}
+ if 'bgp' in dict:
+ dependent_vrfs_default = deepcopy(dict['bgp'])
+ # we do not need to nest the 'dependent_vrfs' key - simply remove it
+ if 'dependent_vrfs' in dependent_vrfs_default:
+ del dependent_vrfs_default['dependent_vrfs']
+
+ vrf_cli_path = ['vrf', 'name']
+ if conf.exists(vrf_cli_path):
+ vrf = conf.get_config_dict(vrf_cli_path, key_mangling=('-', '_'),
+ get_first_key=False,
+ no_tag_node_value_mangle=True)
+ # We do not have any VRF related default values on the CLI. The defaults will only
+ # come into place under the protocols tree, thus we can safely merge them with the
+ # appropriate routing protocols
+ for vrf_name, vrf_config in vrf['name'].items():
+ bgp_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'bgp']
+ if 'bgp' in vrf_config.get('protocols', []):
+ # We have gathered the dict representation of the CLI, but there are default
+ # options which we need to update into the dictionary retrived.
+ default_values = conf.get_config_defaults(bgp_vrf_path, key_mangling=('-', '_'),
+ get_first_key=True, recursive=True)
+
+ # merge in remaining default values
+ vrf_config['protocols']['bgp'] = config_dict_merge(default_values,
+ vrf_config['protocols']['bgp'])
+
+ # Add this BGP VRF instance as dependency into the default VRF
+ if 'bgp' in dict:
+ dict['bgp']['dependent_vrfs'].update({vrf_name : deepcopy(vrf_config)})
+
+ vrf_config['protocols']['bgp']['dependent_vrfs'] = conf.get_config_dict(
+ vrf_cli_path, key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+
+ # We can safely delete ourself from the dependent VRF list
+ if vrf_name in vrf_config['protocols']['bgp']['dependent_vrfs']:
+ del vrf_config['protocols']['bgp']['dependent_vrfs'][vrf_name]
+
+ # Add dependency on possible existing default VRF to this VRF
+ if 'bgp' in dict:
+ vrf_config['protocols']['bgp']['dependent_vrfs'].update({'default': {'protocols': {
+ 'bgp': dependent_vrfs_default}}})
+ elif conf.exists_effective(bgp_vrf_path):
+ # Add this BGP VRF instance as dependency into the default VRF
+ tmp = {'deleted' : '', 'dependent_vrfs': deepcopy(vrf['name'])}
+ # We can safely delete ourself from the dependent VRF list
+ if vrf_name in tmp['dependent_vrfs']:
+ del tmp['dependent_vrfs'][vrf_name]
+
+ # Add dependency on possible existing default VRF to this VRF
+ if 'bgp' in dict:
+ tmp['dependent_vrfs'].update({'default': {'protocols': {
+ 'bgp': dependent_vrfs_default}}})
+
+ if 'bgp' in dict:
+ dict['bgp']['dependent_vrfs'].update({vrf_name : {'protocols': tmp} })
+
+ if 'protocols' not in vrf['name'][vrf_name]:
+ vrf['name'][vrf_name].update({'protocols': {'bgp' : tmp}})
+ else:
+ vrf['name'][vrf_name]['protocols'].update({'bgp' : tmp})
+
+ # We need to check the CLI if the EIGRP node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ eigrp_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'eigrp']
+ if 'eigrp' in vrf_config.get('protocols', []):
+ eigrp = conf.get_config_dict(eigrp_vrf_path, key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True)
+ vrf['name'][vrf_name]['protocols'].update({'eigrp' : isis})
+ elif conf.exists_effective(eigrp_vrf_path):
+ vrf['name'][vrf_name]['protocols'].update({'eigrp' : {'deleted' : ''}})
+
+ # We need to check the CLI if the ISIS node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ isis_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'isis']
+ if 'isis' in vrf_config.get('protocols', []):
+ isis = conf.get_config_dict(isis_vrf_path, key_mangling=('-', '_'), get_first_key=True,
+ no_tag_node_value_mangle=True, with_recursive_defaults=True)
+ vrf['name'][vrf_name]['protocols'].update({'isis' : isis})
+ elif conf.exists_effective(isis_vrf_path):
+ vrf['name'][vrf_name]['protocols'].update({'isis' : {'deleted' : ''}})
+
+ # We need to check the CLI if the OSPF node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ ospf_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'ospf']
+ if 'ospf' in vrf_config.get('protocols', []):
+ ospf = conf.get_config_dict(ospf_vrf_path, key_mangling=('-', '_'), get_first_key=True)
+ ospf = dict_helper_ospf_defaults(vrf_config['protocols']['ospf'], ospf_vrf_path)
+ vrf['name'][vrf_name]['protocols'].update({'ospf' : ospf})
+ elif conf.exists_effective(ospf_vrf_path):
+ vrf['name'][vrf_name]['protocols'].update({'ospf' : {'deleted' : ''}})
+
+ # We need to check the CLI if the OSPFv3 node is present and thus load in all the default
+ # values present on the CLI - that's why we have if conf.exists()
+ ospfv3_vrf_path = ['vrf', 'name', vrf_name, 'protocols', 'ospfv3']
+ if 'ospfv3' in vrf_config.get('protocols', []):
+ ospfv3 = conf.get_config_dict(ospfv3_vrf_path, key_mangling=('-', '_'), get_first_key=True)
+ ospfv3 = dict_helper_ospfv3_defaults(vrf_config['protocols']['ospfv3'], ospfv3_vrf_path)
+ vrf['name'][vrf_name]['protocols'].update({'ospfv3' : ospfv3})
+ elif conf.exists_effective(ospfv3_vrf_path):
+ vrf['name'][vrf_name]['protocols'].update({'ospfv3' : {'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']
+ if 'static' in vrf_config.get('protocols', []):
+ static = conf.get_config_dict(static_vrf_path, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ # T3680 - get a list of all interfaces currently configured to use DHCP
+ tmp = get_dhcp_interfaces(conf, vrf_name)
+ if tmp: static.update({'dhcp' : tmp})
+ tmp = get_pppoe_interfaces(conf, vrf_name)
+ if tmp: static.update({'pppoe' : tmp})
+
+ vrf['name'][vrf_name]['protocols'].update({'static': static})
+ elif conf.exists_effective(static_vrf_path):
+ vrf['name'][vrf_name]['protocols'].update({'static': {'deleted' : ''}})
+
+ vrf_vni_path = ['vrf', 'name', vrf_name, 'vni']
+ if conf.exists(vrf_vni_path):
+ vrf_config.update({'vni': conf.return_value(vrf_vni_path)})
+
+ dict.update({'vrf' : vrf})
+ elif conf.exists_effective(vrf_cli_path):
+ effective_vrf = conf.get_config_dict(vrf_cli_path, key_mangling=('-', '_'),
+ get_first_key=False,
+ no_tag_node_value_mangle=True,
+ effective=True)
+ vrf = {'name' : {}}
+ for vrf_name, vrf_config in effective_vrf.get('name', {}).items():
+ vrf['name'].update({vrf_name : {}})
+ for protocol in frr_protocols:
+ if protocol in vrf_config.get('protocols', []):
+ # Create initial protocols key if not present
+ if 'protocols' not in vrf['name'][vrf_name]:
+ vrf['name'][vrf_name].update({'protocols' : {}})
+ # All routing protocols are deleted when we pass this point
+ tmp = {'deleted' : ''}
+
+ # Special treatment for BGP routing protocol
+ if protocol == 'bgp':
+ tmp['dependent_vrfs'] = {}
+ if 'name' in vrf:
+ tmp['dependent_vrfs'] = conf.get_config_dict(
+ vrf_cli_path, key_mangling=('-', '_'),
+ get_first_key=True, no_tag_node_value_mangle=True,
+ effective=True)
+ # Add dependency on possible existing default VRF to this VRF
+ if 'bgp' in dict:
+ tmp['dependent_vrfs'].update({'default': {'protocols': {
+ 'bgp': dependent_vrfs_default}}})
+ # We can safely delete ourself from the dependent VRF list
+ if vrf_name in tmp['dependent_vrfs']:
+ del tmp['dependent_vrfs'][vrf_name]
+
+ # Update VRF related dict
+ vrf['name'][vrf_name]['protocols'].update({protocol : tmp})
+
+ 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
+
+class FRRender:
+ cached_config_dict = {}
+ def __init__(self):
+ self._frr_conf = '/run/frr/config/vyos.frr.conf'
+
+ def generate(self, config_dict) -> None:
+ """
+ Generate FRR configuration file
+ Returns False if no changes to configuration were made, otherwise True
+ """
+ if not isinstance(config_dict, dict):
+ tmp = type(config_dict)
+ raise ValueError(f'Config must be of type "dict" and not "{tmp}"!')
+
+
+ if self.cached_config_dict == config_dict:
+ debug('FRR: NO CHANGES DETECTED')
+ return False
+ self.cached_config_dict = config_dict
+
+ def inline_helper(config_dict) -> str:
+ output = '!\n'
+ if 'babel' in config_dict and 'deleted' not in config_dict['babel']:
+ output += render_to_string('frr/babeld.frr.j2', config_dict['babel'])
+ output += '\n'
+ if 'bfd' in config_dict and 'deleted' not in config_dict['bfd']:
+ output += render_to_string('frr/bfdd.frr.j2', config_dict['bfd'])
+ output += '\n'
+ if 'bgp' in config_dict and 'deleted' not in config_dict['bgp']:
+ output += render_to_string('frr/bgpd.frr.j2', config_dict['bgp'])
+ output += '\n'
+ if 'eigrp' in config_dict and 'deleted' not in config_dict['eigrp']:
+ output += render_to_string('frr/eigrpd.frr.j2', config_dict['eigrp'])
+ output += '\n'
+ if 'isis' in config_dict and 'deleted' not in config_dict['isis']:
+ output += render_to_string('frr/isisd.frr.j2', config_dict['isis'])
+ output += '\n'
+ if 'mpls' in config_dict and 'deleted' not in config_dict['mpls']:
+ output += render_to_string('frr/ldpd.frr.j2', config_dict['mpls'])
+ output += '\n'
+ if 'openfabric' in config_dict and 'deleted' not in config_dict['openfabric']:
+ output += render_to_string('frr/fabricd.frr.j2', config_dict['openfabric'])
+ output += '\n'
+ if 'ospf' in config_dict and 'deleted' not in config_dict['ospf']:
+ output += render_to_string('frr/ospfd.frr.j2', config_dict['ospf'])
+ output += '\n'
+ if 'ospfv3' in config_dict and 'deleted' not in config_dict['ospfv3']:
+ output += render_to_string('frr/ospf6d.frr.j2', config_dict['ospfv3'])
+ output += '\n'
+ if 'pim' in config_dict and 'deleted' not in config_dict['pim']:
+ output += render_to_string('frr/pimd.frr.j2', config_dict['pim'])
+ output += '\n'
+ if 'pim6' in config_dict and 'deleted' not in config_dict['pim6']:
+ output += render_to_string('frr/pim6d.frr.j2', config_dict['pim6'])
+ output += '\n'
+ if 'policy' in config_dict and len(config_dict['policy']) > 0:
+ output += render_to_string('frr/policy.frr.j2', config_dict['policy'])
+ output += '\n'
+ if 'rip' in config_dict and 'deleted' not in config_dict['rip']:
+ output += render_to_string('frr/ripd.frr.j2', config_dict['rip'])
+ output += '\n'
+ if 'ripng' in config_dict and 'deleted' not in config_dict['ripng']:
+ 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 += '\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'])
+ output += '\n'
+ if 'static' in config_dict and 'deleted' not in config_dict['static']:
+ output += render_to_string('frr/staticd.frr.j2', config_dict['static'])
+ output += '\n'
+ if 'ip' in config_dict and 'deleted' not in config_dict['ip']:
+ output += render_to_string('frr/zebra.route-map.frr.j2', config_dict['ip'])
+ output += '\n'
+ if 'ipv6' in config_dict and 'deleted' not in config_dict['ipv6']:
+ output += render_to_string('frr/zebra.route-map.frr.j2', config_dict['ipv6'])
+ output += '\n'
+ if 'nhrp' in config_dict and 'deleted' not in config_dict['nhrp']:
+ output += render_to_string('frr/nhrpd.frr.j2', config_dict['nhrp'])
+ output += '\n'
+ return output
+
+ 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:
+ output += 'agentx\n'
+ # Add routing protocols in global VRF
+ output += inline_helper(config_dict)
+ # Interface configuration for EVPN is not VRF related
+ if 'interfaces' in config_dict:
+ output += render_to_string('frr/evpn.mh.frr.j2', {'interfaces' : config_dict['interfaces']})
+ output += '\n'
+
+ if 'vrf' in config_dict and 'name' in config_dict['vrf']:
+ output += render_to_string('frr/zebra.vrf.route-map.frr.j2', config_dict['vrf'])
+ for vrf, vrf_config in config_dict['vrf']['name'].items():
+ if 'protocols' not in vrf_config:
+ continue
+ for protocol in vrf_config['protocols']:
+ vrf_config['protocols'][protocol]['vrf'] = vrf
+
+ output += inline_helper(vrf_config['protocols'])
+
+ # remove any accidently added empty newline to not confuse FRR
+ output = os.linesep.join([s for s in output.splitlines() if s])
+
+ if '!!' in output:
+ raise ConfigError('FRR configuration contains "!!" which is not allowed')
+
+ debug(output)
+ write_file(self._frr_conf, output)
+ debug('FRR: RENDERING CONFIG COMPLETE')
+ return True
+
+ def apply(self, count_max=5):
+ count = 0
+ emsg = ''
+ while count < count_max:
+ count += 1
+ debug(f'FRR: reloading configuration - tries: {count} | Python class ID: {id(self)}')
+ cmdline = '/usr/lib/frr/frr-reload.py --reload'
+ if os.path.exists(frr_debug_enable):
+ cmdline += ' --debug --stdout'
+ rc, emsg = rc_cmd(f'{cmdline} {self._frr_conf}')
+ if rc != 0:
+ sleep(2)
+ continue
+ debug(emsg)
+ debug('FRR: configuration reload complete')
+ break
+
+ if count >= count_max:
+ raise ConfigError(emsg)
+
+ # T3217: Save FRR configuration to /run/frr/config/frr.conf
+ return cmd('/usr/bin/vtysh -n --writeconfig')
diff --git a/python/vyos/ifconfig/bond.py b/python/vyos/ifconfig/bond.py
index b8ea90049..a659b9bd2 100644
--- a/python/vyos/ifconfig/bond.py
+++ b/python/vyos/ifconfig/bond.py
@@ -31,7 +31,6 @@ class BondIf(Interface):
monitoring may be performed.
"""
- iftype = 'bond'
definition = {
**Interface.definition,
** {
@@ -109,6 +108,9 @@ class BondIf(Interface):
]
return options
+ def _create(self):
+ super()._create('bond')
+
def remove(self):
"""
Remove interface from operating system. Removing the interface
@@ -504,3 +506,6 @@ class BondIf(Interface):
# call base class first
super().update(config)
+
+ # enable/disable EAPoL (Extensible Authentication Protocol over Local Area Network)
+ self.set_eapol()
diff --git a/python/vyos/ifconfig/bridge.py b/python/vyos/ifconfig/bridge.py
index 917f962b7..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
@@ -32,7 +32,6 @@ class BridgeIf(Interface):
The Linux bridge code implements a subset of the ANSI/IEEE 802.1d standard.
"""
- iftype = 'bridge'
definition = {
**Interface.definition,
**{
@@ -107,6 +106,9 @@ class BridgeIf(Interface):
},
}}
+ def _create(self):
+ super()._create('bridge')
+
def get_vlan_filter(self):
"""
Get the status of the bridge VLAN filter
@@ -378,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']
@@ -387,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):
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/dummy.py b/python/vyos/ifconfig/dummy.py
index d45769931..29a1965a3 100644
--- a/python/vyos/ifconfig/dummy.py
+++ b/python/vyos/ifconfig/dummy.py
@@ -22,8 +22,6 @@ class DummyIf(Interface):
interface. The purpose of a dummy interface is to provide a device to route
packets through without actually transmitting them.
"""
-
- iftype = 'dummy'
definition = {
**Interface.definition,
**{
@@ -31,3 +29,6 @@ class DummyIf(Interface):
'prefixes': ['dum', ],
},
}
+
+ def _create(self):
+ super()._create('dummy')
diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py
index 8d96c863f..93727bdf6 100644
--- a/python/vyos/ifconfig/ethernet.py
+++ b/python/vyos/ifconfig/ethernet.py
@@ -26,12 +26,13 @@ from vyos.utils.file import read_file
from vyos.utils.process import run
from vyos.utils.assertion import assert_list
+
@Interface.register
class EthernetIf(Interface):
"""
Abstraction of a Linux Ethernet Interface
"""
- iftype = 'ethernet'
+
definition = {
**Interface.definition,
**{
@@ -41,7 +42,7 @@ class EthernetIf(Interface):
'broadcast': True,
'bridgeable': True,
'eternal': '(lan|eth|eno|ens|enp|enx)[0-9]+$',
- }
+ },
}
@staticmethod
@@ -49,32 +50,35 @@ class EthernetIf(Interface):
run(f'ethtool --features {ifname} {option} {value}')
return False
- _command_set = {**Interface._command_set, **{
- 'gro': {
- 'validate': lambda v: assert_list(v, ['on', 'off']),
- 'possible': lambda i, v: EthernetIf.feature(i, 'gro', v),
- },
- 'gso': {
- 'validate': lambda v: assert_list(v, ['on', 'off']),
- 'possible': lambda i, v: EthernetIf.feature(i, 'gso', v),
- },
- 'hw-tc-offload': {
- 'validate': lambda v: assert_list(v, ['on', 'off']),
- 'possible': lambda i, v: EthernetIf.feature(i, 'hw-tc-offload', v),
- },
- 'lro': {
- 'validate': lambda v: assert_list(v, ['on', 'off']),
- 'possible': lambda i, v: EthernetIf.feature(i, 'lro', v),
- },
- 'sg': {
- 'validate': lambda v: assert_list(v, ['on', 'off']),
- 'possible': lambda i, v: EthernetIf.feature(i, 'sg', v),
- },
- 'tso': {
- 'validate': lambda v: assert_list(v, ['on', 'off']),
- 'possible': lambda i, v: EthernetIf.feature(i, 'tso', v),
+ _command_set = {
+ **Interface._command_set,
+ **{
+ 'gro': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'possible': lambda i, v: EthernetIf.feature(i, 'gro', v),
+ },
+ 'gso': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'possible': lambda i, v: EthernetIf.feature(i, 'gso', v),
+ },
+ 'hw-tc-offload': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'possible': lambda i, v: EthernetIf.feature(i, 'hw-tc-offload', v),
+ },
+ 'lro': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'possible': lambda i, v: EthernetIf.feature(i, 'lro', v),
+ },
+ 'sg': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'possible': lambda i, v: EthernetIf.feature(i, 'sg', v),
+ },
+ 'tso': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'possible': lambda i, v: EthernetIf.feature(i, 'tso', v),
+ },
},
- }}
+ }
@staticmethod
def get_bond_member_allowed_options() -> list:
@@ -106,7 +110,7 @@ class EthernetIf(Interface):
'ring_buffer.rx',
'ring_buffer.tx',
'speed',
- 'hw_id'
+ 'hw_id',
]
return bond_allowed_sections
@@ -114,6 +118,9 @@ class EthernetIf(Interface):
super().__init__(ifname, **kargs)
self.ethtool = Ethtool(ifname)
+ def _create(self):
+ pass
+
def remove(self):
"""
Remove interface from config. Removing the interface deconfigures all
@@ -130,7 +137,11 @@ class EthernetIf(Interface):
self.set_admin_state('down')
# Remove all VLAN subinterfaces - filter with the VLAN dot
- for vlan in [x for x in Section.interfaces(self.iftype) if x.startswith(f'{self.ifname}.')]:
+ for vlan in [
+ x
+ for x in Section.interfaces('ethernet')
+ if x.startswith(f'{self.ifname}.')
+ ]:
Interface(vlan).remove()
super().remove()
@@ -149,10 +160,12 @@ class EthernetIf(Interface):
ifname = self.config['ifname']
if enable not in ['on', 'off']:
- raise ValueError("Value out of range")
+ raise ValueError('Value out of range')
if not self.ethtool.check_flow_control():
- self._debug_msg(f'NIC driver does not support changing flow control settings!')
+ self._debug_msg(
+ 'NIC driver does not support changing flow control settings!'
+ )
return False
current = self.ethtool.get_flow_control()
@@ -180,12 +193,24 @@ class EthernetIf(Interface):
"""
ifname = self.config['ifname']
- if speed not in ['auto', '10', '100', '1000', '2500', '5000', '10000',
- '25000', '40000', '50000', '100000', '400000']:
- raise ValueError("Value out of range (speed)")
+ if speed not in [
+ 'auto',
+ '10',
+ '100',
+ '1000',
+ '2500',
+ '5000',
+ '10000',
+ '25000',
+ '40000',
+ '50000',
+ '100000',
+ '400000',
+ ]:
+ raise ValueError('Value out of range (speed)')
if duplex not in ['auto', 'full', 'half']:
- raise ValueError("Value out of range (duplex)")
+ raise ValueError('Value out of range (duplex)')
if not self.ethtool.check_speed_duplex(speed, duplex):
Warning(f'changing speed/duplex setting on "{ifname}" is unsupported!')
@@ -224,7 +249,9 @@ class EthernetIf(Interface):
# but they do not actually support it either.
# In that case it's probably better to ignore the error
# than end up with a broken config.
- print('Warning: could not set speed/duplex settings: operation not permitted!')
+ print(
+ 'Warning: could not set speed/duplex settings: operation not permitted!'
+ )
def set_gro(self, state):
"""
@@ -243,7 +270,9 @@ class EthernetIf(Interface):
if not fixed:
return self.set_interface('gro', 'on' if state else 'off')
else:
- print('Adapter does not support changing generic-receive-offload settings!')
+ print(
+ 'Adapter does not support changing generic-receive-offload settings!'
+ )
return False
def set_gso(self, state):
@@ -262,7 +291,9 @@ class EthernetIf(Interface):
if not fixed:
return self.set_interface('gso', 'on' if state else 'off')
else:
- print('Adapter does not support changing generic-segmentation-offload settings!')
+ print(
+ 'Adapter does not support changing generic-segmentation-offload settings!'
+ )
return False
def set_hw_tc_offload(self, state):
@@ -300,7 +331,9 @@ class EthernetIf(Interface):
if not fixed:
return self.set_interface('lro', 'on' if state else 'off')
else:
- print('Adapter does not support changing large-receive-offload settings!')
+ print(
+ 'Adapter does not support changing large-receive-offload settings!'
+ )
return False
def set_rps(self, state):
@@ -310,13 +343,15 @@ class EthernetIf(Interface):
rps_cpus = 0
queues = len(glob(f'/sys/class/net/{self.ifname}/queues/rx-*'))
if state:
+ cpu_count = os.cpu_count()
+
# Enable RPS on all available CPUs except CPU0 which we will not
# utilize so the system has one spare core when it's under high
# preasure to server other means. Linux sysfs excepts a bitmask
# representation of the CPUs which should participate on RPS, we
# can enable more CPUs that are physically present on the system,
# Linux will clip that internally!
- rps_cpus = (1 << os.cpu_count()) -1
+ rps_cpus = (1 << cpu_count) - 1
# XXX: we should probably reserve one core when the system is under
# high preasure so we can still have a core left for housekeeping.
@@ -324,8 +359,21 @@ class EthernetIf(Interface):
# receive packet steering.
rps_cpus &= ~1
- for i in range(0, queues):
- self._write_sysfs(f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_cpus', f'{rps_cpus:x}')
+ # Convert the bitmask to hexadecimal chunks of 32 bits
+ # Split the bitmask into chunks of up to 32 bits each
+ hex_chunks = []
+ for i in range(0, cpu_count, 32):
+ # Extract the next 32-bit chunk
+ chunk = (rps_cpus >> i) & 0xFFFFFFFF
+ hex_chunks.append(f'{chunk:08x}')
+
+ # Join the chunks with commas
+ rps_cpus = ','.join(hex_chunks)
+
+ for i in range(queues):
+ self._write_sysfs(
+ f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_cpus', rps_cpus
+ )
# send bitmask representation as hex string without leading '0x'
return True
@@ -335,10 +383,13 @@ class EthernetIf(Interface):
queues = len(glob(f'/sys/class/net/{self.ifname}/queues/rx-*'))
if state:
global_rfs_flow = 32768
- rfs_flow = int(global_rfs_flow/queues)
+ rfs_flow = int(global_rfs_flow / queues)
for i in range(0, queues):
- self._write_sysfs(f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_flow_cnt', rfs_flow)
+ self._write_sysfs(
+ f'/sys/class/net/{self.ifname}/queues/rx-{i}/rps_flow_cnt',
+ rfs_flow,
+ )
return True
@@ -379,7 +430,9 @@ class EthernetIf(Interface):
if not fixed:
return self.set_interface('tso', 'on' if state else 'off')
else:
- print('Adapter does not support changing tcp-segmentation-offload settings!')
+ print(
+ 'Adapter does not support changing tcp-segmentation-offload settings!'
+ )
return False
def set_ring_buffer(self, rx_tx, size):
@@ -404,39 +457,64 @@ class EthernetIf(Interface):
print(f'could not set "{rx_tx}" ring-buffer for {ifname}')
return output
+ def set_switchdev(self, enable):
+ ifname = self.config['ifname']
+ addr, code = self._popen(
+ f"ethtool -i {ifname} | grep bus-info | awk '{{print $2}}'"
+ )
+ if code != 0:
+ print(f'could not resolve PCIe address of {ifname}')
+ return
+
+ enabled = False
+ state, code = self._popen(
+ f"/sbin/devlink dev eswitch show pci/{addr} | awk '{{print $3}}'"
+ )
+ if code == 0 and state == 'switchdev':
+ enabled = True
+
+ if enable and not enabled:
+ output, code = self._popen(
+ f'/sbin/devlink dev eswitch set pci/{addr} mode switchdev'
+ )
+ if code != 0:
+ print(f'{ifname} does not support switchdev mode')
+ elif not enable and enabled:
+ self._cmd(f'/sbin/devlink dev eswitch set pci/{addr} mode legacy')
+
def update(self, config):
- """ General helper function which works on a dictionary retrived by
+ """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. """
+ on any interface."""
# disable ethernet flow control (pause frames)
value = 'off' if 'disable_flow_control' in config else 'on'
self.set_flow_control(value)
# GRO (generic receive offload)
- self.set_gro(dict_search('offload.gro', config) != None)
+ self.set_gro(dict_search('offload.gro', config) is not None)
# GSO (generic segmentation offload)
- self.set_gso(dict_search('offload.gso', config) != None)
+ self.set_gso(dict_search('offload.gso', config) is not None)
# GSO (generic segmentation offload)
- self.set_hw_tc_offload(dict_search('offload.hw_tc_offload', config) != None)
+ self.set_hw_tc_offload(dict_search('offload.hw_tc_offload', config) is not None)
# LRO (large receive offload)
- self.set_lro(dict_search('offload.lro', config) != None)
+ self.set_lro(dict_search('offload.lro', config) is not None)
# RPS - Receive Packet Steering
- self.set_rps(dict_search('offload.rps', config) != None)
+ self.set_rps(dict_search('offload.rps', config) is not None)
# RFS - Receive Flow Steering
- self.set_rfs(dict_search('offload.rfs', config) != None)
+ self.set_rfs(dict_search('offload.rfs', config) is not None)
# scatter-gather option
- self.set_sg(dict_search('offload.sg', config) != None)
+ self.set_sg(dict_search('offload.sg', config) is not None)
# TSO (TCP segmentation offloading)
- self.set_tso(dict_search('offload.tso', config) != None)
+ self.set_tso(dict_search('offload.tso', config) is not None)
# Set physical interface speed and duplex
if 'speed_duplex_changed' in config:
@@ -450,5 +528,10 @@ class EthernetIf(Interface):
for rx_tx, size in config['ring_buffer'].items():
self.set_ring_buffer(rx_tx, size)
+ self.set_switchdev('switchdev' in config)
+
# call base class last
super().update(config)
+
+ # enable/disable EAPoL (Extensible Authentication Protocol over Local Area Network)
+ self.set_eapol()
diff --git a/python/vyos/ifconfig/geneve.py b/python/vyos/ifconfig/geneve.py
index fbb261a35..f53ef4166 100644
--- a/python/vyos/ifconfig/geneve.py
+++ b/python/vyos/ifconfig/geneve.py
@@ -27,7 +27,6 @@ class GeneveIf(Interface):
https://developers.redhat.com/blog/2019/05/17/an-introduction-to-linux-virtual-interfaces-tunnels/#geneve
https://lwn.net/Articles/644938/
"""
- iftype = 'geneve'
definition = {
**Interface.definition,
**{
@@ -49,7 +48,7 @@ class GeneveIf(Interface):
'parameters.ipv6.flowlabel' : 'flowlabel',
}
- cmd = 'ip link add name {ifname} type {type} 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/python/vyos/ifconfig/input.py b/python/vyos/ifconfig/input.py
index 3e5f5790d..201d3cacb 100644
--- a/python/vyos/ifconfig/input.py
+++ b/python/vyos/ifconfig/input.py
@@ -25,8 +25,6 @@ class InputIf(Interface):
a single stack of qdiscs, classes and filters can be shared between
multiple interfaces.
"""
-
- iftype = 'ifb'
definition = {
**Interface.definition,
**{
@@ -34,3 +32,6 @@ class InputIf(Interface):
'prefixes': ['ifb', ],
},
}
+
+ def _create(self):
+ super()._create('ifb')
diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py
index 31fcf6ca6..003a273c0 100644
--- a/python/vyos/ifconfig/interface.py
+++ b/python/vyos/ifconfig/interface.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2019-2025 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
@@ -22,12 +22,14 @@ 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
from netifaces import AF_INET6
+from netaddr import EUI
+from netaddr import mac_unix_expanded
-from vyos import ConfigError
from vyos.configdict import list_diff
from vyos.configdict import dict_merge
from vyos.configdict import get_vlan_ids
@@ -42,6 +44,7 @@ from vyos.template import render
from vyos.utils.network import mac2eui64
from vyos.utils.dict import dict_search
from vyos.utils.network import get_interface_config
+from vyos.utils.network import get_interface_address
from vyos.utils.network import get_interface_namespace
from vyos.utils.network import get_vrf_tableid
from vyos.utils.network import is_netns_interface
@@ -62,9 +65,6 @@ from vyos.ifconfig.vrrp import VRRP
from vyos.ifconfig.operational import Operational
from vyos.ifconfig import Section
-from netaddr import EUI
-from netaddr import mac_unix_expanded
-
link_local_prefix = 'fe80::/64'
class Interface(Control):
@@ -74,7 +74,6 @@ class Interface(Control):
OperationalClass = Operational
options = ['debug', 'create']
- required = []
default = {
'debug': True,
'create': True,
@@ -98,6 +97,10 @@ class Interface(Control):
'shellcmd': 'ip -json -detail link list dev {ifname}',
'format': lambda j: jmespath.search('[*].ifalias | [0]', json.loads(j)) or '',
},
+ 'ifindex': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].ifindex | [0]', json.loads(j)) or '',
+ },
'mac': {
'shellcmd': 'ip -json -detail link list dev {ifname}',
'format': lambda j: jmespath.search('[*].address | [0]', json.loads(j)),
@@ -332,22 +335,10 @@ class Interface(Control):
super().__init__(**kargs)
if not self.exists(ifname):
- # Any instance of Interface, such as Interface('eth0') can be used
- # safely to access the generic function in this class as 'type' is
- # unset, the class can not be created
- if not self.iftype:
- raise Exception(f'interface "{ifname}" not found')
- self.config['type'] = self.iftype
-
# Should an Instance of a child class (EthernetIf, DummyIf, ..)
# be required, then create should be set to False to not accidentally create it.
# In case a subclass does not define it, we use get to set the default to True
- if self.config.get('create',True):
- for k in self.required:
- if k not in kargs:
- name = self.default['type']
- raise ConfigError(f'missing required option {k} for {name} {ifname} creation')
-
+ if self.config.get('create', True):
self._create()
# If we can not connect to the interface then let the caller know
# as the class could not be correctly initialised
@@ -360,13 +351,14 @@ class Interface(Control):
self.operational = self.OperationalClass(ifname)
self.vrrp = VRRP(ifname)
- def _create(self):
+ def _create(self, type: str=''):
# Do not create interface that already exist or exists in netns
netns = self.config.get('netns', None)
if self.exists(f'{self.ifname}', netns=netns):
return
- cmd = 'ip link add dev {ifname} type {type}'.format(**self.config)
+ cmd = f'ip link add dev {self.ifname}'
+ if type: cmd += f' type {type}'
if 'netns' in self.config: cmd = f'ip netns exec {netns} {cmd}'
self._cmd(cmd)
@@ -428,6 +420,17 @@ class Interface(Control):
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):
+ """
+ Get interface index by name
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_ifindex()
+ '2'
+ """
+ return int(self.get_interface('ifindex'))
+
def get_min_mtu(self):
"""
Get hardware minimum supported MTU
@@ -593,12 +596,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
@@ -609,21 +616,33 @@ 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)
# 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):
"""
@@ -891,7 +910,11 @@ 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':
+ flushed = self.flush_ipv6_slaac_addrs()
+ self.flush_ipv6_slaac_routes(ra_addrs=flushed)
+ return rc
def add_ipv6_eui64_address(self, prefix):
"""
@@ -919,6 +942,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.
@@ -1179,7 +1216,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
@@ -1212,15 +1249,14 @@ 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)
+ 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}'
# Add broadcast address for IPv4
if is_ipv4(addr): tmp += ' brd +'
-
self._cmd(tmp)
else:
return False
@@ -1230,7 +1266,7 @@ class Interface(Control):
return True
- def del_addr(self, addr):
+ 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
@@ -1293,6 +1329,71 @@ class Interface(Control):
# flush all addresses
self._cmd(cmd)
+ 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 ''
+ tmp = get_interface_address(self.ifname)
+ if not tmp or '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}
+ flushed = []
+ 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']}"
+ 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.
+ """
+ # Find IPv6 connected prefixes for flushed SLAAC addresses
+ connected = []
+ for addr in ra_addrs if isinstance(ra_addrs, list) else []:
+ 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):
"""
Adds the interface to the bridge with the passed port config.
@@ -1303,8 +1404,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)
@@ -1320,7 +1419,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= []
@@ -1343,30 +1442,29 @@ 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):
+ def set_dhcp(self, enable: bool, vrf_changed: bool=False):
"""
Enable/Disable DHCP client on a given interface.
"""
if enable not in [True, False]:
raise ValueError()
- ifname = self.ifname
config_base = directories['isc_dhclient_dir'] + '/dhclient'
- dhclient_config_file = f'{config_base}_{ifname}.conf'
- dhclient_lease_file = f'{config_base}_{ifname}.leases'
- systemd_override_file = f'/run/systemd/system/dhclient@{ifname}.service.d/10-override.conf'
- systemd_service = f'dhclient@{ifname}.service'
+ dhclient_config_file = f'{config_base}_{self.ifname}.conf'
+ dhclient_lease_file = f'{config_base}_{self.ifname}.leases'
+ systemd_override_file = f'/run/systemd/system/dhclient@{self.ifname}.service.d/10-override.conf'
+ systemd_service = f'dhclient@{self.ifname}.service'
# Rendered client configuration files require the apsolute config path
self.config['isc_dhclient_dir'] = directories['isc_dhclient_dir']
@@ -1395,11 +1493,28 @@ 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):
self._cmd(f'systemctl stop {systemd_service}')
+
+ # Smoketests occationally 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',
+ # 'broadcast': 'ff:ff:ff:ff:ff:ff'}], 2: [{'addr': '192.0.2.103', 'netmask': '255.255.255.0',
+ #
+ # We will force removal of any dynamic IPv4 address from the interface
+ tmp = get_interface_address(self.ifname)
+ if tmp and 'addr_info' in tmp:
+ for address_dict in tmp['addr_info']:
+ # Only remove dynamic assigned addresses
+ if address_dict['family'] == 'inet' and 'dynamic' in address_dict:
+ address = address_dict['local']
+ prefixlen = address_dict['prefixlen']
+ self.del_addr(f'{address}/{prefixlen}')
+
# cleanup old config files
for file in [dhclient_config_file, systemd_override_file, dhclient_lease_file]:
if os.path.isfile(file):
@@ -1407,19 +1522,18 @@ 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.
"""
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)
@@ -1436,7 +1550,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}')
@@ -1653,30 +1770,31 @@ 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.
+ 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', ''))
+
+ # 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)
+ self.add_addr(addr, vrf_changed=vrf_changed)
# Configure MSS value for IPv4 TCP connections
tmp = dict_search('ip.adjust_mss', config)
@@ -1755,11 +1873,26 @@ 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'
- if 'dhcpv6' in new_addr:
- value = '2'
+ value = '2' if (tmp != None) else '0'
self.set_ipv6_accept_ra(value)
# IPv6 address autoconfiguration
@@ -1813,9 +1946,6 @@ class Interface(Control):
value = '1' if (tmp != None) else '0'
self.set_per_client_thread(value)
- # enable/disable EAPoL (Extensible Authentication Protocol over Local Area Network)
- self.set_eapol()
-
# Enable/Disable of an interface must always be done at the end of the
# derived class to make use of the ref-counting set_admin_state()
# function. We will only enable the interface if 'up' was called as
@@ -1926,8 +2056,6 @@ class Interface(Control):
class VLANIf(Interface):
""" Specific class which abstracts 802.1q and 802.1ad (Q-in-Q) VLAN interfaces """
- iftype = 'vlan'
-
def _create(self):
# bail out early if interface already exists
if self.exists(f'{self.ifname}'):
diff --git a/python/vyos/ifconfig/l2tpv3.py b/python/vyos/ifconfig/l2tpv3.py
index c1f2803ee..dfaa006aa 100644
--- a/python/vyos/ifconfig/l2tpv3.py
+++ b/python/vyos/ifconfig/l2tpv3.py
@@ -45,7 +45,6 @@ class L2TPv3If(Interface):
either hot standby or load balancing services. Additionally, link integrity
monitoring may be performed.
"""
- iftype = 'l2tp'
definition = {
**Interface.definition,
**{
diff --git a/python/vyos/ifconfig/loopback.py b/python/vyos/ifconfig/loopback.py
index e1d041839..13e8a2c50 100644
--- a/python/vyos/ifconfig/loopback.py
+++ b/python/vyos/ifconfig/loopback.py
@@ -22,16 +22,20 @@ class LoopbackIf(Interface):
uses to communicate with itself.
"""
_persistent_addresses = ['127.0.0.1/8', '::1/128']
- iftype = 'loopback'
definition = {
**Interface.definition,
**{
'section': 'loopback',
'prefixes': ['lo', ],
'bridgeable': True,
+ 'eternal': 'lo$',
}
}
+ def _create(self):
+ # we can not create this interface as it is managed by the Kernel
+ pass
+
def remove(self):
"""
Loopback interface can not be deleted from operating system. We can
diff --git a/python/vyos/ifconfig/macsec.py b/python/vyos/ifconfig/macsec.py
index 383905814..3b4dc223f 100644
--- a/python/vyos/ifconfig/macsec.py
+++ b/python/vyos/ifconfig/macsec.py
@@ -27,7 +27,6 @@ class MACsecIf(Interface):
other security solutions such as IPsec (layer 3) or TLS (layer 4), as all
those solutions are used for their own specific use cases.
"""
- iftype = 'macsec'
definition = {
**Interface.definition,
**{
@@ -43,7 +42,7 @@ class MACsecIf(Interface):
"""
# create tunnel interface
- cmd = 'ip link add link {source_interface} {ifname} type {type}'.format(**self.config)
+ cmd = 'ip link add link {source_interface} {ifname} type macsec'.format(**self.config)
cmd += f' cipher {self.config["security"]["cipher"]}'
if 'encrypt' in self.config["security"]:
diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py
index 2266879ec..fe948b920 100644
--- a/python/vyos/ifconfig/macvlan.py
+++ b/python/vyos/ifconfig/macvlan.py
@@ -1,4 +1,4 @@
-# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2019-2024 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
@@ -20,7 +20,6 @@ class MACVLANIf(Interface):
"""
Abstraction of a Linux MACvlan interface
"""
- iftype = 'macvlan'
definition = {
**Interface.definition,
**{
@@ -35,13 +34,12 @@ class MACVLANIf(Interface):
down by default.
"""
# please do not change the order when assembling the command
- cmd = 'ip link add {ifname} link {source_interface} type {type} mode {mode}'
+ cmd = 'ip link add {ifname} link {source_interface} type macvlan mode {mode}'
self._cmd(cmd.format(**self.config))
# interface is always A/D down. It needs to be enabled explicitly
self.set_admin_state('down')
def set_mode(self, mode):
- ifname = self.config['ifname']
- cmd = f'ip link set dev {ifname} type macvlan mode {mode}'
+ cmd = f'ip link set dev {self.ifname} type macvlan mode {mode}'
return self._cmd(cmd)
diff --git a/python/vyos/ifconfig/pppoe.py b/python/vyos/ifconfig/pppoe.py
index febf1452d..85ca3877e 100644
--- a/python/vyos/ifconfig/pppoe.py
+++ b/python/vyos/ifconfig/pppoe.py
@@ -19,7 +19,6 @@ from vyos.utils.network import get_interface_config
@Interface.register
class PPPoEIf(Interface):
- iftype = 'pppoe'
definition = {
**Interface.definition,
**{
@@ -115,14 +114,6 @@ class PPPoEIf(Interface):
# before this is done by the base class.
self._config = config
- # remove old routes from an e.g. old VRF assignment
- if 'shutdown_required':
- vrf = None
- tmp = get_interface_config(self.ifname)
- if 'master' in tmp:
- vrf = tmp['master']
- self._remove_routes(vrf)
-
# DHCPv6 PD handling is a bit different on PPPoE interfaces, as we do
# not require an 'address dhcpv6' CLI option as with other interfaces
if 'dhcpv6_options' in config and 'pd' in config['dhcpv6_options']:
diff --git a/python/vyos/ifconfig/sstpc.py b/python/vyos/ifconfig/sstpc.py
index 50fc6ee6b..d92ef23dc 100644
--- a/python/vyos/ifconfig/sstpc.py
+++ b/python/vyos/ifconfig/sstpc.py
@@ -17,7 +17,6 @@ from vyos.ifconfig.interface import Interface
@Interface.register
class SSTPCIf(Interface):
- iftype = 'sstpc'
definition = {
**Interface.definition,
**{
diff --git a/python/vyos/ifconfig/tunnel.py b/python/vyos/ifconfig/tunnel.py
index 9ba7b31a6..df904f7d5 100644
--- a/python/vyos/ifconfig/tunnel.py
+++ b/python/vyos/ifconfig/tunnel.py
@@ -90,9 +90,8 @@ class TunnelIf(Interface):
# T3357: we do not have the 'encapsulation' in kargs when calling this
# class from op-mode like "show interfaces tunnel"
if 'encapsulation' in kargs:
- self.iftype = kargs['encapsulation']
# The gretap interface has the possibility to act as L2 bridge
- if self.iftype in ['gretap', 'ip6gretap']:
+ if kargs['encapsulation'] in ['gretap', 'ip6gretap']:
# no multicast, ttl or tos for gretap
self.definition = {
**TunnelIf.definition,
@@ -110,10 +109,10 @@ class TunnelIf(Interface):
mapping = { **self.mapping, **self.mapping_ipv4 }
cmd = 'ip tunnel add {ifname} mode {encapsulation}'
- if self.iftype in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']:
+ if self.config['encapsulation'] in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']:
cmd = 'ip link add name {ifname} type {encapsulation}'
# ERSPAN requires the serialisation of packets
- if self.iftype in ['erspan', 'ip6erspan']:
+ if self.config['encapsulation'] in ['erspan', 'ip6erspan']:
cmd += ' seq'
for vyos_key, iproute2_key in mapping.items():
@@ -132,7 +131,7 @@ class TunnelIf(Interface):
def _change_options(self):
# gretap interfaces do not support changing any parameter
- if self.iftype in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']:
+ if self.config['encapsulation'] in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']:
return
if self.config['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre']:
diff --git a/python/vyos/ifconfig/veth.py b/python/vyos/ifconfig/veth.py
index aafbf226a..2c8709d20 100644
--- a/python/vyos/ifconfig/veth.py
+++ b/python/vyos/ifconfig/veth.py
@@ -21,7 +21,6 @@ class VethIf(Interface):
"""
Abstraction of a Linux veth interface
"""
- iftype = 'veth'
definition = {
**Interface.definition,
**{
@@ -46,7 +45,7 @@ class VethIf(Interface):
return
# create virtual-ethernet interface
- cmd = 'ip link add {ifname} type {type}'.format(**self.config)
+ cmd = f'ip link add {self.ifname} type veth'
cmd += f' peer name {self.config["peer_name"]}'
self._cmd(cmd)
diff --git a/python/vyos/ifconfig/vrrp.py b/python/vyos/ifconfig/vrrp.py
index ee9336d1a..3ee22706c 100644
--- a/python/vyos/ifconfig/vrrp.py
+++ b/python/vyos/ifconfig/vrrp.py
@@ -35,25 +35,25 @@ class VRRPNoData(VRRPError):
class VRRP(object):
_vrrp_prefix = '00:00:5E:00:01:'
location = {
- 'pid': '/run/keepalived/keepalived.pid',
- 'fifo': '/run/keepalived/keepalived_notify_fifo',
- 'state': '/tmp/keepalived.data',
- 'stats': '/tmp/keepalived.stats',
- 'json': '/tmp/keepalived.json',
- 'daemon': '/etc/default/keepalived',
- 'config': '/run/keepalived/keepalived.conf',
+ 'pid': '/run/keepalived/keepalived.pid',
+ 'fifo': '/run/keepalived/keepalived_notify_fifo',
+ 'state': '/tmp/keepalived.data',
+ 'stats': '/tmp/keepalived.stats',
+ 'json': '/tmp/keepalived.json',
+ 'daemon': '/etc/default/keepalived',
+ 'config': '/run/keepalived/keepalived.conf',
}
_signal = {
- 'state': signal.SIGUSR1,
- 'stats': signal.SIGUSR2,
- 'json': signal.SIGRTMIN + 2,
+ 'state': signal.SIGUSR1,
+ 'stats': signal.SIGUSR2,
+ 'json': signal.SIGRTMIN + 2,
}
_name = {
'state': 'information',
'stats': 'statistics',
- 'json': 'data',
+ 'json': 'data',
}
state = {
@@ -64,7 +64,7 @@ class VRRP(object):
# UNKNOWN
}
- def __init__(self,ifname):
+ def __init__(self, ifname):
self.ifname = ifname
def enabled(self):
@@ -79,7 +79,7 @@ class VRRP(object):
@classmethod
def decode_state(cls, code):
- return cls.state.get(code,'UNKNOWN')
+ return cls.state.get(code, 'UNKNOWN')
# used in conf mode
@classmethod
@@ -94,16 +94,20 @@ class VRRP(object):
try:
# send signal to generate the configuration file
pid = read_file(cls.location['pid'])
- wait_for_file_write_complete(fname,
- pre_hook=(lambda: os.kill(int(pid), cls._signal[what])),
- timeout=30)
+ wait_for_file_write_complete(
+ fname,
+ pre_hook=(lambda: os.kill(int(pid), cls._signal[what])),
+ timeout=30,
+ )
return read_file(fname)
+ except FileNotFoundError:
+ raise VRRPNoData(
+ 'VRRP data is not available (process not running or no active groups)'
+ )
except OSError:
# raised by vyos.utils.file.read_file
- raise VRRPNoData("VRRP data is not available (wait time exceeded)")
- except FileNotFoundError:
- raise VRRPNoData("VRRP data is not available (process not running or no active groups)")
+ raise VRRPNoData('VRRP data is not available (wait time exceeded)')
except Exception:
name = cls._name[what]
raise VRRPError(f'VRRP {name} is not available')
@@ -118,32 +122,41 @@ class VRRP(object):
conf = ConfigTreeQuery()
if conf.exists(base):
# Read VRRP configuration directly from CLI
- vrrp_config_dict = conf.get_config_dict(base, key_mangling=('-', '_'),
- get_first_key=True)
+ vrrp_config_dict = conf.get_config_dict(
+ base, key_mangling=('-', '_'), get_first_key=True
+ )
# add disabled groups to the list
if 'group' in vrrp_config_dict:
for group, group_config in vrrp_config_dict['group'].items():
if 'disable' not in group_config:
continue
- disabled.append([group, group_config['interface'], group_config['vrid'], 'DISABLED', ''])
+ disabled.append(
+ [
+ group,
+ group_config['interface'],
+ group_config['vrid'],
+ 'DISABLED',
+ '',
+ ]
+ )
# return list with disabled instances
return disabled
@classmethod
def format(cls, data):
- headers = ["Name", "Interface", "VRID", "State", "Priority", "Last Transition"]
+ headers = ['Name', 'Interface', 'VRID', 'State', 'Priority', 'Last Transition']
groups = []
- data = json.loads(data)
+ data = json.loads(data) if isinstance(data, str) else data
for group in data:
data = group['data']
name = data['iname']
intf = data['ifp_ifname']
vrid = data['vrid']
- state = cls.decode_state(data["state"])
+ state = cls.decode_state(data['state'])
priority = data['effective_priority']
since = int(time() - float(data['last_transition']))
@@ -153,4 +166,4 @@ class VRRP(object):
# add to the active list disabled instances
groups.extend(cls.disabled())
- return(tabulate(groups, headers))
+ return tabulate(groups, headers)
diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py
index 251cbeb36..78f5895f8 100644
--- a/python/vyos/ifconfig/vti.py
+++ b/python/vyos/ifconfig/vti.py
@@ -19,7 +19,6 @@ from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_re
@Interface.register
class VTIIf(Interface):
- iftype = 'vti'
definition = {
**Interface.definition,
**{
diff --git a/python/vyos/ifconfig/vtun.py b/python/vyos/ifconfig/vtun.py
index 6fb414e56..ee790f275 100644
--- a/python/vyos/ifconfig/vtun.py
+++ b/python/vyos/ifconfig/vtun.py
@@ -17,7 +17,6 @@ from vyos.ifconfig.interface import Interface
@Interface.register
class VTunIf(Interface):
- iftype = 'vtun'
definition = {
**Interface.definition,
**{
diff --git a/python/vyos/ifconfig/vxlan.py b/python/vyos/ifconfig/vxlan.py
index 1023c58d1..58844885b 100644
--- a/python/vyos/ifconfig/vxlan.py
+++ b/python/vyos/ifconfig/vxlan.py
@@ -42,8 +42,6 @@ class VXLANIf(Interface):
For more information please refer to:
https://www.kernel.org/doc/Documentation/networking/vxlan.txt
"""
-
- iftype = 'vxlan'
definition = {
**Interface.definition,
**{
@@ -94,7 +92,7 @@ class VXLANIf(Interface):
remote_list = self.config['remote'][1:]
self.config['remote'] = self.config['remote'][0]
- cmd = 'ip link add {ifname} type {type} dstport {port}'
+ cmd = 'ip link add {ifname} type vxlan 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/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py
index 5b5f25323..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 <maintainers@vyos.io>
+# Copyright 2019-2025 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
@@ -22,9 +22,11 @@ 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
+from vyos.template import is_ipv4
class WireGuardOperational(Operational):
def _dump(self):
@@ -54,7 +56,17 @@ class WireGuardOperational(Operational):
}
else:
# We are entering a peer
- device, public_key, preshared_key, endpoint, allowed_ips, latest_handshake, transfer_rx, transfer_tx, persistent_keepalive = items
+ (
+ device,
+ public_key,
+ preshared_key,
+ endpoint,
+ allowed_ips,
+ latest_handshake,
+ transfer_rx,
+ transfer_tx,
+ persistent_keepalive,
+ ) = items
if allowed_ips == '(none)':
allowed_ips = []
else:
@@ -72,156 +84,259 @@ class WireGuardOperational(Operational):
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"])
-
- answer = "interface: {}\n".format(self.config['ifname'])
- if (description):
- answer += " description: {}\n".format(description)
- if (ips):
- answer += " address: {}\n".format(", ".join(ips))
-
- 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"]):
+ 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"])
+ 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)
+ 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):
+ status = 'inactive'
+ if wgpeer['latest_handshake'] is None:
""" no handshake ever """
- status = "inactive"
+ 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)):
+ 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"
+ status = 'active'
else:
""" it's been longer than 5 minutes """
- status = "inactive"
+ status = 'inactive'
elif int(wgpeer['latest_handshake']) == 0:
""" no handshake ever """
- status = "inactive"
- answer += " status: {}\n".format(status)
+ status = 'inactive'
+ answer += ' status: {}\n'.format(status)
if wgpeer['endpoint'] is not None:
- answer += " endpoint: {}\n".format(wgpeer['endpoint'])
+ answer += ' endpoint: {}\n'.format(wgpeer['endpoint'])
if wgpeer['allowed_ips'] is not None:
- answer += " allowed ips: {}\n".format(
- ",".join(wgpeer['allowed_ips']).replace(",", ", "))
+ 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)
+ 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 += ' persistent keepalive: every {} seconds\n'.format(
+ wgpeer['persistent_keepalive']
+ )
answer += '\n'
- return answer + super().formated_stats()
+ 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):
+ c = ConfigTreeQuery()
+ 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, 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:
+ 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'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' 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 '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
+ 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':
+ tmp['max_dns_retry']})
+ print('done')
+ except:
+ print(f'Error\nPlease try to run command manually:\n{cmd}\n')
@Interface.register
class WireGuardIf(Interface):
OperationalClass = WireGuardOperational
- iftype = 'wireguard'
definition = {
**Interface.definition,
**{
'section': 'wireguard',
'prefixes': ['wg', ],
'bridgeable': False,
- }
+ },
}
+ def _create(self):
+ super()._create('wireguard')
+
def get_mac(self):
- """ Get a synthetic MAC address. """
+ """Get a synthetic MAC address."""
return self.get_mac_synthetic()
def update(self, config):
- """ General helper function which works on a dictionary retrived by
+ """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. """
-
+ 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 {ifname}'
+ base_cmd = f'wg set {self.ifname}'
+ 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}'
+
+ 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)
+
+ # 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'
- base_cmd += f' private-key {tmp_file.name}'
- base_cmd = base_cmd.format(**config)
if 'peer' in config:
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
- # 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': 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)
# call base class
super().update(config)
diff --git a/python/vyos/ifconfig/wireless.py b/python/vyos/ifconfig/wireless.py
index 88eaa772b..121f56bd5 100644
--- a/python/vyos/ifconfig/wireless.py
+++ b/python/vyos/ifconfig/wireless.py
@@ -20,7 +20,6 @@ class WiFiIf(Interface):
"""
Handle WIFI/WLAN interfaces.
"""
- iftype = 'wifi'
definition = {
**Interface.definition,
**{
diff --git a/python/vyos/ifconfig/wwan.py b/python/vyos/ifconfig/wwan.py
index 845c9bef9..004a64b39 100644
--- a/python/vyos/ifconfig/wwan.py
+++ b/python/vyos/ifconfig/wwan.py
@@ -17,7 +17,6 @@ from vyos.ifconfig.interface import Interface
@Interface.register
class WWANIf(Interface):
- iftype = 'wwan'
definition = {
**Interface.definition,
**{
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 <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/>.
+
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 <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/>.
+
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 <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/>.
+
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 <http://www.gnu.org/licenses/>.
+
+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 <http://www.gnu.org/licenses/>.
+
+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 <http://www.gnu.org/licenses/>.
+
+# 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 <http://www.gnu.org/licenses/>.
+
+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 <http://www.gnu.org/licenses/>.
+
+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 <http://www.gnu.org/licenses/>.
+
+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/python/vyos/kea.py b/python/vyos/kea.py
index addfdba49..5eecbbaad 100644
--- a/python/vyos/kea.py
+++ b/python/vyos/kea.py
@@ -1,4 +1,4 @@
-# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2023-2025 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
@@ -17,8 +17,11 @@ import json
import os
import socket
+from datetime import datetime
+from datetime import timezone
+
+from vyos import ConfigError
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
@@ -40,7 +43,8 @@ kea4_options = {
'time_offset': 'time-offset',
'wpad_url': 'wpad-url',
'ipv6_only_preferred': 'v6-only-preferred',
- 'captive_portal': 'v4-captive-portal'
+ 'captive_portal': 'v4-captive-portal',
+ 'capwap_controller': 'capwap-ac-v4',
}
kea6_options = {
@@ -52,11 +56,36 @@ kea6_options = {
'nisplus_domain': 'nisp-domain-name',
'nisplus_server': 'nisp-servers',
'sntp_server': 'sntp-servers',
- 'captive_portal': 'v6-captive-portal'
+ 'captive_portal': 'v6-captive-portal',
+ 'capwap_controller': 'capwap-ac-v6',
}
kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket'
+
+def _format_hex_string(in_str):
+ out_str = ''
+ # if input is divisible by 2, add : every 2 chars
+ if len(in_str) > 0 and len(in_str) % 2 == 0:
+ out_str = ':'.join(a + b for a, b in zip(in_str[::2], in_str[1::2]))
+ else:
+ out_str = in_str
+
+ return out_str
+
+
+def _find_list_of_dict_index(lst, key='ip', value=''):
+ """
+ Find the index entry of list of dict matching the dict value
+ Exampe:
+ % lst = [{'ip': '192.0.2.1'}, {'ip': '192.0.2.2'}]
+ % _find_list_of_dict_index(lst, key='ip', value='192.0.2.2')
+ % 1
+ """
+ idx = next((index for (index, d) in enumerate(lst) if d[key] == value), None)
+ return idx
+
+
def kea_parse_options(config):
options = []
@@ -64,46 +93,62 @@ def kea_parse_options(config):
if node not in config:
continue
- value = ", ".join(config[node]) if isinstance(config[node], list) else config[node]
+ value = (
+ ', '.join(config[node]) if isinstance(config[node], list) else config[node]
+ )
options.append({'name': option_name, 'data': value})
if 'client_prefix_length' in config:
- options.append({'name': 'subnet-mask', 'data': netmask_from_cidr('0.0.0.0/' + config['client_prefix_length'])})
+ options.append(
+ {
+ 'name': 'subnet-mask',
+ 'data': netmask_from_cidr('0.0.0.0/' + config['client_prefix_length']),
+ }
+ )
if 'ip_forwarding' in config:
- options.append({'name': 'ip-forwarding', 'data': "true"})
+ options.append({'name': 'ip-forwarding', 'data': 'true'})
if 'static_route' in config:
default_route = ''
if 'default_router' in config:
- default_route = isc_static_route('0.0.0.0/0', config['default_router'])
-
- routes = [isc_static_route(route, route_options['next_hop']) for route, route_options in config['static_route'].items()]
-
- options.append({'name': 'rfc3442-static-route', 'data': ", ".join(routes if not default_route else routes + [default_route])})
- options.append({'name': 'windows-static-route', 'data': ", ".join(routes)})
+ default_route = f'0.0.0.0/0 - {config["default_router"]}'
+
+ routes = [
+ f'{route} - {route_options["next_hop"]}'
+ for route, route_options in config['static_route'].items()
+ ]
+
+ options.append(
+ {
+ 'name': 'classless-static-route',
+ 'data': ', '.join(
+ routes if not default_route else routes + [default_route]
+ ),
+ }
+ )
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")
+ with open('/usr/share/zoneinfo/' + config['time_zone'], 'rb') as f:
+ tz_string = f.read().split(b'\n')[-2].decode('utf-8')
options.append({'name': 'pcode', 'data': tz_string})
options.append({'name': 'tcode', 'data': config['time_zone']})
- unifi_controller = dict_search_args(config, 'vendor_option', 'ubiquiti', 'unifi_controller')
+ unifi_controller = dict_search_args(
+ config, 'vendor_option', 'ubiquiti', 'unifi_controller'
+ )
if unifi_controller:
- options.append({
- 'name': 'unifi-controller',
- 'data': unifi_controller,
- 'space': 'ubnt'
- })
+ options.append(
+ {'name': 'unifi-controller', 'data': unifi_controller, 'space': 'ubnt'}
+ )
return options
+
def kea_parse_subnet(subnet, config):
- out = {'subnet': subnet, 'id': int(config['subnet_id'])}
- options = []
+ out = {'subnet': subnet, 'id': int(config['subnet_id']), 'user-context': {}}
if 'option' in config:
out['option-data'] = kea_parse_options(config['option'])
@@ -121,13 +166,14 @@ 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():
start, stop = range_config['start'], range_config['stop']
- pool = {
- 'pool': f'{start} - {stop}'
- }
+ pool = {'pool': f'{start} - {stop}'}
if 'option' in range_config:
pool['option-data'] = kea_parse_options(range_config['option'])
@@ -164,16 +210,24 @@ def kea_parse_subnet(subnet, config):
reservation['option-data'] = kea_parse_options(host_config['option'])
if 'bootfile_name' in host_config['option']:
- reservation['boot-file-name'] = host_config['option']['bootfile_name']
+ reservation['boot-file-name'] = host_config['option'][
+ 'bootfile_name'
+ ]
if 'bootfile_server' in host_config['option']:
- reservation['next-server'] = host_config['option']['bootfile_server']
+ reservation['next-server'] = host_config['option'][
+ 'bootfile_server'
+ ]
reservations.append(reservation)
out['reservations'] = reservations
+ if 'dynamic_dns_update' in config:
+ out.update(kea_parse_ddns_settings(config['dynamic_dns_update']))
+
return out
+
def kea6_parse_options(config):
options = []
@@ -181,7 +235,9 @@ def kea6_parse_options(config):
if node not in config:
continue
- value = ", ".join(config[node]) if isinstance(config[node], list) else config[node]
+ value = (
+ ', '.join(config[node]) if isinstance(config[node], list) else config[node]
+ )
options.append({'name': option_name, 'data': value})
if 'sip_server' in config:
@@ -197,17 +253,20 @@ def kea6_parse_options(config):
hosts.append(server)
if addrs:
- options.append({'name': 'sip-server-addr', 'data': ", ".join(addrs)})
+ options.append({'name': 'sip-server-addr', 'data': ', '.join(addrs)})
if hosts:
- options.append({'name': 'sip-server-dns', 'data': ", ".join(hosts)})
+ options.append({'name': 'sip-server-dns', 'data': ', '.join(hosts)})
cisco_tftp = dict_search_args(config, 'vendor_option', 'cisco', 'tftp-server')
if cisco_tftp:
- options.append({'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp})
+ options.append(
+ {'name': 'tftp-servers', 'code': 2, 'space': 'cisco', 'data': cisco_tftp}
+ )
return options
+
def kea6_parse_subnet(subnet, config):
out = {'subnet': subnet, 'id': int(config['subnet_id'])}
@@ -245,12 +304,14 @@ def kea6_parse_subnet(subnet, config):
pd_pool = {
'prefix': prefix,
'prefix-len': int(pd_conf['prefix_length']),
- 'delegated-len': int(pd_conf['delegated_length'])
+ 'delegated-len': int(pd_conf['delegated_length']),
}
if 'excluded_prefix' in pd_conf:
pd_pool['excluded-prefix'] = pd_conf['excluded_prefix']
- pd_pool['excluded-prefix-len'] = int(pd_conf['excluded_prefix_length'])
+ pd_pool['excluded-prefix-len'] = int(
+ pd_conf['excluded_prefix_length']
+ )
pd_pools.append(pd_pool)
@@ -270,9 +331,7 @@ def kea6_parse_subnet(subnet, config):
if 'disable' in host_config:
continue
- reservation = {
- 'hostname': host
- }
+ reservation = {'hostname': host}
if 'mac' in host_config:
reservation['hw-address'] = host_config['mac']
@@ -281,10 +340,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'])
@@ -295,6 +354,55 @@ 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)
@@ -321,6 +429,7 @@ 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')
@@ -329,6 +438,42 @@ 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}
@@ -339,6 +484,7 @@ def kea_delete_lease(inet, ip_address):
return False
+
def kea_get_active_config(inet):
config = _ctrl_socket_command(inet, 'config-get')
@@ -347,8 +493,18 @@ def kea_get_active_config(inet):
return config
+
+def kea_get_dhcp_pools(config, inet):
+ shared_networks = dict_search_args(
+ config, 'arguments', f'Dhcp{inet}', 'shared-networks'
+ )
+ return [network['name'] for network in shared_networks] if shared_networks else []
+
+
def kea_get_pool_from_subnet_id(config, inet, subnet_id):
- shared_networks = dict_search_args(config, 'arguments', f'Dhcp{inet}', 'shared-networks')
+ shared_networks = dict_search_args(
+ config, 'arguments', f'Dhcp{inet}', 'shared-networks'
+ )
if not shared_networks:
return None
@@ -362,3 +518,146 @@ def kea_get_pool_from_subnet_id(config, inet, subnet_id):
return network['name']
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
+ :return list
+ """
+ shared_networks = dict_search_args(
+ config, 'arguments', f'Dhcp{inet}', 'shared-networks'
+ )
+
+ mappings = []
+
+ if shared_networks:
+ for network in shared_networks:
+ if f'subnet{inet}' not in network:
+ continue
+
+ for p in pools:
+ if network['name'] == p:
+ for subnet in network[f'subnet{inet}']:
+ if 'reservations' in subnet:
+ for reservation in subnet['reservations']:
+ mapping = {'pool': p, 'subnet': subnet['subnet']}
+ mapping.update(reservation)
+ # rename 'ip(v6)-address' to 'ip', inet6 has 'ipv6-address' and inet has 'ip-address'
+ mapping['ip'] = mapping.pop(
+ 'ipv6-address', mapping.pop('ip-address', None)
+ )
+ # rename 'hw-address' to 'mac'
+ mapping['mac'] = mapping.pop('hw-address', None)
+ mappings.append(mapping)
+
+ return mappings
+
+
+def kea_get_server_leases(config, inet, pools=[], state=[], origin=None) -> list:
+ """
+ Get DHCP server leases from active Kea DHCPv4 or DHCPv6 configuration
+ :return list
+ """
+ leases = kea_get_leases(inet)
+
+ data = []
+ for lease in leases:
+ lifetime = lease['valid-lft']
+ start = lease['cltt']
+ expiry = start + lifetime
+
+ lease['start_time'] = datetime.fromtimestamp(start, timezone.utc)
+ lease['expire_time'] = (
+ datetime.fromtimestamp(expiry, timezone.utc) if expiry else None
+ )
+
+ data_lease = {}
+ data_lease['ip'] = lease['ip-address']
+ lease_state_long = {0: 'active', 1: 'rejected', 2: 'expired'}
+ data_lease['state'] = lease_state_long[lease['state']]
+ data_lease['pool'] = (
+ kea_get_pool_from_subnet_id(config, inet, lease['subnet-id'])
+ 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
+ )
+ 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('.') or '-'
+
+ if inet == '4':
+ data_lease['mac'] = lease['hw-address']
+ data_lease['start'] = lease['start_time'].timestamp()
+
+ if inet == '6':
+ data_lease['last_communication'] = lease['start_time'].timestamp()
+ data_lease['duid'] = _format_hex_string(lease['duid'])
+ data_lease['type'] = lease['type']
+
+ if lease['type'] == 'IA_PD':
+ prefix_len = lease['prefix-len']
+ data_lease['ip'] += f'/{prefix_len}'
+
+ data_lease['remaining'] = ''
+
+ 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 (
+ 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)
+ ):
+ data.append(data_lease)
+
+ # deduplicate
+ checked = []
+ for entry in data:
+ addr = entry.get('ip')
+ if addr not in checked:
+ checked.append(addr)
+ else:
+ idx = _find_list_of_dict_index(data, key='ip', value=addr)
+ if idx is not None:
+ data.pop(idx)
+
+ return data
diff --git a/python/vyos/nat.py b/python/vyos/nat.py
index 5fab3c2a1..29f8e961b 100644
--- a/python/vyos/nat.py
+++ b/python/vyos/nat.py
@@ -242,6 +242,13 @@ def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):
output.append(f'{proto} {prefix}port {operator} @P_{group_name}')
+ if 'fqdn' in side_conf:
+ fqdn = side_conf['fqdn']
+ operator = ''
+ if fqdn[0] == '!':
+ operator = '!='
+ output.append(f' ip {prefix}addr {operator} @FQDN_nat_{nat_type}_{rule_id}_{prefix}')
+
output.append('counter')
if 'log' in rule_conf:
diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py
index 066c8058f..7b11d36dd 100644
--- a/python/vyos/opmode.py
+++ b/python/vyos/opmode.py
@@ -20,86 +20,110 @@ from humps import decamelize
class Error(Exception):
- """ Any error that makes requested operation impossible to complete
- for reasons unrelated to the user input or script logic.
+ """Any error that makes requested operation impossible to complete
+ for reasons unrelated to the user input or script logic.
- This is the base class, scripts should not use it directly
- and should raise more specific errors instead,
- whenever possible.
+ This is the base class, scripts should not use it directly
+ and should raise more specific errors instead,
+ whenever possible.
"""
+
pass
+
class UnconfiguredSubsystem(Error):
- """ Requested operation is valid, but cannot be completed
- because corresponding subsystem is not configured
- and thus is not running.
+ """Requested operation is valid, but cannot be completed
+ because corresponding subsystem is not configured
+ and thus is not running.
"""
+
pass
+
class UnconfiguredObject(UnconfiguredSubsystem):
- """ Requested operation is valid but cannot be completed
- because its parameter refers to an object that does not exist
- in the system configuration.
+ """Requested operation is valid but cannot be completed
+ because its parameter refers to an object that does not exist
+ in the system configuration.
"""
+
pass
+
class DataUnavailable(Error):
- """ Requested operation is valid, but cannot be completed
- because data for it is not available.
- This error MAY be treated as temporary because such issues
- are often caused by transient events such as service restarts.
+ """Requested operation is valid, but cannot be completed
+ because data for it is not available.
+ This error MAY be treated as temporary because such issues
+ are often caused by transient events such as service restarts.
"""
+
pass
+
class PermissionDenied(Error):
- """ Requested operation is valid, but the caller has no permission
- to perform it.
+ """Requested operation is valid, but the caller has no permission
+ to perform it.
"""
+
pass
+
class InsufficientResources(Error):
- """ Requested operation and its arguments are valid but the system
- does not have enough resources (such as drive space or memory)
- to complete it.
+ """Requested operation and its arguments are valid but the system
+ does not have enough resources (such as drive space or memory)
+ to complete it.
"""
+
pass
+
class UnsupportedOperation(Error):
- """ Requested operation is technically valid but is not implemented yet. """
+ """Requested operation is technically valid but is not implemented yet."""
+
pass
+
class IncorrectValue(Error):
- """ Requested operation is valid, but an argument provided has an
- incorrect value, preventing successful completion.
+ """Requested operation is valid, but an argument provided has an
+ incorrect value, preventing successful completion.
"""
+
pass
+
class CommitInProgress(Error):
- """ Requested operation is valid, but not possible at the time due
+ """Requested operation is valid, but not possible at the time due
to a commit being in progress.
"""
+
pass
+
class InternalError(Error):
- """ Any situation when VyOS detects that it could not perform
- an operation correctly due to logic errors in its own code
- or errors in underlying software.
+ """Any situation when VyOS detects that it could not perform
+ an operation correctly due to logic errors in its own code
+ or errors in underlying software.
"""
+
pass
def _is_op_mode_function_name(name):
- if re.match(r"^(show|clear|reset|restart|add|update|delete|generate|set|renew|release|execute)", name):
+ if re.match(
+ r'^(show|clear|reset|restart|add|update|delete|generate|set|renew|release|execute|import|mtr)',
+ name,
+ ):
return True
else:
return False
+
def _capture_output(name):
- if re.match(r"^(show|generate)", name):
+ if re.match(r'^(show|generate)', name):
return True
else:
return False
+
def _get_op_mode_functions(module):
from inspect import getmembers, isfunction
@@ -110,32 +134,35 @@ def _get_op_mode_functions(module):
funcs = list(filter(lambda ft: _is_op_mode_function_name(ft[0]), funcs))
funcs_dict = {}
- for (name, thunk) in funcs:
+ for name, thunk in funcs:
funcs_dict[name] = thunk
return funcs_dict
+
def _is_optional_type(t):
# Optional[t] is internally an alias for Union[t, NoneType]
# and there's no easy way to get union members it seems
- if (type(t) == typing._UnionGenericAlias):
- if (len(t.__args__) == 2):
- if t.__args__[1] == type(None):
+ if type(t) is typing._UnionGenericAlias:
+ if len(t.__args__) == 2:
+ if t.__args__[1] is type(None):
return True
return False
+
def _get_arg_type(t):
- """ Returns the type itself if it's a primitive type,
- or the "real" type of typing.Optional
+ """Returns the type itself if it's a primitive type,
+ or the "real" type of typing.Optional
- Doesn't work with anything else at the moment!
+ Doesn't work with anything else at the moment!
"""
if _is_optional_type(t):
return t.__args__[0]
else:
return t
+
def _is_literal_type(t):
if _is_optional_type(t):
t = _get_arg_type(t)
@@ -145,9 +172,9 @@ def _is_literal_type(t):
return False
+
def _get_literal_values(t):
- """ Returns the tuple of allowed values for a Literal type
- """
+ """Returns the tuple of allowed values for a Literal type"""
if not _is_literal_type(t):
return tuple()
if _is_optional_type(t):
@@ -155,6 +182,7 @@ def _get_literal_values(t):
return typing.get_args(t)
+
def _normalize_field_name(name):
# Convert the name to string if it is not
# (in some cases they may be numbers)
@@ -179,6 +207,7 @@ def _normalize_field_name(name):
return name
+
def _normalize_dict_field_names(old_dict):
new_dict = {}
@@ -188,10 +217,11 @@ def _normalize_dict_field_names(old_dict):
# Sanity check
if len(old_dict) != len(new_dict):
- raise InternalError("Dictionary fields do not allow unique normalization")
+ raise InternalError('Dictionary fields do not allow unique normalization')
else:
return new_dict
+
def _normalize_field_names(value):
if isinstance(value, dict):
return _normalize_dict_field_names(value)
@@ -200,16 +230,19 @@ def _normalize_field_names(value):
else:
return value
+
def run(module):
from argparse import ArgumentParser
functions = _get_op_mode_functions(module)
parser = ArgumentParser()
- subparsers = parser.add_subparsers(dest="subcommand")
+ subparsers = parser.add_subparsers(dest='subcommand')
for function_name in functions:
- subparser = subparsers.add_parser(function_name, help=functions[function_name].__doc__)
+ subparser = subparsers.add_parser(
+ function_name, help=functions[function_name].__doc__
+ )
type_hints = typing.get_type_hints(functions[function_name])
if 'return' in type_hints:
@@ -222,62 +255,73 @@ def run(module):
# Without this, we'd get options like "--foo_bar"
opt = re.sub(r'_', '-', opt)
- if _get_arg_type(th) == bool:
- subparser.add_argument(f"--{opt}", action='store_true')
+ if _get_arg_type(th) is bool:
+ subparser.add_argument(f'--{opt}', action='store_true')
else:
if _is_optional_type(th):
if _is_literal_type(th):
- subparser.add_argument(f"--{opt}",
- choices=list(_get_literal_values(th)),
- default=None)
+ subparser.add_argument(
+ f'--{opt}',
+ choices=list(_get_literal_values(th)),
+ default=None,
+ )
else:
- subparser.add_argument(f"--{opt}",
- type=_get_arg_type(th), default=None)
+ subparser.add_argument(
+ f'--{opt}',
+ type=_get_arg_type(th),
+ default=None,
+ )
else:
if _is_literal_type(th):
- subparser.add_argument(f"--{opt}",
- choices=list(_get_literal_values(th)),
- required=True)
+ subparser.add_argument(
+ f'--{opt}',
+ choices=list(_get_literal_values(th)),
+ required=True,
+ )
else:
- subparser.add_argument(f"--{opt}",
- type=_get_arg_type(th), required=True)
+ subparser.add_argument(
+ f'--{opt}', type=_get_arg_type(th), required=True
+ )
# Get options as a dict rather than a namespace,
# so that we can modify it and pack for passing to functions
args = vars(parser.parse_args())
- if not args["subcommand"]:
- print("Subcommand required!")
+ if not args['subcommand']:
+ print('Subcommand required!')
parser.print_usage()
sys.exit(1)
- function_name = args["subcommand"]
+ function_name = args['subcommand']
func = functions[function_name]
# Remove the subcommand from the arguments,
# it would cause an extra argument error when we pass the dict to a function
- del args["subcommand"]
+ del args['subcommand']
# Show and generate commands must always get the "raw" argument,
# but other commands (clear/reset/restart/add/delete) should not,
# because they produce no output and it makes no sense for them.
- if ("raw" not in args) and _capture_output(function_name):
- args["raw"] = False
+ if ('raw' not in args) and _capture_output(function_name):
+ args['raw'] = False
if _capture_output(function_name):
# Show and generate commands are slightly special:
# they may return human-formatted output
# or a raw dict that we need to serialize in JSON for printing
res = func(**args)
- if not args["raw"]:
+ if not args['raw']:
return res
else:
if not isinstance(res, dict) and not isinstance(res, list):
- raise InternalError(f"Bare literal is not an acceptable raw output, must be a list or an object.\
- The output was:{res}")
+ raise InternalError(
+ f'Bare literal is not an acceptable raw output, must be a list or an object.\
+ The output was:{res}'
+ )
res = decamelize(res)
res = _normalize_field_names(res)
from json import dumps
+
return dumps(res, indent=4)
else:
# Other functions should not return anything,
diff --git a/python/vyos/pki.py b/python/vyos/pki.py
index 5a0e2ddda..55dc02631 100644
--- a/python/vyos/pki.py
+++ b/python/vyos/pki.py
@@ -33,6 +33,8 @@ CERT_BEGIN='-----BEGIN CERTIFICATE-----\n'
CERT_END='\n-----END CERTIFICATE-----'
KEY_BEGIN='-----BEGIN PRIVATE KEY-----\n'
KEY_END='\n-----END PRIVATE KEY-----'
+KEY_EC_BEGIN='-----BEGIN EC PRIVATE KEY-----\n'
+KEY_EC_END='\n-----END EC PRIVATE KEY-----'
KEY_ENC_BEGIN='-----BEGIN ENCRYPTED PRIVATE KEY-----\n'
KEY_ENC_END='\n-----END ENCRYPTED PRIVATE KEY-----'
KEY_PUB_BEGIN='-----BEGIN PUBLIC KEY-----\n'
@@ -228,8 +230,18 @@ def create_dh_parameters(bits=2048):
def wrap_public_key(raw_data):
return KEY_PUB_BEGIN + raw_data + KEY_PUB_END
-def wrap_private_key(raw_data, passphrase=None):
- return (KEY_ENC_BEGIN if passphrase else KEY_BEGIN) + raw_data + (KEY_ENC_END if passphrase else KEY_END)
+def wrap_private_key(raw_data, passphrase=None, ec=False):
+ begin = KEY_BEGIN
+ end = KEY_END
+
+ if passphrase:
+ begin = KEY_ENC_BEGIN
+ end = KEY_ENC_END
+ elif ec:
+ begin = KEY_EC_BEGIN
+ end = KEY_EC_END
+
+ return begin + raw_data + end
def wrap_openssh_public_key(raw_data, type):
return f'{type} {raw_data}'
@@ -262,17 +274,26 @@ def load_public_key(raw_data, wrap_tags=True):
except ValueError:
return False
-def load_private_key(raw_data, passphrase=None, wrap_tags=True):
- if wrap_tags:
- raw_data = wrap_private_key(raw_data, passphrase)
+def _load_private_key(raw_data, passphrase):
+ try:
+ return serialization.load_pem_private_key(bytes(raw_data, 'utf-8'), password=passphrase)
+ except (ValueError, TypeError):
+ return False
+def load_private_key(raw_data, passphrase=None, wrap_tags=True):
if passphrase is not None:
passphrase = bytes(passphrase, 'utf-8')
- try:
- return serialization.load_pem_private_key(bytes(raw_data, 'utf-8'), password=passphrase)
- except (ValueError, TypeError):
+ result = False
+
+ if wrap_tags:
+ for ec_test in [False, True]:
+ wrapped_data = wrap_private_key(raw_data, passphrase, ec_test)
+ if result := _load_private_key(wrapped_data, passphrase):
+ return result
return False
+ else:
+ return _load_private_key(raw_data, passphrase)
def load_openssh_public_key(raw_data, type):
try:
diff --git a/python/vyos/proto/__init__.py b/python/vyos/proto/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/python/vyos/proto/__init__.py
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 <http://www.gnu.org/licenses/>.
+#
+#
+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)
diff --git a/python/vyos/proto/vyconf_client.py b/python/vyos/proto/vyconf_client.py
new file mode 100644
index 000000000..b385f0951
--- /dev/null
+++ b/python/vyos/proto/vyconf_client.py
@@ -0,0 +1,89 @@
+# Copyright 2025 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/>.
+
+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, use_integers_for_enums=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
diff --git a/python/vyos/qos/base.py b/python/vyos/qos/base.py
index 98e486e42..b477b5b5e 100644
--- a/python/vyos/qos/base.py
+++ b/python/vyos/qos/base.py
@@ -17,6 +17,7 @@ import os
import jmespath
from vyos.base import Warning
+from vyos.ifconfig import Interface
from vyos.utils.process import cmd
from vyos.utils.dict import dict_search
from vyos.utils.file import read_file
@@ -88,7 +89,8 @@ class QoSBase:
if value in self._dsfields:
return self._dsfields[value]
else:
- return value
+ # left shift operation aligns the DSCP/TOS value with its bit position in the IP header.
+ return int(value) << 2
def _calc_random_detect_queue_params(self, avg_pkt, max_thr, limit=None, min_thr=None,
mark_probability=None, precedence=0):
@@ -163,11 +165,11 @@ class QoSBase:
default_tc += f' red'
qparams = self._calc_random_detect_queue_params(
- avg_pkt=dict_search('average_packet', config),
- max_thr=dict_search('maximum_threshold', config),
+ avg_pkt=dict_search('average_packet', config) or 1024,
+ max_thr=dict_search('maximum_threshold', config) or 18,
limit=dict_search('queue_limit', config),
min_thr=dict_search('minimum_threshold', config),
- mark_probability=dict_search('mark_probability', config)
+ mark_probability=dict_search('mark_probability', config) or 10
)
default_tc += f' limit {qparams["limit"]} avpkt {qparams["avg_pkt"]}'
@@ -244,28 +246,37 @@ class QoSBase:
prio = cls_config['priority']
filter_cmd_base += f' prio {prio}'
- filter_cmd_base += ' protocol all'
-
if 'match' in cls_config:
has_filter = False
+ has_action_policy = any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in cls_config)
+ max_index = len(cls_config['match'])
for index, (match, match_config) in enumerate(cls_config['match'].items(), start=1):
filter_cmd = filter_cmd_base
if not has_filter:
- for key in ['mark', 'vif', 'ip', 'ipv6']:
+ for key in ['mark', 'vif', 'ip', 'ipv6', 'interface', 'ether']:
if key in match_config:
has_filter = True
break
- if self.qostype == 'shaper' and 'prio ' not in filter_cmd:
+ tmp = dict_search(f'ether.protocol', match_config) or 'all'
+ filter_cmd += f' protocol {tmp}'
+
+ if self.qostype in ['shaper', 'shaper_hfsc'] and 'prio ' not in filter_cmd:
filter_cmd += f' prio {index}'
+
if 'mark' in match_config:
mark = match_config['mark']
filter_cmd += f' handle {mark} fw'
+
if 'vif' in match_config:
vif = match_config['vif']
filter_cmd += f' basic match "meta(vlan mask 0xfff eq {vif})"'
+ elif 'interface' in match_config:
+ iif_name = match_config['interface']
+ iif = Interface(iif_name).get_ifindex()
+ filter_cmd += f' basic match "meta(rt_iif eq {iif})"'
- for af in ['ip', 'ipv6']:
+ for af in ['ip', 'ipv6', 'ether']:
tc_af = af
if af == 'ipv6':
tc_af = 'ip6'
@@ -273,77 +284,88 @@ class QoSBase:
if af in match_config:
filter_cmd += ' u32'
- tmp = dict_search(f'{af}.source.address', match_config)
- if tmp: filter_cmd += f' match {tc_af} src {tmp}'
-
- tmp = dict_search(f'{af}.source.port', match_config)
- if tmp: filter_cmd += f' match {tc_af} sport {tmp} 0xffff'
-
- tmp = dict_search(f'{af}.destination.address', match_config)
- if tmp: filter_cmd += f' match {tc_af} dst {tmp}'
-
- tmp = dict_search(f'{af}.destination.port', match_config)
- if tmp: filter_cmd += f' match {tc_af} dport {tmp} 0xffff'
-
- tmp = dict_search(f'{af}.protocol', match_config)
- if tmp:
- tmp = get_protocol_by_name(tmp)
- filter_cmd += f' match {tc_af} protocol {tmp} 0xff'
-
- tmp = dict_search(f'{af}.dscp', match_config)
- if tmp:
- tmp = self._get_dsfield(tmp)
- if af == 'ip':
- filter_cmd += f' match {tc_af} dsfield {tmp} 0xff'
- elif af == 'ipv6':
- filter_cmd += f' match u16 {tmp} 0x0ff0 at 0'
-
- # Will match against total length of an IPv4 packet and
- # payload length of an IPv6 packet.
- #
- # IPv4 : match u16 0x0000 ~MAXLEN at 2
- # IPv6 : match u16 0x0000 ~MAXLEN at 4
- tmp = dict_search(f'{af}.max_length', match_config)
- if tmp:
- # We need the 16 bit two's complement of the maximum
- # packet length
- tmp = hex(0xffff & ~int(tmp))
-
- if af == 'ip':
- filter_cmd += f' match u16 0x0000 {tmp} at 2'
- elif af == 'ipv6':
- filter_cmd += f' match u16 0x0000 {tmp} at 4'
-
- # We match against specific TCP flags - we assume the IPv4
- # header length is 20 bytes and assume the IPv6 packet is
- # not using extension headers (hence a ip header length of 40 bytes)
- # TCP Flags are set on byte 13 of the TCP header.
- # IPv4 : match u8 X X at 33
- # IPv6 : match u8 X X at 53
- # with X = 0x02 for SYN and X = 0x10 for ACK
- tmp = dict_search(f'{af}.tcp', match_config)
- if tmp:
- mask = 0
- if 'ack' in tmp:
- mask |= 0x10
- if 'syn' in tmp:
- mask |= 0x02
- mask = hex(mask)
-
- if af == 'ip':
- filter_cmd += f' match u8 {mask} {mask} at 33'
- elif af == 'ipv6':
- filter_cmd += f' match u8 {mask} {mask} at 53'
-
- cls = int(cls)
- filter_cmd += f' flowid {self._parent:x}:{cls:x}'
- self._cmd(filter_cmd)
+ if af == 'ether':
+ src = dict_search(f'{af}.source', match_config)
+ if src: filter_cmd += f' match {tc_af} src {src}'
+
+ dst = dict_search(f'{af}.destination', match_config)
+ if dst: filter_cmd += f' match {tc_af} dst {dst}'
+
+ if not src and not dst:
+ filter_cmd += f' match u32 0 0'
+ else:
+ tmp = dict_search(f'{af}.source.address', match_config)
+ if tmp: filter_cmd += f' match {tc_af} src {tmp}'
+
+ tmp = dict_search(f'{af}.source.port', match_config)
+ if tmp: filter_cmd += f' match {tc_af} sport {tmp} 0xffff'
+
+ tmp = dict_search(f'{af}.destination.address', match_config)
+ if tmp: filter_cmd += f' match {tc_af} dst {tmp}'
+
+ tmp = dict_search(f'{af}.destination.port', match_config)
+ if tmp: filter_cmd += f' match {tc_af} dport {tmp} 0xffff'
+ ###
+ tmp = dict_search(f'{af}.protocol', match_config)
+ if tmp:
+ tmp = get_protocol_by_name(tmp)
+ filter_cmd += f' match {tc_af} protocol {tmp} 0xff'
+
+ tmp = dict_search(f'{af}.dscp', match_config)
+ if tmp:
+ tmp = self._get_dsfield(tmp)
+ if af == 'ip':
+ filter_cmd += f' match {tc_af} dsfield {tmp} 0xff'
+ elif af == 'ipv6':
+ filter_cmd += f' match u16 {tmp} 0x0ff0 at 0'
+
+ # Will match against total length of an IPv4 packet and
+ # payload length of an IPv6 packet.
+ #
+ # IPv4 : match u16 0x0000 ~MAXLEN at 2
+ # IPv6 : match u16 0x0000 ~MAXLEN at 4
+ tmp = dict_search(f'{af}.max_length', match_config)
+ if tmp:
+ # We need the 16 bit two's complement of the maximum
+ # packet length
+ tmp = hex(0xffff & ~int(tmp))
+
+ if af == 'ip':
+ filter_cmd += f' match u16 0x0000 {tmp} at 2'
+ elif af == 'ipv6':
+ filter_cmd += f' match u16 0x0000 {tmp} at 4'
+
+ # We match against specific TCP flags - we assume the IPv4
+ # header length is 20 bytes and assume the IPv6 packet is
+ # not using extension headers (hence a ip header length of 40 bytes)
+ # TCP Flags are set on byte 13 of the TCP header.
+ # IPv4 : match u8 X X at 33
+ # IPv6 : match u8 X X at 53
+ # with X = 0x02 for SYN and X = 0x10 for ACK
+ tmp = dict_search(f'{af}.tcp', match_config)
+ if tmp:
+ mask = 0
+ if 'ack' in tmp:
+ mask |= 0x10
+ if 'syn' in tmp:
+ mask |= 0x02
+ mask = hex(mask)
+
+ if af == 'ip':
+ filter_cmd += f' match u8 {mask} {mask} at 33'
+ elif af == 'ipv6':
+ filter_cmd += f' match u8 {mask} {mask} at 53'
+
+ if index != max_index or not has_action_policy:
+ # avoid duplicate last match rule
+ cls = int(cls)
+ filter_cmd += f' flowid {self._parent:x}:{cls:x}'
+ self._cmd(filter_cmd)
vlan_expression = "match.*.vif"
match_vlan = jmespath.search(vlan_expression, cls_config)
- if any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in cls_config) \
- and has_filter:
+ if has_action_policy and has_filter:
# For "vif" "basic match" is used instead of "action police" T5961
if not match_vlan:
filter_cmd += f' action police'
diff --git a/python/vyos/qos/cake.py b/python/vyos/qos/cake.py
index 1ee7d0fc3..ca5a26917 100644
--- a/python/vyos/qos/cake.py
+++ b/python/vyos/qos/cake.py
@@ -15,10 +15,25 @@
from vyos.qos.base import QoSBase
+
class CAKE(QoSBase):
+ """
+ https://man7.org/linux/man-pages/man8/tc-cake.8.html
+ """
+
_direction = ['egress']
- # https://man7.org/linux/man-pages/man8/tc-cake.8.html
+ flow_isolation_map = {
+ 'blind': 'flowblind',
+ 'src-host': 'srchost',
+ 'dst-host': 'dsthost',
+ 'dual-dst-host': 'dual-dsthost',
+ 'dual-src-host': 'dual-srchost',
+ 'triple-isolate': 'triple-isolate',
+ 'flow': 'flows',
+ 'host': 'hosts',
+ }
+
def update(self, config, direction):
tmp = f'tc qdisc add dev {self._interface} root handle 1: cake {direction}'
if 'bandwidth' in config:
@@ -30,26 +45,16 @@ class CAKE(QoSBase):
tmp += f' rtt {rtt}ms'
if 'flow_isolation' in config:
- if 'blind' in config['flow_isolation']:
- tmp += f' flowblind'
- if 'dst_host' in config['flow_isolation']:
- tmp += f' dsthost'
- if 'dual_dst_host' in config['flow_isolation']:
- tmp += f' dual-dsthost'
- if 'dual_src_host' in config['flow_isolation']:
- tmp += f' dual-srchost'
- if 'triple_isolate' in config['flow_isolation']:
- tmp += f' triple-isolate'
- if 'flow' in config['flow_isolation']:
- tmp += f' flows'
- if 'host' in config['flow_isolation']:
- tmp += f' hosts'
- if 'nat' in config['flow_isolation']:
- tmp += f' nat'
- if 'src_host' in config['flow_isolation']:
- tmp += f' srchost '
- else:
- tmp += f' nonat'
+ isolation_value = self.flow_isolation_map.get(config['flow_isolation'])
+
+ if isolation_value is not None:
+ tmp += f' {isolation_value}'
+ else:
+ raise ValueError(
+ f'Invalid flow isolation parameter: {config["flow_isolation"]}'
+ )
+
+ tmp += ' nat' if 'flow_isolation_nat' in config else ' nonat'
self._cmd(tmp)
diff --git a/python/vyos/qos/priority.py b/python/vyos/qos/priority.py
index 7f0a67032..66d27a639 100644
--- a/python/vyos/qos/priority.py
+++ b/python/vyos/qos/priority.py
@@ -20,17 +20,18 @@ class Priority(QoSBase):
# https://man7.org/linux/man-pages/man8/tc-prio.8.html
def update(self, config, direction):
- if 'class' in config:
- class_id_max = self._get_class_max_id(config)
- bands = int(class_id_max) +1
+ class_id_max = self._get_class_max_id(config)
+ class_id_max = class_id_max if class_id_max else 1
+ bands = int(class_id_max) + 1
- tmp = f'tc qdisc add dev {self._interface} root handle {self._parent:x}: prio bands {bands} priomap ' \
- f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \
- f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \
- f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \
- f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} '
- self._cmd(tmp)
+ tmp = f'tc qdisc add dev {self._interface} root handle {self._parent:x}: prio bands {bands} priomap ' \
+ f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \
+ f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \
+ f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} ' \
+ f'{class_id_max} {class_id_max} {class_id_max} {class_id_max} '
+ self._cmd(tmp)
+ if 'class' in config:
for cls in config['class']:
cls = int(cls)
tmp = f'tc qdisc add dev {self._interface} parent {self._parent:x}:{cls:x} pfifo'
diff --git a/python/vyos/qos/roundrobin.py b/python/vyos/qos/roundrobin.py
index 80814ddfb..509c4069f 100644
--- a/python/vyos/qos/roundrobin.py
+++ b/python/vyos/qos/roundrobin.py
@@ -15,6 +15,7 @@
from vyos.qos.base import QoSBase
+
class RoundRobin(QoSBase):
_parent = 1
@@ -34,11 +35,21 @@ class RoundRobin(QoSBase):
if 'default' in config:
class_id_max = self._get_class_max_id(config)
- default_cls_id = int(class_id_max) +1
+ default_cls_id = int(class_id_max) + 1 if class_id_max else 1
# class ID via CLI is in range 1-4095, thus 1000 hex = 4096
tmp = f'tc class replace dev {self._interface} parent 1:1 classid 1:{default_cls_id:x} drr'
self._cmd(tmp)
+ # You need to add at least one filter to classify packets
+ # otherwise, all packets will be dropped.
+ filter_cmd = (
+ f'tc filter replace dev {self._interface} '
+ f'parent {self._parent:x}: prio {default_cls_id} protocol all '
+ 'u32 match u32 0 0 '
+ f'flowid {self._parent}:{default_cls_id}'
+ )
+ self._cmd(filter_cmd)
+
# call base class
super().update(config, direction, priority=True)
diff --git a/python/vyos/qos/trafficshaper.py b/python/vyos/qos/trafficshaper.py
index 8b0333c21..9f92ccd8b 100644
--- a/python/vyos/qos/trafficshaper.py
+++ b/python/vyos/qos/trafficshaper.py
@@ -126,91 +126,71 @@ class TrafficShaper(QoSBase):
# call base class
super().update(config, direction)
+
class TrafficShaperHFSC(QoSBase):
+ """
+ Traffic shaper using Hierarchical Fair Service Curve (HFSC).
+ Documentation: https://man7.org/linux/man-pages/man8/tc-hfsc.8.html
+ """
+
_parent = 1
qostype = 'shaper_hfsc'
- # https://man7.org/linux/man-pages/man8/tc-hfsc.8.html
- def update(self, config, direction):
- class_id_max = 0
- if 'class' in config:
- tmp = list(config['class'])
- tmp.sort()
- class_id_max = tmp[-1]
+ criteria = ['linkshare', 'realtime', 'upperlimit']
+ short_criterion = {
+ 'linkshare': 'ls',
+ 'realtime': 'rt',
+ 'upperlimit': 'ul',
+ }
+
+ def _gen_class(self, cls: int, cls_config: dict):
+ """
+ Generate HFSC class and add Stochastic Fair Queueing (SFQ) qdisc.
+
+ Args:
+ cls (int): Class ID
+ cls_config (dict): Configuration for the class
+ """
+ tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} hfsc'
+
+ for crit in self.criteria:
+ param = cls_config.get(crit)
+ if param:
+ tmp += (
+ f' {self.short_criterion[crit]}'
+ f' m1 {self._rate_convert(param["m1"]) if param.get("m1") else 0}'
+ f' d {param.get("d", 0)}ms'
+ f' m2 {self._rate_convert(param["m2"])}'
+ )
- r2q = 10
- # bandwidth is a mandatory CLI node
- speed = self._rate_convert(config['bandwidth'])
- speed_bps = int(speed) // 8
+ self._cmd(tmp)
- # need a bigger r2q if going fast than 16 mbits/sec
- if (speed_bps // r2q) >= MAXQUANTUM: # integer division
- r2q = ceil(speed_bps // MAXQUANTUM)
- else:
- # if there is a slow class then may need smaller value
- if 'class' in config:
- min_speed = speed_bps
- for cls, cls_options in config['class'].items():
- # find class with the lowest bandwidth used
- if 'bandwidth' in cls_options:
- bw_bps = int(self._rate_convert(cls_options['bandwidth'])) // 8 # bandwidth in bytes per second
- if bw_bps < min_speed:
- min_speed = bw_bps
+ tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq perturb 10'
+ self._cmd(tmp)
- while (r2q > 1) and (min_speed // r2q) < MINQUANTUM:
- tmp = r2q -1
- if (speed_bps // tmp) >= MAXQUANTUM:
- break
- r2q = tmp
+ def update(self, config, direction):
+ class_id_max = self._get_class_max_id(config)
+ default_cls_id = int(class_id_max) + 1 if class_id_max else 2
- default_minor_id = int(class_id_max) +1
- tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: hfsc default {default_minor_id:x}' # default is in hex
+ speed = self._rate_convert(config['bandwidth'])
+
+ tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: hfsc default {default_cls_id:x}' # default is in hex
self._cmd(tmp)
tmp = f'tc class replace dev {self._interface} parent {self._parent:x}: classid {self._parent:x}:1 hfsc sc rate {speed} ul rate {speed}'
self._cmd(tmp)
+ # tmp = f'tc qdisc add dev {self._interface} parent {self._parent:x}:1 handle f1: sfq perturb 10'
+ # self._cmd(tmp)
+
if 'class' in config:
for cls, cls_config in config['class'].items():
- # class id is used later on and passed as hex, thus this needs to be an int
- cls = int(cls)
- # ls m1
- if cls_config.get('linkshare', {}).get('m1').endswith('%'):
- percent = cls_config['linkshare']['m1'].rstrip('%')
- m_one_rate = self._rate_convert(config['bandwidth']) * int(percent) // 100
- else:
- m_one_rate = cls_config['linkshare']['m1']
- # ls m2
- if cls_config.get('linkshare', {}).get('m2').endswith('%'):
- percent = cls_config['linkshare']['m2'].rstrip('%')
- m_two_rate = self._rate_convert(config['bandwidth']) * int(percent) // 100
- else:
- m_two_rate = self._rate_convert(cls_config['linkshare']['m2'])
-
- tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} hfsc ls m1 {m_one_rate} m2 {m_two_rate} '
- self._cmd(tmp)
-
- tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq perturb 10'
- self._cmd(tmp)
+ self._gen_class(cls=int(cls), cls_config=cls_config)
if 'default' in config:
- # ls m1
- if config.get('default', {}).get('linkshare', {}).get('m1').endswith('%'):
- percent = config['default']['linkshare']['m1'].rstrip('%')
- m_one_rate = self._rate_convert(config['default']['linkshare']['m1']) * int(percent) // 100
- else:
- m_one_rate = config['default']['linkshare']['m1']
- # ls m2
- if config.get('default', {}).get('linkshare', {}).get('m2').endswith('%'):
- percent = config['default']['linkshare']['m2'].rstrip('%')
- m_two_rate = self._rate_convert(config['default']['linkshare']['m2']) * int(percent) // 100
- else:
- m_two_rate = self._rate_convert(config['default']['linkshare']['m2'])
- tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{default_minor_id:x} hfsc ls m1 {m_one_rate} m2 {m_two_rate} '
- self._cmd(tmp)
-
- tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{default_minor_id:x} sfq perturb 10'
- self._cmd(tmp)
+ self._gen_class(
+ cls=int(default_cls_id), cls_config=config.get('default', {})
+ )
# call base class
super().update(config, direction)
diff --git a/python/vyos/remote.py b/python/vyos/remote.py
index d87fd24f6..c54fb6031 100644
--- a/python/vyos/remote.py
+++ b/python/vyos/remote.py
@@ -363,6 +363,7 @@ class GitC:
# environment vars for our git commands
env = {
+ **os.environ,
"GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": name,
"GIT_AUTHOR_EMAIL": email,
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/python/vyos/template.py b/python/vyos/template.py
index be9f781a6..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.
@@ -150,6 +172,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:
@@ -390,28 +414,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:
- # <netmask>, <network-byte1>, <network-byte2>, <network-byte3>, <router-byte1>, <router-byte2>, <router-byte3>
- # 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):
@@ -612,12 +614,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 +633,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)
@@ -779,6 +786,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
@@ -871,10 +883,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 = []
@@ -885,9 +964,13 @@ def kea_shared_network_json(shared_networks):
network = {
'name': name,
'authoritative': ('authoritative' in config),
- 'subnet4': []
+ 'subnet4': [],
+ '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'])
@@ -897,6 +980,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:
@@ -988,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/python/vyos/utils/auth.py b/python/vyos/utils/auth.py
index a0b3e1cae..5d0e3464a 100644
--- a/python/vyos/utils/auth.py
+++ b/python/vyos/utils/auth.py
@@ -13,10 +13,80 @@
# 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: 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:
+ """
+ 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))
+ 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
+ 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/python/vyos/utils/config.py b/python/vyos/utils/config.py
index 33047010b..deda13c13 100644
--- a/python/vyos/utils/config.py
+++ b/python/vyos/utils/config.py
@@ -14,8 +14,14 @@
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
import os
+from typing import TYPE_CHECKING
+
from vyos.defaults import directories
+# https://peps.python.org/pep-0484/#forward-references
+if TYPE_CHECKING:
+ from vyos.configtree import ConfigTree
+
config_file = os.path.join(directories['config'], 'config.boot')
def read_saved_value(path: list):
@@ -37,3 +43,63 @@ def read_saved_value(path: list):
if len(res) == 1:
return ' '.join(res)
return res
+
+def flag(l: list) -> list:
+ res = [l[0:i] for i,_ in enumerate(l, start=1)]
+ return res
+
+def tag_node_of_path(p: list) -> list:
+ from vyos.xml_ref import is_tag
+
+ fl = flag(p)
+ res = list(map(is_tag, fl))
+
+ return res
+
+def set_tags(ct: 'ConfigTree', path: list) -> None:
+ fl = flag(path)
+ if_tag = tag_node_of_path(path)
+ for condition, target in zip(if_tag, fl):
+ if condition:
+ ct.set_tag(target)
+
+def parse_commands(cmds: str) -> dict:
+ from re import split as re_split
+ from shlex import split as shlex_split
+
+ from vyos.xml_ref import definition
+ from vyos.xml_ref.pkg_cache.vyos_1x_cache import reference
+
+ ref_tree = definition.Xml()
+ ref_tree.define(reference)
+
+ res = []
+
+ cmds = re_split(r'\n+', cmds)
+ for c in cmds:
+ cmd_parts = shlex_split(c)
+
+ if not cmd_parts:
+ # Ignore empty lines
+ continue
+
+ path = cmd_parts[1:]
+ op = cmd_parts[0]
+
+ try:
+ path, value = ref_tree.split_path(path)
+ except ValueError as e:
+ raise ValueError(f'Incorrect command: {e}')
+
+ entry = {}
+ entry["op"] = op
+ entry["path"] = path
+ entry["value"] = value
+
+ entry["is_multi"] = ref_tree.is_multi(path)
+ entry["is_leaf"] = ref_tree.is_leaf(path)
+ entry["is_tag"] = ref_tree.is_tag(path)
+
+ res.append(entry)
+
+ return res
diff --git a/python/vyos/utils/convert.py b/python/vyos/utils/convert.py
index dd4266f57..2f587405d 100644
--- a/python/vyos/utils/convert.py
+++ b/python/vyos/utils/convert.py
@@ -235,3 +235,29 @@ def convert_data(data) -> dict | list | tuple | str | int | float | bool | None:
# which cannot be converted to JSON
# for example: complex | range | memoryview
return
+
+
+def encode_to_base64(input_string):
+ """
+ Encodes a given string to its base64 representation.
+
+ Args:
+ input_string (str): The string to be encoded.
+
+ Returns:
+ str: The base64-encoded version of the input string.
+
+ Example:
+ input_string = "Hello, World!"
+ encoded_string = encode_to_base64(input_string)
+ print(encoded_string) # Output: SGVsbG8sIFdvcmxkIQ==
+ """
+ import base64
+ # Convert the string to bytes
+ byte_string = input_string.encode('utf-8')
+
+ # Encode the byte string to base64
+ encoded_string = base64.b64encode(byte_string)
+
+ # Decode the base64 bytes back to a string
+ return encoded_string.decode('utf-8')
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']
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/python/vyos/utils/network.py b/python/vyos/utils/network.py
index 8fce08de0..20b6a3c9e 100644
--- a/python/vyos/utils/network.py
+++ b/python/vyos/utils/network.py
@@ -69,7 +69,9 @@ def get_vrf_members(vrf: str) -> list:
answer = json.loads(output)
for data in answer:
if 'ifname' in data:
- interfaces.append(data.get('ifname'))
+ # Skip PIM interfaces which appears in VRF
+ if 'pim' not in data.get('ifname'):
+ interfaces.append(data.get('ifname'))
except:
pass
return interfaces
@@ -254,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
@@ -597,3 +619,35 @@ 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
+
+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/python/vyos/utils/process.py b/python/vyos/utils/process.py
index ce880f4a4..21335e6b3 100644
--- a/python/vyos/utils/process.py
+++ b/python/vyos/utils/process.py
@@ -14,16 +14,27 @@
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
import os
+import shlex
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:
+ wrapper = ['ip', 'vrf', 'exec', vrf]
+ elif netns:
+ wrapper = ['ip', 'netns', 'exec', netns]
+ return wrapper
+
+
def popen(command, flag='', shell=None, input=None, timeout=None, env=None,
- stdout=PIPE, stderr=PIPE, decode='utf-8'):
+ stdout=PIPE, stderr=PIPE, decode='utf-8', vrf=None, netns=None):
"""
- popen is a wrapper helper aound subprocess.Popen
+ popen is a wrapper helper around subprocess.Popen
with it default setting it will return a tuple (out, err)
out: the output of the program run
err: the error code returned by the program
@@ -45,6 +56,8 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None,
- DEVNULL, discard the output
decode: specify the expected text encoding (utf-8, ascii, ...)
the default is explicitely utf-8 which is python's own default
+ vrf: run command in a VRF context
+ netns: run command in the named network namespace
usage:
get both stdout and stderr: popen('command', stdout=PIPE, stderr=STDOUT)
@@ -60,9 +73,6 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None,
if not debug.enabled(flag):
flag = 'command'
- cmd_msg = f"cmd '{command}'"
- debug.message(cmd_msg, flag)
-
use_shell = shell
stdin = None
if shell is None:
@@ -72,6 +82,24 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None,
if env:
use_shell = True
+ # Must be run as root to execute command in VRF or network namespace
+ 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'
+ )
+
+ 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}'" if use_shell else f"cmd '{shlex.join(command)}'"
+ debug.message(cmd_msg, flag)
+
if input:
stdin = PIPE
input = input.encode() if type(input) is str else input
@@ -111,7 +139,7 @@ def popen(command, flag='', shell=None, input=None, timeout=None, env=None,
def run(command, flag='', shell=None, input=None, timeout=None, env=None,
- stdout=DEVNULL, stderr=PIPE, decode='utf-8'):
+ stdout=DEVNULL, stderr=PIPE, decode='utf-8', vrf=None, netns=None):
"""
A wrapper around popen, which discard the stdout and
will return the error code of a command
@@ -122,13 +150,15 @@ def run(command, flag='', shell=None, input=None, timeout=None, env=None,
input=input, timeout=timeout,
env=env, shell=shell,
decode=decode,
+ vrf=vrf,
+ netns=netns,
)
return code
def cmd(command, flag='', shell=None, input=None, timeout=None, env=None,
stdout=PIPE, stderr=PIPE, decode='utf-8', raising=None, message='',
- expect=[0]):
+ expect=[0], vrf=None, netns=None):
"""
A wrapper around popen, which returns the stdout and
will raise the error code of a command
@@ -144,8 +174,12 @@ def cmd(command, flag='', shell=None, input=None, timeout=None, env=None,
input=input, timeout=timeout,
env=env, shell=shell,
decode=decode,
+ vrf=vrf,
+ netns=netns,
)
if code not in expect:
+ wrapper = get_wrapper(vrf, netns)
+ command = f'{wrapper} {command}'
feedback = message + '\n' if message else ''
feedback += f'failed to run command: {command}\n'
feedback += f'returned: {decoded}\n'
@@ -159,7 +193,7 @@ def cmd(command, flag='', shell=None, input=None, timeout=None, env=None,
def rc_cmd(command, flag='', shell=None, input=None, timeout=None, env=None,
- stdout=PIPE, stderr=STDOUT, decode='utf-8'):
+ stdout=PIPE, stderr=STDOUT, decode='utf-8', vrf=None, netns=None):
"""
A wrapper around popen, which returns the return code
of a command and stdout
@@ -175,11 +209,14 @@ def rc_cmd(command, flag='', shell=None, input=None, timeout=None, env=None,
input=input, timeout=timeout,
env=env, shell=shell,
decode=decode,
+ vrf=vrf,
+ netns=netns,
)
return code, out
+
def call(command, flag='', shell=None, input=None, timeout=None, env=None,
- stdout=None, stderr=None, decode='utf-8'):
+ stdout=None, stderr=None, decode='utf-8', vrf=None, netns=None):
"""
A wrapper around popen, which print the stdout and
will return the error code of a command
@@ -190,11 +227,14 @@ def call(command, flag='', shell=None, input=None, timeout=None, env=None,
input=input, timeout=timeout,
env=env, shell=shell,
decode=decode,
+ vrf=vrf,
+ netns=netns,
)
if out:
print(out)
return code
+
def process_running(pid_file):
""" Checks if a process with PID in pid_file is running """
from psutil import pid_exists
diff --git a/python/vyos/utils/system.py b/python/vyos/utils/system.py
index 7b12efb14..6c112334b 100644
--- a/python/vyos/utils/system.py
+++ b/python/vyos/utils/system.py
@@ -145,5 +145,5 @@ def get_secure_boot_state() -> bool:
from vyos.utils.boot import is_uefi_system
if not is_uefi_system():
return False
- tmp = cmd('mokutil --sb-state')
+ tmp = cmd('mokutil --sb-state', expect=[0, 255])
return bool('enabled' in tmp)
diff --git a/python/vyos/vyconf_session.py b/python/vyos/vyconf_session.py
new file mode 100644
index 000000000..506095625
--- /dev/null
+++ b/python/vyos/vyconf_session.py
@@ -0,0 +1,123 @@
+# Copyright 2025 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/>.
+#
+#
+
+import tempfile
+import shutil
+from functools import wraps
+from typing import Type
+
+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, 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
+
+ def session_changed(self) -> bool:
+ 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:
+ 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
+
+ @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 = []
+ 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)}')
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 <http://www.gnu.org/licenses/>.
+
+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/python/vyos/xml_ref/definition.py b/python/vyos/xml_ref/definition.py
index 5ff28daed..4e755ab72 100644
--- a/python/vyos/xml_ref/definition.py
+++ b/python/vyos/xml_ref/definition.py
@@ -13,7 +13,7 @@
# 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 typing import Optional, Union, Any, TYPE_CHECKING
+from typing import Tuple, Optional, Union, Any, TYPE_CHECKING
# https://peps.python.org/pep-0484/#forward-references
# for type 'ConfigDict'
@@ -90,6 +90,32 @@ class Xml:
res = self._get_ref_node_data(node, 'node_type')
return res == 'tag'
+ def exists(self, path: list) -> bool:
+ try:
+ _ = self._get_ref_path(path)
+ return True
+ except ValueError:
+ return False
+
+ def split_path(self, path: list) -> Tuple[list, Optional[str]]:
+ """ Splits a list into config path and value components """
+
+ # First, check if the complete path is valid by itself
+ if self.exists(path):
+ if self.is_valueless(path) or not self.is_leaf(path):
+ # It's a complete path for a valueless node
+ # or a path to an empy non-leaf node
+ return (path, None)
+ else:
+ raise ValueError(f'Path "{path}" needs a value or children')
+ else:
+ # If the complete path doesn't exist, it's probably a path with a value
+ if self.exists(path[0:-1]):
+ return (path[0:-1], path[-1])
+ else:
+ # Or not a valid path at all
+ raise ValueError(f'Path "{path}" is incorrect')
+
def is_tag(self, path: list) -> bool:
ref_path = path.copy()
d = self.ref
diff --git a/python/vyos/xml_ref/generate_cache.py b/python/vyos/xml_ref/generate_cache.py
index 5f3f84dee..093697993 100755
--- a/python/vyos/xml_ref/generate_cache.py
+++ b/python/vyos/xml_ref/generate_cache.py
@@ -55,6 +55,8 @@ def main():
parser = ArgumentParser(description='generate and save dict from xml defintions')
parser.add_argument('--xml-dir', type=str, required=True,
help='transcluded xml interface-definition directory')
+ parser.add_argument('--internal-cache', type=str, required=True,
+ help='cache as unrendered json data for loading by vyconfd')
parser.add_argument('--package-name', type=non_trivial, default='vyos-1x',
help='name of current package')
parser.add_argument('--output-path', help='path to generated cache')
@@ -66,9 +68,11 @@ def main():
out_path = args['output_path']
path = out_path if out_path is not None else pkg_cache
xml_cache = abspath(join(path, cache_name))
+ internal_cache = args['internal_cache']
try:
- reference_tree_to_json(xml_dir, xml_tmp)
+ reference_tree_to_json(xml_dir, xml_tmp,
+ internal_cache=internal_cache)
except ConfigTreeError as e:
print(e)
sys.exit(1)
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()