summaryrefslogtreecommitdiff
path: root/python
diff options
context:
space:
mode:
authorkumvijaya <kuvmijaya@gmail.com>2024-09-26 11:31:07 +0530
committerkumvijaya <kuvmijaya@gmail.com>2024-09-26 11:31:07 +0530
commita950059053f7394acfb453cc0d8194aa3dc721fa (patch)
treeeb0acf278f649b5d1417e18e34d728efcd16e745 /python
parentf0815f3e9b212f424f5adb0c572a71119ad4a8a0 (diff)
downloadvyos-workflow-test-temp-a950059053f7394acfb453cc0d8194aa3dc721fa.tar.gz
vyos-workflow-test-temp-a950059053f7394acfb453cc0d8194aa3dc721fa.zip
T6732: added same as vyos 1x
Diffstat (limited to 'python')
-rw-r--r--python/setup.py32
-rw-r--r--python/vyos/__init__.py1
-rw-r--r--python/vyos/accel_ppp.py70
-rw-r--r--python/vyos/accel_ppp_util.py238
-rw-r--r--python/vyos/airbag.py173
-rw-r--r--python/vyos/base.py72
-rw-r--r--python/vyos/certbot_util.py58
-rw-r--r--python/vyos/component_version.py204
-rw-r--r--python/vyos/compose_config.py88
-rw-r--r--python/vyos/config.py622
-rw-r--r--python/vyos/config_mgmt.py761
-rw-r--r--python/vyos/configdep.py211
-rw-r--r--python/vyos/configdict.py666
-rw-r--r--python/vyos/configdiff.py436
-rw-r--r--python/vyos/configquery.py192
-rw-r--r--python/vyos/configsession.py287
-rw-r--r--python/vyos/configsource.py321
-rw-r--r--python/vyos/configtree.py498
-rw-r--r--python/vyos/configverify.py539
-rw-r--r--python/vyos/debug.py205
-rw-r--r--python/vyos/defaults.py63
-rw-r--r--python/vyos/ethtool.py200
-rw-r--r--python/vyos/firewall.py785
-rw-r--r--python/vyos/frr.py551
-rw-r--r--python/vyos/hostsd_client.py131
-rw-r--r--python/vyos/ifconfig/__init__.py41
-rw-r--r--python/vyos/ifconfig/afi.py19
-rw-r--r--python/vyos/ifconfig/bond.py509
-rw-r--r--python/vyos/ifconfig/bridge.py413
-rw-r--r--python/vyos/ifconfig/control.py196
-rw-r--r--python/vyos/ifconfig/dummy.py33
-rw-r--r--python/vyos/ifconfig/ethernet.py457
-rw-r--r--python/vyos/ifconfig/geneve.py65
-rw-r--r--python/vyos/ifconfig/input.py36
-rw-r--r--python/vyos/ifconfig/interface.py1974
-rw-r--r--python/vyos/ifconfig/l2tpv3.py113
-rw-r--r--python/vyos/ifconfig/loopback.py70
-rw-r--r--python/vyos/ifconfig/macsec.py74
-rw-r--r--python/vyos/ifconfig/macvlan.py47
-rw-r--r--python/vyos/ifconfig/operational.py180
-rw-r--r--python/vyos/ifconfig/pppoe.py150
-rw-r--r--python/vyos/ifconfig/section.py195
-rw-r--r--python/vyos/ifconfig/sstpc.py40
-rw-r--r--python/vyos/ifconfig/tunnel.py178
-rw-r--r--python/vyos/ifconfig/veth.py54
-rw-r--r--python/vyos/ifconfig/vrrp.py156
-rw-r--r--python/vyos/ifconfig/vti.py80
-rw-r--r--python/vyos/ifconfig/vtun.py49
-rw-r--r--python/vyos/ifconfig/vxlan.py211
-rw-r--r--python/vyos/ifconfig/wireguard.py243
-rw-r--r--python/vyos/ifconfig/wireless.py65
-rw-r--r--python/vyos/ifconfig/wwan.py45
-rw-r--r--python/vyos/iflag.py36
-rw-r--r--python/vyos/initialsetup.py72
-rw-r--r--python/vyos/ioctl.py35
-rw-r--r--python/vyos/ipsec.py247
-rw-r--r--python/vyos/kea.py364
-rw-r--r--python/vyos/limericks.py72
-rw-r--r--python/vyos/load_config.py181
-rw-r--r--python/vyos/logger.py143
-rw-r--r--python/vyos/migrate.py283
-rw-r--r--python/vyos/nat.py317
-rw-r--r--python/vyos/opmode.py285
-rw-r--r--python/vyos/pki.py453
-rw-r--r--python/vyos/priority.py75
-rw-r--r--python/vyos/progressbar.py77
-rw-r--r--python/vyos/qos/__init__.py28
-rw-r--r--python/vyos/qos/base.py440
-rw-r--r--python/vyos/qos/cake.py57
-rw-r--r--python/vyos/qos/droptail.py28
-rw-r--r--python/vyos/qos/fairqueue.py31
-rw-r--r--python/vyos/qos/fqcodel.py40
-rw-r--r--python/vyos/qos/limiter.py28
-rw-r--r--python/vyos/qos/netem.py53
-rw-r--r--python/vyos/qos/priority.py40
-rw-r--r--python/vyos/qos/randomdetect.py46
-rw-r--r--python/vyos/qos/ratelimiter.py37
-rw-r--r--python/vyos/qos/roundrobin.py44
-rw-r--r--python/vyos/qos/trafficshaper.py216
-rw-r--r--python/vyos/raid.py71
-rw-r--r--python/vyos/range_regex.py141
-rw-r--r--python/vyos/remote.py479
-rw-r--r--python/vyos/snmpv3_hashgen.py50
-rw-r--r--python/vyos/system/__init__.py18
-rw-r--r--python/vyos/system/compat.py337
-rw-r--r--python/vyos/system/disk.py242
-rw-r--r--python/vyos/system/grub.py464
-rw-r--r--python/vyos/system/grub_util.py70
-rw-r--r--python/vyos/system/image.py283
-rw-r--r--python/vyos/system/raid.py122
-rw-r--r--python/vyos/template.py990
-rw-r--r--python/vyos/tpm.py96
-rw-r--r--python/vyos/utils/__init__.py33
-rw-r--r--python/vyos/utils/assertion.py81
-rw-r--r--python/vyos/utils/auth.py51
-rw-r--r--python/vyos/utils/boot.py39
-rw-r--r--python/vyos/utils/commit.py60
-rw-r--r--python/vyos/utils/config.py39
-rw-r--r--python/vyos/utils/configfs.py37
-rw-r--r--python/vyos/utils/convert.py237
-rw-r--r--python/vyos/utils/cpu.py101
-rw-r--r--python/vyos/utils/dict.py374
-rw-r--r--python/vyos/utils/disk.py72
-rw-r--r--python/vyos/utils/error.py24
-rw-r--r--python/vyos/utils/file.py214
-rw-r--r--python/vyos/utils/io.py113
-rw-r--r--python/vyos/utils/kernel.py113
-rw-r--r--python/vyos/utils/list.py20
-rw-r--r--python/vyos/utils/locking.py115
-rw-r--r--python/vyos/utils/misc.py66
-rw-r--r--python/vyos/utils/network.py599
-rw-r--r--python/vyos/utils/permission.py78
-rw-r--r--python/vyos/utils/process.py262
-rw-r--r--python/vyos/utils/serial.py118
-rw-r--r--python/vyos/utils/strip_config.py210
-rw-r--r--python/vyos/utils/system.py149
-rw-r--r--python/vyos/utils/vti_updown_db.py194
-rw-r--r--python/vyos/version.py142
-rw-r--r--python/vyos/xml_ref/__init__.py112
-rw-r--r--python/vyos/xml_ref/definition.py339
-rw-r--r--python/vyos/xml_ref/generate_cache.py116
-rw-r--r--python/vyos/xml_ref/generate_op_cache.py174
-rw-r--r--python/vyos/xml_ref/op_definition.py49
-rw-r--r--python/vyos/xml_ref/pkg_cache/__init__.py0
-rw-r--r--python/vyos/xml_ref/update_cache.py51
125 files changed, 24570 insertions, 0 deletions
diff --git a/python/setup.py b/python/setup.py
new file mode 100644
index 0000000..2d614e7
--- /dev/null
+++ b/python/setup.py
@@ -0,0 +1,32 @@
+import os
+from setuptools import setup
+
+def packages(directory):
+ return [
+ _[0].replace('/','.')
+ for _ in os.walk(directory)
+ if os.path.isfile(os.path.join(_[0], '__init__.py'))
+ ]
+
+setup(
+ name = "vyos",
+ version = "1.3.0",
+ author = "VyOS maintainers and contributors",
+ author_email = "maintainers@vyos.net",
+ description = ("VyOS configuration libraries."),
+ license = "LGPLv2+",
+ keywords = "vyos",
+ url = "http://www.vyos.io",
+ packages = packages('vyos'),
+ long_description="VyOS configuration libraries",
+ classifiers=[
+ "Development Status :: 4 - Beta",
+ "Topic :: Utilities",
+ "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)",
+ ],
+ entry_points={
+ "console_scripts": [
+ "config-mgmt = vyos.config_mgmt:run",
+ ],
+ },
+)
diff --git a/python/vyos/__init__.py b/python/vyos/__init__.py
new file mode 100644
index 0000000..e3e14fd
--- /dev/null
+++ b/python/vyos/__init__.py
@@ -0,0 +1 @@
+from .base import ConfigError
diff --git a/python/vyos/accel_ppp.py b/python/vyos/accel_ppp.py
new file mode 100644
index 0000000..bae695f
--- /dev/null
+++ b/python/vyos/accel_ppp.py
@@ -0,0 +1,70 @@
+# Copyright (C) 2022-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/>.
+
+from vyos.utils.process import rc_cmd
+
+def get_server_statistics(accel_statistics, pattern, sep=':') -> dict:
+ import re
+
+ stat_dict = {'sessions': {}}
+
+ cpu = re.search(r'cpu(.*)', accel_statistics).group(0)
+ # Find all lines with pattern, for example 'sstp:'
+ data = re.search(rf'{pattern}(.*)', accel_statistics, re.DOTALL).group(0)
+ session_starting = re.search(r'starting(.*)', data).group(0)
+ session_active = re.search(r'active(.*)', data).group(0)
+
+ for entry in {cpu, session_starting, session_active}:
+ if sep in entry:
+ key, value = entry.split(sep)
+ if key in ['starting', 'active', 'finishing']:
+ stat_dict['sessions'][key] = value.strip()
+ continue
+ if key == 'cpu':
+ stat_dict['cpu_load_percentage'] = int(re.sub(r'%', '', value.strip()))
+ continue
+ stat_dict[key] = value.strip()
+ return stat_dict
+
+
+def accel_cmd(port: int, command: str) -> str:
+ _, output = rc_cmd(f'/usr/bin/accel-cmd -p{port} {command}')
+ return output
+
+
+def accel_out_parse(accel_output: list[str]) -> list[dict[str, str]]:
+ """ Parse accel-cmd show sessions output """
+ data_list: list[dict[str, str]] = list()
+ field_names: list[str] = list()
+
+ field_names_unstripped: list[str] = accel_output.pop(0).split('|')
+ for field_name in field_names_unstripped:
+ field_names.append(field_name.strip())
+
+ while accel_output:
+ if '|' not in accel_output[0]:
+ accel_output.pop(0)
+ continue
+
+ current_item: list[str] = accel_output.pop(0).split('|')
+ item_dict: dict[str, str] = {}
+
+ for field_index in range(len(current_item)):
+ field_name: str = field_names[field_index]
+ field_value: str = current_item[field_index].strip()
+ item_dict[field_name] = field_value
+
+ data_list.append(item_dict)
+
+ return data_list
diff --git a/python/vyos/accel_ppp_util.py b/python/vyos/accel_ppp_util.py
new file mode 100644
index 0000000..ae75e66
--- /dev/null
+++ b/python/vyos/accel_ppp_util.py
@@ -0,0 +1,238 @@
+# Copyright 2023-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/>.
+
+# The sole purpose of this module is to hold common functions used in
+# all kinds of implementations to verify the CLI configuration.
+# It is started by migrating the interfaces to the new get_config_dict()
+# approach which will lead to a lot of code that can be reused.
+
+# NOTE: imports should be as local as possible to the function which
+# makes use of it!
+
+from vyos import ConfigError
+from vyos.base import Warning
+from vyos.utils.dict import dict_search
+
+def get_pools_in_order(data: dict) -> list:
+ """Return a list of dictionaries representing pool data in the order
+ in which they should be allocated. Pool must be defined before we can
+ use it with 'next-pool' option.
+
+ Args:
+ data: A dictionary of pool data, where the keys are pool names and the
+ values are dictionaries containing the 'subnet' key and the optional
+ 'next_pool' key.
+
+ Returns:
+ list: A list of dictionaries
+
+ Raises:
+ ValueError: If a 'next_pool' key references a pool name that
+ has not been defined.
+ ValueError: If a circular reference is found in the 'next_pool' keys.
+
+ Example:
+ config_data = {
+ ... 'first-pool': {
+ ... 'next_pool': 'second-pool',
+ ... 'subnet': '192.0.2.0/25'
+ ... },
+ ... 'second-pool': {
+ ... 'next_pool': 'third-pool',
+ ... 'subnet': '203.0.113.0/25'
+ ... },
+ ... 'third-pool': {
+ ... 'subnet': '198.51.100.0/24'
+ ... },
+ ... 'foo': {
+ ... 'subnet': '100.64.0.0/24',
+ ... 'next_pool': 'second-pool'
+ ... }
+ ... }
+
+ % get_pools_in_order(config_data)
+ [{'third-pool': {'subnet': '198.51.100.0/24'}},
+ {'second-pool': {'next_pool': 'third-pool', 'subnet': '203.0.113.0/25'}},
+ {'first-pool': {'next_pool': 'second-pool', 'subnet': '192.0.2.0/25'}},
+ {'foo': {'next_pool': 'second-pool', 'subnet': '100.64.0.0/24'}}]
+ """
+ pools = []
+ unresolved_pools = {}
+
+ for pool, pool_config in data.items():
+ if "next_pool" not in pool_config or not pool_config["next_pool"]:
+ pools.insert(0, {pool: pool_config})
+ else:
+ unresolved_pools[pool] = pool_config
+
+ while unresolved_pools:
+ resolved_pools = []
+
+ for pool, pool_config in unresolved_pools.items():
+ next_pool_name = pool_config["next_pool"]
+
+ if any(p for p in pools if next_pool_name in p):
+ index = next(
+ (i for i, p in enumerate(pools) if next_pool_name in p), None
+ )
+ pools.insert(index + 1, {pool: pool_config})
+ resolved_pools.append(pool)
+ elif next_pool_name in unresolved_pools:
+ # next pool not yet resolved
+ pass
+ else:
+ raise ConfigError(
+ f"Pool '{next_pool_name}' not defined in configuration data"
+ )
+
+ if not resolved_pools:
+ raise ConfigError("Circular reference in configuration data")
+
+ for pool in resolved_pools:
+ unresolved_pools.pop(pool)
+
+ return pools
+
+
+def verify_accel_ppp_name_servers(config):
+ if "name_server_ipv4" in config:
+ if len(config["name_server_ipv4"]) > 2:
+ raise ConfigError(
+ "Not more then two IPv4 DNS name-servers " "can be configured"
+ )
+ if "name_server_ipv6" in config:
+ if len(config["name_server_ipv6"]) > 3:
+ raise ConfigError(
+ "Not more then three IPv6 DNS name-servers " "can be configured"
+ )
+
+
+def verify_accel_ppp_wins_servers(config):
+ if 'wins_server' in config and len(config['wins_server']) > 2:
+ raise ConfigError(
+ 'Not more then two WINS name-servers can be configured')
+
+
+def verify_accel_ppp_authentication(config, local_users=True):
+ """
+ Common helper function which must be used by all Accel-PPP services based
+ on get_config_dict()
+ """
+ # vertify auth settings
+ if local_users and dict_search("authentication.mode", config) == "local":
+ if (
+ dict_search("authentication.local_users", config) is None
+ or dict_search("authentication.local_users", config) == {}
+ ):
+ raise ConfigError(
+ "Authentication mode local requires local users to be configured!"
+ )
+
+ for user in dict_search("authentication.local_users.username", config):
+ user_config = config["authentication"]["local_users"]["username"][user]
+
+ if "password" not in user_config:
+ raise ConfigError(f'Password required for local user "{user}"')
+
+ if "rate_limit" in user_config:
+ # if up/download is set, check that both have a value
+ if not {"upload", "download"} <= set(user_config["rate_limit"]):
+ raise ConfigError(
+ f'User "{user}" has rate-limit configured for only one '
+ "direction but both upload and download must be given!"
+ )
+
+ elif dict_search("authentication.mode", config) == "radius":
+ if not dict_search("authentication.radius.server", config):
+ raise ConfigError("RADIUS authentication requires at least one server")
+
+ for server in dict_search("authentication.radius.server", config):
+ radius_config = config["authentication"]["radius"]["server"][server]
+ if "key" not in radius_config:
+ raise ConfigError(f'Missing RADIUS secret key for server "{server}"')
+
+ if dict_search("server_type", config) == 'ipoe' and dict_search(
+ "authentication.mode", config) == "local":
+ if not dict_search("authentication.interface", config):
+ raise ConfigError(
+ "Authentication mode local requires authentication interface to be configured!"
+ )
+ for interface in dict_search("authentication.interface", config):
+ user_config = config["authentication"]["interface"][interface]
+ if "mac" not in user_config:
+ raise ConfigError(
+ f'Users MAC addreses are not configured for interface "{interface}"')
+
+ if dict_search('authentication.radius.dynamic_author.server', config):
+ if not dict_search('authentication.radius.dynamic_author.key', config):
+ raise ConfigError('DAE/CoA server key required!')
+
+
+def verify_accel_ppp_ip_pool(vpn_config):
+ """
+ Common helper function which must be used by Accel-PPP
+ services (pptp, l2tp, sstp, pppoe) to verify client-ip-pool
+ and client-ipv6-pool
+ """
+ if dict_search("client_ip_pool", vpn_config):
+ for pool_name, pool_config in vpn_config["client_ip_pool"].items():
+ next_pool = dict_search(f"next_pool", pool_config)
+ if next_pool:
+ if next_pool not in vpn_config["client_ip_pool"]:
+ raise ConfigError(
+ f'Next pool "{next_pool}" does not exist')
+ if not dict_search(f"range", pool_config):
+ raise ConfigError(
+ f'Pool "{pool_name}" does not contain range but next-pool exists'
+ )
+ if not dict_search("gateway_address", vpn_config):
+ Warning("IPv4 Server requires gateway-address to be configured!")
+
+ default_pool = dict_search("default_pool", vpn_config)
+ if default_pool:
+ if not dict_search('client_ip_pool',
+ vpn_config) or default_pool not in dict_search(
+ 'client_ip_pool', vpn_config):
+ raise ConfigError(f'Default pool "{default_pool}" does not exists')
+
+ if 'client_ipv6_pool' in vpn_config:
+ for ipv6_pool, ipv6_pool_config in vpn_config['client_ipv6_pool'].items():
+ if 'delegate' in ipv6_pool_config and 'prefix' not in ipv6_pool_config:
+ raise ConfigError(
+ f'IPv6 delegate-prefix requires IPv6 prefix to be configured in "{ipv6_pool}"!')
+
+ if dict_search('authentication.mode', vpn_config) in ['local', 'noauth']:
+ if not dict_search('client_ip_pool', vpn_config) and not dict_search(
+ 'client_ipv6_pool', vpn_config):
+ if dict_search('server_type', vpn_config) == 'ipoe':
+ if 'interface' in vpn_config:
+ for interface, interface_config in vpn_config['interface'].items():
+ if dict_search('client_subnet', interface_config):
+ break
+ else:
+ raise ConfigError(
+ 'Local auth and noauth mode requires local client-ip-pool \
+ or client-ipv6-pool or client-subnet to be configured!')
+ else:
+ raise ConfigError(
+ "Local auth mode requires local client-ip-pool \
+ or client-ipv6-pool to be configured!")
+
+ if dict_search('client_ip_pool', vpn_config) and not dict_search(
+ 'default_pool', vpn_config):
+ Warning("'default-pool' is not defined")
+ if dict_search('client_ipv6_pool', vpn_config) and not dict_search(
+ 'default_ipv6_pool', vpn_config):
+ Warning("'default-ipv6-pool' is not defined")
diff --git a/python/vyos/airbag.py b/python/vyos/airbag.py
new file mode 100644
index 0000000..3c7a144
--- /dev/null
+++ b/python/vyos/airbag.py
@@ -0,0 +1,173 @@
+# Copyright 2019-2020 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 sys
+from datetime import datetime
+
+from vyos import debug
+from vyos.logger import syslog
+from vyos.version import get_full_version_data
+
+
+def enable(log=True):
+ if log:
+ _intercepting_logger()
+ _intercepting_exceptions()
+
+
+_noteworthy = []
+
+
+def noteworthy(msg):
+ """
+ noteworthy can be use to take note things which we may not want to
+ report to the user may but be worth including in bug report
+ if something goes wrong later on
+ """
+ _noteworthy.append(msg)
+
+
+# emulate a file object
+class _IO(object):
+ def __init__(self, std, log):
+ self.std = std
+ self.log = log
+
+ def write(self, message):
+ self.std.write(message)
+ for line in message.split('\n'):
+ s = line.rstrip()
+ if s:
+ self.log(s)
+
+ def flush(self):
+ self.std.flush()
+
+ def close(self):
+ pass
+
+
+# The function which will be used to report information
+# to users when an exception is unhandled
+def bug_report(dtype, value, trace):
+ from traceback import format_exception
+
+ sys.stdout.flush()
+ sys.stderr.flush()
+
+ information = get_full_version_data()
+ trace = '\n'.join(format_exception(dtype, value, trace)).replace('\n\n','\n')
+ note = ''
+ if _noteworthy:
+ note = 'noteworthy:\n'
+ note += '\n'.join(_noteworthy)
+
+ information.update({
+ 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+ 'trace': trace,
+ 'instructions': INSTRUCTIONS,
+ 'note': note,
+ })
+
+ sys.stdout.write(INTRO.format(**information))
+ sys.stdout.flush()
+
+ sys.stderr.write(FAULT.format(**information))
+ sys.stderr.flush()
+
+
+# define an exception handler to be run when an exception
+# reach the end of __main__ and was not intercepted
+def _intercepter(dtype, value, trace):
+ bug_report(dtype, value, trace)
+ if debug.enabled('developer'):
+ import pdb
+ pdb.pm()
+
+
+def _intercepting_logger(_singleton=[False]):
+ skip = _singleton.pop()
+ _singleton.append(True)
+ if skip:
+ return
+
+ # log to syslog any message sent to stderr
+ sys.stderr = _IO(sys.stderr, syslog.critical)
+
+
+# lists as default arguments in function is normally dangerous
+# as they will keep any modification performed, unless this is
+# what you want to do (in that case to only run the code once)
+def _intercepting_exceptions(_singleton=[False]):
+ skip = _singleton.pop()
+ _singleton.append(True)
+ if skip:
+ return
+
+ # install the handler to replace the default behaviour
+ # which just prints the exception trace on screen
+ sys.excepthook = _intercepter
+
+
+# Messages to print
+# if the key before the value has not time, syslog takes that as the source of the message
+
+FAULT = """\
+Report time: {date}
+Image version: VyOS {version}
+Release train: {release_train}
+
+Built by: {built_by}
+Built on: {built_on}
+Build UUID: {build_uuid}
+Build commit ID: {build_git}
+
+Architecture: {system_arch}
+Boot via: {boot_via}
+System type: {system_type}
+
+Hardware vendor: {hardware_vendor}
+Hardware model: {hardware_model}
+Hardware S/N: {hardware_serial}
+Hardware UUID: {hardware_uuid}
+
+{trace}
+{note}
+"""
+
+INTRO = """\
+VyOS had an issue completing a command.
+
+We are sorry that you encountered a problem while using VyOS.
+There are a few things you can do to help us (and yourself):
+{instructions}
+
+When reporting problems, please include as much information as possible:
+- do not obfuscate any data (feel free to contact us privately if your
+ business policy requires it)
+- and include all the information presented below
+
+"""
+
+INSTRUCTIONS = """\
+- Contact us using the online help desk if you have a subscription:
+ https://support.vyos.io/
+- Make sure you are running the latest version of VyOS available at:
+ https://vyos.net/get/
+- Consult the community forum to see how to handle this issue:
+ https://forum.vyos.io
+- Join us on Slack where our users exchange help and advice:
+ https://vyos.slack.com
+""".strip()
diff --git a/python/vyos/base.py b/python/vyos/base.py
new file mode 100644
index 0000000..ca96d96
--- /dev/null
+++ b/python/vyos/base.py
@@ -0,0 +1,72 @@
+# Copyright 2018-2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from textwrap import fill
+
+
+class BaseWarning:
+ def __init__(self, header, message, **kwargs):
+ self.message = message
+ self.kwargs = kwargs
+ if 'width' not in kwargs:
+ self.width = 72
+ if 'initial_indent' in kwargs:
+ del self.kwargs['initial_indent']
+ if 'subsequent_indent' in kwargs:
+ del self.kwargs['subsequent_indent']
+ self.textinitindent = header
+ self.standardindent = ''
+
+ def print(self):
+ 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)
+ if isfirstmessage:
+ isfirstmessage = False
+ initial_indent = self.standardindent
+ print(f'{mes}')
+ print('', flush=True)
+
+
+class Warning():
+ def __init__(self, message, **kwargs):
+ self.BaseWarn = BaseWarning('WARNING: ', message, **kwargs)
+ self.BaseWarn.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()
+
+
+class ConfigError(Exception):
+ def __init__(self, message):
+ # Reformat the message and trim it to 72 characters in length
+ message = fill(message, width=72)
+ # Call the base class constructor with the parameters it needs
+ super().__init__(message)
+
+class MigrationError(Exception):
+ def __init__(self, message):
+ # Reformat the message and trim it to 72 characters in length
+ message = fill(message, width=72)
+ # Call the base class constructor with the parameters it needs
+ super().__init__(message)
diff --git a/python/vyos/certbot_util.py b/python/vyos/certbot_util.py
new file mode 100644
index 0000000..bcb7838
--- /dev/null
+++ b/python/vyos/certbot_util.py
@@ -0,0 +1,58 @@
+# certbot_util -- adaptation of certbot_nginx name matching functions for VyOS
+# https://github.com/certbot/certbot/blob/master/LICENSE.txt
+
+from certbot_nginx._internal import parser
+
+NAME_RANK = 0
+START_WILDCARD_RANK = 1
+END_WILDCARD_RANK = 2
+REGEX_RANK = 3
+
+def _rank_matches_by_name(server_block_list, target_name):
+ """Returns a ranked list of server_blocks that match target_name.
+ Adapted from the function of the same name in
+ certbot_nginx.NginxConfigurator
+ """
+ matches = []
+ for server_block in server_block_list:
+ name_type, name = parser.get_best_match(target_name,
+ server_block['name'])
+ if name_type == 'exact':
+ matches.append({'vhost': server_block,
+ 'name': name,
+ 'rank': NAME_RANK})
+ elif name_type == 'wildcard_start':
+ matches.append({'vhost': server_block,
+ 'name': name,
+ 'rank': START_WILDCARD_RANK})
+ elif name_type == 'wildcard_end':
+ matches.append({'vhost': server_block,
+ 'name': name,
+ 'rank': END_WILDCARD_RANK})
+ elif name_type == 'regex':
+ matches.append({'vhost': server_block,
+ 'name': name,
+ 'rank': REGEX_RANK})
+
+ return sorted(matches, key=lambda x: x['rank'])
+
+def _select_best_name_match(matches):
+ """Returns the best name match of a ranked list of server_blocks.
+ Adapted from the function of the same name in
+ certbot_nginx.NginxConfigurator
+ """
+ if not matches:
+ return None
+ elif matches[0]['rank'] in [START_WILDCARD_RANK, END_WILDCARD_RANK]:
+ rank = matches[0]['rank']
+ wildcards = [x for x in matches if x['rank'] == rank]
+ return max(wildcards, key=lambda x: len(x['name']))['vhost']
+ else:
+ return matches[0]['vhost']
+
+def choose_server_block(server_block_list, target_name):
+ matches = _rank_matches_by_name(server_block_list, target_name)
+ server_blocks = [x for x in [_select_best_name_match(matches)]
+ if x is not None]
+ return server_blocks
+
diff --git a/python/vyos/component_version.py b/python/vyos/component_version.py
new file mode 100644
index 0000000..9421553
--- /dev/null
+++ b/python/vyos/component_version.py
@@ -0,0 +1,204 @@
+# Copyright 2022-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/>.
+
+"""
+Functions for reading/writing component versions.
+
+The config file version string has the following form:
+
+VyOS 1.3/1.4:
+
+// Warning: Do not remove the following line.
+// vyos-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack@3:conntrack-sync@2:dhcp-relay@2:dhcp-server@6:dhcpv6-server@1:dns-forwarding@3:firewall@5:https@2:interfaces@22:ipoe-server@1:ipsec@5:isis@1:l2tp@3:lldp@1:mdns@1:nat@5:ntp@1:pppoe-server@5:pptp@2:qos@1:quagga@8:rpki@1:salt@1:snmp@2:ssh@2:sstp@3:system@21:vrrp@2:vyos-accel-ppp@2:wanloadbalance@3:webproxy@2:zone-policy@1"
+// Release version: 1.3.0
+
+VyOS 1.2:
+
+/* Warning: Do not remove the following line. */
+/* === vyatta-config-version: "broadcast-relay@1:cluster@1:config-management@1:conntrack-sync@1:conntrack@1:dhcp-relay@2:dhcp-server@5:dns-forwarding@1:firewall@5:ipsec@5:l2tp@1:mdns@1:nat@4:ntp@1:pppoe-server@2:pptp@1:qos@1:quagga@7:snmp@1:ssh@1:system@10:vrrp@2:wanloadbalance@3:webgui@1:webproxy@2:zone-policy@1" === */
+/* Release version: 1.2.8 */
+
+"""
+
+import os
+import re
+import sys
+from dataclasses import dataclass
+from dataclasses import replace
+from typing import Optional
+
+from vyos.xml_ref import component_version
+from vyos.utils.file import write_file
+from vyos.version import get_version
+from vyos.defaults import directories
+
+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_RELEASE_VERSION_VYOS = r'// Release version:\s+(\S*)\s*'
+REGEX_RELEASE_VERSION_VYATTA = r'/\* Release version:\s+(\S*)\s*\*/'
+
+CONFIG_FILE_VERSION = """\
+// Warning: Do not remove the following line.
+// vyos-config-version: "{}"
+// Release 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)])) }
+
+@dataclass
+class VersionInfo:
+ component: Optional[dict[str,int]] = None
+ release: str = get_version()
+ vintage: str = 'vyos'
+ config_body: Optional[str] = None
+ footer_lines: Optional[list[str]] = None
+
+ def component_is_none(self) -> bool:
+ return bool(self.component is None)
+
+ def config_body_is_none(self) -> bool:
+ return bool(self.config_body is None)
+
+ def update_footer(self):
+ f = CONFIG_FILE_VERSION.format(component_to_string(self.component),
+ self.release)
+ self.footer_lines = f.splitlines()
+
+ def update_syntax(self):
+ self.vintage = 'vyos'
+ self.update_footer()
+
+ def update_release(self, release: str):
+ self.release = release
+ self.update_footer()
+
+ def update_component(self, key: str, version: int):
+ if not isinstance(version, int):
+ raise ValueError('version must be int')
+ if self.component is None:
+ self.component = {}
+ self.component[key] = version
+ self.component = dict(sorted(self.component.items(), key=lambda x: x[0]))
+ self.update_footer()
+
+ def update_config_body(self, config_str: str):
+ self.config_body = config_str
+
+ def write_string(self) -> str:
+ config_body = '' if self.config_body is None else self.config_body
+ footer_lines = [] if self.footer_lines is None else self.footer_lines
+
+ return config_body + '\n' + '\n'.join(footer_lines) + '\n'
+
+ def write(self, config_file):
+ string = self.write_string()
+ try:
+ write_file(config_file, string)
+ 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])]
+ 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()
+ try:
+ with open(config_file) as f:
+ config_str = f.read()
+ except OSError:
+ return None
+
+ if len(parts := warn_filter_vyos.split(config_str)) > 1:
+ vintage = 'vyos'
+ elif len(parts := warn_filter_vyatta.split(config_str)) > 1:
+ vintage = 'vyatta'
+ else:
+ version_info.config_body = parts[0] if parts else None
+ return version_info
+
+ version_info.vintage = vintage
+ version_info.config_body = parts[0]
+ version_lines = ''.join(parts[1:]).splitlines()
+ version_lines = [k for k in version_lines if k]
+ if len(version_lines) != 3:
+ raise ValueError(f'Malformed version strings: {version_lines}')
+
+ m = regex_filter[vintage]['component'].match(version_lines[1])
+ if not m:
+ raise ValueError(f'Malformed component string: {version_lines[1]}')
+ version_info.component = component_from_string(m.group(1))
+
+ m = regex_filter[vintage]['release'].match(version_lines[2])
+ if not m:
+ raise ValueError(f'Malformed component string: {version_lines[2]}')
+ version_info.release = m.group(1)
+
+ version_info.footer_lines = version_lines
+
+ 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'
+ )
+
+ 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 }
+
+def add_system_version(config_str: str = None, out_file: str = None):
+ """Wrap config string with system version and write to out_file.
+
+ For convenience, calling with no argument will write system version
+ string to stdout, for use in bash scripts.
+ """
+ version_info = version_info_from_system()
+ if config_str is not None:
+ version_info.update_config_body(config_str)
+ version_info.update_footer()
+ if out_file is not None:
+ version_info.write(out_file)
+ else:
+ sys.stdout.write(version_info.write_string())
diff --git a/python/vyos/compose_config.py b/python/vyos/compose_config.py
new file mode 100644
index 0000000..79a8718
--- /dev/null
+++ b/python/vyos/compose_config.py
@@ -0,0 +1,88 @@
+# Copyright 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/>.
+
+"""This module allows iterating over function calls to modify an existing
+config.
+"""
+
+import traceback
+from pathlib import Path
+from typing import TypeAlias, Union, Callable
+
+from vyos.configtree import ConfigTree
+from vyos.configtree import deep_copy as ct_deep_copy
+from vyos.utils.system import load_as_module_source
+
+ConfigObj: TypeAlias = Union[str, ConfigTree]
+
+class ComposeConfigError(Exception):
+ """Raised when an error occurs modifying a config object.
+ """
+
+class ComposeConfig:
+ """Apply function to config tree: for iteration over functions or files.
+ """
+ def __init__(self, config_obj: ConfigObj, checkpoint_file=None):
+ if isinstance(config_obj, ConfigTree):
+ self.config_tree = config_obj
+ else:
+ self.config_tree = ConfigTree(config_obj)
+
+ self.checkpoint = self.config_tree
+ self.checkpoint_file = checkpoint_file
+
+ def apply_func(self, func: Callable):
+ """Apply the function to the config tree.
+ """
+ if not callable(func):
+ raise ComposeConfigError(f'{func.__name__} is not callable')
+
+ if self.checkpoint_file is not None:
+ self.checkpoint = ct_deep_copy(self.config_tree)
+
+ try:
+ func(self.config_tree)
+ except Exception as e:
+ if self.checkpoint_file is not None:
+ self.config_tree = self.checkpoint
+ raise ComposeConfigError(e) from e
+
+ def apply_file(self, func_file: str, func_name: str):
+ """Apply named function from file.
+ """
+ try:
+ mod_name = Path(func_file).stem.replace('-', '_')
+ mod = load_as_module_source(mod_name, func_file)
+ func = getattr(mod, func_name)
+ except Exception as e:
+ raise ComposeConfigError(f'Error with {func_file}: {e}') from e
+
+ try:
+ self.apply_func(func)
+ except ComposeConfigError as e:
+ msg = str(e)
+ tb = f'{traceback.format_exc()}'
+ raise ComposeConfigError(f'Error in {func_file}: {msg}\n{tb}') from e
+
+ def to_string(self, with_version=False) -> str:
+ """Return the rendered config tree.
+ """
+ return self.config_tree.to_string(no_version=not with_version)
+
+ def write(self, config_file: str, with_version=False):
+ """Write the config tree to a file.
+ """
+ config_str = self.to_string(with_version=with_version)
+ Path(config_file).write_text(config_str)
diff --git a/python/vyos/config.py b/python/vyos/config.py
new file mode 100644
index 0000000..1fab467
--- /dev/null
+++ b/python/vyos/config.py
@@ -0,0 +1,622 @@
+# Copyright 2017-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/>.
+
+"""
+A library for reading VyOS running config data.
+
+This library is used internally by all config scripts of VyOS,
+but its API should be considered stable and safe to use
+in user scripts.
+
+Note that this module will not work outside VyOS.
+
+Node taxonomy
+#############
+
+There are multiple types of config tree nodes in VyOS, each requires
+its own set of operations.
+
+*Leaf nodes* (such as "address" in interfaces) can have values, but cannot
+have children.
+Leaf nodes can have one value, multiple values, or no values at all.
+
+For example, "system host-name" is a single-value leaf node,
+"system name-server" is a multi-value leaf node (commonly abbreviated "multi node"),
+and "system ip disable-forwarding" is a valueless leaf node.
+
+Non-leaf nodes cannot have values, but they can have child nodes. They are divided into
+two classes depending on whether the names of their children are fixed or not.
+For example, under "system", the names of all valid child nodes are predefined
+("login", "name-server" etc.).
+
+To the contrary, children of the "system task-scheduler task" node can have arbitrary names.
+Such nodes are called *tag nodes*. This terminology is confusing but we keep using it for lack
+of a better word. No one remembers if the "tag" in "task Foo" is "task" or "Foo",
+but the distinction is irrelevant in practice.
+
+Configuration modes
+###################
+
+VyOS has two distinct modes: operational mode and configuration mode. When a user logins,
+the CLI is in the operational mode. In this mode, only the running (effective) config is accessible for reading.
+
+When a user enters the "configure" command, a configuration session is setup. Every config session
+has its *proposed* (or *session*) config built on top of the current running config. When changes are commited, if commit succeeds,
+the proposed config is merged into the running config.
+
+In configuration mode, "base" functions like `exists`, `return_value` return values from the session config,
+while functions prefixed "effective" return values from the running config.
+
+In operational mode, all functions return values from the running config.
+"""
+
+import re
+import json
+from typing import Union
+
+import vyos.configtree
+from vyos.xml_ref import multi_to_list
+from vyos.xml_ref import from_source
+from vyos.xml_ref import ext_dict_merge
+from vyos.xml_ref import relative_defaults
+from vyos.utils.dict import get_sub_dict
+from vyos.utils.dict import mangle_dict_keys
+from vyos.configsource import ConfigSource
+from vyos.configsource import ConfigSourceSession
+
+class ConfigDict(dict):
+ _from_defaults = {}
+ _dict_kwargs = {}
+ def from_defaults(self, path: list[str]) -> bool:
+ return from_source(self._from_defaults, path)
+ @property
+ def kwargs(self) -> dict:
+ return self._dict_kwargs
+
+def config_dict_merge(src: dict, dest: Union[dict, ConfigDict]) -> ConfigDict:
+ if not isinstance(dest, ConfigDict):
+ dest = ConfigDict(dest)
+ return ext_dict_merge(src, dest)
+
+def config_dict_mangle_acme(name, cli_dict):
+ """
+ Load CLI PKI dictionary and if an ACME certificate is used, load it's content
+ and place it into the CLI dictionary as it would be a "regular" CLI PKI based
+ certificate with private key
+ """
+ from vyos.base import ConfigError
+ from vyos.defaults import directories
+ from vyos.utils.file import read_file
+ from vyos.pki import encode_certificate
+ from vyos.pki import encode_private_key
+ from vyos.pki import load_certificate
+ from vyos.pki import load_private_key
+
+ try:
+ vyos_certbot_dir = directories['certbot']
+
+ if 'acme' in cli_dict:
+ tmp = read_file(f'{vyos_certbot_dir}/live/{name}/cert.pem')
+ tmp = load_certificate(tmp, wrap_tags=False)
+ cert_base64 = "".join(encode_certificate(tmp).strip().split("\n")[1:-1])
+
+ tmp = read_file(f'{vyos_certbot_dir}/live/{name}/privkey.pem')
+ tmp = load_private_key(tmp, wrap_tags=False)
+ key_base64 = "".join(encode_private_key(tmp).strip().split("\n")[1:-1])
+ # install ACME based PEM keys into "regular" CLI config keys
+ cli_dict.update({'certificate' : cert_base64, 'private' : {'key' : key_base64}})
+ except:
+ raise ConfigError(f'Unable to load ACME certificates for "{name}"!')
+
+ return cli_dict
+
+class Config(object):
+ """
+ The class of config access objects.
+
+ Internally, in the current implementation, this object is *almost* stateless,
+ the only state it keeps is relative *config path* for convenient access to config
+ subtrees.
+ """
+ def __init__(self, session_env=None, config_source=None):
+ if config_source is None:
+ self._config_source = ConfigSourceSession(session_env)
+ else:
+ if not isinstance(config_source, ConfigSource):
+ raise TypeError("config_source not of type ConfigSource")
+ self._config_source = config_source
+
+ self._level = []
+ self._dict_cache = {}
+ self.dependency_list = []
+ (self._running_config,
+ self._session_config) = self._config_source.get_configtree_tuple()
+
+ def get_config_tree(self, effective=False):
+ if effective:
+ return self._running_config
+ return self._session_config
+
+ def _make_path(self, path):
+ # Backwards-compatibility stuff: original implementation used string paths
+ # libvyosconfig paths are lists, but since node names cannot contain whitespace,
+ # splitting at whitespace is reasonably safe.
+ # It may cause problems with exists() when it's used for checking values,
+ # since values may contain whitespace.
+ if isinstance(path, str):
+ path = re.split(r'\s+', path)
+ elif isinstance(path, list):
+ pass
+ else:
+ raise TypeError("Path must be a whitespace-separated string or a list")
+ return (self._level + path)
+
+ def set_level(self, path):
+ """
+ Set the *edit level*, that is, a relative config tree path.
+ Once set, all operations will be relative to this path,
+ for example, after ``set_level("system")``, calling
+ ``exists("name-server")`` is equivalent to calling
+ ``exists("system name-server"`` without ``set_level``.
+
+ Args:
+ path (str|list): relative config path
+ """
+ # Make sure there's always a space between default path (level)
+ # and path supplied as method argument
+ # XXX: for small strings in-place concatenation is not a problem
+ if isinstance(path, str):
+ if path:
+ self._level = re.split(r'\s+', path)
+ else:
+ self._level = []
+ elif isinstance(path, list):
+ self._level = path.copy()
+ else:
+ raise TypeError("Level path must be either a whitespace-separated string or a list")
+
+ def get_level(self):
+ """
+ Gets the current edit level.
+
+ Returns:
+ str: current edit level
+ """
+ return(self._level.copy())
+
+ def exists(self, path):
+ """
+ Checks if a node or value with given path exists in the proposed config.
+
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if node or value exists in the proposed config, False otherwise
+
+ Note:
+ This function should not be used outside of configuration sessions.
+ In operational mode scripts, use ``exists_effective``.
+ """
+ if self._session_config is None:
+ return False
+
+ # Assume the path is a node path first
+ if self._session_config.exists(self._make_path(path)):
+ return True
+ else:
+ # If that check fails, it may mean the path has a value at the end.
+ # libvyosconfig exists() works only for _nodes_, not _values_
+ # libvyattacfg also worked for values, so we emulate that case here
+ if isinstance(path, str):
+ path = re.split(r'\s+', path)
+ path_without_value = path[:-1]
+ try:
+ # return_values() is safe to use with single-value nodes,
+ # it simply returns a single-item list in that case.
+ values = self._session_config.return_values(self._make_path(path_without_value))
+
+ # If we got this far, the node does exist and has values,
+ # so we need to check if it has the value in question among its values.
+ return (path[-1] in values)
+ except vyos.configtree.ConfigTreeError:
+ # Even the parent node doesn't exist at all
+ return False
+
+ def session_changed(self):
+ """
+ Returns:
+ True if the config session has uncommited changes, False otherwise.
+ """
+ return self._config_source.session_changed()
+
+ def in_session(self):
+ """
+ Returns:
+ True if called from a configuration session, False otherwise.
+ """
+ return self._config_source.in_session()
+
+ def show_config(self, path=[], default=None, effective=False):
+ """
+ Args:
+ path (str list): Configuration tree path, or empty
+ default (str): Default value to return
+
+ Returns:
+ str: working configuration
+ """
+ return self._config_source.show_config(path, default, effective)
+
+ def get_cached_root_dict(self, effective=False):
+ cached = self._dict_cache.get(effective, {})
+ if cached:
+ return cached
+
+ if effective:
+ config = self._running_config
+ else:
+ config = self._session_config
+
+ if config:
+ config_dict = json.loads(config.to_json())
+ else:
+ config_dict = {}
+
+ self._dict_cache[effective] = config_dict
+
+ return config_dict
+
+ def verify_mangling(self, key_mangling):
+ if not (isinstance(key_mangling, tuple) and \
+ (len(key_mangling) == 2) and \
+ isinstance(key_mangling[0], str) and \
+ isinstance(key_mangling[1], str)):
+ raise ValueError("key_mangling must be a tuple of two strings")
+
+ 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,
+ with_defaults=False,
+ with_recursive_defaults=False,
+ with_pki=False):
+ """
+ Args:
+ path (str list): Configuration tree path, can be empty
+ effective=False: effective or session config
+ key_mangling=None: mangle dict keys according to regex and replacement
+ get_first_key=False: if k = path[:-1], return sub-dict d[k] instead of {k: d[k]}
+ no_multi_convert=False: if convert, return single value of multi node as list
+
+ Returns: a dict representation of the config under path
+ """
+ kwargs = locals().copy()
+ del kwargs['self']
+ del kwargs['no_multi_convert']
+ del kwargs['with_defaults']
+ del kwargs['with_recursive_defaults']
+ del kwargs['with_pki']
+
+ lpath = self._make_path(path)
+ root_dict = self.get_cached_root_dict(effective)
+ conf_dict = get_sub_dict(root_dict, lpath, get_first_key=get_first_key)
+
+ rpath = lpath if get_first_key else lpath[:-1]
+
+ if not no_multi_convert:
+ conf_dict = multi_to_list(rpath, conf_dict)
+
+ if key_mangling is not None:
+ self.verify_mangling(key_mangling)
+ conf_dict = mangle_dict_keys(conf_dict,
+ key_mangling[0], key_mangling[1],
+ abs_path=rpath,
+ no_tag_node_value_mangle=no_tag_node_value_mangle)
+
+ if with_defaults or with_recursive_defaults:
+ defaults = self.get_config_defaults(**kwargs,
+ recursive=with_recursive_defaults)
+ conf_dict = config_dict_merge(defaults, conf_dict)
+ else:
+ conf_dict = ConfigDict(conf_dict)
+
+ if with_pki and conf_dict:
+ pki_dict = self.get_config_dict(['pki'], key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True,
+ get_first_key=True)
+ if pki_dict:
+ if 'certificate' in pki_dict:
+ for certificate in pki_dict['certificate']:
+ pki_dict['certificate'][certificate] = config_dict_mangle_acme(
+ certificate, pki_dict['certificate'][certificate])
+
+ conf_dict['pki'] = pki_dict
+
+ interfaces_root = root_dict.get('interfaces', {})
+ setattr(conf_dict, 'interfaces_root', interfaces_root)
+
+ # save optional args for a call to get_config_defaults
+ setattr(conf_dict, '_dict_kwargs', kwargs)
+
+ return conf_dict
+
+ def get_config_defaults(self, path=[], effective=False, key_mangling=None,
+ no_tag_node_value_mangle=False, get_first_key=False,
+ recursive=False) -> dict:
+ lpath = self._make_path(path)
+ root_dict = self.get_cached_root_dict(effective)
+ conf_dict = get_sub_dict(root_dict, lpath, get_first_key)
+
+ defaults = relative_defaults(lpath, conf_dict,
+ get_first_key=get_first_key,
+ recursive=recursive)
+
+ rpath = lpath if get_first_key else lpath[:-1]
+
+ if key_mangling is not None:
+ self.verify_mangling(key_mangling)
+ defaults = mangle_dict_keys(defaults,
+ key_mangling[0], key_mangling[1],
+ abs_path=rpath,
+ no_tag_node_value_mangle=no_tag_node_value_mangle)
+
+ return defaults
+
+ def merge_defaults(self, config_dict: ConfigDict, recursive=False):
+ if not isinstance(config_dict, ConfigDict):
+ raise TypeError('argument is not of type ConfigDict')
+ if not config_dict.kwargs:
+ raise ValueError('argument missing metadata')
+
+ args = config_dict.kwargs
+ d = self.get_config_defaults(**args, recursive=recursive)
+ config_dict = config_dict_merge(d, config_dict)
+ return config_dict
+
+ def is_multi(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node can have multiple values, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ self._config_source.set_level(self.get_level)
+ return self._config_source.is_multi(path)
+
+ def is_tag(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node is a tag node, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ self._config_source.set_level(self.get_level)
+ return self._config_source.is_tag(path)
+
+ def is_leaf(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node is a leaf node, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ self._config_source.set_level(self.get_level)
+ return self._config_source.is_leaf(path)
+
+ def return_value(self, path, default=None):
+ """
+ Retrieve a value of single-value leaf node in the running or proposed config
+
+ Args:
+ path (str): Configuration tree path
+ default (str): Default value to return if node does not exist
+
+ Returns:
+ str: Node value, if it has any
+ None: if node is valueless *or* if it doesn't exist
+
+ Note:
+ Due to the issue with treatment of valueless nodes by this function,
+ valueless nodes should be checked with ``exists`` instead.
+
+ This function cannot be used outside a configuration session.
+ In operational mode scripts, use ``return_effective_value``.
+ """
+ if self._session_config:
+ try:
+ value = self._session_config.return_value(self._make_path(path))
+ except vyos.configtree.ConfigTreeError:
+ value = None
+ else:
+ value = None
+
+ if not value:
+ return(default)
+ else:
+ return(value)
+
+ def return_values(self, path, default=[]):
+ """
+ Retrieve all values of a multi-value leaf node in the running or proposed config
+
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ str list: Node values, if it has any
+ []: if node does not exist
+
+ Note:
+ This function cannot be used outside a configuration session.
+ In operational mode scripts, use ``return_effective_values``.
+ """
+ if self._session_config:
+ try:
+ values = self._session_config.return_values(self._make_path(path))
+ except vyos.configtree.ConfigTreeError:
+ values = []
+ else:
+ values = []
+
+ if not values:
+ return(default.copy())
+ else:
+ return(values)
+
+ def list_nodes(self, path, default=[]):
+ """
+ Retrieve names of all children of a tag node in the running or proposed config
+
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ string list: child node names
+
+ """
+ if self._session_config:
+ try:
+ nodes = self._session_config.list_nodes(self._make_path(path))
+ except vyos.configtree.ConfigTreeError:
+ nodes = []
+ else:
+ nodes = []
+
+ if not nodes:
+ return(default.copy())
+ else:
+ return(nodes)
+
+ def exists_effective(self, path):
+ """
+ Checks if a node or value exists in the running (effective) config.
+
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if node exists in the running config, False otherwise
+
+ Note:
+ This function is safe to use in operational mode. In configuration mode,
+ it ignores uncommited changes.
+ """
+ if self._running_config is None:
+ return False
+
+ # Assume the path is a node path first
+ if self._running_config.exists(self._make_path(path)):
+ return True
+ else:
+ # If that check fails, it may mean the path has a value at the end.
+ # libvyosconfig exists() works only for _nodes_, not _values_
+ # libvyattacfg also worked for values, so we emulate that case here
+ if isinstance(path, str):
+ path = re.split(r'\s+', path)
+ path_without_value = path[:-1]
+ try:
+ # return_values() is safe to use with single-value nodes,
+ # it simply returns a single-item list in that case.
+ values = self._running_config.return_values(self._make_path(path_without_value))
+
+ # If we got this far, the node does exist and has values,
+ # so we need to check if it has the value in question among its values.
+ return (path[-1] in values)
+ except vyos.configtree.ConfigTreeError:
+ # Even the parent node doesn't exist at all
+ return False
+
+
+ def return_effective_value(self, path, default=None):
+ """
+ Retrieve a values of a single-value leaf node in a running (effective) config
+
+ Args:
+ path (str): Configuration tree path
+ default (str): Default value to return if node does not exist
+
+ Returns:
+ str: Node value
+ """
+ if self._running_config:
+ try:
+ value = self._running_config.return_value(self._make_path(path))
+ except vyos.configtree.ConfigTreeError:
+ value = None
+ else:
+ value = None
+
+ if not value:
+ return(default)
+ else:
+ return(value)
+
+ def return_effective_values(self, path, default=[]):
+ """
+ Retrieve all values of a multi-value node in a running (effective) config
+
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ str list: A list of values
+ """
+ if self._running_config:
+ try:
+ values = self._running_config.return_values(self._make_path(path))
+ except vyos.configtree.ConfigTreeError:
+ values = []
+ else:
+ values = []
+
+ if not values:
+ return(default.copy())
+ else:
+ return(values)
+
+ def list_effective_nodes(self, path, default=[]):
+ """
+ Retrieve names of all children of a tag node in the running config
+
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ str list: child node names
+ """
+ if self._running_config:
+ try:
+ nodes = self._running_config.list_nodes(self._make_path(path))
+ except vyos.configtree.ConfigTreeError:
+ nodes = []
+ else:
+ nodes = []
+
+ if not nodes:
+ return(default.copy())
+ else:
+ return(nodes)
diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py
new file mode 100644
index 0000000..d518737
--- /dev/null
+++ b/python/vyos/config_mgmt.py
@@ -0,0 +1,761 @@
+# Copyright 2023-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/>.
+
+import os
+import re
+import sys
+import gzip
+import logging
+
+from typing import Optional
+from typing import Tuple
+from filecmp import cmp
+from datetime import datetime
+from textwrap import dedent
+from pathlib import Path
+from tabulate import tabulate
+from shutil import copy, chown
+from urllib.parse import urlsplit
+from urllib.parse import urlunsplit
+
+from vyos.config import Config
+from vyos.configtree import ConfigTree
+from vyos.configtree import ConfigTreeError
+from vyos.configtree import show_diff
+from vyos.load_config import load
+from vyos.load_config import LoadConfigError
+from vyos.defaults import directories
+from vyos.version import get_full_version_data
+from vyos.utils.io import ask_yes_no
+from vyos.utils.boot import boot_configuration_complete
+from vyos.utils.process import is_systemd_service_active
+from vyos.utils.process import rc_cmd
+
+SAVE_CONFIG = '/usr/libexec/vyos/vyos-save-config.py'
+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'}
+
+DEFAULT_TIME_MINUTES = 10
+timer_name = 'commit-confirm'
+
+config_file = os.path.join(directories['config'], 'config.boot')
+archive_dir = os.path.join(directories['config'], 'archive')
+archive_config_file = os.path.join(archive_dir, 'config.boot')
+commit_log_file = os.path.join(archive_dir, 'commits')
+logrotate_conf = os.path.join(archive_dir, 'lr.conf')
+logrotate_state = os.path.join(archive_dir, 'lr.state')
+rollback_config = os.path.join(archive_dir, 'config.boot-rollback')
+prerollback_config = os.path.join(archive_dir, 'config.boot-prerollback')
+tmp_log_entry = '/tmp/commit-rev-entry'
+
+logger = logging.getLogger('config_mgmt')
+logger.setLevel(logging.INFO)
+ch = logging.StreamHandler()
+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}'
+ else:
+ cmd = f'{SAVE_CONFIG} {target} --write-json-file {json_out}'
+ rc, out = rc_cmd(cmd)
+ 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
+ if allow_missing_config and not os.path.exists(config_file):
+ return True
+ tmp_save = '/tmp/config.running'
+ save_config(tmp_save)
+ ret = not cmp(tmp_save, config_file, shallow=False)
+ os.unlink(tmp_save)
+ return ret
+
+def get_file_revision(rev: int):
+ revision = os.path.join(archive_dir, f'config.boot.{rev}.gz')
+ try:
+ with gzip.open(revision) as f:
+ r = f.read().decode()
+ except FileNotFoundError:
+ logger.warning(f'commit revision {rev} not available')
+ 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)
+ if diff_tree.add.exists(path) or diff_tree.sub.exists(path):
+ return True
+ return False
+
+class ConfigMgmtError(Exception):
+ pass
+
+class ConfigMgmt:
+ def __init__(self, session_env=None, config=None):
+ if session_env:
+ self._session_env = session_env
+ else:
+ self._session_env = None
+
+ if config is None:
+ config = Config()
+
+ d = config.get_config_dict(['system', 'config-management'],
+ key_mangling=('-', '_'),
+ get_first_key=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', '')
+ if config.exists(['system', 'host-name']):
+ self.hostname = config.return_value(['system', 'host-name'])
+ if config.exists(['system', 'domain-name']):
+ tmp = config.return_value(['system', 'domain-name'])
+ self.hostname += f'.{tmp}'
+ else:
+ self.hostname = 'vyos'
+
+ # upload only on existence of effective values, notably, on boot.
+ # one still needs session self.locations (above) for setting
+ # post-commit hook in conf_mode script
+ path = ['system', 'config-management', 'commit-archive', 'location']
+ if config.exists_effective(path):
+ self.effective_locations = config.return_effective_values(path)
+ else:
+ self.effective_locations = []
+
+ # 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.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
+ '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():
+ W = '\nYou should save previous commits before commit-confirm !\n'
+ else:
+ W = ''
+
+ prompt_str = f'''
+commit-confirm will automatically reboot in {minutes} minutes unless changes
+are confirmed.\n
+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"'
+ 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}'
+ os.system(cmd)
+
+ msg = f'Initialized commit-confirm; {minutes} minutes to confirm before reboot'
+ return msg, 0
+
+ def confirm(self) -> Tuple[str,int]:
+ """Do not reboot to saved config following 'commit-confirm'.
+ Update commit log and archive.
+ """
+ if not is_systemd_service_active(f'{timer_name}.timer'):
+ msg = 'No confirm pending'
+ return msg, 0
+
+ cmd = f'sudo systemctl stop --quiet {timer_name}.timer'
+ rc, out = rc_cmd(cmd)
+ if rc != 0:
+ raise ConfigMgmtError(out)
+
+ # kill notify
+ cmd = 'sudo pkill -f commit-confirm-notify.py'
+ rc, out = rc_cmd(cmd)
+ if rc != 0:
+ raise ConfigMgmtError(out)
+
+ entry = self._read_tmp_log_entry()
+
+ if self._archive_active_config():
+ self._add_log_entry(**entry)
+ self._update_archive()
+
+ msg = 'Reboot timer stopped'
+ return msg, 0
+
+ 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
+ rc, out = rc_cmd('sudo systemctl reboot')
+ if rc != 0:
+ raise ConfigMgmtError(out)
+
+ 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}'
+ return msg, 1
+
+ prompt_str = 'Proceed with reboot ?'
+ if not no_prompt and not ask_yes_no(prompt_str, default=True):
+ msg = 'Canceling rollback'
+ return msg, 0
+
+ rc, out = rc_cmd(f'sudo cp {archive_config_file} {prerollback_config}')
+ if rc != 0:
+ raise ConfigMgmtError(out)
+
+ path = os.path.join(archive_dir, f'config.boot.{rev}.gz')
+ with gzip.open(path) as f:
+ config = f.read()
+ try:
+ with open(rollback_config, 'wb') as f:
+ f.write(config)
+ copy(rollback_config, config_file)
+ except OSError as e:
+ raise ConfigMgmtError from e
+
+ rc, out = rc_cmd('sudo systemctl reboot')
+ if rc != 0:
+ raise ConfigMgmtError(out)
+
+ return msg, 0
+
+ def rollback_soft(self, rev: int):
+ """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}'
+ return msg, 1
+
+ rollback_ct = self._get_config_tree_revision(rev)
+ try:
+ load(rollback_ct, switch='explicit')
+ print('Rollback diff has been applied.')
+ print('Use "compare" to review the changes or "commit" to apply them.')
+ except LoadConfigError as e:
+ raise ConfigMgmtError(e) from e
+
+ return msg, 0
+
+ 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.
+ """
+ ct1 = self.active_config
+ ct2 = self.working_config
+ msg = 'No changes between working and active configurations.\n'
+ if saved:
+ ct1 = self._get_saved_config_tree()
+ ct2 = self.working_config
+ msg = 'No changes between working and saved configurations.\n'
+ 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)
+ ct2 = self.working_config
+ msg = f'No changes between working and revision {rev1} configurations.\n'
+ if rev2 is not None:
+ if not self._check_revision_number(rev2):
+ return f'Invalid revision number {rev2}', 1
+ # compare older to newer
+ ct2 = ct1
+ ct1 = self._get_config_tree_revision(rev2)
+ msg = f'No changes between revisions {rev2} and {rev1} configurations.\n'
+
+ out = ''
+ path = [] if commands else self.edit_path
+ try:
+ if commands:
+ out = show_diff(ct1, ct2, path=path, commands=True)
+ else:
+ out = show_diff(ct1, ct2, path=path)
+ except ConfigTreeError as e:
+ return e, 1
+
+ if out:
+ msg = out
+
+ return msg, 0
+
+ def wrap_compare(self, options) -> Tuple[str,int]:
+ """Interface to vyatta-cfg-run: args collected as 'options' to parse
+ for compare.
+ """
+ cmnds = False
+ r1 = None
+ r2 = None
+ if 'commands' in options:
+ cmnds=True
+ options.remove('commands')
+ for i in options:
+ if not i.isnumeric():
+ options.remove(i)
+ if len(options) > 0:
+ r1 = int(options[0])
+ if len(options) > 1:
+ r2 = int(options[1])
+
+ return self.compare(commands=cmnds, rev1=r1, rev2=r2)
+
+ # Initialization and post-commit hooks for conf-mode
+ #
+ def initialize_revision(self):
+ """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)
+ try:
+ os.makedirs(json_dir, exist_ok=True)
+ chown(json_dir, group='vyattacfg')
+ except OSError as e:
+ logger.warning(f'cannot create {json_dir}: {e}')
+
+ self._add_logrotate_conf()
+
+ if (not os.path.exists(commit_log_file) or
+ self._get_number_of_revisions() == 0):
+ user = self._get_user()
+ via = 'init'
+ comment = ''
+ # add empty init config before boot-config load for revision
+ # and diff consistency
+ if self._archive_active_config():
+ self._add_log_entry(user, via, comment)
+ self._update_archive()
+
+ os.umask(mask)
+
+ def commit_revision(self):
+ """Update commit log and rotate archived config.boot.
+
+ commit_revision is called in post-commit-hooks, if
+ ['commit-archive', 'commit-revisions'] is configured.
+ """
+ if os.getenv('IN_COMMIT_CONFIRM', ''):
+ self._new_log_entry(tmp_file=tmp_log_entry)
+ return
+
+ if self._archive_active_config():
+ self._add_log_entry()
+ self._update_archive()
+
+ def commit_archive(self):
+ """Upload config to remote archive.
+ """
+ from vyos.remote import upload
+
+ hostname = self.hostname
+ t = datetime.now()
+ timestamp = t.strftime('%Y%m%d_%H%M%S')
+ remote_file = f'config.boot-{hostname}.{timestamp}'
+ source_address = self.source_address
+
+ if self.effective_locations:
+ print("Archiving config...")
+ for location in self.effective_locations:
+ url = urlsplit(location)
+ _, _, 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)
+
+ # op-mode functions
+ #
+ def get_raw_log_data(self) -> list:
+ """Return list of dicts of log data:
+ keys: [timestamp, user, commit_via, commit_comment]
+ """
+ log = self._get_log_entries()
+ res_l = []
+ for line in log:
+ d = self._get_log_entry(line)
+ res_l.append(d)
+
+ return res_l
+
+ @staticmethod
+ def format_log_data(data: list) -> 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")
+
+ res_l.append([l_no, time_str,
+ f"by {l['user']}", f"via {l['commit_via']}"])
+
+ if l['commit_comment'] != 'commit': # default comment
+ res_l.append([None, l['commit_comment']])
+
+ ret = tabulate(res_l, tablefmt="plain")
+ return ret
+
+ @staticmethod
+ def format_log_data_brief(data: list) -> str:
+ """Return 'brief' form of log data as str.
+
+ Slightly compacted format used in completion help for
+ '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")
+
+ res_l.append(['\t', l_no, time_str,
+ f"{l['user']}", f"by {l['commit_via']}"])
+
+ ret = tabulate(res_l, tablefmt="plain")
+ return ret
+
+ 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))
+ return out
+
+ out, _ = self.compare(commands=commands, rev1=rev, rev2=rev2)
+ return out
+
+ def show_commit_file(self, rev: int) -> str:
+ return self._get_file_revision(rev)
+
+ # utility functions
+ #
+
+ def _get_saved_config_tree(self):
+ with open(config_file) as f:
+ c = f.read()
+ return ConfigTree(c)
+
+ def _get_file_revision(self, rev: int):
+ if rev not in range(0, self._get_number_of_revisions()):
+ raise ConfigMgmtError('revision not available')
+ revision = os.path.join(archive_dir, f'config.boot.{rev}.gz')
+ with gzip.open(revision) as f:
+ r = f.read().decode()
+ return r
+
+ def _get_config_tree_revision(self, rev: int):
+ c = self._get_file_revision(rev)
+ return ConfigTree(c)
+
+ def _add_logrotate_conf(self):
+ conf: str = dedent(f"""\
+ {archive_config_file} {{
+ su root vyattacfg
+ rotate {self.max_revisions}
+ start 0
+ compress
+ copy
+ }}
+ """)
+ conf_file = Path(logrotate_conf)
+ conf_file.write_text(conf)
+ conf_file.chmod(0o644)
+
+ def _archive_active_config(self) -> bool:
+ save_to_tmp = (boot_configuration_complete() or not
+ os.path.isfile(archive_config_file))
+ mask = os.umask(0o113)
+
+ ext = os.getpid()
+ cmp_saved = f'/tmp/config.boot.{ext}'
+ if save_to_tmp:
+ save_config(cmp_saved, json_out=config_json)
+ else:
+ copy(config_file, cmp_saved)
+
+ # on boot, we need to manually create the config.json file; after
+ # boot, it is written by save_config, above
+ if not os.path.exists(config_json):
+ ct = self._get_saved_config_tree()
+ try:
+ with open(config_json, 'w') as f:
+ f.write(ct.to_json())
+ chown(config_json, group='vyattacfg')
+ except OSError as e:
+ logger.warning(f'cannot create {config_json}: {e}')
+
+ try:
+ if cmp(cmp_saved, archive_config_file, shallow=False):
+ os.unlink(cmp_saved)
+ os.umask(mask)
+ return False
+ except FileNotFoundError:
+ pass
+
+ rc, out = rc_cmd(f'sudo mv {cmp_saved} {archive_config_file}')
+ os.umask(mask)
+
+ if rc != 0:
+ logger.critical(f'mv file to archive failed: {out}')
+ return False
+
+ return True
+
+ @staticmethod
+ def _update_archive():
+ 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
+ """
+ entries = []
+ if os.path.exists(commit_log_file):
+ with open(commit_log_file) as f:
+ entries = f.readlines()
+
+ return entries
+
+ def _get_number_of_revisions(self) -> int:
+ l = self._get_log_entries()
+ return len(l)
+
+ def _check_revision_number(self, rev: int) -> bool:
+ self.num_revisions = self._get_number_of_revisions()
+ if not 0 <= rev < self.num_revisions:
+ return False
+ return True
+
+ @staticmethod
+ def _get_user() -> str:
+ import pwd
+
+ try:
+ user = os.getlogin()
+ except OSError:
+ try:
+ user = pwd.getpwuid(os.geteuid())[0]
+ except KeyError:
+ 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]:
+ # Format log entry and return str or write to file.
+ #
+ # Usage is within a post-commit hook, using env values. In case of
+ # commit-confirm, it can be written to a temporary file for
+ # inclusion on 'confirm'.
+ from time import time
+
+ if timestamp is None:
+ timestamp = int(time())
+
+ if not user:
+ user = self._get_user()
+ if not commit_via:
+ commit_via = os.getenv('COMMIT_VIA', 'other')
+ if not commit_comment:
+ commit_comment = os.getenv('COMMIT_COMMENT', 'commit')
+
+ # the commit log reserves '|' as field demarcation, so replace in
+ # comment if present; undo this in _get_log_entry, below
+ if re.search(r'\|', commit_comment):
+ commit_comment = commit_comment.replace('|', '%%')
+
+ entry = f'|{timestamp}|{user}|{commit_via}|{commit_comment}|\n'
+
+ mask = os.umask(0o113)
+ if tmp_file is not None:
+ try:
+ with open(tmp_file, 'w') as f:
+ f.write(entry)
+ except OSError as e:
+ logger.critical(f'write to {tmp_file} failed: {e}')
+ os.umask(mask)
+ return None
+
+ os.umask(mask)
+ return entry
+
+ @staticmethod
+ def _get_log_entry(line: str) -> dict:
+ log_fmt = re.compile(r'\|.*\|\n?$')
+ keys = ['user', 'commit_via', 'commit_comment', 'timestamp']
+ if not log_fmt.match(line):
+ logger.critical(f'Invalid log format {line}')
+ return {}
+
+ 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]))
+
+ return d
+
+ def _read_tmp_log_entry(self) -> dict:
+ try:
+ with open(tmp_log_entry) as f:
+ entry = f.read()
+ os.unlink(tmp_log_entry)
+ except OSError as e:
+ logger.critical(f'error on file {tmp_log_entry}: {e}')
+
+ return self._get_log_entry(entry)
+
+ 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)
+
+ log_entries = self._get_log_entries()
+ log_entries.insert(0, entry)
+ if len(log_entries) > self.max_revisions:
+ log_entries = log_entries[:-1]
+
+ try:
+ with open(commit_log_file, 'w') as f:
+ f.writelines(log_entries)
+ except OSError as e:
+ logger.critical(e)
+
+ os.umask(mask)
+
+# entry_point for console script
+#
+def run():
+ from argparse import ArgumentParser, REMAINDER
+
+ config_mgmt = ConfigMgmt()
+
+ for s in list(commit_hooks):
+ if sys.argv[0].replace('-', '_').endswith(s):
+ func = getattr(config_mgmt, s)
+ try:
+ func()
+ except Exception as e:
+ print(f'{s}: {e}')
+ sys.exit(0)
+
+ 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")
+ wrap_compare.add_argument('--options', nargs=REMAINDER)
+
+ args = vars(parser.parse_args())
+
+ func = getattr(config_mgmt, args['subcommand'])
+ del args['subcommand']
+
+ res = ''
+ try:
+ res, rc = func(**args)
+ except ConfigMgmtError as e:
+ print(e)
+ sys.exit(1)
+ if res:
+ print(res)
+ sys.exit(rc)
diff --git a/python/vyos/configdep.py b/python/vyos/configdep.py
new file mode 100644
index 0000000..cf7c9d5
--- /dev/null
+++ b/python/vyos/configdep.py
@@ -0,0 +1,211 @@
+# Copyright 2023-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/>.
+
+import os
+import json
+import typing
+from inspect import stack
+from graphlib import TopologicalSorter, CycleError
+
+from vyos.utils.system import load_as_module
+from vyos.configdict import dict_merge
+from vyos.defaults import directories
+from vyos.configsource import VyOSError
+from vyos import ConfigError
+
+# https://peps.python.org/pep-0484/#forward-references
+# for type 'Config'
+if typing.TYPE_CHECKING:
+ from vyos.config import Config
+
+dependency_dir = os.path.join(directories['data'],
+ 'config-mode-dependencies')
+
+dependency_list: list[typing.Callable] = []
+
+DEBUG = False
+
+def debug_print(s: str):
+ if DEBUG:
+ print(s)
+
+def canon_name(name: str) -> str:
+ return os.path.splitext(name)[0].replace('-', '_')
+
+def canon_name_of_path(path: str) -> str:
+ script = os.path.basename(path)
+ return canon_name(script)
+
+def caller_name() -> str:
+ filename = stack()[2].filename
+ return canon_name_of_path(filename)
+
+def name_of(f: typing.Callable) -> str:
+ return f.__name__
+
+def names_of(l: list[typing.Callable]) -> list[str]:
+ return [name_of(f) for f in l]
+
+def remove_redundant(l: list[typing.Callable]) -> list[typing.Callable]:
+ names = set()
+ for e in reversed(l):
+ _ = l.remove(e) if name_of(e) in names else names.add(name_of(e))
+
+def append_uniq(l: list[typing.Callable], e: typing.Callable):
+ """Append an element, removing earlier occurrences
+
+ The list of dependencies is generally short and traversing the list on
+ each append is preferable to the cost of redundant script invocation.
+ """
+ l.append(e)
+ remove_redundant(l)
+
+def read_dependency_dict(dependency_dir: str = dependency_dir) -> dict:
+ res = {}
+ for dep_file in os.listdir(dependency_dir):
+ if not dep_file.endswith('.json'):
+ continue
+ path = os.path.join(dependency_dir, dep_file)
+ with open(path) as f:
+ d = json.load(f)
+ if dep_file == 'vyos-1x.json':
+ res = dict_merge(res, d)
+ else:
+ res = dict_merge(d, res)
+
+ return res
+
+def get_dependency_dict(config: 'Config') -> dict:
+ if hasattr(config, 'cached_dependency_dict'):
+ d = getattr(config, 'cached_dependency_dict')
+ else:
+ d = read_dependency_dict()
+ setattr(config, 'cached_dependency_dict', d)
+ return d
+
+def run_config_mode_script(target: str, config: 'Config'):
+ script = target + '.py'
+ path = os.path.join(directories['conf_mode'], script)
+ name = canon_name(script)
+ mod = load_as_module(name, path)
+
+ config.set_level([])
+ try:
+ c = mod.get_config(config)
+ mod.verify(c)
+ mod.generate(c)
+ mod.apply(c)
+ except (VyOSError, ConfigError) as e:
+ raise ConfigError(str(e)) from e
+
+def run_conditionally(target: str, tagnode: str, config: 'Config'):
+ tag_ext = f'_{tagnode}' if tagnode else ''
+ script_name = f'{target}{tag_ext}'
+
+ scripts_called = getattr(config, 'scripts_called', [])
+ commit_scripts = getattr(config, 'commit_scripts', [])
+
+ debug_print(f'scripts_called: {scripts_called}')
+ debug_print(f'commit_scripts: {commit_scripts}')
+
+ if script_name in commit_scripts and script_name not in scripts_called:
+ debug_print(f'dependency {script_name} deferred to priority')
+ return
+
+ run_config_mode_script(target, config)
+
+def def_closure(target: str, config: 'Config',
+ tagnode: typing.Optional[str] = None) -> typing.Callable:
+ def func_impl():
+ tag_value = ''
+ if tagnode is not None:
+ os.environ['VYOS_TAGNODE_VALUE'] = tagnode
+ tag_value = tagnode
+ run_conditionally(target, tag_value, config)
+
+ tag_ext = f'_{tagnode}' if tagnode is not None else ''
+ func_impl.__name__ = f'{target}{tag_ext}'
+
+ return func_impl
+
+def set_dependents(case: str, config: 'Config',
+ tagnode: typing.Optional[str] = None):
+ global dependency_list
+
+ dependency_list = config.dependency_list
+
+ d = get_dependency_dict(config)
+ k = caller_name()
+ l = dependency_list
+
+ for target in d[k][case]:
+ func = def_closure(target, config, tagnode)
+ append_uniq(l, func)
+
+ debug_print(f'set_dependents: caller {k}, current dependents {names_of(l)}')
+
+def call_dependents():
+ k = caller_name()
+ l = dependency_list
+ debug_print(f'call_dependents: caller {k}, remaining dependents {names_of(l)}')
+ while l:
+ f = l.pop(0)
+ debug_print(f'calling: {f.__name__}')
+ try:
+ f()
+ except ConfigError as e:
+ s = f'dependent {f.__name__}: {str(e)}'
+ raise ConfigError(s) from e
+
+def called_as_dependent() -> bool:
+ st = stack()[1:]
+ for f in st:
+ if f.filename == __file__:
+ return True
+ return False
+
+def graph_from_dependency_dict(d: dict) -> dict:
+ g = {}
+ for k in list(d):
+ g[k] = set()
+ # add the dependencies for every sub-case; should there be cases
+ # that are mutally exclusive in the future, the graphs will be
+ # distinguished
+ for el in list(d[k]):
+ g[k] |= set(d[k][el])
+
+ return g
+
+def is_acyclic(d: dict) -> bool:
+ g = graph_from_dependency_dict(d)
+ ts = TopologicalSorter(g)
+ try:
+ # get node iterator
+ order = ts.static_order()
+ # try iteration
+ _ = [*order]
+ except CycleError:
+ return False
+
+ return True
+
+def check_dependency_graph(dependency_dir: str = dependency_dir,
+ supplement: str = None) -> bool:
+ d = read_dependency_dict(dependency_dir=dependency_dir)
+ if supplement is not None:
+ with open(supplement) as f:
+ d = dict_merge(json.load(f), d)
+
+ return is_acyclic(d)
diff --git a/python/vyos/configdict.py b/python/vyos/configdict.py
new file mode 100644
index 0000000..5a353b1
--- /dev/null
+++ b/python/vyos/configdict.py
@@ -0,0 +1,666 @@
+# 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
+# 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/>.
+
+"""
+A library for retrieving value dicts from VyOS configs in a declarative fashion.
+"""
+import os
+import json
+
+from vyos.utils.dict import dict_search
+from vyos.utils.process import cmd
+
+def retrieve_config(path_hash, base_path, config):
+ """
+ Retrieves a VyOS config as a dict according to a declarative description
+
+ The description dict, passed in the first argument, must follow this format:
+ ``field_name : <path, type, [inner_options_dict]>``.
+
+ Supported types are: ``str`` (for normal nodes),
+ ``list`` (returns a list of strings, for multi nodes),
+ ``bool`` (returns True if valueless node exists),
+ ``dict`` (for tag nodes, returns a dict indexed by node names,
+ according to description in the third item of the tuple).
+
+ Args:
+ path_hash (dict): Declarative description of the config to retrieve
+ base_path (list): A base path to prepend to all option paths
+ config (vyos.config.Config): A VyOS config object
+
+ Returns:
+ dict: config dict
+ """
+ config_hash = {}
+
+ for k in path_hash:
+
+ if type(path_hash[k]) != tuple:
+ raise ValueError("In field {0}: expected a tuple, got a value {1}".format(k, str(path_hash[k])))
+ if len(path_hash[k]) < 2:
+ raise ValueError("In field {0}: field description must be a tuple of at least two items, path (list) and type".format(k))
+
+ path = path_hash[k][0]
+ if type(path) != list:
+ raise ValueError("In field {0}: path must be a list, not a {1}".format(k, type(path)))
+
+ typ = path_hash[k][1]
+ if type(typ) != type:
+ raise ValueError("In field {0}: type must be a type, not a {1}".format(k, type(typ)))
+
+ path = base_path + path
+
+ path_str = " ".join(path)
+
+ if typ == str:
+ config_hash[k] = config.return_value(path_str)
+ elif typ == list:
+ config_hash[k] = config.return_values(path_str)
+ elif typ == bool:
+ config_hash[k] = config.exists(path_str)
+ elif typ == dict:
+ try:
+ inner_hash = path_hash[k][2]
+ except IndexError:
+ raise ValueError("The type of the \'{0}\' field is dict, but inner options hash is missing from the tuple".format(k))
+ config_hash[k] = {}
+ nodes = config.list_nodes(path_str)
+ for node in nodes:
+ config_hash[k][node] = retrieve_config(inner_hash, path + [node], config)
+
+ return config_hash
+
+
+def dict_merge(source, destination):
+ """ Merge two dictionaries. Only keys which are not present in destination
+ will be copied from source, anything else will be kept untouched. Function
+ will return a new dict which has the merged key/value pairs. """
+ from copy import deepcopy
+ tmp = deepcopy(destination)
+
+ for key, value in source.items():
+ if key not in tmp:
+ tmp[key] = value
+ elif isinstance(source[key], dict):
+ tmp[key] = dict_merge(source[key], tmp[key])
+
+ return tmp
+
+def list_diff(first, second):
+ """ Diff two dictionaries and return only unique items """
+ second = set(second)
+ return [item for item in first if item not in second]
+
+def is_node_changed(conf, path):
+ """
+ Check if any key under path has been changed and return True.
+ If nothing changed, return false
+ """
+ from vyos.configdiff import get_config_diff
+ D = get_config_diff(conf, key_mangling=('-', '_'))
+ return D.is_node_changed(path)
+
+def leaf_node_changed(conf, path):
+ """
+ Check if a leaf node was altered. If it has been altered - values has been
+ changed, or it was added/removed, we will return a list containing the old
+ value(s). If nothing has been changed, None is returned.
+
+ NOTE: path must use the real CLI node name (e.g. with a hyphen!)
+ """
+ from vyos.configdiff import get_config_diff
+ D = get_config_diff(conf, key_mangling=('-', '_'))
+ (new, old) = D.get_value_diff(path)
+ if new != old:
+ if isinstance(old, dict):
+ # valueLess nodes return {} if node is deleted
+ return True
+ if old is None and isinstance(new, dict):
+ # valueLess nodes return {} if node was added
+ return True
+ if old is None:
+ return []
+ if isinstance(old, str):
+ return [old]
+ if isinstance(old, list):
+ if isinstance(new, str):
+ new = [new]
+ elif isinstance(new, type(None)):
+ new = []
+ return list_diff(old, new)
+
+ return None
+
+def node_changed(conf, path, key_mangling=None, recursive=False, expand_nodes=None) -> list:
+ """
+ Check if node under path (or anything under path if recursive=True) was changed. By default
+ we only check if a node or subnode (recursive) was deleted from path. If expand_nodes
+ is set to Diff.ADD we can also check if something was added to the path.
+
+ If nothing changed, an empty list is returned.
+ """
+ from vyos.configdiff import get_config_diff
+ from vyos.configdiff import Diff
+ # to prevent circular dependencies we assign the default here
+ if not expand_nodes: expand_nodes = Diff.DELETE
+ D = get_config_diff(conf, key_mangling)
+ # get_child_nodes_diff() will return dict_keys()
+ tmp = D.get_child_nodes_diff(path, expand_nodes=expand_nodes, recursive=recursive)
+ output = []
+ if expand_nodes & Diff.DELETE:
+ output.extend(list(tmp['delete'].keys()))
+ if expand_nodes & Diff.ADD:
+ output.extend(list(tmp['add'].keys()))
+
+ # remove duplicate keys from list, this happens when a node (e.g. description) is altered
+ output = list(dict.fromkeys(output))
+ return output
+
+def get_removed_vlans(conf, path, dict):
+ """
+ Common function to parse a dictionary retrieved via get_config_dict() and
+ determine any added/removed VLAN interfaces - be it 802.1q or Q-in-Q.
+ """
+ from vyos.configdiff import get_config_diff, Diff
+
+ # Check vif, vif-s/vif-c VLAN interfaces for removal
+ D = get_config_diff(conf, key_mangling=('-', '_'))
+ D.set_level(conf.get_level())
+
+ # get_child_nodes() will return dict_keys(), mangle this into a list with PEP448
+ keys = D.get_child_nodes_diff(path + ['vif'], expand_nodes=Diff.DELETE)['delete'].keys()
+ if keys: dict['vif_remove'] = [*keys]
+
+ # get_child_nodes() will return dict_keys(), mangle this into a list with PEP448
+ keys = D.get_child_nodes_diff(path + ['vif-s'], expand_nodes=Diff.DELETE)['delete'].keys()
+ if keys: dict['vif_s_remove'] = [*keys]
+
+ for vif in dict.get('vif_s', {}).keys():
+ keys = D.get_child_nodes_diff(path + ['vif-s', vif, 'vif-c'], expand_nodes=Diff.DELETE)['delete'].keys()
+ if keys: dict['vif_s'][vif]['vif_c_remove'] = [*keys]
+
+ return dict
+
+def is_member(conf, interface, intftype=None):
+ """
+ Checks if passed interface is member of other interface of specified type.
+ intftype is optional, if not passed it will search all known types
+ (currently bridge and bonding)
+
+ Returns: dict
+ empty -> Interface is not a member
+ key -> Interface is a member of this interface
+ """
+ ret_val = {}
+ intftypes = ['bonding', 'bridge']
+
+ if intftype not in intftypes + [None]:
+ raise ValueError((
+ f'unknown interface type "{intftype}" or it cannot '
+ f'have member interfaces'))
+
+ intftype = intftypes if intftype == None else [intftype]
+
+ for iftype in intftype:
+ base = ['interfaces', iftype]
+ for intf in conf.list_nodes(base):
+ member = base + [intf, 'member', 'interface', interface]
+ if conf.exists(member):
+ tmp = conf.get_config_dict(member, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ ret_val.update({intf : tmp})
+
+ return ret_val
+
+def is_mirror_intf(conf, interface, direction=None):
+ """
+ Check whether the passed interface is used for port mirroring. Direction
+ is optional, if not passed it will search all known direction
+ (currently ingress and egress)
+
+ Returns:
+ None -> Interface is not a monitor interface
+ Array() -> This interface is a monitor interface of interfaces
+ """
+ from vyos.ifconfig import Section
+
+ directions = ['ingress', 'egress']
+ if direction not in directions + [None]:
+ raise ValueError(f'Unknown interface mirror direction "{direction}"')
+
+ direction = directions if direction == None else [direction]
+
+ ret_val = None
+ base = ['interfaces']
+
+ for dir in direction:
+ for iftype in conf.list_nodes(base):
+ iftype_base = base + [iftype]
+ for intf in conf.list_nodes(iftype_base):
+ mirror = iftype_base + [intf, 'mirror', dir, interface]
+ if conf.exists(mirror):
+ path = ['interfaces', Section.section(intf), intf]
+ tmp = conf.get_config_dict(path, key_mangling=('-', '_'),
+ get_first_key=True)
+ ret_val = {intf : tmp}
+
+ return ret_val
+
+def has_address_configured(conf, intf):
+ """
+ Checks if interface has an address configured.
+ Checks the following config nodes:
+ 'address', 'ipv6 address eui64', 'ipv6 address autoconf'
+
+ Returns True if interface has address configured, False if it doesn't.
+ """
+ from vyos.ifconfig import Section
+ ret = False
+
+ old_level = conf.get_level()
+ conf.set_level([])
+
+ intfpath = ['interfaces', Section.get_config_path(intf)]
+ if (conf.exists([intfpath, 'address']) or
+ conf.exists([intfpath, 'ipv6', 'address', 'autoconf']) or
+ conf.exists([intfpath, 'ipv6', 'address', 'eui64'])):
+ ret = True
+
+ conf.set_level(old_level)
+ return ret
+
+def has_vrf_configured(conf, intf):
+ """
+ Checks if interface has a VRF configured.
+
+ Returns True if interface has VRF configured, False if it doesn't.
+ """
+ from vyos.ifconfig import Section
+ ret = False
+
+ old_level = conf.get_level()
+ conf.set_level([])
+
+ if conf.exists(['interfaces', Section.get_config_path(intf), 'vrf']):
+ ret = True
+
+ conf.set_level(old_level)
+ return ret
+
+def has_vlan_subinterface_configured(conf, intf):
+ """
+ Checks if interface has an VLAN subinterface configured.
+ Checks the following config nodes:
+ 'vif', 'vif-s'
+
+ Return True if interface has VLAN subinterface configured.
+ """
+ from vyos.ifconfig import Section
+ ret = False
+
+ intfpath = ['interfaces', Section.section(intf), intf]
+ if (conf.exists(intfpath + ['vif']) or conf.exists(intfpath + ['vif-s'])):
+ ret = True
+
+ return ret
+
+def is_source_interface(conf, interface, intftype=None):
+ """
+ Checks if passed interface is configured as source-interface of other
+ interfaces of specified type. intftype is optional, if not passed it will
+ search all known types (currently pppoe, macsec, pseudo-ethernet, tunnel
+ and vxlan)
+
+ Returns:
+ None -> Interface is not a member
+ interface name -> Interface is a member of this interface
+ False -> interface type cannot have members
+ """
+ ret_val = None
+ intftypes = ['macsec', 'pppoe', 'pseudo-ethernet', 'tunnel', 'vxlan']
+ if not intftype:
+ intftype = intftypes
+
+ if isinstance(intftype, str):
+ intftype = [intftype]
+ elif not isinstance(intftype, list):
+ raise ValueError(f'Interface type "{type(intftype)}" must be either str or list!')
+
+ if not all(x in intftypes for x in intftype):
+ raise ValueError(f'unknown interface type "{intftype}" or it can not '
+ 'have a source-interface')
+
+ for it in intftype:
+ base = ['interfaces', it]
+ for intf in conf.list_nodes(base):
+ src_intf = base + [intf, 'source-interface']
+ if conf.exists(src_intf) and interface in conf.return_values(src_intf):
+ ret_val = intf
+ break
+
+ return ret_val
+
+def get_dhcp_interfaces(conf, vrf=None):
+ """ Common helper functions to retrieve all interfaces from current CLI
+ sessions that have DHCP configured. """
+ dhcp_interfaces = {}
+ dict = conf.get_config_dict(['interfaces'], get_first_key=True)
+ if not dict:
+ return dhcp_interfaces
+
+ def check_dhcp(config):
+ ifname = config['ifname']
+ tmp = {}
+ if 'address' in config and 'dhcp' in config['address']:
+ options = {}
+ if dict_search('dhcp_options.default_route_distance', config) != None:
+ options.update({'dhcp_options' : config['dhcp_options']})
+ if 'vrf' in config:
+ if vrf == config['vrf']: tmp.update({ifname : options})
+ else:
+ if vrf is None: tmp.update({ifname : options})
+
+ return tmp
+
+ for section, interface in dict.items():
+ for ifname in interface:
+ # always reset config level, as get_interface_dict() will alter it
+ conf.set_level([])
+ # we already have a dict representation of the config from get_config_dict(),
+ # but with the extended information from get_interface_dict() we also
+ # get the DHCP client default-route-distance default option if not specified.
+ _, ifconfig = get_interface_dict(conf, ['interfaces', section], ifname)
+
+ tmp = check_dhcp(ifconfig)
+ dhcp_interfaces.update(tmp)
+ # check per VLAN interfaces
+ for vif, vif_config in ifconfig.get('vif', {}).items():
+ tmp = check_dhcp(vif_config)
+ dhcp_interfaces.update(tmp)
+ # check QinQ VLAN interfaces
+ for vif_s, vif_s_config in ifconfig.get('vif_s', {}).items():
+ tmp = check_dhcp(vif_s_config)
+ dhcp_interfaces.update(tmp)
+ for vif_c, vif_c_config in vif_s_config.get('vif_c', {}).items():
+ tmp = check_dhcp(vif_c_config)
+ dhcp_interfaces.update(tmp)
+
+ return dhcp_interfaces
+
+def get_pppoe_interfaces(conf, vrf=None):
+ """ Common helper functions to retrieve all interfaces from current CLI
+ sessions that have DHCP configured. """
+ pppoe_interfaces = {}
+ conf.set_level([])
+ for ifname in conf.list_nodes(['interfaces', 'pppoe']):
+ # always reset config level, as get_interface_dict() will alter it
+ conf.set_level([])
+ # we already have a dict representation of the config from get_config_dict(),
+ # but with the extended information from get_interface_dict() we also
+ # get the DHCP client default-route-distance default option if not specified.
+ _, ifconfig = get_interface_dict(conf, ['interfaces', 'pppoe'], ifname)
+
+ options = {}
+ if 'default_route_distance' in ifconfig:
+ options.update({'default_route_distance' : ifconfig['default_route_distance']})
+ if 'no_default_route' in ifconfig:
+ options.update({'no_default_route' : {}})
+ if 'vrf' in ifconfig:
+ if vrf == ifconfig['vrf']: pppoe_interfaces.update({ifname : options})
+ else:
+ if vrf is None: pppoe_interfaces.update({ifname : options})
+
+ return pppoe_interfaces
+
+def get_interface_dict(config, base, ifname='', recursive_defaults=True, with_pki=False):
+ """
+ Common utility function to retrieve and mangle the interfaces configuration
+ from the CLI input nodes. All interfaces have a common base where value
+ retrival is identical. This function must be used whenever possible when
+ working on the interfaces node!
+
+ Return a dictionary with the necessary interface config keys.
+ """
+ if not ifname:
+ from vyos import ConfigError
+ # determine tagNode instance
+ if 'VYOS_TAGNODE_VALUE' not in os.environ:
+ raise ConfigError('Interface (VYOS_TAGNODE_VALUE) not specified')
+ ifname = os.environ['VYOS_TAGNODE_VALUE']
+
+ # Check if interface has been removed. We must use exists() as
+ # get_config_dict() will always return {} - even when an empty interface
+ # node like the following exists.
+ # +macsec macsec1 {
+ # +}
+ if not config.exists(base + [ifname]):
+ dict = config.get_config_dict(base + [ifname], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True)
+ dict.update({'deleted' : {}})
+ else:
+ # Get config_dict with default values
+ dict = config.get_config_dict(base + [ifname], key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_defaults=True,
+ with_recursive_defaults=recursive_defaults,
+ with_pki=with_pki)
+
+ # If interface does not request an IPv4 DHCP address there is no need
+ # to keep the dhcp-options key
+ if 'address' not in dict or 'dhcp' not in dict['address']:
+ if 'dhcp_options' in dict:
+ del dict['dhcp_options']
+
+ # Add interface instance name into dictionary
+ dict.update({'ifname': ifname})
+
+ # Check if QoS policy applied on this interface - See ifconfig.interface.set_mirror_redirect()
+ if config.exists(['qos', 'interface', ifname]):
+ dict.update({'traffic_policy': {}})
+
+ address = leaf_node_changed(config, base + [ifname, 'address'])
+ if address: dict.update({'address_old' : address})
+
+ # Check if we are a member of a bridge device
+ bridge = is_member(config, ifname, 'bridge')
+ if bridge: dict.update({'is_bridge_member' : bridge})
+
+ # Check if it is a monitor interface
+ mirror = is_mirror_intf(config, ifname)
+ if mirror: dict.update({'is_mirror_intf' : mirror})
+
+ # Check if we are a member of a bond device
+ bond = is_member(config, ifname, 'bonding')
+ if bond: dict.update({'is_bond_member' : bond})
+
+ # 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' : {}})
+
+ # Some interfaces come with a source_interface which must also not be part
+ # of any other bond or bridge interface as it is exclusivly assigned as the
+ # Kernels "lower" interface to this new "virtual/upper" interface.
+ if 'source_interface' in dict:
+ # Check if source interface is member of another bridge
+ tmp = is_member(config, dict['source_interface'], 'bridge')
+ if tmp: dict.update({'source_interface_is_bridge_member' : tmp})
+
+ # Check if source interface is member of another bridge
+ tmp = is_member(config, dict['source_interface'], 'bonding')
+ if tmp: dict.update({'source_interface_is_bond_member' : tmp})
+
+ mac = leaf_node_changed(config, base + [ifname, 'mac'])
+ if mac: dict.update({'mac_old' : mac})
+
+ eui64 = leaf_node_changed(config, base + [ifname, 'ipv6', 'address', 'eui64'])
+ if eui64:
+ tmp = dict_search('ipv6.address', dict)
+ if not tmp:
+ dict.update({'ipv6': {'address': {'eui64_old': eui64}}})
+ else:
+ dict['ipv6']['address'].update({'eui64_old': eui64})
+
+ for vif, vif_config in dict.get('vif', {}).items():
+ # Add subinterface name to dictionary
+ dict['vif'][vif].update({'ifname' : f'{ifname}.{vif}'})
+
+ if config.exists(['qos', 'interface', f'{ifname}.{vif}']):
+ dict['vif'][vif].update({'traffic_policy': {}})
+
+ if 'deleted' not in dict:
+ address = leaf_node_changed(config, base + [ifname, 'vif', vif, 'address'])
+ if address: dict['vif'][vif].update({'address_old' : address})
+
+ # If interface does not request an IPv4 DHCP address there is no need
+ # to keep the dhcp-options key
+ if 'address' not in dict['vif'][vif] or 'dhcp' not in dict['vif'][vif]['address']:
+ if 'dhcp_options' in dict['vif'][vif]:
+ del dict['vif'][vif]['dhcp_options']
+
+ # Check if we are a member of a bridge device
+ bridge = is_member(config, f'{ifname}.{vif}', 'bridge')
+ if bridge: dict['vif'][vif].update({'is_bridge_member' : bridge})
+
+ # 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' : {}})
+
+ for vif_s, vif_s_config in dict.get('vif_s', {}).items():
+ # Add subinterface name to dictionary
+ dict['vif_s'][vif_s].update({'ifname' : f'{ifname}.{vif_s}'})
+
+ if config.exists(['qos', 'interface', f'{ifname}.{vif_s}']):
+ dict['vif_s'][vif_s].update({'traffic_policy': {}})
+
+ if 'deleted' not in dict:
+ address = leaf_node_changed(config, base + [ifname, 'vif-s', vif_s, 'address'])
+ if address: dict['vif_s'][vif_s].update({'address_old' : address})
+
+ # If interface does not request an IPv4 DHCP address there is no need
+ # to keep the dhcp-options key
+ if 'address' not in dict['vif_s'][vif_s] or 'dhcp' not in \
+ dict['vif_s'][vif_s]['address']:
+ if 'dhcp_options' in dict['vif_s'][vif_s]:
+ del dict['vif_s'][vif_s]['dhcp_options']
+
+ # Check if we are a member of a bridge device
+ bridge = is_member(config, f'{ifname}.{vif_s}', 'bridge')
+ if bridge: dict['vif_s'][vif_s].update({'is_bridge_member' : bridge})
+
+ # 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' : {}})
+
+ for vif_c, vif_c_config in vif_s_config.get('vif_c', {}).items():
+ # Add subinterface name to dictionary
+ dict['vif_s'][vif_s]['vif_c'][vif_c].update({'ifname' : f'{ifname}.{vif_s}.{vif_c}'})
+
+ if config.exists(['qos', 'interface', f'{ifname}.{vif_s}.{vif_c}']):
+ dict['vif_s'][vif_s]['vif_c'][vif_c].update({'traffic_policy': {}})
+
+ if 'deleted' not in dict:
+ address = leaf_node_changed(config, base + [ifname, 'vif-s', vif_s, 'vif-c', vif_c, 'address'])
+ if address: dict['vif_s'][vif_s]['vif_c'][vif_c].update(
+ {'address_old' : address})
+
+ # If interface does not request an IPv4 DHCP address there is no need
+ # to keep the dhcp-options key
+ if 'address' not in dict['vif_s'][vif_s]['vif_c'][vif_c] or 'dhcp' \
+ not in dict['vif_s'][vif_s]['vif_c'][vif_c]['address']:
+ if 'dhcp_options' in dict['vif_s'][vif_s]['vif_c'][vif_c]:
+ del dict['vif_s'][vif_s]['vif_c'][vif_c]['dhcp_options']
+
+ # Check if we are a member of a bridge device
+ bridge = is_member(config, f'{ifname}.{vif_s}.{vif_c}', 'bridge')
+ if bridge: dict['vif_s'][vif_s]['vif_c'][vif_c].update(
+ {'is_bridge_member' : bridge})
+
+ # 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' : {}})
+
+ # Check vif, vif-s/vif-c VLAN interfaces for removal
+ dict = get_removed_vlans(config, base + [ifname], dict)
+ return ifname, dict
+
+def get_vlan_ids(interface):
+ """
+ Get the VLAN ID of the interface bound to the bridge
+ """
+ vlan_ids = set()
+
+ bridge_status = cmd('bridge -j vlan show', shell=True)
+ vlan_filter_status = json.loads(bridge_status)
+
+ if vlan_filter_status is not None:
+ for interface_status in vlan_filter_status:
+ ifname = interface_status['ifname']
+ if interface == ifname:
+ vlans_status = interface_status['vlans']
+ for vlan_status in vlans_status:
+ vlan_id = vlan_status['vlan']
+ vlan_ids.add(vlan_id)
+
+ 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
+ from different CLI input nodes. All Accel-PPP services have a common base
+ where value retrival is identical. This function must be used whenever
+ possible when working with Accel-PPP services!
+
+ Return a dictionary with the necessary interface config keys.
+ """
+ from vyos.utils.cpu import get_core_count
+ from vyos.template import is_ipv4
+
+ dict = config.get_config_dict(base, key_mangling=('-', '_'),
+ get_first_key=True,
+ no_tag_node_value_mangle=True,
+ with_recursive_defaults=True,
+ with_pki=with_pki)
+
+ # set CPUs cores to process requests
+ dict.update({'thread_count' : get_core_count()})
+ # we need to store the path to the secrets file
+ dict.update({'chap_secrets_file' : chap_secrets})
+
+ # We can only have two IPv4 and three IPv6 nameservers - also they are
+ # configured in a different way in the configuration, this is why we split
+ # the configuration
+ if 'name_server' in dict:
+ ns_v4 = []
+ ns_v6 = []
+ for ns in dict['name_server']:
+ if is_ipv4(ns): ns_v4.append(ns)
+ else: ns_v6.append(ns)
+
+ dict.update({'name_server_ipv4' : ns_v4, 'name_server_ipv6' : ns_v6})
+ del dict['name_server']
+
+ # Check option "disable-accounting" per server and replace default value from '1813' to '0'
+ for server in (dict_search('authentication.radius.server', dict) or []):
+ if 'disable_accounting' in dict['authentication']['radius']['server'][server]:
+ dict['authentication']['radius']['server'][server]['acct_port'] = '0'
+
+ return dict
diff --git a/python/vyos/configdiff.py b/python/vyos/configdiff.py
new file mode 100644
index 0000000..b6d4a55
--- /dev/null
+++ b/python/vyos/configdiff.py
@@ -0,0 +1,436 @@
+# 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/>.
+
+from enum import IntFlag
+from enum import auto
+from itertools import chain
+
+from vyos.config import Config
+from vyos.configtree import DiffTree
+from vyos.configdict import dict_merge
+from vyos.utils.dict import get_sub_dict
+from vyos.utils.dict import mangle_dict_keys
+from vyos.utils.dict import dict_search_args
+from vyos.utils.dict import dict_to_key_paths
+from vyos.xml_ref import get_defaults
+from vyos.xml_ref import owner
+from vyos.xml_ref import priority
+
+class ConfigDiffError(Exception):
+ """
+ Raised on config dict access errors, for example, calling get_value on
+ a non-leaf node.
+ """
+ pass
+
+def enum_to_key(e):
+ return e.name.lower()
+
+class Diff(IntFlag):
+ MERGE = auto()
+ DELETE = auto()
+ ADD = auto()
+ STABLE = auto()
+
+ALL = Diff.MERGE | Diff.DELETE | Diff.ADD | Diff.STABLE
+
+requires_effective = [enum_to_key(Diff.DELETE)]
+target_defaults = [enum_to_key(Diff.MERGE)]
+
+def _key_sets_from_dicts(session_dict, effective_dict):
+ session_keys = list(session_dict)
+ effective_keys = list(effective_dict)
+
+ ret = {}
+ stable_keys = [k for k in session_keys if k in effective_keys]
+
+ ret[enum_to_key(Diff.MERGE)] = session_keys
+ ret[enum_to_key(Diff.DELETE)] = [k for k in effective_keys if k not in stable_keys]
+ ret[enum_to_key(Diff.ADD)] = [k for k in session_keys if k not in stable_keys]
+ ret[enum_to_key(Diff.STABLE)] = stable_keys
+
+ return ret
+
+def _dict_from_key_set(key_set, d):
+ # This will always be applied to a key_set obtained from a get_sub_dict,
+ # hence there is no possibility of KeyError, as get_sub_dict guarantees
+ # a return type of dict
+ ret = {k: d[k] for k in key_set}
+
+ return ret
+
+def get_config_diff(config, key_mangling=None):
+ """
+ Check type and return ConfigDiff instance.
+ """
+ if not config or not isinstance(config, Config):
+ raise TypeError("argument must me a Config instance")
+ if key_mangling and not (isinstance(key_mangling, tuple) and \
+ (len(key_mangling) == 2) and \
+ isinstance(key_mangling[0], str) and \
+ isinstance(key_mangling[1], str)):
+ raise ValueError("key_mangling must be a tuple of two strings")
+
+ if hasattr(config, 'cached_diff_tree'):
+ diff_t = getattr(config, 'cached_diff_tree')
+ else:
+ diff_t = DiffTree(config._running_config, config._session_config)
+ setattr(config, 'cached_diff_tree', diff_t)
+
+ if hasattr(config, 'cached_diff_dict'):
+ diff_d = getattr(config, 'cached_diff_dict')
+ else:
+ diff_d = diff_t.dict
+ setattr(config, 'cached_diff_dict', diff_d)
+
+ return ConfigDiff(config, key_mangling, diff_tree=diff_t,
+ diff_dict=diff_d)
+
+def get_commit_scripts(config) -> list:
+ """Return the list of config scripts to be executed by commit
+
+ Return a list of the scripts to be called by commit for the proposed
+ config. The list is ordered by priority for reference, however, the
+ actual order of execution by the commit algorithm is not reflected
+ (delete vs. add queue), nor needed for current use.
+ """
+ if not config or not isinstance(config, Config):
+ raise TypeError("argument must me a Config instance")
+
+ if hasattr(config, 'commit_scripts'):
+ return getattr(config, 'commit_scripts')
+
+ D = get_config_diff(config)
+ d = D._diff_dict
+ s = set()
+ for p in chain(dict_to_key_paths(d['sub']), dict_to_key_paths(d['add'])):
+ p_owner = owner(p, with_tag=True)
+ if not p_owner:
+ continue
+ p_priority = priority(p)
+ if not p_priority:
+ # default priority in legacy commit-algorithm
+ p_priority = 0
+ p_priority = int(p_priority)
+ s.add((p_priority, p_owner))
+
+ res = [x[1] for x in sorted(s, key=lambda x: x[0])]
+ setattr(config, 'commit_scripts', res)
+
+ return res
+
+class ConfigDiff(object):
+ """
+ The class of config changes as represented by comparison between the
+ session config dict and the effective config dict.
+ """
+ def __init__(self, config, key_mangling=None, diff_tree=None, diff_dict=None):
+ self._level = config.get_level()
+ self._session_config_dict = config.get_cached_root_dict(effective=False)
+ self._effective_config_dict = config.get_cached_root_dict(effective=True)
+ self._key_mangling = key_mangling
+
+ self._diff_tree = diff_tree
+ self._diff_dict = diff_dict
+
+ # mirrored from Config; allow path arguments relative to level
+ def _make_path(self, path):
+ if isinstance(path, str):
+ path = path.split()
+ elif isinstance(path, list):
+ pass
+ else:
+ raise TypeError("Path must be a whitespace-separated string or a list")
+
+ ret = self._level + path
+ return ret
+
+ def set_level(self, path):
+ """
+ Set the *edit level*, that is, a relative config dict path.
+ Once set, all operations will be relative to this path,
+ for example, after ``set_level("system")``, calling
+ ``get_value("name-server")`` is equivalent to calling
+ ``get_value("system name-server")`` without ``set_level``.
+
+ Args:
+ path (str|list): relative config path
+ """
+ if isinstance(path, str):
+ if path:
+ self._level = path.split()
+ else:
+ self._level = []
+ elif isinstance(path, list):
+ self._level = path.copy()
+ else:
+ raise TypeError("Level path must be either a whitespace-separated string or a list")
+
+ def get_level(self):
+ """
+ Gets the current edit level.
+
+ Returns:
+ str: current edit level
+ """
+ ret = self._level.copy()
+ return ret
+
+ def _mangle_dict_keys(self, config_dict):
+ config_dict = mangle_dict_keys(config_dict, self._key_mangling[0],
+ self._key_mangling[1])
+ return config_dict
+
+ def is_node_changed(self, path=[]):
+ if self._diff_tree is None:
+ raise NotImplementedError("diff_tree class not available")
+
+ if (self._diff_tree.add.exists(self._make_path(path)) or
+ self._diff_tree.sub.exists(self._make_path(path))):
+ return True
+ return False
+
+ def node_changed_presence(self, path=[]) -> bool:
+ if self._diff_tree is None:
+ raise NotImplementedError("diff_tree class not available")
+
+ path = self._make_path(path)
+ before = self._diff_tree.left.exists(path)
+ after = self._diff_tree.right.exists(path)
+ return (before and not after) or (not before and after)
+
+ def node_changed_children(self, path=[]) -> list:
+ if self._diff_tree is None:
+ raise NotImplementedError("diff_tree class not available")
+
+ path = self._make_path(path)
+ add = self._diff_tree.add
+ sub = self._diff_tree.sub
+ children = set()
+ if add.exists(path):
+ children.update(add.list_nodes(path))
+ if sub.exists(path):
+ children.update(sub.list_nodes(path))
+
+ return list(children)
+
+ def get_child_nodes_diff_str(self, path=[]):
+ ret = {'add': {}, 'change': {}, 'delete': {}}
+
+ diff = self.get_child_nodes_diff(path,
+ expand_nodes=Diff.ADD | Diff.DELETE | Diff.MERGE | Diff.STABLE,
+ no_defaults=True)
+
+ def parse_dict(diff_dict, diff_type, prefix=[]):
+ for k, v in diff_dict.items():
+ if isinstance(v, dict):
+ parse_dict(v, diff_type, prefix + [k])
+ else:
+ path_str = ' '.join(prefix + [k])
+ if diff_type == 'add' or diff_type == 'delete':
+ if isinstance(v, list):
+ v = ', '.join(v)
+ ret[diff_type][path_str] = v
+ elif diff_type == 'merge':
+ old_value = dict_search_args(diff['stable'], *prefix, k)
+ if old_value and old_value != v:
+ ret['change'][path_str] = [old_value, v]
+
+ parse_dict(diff['merge'], 'merge')
+ parse_dict(diff['add'], 'add')
+ parse_dict(diff['delete'], 'delete')
+
+ return ret
+
+ def get_child_nodes_diff(self, path=[], expand_nodes=Diff(0), no_defaults=False,
+ recursive=False):
+ """
+ Args:
+ path (str|list): config path
+ expand_nodes=Diff(0): bit mask of enum indicating for which nodes
+ to provide full dict; for example, Diff.MERGE
+ will expand dict['merge'] into dict under
+ value
+ no_detaults=False: if expand_nodes & Diff.MERGE, do not merge default
+ values to ret['merge']
+ recursive: if true, use config_tree diff algorithm provided by
+ diff_tree class
+
+ Returns: dict of lists, representing differences between session
+ and effective config, under path
+ dict['merge'] = session config values
+ dict['delete'] = effective config values, not in session
+ dict['add'] = session config values, not in effective
+ dict['stable'] = config values in both session and effective
+ """
+ session_dict = get_sub_dict(self._session_config_dict,
+ self._make_path(path), get_first_key=True)
+
+ if recursive:
+ if self._diff_tree is None:
+ raise NotImplementedError("diff_tree class not available")
+ else:
+ add = get_sub_dict(self._diff_dict, ['add'], get_first_key=True)
+ sub = get_sub_dict(self._diff_dict, ['sub'], get_first_key=True)
+ inter = get_sub_dict(self._diff_dict, ['inter'], get_first_key=True)
+ ret = {}
+ ret[enum_to_key(Diff.MERGE)] = session_dict
+ ret[enum_to_key(Diff.DELETE)] = get_sub_dict(sub, self._make_path(path),
+ get_first_key=True)
+ ret[enum_to_key(Diff.ADD)] = get_sub_dict(add, self._make_path(path),
+ get_first_key=True)
+ ret[enum_to_key(Diff.STABLE)] = get_sub_dict(inter, self._make_path(path),
+ get_first_key=True)
+ for e in Diff:
+ k = enum_to_key(e)
+ if not (e & expand_nodes):
+ ret[k] = list(ret[k])
+ else:
+ if self._key_mangling:
+ ret[k] = self._mangle_dict_keys(ret[k])
+ if k in target_defaults and not no_defaults:
+ default_values = get_defaults(self._make_path(path),
+ get_first_key=True,
+ recursive=True)
+ ret[k] = dict_merge(default_values, ret[k])
+ return ret
+
+ effective_dict = get_sub_dict(self._effective_config_dict,
+ self._make_path(path), get_first_key=True)
+
+ ret = _key_sets_from_dicts(session_dict, effective_dict)
+
+ if not expand_nodes:
+ return ret
+
+ for e in Diff:
+ if expand_nodes & e:
+ k = enum_to_key(e)
+ if k in requires_effective:
+ ret[k] = _dict_from_key_set(ret[k], effective_dict)
+ else:
+ ret[k] = _dict_from_key_set(ret[k], session_dict)
+
+ if self._key_mangling:
+ ret[k] = self._mangle_dict_keys(ret[k])
+
+ if k in target_defaults and not no_defaults:
+ default_values = get_defaults(self._make_path(path),
+ get_first_key=True,
+ recursive=True)
+ ret[k] = dict_merge(default_values, ret[k])
+
+ return ret
+
+ def get_node_diff(self, path=[], expand_nodes=Diff(0), no_defaults=False,
+ recursive=False):
+ """
+ Args:
+ path (str|list): config path
+ expand_nodes=Diff(0): bit mask of enum indicating for which nodes
+ to provide full dict; for example, Diff.MERGE
+ will expand dict['merge'] into dict under
+ value
+ no_detaults=False: if expand_nodes & Diff.MERGE, do not merge default
+ values to ret['merge']
+ recursive: if true, use config_tree diff algorithm provided by
+ diff_tree class
+
+ Returns: dict of lists, representing differences between session
+ and effective config, at path
+ dict['merge'] = session config values
+ dict['delete'] = effective config values, not in session
+ dict['add'] = session config values, not in effective
+ dict['stable'] = config values in both session and effective
+ """
+ session_dict = get_sub_dict(self._session_config_dict, self._make_path(path))
+
+ if recursive:
+ if self._diff_tree is None:
+ raise NotImplementedError("diff_tree class not available")
+ else:
+ add = get_sub_dict(self._diff_dict, ['add'], get_first_key=True)
+ sub = get_sub_dict(self._diff_dict, ['sub'], get_first_key=True)
+ inter = get_sub_dict(self._diff_dict, ['inter'], get_first_key=True)
+ ret = {}
+ ret[enum_to_key(Diff.MERGE)] = session_dict
+ ret[enum_to_key(Diff.DELETE)] = get_sub_dict(sub, self._make_path(path))
+ ret[enum_to_key(Diff.ADD)] = get_sub_dict(add, self._make_path(path))
+ ret[enum_to_key(Diff.STABLE)] = get_sub_dict(inter, self._make_path(path))
+ for e in Diff:
+ k = enum_to_key(e)
+ if not (e & expand_nodes):
+ ret[k] = list(ret[k])
+ else:
+ if self._key_mangling:
+ ret[k] = self._mangle_dict_keys(ret[k])
+ if k in target_defaults and not no_defaults:
+ default_values = get_defaults(self._make_path(path),
+ get_first_key=True,
+ recursive=True)
+ ret[k] = dict_merge(default_values, ret[k])
+ return ret
+
+ effective_dict = get_sub_dict(self._effective_config_dict, self._make_path(path))
+
+ ret = _key_sets_from_dicts(session_dict, effective_dict)
+
+ if not expand_nodes:
+ return ret
+
+ for e in Diff:
+ if expand_nodes & e:
+ k = enum_to_key(e)
+ if k in requires_effective:
+ ret[k] = _dict_from_key_set(ret[k], effective_dict)
+ else:
+ ret[k] = _dict_from_key_set(ret[k], session_dict)
+
+ if self._key_mangling:
+ ret[k] = self._mangle_dict_keys(ret[k])
+
+ if k in target_defaults and not no_defaults:
+ default_values = get_defaults(self._make_path(path),
+ get_first_key=True,
+ recursive=True)
+ ret[k] = dict_merge(default_values, ret[k])
+
+ return ret
+
+ def get_value_diff(self, path=[]):
+ """
+ Args:
+ path (str|list): config path
+
+ Returns: (new, old) tuple of values in session config/effective config
+ """
+ # one should properly use is_leaf as check; for the moment we will
+ # deduce from type, which will not catch call on non-leaf node if None
+ new_value_dict = get_sub_dict(self._session_config_dict, self._make_path(path))
+ old_value_dict = get_sub_dict(self._effective_config_dict, self._make_path(path))
+
+ new_value = None
+ old_value = None
+ if new_value_dict:
+ new_value = next(iter(new_value_dict.values()))
+ if old_value_dict:
+ old_value = next(iter(old_value_dict.values()))
+
+ if new_value and isinstance(new_value, dict):
+ raise ConfigDiffError("get_value_changed called on non-leaf node")
+ if old_value and isinstance(old_value, dict):
+ raise ConfigDiffError("get_value_changed called on non-leaf node")
+
+ return new_value, old_value
diff --git a/python/vyos/configquery.py b/python/vyos/configquery.py
new file mode 100644
index 0000000..5d6ca9b
--- /dev/null
+++ b/python/vyos/configquery.py
@@ -0,0 +1,192 @@
+# Copyright 2021-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/>.
+
+'''
+A small library that allows querying existence or value(s) of config
+settings from op mode, and execution of arbitrary op mode commands.
+'''
+
+import os
+import json
+import subprocess
+
+from vyos.utils.process import STDOUT
+from vyos.utils.process import popen
+
+from vyos.utils.boot import boot_configuration_complete
+from vyos.config import Config
+from vyos.configsource import ConfigSourceSession, ConfigSourceString
+from vyos.defaults import directories
+from vyos.configtree import ConfigTree
+from vyos.utils.dict import embed_dict
+from vyos.utils.dict import get_sub_dict
+from vyos.utils.dict import mangle_dict_keys
+from vyos.utils.error import cli_shell_api_err
+from vyos.xml_ref import multi_to_list
+from vyos.xml_ref import is_tag
+from vyos.base import Warning
+
+config_file = os.path.join(directories['config'], 'config.boot')
+
+class ConfigQueryError(Exception):
+ pass
+
+class GenericConfigQuery:
+ def __init__(self):
+ pass
+
+ def exists(self, path: list):
+ raise NotImplementedError
+
+ def value(self, path: list):
+ raise NotImplementedError
+
+ def values(self, path: list):
+ raise NotImplementedError
+
+class GenericOpRun:
+ def __init__(self):
+ pass
+
+ def run(self, path: list, **kwargs):
+ raise NotImplementedError
+
+class CliShellApiConfigQuery(GenericConfigQuery):
+ def __init__(self):
+ super().__init__()
+
+ def exists(self, path: list):
+ cmd = ' '.join(path)
+ (_, err) = popen(f'cli-shell-api existsActive {cmd}')
+ if err:
+ return False
+ return True
+
+ def value(self, path: list):
+ cmd = ' '.join(path)
+ (out, err) = popen(f'cli-shell-api returnActiveValue {cmd}')
+ if err:
+ raise ConfigQueryError('No value for given path')
+ return out
+
+ def values(self, path: list):
+ cmd = ' '.join(path)
+ (out, err) = popen(f'cli-shell-api returnActiveValues {cmd}')
+ if err:
+ raise ConfigQueryError('No values for given path')
+ return out
+
+class ConfigTreeQuery(GenericConfigQuery):
+ def __init__(self):
+ super().__init__()
+
+ if boot_configuration_complete():
+ config_source = ConfigSourceSession()
+ self.config = Config(config_source=config_source)
+ else:
+ try:
+ with open(config_file) as f:
+ config_string = f.read()
+ except OSError as err:
+ config_string = ''
+
+ config_source = ConfigSourceString(running_config_text=config_string,
+ session_config_text=config_string)
+ self.config = Config(config_source=config_source)
+
+ def exists(self, path: list):
+ return self.config.exists(path)
+
+ def value(self, path: list):
+ return self.config.return_value(path)
+
+ def values(self, path: list):
+ return self.config.return_values(path)
+
+ def list_nodes(self, path: list):
+ return self.config.list_nodes(path)
+
+ 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):
+ 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)
+
+class VbashOpRun(GenericOpRun):
+ def __init__(self):
+ super().__init__()
+
+ def run(self, path: list, **kwargs):
+ cmd = ' '.join(path)
+ (out, err) = popen(f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {cmd}', stderr=STDOUT, **kwargs)
+ if err:
+ raise ConfigQueryError(out)
+ return out
+
+def query_context(config_query_class=CliShellApiConfigQuery,
+ op_run_class=VbashOpRun):
+ query = config_query_class()
+ run = op_run_class()
+ return query, run
+
+def verify_mangling(key_mangling):
+ if not (isinstance(key_mangling, tuple) and
+ len(key_mangling) == 2 and
+ isinstance(key_mangling[0], str) and
+ isinstance(key_mangling[1], str)):
+ raise ValueError("key_mangling must be a tuple of two strings")
+
+def op_mode_run(cmd):
+ """ low-level to avoid overhead """
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+ out = p.stdout.read()
+ p.wait()
+ return p.returncode, out.decode()
+
+def op_mode_config_dict(path=None, key_mangling=None,
+ no_tag_node_value_mangle=False,
+ no_multi_convert=False, get_first_key=False):
+
+ if path is None:
+ path = []
+ command = ['/bin/cli-shell-api', '--show-active-only', 'showConfig']
+
+ rc, out = op_mode_run(command + path)
+ if rc == cli_shell_api_err.VYOS_EMPTY_CONFIG:
+ out = ''
+ if rc == cli_shell_api_err.VYOS_INVALID_PATH:
+ Warning(out)
+ return {}
+
+ ct = ConfigTree(out)
+ d = json.loads(ct.to_json())
+ # cli-shell-api output includes last path component if tag node
+ if is_tag(path):
+ config_dict = embed_dict(path[:-1], d)
+ else:
+ config_dict = embed_dict(path, d)
+
+ if not no_multi_convert:
+ config_dict = multi_to_list([], config_dict)
+
+ if key_mangling is not None:
+ verify_mangling(key_mangling)
+ config_dict = mangle_dict_keys(config_dict,
+ key_mangling[0], key_mangling[1],
+ no_tag_node_value_mangle=no_tag_node_value_mangle)
+
+ return get_sub_dict(config_dict, path, get_first_key=get_first_key)
diff --git a/python/vyos/configsession.py b/python/vyos/configsession.py
new file mode 100644
index 0000000..7d51b94
--- /dev/null
+++ b/python/vyos/configsession.py
@@ -0,0 +1,287 @@
+# Copyright (C) 2019-2024 VyOS maintainers and contributors
+#
+# This library is free software; you can redistribute it and/or modify it under the terms of
+# the GNU Lesser General Public License as published by the Free Software Foundation;
+# either version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along with this library;
+# if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+# configsession -- the write API for the VyOS running config
+
+import os
+import re
+import sys
+import subprocess
+
+from vyos.defaults import directories
+from vyos.utils.process import is_systemd_service_running
+from vyos.utils.dict import dict_to_paths
+
+CLI_SHELL_API = '/bin/cli-shell-api'
+SET = '/opt/vyatta/sbin/my_set'
+DELETE = '/opt/vyatta/sbin/my_delete'
+COMMENT = '/opt/vyatta/sbin/my_comment'
+COMMIT = '/opt/vyatta/sbin/my_commit'
+DISCARD = '/opt/vyatta/sbin/my_discard'
+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']
+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']
+GENERATE = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'generate']
+SHOW = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'show']
+RESET = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reset']
+REBOOT = ['/opt/vyatta/bin/vyatta-op-cmd-wrapper', 'reboot']
+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']
+
+# Default "commit via" string
+APP = "vyos-http-api"
+
+# When started as a service rather than from a user shell,
+# the process lacks the VyOS-specific environment that comes
+# from bash configs, so we have to inject it
+# XXX: maybe it's better to do via a systemd environment file
+def inject_vyos_env(env):
+ env['VYATTA_CFG_GROUP_NAME'] = 'vyattacfg'
+ 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_cfg_templates'] = '/opt/vyatta/share/vyatta-cfg/templates'
+ env['vyatta_configdir'] = directories['vyos_configdir']
+ env['vyatta_datadir'] = '/opt/vyatta/share'
+ env['vyatta_datarootdir'] = '/opt/vyatta/share'
+ env['vyatta_libdir'] = '/opt/vyatta/lib'
+ env['vyatta_libexecdir'] = '/opt/vyatta/libexec'
+ env['vyatta_op_templates'] = '/opt/vyatta/share/vyatta-op/templates'
+ env['vyatta_prefix'] = '/opt/vyatta'
+ env['vyatta_sbindir'] = '/opt/vyatta/sbin'
+ env['vyatta_sysconfdir'] = '/opt/vyatta/etc'
+ env['vyos_bin_dir'] = '/usr/bin'
+ env['vyos_cfg_templates'] = '/opt/vyatta/share/vyatta-cfg/templates'
+ env['vyos_completion_dir'] = '/usr/libexec/vyos/completion'
+ 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_libdir'] = '/opt/vyatta/lib'
+ env['vyos_libexec_dir'] = '/usr/libexec/vyos'
+ env['vyos_op_scripts_dir'] = '/usr/libexec/vyos/op_mode'
+ env['vyos_op_templates'] = '/opt/vyatta/share/vyatta-op/templates'
+ env['vyos_prefix'] = '/opt/vyatta'
+ env['vyos_sbin_dir'] = '/usr/sbin'
+ env['vyos_validators_dir'] = '/usr/libexec/vyos/validators'
+
+ # if running the vyos-configd daemon, inject the vyshim env var
+ if is_systemd_service_running('vyos-configd.service'):
+ env['vyshim'] = '/usr/sbin/vyshim'
+
+ return env
+
+
+class ConfigSessionError(Exception):
+ pass
+
+
+class ConfigSession(object):
+ """
+ The write API of VyOS.
+ """
+ def __init__(self, session_id, app=APP):
+ """
+ Creates a new config session.
+
+ Args:
+ session_id (str): Session identifier
+ app (str): Application name, purely informational
+
+ Note:
+ The session identifier MUST be globally unique within the system.
+ The best practice is to only have one ConfigSession object per process
+ and used the PID for the session identifier.
+ """
+
+ 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
+ # XXX: it's better to extend cli-shell-api to provide easily readable output
+ env_list = re.findall(r'([A-Z_]+)=([^;\s]+)', env_str.decode())
+
+ session_env = os.environ
+ session_env = inject_vyos_env(session_env)
+ for k, v in env_list:
+ session_env[k] = v
+
+ self.__session_env = session_env
+ self.__session_env["COMMIT_VIA"] = app
+
+ self.__run_command([CLI_SHELL_API, 'setupSession'])
+
+ def __del__(self):
+ try:
+ 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)
+ except Exception as e:
+ 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)
+ (stdout_data, stderr_data) = p.communicate()
+ output = stdout_data.decode()
+ result = p.wait()
+ if result != 0:
+ raise ConfigSessionError(output)
+ return output
+
+ def get_session_env(self):
+ return self.__session_env
+
+ def set(self, path, value=None):
+ if not value:
+ value = []
+ else:
+ value = [value]
+ self.__run_command([SET] + path + value)
+
+ def set_section(self, path: list, d: dict):
+ try:
+ for p in dict_to_paths(d):
+ self.set(path + p)
+ except (ValueError, ConfigSessionError) as e:
+ raise ConfigSessionError(e)
+
+ def delete(self, path, value=None):
+ if not value:
+ value = []
+ else:
+ value = [value]
+ self.__run_command([DELETE] + path + value)
+
+ def load_section(self, path: list, d: dict):
+ try:
+ self.delete(path)
+ if d:
+ for p in dict_to_paths(d):
+ self.set(path + p)
+ except (ValueError, ConfigSessionError) as e:
+ raise ConfigSessionError(e)
+
+ def set_section_tree(self, d: dict):
+ try:
+ if d:
+ for p in dict_to_paths(d):
+ self.set(p)
+ except (ValueError, ConfigSessionError) as e:
+ raise ConfigSessionError(e)
+
+ def load_section_tree(self, mask: dict, d: dict):
+ try:
+ if mask:
+ for p in dict_to_paths(mask):
+ self.delete(p)
+ if d:
+ for p in dict_to_paths(d):
+ self.set(p)
+ except (ValueError, ConfigSessionError) as e:
+ raise ConfigSessionError(e)
+
+ def comment(self, path, value=None):
+ if not value:
+ value = [""]
+ else:
+ value = [value]
+ self.__run_command([COMMENT] + path + value)
+
+ def commit(self):
+ out = self.__run_command([COMMIT])
+ return out
+
+ def discard(self):
+ self.__run_command([DISCARD])
+
+ def show_config(self, path, format='raw'):
+ config_data = self.__run_command(SHOW_CONFIG + path)
+
+ if format == 'raw':
+ return config_data
+
+ def load_config(self, file_path):
+ out = self.__run_command(LOAD_CONFIG + [file_path])
+ return out
+
+ def migrate_and_load_config(self, file_path):
+ out = self.__run_command(MIGRATE_LOAD_CONFIG + [file_path])
+ return out
+
+ def save_config(self, file_path):
+ out = self.__run_command(SAVE_CONFIG + [file_path])
+ return out
+
+ def install_image(self, url):
+ out = self.__run_command(INSTALL_IMAGE + [url])
+ return out
+
+ def remove_image(self, name):
+ out = self.__run_command(REMOVE_IMAGE + [name])
+ return out
+
+ def import_pki(self, path):
+ out = self.__run_command(IMPORT_PKI + path)
+ return out
+
+ def import_pki_no_prompt(self, path):
+ out = self.__run_command(IMPORT_PKI_NO_PROMPT + path)
+ return out
+
+ def set_default_image(self, name):
+ out = self.__run_command(SET_DEFAULT_IMAGE + [name])
+ return out
+
+ def generate(self, path):
+ out = self.__run_command(GENERATE + path)
+ return out
+
+ def show(self, path):
+ out = self.__run_command(SHOW + path)
+ return out
+
+ def reboot(self, path):
+ out = self.__run_command(REBOOT + path)
+ return out
+
+ def reset(self, path):
+ out = self.__run_command(RESET + path)
+ return out
+
+ def poweroff(self, path):
+ out = self.__run_command(POWEROFF + path)
+ return out
+
+ def add_container_image(self, name):
+ out = self.__run_command(OP_CMD_ADD + ['container', 'image'] + [name])
+ return out
+
+ def delete_container_image(self, name):
+ out = self.__run_command(OP_CMD_DELETE + ['container', 'image'] + [name])
+ return out
+
+ def show_container_image(self):
+ out = self.__run_command(SHOW + ['container', 'image'])
+ return out
diff --git a/python/vyos/configsource.py b/python/vyos/configsource.py
new file mode 100644
index 0000000..59e5ac8
--- /dev/null
+++ b/python/vyos/configsource.py
@@ -0,0 +1,321 @@
+
+# Copyright 2020-2023 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 os
+import re
+import subprocess
+
+from vyos.configtree import ConfigTree
+from vyos.utils.boot import boot_configuration_complete
+
+class VyOSError(Exception):
+ """
+ Raised on config access errors.
+ """
+ pass
+
+class ConfigSourceError(Exception):
+ '''
+ Raised on error in ConfigSource subclass init.
+ '''
+ pass
+
+class ConfigSource:
+ def __init__(self):
+ self._running_config: ConfigTree = None
+ self._session_config: ConfigTree = None
+
+ def get_configtree_tuple(self):
+ return self._running_config, self._session_config
+
+ def session_changed(self):
+ """
+ Returns:
+ True if the config session has uncommited changes, False otherwise.
+ """
+ raise NotImplementedError(f"function not available for {type(self)}")
+
+ def in_session(self):
+ """
+ Returns:
+ True if called from a configuration session, False otherwise.
+ """
+ raise NotImplementedError(f"function not available for {type(self)}")
+
+ def show_config(self, path=[], default=None, effective=False):
+ """
+ Args:
+ path (str|list): Configuration tree path, or empty
+ default (str): Default value to return
+
+ Returns:
+ str: working configuration
+ """
+ raise NotImplementedError(f"function not available for {type(self)}")
+
+ def is_multi(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node can have multiple values, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ raise NotImplementedError(f"function not available for {type(self)}")
+
+ def is_tag(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node is a tag node, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ raise NotImplementedError(f"function not available for {type(self)}")
+
+ def is_leaf(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node is a leaf node, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ raise NotImplementedError(f"function not available for {type(self)}")
+
+class ConfigSourceSession(ConfigSource):
+ def __init__(self, session_env=None):
+ super().__init__()
+ self._cli_shell_api = "/bin/cli-shell-api"
+ self._level = []
+ if session_env:
+ self.__session_env = session_env
+ else:
+ self.__session_env = None
+
+ # Running config can be obtained either from op or conf mode, it always succeeds
+ # once the config system is initialized during boot;
+ # before initialization, set to empty string
+ if boot_configuration_complete():
+ try:
+ running_config_text = self._run([self._cli_shell_api, '--show-active-only', '--show-show-defaults', '--show-ignore-edit', 'showConfig'])
+ except VyOSError:
+ running_config_text = ''
+ else:
+ running_config_text = ''
+
+ # Session config ("active") only exists in conf mode.
+ # In op mode, we'll just use the same running config for both active and session configs.
+ if self.in_session():
+ try:
+ session_config_text = self._run([self._cli_shell_api, '--show-working-only', '--show-show-defaults', '--show-ignore-edit', 'showConfig'])
+ except VyOSError:
+ session_config_text = ''
+ else:
+ session_config_text = running_config_text
+
+ if running_config_text:
+ self._running_config = ConfigTree(running_config_text)
+ else:
+ self._running_config = None
+
+ if session_config_text:
+ self._session_config = ConfigTree(session_config_text)
+ else:
+ self._session_config = None
+
+ def _make_command(self, op, path):
+ args = path.split()
+ cmd = [self._cli_shell_api, op] + args
+ return cmd
+
+ def _run(self, cmd):
+ if self.__session_env:
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE, env=self.__session_env)
+ else:
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+ out = p.stdout.read()
+ p.wait()
+ p.communicate()
+ if p.returncode != 0:
+ raise VyOSError()
+ else:
+ return out.decode()
+
+ def set_level(self, path):
+ """
+ Set the *edit level*, that is, a relative config tree path.
+ Once set, all operations will be relative to this path,
+ for example, after ``set_level("system")``, calling
+ ``exists("name-server")`` is equivalent to calling
+ ``exists("system name-server"`` without ``set_level``.
+
+ Args:
+ path (str|list): relative config path
+ """
+ # Make sure there's always a space between default path (level)
+ # and path supplied as method argument
+ # XXX: for small strings in-place concatenation is not a problem
+ if isinstance(path, str):
+ if path:
+ self._level = re.split(r'\s+', path)
+ else:
+ self._level = []
+ elif isinstance(path, list):
+ self._level = path.copy()
+ else:
+ raise TypeError("Level path must be either a whitespace-separated string or a list")
+
+ def session_changed(self):
+ """
+ Returns:
+ True if the config session has uncommited changes, False otherwise.
+ """
+ try:
+ self._run(self._make_command('sessionChanged', ''))
+ return True
+ except VyOSError:
+ return False
+
+ def in_session(self):
+ """
+ Returns:
+ True if called from a configuration session, False otherwise.
+ """
+ if os.getenv('VYOS_CONFIGD', ''):
+ return False
+ try:
+ self._run(self._make_command('inSession', ''))
+ return True
+ except VyOSError:
+ return False
+
+ def show_config(self, path=[], default=None, effective=False):
+ """
+ Args:
+ path (str|list): Configuration tree path, or empty
+ default (str): Default value to return
+
+ Returns:
+ str: working configuration
+ """
+
+ # show_config should be independent of CLI edit level.
+ # Set the CLI edit environment to the top level, and
+ # restore original on exit.
+ save_env = self.__session_env
+
+ env_str = self._run(self._make_command('getEditResetEnv', ''))
+ env_list = re.findall(r'([A-Z_]+)=\'([^;\s]+)\'', env_str)
+ root_env = os.environ
+ for k, v in env_list:
+ root_env[k] = v
+
+ self.__session_env = root_env
+
+ # FIXUP: by default, showConfig will give you a diff
+ # if there are uncommitted changes.
+ # The config parser obviously cannot work with diffs,
+ # so we need to supress diff production using appropriate
+ # options for getting either running (active)
+ # or proposed (working) config.
+ if effective:
+ path = ['--show-active-only'] + path
+ else:
+ path = ['--show-working-only'] + path
+
+ if isinstance(path, list):
+ path = " ".join(path)
+ try:
+ out = self._run(self._make_command('showConfig', path))
+ self.__session_env = save_env
+ return out
+ except VyOSError:
+ self.__session_env = save_env
+ return(default)
+
+ def is_multi(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node can have multiple values, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ try:
+ path = " ".join(self._level) + " " + path
+ self._run(self._make_command('isMulti', path))
+ return True
+ except VyOSError:
+ return False
+
+ def is_tag(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node is a tag node, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ try:
+ path = " ".join(self._level) + " " + path
+ self._run(self._make_command('isTag', path))
+ return True
+ except VyOSError:
+ return False
+
+ def is_leaf(self, path):
+ """
+ Args:
+ path (str): Configuration tree path
+
+ Returns:
+ True if a node is a leaf node, False otherwise.
+
+ Note:
+ It also returns False if node doesn't exist.
+ """
+ try:
+ path = " ".join(self._level) + " " + path
+ self._run(self._make_command('isLeaf', path))
+ return True
+ except VyOSError:
+ return False
+
+class ConfigSourceString(ConfigSource):
+ def __init__(self, running_config_text=None, session_config_text=None):
+ super().__init__()
+
+ try:
+ self._running_config = ConfigTree(running_config_text) if running_config_text else None
+ self._session_config = ConfigTree(session_config_text) if session_config_text else None
+ except ValueError:
+ raise ConfigSourceError(f"Init error in {type(self)}")
diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py
new file mode 100644
index 0000000..bd77ab8
--- /dev/null
+++ b/python/vyos/configtree.py
@@ -0,0 +1,498 @@
+# configtree -- a standalone VyOS config file manipulation library (Python bindings)
+# Copyright (C) 2018-2024 VyOS maintainers and contributors
+#
+# This library is free software; you can redistribute it and/or modify it under the terms of
+# the GNU Lesser General Public License as published by the Free Software Foundation;
+# either version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along with this library;
+# if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+import os
+import re
+import json
+import logging
+
+from ctypes import cdll, c_char_p, c_void_p, c_int, c_bool
+
+LIBPATH = '/usr/lib/libvyosconfig.so.0'
+
+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 """
+ t = re.split('(^//)', s, maxsplit=1, flags=re.MULTILINE)
+ return (s, ''.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)))
+ else:
+ pass
+
+
+class ConfigTreeError(Exception):
+ pass
+
+
+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'")
+ self.__config = None
+ self.__lib = cdll.LoadLibrary(libpath)
+
+ # Import functions
+ self.__from_string = self.__lib.from_string
+ self.__from_string.argtypes = [c_char_p]
+ self.__from_string.restype = c_void_p
+
+ self.__get_error = self.__lib.get_error
+ self.__get_error.argtypes = []
+ self.__get_error.restype = c_char_p
+
+ self.__to_string = self.__lib.to_string
+ self.__to_string.argtypes = [c_void_p, c_bool]
+ self.__to_string.restype = c_char_p
+
+ self.__to_commands = self.__lib.to_commands
+ self.__to_commands.argtypes = [c_void_p, c_char_p]
+ self.__to_commands.restype = c_char_p
+
+ self.__to_json = self.__lib.to_json
+ self.__to_json.argtypes = [c_void_p]
+ self.__to_json.restype = c_char_p
+
+ self.__to_json_ast = self.__lib.to_json_ast
+ self.__to_json_ast.argtypes = [c_void_p]
+ self.__to_json_ast.restype = c_char_p
+
+ 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
+
+ self.__delete_value = self.__lib.delete_value
+ self.__delete_value.argtypes = [c_void_p, c_char_p, c_char_p]
+ self.__delete_value.restype = c_int
+
+ self.__delete = self.__lib.delete_node
+ self.__delete.argtypes = [c_void_p, c_char_p]
+ self.__delete.restype = c_int
+
+ self.__rename = self.__lib.rename_node
+ self.__rename.argtypes = [c_void_p, c_char_p, c_char_p]
+ self.__rename.restype = c_int
+
+ self.__copy = self.__lib.copy_node
+ self.__copy.argtypes = [c_void_p, c_char_p, c_char_p]
+ self.__copy.restype = c_int
+
+ self.__set_replace_value = self.__lib.set_replace_value
+ self.__set_replace_value.argtypes = [c_void_p, c_char_p, c_char_p]
+ self.__set_replace_value.restype = c_int
+
+ self.__set_valueless = self.__lib.set_valueless
+ self.__set_valueless.argtypes = [c_void_p, c_char_p]
+ self.__set_valueless.restype = c_int
+
+ self.__exists = self.__lib.exists
+ self.__exists.argtypes = [c_void_p, c_char_p]
+ self.__exists.restype = c_int
+
+ self.__list_nodes = self.__lib.list_nodes
+ self.__list_nodes.argtypes = [c_void_p, c_char_p]
+ self.__list_nodes.restype = c_char_p
+
+ self.__return_value = self.__lib.return_value
+ self.__return_value.argtypes = [c_void_p, c_char_p]
+ self.__return_value.restype = c_char_p
+
+ self.__return_values = self.__lib.return_values
+ self.__return_values.argtypes = [c_void_p, c_char_p]
+ self.__return_values.restype = c_char_p
+
+ self.__is_tag = self.__lib.is_tag
+ self.__is_tag.argtypes = [c_void_p, c_char_p]
+ 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.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
+
+ self.__destroy = self.__lib.destroy
+ self.__destroy.argtypes = [c_void_p]
+
+ if address is 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))
+ else:
+ self.__config = config
+ self.__version = version_section
+ else:
+ self.__config = address
+ self.__version = ''
+
+ self.__migration = os.environ.get('VYOS_MIGRATION')
+ if self.__migration:
+ self.migration_log = logging.getLogger('vyos.migrate')
+
+ def __del__(self):
+ if self.__config is not None:
+ self.__destroy(self.__config)
+
+ def __str__(self):
+ return self.to_string()
+
+ def _get_config(self):
+ return self.__config
+
+ def get_version_string(self):
+ return self.__version
+
+ 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)
+ return config_string
+
+ def to_commands(self, op="set"):
+ commands = self.__to_commands(self.__config, op.encode()).decode()
+ commands = unescape_backslash(commands)
+ return commands
+
+ def to_json(self):
+ return self.__to_json(self.__config).decode()
+
+ def to_json_ast(self):
+ return self.__to_json_ast(self.__config).decode()
+
+ def set(self, path, value=None, replace=True):
+ """Set new entry in VyOS configuration.
+ path: configuration path e.g. 'system dns forwarding listen-address'
+ value: value to be added to node, e.g. '172.18.254.201'
+ replace: True: current occurance will be replaced
+ False: new value will be appended to current occurances - use
+ this for adding values to a multi node
+ """
+
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ if value is None:
+ self.__set_valueless(self.__config, path_str)
+ else:
+ if replace:
+ self.__set_replace_value(self.__config, path_str, str(value).encode())
+ else:
+ 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}")
+
+ def delete(self, path):
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ res = self.__delete(self.__config, path_str)
+ if (res != 0):
+ raise ConfigTreeError(f"Path doesn't exist: {path}")
+
+ if self.__migration:
+ 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()
+
+ res = self.__delete_value(self.__config, path_str, value.encode())
+ if (res != 0):
+ if res == 1:
+ raise ConfigTreeError(f"Path doesn't exist: {path}")
+ elif res == 2:
+ raise ConfigTreeError(f"Value doesn't exist: '{value}'")
+ else:
+ raise ConfigTreeError()
+
+ if self.__migration:
+ 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()
+ newname_str = new_name.encode()
+
+ # Check if a node with intended new name already exists
+ new_path = path[:-1] + [new_name]
+ if self.exists(new_path):
+ raise ConfigTreeError()
+ res = self.__rename(self.__config, path_str, newname_str)
+ 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}")
+
+ 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()
+
+ # 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):
+ 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}")
+
+ def exists(self, path):
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ res = self.__exists(self.__config, path_str)
+ 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()
+
+ res_json = self.__list_nodes(self.__config, path_str).decode()
+ res = json.loads(res_json)
+
+ if res is None:
+ if path_must_exist:
+ raise ConfigTreeError("Path [{}] doesn't exist".format(path_str))
+ else:
+ return []
+ else:
+ return res
+
+ def return_value(self, path):
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ res_json = self.__return_value(self.__config, path_str).decode()
+ res = json.loads(res_json)
+
+ if res is None:
+ raise ConfigTreeError("Path [{}] doesn't exist".format(path_str))
+ else:
+ return res
+
+ def return_values(self, path):
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ res_json = self.__return_values(self.__config, path_str).decode()
+ res = json.loads(res_json)
+
+ if res is None:
+ raise ConfigTreeError("Path [{}] doesn't exist".format(path_str))
+ else:
+ return res
+
+ def is_tag(self, path):
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ res = self.__is_tag(self.__config, path_str)
+ if (res >= 1):
+ return True
+ else:
+ return False
+
+ def set_tag(self, path):
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ res = self.__set_tag(self.__config, path_str)
+ 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()
+
+ 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")
+ 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()
+
+ __lib = cdll.LoadLibrary(libpath)
+ __show_diff = __lib.show_diff
+ __show_diff.argtypes = [c_bool, c_char_p, c_void_p, c_void_p]
+ __show_diff.restype = c_char_p
+ __get_error = __lib.get_error
+ __get_error.argtypes = []
+ __get_error.restype = c_char_p
+
+ res = __show_diff(commands, path_str, left._get_config(), right._get_config())
+ res = res.decode()
+ 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")
+
+ __lib = cdll.LoadLibrary(libpath)
+ __tree_union = __lib.tree_union
+ __tree_union.argtypes = [c_void_p, c_void_p]
+ __tree_union.restype = c_void_p
+ __get_error = __lib.get_error
+ __get_error.argtypes = []
+ __get_error.restype = c_char_p
+
+ 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")
+
+ try:
+ __lib = cdll.LoadLibrary(libpath)
+ __mask_tree = __lib.mask_tree
+ __mask_tree.argtypes = [c_void_p, c_void_p]
+ __mask_tree.restype = c_void_p
+ __get_error = __lib.get_error
+ __get_error.argtypes = []
+ __get_error.restype = c_char_p
+
+ res = __mask_tree(left._get_config(), right._get_config())
+ except Exception as e:
+ raise ConfigTreeError(e)
+ if not res:
+ msg = __get_error().decode()
+ raise ConfigTreeError(msg)
+
+ tree = ConfigTree(address=res)
+
+ return tree
+
+def reference_tree_to_json(from_dir, to_file, 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]
+ __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())
+ 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:
+ 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")
+ if path:
+ if not left.exists(path):
+ raise ConfigTreeError(f"Path {path} doesn't exist in lhs tree")
+ if not right.exists(path):
+ raise ConfigTreeError(f"Path {path} doesn't exist in rhs tree")
+
+ self.left = left
+ self.right = right
+
+ self.__lib = cdll.LoadLibrary(libpath)
+
+ self.__diff_tree = self.__lib.diff_tree
+ self.__diff_tree.argtypes = [c_char_p, c_void_p, c_void_p]
+ self.__diff_tree.restype = c_void_p
+
+ check_path(path)
+ path_str = " ".join(map(str, path)).encode()
+
+ res = self.__diff_tree(path_str, left._get_config(), right._get_config())
+
+ # full diff config_tree and python dict representation
+ self.full = ConfigTree(address=res)
+ self.dict = json.loads(self.full.to_json())
+
+ # config_tree sub-trees
+ self.add = self.full.get_subtree(['add'])
+ self.sub = self.full.get_subtree(['sub'])
+ self.inter = self.full.get_subtree(['inter'])
+ self.delete = self.full.get_subtree(['del'])
+
+ def to_commands(self):
+ add = self.add.to_commands()
+ 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
+ """
+ D = DiffTree(None, config_tree)
+ return D.add
diff --git a/python/vyos/configverify.py b/python/vyos/configverify.py
new file mode 100644
index 0000000..92996f2
--- /dev/null
+++ b/python/vyos/configverify.py
@@ -0,0 +1,539 @@
+# 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/>.
+
+# The sole purpose of this module is to hold common functions used in
+# all kinds of implementations to verify the CLI configuration.
+# It is started by migrating the interfaces to the new get_config_dict()
+# approach which will lead to a lot of code that can be reused.
+
+# NOTE: imports should be as local as possible to the function which
+# makes use of it!
+
+from vyos import ConfigError
+from vyos.utils.dict import dict_search
+# pattern re-used in ipsec migration script
+dynamic_interface_pattern = r'(ppp|pppoe|sstpc|l2tp|ipoe)[0-9]+'
+
+def verify_mtu(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation if the specified MTU can be used by the underlaying
+ hardware.
+ """
+ from vyos.ifconfig import Interface
+ if 'mtu' in config:
+ mtu = int(config['mtu'])
+
+ tmp = Interface(config['ifname'])
+ # Not all interfaces support min/max MTU
+ # https://vyos.dev/T5011
+ try:
+ min_mtu = tmp.get_min_mtu()
+ max_mtu = tmp.get_max_mtu()
+ except: # Fallback to defaults
+ min_mtu = 68
+ max_mtu = 9000
+
+ if mtu < min_mtu:
+ raise ConfigError(f'Interface MTU too low, ' \
+ f'minimum supported MTU is {min_mtu}!')
+ if mtu > max_mtu:
+ raise ConfigError(f'Interface MTU too high, ' \
+ f'maximum supported MTU is {max_mtu}!')
+
+def verify_mtu_parent(config, parent):
+ if 'mtu' not in config or 'mtu' not in parent:
+ return
+
+ mtu = int(config['mtu'])
+ parent_mtu = int(parent['mtu'])
+ if mtu > parent_mtu:
+ raise ConfigError(f'Interface MTU "{mtu}" too high, ' \
+ f'parent interface MTU is "{parent_mtu}"!')
+
+def verify_mtu_ipv6(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation if the specified MTU can be used when IPv6 is
+ configured on the interface. IPv6 requires a 1280 bytes MTU.
+ """
+ from vyos.template import is_ipv6
+ if 'mtu' in config:
+ # IPv6 minimum required link mtu
+ min_mtu = 1280
+ if int(config['mtu']) < min_mtu:
+ interface = config['ifname']
+ error_msg = f'IPv6 address will be configured on interface "{interface}",\n' \
+ f'the required minimum MTU is "{min_mtu}"!'
+
+ if 'address' in config:
+ for address in config['address']:
+ if address in ['dhcpv6'] or is_ipv6(address):
+ raise ConfigError(error_msg)
+
+ tmp = dict_search('ipv6.address.no_default_link_local', config)
+ if tmp == None: raise ConfigError('link-local ' + error_msg)
+
+ tmp = dict_search('ipv6.address.autoconf', config)
+ if tmp != None: raise ConfigError(error_msg)
+
+ tmp = dict_search('ipv6.address.eui64', config)
+ if tmp != None: raise ConfigError(error_msg)
+
+def verify_vrf(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation of VRF configuration.
+ """
+ from vyos.utils.network import interface_exists
+ if 'vrf' in config:
+ vrfs = config['vrf']
+ if isinstance(vrfs, str):
+ vrfs = [vrfs]
+
+ for vrf in vrfs:
+ if vrf == 'default':
+ continue
+ if not interface_exists(vrf):
+ raise ConfigError(f'VRF "{vrf}" does not exist!')
+
+ if 'is_bridge_member' in config:
+ raise ConfigError(
+ 'Interface "{ifname}" cannot be both a member of VRF "{vrf}" '
+ 'and bridge "{is_bridge_member}"!'.format(**config))
+
+def verify_bond_bridge_member(config):
+ """
+ Checks if interface has a VRF configured and is also part of a bond or
+ bridge, which is not allowed!
+ """
+ if 'vrf' in config:
+ ifname = config['ifname']
+ if 'is_bond_member' in config:
+ raise ConfigError(f'Can not add interface "{ifname}" to bond, it has a VRF assigned!')
+ if 'is_bridge_member' in config:
+ raise ConfigError(f'Can not add interface "{ifname}" to bridge, it has a VRF assigned!')
+
+def verify_tunnel(config):
+ """
+ This helper is used to verify the common part of the tunnel
+ """
+ from vyos.template import is_ipv4
+ from vyos.template import is_ipv6
+
+ if 'encapsulation' not in config:
+ raise ConfigError('Must configure the tunnel encapsulation for '\
+ '{ifname}!'.format(**config))
+
+ if 'source_address' not in config and 'source_interface' not in config:
+ raise ConfigError('source-address or source-interface required for tunnel!')
+
+ if 'remote' not in config and config['encapsulation'] != 'gre':
+ raise ConfigError('remote ip address is mandatory for tunnel')
+
+ if config['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre', 'ip6gretap', 'ip6erspan']:
+ error_ipv6 = 'Encapsulation mode requires IPv6'
+ if 'source_address' in config and not is_ipv6(config['source_address']):
+ raise ConfigError(f'{error_ipv6} source-address')
+
+ if 'remote' in config and not is_ipv6(config['remote']):
+ raise ConfigError(f'{error_ipv6} remote')
+ else:
+ error_ipv4 = 'Encapsulation mode requires IPv4'
+ if 'source_address' in config and not is_ipv4(config['source_address']):
+ raise ConfigError(f'{error_ipv4} source-address')
+
+ if 'remote' in config and not is_ipv4(config['remote']):
+ raise ConfigError(f'{error_ipv4} remote address')
+
+ if config['encapsulation'] in ['sit', 'gretap', 'ip6gretap']:
+ if 'source_interface' in config:
+ encapsulation = config['encapsulation']
+ raise ConfigError(f'Option source-interface can not be used with ' \
+ f'encapsulation "{encapsulation}"!')
+ elif config['encapsulation'] == 'gre':
+ if 'source_address' in config and is_ipv6(config['source_address']):
+ raise ConfigError('Can not use local IPv6 address is for mGRE tunnels')
+
+def verify_mirror_redirect(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation of mirror and redirect interface configuration via tc(8)
+
+ It makes no sense to mirror traffic back at yourself!
+ """
+ from vyos.utils.network import interface_exists
+ if {'mirror', 'redirect'} <= set(config):
+ raise ConfigError('Mirror and redirect can not be enabled at the same time!')
+
+ if 'mirror' in config:
+ for direction, mirror_interface in config['mirror'].items():
+ if not interface_exists(mirror_interface):
+ raise ConfigError(f'Requested mirror interface "{mirror_interface}" '\
+ 'does not exist!')
+
+ if mirror_interface == config['ifname']:
+ raise ConfigError(f'Can not mirror "{direction}" traffic back '\
+ 'the originating interface!')
+
+ if 'redirect' in config:
+ redirect_ifname = config['redirect']
+ if not interface_exists(redirect_ifname):
+ raise ConfigError(f'Requested redirect interface "{redirect_ifname}" '\
+ 'does not exist!')
+
+ if ('mirror' in config or 'redirect' in config) and dict_search('traffic_policy.in', config) is not None:
+ # XXX: support combination of limiting and redirect/mirror - this is an
+ # artificial limitation
+ raise ConfigError('Can not use ingress policy together with mirror or redirect!')
+
+def verify_authentication(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation of authentication for either PPPoE or WWAN interfaces.
+
+ If authentication CLI option is defined, both username and password must
+ be set!
+ """
+ if 'authentication' not in config:
+ return
+ if not {'username', 'password'} <= set(config['authentication']):
+ raise ConfigError('Authentication requires both username and ' \
+ 'password to be set!')
+
+def verify_address(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation of IP address assignment when interface is part
+ of a bridge or bond.
+ """
+ if {'is_bridge_member', 'address'} <= set(config):
+ interface = config['ifname']
+ bridge_name = next(iter(config['is_bridge_member']))
+ raise ConfigError(f'Cannot assign address to interface "{interface}" '
+ f'as it is a member of bridge "{bridge_name}"!')
+
+def verify_bridge_delete(config):
+ """
+ Common helper function used by interface implementations to
+ perform recurring validation of IP address assignmenr
+ when interface also is part of a bridge.
+ """
+ if 'is_bridge_member' in config:
+ interface = config['ifname']
+ bridge_name = next(iter(config['is_bridge_member']))
+ raise ConfigError(f'Interface "{interface}" cannot be deleted as it '
+ f'is a member of bridge "{bridge_name}"!')
+
+def verify_interface_exists(config, ifname, state_required=False, warning_only=False):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation if an interface actually exists. We first probe
+ if the interface is defined on the CLI, if it's not found we try if
+ it exists at the OS level.
+ """
+ from vyos.base import Warning
+ from vyos.utils.dict import dict_search_recursive
+ from vyos.utils.network import interface_exists
+
+ if not state_required:
+ # Check if interface is present in CLI config
+ tmp = getattr(config, 'interfaces_root', {})
+ if bool(list(dict_search_recursive(tmp, ifname))):
+ return True
+
+ # Interface not found on CLI, try Linux Kernel
+ if interface_exists(ifname):
+ return True
+
+ message = f'Interface "{ifname}" does not exist!'
+ if warning_only:
+ Warning(message)
+ return False
+ raise ConfigError(message)
+
+def verify_source_interface(config):
+ """
+ Common helper function used by interface implementations to
+ perform recurring validation of the existence of a source-interface
+ required by e.g. peth/MACvlan, MACsec ...
+ """
+ import re
+ from vyos.utils.network import interface_exists
+
+ ifname = config['ifname']
+ if 'source_interface' not in config:
+ raise ConfigError(f'Physical source-interface required for "{ifname}"!')
+
+ src_ifname = config['source_interface']
+ # We do not allow sourcing other interfaces (e.g. tunnel) from dynamic interfaces
+ tmp = re.compile(dynamic_interface_pattern)
+ if tmp.match(src_ifname):
+ raise ConfigError(f'Can not source "{ifname}" from dynamic interface "{src_ifname}"!')
+
+ if not interface_exists(src_ifname):
+ raise ConfigError(f'Specified source-interface {src_ifname} does not exist')
+
+ if 'source_interface_is_bridge_member' in config:
+ bridge_name = next(iter(config['source_interface_is_bridge_member']))
+ raise ConfigError(f'Invalid source-interface "{src_ifname}". Interface '
+ f'is already a member of bridge "{bridge_name}"!')
+
+ if 'source_interface_is_bond_member' in config:
+ bond_name = next(iter(config['source_interface_is_bond_member']))
+ raise ConfigError(f'Invalid source-interface "{src_ifname}". Interface '
+ f'is already a member of bond "{bond_name}"!')
+
+ if 'is_source_interface' in config:
+ tmp = config['is_source_interface']
+ raise ConfigError(f'Can not use source-interface "{src_ifname}", it already ' \
+ f'belongs to interface "{tmp}"!')
+
+def verify_dhcpv6(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation of DHCPv6 options which are mutually exclusive.
+ """
+ if 'dhcpv6_options' in config:
+ if {'parameters_only', 'temporary'} <= set(config['dhcpv6_options']):
+ raise ConfigError('DHCPv6 temporary and parameters-only options '
+ 'are mutually exclusive!')
+
+ # It is not allowed to have duplicate SLA-IDs as those identify an
+ # assigned IPv6 subnet from a delegated prefix
+ for pd in (dict_search('dhcpv6_options.pd', config) or []):
+ sla_ids = []
+ interfaces = dict_search(f'dhcpv6_options.pd.{pd}.interface', config)
+
+ if not interfaces:
+ raise ConfigError('DHCPv6-PD requires an interface where to assign '
+ 'the delegated prefix!')
+
+ for count, interface in enumerate(interfaces):
+ if 'sla_id' in interfaces[interface]:
+ sla_ids.append(interfaces[interface]['sla_id'])
+ else:
+ sla_ids.append(str(count))
+
+ # Check for duplicates
+ duplicates = [x for n, x in enumerate(sla_ids) if x in sla_ids[:n]]
+ if duplicates:
+ raise ConfigError('Site-Level Aggregation Identifier (SLA-ID) '
+ 'must be unique per prefix-delegation!')
+
+def verify_vlan_config(config):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation of interface VLANs
+ """
+
+ # VLAN and Q-in-Q IDs are not allowed to overlap
+ if 'vif' in config and 'vif_s' in config:
+ duplicate = list(set(config['vif']) & set(config['vif_s']))
+ if duplicate:
+ raise ConfigError(f'Duplicate VLAN id "{duplicate[0]}" used for vif and vif-s interfaces!')
+
+ parent_ifname = config['ifname']
+ # 802.1q VLANs
+ for vlan_id in config.get('vif', {}):
+ vlan = config['vif'][vlan_id]
+ vlan['ifname'] = f'{parent_ifname}.{vlan_id}'
+
+ verify_dhcpv6(vlan)
+ verify_address(vlan)
+ verify_vrf(vlan)
+ verify_mirror_redirect(vlan)
+ verify_mtu_parent(vlan, config)
+
+ # 802.1ad (Q-in-Q) VLANs
+ for s_vlan_id in config.get('vif_s', {}):
+ s_vlan = config['vif_s'][s_vlan_id]
+ s_vlan['ifname'] = f'{parent_ifname}.{s_vlan_id}'
+
+ verify_dhcpv6(s_vlan)
+ verify_address(s_vlan)
+ verify_vrf(s_vlan)
+ verify_mirror_redirect(s_vlan)
+ verify_mtu_parent(s_vlan, config)
+
+ for c_vlan_id in s_vlan.get('vif_c', {}):
+ c_vlan = s_vlan['vif_c'][c_vlan_id]
+ c_vlan['ifname'] = f'{parent_ifname}.{s_vlan_id}.{c_vlan_id}'
+
+ verify_dhcpv6(c_vlan)
+ verify_address(c_vlan)
+ verify_vrf(c_vlan)
+ verify_mirror_redirect(c_vlan)
+ verify_mtu_parent(c_vlan, config)
+ verify_mtu_parent(c_vlan, s_vlan)
+
+
+def verify_diffie_hellman_length(file, min_keysize):
+ """ Verify Diffie-Hellamn keypair length given via file. It must be greater
+ then or equal to min_keysize """
+ import os
+ import re
+ from vyos.utils.process import cmd
+
+ try:
+ keysize = str(min_keysize)
+ except:
+ return False
+
+ if os.path.exists(file):
+ out = cmd(f'openssl dhparam -inform PEM -in {file} -text')
+ prog = re.compile('\d+\s+bit')
+ if prog.search(out):
+ bits = prog.search(out)[0].split()[0]
+ if int(bits) >= int(min_keysize):
+ return True
+
+ return False
+
+def verify_common_route_maps(config):
+ """
+ Common helper function used by routing protocol implementations to perform
+ recurring validation if the specified route-map for either zebra to kernel
+ installation exists (this is the top-level route_map key) or when a route
+ is redistributed with a route-map that it exists!
+ """
+ # XXX: This function is called in combination with a previous call to:
+ # tmp = conf.get_config_dict(['policy']) - see protocols_ospf.py as example.
+ # We should NOT call this with the key_mangling option as this would rename
+ # route-map hypens '-' to underscores '_' and one could no longer distinguish
+ # what should have been the "proper" route-map name, as foo-bar and foo_bar
+ # are two entire different route-map instances!
+ for route_map in ['route-map', 'route_map']:
+ if route_map not in 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:
+ raise ConfigError(f'Specified route-map "{tmp}" does not exist!')
+
+ if 'redistribute' in config:
+ for protocol, protocol_config in config['redistribute'].items():
+ if 'route_map' in protocol_config:
+ verify_route_map(protocol_config['route_map'], config)
+
+def verify_route_map(route_map_name, config):
+ """
+ Common helper function used by routing protocol implementations to perform
+ 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:
+ raise ConfigError(f'Specified route-map "{route_map_name}" does not exist!')
+
+def verify_prefix_list(prefix_list, config, version=''):
+ """
+ Common helper function used by routing protocol implementations to perform
+ 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:
+ raise ConfigError(f'Specified prefix-list{version} "{prefix_list}" does not exist!')
+
+def verify_access_list(access_list, config, version=''):
+ """
+ Common helper function used by routing protocol implementations to perform
+ 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:
+ 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):
+ """
+ Common helper function user by PKI consumers to perform recurring
+ validation functions for PEM based certificates
+ """
+ if 'pki' not in config:
+ raise ConfigError('PKI is not configured!')
+
+ if 'certificate' not in config['pki']:
+ raise ConfigError('PKI does not contain any certificates!')
+
+ if cert_name not in config['pki']['certificate']:
+ raise ConfigError(f'Certificate "{cert_name}" not found in configuration!')
+
+ pki_cert = config['pki']['certificate'][cert_name]
+ if 'certificate' not in pki_cert:
+ raise ConfigError(f'PEM certificate for "{cert_name}" missing in configuration!')
+
+ if 'private' not in pki_cert or 'key' not in pki_cert['private']:
+ raise ConfigError(f'PEM private key for "{cert_name}" missing in configuration!')
+
+ if no_password_protected and 'password_protected' in pki_cert['private']:
+ raise ConfigError('Password protected PEM private key is not supported!')
+
+def verify_pki_ca_certificate(config: dict, ca_name: str):
+ """
+ Common helper function user by PKI consumers to perform recurring
+ validation functions for PEM based CA certificates
+ """
+ if 'pki' not in config:
+ raise ConfigError('PKI is not configured!')
+
+ if 'ca' not in config['pki']:
+ raise ConfigError('PKI does not contain any CA certificates!')
+
+ if ca_name not in config['pki']['ca']:
+ raise ConfigError(f'CA Certificate "{ca_name}" not found in configuration!')
+
+ pki_cert = config['pki']['ca'][ca_name]
+ if 'certificate' not in pki_cert:
+ raise ConfigError(f'PEM CA certificate for "{cert_name}" missing in configuration!')
+
+def verify_pki_dh_parameters(config: dict, dh_name: str, min_key_size: int=0):
+ """
+ Common helper function user by PKI consumers to perform recurring
+ validation functions on DH parameters
+ """
+ from vyos.pki import load_dh_parameters
+
+ if 'pki' not in config:
+ raise ConfigError('PKI is not configured!')
+
+ if 'dh' not in config['pki']:
+ raise ConfigError('PKI does not contain any DH parameters!')
+
+ if dh_name not in config['pki']['dh']:
+ raise ConfigError(f'DH parameter "{dh_name}" not found in configuration!')
+
+ if min_key_size:
+ pki_dh = config['pki']['dh'][dh_name]
+ dh_params = load_dh_parameters(pki_dh['parameters'])
+ dh_numbers = dh_params.parameter_numbers()
+ dh_bits = dh_numbers.p.bit_length()
+ if dh_bits < min_key_size:
+ raise ConfigError(f'Minimum DH key-size is {min_key_size} bits!')
+
+def verify_eapol(config: dict):
+ """
+ Common helper function used by interface implementations to perform
+ recurring validation of EAPoL configuration.
+ """
+ if 'eapol' not in config:
+ return
+
+ if 'certificate' not in config['eapol']:
+ raise ConfigError('Certificate must be specified when using EAPoL!')
+
+ verify_pki_certificate(config, config['eapol']['certificate'], no_password_protected=True)
+
+ if 'ca_certificate' in config['eapol']:
+ for ca_cert in config['eapol']['ca_certificate']:
+ verify_pki_ca_certificate(config, ca_cert)
diff --git a/python/vyos/debug.py b/python/vyos/debug.py
new file mode 100644
index 0000000..6ce42b1
--- /dev/null
+++ b/python/vyos/debug.py
@@ -0,0 +1,205 @@
+# Copyright 2019 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 os
+import sys
+from datetime import datetime
+
+def message(message, flag='', destination=sys.stdout):
+ """
+ print a debug message line on stdout if debugging is enabled for the flag
+ also log it to a file if the flag 'log' is enabled
+
+ message: the message to print
+ flag: which flag must be set for it to print
+ destination: which file like object to write to (default: sys.stdout)
+
+ returns if any message was logged or not
+ """
+ enable = enabled(flag)
+ if enable:
+ destination.write(_format(flag,message))
+
+ # the log flag is special as it logs all the commands
+ # executed to a log
+ logfile = _logfile('log', '/tmp/developer-log')
+ if not logfile:
+ return enable
+
+ try:
+ # at boot the file is created as root:vyattacfg
+ # at runtime the file is created as user:vyattacfg
+ # but the helper scripts are not run as this so it
+ # need the default permission to be 666 (an not 660)
+ mask = os.umask(0o111)
+
+ with open(logfile, 'a') as f:
+ f.write(_timed(_format('log', message)))
+ finally:
+ os.umask(mask)
+
+ return enable
+
+
+def enabled(flag):
+ """
+ a flag can be set by touching the file in /tmp or /config
+
+ The current flags are:
+ - developer: the code will drop into PBD on un-handled exception
+ - log: the code will log all command to a file
+ - ifconfig: when modifying an interface,
+ prints command with result and sysfs access on stdout for interface
+ - command: print command run with result
+
+ Having the flag setup on the filesystem is required to have
+ debuging at boot time, however, setting the flag via environment
+ does not require a seek to the filesystem and is more efficient
+ it can be done on the shell on via .bashrc for the user
+
+ The function returns an empty string if the flag was not set otherwise
+ the function returns either the file or environment name used to set it up
+ """
+
+ # this is to force all new flags to be registered here to be
+ # documented both here and a reminder to update readthedocs :-)
+ if flag not in ['developer', 'log', 'ifconfig', 'command']:
+ return ''
+
+ return _fromenv(flag) or _fromfile(flag)
+
+
+def _timed(message):
+ now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ return f'{now} {message}'
+
+
+def _remove_invisible(string):
+ for char in ('\0', '\a', '\b', '\f', '\v'):
+ string = string.replace(char, '')
+ return string
+
+
+def _format(flag, message):
+ """
+ format a log message
+ """
+ message = _remove_invisible(message)
+ return f'DEBUG/{flag.upper():<7} {message}\n'
+
+
+def _fromenv(flag):
+ """
+ check if debugging is set for this flag via environment
+
+ For a given debug flag named "test"
+ The presence of the environment VYOS_TEST_DEBUG (uppercase) enables it
+
+ return empty string if not
+ return content of env value it is
+ """
+
+ flagname = f'VYOS_{flag.upper()}_DEBUG'
+ flagenv = os.environ.get(flagname, None)
+
+ if flagenv is None:
+ return ''
+ return flagenv
+
+
+def _fromfile(flag):
+ """
+ Check if debug exist for a given debug flag name
+
+ Check is a debug flag was set by the user. the flag can be set either:
+ - in /tmp for a non-persistent presence between reboot
+ - in /config for always on (an existence at boot time)
+
+ For a given debug flag named "test"
+ The presence of the file vyos.test.debug (all lowercase) enables it
+
+ The function returns an empty string if the flag was not set otherwise
+ the function returns the full flagname
+ """
+
+ for folder in ('/tmp', '/config'):
+ flagfile = f'{folder}/vyos.{flag}.debug'
+ if os.path.isfile(flagfile):
+ return flagfile
+
+ return ''
+
+
+def _contentenv(flag):
+ return os.environ.get(f'VYOS_{flag.upper()}_DEBUG', '').strip()
+
+
+def _contentfile(flag, default=''):
+ """
+ Check if debug exist for a given debug flag name
+
+ Check is a debug flag was set by the user. the flag can be set either:
+ - in /tmp for a non-persistent presence between reboot
+ - in /config for always on (an existence at boot time)
+
+ For a given debug flag named "test"
+ The presence of the file vyos.test.debug (all lowercase) enables it
+
+ The function returns an empty string if the flag was not set otherwise
+ the function returns the full flagname
+ """
+
+ for folder in ('/tmp', '/config'):
+ flagfile = f'{folder}/vyos.{flag}.debug'
+ if not os.path.isfile(flagfile):
+ continue
+ with open(flagfile) as f:
+ content = f.readline().strip()
+ return content or default
+
+ return ''
+
+
+def _logfile(flag, default):
+ """
+ return the name of the file to use for logging when the flag 'log' is set
+ if it could not be established or the location is invalid it returns
+ an empty string
+ """
+
+ # For log we return the location of the log file
+ log_location = _contentenv(flag) or _contentfile(flag, default)
+
+ # it was not set
+ if not log_location:
+ return ''
+
+ # Make sure that the logs can only be in /tmp, /var/log, or /tmp
+ if not log_location.startswith('/tmp/') and \
+ not log_location.startswith('/config/') and \
+ not log_location.startswith('/var/log/'):
+ return default
+ # Do not allow to escape the folders
+ if '..' in log_location:
+ return default
+
+ if not os.path.exists(log_location):
+ return log_location
+
+ # this permission is unique the the config and var folder
+ stat = os.stat(log_location).st_mode
+ if stat != 0o100666:
+ return default
+ return log_location
diff --git a/python/vyos/defaults.py b/python/vyos/defaults.py
new file mode 100644
index 0000000..dec619d
--- /dev/null
+++ b/python/vyos/defaults.py
@@ -0,0 +1,63 @@
+# Copyright 2018-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/>.
+
+import os
+
+base_dir = '/usr/libexec/vyos/'
+
+directories = {
+ 'base' : base_dir,
+ 'data' : '/usr/share/vyos/',
+ 'conf_mode' : f'{base_dir}/conf_mode',
+ 'op_mode' : f'{base_dir}/op_mode',
+ 'services' : f'{base_dir}/services',
+ 'config' : '/opt/vyatta/etc/config',
+ 'migrate' : '/opt/vyatta/etc/config-migrate/migrate',
+ 'activate' : f'{base_dir}/activate',
+ 'log' : '/var/log/vyatta',
+ 'templates' : '/usr/share/vyos/templates/',
+ 'certbot' : '/config/auth/letsencrypt',
+ 'api_schema': f'{base_dir}/services/api/graphql/graphql/schema/',
+ 'api_client_op': f'{base_dir}/services/api/graphql/graphql/client_op/',
+ 'api_templates': f'{base_dir}/services/api/graphql/session/templates/',
+ 'vyos_udev_dir' : '/run/udev/vyos',
+ 'isc_dhclient_dir' : '/run/dhclient',
+ 'dhcp6_client_dir' : '/run/dhcp6c',
+ 'vyos_configdir' : '/opt/vyatta/config',
+ 'completion_dir' : f'{base_dir}/completion'
+}
+
+config_status = '/tmp/vyos-config-status'
+api_config_state = '/run/http-api-state'
+
+cfg_group = 'vyattacfg'
+
+cfg_vintage = 'vyos'
+
+commit_lock = os.path.join(directories['vyos_configdir'], '.lock')
+
+component_version_json = os.path.join(directories['data'], 'component-versions.json')
+
+config_default = os.path.join(directories['data'], 'config.boot.default')
+
+rt_symbolic_names = {
+ # Standard routing tables for Linux & reserved IDs for VyOS
+ 'default': 253, # Confusingly, a final fallthru, not the default.
+ 'main': 254, # The actual global table used by iproute2 unless told otherwise.
+ 'local': 255, # Special kernel loopback table.
+}
+
+rt_global_vrf = rt_symbolic_names['main']
+rt_global_table = rt_symbolic_names['main']
diff --git a/python/vyos/ethtool.py b/python/vyos/ethtool.py
new file mode 100644
index 0000000..21272cc
--- /dev/null
+++ b/python/vyos/ethtool.py
@@ -0,0 +1,200 @@
+# Copyright 2021-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/>.
+
+import re
+
+from json import loads
+from vyos.utils.network import interface_exists
+from vyos.utils.process import popen
+
+# These drivers do not support using ethtool to change the speed, duplex, or
+# flow control settings
+_drivers_without_speed_duplex_flow = ['vmxnet3', 'virtio_net', 'xen_netfront',
+ 'iavf', 'ice', 'i40e', 'hv_netvsc', 'veth', 'ixgbevf',
+ 'tun']
+
+class Ethtool:
+ """
+ Class is used to retrive and cache information about an ethernet adapter
+ """
+ # dictionary containing driver featurs, it will be populated on demand and
+ # the content will look like:
+ # [{'esp-hw-offload': {'active': False, 'fixed': True, 'requested': False},
+ # 'esp-tx-csum-hw-offload': {'active': False,
+ # 'fixed': True,
+ # 'requested': False},
+ # 'fcoe-mtu': {'active': False, 'fixed': True, 'requested': False},
+ # 'generic-receive-offload': {'active': True,
+ # 'fixed': False,
+ # 'requested': True},
+ # 'generic-segmentation-offload': {'active': True,
+ # 'fixed': False,
+ # 'requested': True},
+ # 'highdma': {'active': True, 'fixed': False, 'requested': True},
+ # 'ifname': 'eth0',
+ # 'l2-fwd-offload': {'active': False, 'fixed': True, 'requested': False},
+ # 'large-receive-offload': {'active': False,
+ # 'fixed': False,
+ # 'requested': False},
+ # ...
+ _features = { }
+ # dictionary containing available interface speed and duplex settings
+ # {
+ # '10' : {'full': '', 'half': ''},
+ # '100' : {'full': '', 'half': ''},
+ # '1000': {'full': ''}
+ # }
+ _ring_buffer = None
+ _driver_name = None
+ _flow_control = None
+
+ def __init__(self, ifname):
+ # Get driver used for interface
+ if not interface_exists(ifname):
+ raise ValueError(f'Interface "{ifname}" does not exist!')
+
+ out, _ = popen(f'ethtool --driver {ifname}')
+ driver = re.search(r'driver:\s(\w+)', out)
+ if driver:
+ self._driver_name = driver.group(1)
+
+ # Build a dictinary of supported link-speed and dupley settings.
+ # [ {
+ # "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)[0]
+
+ # Get information about NIC ring buffers
+ out, _ = popen(f'ethtool --json --show-ring {ifname}')
+ 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)[0]
+
+ def check_auto_negotiation_supported(self):
+ """ Check if the NIC supports changing auto-negotiation """
+ return self._base_settings['supports-auto-negotiation']
+
+ def get_auto_negotiation(self):
+ return self._base_settings['supports-auto-negotiation'] and self._base_settings['auto-negotiation']
+
+ def get_driver_name(self):
+ return self._driver_name
+
+ def _get_generic(self, feature):
+ """
+ Generic method to read self._features and return a tuple for feature
+ enabled and feature is fixed.
+
+ In case of a missing key, return "fixed = True and enabled = False"
+ """
+ active = False
+ fixed = True
+ 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):
+ return self._get_generic('generic-receive-offload')
+
+ def get_generic_segmentation_offload(self):
+ return self._get_generic('generic-segmentation-offload')
+
+ def get_hw_tc_offload(self):
+ return self._get_generic('hw-tc-offload')
+
+ def get_large_receive_offload(self):
+ return self._get_generic('large-receive-offload')
+
+ def get_scatter_gather(self):
+ return self._get_generic('scatter-gather')
+
+ def get_tcp_segmentation_offload(self):
+ return self._get_generic('tcp-segmentation-offload')
+
+ def get_ring_buffer_max(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.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.get(rx_tx, None))
+
+ def check_speed_duplex(self, speed, duplex):
+ """ Check if the passed speed and duplex combination is supported by
+ the underlaying network adapter. """
+ if isinstance(speed, int):
+ speed = str(speed)
+ if speed != 'auto' and not speed.isdigit():
+ raise ValueError(f'Value "{speed}" for speed is invalid!')
+ 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
+
+ # ['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):
+ """ Check if the NIC supports flow-control """
+ return bool(self._flow_control)
+
+ def get_flow_control(self):
+ if self._flow_control == None:
+ raise ValueError('Interface does not support changing '\
+ 'flow-control settings!')
+
+ return 'on' if bool(self._flow_control['autonegotiate']) else 'off'
diff --git a/python/vyos/firewall.py b/python/vyos/firewall.py
new file mode 100644
index 0000000..64fed81
--- /dev/null
+++ b/python/vyos/firewall.py
@@ -0,0 +1,785 @@
+# Copyright (C) 2021-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 csv
+import gzip
+import os
+import re
+
+from pathlib import Path
+from socket import AF_INET
+from socket import AF_INET6
+from socket import getaddrinfo
+from time import strftime
+
+from vyos.remote import download
+from vyos.template import is_ipv4
+from vyos.template import render
+from vyos.utils.dict import dict_search_args
+from vyos.utils.dict import dict_search_recursive
+from vyos.utils.process import cmd
+from vyos.utils.process import run
+from vyos.utils.network import get_vrf_tableid
+from vyos.defaults import rt_global_table
+from vyos.defaults import rt_global_vrf
+
+# Conntrack
+def conntrack_required(conf):
+ required_nodes = ['nat', 'nat66', 'load-balancing wan']
+
+ for path in required_nodes:
+ if conf.exists(path):
+ return True
+
+ firewall = conf.get_config_dict(['firewall'], key_mangling=('-', '_'),
+ no_tag_node_value_mangle=True, get_first_key=True)
+
+ for rules, path in dict_search_recursive(firewall, 'rule'):
+ if any(('state' in rule_conf or 'connection_status' in rule_conf or 'offload_target' in rule_conf) for rule_conf in rules.values()):
+ return True
+
+ return False
+
+# 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_resolve(fqdn, ipv6=False):
+ try:
+ res = getaddrinfo(fqdn, None, AF_INET6 if ipv6 else AF_INET)
+ return set(item[4][0] for item in res)
+ 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")
+ for line in results:
+ if all(rule_match in line for rule_match in rule_matches):
+ handle_search = re.search('handle (\d+)', line)
+ if handle_search:
+ return handle_search[1]
+ return None
+
+def remove_nftables_rule(table, chain, handle):
+ cmd(f'sudo nft delete rule {table} {chain} handle {handle}')
+
+# Functions below used by template generation
+
+def nft_action(vyos_action):
+ if vyos_action == 'accept':
+ return 'return'
+ return vyos_action
+
+def parse_rule(rule_conf, hook, fw_name, rule_id, ip_name):
+ output = []
+
+ if ip_name == 'ip6':
+ def_suffix = '6'
+ family = 'ipv6'
+ else:
+ def_suffix = ''
+ family = 'bri' if ip_name == 'bri' else 'ipv4'
+
+ if 'state' in rule_conf and rule_conf['state']:
+ states = ",".join([s for s in rule_conf['state']])
+
+ if states:
+ output.append(f'ct state {{{states}}}')
+
+ if 'conntrack_helper' in rule_conf:
+ helper_map = {'h323': ['RAS', 'Q.931'], 'nfs': ['rpc'], 'sqlnet': ['tns']}
+ helper_out = []
+
+ for helper in rule_conf['conntrack_helper']:
+ if helper in helper_map:
+ helper_out.extend(helper_map[helper])
+ else:
+ helper_out.append(helper)
+
+ if helper_out:
+ helper_str = ','.join(f'"{s}"' for s in helper_out)
+ output.append(f'ct helper {{{helper_str}}}')
+
+ if 'connection_status' in rule_conf and rule_conf['connection_status']:
+ status = rule_conf['connection_status']
+ if status['nat'] == 'destination':
+ nat_status = 'dnat'
+ output.append(f'ct status {nat_status}')
+ if status['nat'] == 'source':
+ nat_status = 'snat'
+ output.append(f'ct status {nat_status}')
+
+ if 'protocol' in rule_conf and rule_conf['protocol'] != 'all':
+ proto = rule_conf['protocol']
+ operator = ''
+ if proto[0] == '!':
+ operator = '!='
+ proto = proto[1:]
+ if proto == 'tcp_udp':
+ proto = '{tcp, udp}'
+ output.append(f'meta l4proto {operator} {proto}')
+
+ if 'ethernet_type' in rule_conf:
+ ether_type_mapping = {
+ '802.1q': '8021q',
+ '802.1ad': '8021ad',
+ 'ipv6': 'ip6',
+ 'ipv4': 'ip',
+ 'arp': 'arp'
+ }
+ ether_type = rule_conf['ethernet_type']
+ operator = '!=' if ether_type.startswith('!') else ''
+ ether_type = ether_type.lstrip('!')
+ ether_type = ether_type_mapping.get(ether_type, ether_type)
+ output.append(f'ether type {operator} {ether_type}')
+
+ for side in ['destination', 'source']:
+ if side in rule_conf:
+ prefix = side[0]
+ side_conf = rule_conf[side]
+ address_mask = side_conf.get('address_mask', None)
+
+ if 'address' in side_conf:
+ suffix = side_conf['address']
+ operator = ''
+ exclude = suffix[0] == '!'
+ if exclude:
+ operator = '!= '
+ suffix = suffix[1:]
+ if address_mask:
+ operator = '!=' if exclude else '=='
+ operator = f'& {address_mask} {operator} '
+
+ if suffix.find('-') != -1:
+ # Range
+ start, end = suffix.split('-')
+ if is_ipv4(start):
+ output.append(f'ip {prefix}addr {operator}{suffix}')
+ else:
+ output.append(f'ip6 {prefix}addr {operator}{suffix}')
+ else:
+ if is_ipv4(suffix):
+ output.append(f'ip {prefix}addr {operator}{suffix}')
+ else:
+ output.append(f'ip6 {prefix}addr {operator}{suffix}')
+
+ if 'fqdn' in side_conf:
+ fqdn = side_conf['fqdn']
+ hook_name = ''
+ operator = ''
+ if fqdn[0] == '!':
+ operator = '!='
+ if hook == 'FWD':
+ hook_name = 'forward'
+ if hook == 'INP':
+ hook_name = 'input'
+ if hook == 'OUT':
+ hook_name = 'output'
+ if hook == 'PRE':
+ hook_name = 'prerouting'
+ if hook == 'NAM':
+ hook_name = f'name{def_suffix}'
+ output.append(f'{ip_name} {prefix}addr {operator} @FQDN_{hook_name}_{fw_name}_{rule_id}_{prefix}')
+
+ if dict_search_args(side_conf, 'geoip', 'country_code'):
+ operator = ''
+ hook_name = ''
+ if dict_search_args(side_conf, 'geoip', 'inverse_match') != None:
+ operator = '!='
+ if hook == 'FWD':
+ hook_name = 'forward'
+ if hook == 'INP':
+ hook_name = 'input'
+ if hook == 'OUT':
+ hook_name = 'output'
+ if hook == 'PRE':
+ hook_name = 'prerouting'
+ if hook == 'NAM':
+ hook_name = f'name'
+ output.append(f'{ip_name} {prefix}addr {operator} @GEOIP_CC{def_suffix}_{hook_name}_{fw_name}_{rule_id}')
+
+ if 'mac_address' in side_conf:
+ suffix = side_conf["mac_address"]
+ if suffix[0] == '!':
+ suffix = f'!= {suffix[1:]}'
+ output.append(f'ether {prefix}addr {suffix}')
+
+ if 'port' in side_conf:
+ proto = rule_conf['protocol']
+ port = side_conf['port'].split(',')
+
+ ports = []
+ negated_ports = []
+
+ for p in port:
+ if p[0] == '!':
+ negated_ports.append(p[1:])
+ else:
+ ports.append(p)
+
+ if proto == 'tcp_udp':
+ proto = 'th'
+
+ if ports:
+ ports_str = ','.join(ports)
+ output.append(f'{proto} {prefix}port {{{ports_str}}}')
+
+ if negated_ports:
+ negated_ports_str = ','.join(negated_ports)
+ output.append(f'{proto} {prefix}port != {{{negated_ports_str}}}')
+
+ if 'group' in side_conf:
+ group = side_conf['group']
+ for ipvx_address_group in ['address_group', 'ipv4_address_group', 'ipv6_address_group']:
+ if ipvx_address_group in group:
+ group_name = group[ipvx_address_group]
+ operator = ''
+ exclude = group_name[0] == "!"
+ if exclude:
+ operator = '!='
+ group_name = group_name[1:]
+ if address_mask:
+ operator = '!=' if exclude else '=='
+ operator = f'& {address_mask} {operator}'
+ # for bridge, change ip_name
+ if ip_name == 'bri':
+ ip_name = 'ip' if ipvx_address_group == 'ipv4_address_group' else 'ip6'
+ def_suffix = '6' if ipvx_address_group == 'ipv6_address_group' else ''
+ output.append(f'{ip_name} {prefix}addr {operator} @A{def_suffix}_{group_name}')
+ for ipvx_network_group in ['network_group', 'ipv4_network_group', 'ipv6_network_group']:
+ if ipvx_network_group in group:
+ group_name = group[ipvx_network_group]
+ operator = ''
+ if group_name[0] == "!":
+ operator = '!='
+ group_name = group_name[1:]
+ # for bridge, change ip_name
+ if ip_name == 'bri':
+ ip_name = 'ip' if ipvx_network_group == 'ipv4_network_group' else 'ip6'
+ def_suffix = '6' if ipvx_network_group == 'ipv6_network_group' else ''
+ output.append(f'{ip_name} {prefix}addr {operator} @N{def_suffix}_{group_name}')
+ if 'dynamic_address_group' in group:
+ group_name = group['dynamic_address_group']
+ operator = ''
+ if group_name[0] == "!":
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'{ip_name} {prefix}addr {operator} @DA{def_suffix}_{group_name}')
+ # Generate firewall group domain-group
+ elif 'domain_group' in group:
+ group_name = group['domain_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'{ip_name} {prefix}addr {operator} @D_{group_name}')
+ if 'mac_group' in group:
+ group_name = group['mac_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'ether {prefix}addr {operator} @M_{group_name}')
+ if 'port_group' in group:
+ proto = rule_conf['protocol']
+ group_name = group['port_group']
+
+ if proto == 'tcp_udp':
+ proto = 'th'
+
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+
+ output.append(f'{proto} {prefix}port {operator} @P_{group_name}')
+
+ if dict_search_args(rule_conf, 'action') == 'synproxy':
+ output.append('ct state invalid,untracked')
+
+ if 'hop_limit' in rule_conf:
+ operators = {'eq': '==', 'gt': '>', 'lt': '<'}
+ for op, operator in operators.items():
+ if op in rule_conf['hop_limit']:
+ value = rule_conf['hop_limit'][op]
+ output.append(f'ip6 hoplimit {operator} {value}')
+
+ if 'inbound_interface' in rule_conf:
+ operator = ''
+ if 'name' in rule_conf['inbound_interface']:
+ iiface = rule_conf['inbound_interface']['name']
+ if iiface[0] == '!':
+ operator = '!='
+ iiface = iiface[1:]
+ output.append(f'iifname {operator} {{{iiface}}}')
+ elif 'group' in rule_conf['inbound_interface']:
+ iiface = rule_conf['inbound_interface']['group']
+ if iiface[0] == '!':
+ operator = '!='
+ iiface = iiface[1:]
+ output.append(f'iifname {operator} @I_{iiface}')
+
+ if 'outbound_interface' in rule_conf:
+ operator = ''
+ if 'name' in rule_conf['outbound_interface']:
+ oiface = rule_conf['outbound_interface']['name']
+ if oiface[0] == '!':
+ operator = '!='
+ oiface = oiface[1:]
+ output.append(f'oifname {operator} {{{oiface}}}')
+ elif 'group' in rule_conf['outbound_interface']:
+ oiface = rule_conf['outbound_interface']['group']
+ if oiface[0] == '!':
+ operator = '!='
+ oiface = oiface[1:]
+ output.append(f'oifname {operator} @I_{oiface}')
+
+ if 'ttl' in rule_conf:
+ operators = {'eq': '==', 'gt': '>', 'lt': '<'}
+ for op, operator in operators.items():
+ if op in rule_conf['ttl']:
+ value = rule_conf['ttl'][op]
+ output.append(f'ip ttl {operator} {value}')
+
+ for icmp in ['icmp', 'icmpv6']:
+ if icmp in rule_conf:
+ if 'type_name' in rule_conf[icmp]:
+ output.append(icmp + ' type ' + rule_conf[icmp]['type_name'])
+ else:
+ if 'code' in rule_conf[icmp]:
+ output.append(icmp + ' code ' + rule_conf[icmp]['code'])
+ if 'type' in rule_conf[icmp]:
+ output.append(icmp + ' type ' + rule_conf[icmp]['type'])
+
+
+ if 'packet_length' in rule_conf:
+ lengths_str = ','.join(rule_conf['packet_length'])
+ output.append(f'ip{def_suffix} length {{{lengths_str}}}')
+
+ if 'packet_length_exclude' in rule_conf:
+ negated_lengths_str = ','.join(rule_conf['packet_length_exclude'])
+ output.append(f'ip{def_suffix} length != {{{negated_lengths_str}}}')
+
+ if 'packet_type' in rule_conf:
+ output.append(f'pkttype ' + rule_conf['packet_type'])
+
+ if 'dscp' in rule_conf:
+ dscp_str = ','.join(rule_conf['dscp'])
+ output.append(f'ip{def_suffix} dscp {{{dscp_str}}}')
+
+ if 'dscp_exclude' in rule_conf:
+ negated_dscp_str = ','.join(rule_conf['dscp_exclude'])
+ output.append(f'ip{def_suffix} dscp != {{{negated_dscp_str}}}')
+
+ if 'ipsec' in rule_conf:
+ if 'match_ipsec_in' in rule_conf['ipsec']:
+ output.append('meta ipsec == 1')
+ if 'match_none_in' in rule_conf['ipsec']:
+ output.append('meta ipsec == 0')
+ if 'match_ipsec_out' in rule_conf['ipsec']:
+ output.append('rt ipsec exists')
+ if 'match_none_out' in rule_conf['ipsec']:
+ output.append('rt ipsec missing')
+
+ if 'fragment' in rule_conf:
+ # Checking for fragmentation after priority -400 is not possible,
+ # so we use a priority -450 hook to set a mark
+ if 'match_frag' in rule_conf['fragment']:
+ output.append('meta mark 0xffff1')
+ if 'match_non_frag' in rule_conf['fragment']:
+ output.append('meta mark != 0xffff1')
+
+ if 'limit' in rule_conf:
+ if 'rate' in rule_conf['limit']:
+ output.append(f'limit rate {rule_conf["limit"]["rate"]}')
+ if 'burst' in rule_conf['limit']:
+ output.append(f'burst {rule_conf["limit"]["burst"]} packets')
+
+ if 'recent' in rule_conf:
+ count = rule_conf['recent']['count']
+ time = rule_conf['recent']['time']
+ output.append(f'add @RECENT{def_suffix}_{hook}_{fw_name}_{rule_id} {{ {ip_name} saddr limit rate over {count}/{time} burst {count} packets }}')
+
+ if 'gre' in rule_conf:
+ gre_key = dict_search_args(rule_conf, 'gre', 'key')
+
+ gre_flags = dict_search_args(rule_conf, 'gre', 'flags')
+ output.append(parse_gre_flags(gre_flags or {}, force_keyed=gre_key is not None))
+
+ gre_proto_alias_map = {
+ '802.1q': '8021q',
+ '802.1ad': '8021ad',
+ 'gretap': '0x6558',
+ }
+
+ gre_proto = dict_search_args(rule_conf, 'gre', 'inner_proto')
+ if gre_proto is not None:
+ gre_proto = gre_proto_alias_map.get(gre_proto, gre_proto)
+ output.append(f'gre protocol {gre_proto}')
+
+ gre_ver = dict_search_args(rule_conf, 'gre', 'version')
+ if gre_ver == 'gre':
+ output.append('gre version 0')
+ elif gre_ver == 'pptp':
+ 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
+ # 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']:
+ # No "unset" child node means C is set, we offset key lookup +32 bits
+ output.append(f'@th,64,32 == {gre_key}')
+ else:
+ output.append(f'@th,32,32 == {gre_key}')
+
+ if 'time' in rule_conf:
+ output.append(parse_time(rule_conf['time']))
+
+ tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags')
+ if tcp_flags:
+ output.append(parse_tcp_flags(tcp_flags))
+
+ # TCP MSS
+ tcp_mss = dict_search_args(rule_conf, 'tcp', 'mss')
+ if tcp_mss:
+ output.append(f'tcp option maxseg size {tcp_mss}')
+
+ if 'connection_mark' in rule_conf:
+ conn_mark_str = ','.join(rule_conf['connection_mark'])
+ output.append(f'ct mark {{{conn_mark_str}}}')
+
+ if 'mark' in rule_conf:
+ mark = rule_conf['mark']
+ operator = ''
+ if mark[0] == '!':
+ operator = '!='
+ mark = mark[1:]
+ output.append(f'meta mark {operator} {{{mark}}}')
+
+ if 'vlan' in rule_conf:
+ if 'id' in rule_conf['vlan']:
+ output.append(f'vlan id {rule_conf["vlan"]["id"]}')
+ if 'priority' in rule_conf['vlan']:
+ output.append(f'vlan pcp {rule_conf["vlan"]["priority"]}')
+ if 'ethernet_type' in rule_conf['vlan']:
+ ether_type_mapping = {
+ '802.1q': '8021q',
+ '802.1ad': '8021ad',
+ 'ipv6': 'ip6',
+ 'ipv4': 'ip',
+ 'arp': 'arp'
+ }
+ ether_type = rule_conf['vlan']['ethernet_type']
+ operator = '!=' if ether_type.startswith('!') else ''
+ ether_type = ether_type.lstrip('!')
+ ether_type = ether_type_mapping.get(ether_type, ether_type)
+ output.append(f'vlan type {operator} {ether_type}')
+
+ if 'log' in rule_conf:
+ action = rule_conf['action'] if 'action' in rule_conf else 'accept'
+ #output.append(f'log prefix "[{fw_name[:19]}-{rule_id}-{action[:1].upper()}]"')
+ output.append(f'log prefix "[{family}-{hook}-{fw_name}-{rule_id}-{action[:1].upper()}]"')
+ ##{family}-{hook}-{fw_name}-{rule_id}
+ if 'log_options' in rule_conf:
+
+ if 'level' in rule_conf['log_options']:
+ log_level = rule_conf['log_options']['level']
+ output.append(f'log level {log_level}')
+
+ if 'group' in rule_conf['log_options']:
+ log_group = rule_conf['log_options']['group']
+ output.append(f'log group {log_group}')
+
+ if 'queue_threshold' in rule_conf['log_options']:
+ queue_threshold = rule_conf['log_options']['queue_threshold']
+ output.append(f'queue-threshold {queue_threshold}')
+
+ if 'snapshot_length' in rule_conf['log_options']:
+ log_snaplen = rule_conf['log_options']['snapshot_length']
+ output.append(f'snaplen {log_snaplen}')
+
+ output.append('counter')
+
+ if 'add_address_to_group' in rule_conf:
+ for side in ['destination_address', 'source_address']:
+ if side in rule_conf['add_address_to_group']:
+ prefix = side[0]
+ side_conf = rule_conf['add_address_to_group'][side]
+ dyn_group = side_conf['address_group']
+ if 'timeout' in side_conf:
+ timeout_value = side_conf['timeout']
+ output.append(f'set update ip{def_suffix} {prefix}addr timeout {timeout_value} @DA{def_suffix}_{dyn_group}')
+ else:
+ output.append(f'set update ip{def_suffix} saddr @DA{def_suffix}_{dyn_group}')
+
+ set_table = False
+ if 'set' in rule_conf:
+ # Parse set command used in policy route:
+ if 'connection_mark' in rule_conf['set']:
+ conn_mark = rule_conf['set']['connection_mark']
+ output.append(f'ct mark set {conn_mark}')
+ if 'dscp' in rule_conf['set']:
+ dscp = rule_conf['set']['dscp']
+ output.append(f'ip{def_suffix} dscp set {dscp}')
+ if 'mark' in rule_conf['set']:
+ mark = rule_conf['set']['mark']
+ output.append(f'meta mark set {mark}')
+ if 'vrf' in rule_conf['set']:
+ set_table = True
+ vrf_name = rule_conf['set']['vrf']
+ if vrf_name == 'default':
+ table = rt_global_vrf
+ else:
+ # NOTE: VRF->table ID lookup depends on the VRF iface already existing.
+ table = get_vrf_tableid(vrf_name)
+ if 'table' in rule_conf['set']:
+ set_table = True
+ table = rule_conf['set']['table']
+ if table == 'main':
+ table = rt_global_table
+ if set_table:
+ mark = 0x7FFFFFFF - int(table)
+ output.append(f'meta mark set {mark}')
+ if 'tcp_mss' in rule_conf['set']:
+ mss = rule_conf['set']['tcp_mss']
+ output.append(f'tcp option maxseg size set {mss}')
+
+ if 'action' in rule_conf:
+ if rule_conf['action'] == 'offload':
+ offload_target = rule_conf['offload_target']
+ output.append(f'flow add @VYOS_FLOWTABLE_{offload_target}')
+ else:
+ output.append(f'{rule_conf["action"]}')
+
+ if 'jump' in rule_conf['action']:
+ target = rule_conf['jump_target']
+ output.append(f'NAME{def_suffix}_{target}')
+
+ if 'queue' in rule_conf['action']:
+ if 'queue' in rule_conf:
+ target = rule_conf['queue']
+ output.append(f'num {target}')
+
+ if 'queue_options' in rule_conf:
+ queue_opts = ','.join(rule_conf['queue_options'])
+ output.append(f'{queue_opts}')
+
+ # Synproxy
+ if 'synproxy' in rule_conf:
+ synproxy_mss = dict_search_args(rule_conf, 'synproxy', 'tcp', 'mss')
+ if synproxy_mss:
+ output.append(f'mss {synproxy_mss}')
+ synproxy_ws = dict_search_args(rule_conf, 'synproxy', 'tcp', 'window_scale')
+ if synproxy_ws:
+ output.append(f'wscale {synproxy_ws} timestamp sack-perm')
+
+ else:
+ if set_table:
+ output.append('return')
+
+ output.append(f'comment "{family}-{hook}-{fw_name}-{rule_id}"')
+ return " ".join(output)
+
+def parse_gre_flags(flags, force_keyed=False):
+ flag_map = { # nft does not have symbolic names for these.
+ 'checksum': 1<<0,
+ 'routing': 1<<1,
+ 'key': 1<<2,
+ 'sequence': 1<<3,
+ 'strict_routing': 1<<4,
+ }
+
+ include = 0
+ exclude = 0
+ for fl_name, fl_state in flags.items():
+ if not fl_state:
+ include |= flag_map[fl_name]
+ else: # 'unset' child tag
+ exclude |= flag_map[fl_name]
+
+ if force_keyed:
+ # Implied by a key-match.
+ include |= flag_map['key']
+
+ if include == 0 and exclude == 0:
+ return '' # Don't bother extracting and matching no bits
+
+ return f'gre flags & {include + exclude} == {include}'
+
+def parse_tcp_flags(flags):
+ include = [flag for flag in flags if flag != 'not']
+ exclude = list(flags['not']) if 'not' in flags else []
+ return f'tcp flags & ({"|".join(include + exclude)}) == {"|".join(include) if include else "0x0"}'
+
+def parse_time(time):
+ out = []
+ if 'startdate' in time:
+ start = time['startdate']
+ if 'T' not in start and 'starttime' in time:
+ start += f' {time["starttime"]}'
+ out.append(f'time >= "{start}"')
+ if 'starttime' in time and 'startdate' not in time:
+ out.append(f'hour >= "{time["starttime"]}"')
+ if 'stopdate' in time:
+ stop = time['stopdate']
+ if 'T' not in stop and 'stoptime' in time:
+ stop += f' {time["stoptime"]}'
+ out.append(f'time < "{stop}"')
+ if 'stoptime' in time and 'stopdate' not in time:
+ out.append(f'hour < "{time["stoptime"]}"')
+ if 'weekdays' in time:
+ days = time['weekdays'].split(",")
+ out_days = [f'"{day}"' for day in days if day[0] != '!']
+ out.append(f'day {{{",".join(out_days)}}}')
+ return " ".join(out)
+
+# GeoIP
+
+nftables_geoip_conf = '/run/nftables-geoip.conf'
+geoip_database = '/usr/share/vyos-geoip/dbip-country-lite.csv.gz'
+geoip_lock_file = '/run/vyos-geoip.lock'
+
+def geoip_load_data(codes=[]):
+ data = None
+
+ if not os.path.exists(geoip_database):
+ return []
+
+ try:
+ with gzip.open(geoip_database, mode='rt') as csv_fh:
+ reader = csv.reader(csv_fh)
+ out = []
+ for start, end, code in reader:
+ if code.lower() in codes:
+ out.append([start, end, code.lower()])
+ return out
+ except:
+ print('Error: Failed to open GeoIP database')
+ return []
+
+def geoip_download_data():
+ url = 'https://download.db-ip.com/free/dbip-country-lite-{}.csv.gz'.format(strftime("%Y-%m"))
+ try:
+ dirname = os.path.dirname(geoip_database)
+ if not os.path.exists(dirname):
+ os.mkdir(dirname)
+
+ download(geoip_database, url)
+ print("Downloaded GeoIP database")
+ return True
+ except:
+ print("Error: Failed to download GeoIP database")
+ return False
+
+class GeoIPLock(object):
+ def __init__(self, file):
+ self.file = file
+
+ def __enter__(self):
+ if os.path.exists(self.file):
+ return False
+
+ Path(self.file).touch()
+ return True
+
+ def __exit__(self, exc_type, exc_value, tb):
+ os.unlink(self.file)
+
+def geoip_update(firewall, 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")
+ return True
+
+ if not os.path.exists(geoip_database):
+ if not geoip_download_data():
+ return False
+ elif force:
+ geoip_download_data()
+
+ ipv4_codes = {}
+ ipv6_codes = {}
+
+ ipv4_sets = {}
+ ipv6_sets = {}
+
+ # 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 force:
+ print("GeoIP not in use by firewall")
+ return True
+
+ geoip_data = geoip_load_data([*ipv4_codes, *ipv6_codes])
+
+ # Iterate IP blocks to assign to sets
+ for start, end, code in geoip_data:
+ ipv4 = is_ipv4(start)
+ if code in ipv4_codes and ipv4:
+ 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 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)
+
+ render(nftables_geoip_conf, 'firewall/nftables-geoip-update.j2', {
+ 'ipv4_sets': ipv4_sets,
+ 'ipv6_sets': ipv6_sets
+ })
+
+ result = run(f'nft --file {nftables_geoip_conf}')
+ if result != 0:
+ print('Error: GeoIP failed to update firewall')
+ return False
+
+ return True
diff --git a/python/vyos/frr.py b/python/vyos/frr.py
new file mode 100644
index 0000000..6fb8180
--- /dev/null
+++ b/python/vyos/frr.py
@@ -0,0 +1,551 @@
+# 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/hostsd_client.py b/python/vyos/hostsd_client.py
new file mode 100644
index 0000000..f31ef51
--- /dev/null
+++ b/python/vyos/hostsd_client.py
@@ -0,0 +1,131 @@
+import json
+import zmq
+
+SOCKET_PATH = "ipc:///run/vyos-hostsd/vyos-hostsd.sock"
+
+class VyOSHostsdError(Exception):
+ pass
+
+class Client(object):
+ def __init__(self):
+ try:
+ context = zmq.Context()
+ self.__socket = context.socket(zmq.REQ)
+ self.__socket.RCVTIMEO = 10000 #ms
+ self.__socket.setsockopt(zmq.LINGER, 0)
+ self.__socket.connect(SOCKET_PATH)
+ except zmq.error.Again:
+ raise VyOSHostsdError("Could not connect to vyos-hostsd")
+
+ def _communicate(self, msg):
+ try:
+ request = json.dumps(msg).encode()
+ self.__socket.send(request)
+
+ reply_msg = self.__socket.recv().decode()
+ reply = json.loads(reply_msg)
+ if 'error' in reply:
+ raise VyOSHostsdError(reply['error'])
+ else:
+ return reply["data"]
+ except zmq.error.Again:
+ raise VyOSHostsdError("Could not connect to vyos-hostsd")
+
+ def add_name_servers(self, data):
+ msg = {'type': 'name_servers', 'op': 'add', 'data': data}
+ self._communicate(msg)
+
+ def delete_name_servers(self, data):
+ msg = {'type': 'name_servers', 'op': 'delete', 'data': data}
+ self._communicate(msg)
+
+ def get_name_servers(self, tag_regex):
+ msg = {'type': 'name_servers', 'op': 'get', 'tag_regex': tag_regex}
+ return self._communicate(msg)
+
+ def add_name_server_tags_recursor(self, data):
+ msg = {'type': 'name_server_tags_recursor', 'op': 'add', 'data': data}
+ self._communicate(msg)
+
+ def delete_name_server_tags_recursor(self, data):
+ msg = {'type': 'name_server_tags_recursor', 'op': 'delete', 'data': data}
+ self._communicate(msg)
+
+ def get_name_server_tags_recursor(self):
+ msg = {'type': 'name_server_tags_recursor', 'op': 'get'}
+ return self._communicate(msg)
+
+ def add_name_server_tags_system(self, data):
+ msg = {'type': 'name_server_tags_system', 'op': 'add', 'data': data}
+ self._communicate(msg)
+
+ def delete_name_server_tags_system(self, data):
+ msg = {'type': 'name_server_tags_system', 'op': 'delete', 'data': data}
+ self._communicate(msg)
+
+ def get_name_server_tags_system(self):
+ msg = {'type': 'name_server_tags_system', 'op': 'get'}
+ return self._communicate(msg)
+
+ def add_forward_zones(self, data):
+ msg = {'type': 'forward_zones', 'op': 'add', 'data': data}
+ self._communicate(msg)
+
+ def delete_forward_zones(self, data):
+ msg = {'type': 'forward_zones', 'op': 'delete', 'data': data}
+ self._communicate(msg)
+
+ def get_forward_zones(self):
+ msg = {'type': 'forward_zones', 'op': 'get'}
+ return self._communicate(msg)
+
+ def add_authoritative_zones(self, data):
+ msg = {'type': 'authoritative_zones', 'op': 'add', 'data': data}
+ self._communicate(msg)
+
+ def delete_authoritative_zones(self, data):
+ msg = {'type': 'authoritative_zones', 'op': 'delete', 'data': data}
+ self._communicate(msg)
+
+ def get_authoritative_zones(self):
+ msg = {'type': 'authoritative_zones', 'op': 'get'}
+ return self._communicate(msg)
+
+ def add_search_domains(self, data):
+ msg = {'type': 'search_domains', 'op': 'add', 'data': data}
+ self._communicate(msg)
+
+ def delete_search_domains(self, data):
+ msg = {'type': 'search_domains', 'op': 'delete', 'data': data}
+ self._communicate(msg)
+
+ def get_search_domains(self, tag_regex):
+ msg = {'type': 'search_domains', 'op': 'get', 'tag_regex': tag_regex}
+ return self._communicate(msg)
+
+ def add_hosts(self, data):
+ msg = {'type': 'hosts', 'op': 'add', 'data': data}
+ self._communicate(msg)
+
+ def delete_hosts(self, data):
+ msg = {'type': 'hosts', 'op': 'delete', 'data': data}
+ self._communicate(msg)
+
+ def get_hosts(self, tag_regex):
+ msg = {'type': 'hosts', 'op': 'get', 'tag_regex': tag_regex}
+ return self._communicate(msg)
+
+ def set_host_name(self, host_name, domain_name):
+ msg = {
+ 'type': 'host_name',
+ 'op': 'set',
+ 'data': {
+ 'host_name': host_name,
+ 'domain_name': domain_name,
+ }
+ }
+ self._communicate(msg)
+
+ def apply(self):
+ msg = {'op': 'apply'}
+ return self._communicate(msg)
diff --git a/python/vyos/ifconfig/__init__.py b/python/vyos/ifconfig/__init__.py
new file mode 100644
index 0000000..206b2bb
--- /dev/null
+++ b/python/vyos/ifconfig/__init__.py
@@ -0,0 +1,41 @@
+# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.section import Section
+from vyos.ifconfig.control import Control
+from vyos.ifconfig.interface import Interface
+from vyos.ifconfig.operational import Operational
+from vyos.ifconfig.vrrp import VRRP
+
+from vyos.ifconfig.bond import BondIf
+from vyos.ifconfig.bridge import BridgeIf
+from vyos.ifconfig.dummy import DummyIf
+from vyos.ifconfig.ethernet import EthernetIf
+from vyos.ifconfig.geneve import GeneveIf
+from vyos.ifconfig.loopback import LoopbackIf
+from vyos.ifconfig.macvlan import MACVLANIf
+from vyos.ifconfig.input import InputIf
+from vyos.ifconfig.vxlan import VXLANIf
+from vyos.ifconfig.wireguard import WireGuardIf
+from vyos.ifconfig.vtun import VTunIf
+from vyos.ifconfig.vti import VTIIf
+from vyos.ifconfig.pppoe import PPPoEIf
+from vyos.ifconfig.tunnel import TunnelIf
+from vyos.ifconfig.wireless import WiFiIf
+from vyos.ifconfig.l2tpv3 import L2TPv3If
+from vyos.ifconfig.macsec import MACsecIf
+from vyos.ifconfig.veth import VethIf
+from vyos.ifconfig.wwan import WWANIf
+from vyos.ifconfig.sstpc import SSTPCIf
diff --git a/python/vyos/ifconfig/afi.py b/python/vyos/ifconfig/afi.py
new file mode 100644
index 0000000..fd263d2
--- /dev/null
+++ b/python/vyos/ifconfig/afi.py
@@ -0,0 +1,19 @@
+# Copyright 2019 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/>.
+
+# https://www.iana.org/assignments/address-family-numbers/address-family-numbers.xhtml
+
+IP4 = 1
+IP6 = 2
diff --git a/python/vyos/ifconfig/bond.py b/python/vyos/ifconfig/bond.py
new file mode 100644
index 0000000..8ba4817
--- /dev/null
+++ b/python/vyos/ifconfig/bond.py
@@ -0,0 +1,509 @@
+# 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
+# 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 os
+
+from vyos.ifconfig.interface import Interface
+from vyos.utils.dict import dict_search
+from vyos.utils.assertion import assert_list
+from vyos.utils.assertion import assert_mac
+from vyos.utils.assertion import assert_positive
+
+@Interface.register
+class BondIf(Interface):
+ """
+ The Linux bonding driver provides a method for aggregating multiple network
+ interfaces into a single logical "bonded" interface. The behavior of the
+ bonded interfaces depends upon the mode; generally speaking, modes provide
+ either hot standby or load balancing services. Additionally, link integrity
+ monitoring may be performed.
+ """
+
+ iftype = 'bond'
+ definition = {
+ **Interface.definition,
+ ** {
+ 'section': 'bonding',
+ 'prefixes': ['bond', ],
+ 'broadcast': True,
+ 'bridgeable': True,
+ },
+ }
+
+ _sysfs_set = {**Interface._sysfs_set, **{
+ 'bond_hash_policy': {
+ 'validate': lambda v: assert_list(v, ['layer2', 'layer2+3', 'layer3+4', 'encap2+3', 'encap3+4']),
+ 'location': '/sys/class/net/{ifname}/bonding/xmit_hash_policy',
+ },
+ 'bond_min_links': {
+ 'validate': assert_positive,
+ 'location': '/sys/class/net/{ifname}/bonding/min_links',
+ },
+ 'bond_lacp_rate': {
+ 'validate': lambda v: assert_list(v, ['slow', 'fast']),
+ 'location': '/sys/class/net/{ifname}/bonding/lacp_rate',
+ },
+ 'bond_system_mac': {
+ 'validate': lambda v: assert_mac(v, test_all_zero=False),
+ 'location': '/sys/class/net/{ifname}/bonding/ad_actor_system',
+ },
+ 'bond_miimon': {
+ 'validate': assert_positive,
+ 'location': '/sys/class/net/{ifname}/bonding/miimon'
+ },
+ 'bond_arp_interval': {
+ 'validate': assert_positive,
+ 'location': '/sys/class/net/{ifname}/bonding/arp_interval'
+ },
+ 'bond_arp_ip_target': {
+ # XXX: no validation of the IP
+ 'location': '/sys/class/net/{ifname}/bonding/arp_ip_target',
+ },
+ 'bond_add_port': {
+ 'location': '/sys/class/net/{ifname}/bonding/slaves',
+ },
+ 'bond_del_port': {
+ 'location': '/sys/class/net/{ifname}/bonding/slaves',
+ },
+ 'bond_primary': {
+ 'convert': lambda name: name if name else '\0',
+ 'location': '/sys/class/net/{ifname}/bonding/primary',
+ },
+ 'bond_mode': {
+ 'validate': lambda v: assert_list(v, ['balance-rr', 'active-backup', 'balance-xor', 'broadcast', '802.3ad', 'balance-tlb', 'balance-alb']),
+ 'location': '/sys/class/net/{ifname}/bonding/mode',
+ },
+ }}
+
+ _sysfs_get = {**Interface._sysfs_get, **{
+ 'bond_arp_ip_target': {
+ 'location': '/sys/class/net/{ifname}/bonding/arp_ip_target',
+ },
+ 'bond_mode': {
+ 'location': '/sys/class/net/{ifname}/bonding/mode',
+ }
+ }}
+
+ @staticmethod
+ def get_inherit_bond_options() -> list:
+ """
+ Returns list of option
+ which are inherited from bond interface to member interfaces
+ :return: List of interface options
+ :rtype: list
+ """
+ options = [
+ 'mtu'
+ ]
+ return options
+
+ def remove(self):
+ """
+ Remove interface from operating system. Removing the interface
+ deconfigures all assigned IP addresses and clear possible DHCP(v6)
+ client processes.
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> i = Interface('eth0')
+ >>> i.remove()
+ """
+ # when a bond member gets deleted, all members are placed in A/D state
+ # even when they are enabled inside CLI. This will make the config
+ # and system look async.
+ slave_list = []
+ for s in self.get_slaves():
+ slave = {
+ 'ifname': s,
+ 'state': Interface(s).get_admin_state()
+ }
+ slave_list.append(slave)
+
+ # remove bond master which places members in disabled state
+ super().remove()
+
+ # replicate previous interface state before bond destruction back to
+ # physical interface
+ for slave in slave_list:
+ i = Interface(slave['ifname'])
+ i.set_admin_state(slave['state'])
+
+ def set_hash_policy(self, mode):
+ """
+ Selects the transmit hash policy to use for slave selection in
+ balance-xor, 802.3ad, and tlb modes. Possible values are: layer2,
+ layer2+3, layer3+4, encap2+3, encap3+4.
+
+ The default value is layer2
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_hash_policy('layer2+3')
+ """
+ self.set_interface('bond_hash_policy', mode)
+
+ def set_min_links(self, number):
+ """
+ Specifies the minimum number of links that must be active before
+ asserting carrier. It is similar to the Cisco EtherChannel min-links
+ feature. This allows setting the minimum number of member ports that
+ must be up (link-up state) before marking the bond device as up
+ (carrier on). This is useful for situations where higher level services
+ such as clustering want to ensure a minimum number of low bandwidth
+ links are active before switchover. This option only affect 802.3ad
+ mode.
+
+ The default value is 0. This will cause carrier to be asserted (for
+ 802.3ad mode) whenever there is an active aggregator, regardless of the
+ number of available links in that aggregator. Note that, because an
+ aggregator cannot be active without at least one available link,
+ setting this option to 0 or to 1 has the exact same effect.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_min_links('0')
+ """
+ self.set_interface('bond_min_links', number)
+
+ def set_lacp_rate(self, slow_fast):
+ """
+ Option specifying the rate in which we'll ask our link partner
+ to transmit LACPDU packets in 802.3ad mode. Possible values
+ are:
+
+ slow or 0
+ Request partner to transmit LACPDUs every 30 seconds
+
+ fast or 1
+ Request partner to transmit LACPDUs every 1 second
+
+ The default is slow.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_lacp_rate('slow')
+ """
+ self.set_interface('bond_lacp_rate', slow_fast)
+
+ def set_miimon_interval(self, interval):
+ """
+ Specifies the MII link monitoring frequency in milliseconds. This
+ determines how often the link state of each slave is inspected for link
+ failures. A value of zero disables MII link monitoring. A value of 100
+ is a good starting point.
+
+ The default value is 0.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_miimon_interval('100')
+ """
+ return self.set_interface('bond_miimon', interval)
+
+ def set_arp_interval(self, interval):
+ """
+ Specifies the ARP link monitoring frequency in milliseconds.
+
+ The ARP monitor works by periodically checking the slave devices
+ to determine whether they have sent or received traffic recently
+ (the precise criteria depends upon the bonding mode, and the
+ state of the slave). Regular traffic is generated via ARP probes
+ issued for the addresses specified by the arp_ip_target option.
+
+ If ARP monitoring is used in an etherchannel compatible mode
+ (modes 0 and 2), the switch should be configured in a mode that
+ evenly distributes packets across all links. If the switch is
+ configured to distribute the packets in an XOR fashion, all
+ replies from the ARP targets will be received on the same link
+ which could cause the other team members to fail.
+
+ value of 0 disables ARP monitoring. The default value is 0.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_arp_interval('100')
+ """
+ return self.set_interface('bond_arp_interval', interval)
+
+ def get_arp_ip_target(self):
+ """
+ Specifies the IP addresses to use as ARP monitoring peers when
+ arp_interval is > 0. These are the targets of the ARP request sent to
+ determine the health of the link to the targets. Specify these values
+ in ddd.ddd.ddd.ddd format. Multiple IP addresses must be separated by
+ a comma. At least one IP address must be given for ARP monitoring to
+ function. The maximum number of targets that can be specified is 16.
+
+ The default value is no IP addresses.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').get_arp_ip_target()
+ '192.0.2.1'
+ """
+ # As this function might also be called from update() of a VLAN interface
+ # we must check if the bond_arp_ip_target retrieval worked or not - as this
+ # can not be set for a bond vif interface
+ try:
+ return self.get_interface('bond_arp_ip_target')
+ except FileNotFoundError:
+ return ''
+
+ def set_arp_ip_target(self, target):
+ """
+ Specifies the IP addresses to use as ARP monitoring peers when
+ arp_interval is > 0. These are the targets of the ARP request sent to
+ determine the health of the link to the targets. Specify these values
+ in ddd.ddd.ddd.ddd format. Multiple IP addresses must be separated by
+ a comma. At least one IP address must be given for ARP monitoring to
+ function. The maximum number of targets that can be specified is 16.
+
+ The default value is no IP addresses.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_arp_ip_target('192.0.2.1')
+ >>> BondIf('bond0').get_arp_ip_target()
+ '192.0.2.1'
+ """
+ return self.set_interface('bond_arp_ip_target', target)
+
+ def add_port(self, interface):
+ """
+ Enslave physical interface to bond.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').add_port('eth0')
+ >>> BondIf('bond0').add_port('eth1')
+ """
+
+ # From drivers/net/bonding/bond_main.c:
+ # ...
+ # bond_set_slave_link_state(new_slave,
+ # BOND_LINK_UP,
+ # BOND_SLAVE_NOTIFY_NOW);
+ # ...
+ #
+ # The kernel will ALWAYS place new bond members in "up" state regardless
+ # what the CLI will tell us!
+
+ # Physical interface must be in admin down state before they can be
+ # enslaved. If this is not the case an error will be shown:
+ # bond0: eth0 is up - this may be due to an out of date ifenslave
+ slave = Interface(interface)
+ slave_state = slave.get_admin_state()
+ if slave_state == 'up':
+ slave.set_admin_state('down')
+
+ ret = self.set_interface('bond_add_port', f'+{interface}')
+ # The kernel will ALWAYS place new bond members in "up" state regardless
+ # what the LI is configured for - thus we place the interface in its
+ # desired state
+ slave.set_admin_state(slave_state)
+ return ret
+
+ def del_port(self, interface):
+ """
+ Remove physical port from bond
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').del_port('eth1')
+ """
+ return self.set_interface('bond_del_port', f'-{interface}')
+
+ def get_slaves(self):
+ """
+ Return a list with all configured slave interfaces on this bond.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').get_slaves()
+ ['eth1', 'eth2']
+ """
+ enslaved_ifs = []
+ # retrieve real enslaved interfaces from OS kernel
+ sysfs_bond = '/sys/class/net/{}'.format(self.config['ifname'])
+ if os.path.isdir(sysfs_bond):
+ for directory in os.listdir(sysfs_bond):
+ if 'lower_' in directory:
+ enslaved_ifs.append(directory.replace('lower_', ''))
+
+ return enslaved_ifs
+
+ def get_mode(self):
+ """
+ Return bond operation mode.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').get_mode()
+ '802.3ad'
+ """
+ mode = self.get_interface('bond_mode')
+ # mode is now "802.3ad 4", we are only interested in "802.3ad"
+ return mode.split()[0]
+
+ def set_primary(self, interface):
+ """
+ A string (eth0, eth2, etc) specifying which slave is the primary
+ device. The specified device will always be the active slave while it
+ is available. Only when the primary is off-line will alternate devices
+ be used. This is useful when one slave is preferred over another, e.g.,
+ when one slave has higher throughput than another.
+
+ The primary option is only valid for active-backup, balance-tlb and
+ balance-alb mode.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_primary('eth2')
+ """
+ return self.set_interface('bond_primary', interface)
+
+ def set_mode(self, mode):
+ """
+ Specifies one of the bonding policies. The default is balance-rr
+ (round robin).
+
+ Possible values are: balance-rr, active-backup, balance-xor,
+ broadcast, 802.3ad, balance-tlb, balance-alb
+
+ NOTE: the bonding mode can not be changed when the bond itself has
+ slaves
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_mode('802.3ad')
+ """
+ return self.set_interface('bond_mode', mode)
+
+ def set_system_mac(self, mac):
+ """
+ In an AD system, this specifies the mac-address for the actor in
+ protocol packet exchanges (LACPDUs). The value cannot be NULL or
+ multicast. It is preferred to have the local-admin bit set for this
+ mac but driver does not enforce it. If the value is not given then
+ system defaults to using the masters' mac address as actors' system
+ address.
+
+ This parameter has effect only in 802.3ad mode and is available through
+ SysFs interface.
+
+ Example:
+ >>> from vyos.ifconfig import BondIf
+ >>> BondIf('bond0').set_system_mac('00:50:ab:cd:ef:01')
+ """
+ return self.set_interface('bond_system_mac', mac)
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ # use ref-counting function to place an interface into admin down state.
+ # set_admin_state_up() must be called the same amount of times else the
+ # interface won't come up. This can/should be used to prevent link flapping
+ # when changing interface parameters require the interface to be down.
+ # We will disable it once before reconfiguration and enable it afterwards.
+ if 'shutdown_required' in config:
+ self.set_admin_state('down')
+
+ # Specifies the MII link monitoring frequency in milliseconds
+ value = config.get('mii_mon_interval')
+ self.set_miimon_interval(value)
+
+ # Bonding transmit hash policy
+ value = config.get('hash_policy')
+ if value: self.set_hash_policy(value)
+
+ # Minimum number of member interfaces
+ value = config.get('min_links')
+ if value: self.set_min_links(value)
+
+ # Some interface options can only be changed if the interface is
+ # administratively down
+ if self.get_admin_state() == 'down':
+ # Remove ALL bond member interfaces
+ for interface in self.get_slaves():
+ self.del_port(interface)
+
+ # Restore correct interface status based on config
+ if dict_search(f'member.interface.{interface}.disable', config) is not None or \
+ dict_search(f'member.interface_remove.{interface}.disable', config) is not None:
+ Interface(interface).set_admin_state('down')
+ else:
+ Interface(interface).set_admin_state('up')
+
+ # Bonding policy/mode - default value, always present
+ self.set_mode(config['mode'])
+
+ # LACPDU transmission rate - default value
+ if config['mode'] == '802.3ad':
+ self.set_lacp_rate(config.get('lacp_rate'))
+
+ if config['mode'] not in ['802.3ad', 'balance-tlb', 'balance-alb']:
+ tmp = dict_search('arp_monitor.interval', config)
+ value = tmp if (tmp != None) else '0'
+ self.set_arp_interval(value)
+
+ # ARP monitor targets need to be synchronized between sysfs and CLI.
+ # Unfortunately an address can't be send twice to sysfs as this will
+ # result in the following exception: OSError: [Errno 22] Invalid argument.
+ #
+ # We remove ALL addresses prior to adding new ones, this will remove
+ # addresses manually added by the user too - but as we are limited to 16 adresses
+ # from the kernel side this looks valid to me. We won't run into an error
+ # when a user added manual adresses which would result in having more
+ # then 16 adresses in total.
+ arp_tgt_addr = list(map(str, self.get_arp_ip_target().split()))
+ for addr in arp_tgt_addr:
+ self.set_arp_ip_target('-' + addr)
+
+ # Add configured ARP target addresses
+ value = dict_search('arp_monitor.target', config)
+ if isinstance(value, str):
+ value = [value]
+ if value:
+ for addr in value:
+ self.set_arp_ip_target('+' + addr)
+
+ # Add (enslave) interfaces to bond
+ value = dict_search('member.interface', config)
+ for interface in (value or []):
+ # if we've come here we already verified the interface
+ # does not have an addresses configured so just flush
+ # any remaining ones
+ Interface(interface).flush_addrs()
+ self.add_port(interface)
+
+ # Add system mac address for 802.3ad - default address is all zero
+ # mode is always present (defaultValue)
+ if config['mode'] == '802.3ad':
+ mac = '00:00:00:00:00:00'
+ if 'system_mac' in config:
+ mac = config['system_mac']
+ self.set_system_mac(mac)
+
+ # Primary device interface - must be set after 'mode'
+ value = config.get('primary')
+ if value: self.set_primary(value)
+
+ # 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
new file mode 100644
index 0000000..917f962
--- /dev/null
+++ b/python/vyos/ifconfig/bridge.py
@@ -0,0 +1,413 @@
+# 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
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+from vyos.utils.assertion import assert_boolean
+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 list_diff
+
+@Interface.register
+class BridgeIf(Interface):
+ """
+ A bridge is a way to connect two Ethernet segments together in a protocol
+ independent way. Packets are forwarded based on Ethernet address, rather
+ than IP address (like a router). Since forwarding is done at Layer 2, all
+ protocols can go transparently through a bridge.
+
+ The Linux bridge code implements a subset of the ANSI/IEEE 802.1d standard.
+ """
+ iftype = 'bridge'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'bridge',
+ 'prefixes': ['br', ],
+ 'broadcast': True,
+ 'vlan': True,
+ },
+ }
+
+ _sysfs_get = {
+ **Interface._sysfs_get,**{
+ 'vlan_filter': {
+ 'location': '/sys/class/net/{ifname}/bridge/vlan_filtering'
+ }
+ }
+ }
+
+ _sysfs_set = {**Interface._sysfs_set, **{
+ 'ageing_time': {
+ 'validate': assert_positive,
+ 'convert': lambda t: int(t) * 100,
+ 'location': '/sys/class/net/{ifname}/bridge/ageing_time',
+ },
+ 'forward_delay': {
+ 'validate': assert_positive,
+ 'convert': lambda t: int(t) * 100,
+ 'location': '/sys/class/net/{ifname}/bridge/forward_delay',
+ },
+ 'hello_time': {
+ 'validate': assert_positive,
+ 'convert': lambda t: int(t) * 100,
+ 'location': '/sys/class/net/{ifname}/bridge/hello_time',
+ },
+ 'max_age': {
+ 'validate': assert_positive,
+ 'convert': lambda t: int(t) * 100,
+ 'location': '/sys/class/net/{ifname}/bridge/max_age',
+ },
+ 'priority': {
+ 'validate': assert_positive,
+ 'location': '/sys/class/net/{ifname}/bridge/priority',
+ },
+ 'stp': {
+ 'validate': assert_boolean,
+ 'location': '/sys/class/net/{ifname}/bridge/stp_state',
+ },
+ 'vlan_filter': {
+ 'validate': assert_boolean,
+ 'location': '/sys/class/net/{ifname}/bridge/vlan_filtering',
+ },
+ 'vlan_protocol': {
+ 'validate': lambda v: assert_list(v, ['0x88a8', '0x8100']),
+ 'location': '/sys/class/net/{ifname}/bridge/vlan_protocol',
+ },
+ 'multicast_querier': {
+ 'validate': assert_boolean,
+ 'location': '/sys/class/net/{ifname}/bridge/multicast_querier',
+ },
+ 'multicast_snooping': {
+ 'validate': assert_boolean,
+ 'location': '/sys/class/net/{ifname}/bridge/multicast_snooping',
+ },
+ }}
+
+ _command_set = {**Interface._command_set, **{
+ 'add_port': {
+ 'shellcmd': 'ip link set dev {value} master {ifname}',
+ },
+ 'del_port': {
+ 'shellcmd': 'ip link set dev {value} nomaster',
+ },
+ }}
+
+ def get_vlan_filter(self):
+ """
+ Get the status of the bridge VLAN filter
+ """
+
+ return self.get_interface('vlan_filter')
+
+
+ def set_ageing_time(self, time):
+ """
+ Set bridge interface MAC address aging time in seconds. Internal kernel
+ representation is in centiseconds. Kernel default is 300 seconds.
+
+ Example:
+ >>> from vyos.ifconfig import BridgeIf
+ >>> BridgeIf('br0').ageing_time(2)
+ """
+ self.set_interface('ageing_time', time)
+
+ def set_forward_delay(self, time):
+ """
+ Set bridge forwarding delay in seconds. Internal Kernel representation
+ is in centiseconds.
+
+ Example:
+ >>> from vyos.ifconfig import BridgeIf
+ >>> BridgeIf('br0').forward_delay(15)
+ """
+ self.set_interface('forward_delay', time)
+
+ def set_hello_time(self, time):
+ """
+ Set bridge hello time in seconds. Internal Kernel representation
+ is in centiseconds.
+
+ Example:
+ >>> from vyos.ifconfig import BridgeIf
+ >>> BridgeIf('br0').set_hello_time(2)
+ """
+ self.set_interface('hello_time', time)
+
+ def set_max_age(self, time):
+ """
+ Set bridge max message age in seconds. Internal Kernel representation
+ is in centiseconds.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> BridgeIf('br0').set_max_age(30)
+ """
+ self.set_interface('max_age', time)
+
+ def set_priority(self, priority):
+ """
+ Set bridge max aging time in seconds.
+
+ Example:
+ >>> from vyos.ifconfig import BridgeIf
+ >>> BridgeIf('br0').set_priority(8192)
+ """
+ self.set_interface('priority', priority)
+
+ def set_stp(self, state):
+ """
+ Set bridge STP (Spanning Tree) state. 0 -> STP disabled, 1 -> STP enabled
+
+ Example:
+ >>> from vyos.ifconfig import BridgeIf
+ >>> BridgeIf('br0').set_stp(1)
+ """
+ self.set_interface('stp', state)
+
+ def set_vlan_filter(self, state):
+ """
+ Set bridge Vlan Filter state. 0 -> Vlan Filter disabled, 1 -> Vlan Filter enabled
+
+ Example:
+ >>> from vyos.ifconfig import BridgeIf
+ >>> BridgeIf('br0').set_vlan_filter(1)
+ """
+ self.set_interface('vlan_filter', state)
+
+ # VLAN of bridge parent interface is always 1
+ # VLAN 1 is the default VLAN for all unlabeled packets
+ cmd = f'bridge vlan add dev {self.ifname} vid 1 pvid untagged self'
+ self._cmd(cmd)
+
+ def set_multicast_querier(self, enable):
+ """
+ Sets whether the bridge actively runs a multicast querier or not. When a
+ bridge receives a 'multicast host membership' query from another network
+ host, that host is tracked based on the time that the query was received
+ plus the multicast query interval time.
+
+ Use enable=1 to enable or enable=0 to disable
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> BridgeIf('br0').set_multicast_querier(1)
+ """
+ self.set_interface('multicast_querier', enable)
+
+ def set_multicast_snooping(self, enable):
+ """
+ Enable or disable multicast snooping on the bridge.
+
+ Use enable=1 to enable or enable=0 to disable
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> BridgeIf('br0').set_multicast_snooping(1)
+ """
+ self.set_interface('multicast_snooping', enable)
+
+ def add_port(self, interface):
+ """
+ Add physical interface to bridge (member port)
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> BridgeIf('br0').add_port('eth0')
+ >>> BridgeIf('br0').add_port('eth1')
+ """
+ # Bridge port handling of wireless interfaces is done by hostapd.
+ if 'wlan' in interface:
+ return
+
+ try:
+ return self.set_interface('add_port', interface)
+ except:
+ from vyos import ConfigError
+ raise ConfigError('Error: Device does not allow enslaving to a bridge.')
+
+ def del_port(self, interface):
+ """
+ Remove member port from bridge instance.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> BridgeIf('br0').del_port('eth1')
+ """
+ return self.set_interface('del_port', interface)
+
+ def set_vlan_protocol(self, protocol):
+ """
+ Set protocol used for VLAN filtering.
+ The valid values are 0x8100(802.1q) or 0x88A8(802.1ad).
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> BridgeIf('br0').del_port('eth1')
+ """
+
+ if protocol not in ['802.1q', '802.1ad']:
+ raise ValueError()
+
+ map = {
+ '802.1ad': '0x88a8',
+ '802.1q' : '0x8100'
+ }
+
+ return self.set_interface('vlan_protocol', map[protocol])
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ # Set ageing time
+ value = config.get('aging')
+ self.set_ageing_time(value)
+
+ # set bridge forward delay
+ value = config.get('forwarding_delay')
+ self.set_forward_delay(value)
+
+ # set hello time
+ value = config.get('hello_time')
+ self.set_hello_time(value)
+
+ # set max message age
+ value = config.get('max_age')
+ self.set_max_age(value)
+
+ # set bridge priority
+ value = config.get('priority')
+ self.set_priority(value)
+
+ # enable/disable spanning tree
+ value = '1' if 'stp' in config else '0'
+ self.set_stp(value)
+
+ # enable or disable multicast snooping
+ tmp = dict_search('igmp.snooping', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_multicast_snooping(value)
+
+ # enable or disable IGMP querier
+ tmp = dict_search('igmp.querier', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_multicast_querier(value)
+
+ # remove interface from bridge
+ tmp = dict_search('member.interface_remove', config)
+ for member in (tmp or []):
+ if interface_exists(member):
+ self.del_port(member)
+
+ # enable/disable VLAN Filter
+ tmp = '1' if 'enable_vlan' in config else '0'
+ self.set_vlan_filter(tmp)
+
+ tmp = config.get('protocol')
+ self.set_vlan_protocol(tmp)
+
+ # add VLAN interfaces to local 'parent' bridge to allow forwarding
+ if 'enable_vlan' in config:
+ for vlan in config.get('vif_remove', {}):
+ # Remove old VLANs from the bridge
+ cmd = f'bridge vlan del dev {self.ifname} vid {vlan} self'
+ self._cmd(cmd)
+
+ for vlan in config.get('vif', {}):
+ cmd = f'bridge vlan add dev {self.ifname} vid {vlan} self'
+ self._cmd(cmd)
+
+ # VLAN of bridge parent interface is always 1. VLAN 1 is the default
+ # VLAN for all unlabeled packets
+ cmd = f'bridge vlan add dev {self.ifname} vid 1 pvid untagged self'
+ self._cmd(cmd)
+
+ tmp = dict_search('member.interface', config)
+ if tmp:
+ for interface, interface_config in tmp.items():
+ # if interface does yet not exist bail out early and
+ # add it later
+ if not interface_exists(interface):
+ continue
+
+ # Bridge lower "physical" interface
+ lower = Interface(interface)
+
+ # If we've come that far we already verified the interface does
+ # not have any addresses configured by CLI so just flush any
+ # remaining ones
+ lower.flush_addrs()
+
+ # enslave interface port to bridge
+ self.add_port(interface)
+
+ if not interface.startswith('wlan'):
+ # always set private-vlan/port isolation - this can not be
+ # done when lower link is a wifi link, as it will trigger:
+ # RTNETLINK answers: Operation not supported
+ tmp = dict_search('isolated', interface_config)
+ value = 'on' if (tmp != None) else 'off'
+ lower.set_port_isolation(value)
+
+ # set bridge port path cost
+ if 'cost' in interface_config:
+ lower.set_path_cost(interface_config['cost'])
+
+ # set bridge port path priority
+ if 'priority' in interface_config:
+ lower.set_path_priority(interface_config['priority'])
+
+ if 'enable_vlan' in config:
+ add_vlan = []
+ native_vlan_id = None
+ allowed_vlan_ids= []
+ cur_vlan_ids = get_vlan_ids(interface)
+
+ if 'native_vlan' in interface_config:
+ vlan_id = interface_config['native_vlan']
+ add_vlan.append(vlan_id)
+ native_vlan_id = vlan_id
+
+ 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)
+
+ # Remove redundant VLANs from the system
+ for vlan in list_diff(cur_vlan_ids, add_vlan):
+ cmd = f'bridge vlan del dev {interface} vid {vlan} master'
+ self._cmd(cmd)
+
+ for vlan in allowed_vlan_ids:
+ cmd = f'bridge vlan add dev {interface} vid {vlan} master'
+ self._cmd(cmd)
+
+ # Setting native VLAN to system
+ if native_vlan_id:
+ cmd = f'bridge vlan add dev {interface} vid {native_vlan_id} pvid untagged master'
+ self._cmd(cmd)
+
+ super().update(config)
diff --git a/python/vyos/ifconfig/control.py b/python/vyos/ifconfig/control.py
new file mode 100644
index 0000000..7402da5
--- /dev/null
+++ b/python/vyos/ifconfig/control.py
@@ -0,0 +1,196 @@
+# Copyright 2019-2023 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 os
+
+from inspect import signature
+from inspect import _empty
+
+from vyos.ifconfig.section import Section
+from vyos.utils.process import popen
+from vyos.utils.process import cmd
+from vyos.utils.file import read_file
+from vyos.utils.file import write_file
+from vyos import debug
+
+class Control(Section):
+ _command_get = {}
+ _command_set = {}
+ _signature = {}
+
+ def __init__(self, **kargs):
+ # some commands (such as operation comands - show interfaces, etc.)
+ # need to query the interface statistics. If the interface
+ # code is used and the debugging is enabled, the screen output
+ # will include both the command but also the debugging for that command
+ # to prevent this, debugging can be explicitely disabled
+
+ # if debug is not explicitely disabled the the config, enable it
+ self.debug = ''
+ if kargs.get('debug', True) and debug.enabled('ifconfig'):
+ self.debug = 'ifconfig'
+
+ def _debug_msg (self, message):
+ return debug.message(message, self.debug)
+
+ def _popen(self, command):
+ return popen(command, self.debug)
+
+ def _cmd(self, command):
+ import re
+ if 'netns' in self.config:
+ # This command must be executed from default netns 'ip link set dev X netns X'
+ # exclude set netns cmd from netns to avoid:
+ # failed to run command: ip netns exec ns01 ip link set dev veth20 netns ns01
+ pattern = r'ip link set dev (\S+) netns (\S+)'
+ matches = re.search(pattern, command)
+ if matches and matches.group(2) == self.config['netns']:
+ # Command already includes netns and matches desired namespace:
+ command = command
+ else:
+ command = f'ip netns exec {self.config["netns"]} {command}'
+ return cmd(command, self.debug)
+
+ def _get_command(self, config, name):
+ """
+ Using the defined names, set data write to sysfs.
+ """
+ cmd = self._command_get[name]['shellcmd'].format(**config)
+ return self._command_get[name].get('format', lambda _: _)(self._cmd(cmd))
+
+ def _values(self, name, validate, value):
+ """
+ looks at the validation function "validate"
+ for the interface sysfs or command and
+ returns a dict with the right options to call it
+ """
+ if name not in self._signature:
+ self._signature[name] = signature(validate)
+
+ values = {}
+
+ for k in self._signature[name].parameters:
+ default = self._signature[name].parameters[k].default
+ if default is not _empty:
+ continue
+ if k == 'self':
+ values[k] = self
+ elif k == 'ifname':
+ values[k] = self.ifname
+ else:
+ values[k] = value
+
+ return values
+
+ def _set_command(self, config, name, value):
+ """
+ Using the defined names, set data write to sysfs.
+ """
+ # the code can pass int as int
+ value = str(value)
+
+ validate = self._command_set[name].get('validate', None)
+ if validate:
+ try:
+ validate(**self._values(name, validate, value))
+ except Exception as e:
+ raise e.__class__(f'Could not set {name}. {e}')
+
+ convert = self._command_set[name].get('convert', None)
+ if convert:
+ value = convert(value)
+
+ possible = self._command_set[name].get('possible', None)
+ if possible and not possible(config['ifname'], value):
+ return False
+
+ config = {**config, **{'value': value}}
+
+ cmd = self._command_set[name]['shellcmd'].format(**config)
+ return self._command_set[name].get('format', lambda _: _)(self._cmd(cmd))
+
+ _sysfs_get = {}
+ _sysfs_set = {}
+
+ def _read_sysfs(self, filename):
+ """
+ Provide a single primitive w/ error checking for reading from sysfs.
+ """
+ value = None
+ if os.path.exists(filename):
+ value = read_file(filename)
+ self._debug_msg("read '{}' < '{}'".format(value, filename))
+ return value
+
+ def _write_sysfs(self, filename, value):
+ """
+ Provide a single primitive w/ error checking for writing to sysfs.
+ """
+ if os.path.isfile(filename):
+ write_file(filename, str(value))
+ self._debug_msg("write '{}' > '{}'".format(value, filename))
+ return True
+ return False
+
+ def _get_sysfs(self, config, name):
+ """
+ Using the defined names, get data write from sysfs.
+ """
+ filename = self._sysfs_get[name]['location'].format(**config)
+ if not filename:
+ return None
+ return self._read_sysfs(filename)
+
+ def _set_sysfs(self, config, name, value):
+ """
+ Using the defined names, set data write to sysfs.
+ """
+ # the code can pass int as int
+ value = str(value)
+
+ validate = self._sysfs_set[name].get('validate', None)
+ if validate:
+ try:
+ validate(**self._values(name, validate, value))
+ except Exception as e:
+ raise e.__class__(f'Could not set {name}. {e}')
+
+ config = {**config, **{'value': value}}
+
+ convert = self._sysfs_set[name].get('convert', None)
+ if convert:
+ value = convert(value)
+
+ commited = self._write_sysfs(
+ self._sysfs_set[name]['location'].format(**config), value)
+ if not commited:
+ errmsg = self._sysfs_set.get('errormsg', '')
+ if errmsg:
+ raise TypeError(errmsg.format(**config))
+ return commited
+
+ def get_interface(self, name):
+ if name in self._sysfs_get:
+ return self._get_sysfs(self.config, name)
+ if name in self._command_get:
+ return self._get_command(self.config, name)
+ raise KeyError(f'{name} is not a attribute of the interface we can get')
+
+ def set_interface(self, name, value):
+ if name in self._sysfs_set:
+ return self._set_sysfs(self.config, name, value)
+ if name in self._command_set:
+ return self._set_command(self.config, name, value)
+ raise KeyError(f'{name} is not a attribute of the interface we can set')
diff --git a/python/vyos/ifconfig/dummy.py b/python/vyos/ifconfig/dummy.py
new file mode 100644
index 0000000..d457699
--- /dev/null
+++ b/python/vyos/ifconfig/dummy.py
@@ -0,0 +1,33 @@
+# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class DummyIf(Interface):
+ """
+ A dummy interface is entirely virtual like, for example, the loopback
+ 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,
+ **{
+ 'section': 'dummy',
+ 'prefixes': ['dum', ],
+ },
+ }
diff --git a/python/vyos/ifconfig/ethernet.py b/python/vyos/ifconfig/ethernet.py
new file mode 100644
index 0000000..61da7b7
--- /dev/null
+++ b/python/vyos/ifconfig/ethernet.py
@@ -0,0 +1,457 @@
+# Copyright 2019-2023 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 os
+
+from glob import glob
+
+from vyos.base import Warning
+from vyos.ethtool import Ethtool
+from vyos.ifconfig import Section
+from vyos.ifconfig.interface import Interface
+from vyos.utils.dict import dict_search
+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,
+ **{
+ 'section': 'ethernet',
+ 'prefixes': ['lan', 'eth', 'eno', 'ens', 'enp', 'enx'],
+ 'bondable': True,
+ 'broadcast': True,
+ 'bridgeable': True,
+ 'eternal': '(lan|eth|eno|ens|enp|enx)[0-9]+$',
+ }
+ }
+
+ @staticmethod
+ def feature(ifname, option, value):
+ 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),
+ },
+ }}
+
+ @staticmethod
+ def get_bond_member_allowed_options() -> list:
+ """
+ Return list of options which are allowed for changing,
+ when interface is a bond member
+ :return: List of interface options
+ :rtype: list
+ """
+ bond_allowed_sections = [
+ 'description',
+ 'disable',
+ 'disable_flow_control',
+ 'disable_link_detect',
+ 'duplex',
+ 'eapol.ca_certificate',
+ 'eapol.certificate',
+ 'eapol.passphrase',
+ 'mirror.egress',
+ 'mirror.ingress',
+ 'offload.gro',
+ 'offload.gso',
+ 'offload.lro',
+ 'offload.rfs',
+ 'offload.rps',
+ 'offload.sg',
+ 'offload.tso',
+ 'redirect',
+ 'ring_buffer.rx',
+ 'ring_buffer.tx',
+ 'speed',
+ 'hw_id'
+ ]
+ return bond_allowed_sections
+
+ def __init__(self, ifname, **kargs):
+ super().__init__(ifname, **kargs)
+ self.ethtool = Ethtool(ifname)
+
+ def remove(self):
+ """
+ Remove interface from config. Removing the interface deconfigures all
+ assigned IP addresses.
+ Example:
+ >>> from vyos.ifconfig import WWANIf
+ >>> i = EthernetIf('eth0')
+ >>> i.remove()
+ """
+
+ if self.exists(self.ifname):
+ # interface is placed in A/D state when removed from config! It
+ # will remain visible for the operating system.
+ 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}.')]:
+ Interface(vlan).remove()
+
+ super().remove()
+
+ def set_flow_control(self, enable):
+ """
+ Changes the pause parameters of the specified Ethernet device.
+
+ @param enable: true -> enable pause frames, false -> disable pause frames
+
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_flow_control(True)
+ """
+ ifname = self.config['ifname']
+
+ if enable not in ['on', 'off']:
+ 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!')
+ return False
+
+ current = self.ethtool.get_flow_control()
+ if current != enable:
+ # Assemble command executed on system. Unfortunately there is no way
+ # to change this setting via sysfs
+ cmd = f'ethtool --pause {ifname} autoneg {enable} tx {enable} rx {enable}'
+ output, code = self._popen(cmd)
+ if code:
+ Warning(f'could not change "{ifname}" flow control setting!')
+ return output
+ return None
+
+ def set_speed_duplex(self, speed, duplex):
+ """
+ Set link speed in Mbit/s and duplex.
+
+ @speed can be any link speed in MBit/s, e.g. 10, 100, 1000 auto
+ @duplex can be half, full, auto
+
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_speed_duplex('auto', 'auto')
+ """
+ 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 duplex not in ['auto', 'full', 'half']:
+ 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!')
+ return
+
+ if not self.ethtool.check_auto_negotiation_supported():
+ Warning(f'changing auto-negotiation setting on "{ifname}" is unsupported!')
+ return
+
+ # Get current speed and duplex settings:
+ ifname = self.config['ifname']
+ if self.ethtool.get_auto_negotiation():
+ if speed == 'auto' and duplex == 'auto':
+ # bail out early as nothing is to change
+ return
+ else:
+ # XXX: read in current speed and duplex settings
+ # There are some "nice" NICs like AX88179 which do not support
+ # reading the speed thus we simply fallback to the supplied speed
+ # to not cause any change here and raise an exception.
+ cur_speed = read_file(f'/sys/class/net/{ifname}/speed', speed)
+ cur_duplex = read_file(f'/sys/class/net/{ifname}/duplex', duplex)
+ if (cur_speed == speed) and (cur_duplex == duplex):
+ # bail out early as nothing is to change
+ return
+
+ cmd = f'ethtool --change {ifname}'
+ try:
+ if speed == 'auto' or duplex == 'auto':
+ cmd += ' autoneg on'
+ else:
+ cmd += f' speed {speed} duplex {duplex} autoneg off'
+ return self._cmd(cmd)
+ except PermissionError:
+ # Some NICs do not tell that they don't suppport settings speed/duplex,
+ # 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!')
+
+ def set_gro(self, state):
+ """
+ Enable Generic Receive Offload. State can be either True or False.
+
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_gro(True)
+ """
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ enabled, fixed = self.ethtool.get_generic_receive_offload()
+ if enabled != state:
+ if not fixed:
+ return self.set_interface('gro', 'on' if state else 'off')
+ else:
+ print('Adapter does not support changing generic-receive-offload settings!')
+ return False
+
+ def set_gso(self, state):
+ """
+ Enable Generic Segmentation offload. State can be either True or False.
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_gso(True)
+ """
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ enabled, fixed = self.ethtool.get_generic_segmentation_offload()
+ if enabled != state:
+ if not fixed:
+ return self.set_interface('gso', 'on' if state else 'off')
+ else:
+ print('Adapter does not support changing generic-segmentation-offload settings!')
+ return False
+
+ def set_hw_tc_offload(self, state):
+ """
+ Enable hardware TC flow offload. State can be either True or False.
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_hw_tc_offload(True)
+ """
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ enabled, fixed = self.ethtool.get_hw_tc_offload()
+ if enabled != state:
+ if not fixed:
+ return self.set_interface('hw-tc-offload', 'on' if state else 'off')
+ else:
+ print('Adapter does not support changing hw-tc-offload settings!')
+ return False
+
+ def set_lro(self, state):
+ """
+ Enable Large Receive offload. State can be either True or False.
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_lro(True)
+ """
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ enabled, fixed = self.ethtool.get_large_receive_offload()
+ if enabled != state:
+ if not fixed:
+ return self.set_interface('lro', 'on' if state else 'off')
+ else:
+ print('Adapter does not support changing large-receive-offload settings!')
+ return False
+
+ def set_rps(self, state):
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ rps_cpus = 0
+ queues = len(glob(f'/sys/class/net/{self.ifname}/queues/rx-*'))
+ if state:
+ # 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
+
+ # XXX: we should probably reserve one core when the system is under
+ # high preasure so we can still have a core left for housekeeping.
+ # This is done by masking out the lowst bit so CPU0 is spared from
+ # 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}')
+
+ # send bitmask representation as hex string without leading '0x'
+ return True
+
+ def set_rfs(self, state):
+ rfs_flow = 0
+ queues = len(glob(f'/sys/class/net/{self.ifname}/queues/rx-*'))
+ if state:
+ global_rfs_flow = 32768
+ 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)
+
+ return True
+
+ def set_sg(self, state):
+ """
+ Enable Scatter-Gather support. State can be either True or False.
+
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_sg(True)
+ """
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ enabled, fixed = self.ethtool.get_scatter_gather()
+ if enabled != state:
+ if not fixed:
+ return self.set_interface('sg', 'on' if state else 'off')
+ else:
+ print('Adapter does not support changing scatter-gather settings!')
+ return False
+
+ def set_tso(self, state):
+ """
+ Enable TCP segmentation offloading. State can be either True or False.
+
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_tso(False)
+ """
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ enabled, fixed = self.ethtool.get_tcp_segmentation_offload()
+ if enabled != state:
+ if not fixed:
+ return self.set_interface('tso', 'on' if state else 'off')
+ else:
+ print('Adapter does not support changing tcp-segmentation-offload settings!')
+ return False
+
+ def set_ring_buffer(self, rx_tx, size):
+ """
+ Example:
+ >>> from vyos.ifconfig import EthernetIf
+ >>> i = EthernetIf('eth0')
+ >>> i.set_ring_buffer('rx', '4096')
+ """
+ current_size = self.ethtool.get_ring_buffer(rx_tx)
+ if current_size == size:
+ # bail out early if nothing is about to change
+ return None
+
+ ifname = self.config['ifname']
+ cmd = f'ethtool --set-ring {ifname} {rx_tx} {size}'
+ output, code = self._popen(cmd)
+ # ethtool error codes:
+ # 80 - value already setted
+ # 81 - does not possible to set value
+ if code and code != 80:
+ print(f'could not set "{rx_tx}" ring-buffer for {ifname}')
+ return output
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ # 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)
+
+ # GSO (generic segmentation offload)
+ self.set_gso(dict_search('offload.gso', config) != None)
+
+ # GSO (generic segmentation offload)
+ self.set_hw_tc_offload(dict_search('offload.hw_tc_offload', config) != None)
+
+ # LRO (large receive offload)
+ self.set_lro(dict_search('offload.lro', config) != None)
+
+ # RPS - Receive Packet Steering
+ self.set_rps(dict_search('offload.rps', config) != None)
+
+ # RFS - Receive Flow Steering
+ self.set_rfs(dict_search('offload.rfs', config) != None)
+
+ # scatter-gather option
+ self.set_sg(dict_search('offload.sg', config) != None)
+
+ # TSO (TCP segmentation offloading)
+ self.set_tso(dict_search('offload.tso', config) != None)
+
+ # Set physical interface speed and duplex
+ if 'speed_duplex_changed' in config:
+ if {'speed', 'duplex'} <= set(config):
+ speed = config.get('speed')
+ duplex = config.get('duplex')
+ self.set_speed_duplex(speed, duplex)
+
+ # Set interface ring buffer
+ if 'ring_buffer' in config:
+ for rx_tx, size in config['ring_buffer'].items():
+ self.set_ring_buffer(rx_tx, size)
+
+ # 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
new file mode 100644
index 0000000..fbb261a
--- /dev/null
+++ b/python/vyos/ifconfig/geneve.py
@@ -0,0 +1,65 @@
+# Copyright 2019-2021 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig import Interface
+from vyos.utils.dict import dict_search
+
+@Interface.register
+class GeneveIf(Interface):
+ """
+ Geneve: Generic Network Virtualization Encapsulation
+
+ For more information please refer to:
+ https://tools.ietf.org/html/draft-gross-geneve-00
+ https://www.redhat.com/en/blog/what-geneve
+ 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,
+ **{
+ 'section': 'geneve',
+ 'prefixes': ['gnv', ],
+ 'bridgeable': True,
+ }
+ }
+
+ def _create(self):
+ # This table represents a mapping from VyOS internal config dict to
+ # arguments used by iproute2. For more information please refer to:
+ # - https://man7.org/linux/man-pages/man8/ip-link.8.html
+ mapping = {
+ 'parameters.ip.df' : 'df',
+ 'parameters.ip.tos' : 'tos',
+ 'parameters.ip.ttl' : 'ttl',
+ 'parameters.ip.innerproto' : 'innerprotoinherit',
+ 'parameters.ipv6.flowlabel' : 'flowlabel',
+ }
+
+ cmd = 'ip link add name {ifname} type {type} id {vni} remote {remote}'
+ 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
+ # by using isinstance()
+ tmp = dict_search(vyos_key, self.config)
+ if isinstance(tmp, dict):
+ cmd += f' {iproute2_key}'
+ elif tmp != None:
+ cmd += f' {iproute2_key} {tmp}'
+
+ self._cmd(cmd.format(**self.config))
+ # interface is always A/D down. It needs to be enabled explicitly
+ self.set_admin_state('down')
diff --git a/python/vyos/ifconfig/input.py b/python/vyos/ifconfig/input.py
new file mode 100644
index 0000000..3e5f579
--- /dev/null
+++ b/python/vyos/ifconfig/input.py
@@ -0,0 +1,36 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class InputIf(Interface):
+ """
+ The Intermediate Functional Block (ifb) pseudo network interface acts as a
+ QoS concentrator for multiple different sources of traffic. Packets from
+ or to other interfaces have to be redirected to it using the mirred action
+ in order to be handled, regularly routed traffic will be dropped. This way,
+ a single stack of qdiscs, classes and filters can be shared between
+ multiple interfaces.
+ """
+
+ iftype = 'ifb'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'input',
+ 'prefixes': ['ifb', ],
+ },
+ }
diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py
new file mode 100644
index 0000000..002d3da
--- /dev/null
+++ b/python/vyos/ifconfig/interface.py
@@ -0,0 +1,1974 @@
+# 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
+# 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 os
+import re
+import json
+import jmespath
+
+from copy import deepcopy
+from glob import glob
+
+from ipaddress import IPv4Network
+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 vyos import ConfigError
+from vyos.configdict import list_diff
+from vyos.configdict import dict_merge
+from vyos.configdict import get_vlan_ids
+from vyos.defaults import directories
+from vyos.pki import find_chain
+from vyos.pki import encode_certificate
+from vyos.pki import load_certificate
+from vyos.pki import wrap_private_key
+from vyos.template import is_ipv4
+from vyos.template import is_ipv6
+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_namespace
+from vyos.utils.network import get_vrf_tableid
+from vyos.utils.network import is_netns_interface
+from vyos.utils.process import is_systemd_service_active
+from vyos.utils.process import run
+from vyos.utils.file import read_file
+from vyos.utils.file import write_file
+from vyos.utils.network import is_intf_addr_assigned
+from vyos.utils.network import is_ipv6_link_local
+from vyos.utils.assertion import assert_boolean
+from vyos.utils.assertion import assert_list
+from vyos.utils.assertion import assert_mac
+from vyos.utils.assertion import assert_mtu
+from vyos.utils.assertion import assert_positive
+from vyos.utils.assertion import assert_range
+from vyos.ifconfig.control import Control
+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):
+ # This is the class which will be used to create
+ # self.operational, it allows subclasses, such as
+ # WireGuard to modify their display behaviour
+ OperationalClass = Operational
+
+ options = ['debug', 'create']
+ required = []
+ default = {
+ 'debug': True,
+ 'create': True,
+ }
+ definition = {
+ 'section': '',
+ 'prefixes': [],
+ 'vlan': False,
+ 'bondable': False,
+ 'broadcast': False,
+ 'bridgeable': False,
+ 'eternal': '',
+ }
+
+ _command_get = {
+ 'admin_state': {
+ 'shellcmd': 'ip -json link show dev {ifname}',
+ 'format': lambda j: 'up' if 'UP' in jmespath.search('[*].flags | [0]', json.loads(j)) else 'down',
+ },
+ 'alias': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].ifalias | [0]', json.loads(j)) or '',
+ },
+ 'mac': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].address | [0]', json.loads(j)),
+ },
+ 'min_mtu': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].min_mtu | [0]', json.loads(j)),
+ },
+ 'max_mtu': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].max_mtu | [0]', json.loads(j)),
+ },
+ 'mtu': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].mtu | [0]', json.loads(j)),
+ },
+ 'oper_state': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].operstate | [0]', json.loads(j)),
+ },
+ 'vrf': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[?linkinfo.info_slave_kind == `vrf`].master | [0]', json.loads(j)),
+ },
+ }
+
+ _command_set = {
+ 'admin_state': {
+ 'validate': lambda v: assert_list(v, ['up', 'down']),
+ 'shellcmd': 'ip link set dev {ifname} {value}',
+ },
+ 'alias': {
+ 'convert': lambda name: name if name else '',
+ 'shellcmd': 'ip link set dev {ifname} alias "{value}"',
+ },
+ 'bridge_port_isolation': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'shellcmd': 'bridge link set dev {ifname} isolated {value}',
+ },
+ 'mac': {
+ 'validate': assert_mac,
+ 'shellcmd': 'ip link set dev {ifname} address {value}',
+ },
+ 'mtu': {
+ 'validate': assert_mtu,
+ 'shellcmd': 'ip link set dev {ifname} mtu {value}',
+ },
+ 'vrf': {
+ 'convert': lambda v: f'master {v}' if v else 'nomaster',
+ 'shellcmd': 'ip link set dev {ifname} {value}',
+ },
+ }
+
+ _sysfs_set = {
+ 'arp_cache_tmo': {
+ 'location': '/proc/sys/net/ipv4/neigh/{ifname}/base_reachable_time_ms',
+ },
+ 'arp_filter': {
+ 'validate': assert_boolean,
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_filter',
+ },
+ 'arp_accept': {
+ 'validate': lambda arp: assert_range(arp,0,2),
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_accept',
+ },
+ 'arp_announce': {
+ 'validate': assert_boolean,
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_announce',
+ },
+ 'arp_ignore': {
+ 'validate': assert_boolean,
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_ignore',
+ },
+ 'ipv4_forwarding': {
+ 'validate': assert_boolean,
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/forwarding',
+ },
+ 'ipv4_directed_broadcast': {
+ 'validate': assert_boolean,
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/bc_forwarding',
+ },
+ 'ipv6_accept_ra': {
+ 'validate': lambda ara: assert_range(ara,0,3),
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/accept_ra',
+ },
+ 'ipv6_autoconf': {
+ 'validate': lambda aco: assert_range(aco,0,2),
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/autoconf',
+ },
+ 'ipv6_forwarding': {
+ 'validate': lambda fwd: assert_range(fwd,0,2),
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/forwarding',
+ },
+ 'ipv6_accept_dad': {
+ 'validate': lambda dad: assert_range(dad,0,3),
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/accept_dad',
+ },
+ 'ipv6_dad_transmits': {
+ 'validate': assert_positive,
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/dad_transmits',
+ },
+ 'ipv6_cache_tmo': {
+ 'location': '/proc/sys/net/ipv6/neigh/{ifname}/base_reachable_time_ms',
+ },
+ 'path_cost': {
+ # XXX: we should set a maximum
+ 'validate': assert_positive,
+ 'location': '/sys/class/net/{ifname}/brport/path_cost',
+ 'errormsg': '{ifname} is not a bridge port member'
+ },
+ 'path_priority': {
+ # XXX: we should set a maximum
+ 'validate': assert_positive,
+ 'location': '/sys/class/net/{ifname}/brport/priority',
+ 'errormsg': '{ifname} is not a bridge port member'
+ },
+ 'proxy_arp': {
+ 'validate': assert_boolean,
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/proxy_arp',
+ },
+ 'proxy_arp_pvlan': {
+ 'validate': assert_boolean,
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/proxy_arp_pvlan',
+ },
+ # link_detect vs link_filter name weirdness
+ 'link_detect': {
+ 'validate': lambda link: assert_range(link,0,3),
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/link_filter',
+ },
+ 'per_client_thread': {
+ 'validate': assert_boolean,
+ 'location': '/sys/class/net/{ifname}/threaded',
+ },
+ }
+
+ _sysfs_get = {
+ 'arp_cache_tmo': {
+ 'location': '/proc/sys/net/ipv4/neigh/{ifname}/base_reachable_time_ms',
+ },
+ 'arp_filter': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_filter',
+ },
+ 'arp_accept': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_accept',
+ },
+ 'arp_announce': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_announce',
+ },
+ 'arp_ignore': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/arp_ignore',
+ },
+ 'ipv4_forwarding': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/forwarding',
+ },
+ 'ipv4_directed_broadcast': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/bc_forwarding',
+ },
+ 'ipv6_accept_ra': {
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/accept_ra',
+ },
+ 'ipv6_autoconf': {
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/autoconf',
+ },
+ 'ipv6_forwarding': {
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/forwarding',
+ },
+ 'ipv6_accept_dad': {
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/accept_dad',
+ },
+ 'ipv6_dad_transmits': {
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/dad_transmits',
+ },
+ 'ipv6_cache_tmo': {
+ 'location': '/proc/sys/net/ipv6/neigh/{ifname}/base_reachable_time_ms',
+ },
+ 'proxy_arp': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/proxy_arp',
+ },
+ 'proxy_arp_pvlan': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/proxy_arp_pvlan',
+ },
+ 'link_detect': {
+ 'location': '/proc/sys/net/ipv4/conf/{ifname}/link_filter',
+ },
+ 'per_client_thread': {
+ 'validate': assert_boolean,
+ 'location': '/sys/class/net/{ifname}/threaded',
+ },
+ }
+
+ @classmethod
+ def exists(cls, ifname: str, netns: str=None) -> bool:
+ cmd = f'ip link show dev {ifname}'
+ if netns:
+ cmd = f'ip netns exec {netns} {cmd}'
+ return run(cmd) == 0
+
+ @classmethod
+ def get_config(cls):
+ """
+ Some but not all interfaces require a configuration when they are added
+ using iproute2. This method will provide the configuration dictionary
+ used by this class.
+ """
+ return deepcopy(cls.default)
+
+ def __init__(self, ifname, **kargs):
+ """
+ This is the base interface class which supports basic IP/MAC address
+ operations as well as DHCP(v6). Other interface which represent e.g.
+ and ethernet bridge are implemented as derived classes adding all
+ additional functionality.
+
+ For creation you will need to provide the interface type, otherwise
+ the existing interface is used
+
+ DEBUG:
+ This class has embedded debugging (print) which can be enabled by
+ creating the following file:
+ vyos@vyos# touch /tmp/vyos.ifconfig.debug
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> i = Interface('eth0')
+ """
+ self.config = deepcopy(kargs)
+ self.config['ifname'] = self.ifname = ifname
+
+ self._admin_state_down_cnt = 0
+
+ # we must have updated config before initialising the Interface
+ 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')
+
+ self._create()
+ # If we can not connect to the interface then let the caller know
+ # as the class could not be correctly initialised
+ else:
+ raise Exception(f'interface "{ifname}" not found!')
+
+ # temporary list of assigned IP addresses
+ self._addr = []
+
+ self.operational = self.OperationalClass(ifname)
+ self.vrrp = VRRP(ifname)
+
+ def _create(self):
+ # 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)
+ if 'netns' in self.config: cmd = f'ip netns exec {netns} {cmd}'
+ self._cmd(cmd)
+
+ def remove(self):
+ """
+ Remove interface from operating system. Removing the interface
+ deconfigures all assigned IP addresses and clear possible DHCP(v6)
+ client processes.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> i = Interface('eth0')
+ >>> i.remove()
+ """
+ # Stop WPA supplicant if EAPoL was in use
+ if is_systemd_service_active(f'wpa_supplicant-wired@{self.ifname}'):
+ self._cmd(f'systemctl stop wpa_supplicant-wired@{self.ifname}')
+
+ # remove all assigned IP addresses from interface - this is a bit redundant
+ # as the kernel will remove all addresses on interface deletion, but we
+ # can not delete ALL interfaces, see below
+ self.flush_addrs()
+
+ # remove interface from conntrack VRF interface map
+ self._del_interface_from_ct_iface_map()
+
+ # ---------------------------------------------------------------------
+ # Any class can define an eternal regex in its definition
+ # interface matching the regex will not be deleted
+
+ eternal = self.definition['eternal']
+ if not eternal:
+ self._delete()
+ elif not re.match(eternal, self.ifname):
+ self._delete()
+
+ def _delete(self):
+ # NOTE (Improvement):
+ # after interface removal no other commands should be allowed
+ # to be called and instead should raise an Exception:
+ cmd = 'ip link del dev {ifname}'.format(**self.config)
+ # for delete we can't get data from self.config{'netns'}
+ netns = get_interface_namespace(self.ifname)
+ if netns: cmd = f'ip netns exec {netns} {cmd}'
+ return self._cmd(cmd)
+
+ def _nft_check_and_run(self, nft_command):
+ # Check if deleting is possible first to avoid raising errors
+ _, err = self._popen(f'nft --check {nft_command}')
+ if not err:
+ # Remove map element
+ self._cmd(f'nft {nft_command}')
+
+ def _del_interface_from_ct_iface_map(self):
+ nft_command = f'delete element inet vrf_zones ct_iface_map {{ "{self.ifname}" }}'
+ self._nft_check_and_run(nft_command)
+
+ def _add_interface_to_ct_iface_map(self, vrf_table_id: int):
+ nft_command = f'add element inet vrf_zones ct_iface_map {{ "{self.ifname}" : {vrf_table_id} }}'
+ self._nft_check_and_run(nft_command)
+
+ def get_min_mtu(self):
+ """
+ Get hardware minimum supported MTU
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_min_mtu()
+ '60'
+ """
+ return int(self.get_interface('min_mtu'))
+
+ def get_max_mtu(self):
+ """
+ Get hardware maximum supported MTU
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_max_mtu()
+ '9000'
+ """
+ return int(self.get_interface('max_mtu'))
+
+ def get_mtu(self):
+ """
+ Get/set interface mtu in bytes.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_mtu()
+ '1500'
+ """
+ return int(self.get_interface('mtu'))
+
+ def set_mtu(self, mtu):
+ """
+ Get/set interface mtu in bytes.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_mtu(1400)
+ >>> Interface('eth0').get_mtu()
+ '1400'
+ """
+ tmp = self.get_interface('mtu')
+ if str(tmp) == mtu:
+ return None
+ return self.set_interface('mtu', mtu)
+
+ def get_mac(self):
+ """
+ Get current interface MAC (Media Access Contrl) address used.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_mac()
+ '00:50:ab:cd:ef:00'
+ """
+ return self.get_interface('mac')
+
+ def get_mac_synthetic(self):
+ """
+ Get a synthetic MAC address. This is a common method which can be called
+ from derived classes to overwrite the get_mac() call in a generic way.
+
+ NOTE: Tunnel interfaces have no "MAC" address by default. The content
+ of the 'address' file in /sys/class/net/device contains the
+ local-ip thus we generate a random MAC address instead
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_mac()
+ '00:50:ab:cd:ef:00'
+ """
+ from hashlib import sha256
+
+ # Get processor ID number
+ cpu_id = self._cmd('sudo dmidecode -t 4 | grep ID | head -n1 | sed "s/.*ID://;s/ //g"')
+
+ # XXX: T3894 - it seems not all systems have eth0 - get a list of all
+ # available Ethernet interfaces on the system (without VLAN subinterfaces)
+ # and then take the first one.
+ all_eth_ifs = Section.interfaces('ethernet', vlan=False)
+ first_mac = Interface(all_eth_ifs[0]).get_mac()
+
+ sha = sha256()
+ # Calculate SHA256 sum based on the CPU ID number, eth0 mac address and
+ # this interface identifier - this is as predictable as an interface
+ # MAC address and thus can be used in the same way
+ sha.update(cpu_id.encode())
+ sha.update(first_mac.encode())
+ sha.update(self.ifname.encode())
+ # take the most significant 48 bits from the SHA256 string
+ tmp = sha.hexdigest()[:12]
+ # Convert pseudo random string into EUI format which now represents a
+ # MAC address
+ tmp = EUI(tmp).value
+ # set locally administered bit in MAC address
+ tmp |= 0xf20000000000
+ # convert integer to "real" MAC address representation
+ mac = EUI(hex(tmp).split('x')[-1])
+ # change dialect to use : as delimiter instead of -
+ mac.dialect = mac_unix_expanded
+ return str(mac)
+
+ def set_mac(self, mac):
+ """
+ Set interface MAC (Media Access Contrl) address to given value.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_mac('00:50:ab:cd:ef:01')
+ """
+
+ # If MAC is unchanged, bail out early
+ if mac == self.get_mac():
+ return None
+
+ # MAC address can only be changed if interface is in 'down' state
+ prev_state = self.get_admin_state()
+ if prev_state == 'up':
+ self.set_admin_state('down')
+
+ self.set_interface('mac', mac)
+
+ # Turn an interface to the 'up' state if it was changed to 'down' by this fucntion
+ if prev_state == 'up':
+ self.set_admin_state('up')
+
+ def del_netns(self, netns: str) -> bool:
+ """ Remove interface from given network namespace """
+ # If network namespace does not exist then there is nothing to delete
+ if not os.path.exists(f'/run/netns/{netns}'):
+ return False
+
+ # Check if interface exists in network namespace
+ if is_netns_interface(self.ifname, netns):
+ self._cmd(f'ip netns exec {netns} ip link del dev {self.ifname}')
+ return True
+ return False
+
+ def set_netns(self, netns: str) -> bool:
+ """
+ Add interface from given network namespace
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('dum0').set_netns('foo')
+ """
+ self._cmd(f'ip link set dev {self.ifname} netns {netns}')
+ return True
+
+ def get_vrf(self):
+ """
+ Get VRF from interface
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_vrf()
+ """
+ return self.get_interface('vrf')
+
+ def set_vrf(self, vrf: str) -> bool:
+ """
+ Add/Remove interface from given VRF instance.
+
+ 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
+
+ tmp = self.get_interface('vrf')
+ if tmp == vrf:
+ return False
+
+ # Get current VRF table ID
+ old_vrf_tableid = get_vrf_tableid(self.ifname)
+ 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:
+ # 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._add_interface_to_ct_iface_map(vrf_table_id)
+ else:
+ self._del_interface_from_ct_iface_map()
+
+ return True
+
+ def set_arp_cache_tmo(self, tmo):
+ """
+ Set ARP cache timeout value in seconds. Internal Kernel representation
+ is in milliseconds.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_arp_cache_tmo(40)
+ """
+ tmo = str(int(tmo) * 1000)
+ tmp = self.get_interface('arp_cache_tmo')
+ if tmp == tmo:
+ return None
+ return self.set_interface('arp_cache_tmo', tmo)
+
+ def set_ipv6_cache_tmo(self, tmo):
+ """
+ Set IPv6 cache timeout value in seconds. Internal Kernel representation
+ is in milliseconds.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_ipv6_cache_tmo(40)
+ """
+ tmo = str(int(tmo) * 1000)
+ tmp = self.get_interface('ipv6_cache_tmo')
+ if tmp == tmo:
+ return None
+ return self.set_interface('ipv6_cache_tmo', tmo)
+
+ def _cleanup_mss_rules(self, table, ifname):
+ commands = []
+ results = self._cmd(f'nft -a list chain {table} VYOS_TCP_MSS').split("\n")
+ for line in results:
+ if f'oifname "{ifname}"' in line:
+ handle_search = re.search('handle (\d+)', line)
+ if handle_search:
+ self._cmd(f'nft delete rule {table} VYOS_TCP_MSS handle {handle_search[1]}')
+
+ def set_tcp_ipv4_mss(self, mss):
+ """
+ Set IPv4 TCP MSS value advertised when TCP SYN packets leave this
+ interface. Value is in bytes.
+
+ A value of 0 will disable the MSS adjustment
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_tcp_ipv4_mss(1340)
+ """
+ # Don't allow for netns yet
+ if 'netns' in self.config:
+ return None
+
+ self._cleanup_mss_rules('raw', self.ifname)
+ nft_prefix = 'nft add rule raw VYOS_TCP_MSS'
+ base_cmd = f'oifname "{self.ifname}" tcp flags & (syn|rst) == syn'
+ if mss == 'clamp-mss-to-pmtu':
+ self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size set rt mtu'")
+ elif int(mss) > 0:
+ low_mss = str(int(mss) + 1)
+ self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size {low_mss}-65535 tcp option maxseg size set {mss}'")
+
+ def set_tcp_ipv6_mss(self, mss):
+ """
+ Set IPv6 TCP MSS value advertised when TCP SYN packets leave this
+ interface. Value is in bytes.
+
+ A value of 0 will disable the MSS adjustment
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_tcp_mss(1320)
+ """
+ # Don't allow for netns yet
+ if 'netns' in self.config:
+ return None
+
+ self._cleanup_mss_rules('ip6 raw', self.ifname)
+ nft_prefix = 'nft add rule ip6 raw VYOS_TCP_MSS'
+ base_cmd = f'oifname "{self.ifname}" tcp flags & (syn|rst) == syn'
+ if mss == 'clamp-mss-to-pmtu':
+ self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size set rt mtu'")
+ elif int(mss) > 0:
+ low_mss = str(int(mss) + 1)
+ self._cmd(f"{nft_prefix} '{base_cmd} tcp option maxseg size {low_mss}-65535 tcp option maxseg size set {mss}'")
+
+ def set_arp_filter(self, arp_filter):
+ """
+ Filter ARP requests
+
+ 1 - Allows you to have multiple network interfaces on the same
+ subnet, and have the ARPs for each interface be answered
+ based on whether or not the kernel would route a packet from
+ the ARP'd IP out that interface (therefore you must use source
+ based routing for this to work). In other words it allows control
+ of which cards (usually 1) will respond to an arp request.
+
+ 0 - (default) The kernel can respond to arp requests with addresses
+ from other interfaces. This may seem wrong but it usually makes
+ sense, because it increases the chance of successful communication.
+ IP addresses are owned by the complete host on Linux, not by
+ particular interfaces. Only for more complex setups like load-
+ balancing, does this behaviour cause problems.
+ """
+ tmp = self.get_interface('arp_filter')
+ if tmp == arp_filter:
+ return None
+ return self.set_interface('arp_filter', arp_filter)
+
+ def set_arp_accept(self, arp_accept):
+ """
+ Define behavior for gratuitous ARP frames who's IP is not
+ already present in the ARP table:
+ 0 - don't create new entries in the ARP table
+ 1 - create new entries in the ARP table
+
+ Both replies and requests type gratuitous arp will trigger the
+ ARP table to be updated, if this setting is on.
+
+ If the ARP table already contains the IP address of the
+ gratuitous arp frame, the arp table will be updated regardless
+ if this setting is on or off.
+ """
+ tmp = self.get_interface('arp_accept')
+ if tmp == arp_accept:
+ return None
+ return self.set_interface('arp_accept', arp_accept)
+
+ def set_arp_announce(self, arp_announce):
+ """
+ Define different restriction levels for announcing the local
+ source IP address from IP packets in ARP requests sent on
+ interface:
+ 0 - (default) Use any local address, configured on any interface
+ 1 - Try to avoid local addresses that are not in the target's
+ subnet for this interface. This mode is useful when target
+ hosts reachable via this interface require the source IP
+ address in ARP requests to be part of their logical network
+ configured on the receiving interface. When we generate the
+ request we will check all our subnets that include the
+ target IP and will preserve the source address if it is from
+ such subnet.
+
+ Increasing the restriction level gives more chance for
+ receiving answer from the resolved target while decreasing
+ the level announces more valid sender's information.
+ """
+ tmp = self.get_interface('arp_announce')
+ if tmp == arp_announce:
+ return None
+ return self.set_interface('arp_announce', arp_announce)
+
+ def set_arp_ignore(self, arp_ignore):
+ """
+ Define different modes for sending replies in response to received ARP
+ requests that resolve local target IP addresses:
+
+ 0 - (default): reply for any local target IP address, configured
+ on any interface
+ 1 - reply only if the target IP address is local address
+ configured on the incoming interface
+ """
+ tmp = self.get_interface('arp_ignore')
+ if tmp == arp_ignore:
+ return None
+ return self.set_interface('arp_ignore', arp_ignore)
+
+ def set_ipv4_forwarding(self, forwarding):
+ """ Configure IPv4 forwarding. """
+ tmp = self.get_interface('ipv4_forwarding')
+ if tmp == forwarding:
+ return None
+ return self.set_interface('ipv4_forwarding', forwarding)
+
+ def set_ipv4_directed_broadcast(self, forwarding):
+ """ Configure IPv4 directed broadcast forwarding. """
+ tmp = self.get_interface('ipv4_directed_broadcast')
+ if tmp == forwarding:
+ return None
+ return self.set_interface('ipv4_directed_broadcast', forwarding)
+
+ def _cleanup_ipv4_source_validation_rules(self, ifname):
+ results = self._cmd(f'nft -a list chain ip raw vyos_rpfilter').split("\n")
+ for line in results:
+ if f'iifname "{ifname}"' in line:
+ handle_search = re.search('handle (\d+)', line)
+ if handle_search:
+ self._cmd(f'nft delete rule ip raw vyos_rpfilter handle {handle_search[1]}')
+
+ def set_ipv4_source_validation(self, mode):
+ """
+ Set IPv4 reverse path validation
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_ipv4_source_validation('strict')
+ """
+ # Don't allow for netns yet
+ if 'netns' in self.config:
+ return None
+
+ self._cleanup_ipv4_source_validation_rules(self.ifname)
+ nft_prefix = f'nft insert rule ip raw vyos_rpfilter iifname "{self.ifname}"'
+ if mode in ['strict', 'loose']:
+ self._cmd(f"{nft_prefix} counter return")
+ if mode == 'strict':
+ self._cmd(f"{nft_prefix} fib saddr . iif oif 0 counter drop")
+ elif mode == 'loose':
+ self._cmd(f"{nft_prefix} fib saddr oif 0 counter drop")
+
+ def _cleanup_ipv6_source_validation_rules(self, ifname):
+ results = self._cmd(f'nft -a list chain ip6 raw vyos_rpfilter').split("\n")
+ for line in results:
+ if f'iifname "{ifname}"' in line:
+ handle_search = re.search('handle (\d+)', line)
+ if handle_search:
+ self._cmd(f'nft delete rule ip6 raw vyos_rpfilter handle {handle_search[1]}')
+
+ def set_ipv6_source_validation(self, mode):
+ """
+ Set IPv6 reverse path validation
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_ipv6_source_validation('strict')
+ """
+ # Don't allow for netns yet
+ if 'netns' in self.config:
+ return None
+
+ self._cleanup_ipv6_source_validation_rules(self.ifname)
+ nft_prefix = f'nft insert rule ip6 raw vyos_rpfilter iifname "{self.ifname}"'
+ if mode in ['strict', 'loose']:
+ self._cmd(f"{nft_prefix} counter return")
+ if mode == 'strict':
+ self._cmd(f"{nft_prefix} fib saddr . iif oif 0 counter drop")
+ elif mode == 'loose':
+ self._cmd(f"{nft_prefix} fib saddr oif 0 counter drop")
+
+ def set_ipv6_accept_ra(self, accept_ra):
+ """
+ Accept Router Advertisements; autoconfigure using them.
+
+ It also determines whether or not to transmit Router Solicitations.
+ If and only if the functional setting is to accept Router
+ Advertisements, Router Solicitations will be transmitted.
+
+ 0 - Do not accept Router Advertisements.
+ 1 - (default) Accept Router Advertisements if forwarding is disabled.
+ 2 - Overrule forwarding behaviour. Accept Router Advertisements even if
+ forwarding is enabled.
+ """
+ tmp = self.get_interface('ipv6_accept_ra')
+ if tmp == accept_ra:
+ return None
+ return self.set_interface('ipv6_accept_ra', accept_ra)
+
+ def set_ipv6_autoconf(self, autoconf):
+ """
+ Autoconfigure addresses using Prefix Information in Router
+ Advertisements.
+ """
+ tmp = self.get_interface('ipv6_autoconf')
+ if tmp == autoconf:
+ return None
+ return self.set_interface('ipv6_autoconf', autoconf)
+
+ def add_ipv6_eui64_address(self, prefix):
+ """
+ Extended Unique Identifier (EUI), as per RFC2373, allows a host to
+ assign itself a unique IPv6 address based on a given IPv6 prefix.
+
+ Calculate the EUI64 from the interface's MAC, then assign it
+ with the given prefix to the interface.
+ """
+ # T2863: only add a link-local IPv6 address if the interface returns
+ # a MAC address. This is not the case on e.g. WireGuard interfaces.
+ mac = self.get_mac()
+ if mac:
+ eui64 = mac2eui64(mac, prefix)
+ prefixlen = prefix.split('/')[1]
+ self.add_addr(f'{eui64}/{prefixlen}')
+
+ def del_ipv6_eui64_address(self, prefix):
+ """
+ Delete the address based on the interface's MAC-based EUI64
+ combined with the prefix address.
+ """
+ if is_ipv6(prefix):
+ eui64 = mac2eui64(self.get_mac(), prefix)
+ prefixlen = prefix.split('/')[1]
+ self.del_addr(f'{eui64}/{prefixlen}')
+
+ def set_ipv6_forwarding(self, forwarding):
+ """
+ Configure IPv6 interface-specific Host/Router behaviour.
+
+ False:
+
+ By default, Host behaviour is assumed. This means:
+
+ 1. IsRouter flag is not set in Neighbour Advertisements.
+ 2. If accept_ra is TRUE (default), transmit Router
+ Solicitations.
+ 3. If accept_ra is TRUE (default), accept Router
+ Advertisements (and do autoconfiguration).
+ 4. If accept_redirects is TRUE (default), accept Redirects.
+
+ True:
+
+ If local forwarding is enabled, Router behaviour is assumed.
+ This means exactly the reverse from the above:
+
+ 1. IsRouter flag is set in Neighbour Advertisements.
+ 2. Router Solicitations are not sent unless accept_ra is 2.
+ 3. Router Advertisements are ignored unless accept_ra is 2.
+ 4. Redirects are ignored.
+ """
+ tmp = self.get_interface('ipv6_forwarding')
+ if tmp == forwarding:
+ return None
+ return self.set_interface('ipv6_forwarding', forwarding)
+
+ def set_ipv6_dad_accept(self, dad):
+ """Whether to accept DAD (Duplicate Address Detection)"""
+ tmp = self.get_interface('ipv6_accept_dad')
+ if tmp == dad:
+ return None
+ return self.set_interface('ipv6_accept_dad', dad)
+
+ def set_ipv6_dad_messages(self, dad):
+ """
+ The amount of Duplicate Address Detection probes to send.
+ Default: 1
+ """
+ tmp = self.get_interface('ipv6_dad_transmits')
+ if tmp == dad:
+ return None
+ return self.set_interface('ipv6_dad_transmits', dad)
+
+ def set_link_detect(self, link_filter):
+ """
+ Configure kernel response in packets received on interfaces that are 'down'
+
+ 0 - Allow packets to be received for the address on this interface
+ even if interface is disabled or no carrier.
+
+ 1 - Ignore packets received if interface associated with the incoming
+ address is down.
+
+ 2 - Ignore packets received if interface associated with the incoming
+ address is down or has no carrier.
+
+ Default value is 0. Note that some distributions enable it in startup
+ scripts.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_link_detect(1)
+ """
+ tmp = self.get_interface('link_detect')
+ if tmp == link_filter:
+ return None
+ return self.set_interface('link_detect', link_filter)
+
+ def get_alias(self):
+ """
+ Get interface alias name used by e.g. SNMP
+
+ Example:
+ >>> Interface('eth0').get_alias()
+ 'interface description as set by user'
+ """
+ return self.get_interface('alias')
+
+ def set_alias(self, ifalias=''):
+ """
+ Set interface alias name used by e.g. SNMP
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_alias('VyOS upstream interface')
+
+ to clear alias e.g. delete it use:
+
+ >>> Interface('eth0').set_ifalias('')
+ """
+ tmp = self.get_interface('alias')
+ if tmp == ifalias:
+ return None
+ self.set_interface('alias', ifalias)
+
+ def get_admin_state(self):
+ """
+ Get interface administrative state. Function will return 'up' or 'down'
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_admin_state()
+ 'up'
+ """
+ return self.get_interface('admin_state')
+
+ def set_admin_state(self, state):
+ """
+ Set interface administrative state to be 'up' or 'down'
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_admin_state('down')
+ >>> Interface('eth0').get_admin_state()
+ 'down'
+ """
+ if state == 'up':
+ self._admin_state_down_cnt -= 1
+ if self._admin_state_down_cnt < 1:
+ return self.set_interface('admin_state', state)
+ else:
+ self._admin_state_down_cnt += 1
+ return self.set_interface('admin_state', state)
+
+ def set_path_cost(self, cost):
+ """
+ Set interface path cost, only relevant for STP enabled interfaces
+
+ Example:
+
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_path_cost(4)
+ """
+ self.set_interface('path_cost', cost)
+
+ def set_path_priority(self, priority):
+ """
+ Set interface path priority, only relevant for STP enabled interfaces
+
+ Example:
+
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_path_priority(4)
+ """
+ self.set_interface('path_priority', priority)
+
+ def set_port_isolation(self, on_or_off):
+ """
+ Controls whether a given port will be isolated, which means it will be
+ able to communicate with non-isolated ports only. By default this flag
+ is off.
+
+ Use enable=1 to enable or enable=0 to disable
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth1').set_port_isolation('on')
+ """
+ self.set_interface('bridge_port_isolation', on_or_off)
+
+ def set_proxy_arp(self, enable):
+ """
+ Set per interface proxy ARP configuration
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_proxy_arp(1)
+ """
+ tmp = self.get_interface('proxy_arp')
+ if tmp == enable:
+ return None
+ self.set_interface('proxy_arp', enable)
+
+ def set_proxy_arp_pvlan(self, enable):
+ """
+ Private VLAN proxy arp.
+ Basically allow proxy arp replies back to the same interface
+ (from which the ARP request/solicitation was received).
+
+ This is done to support (ethernet) switch features, like RFC
+ 3069, where the individual ports are NOT allowed to
+ communicate with each other, but they are allowed to talk to
+ the upstream router. As described in RFC 3069, it is possible
+ to allow these hosts to communicate through the upstream
+ router by proxy_arp'ing. Don't need to be used together with
+ proxy_arp.
+
+ This technology is known by different names:
+ In RFC 3069 it is called VLAN Aggregation.
+ Cisco and Allied Telesyn call it Private VLAN.
+ Hewlett-Packard call it Source-Port filtering or port-isolation.
+ Ericsson call it MAC-Forced Forwarding (RFC Draft).
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').set_proxy_arp_pvlan(1)
+ """
+ tmp = self.get_interface('proxy_arp_pvlan')
+ if tmp == enable:
+ return None
+ self.set_interface('proxy_arp_pvlan', enable)
+
+ def get_addr_v4(self):
+ """
+ Retrieve assigned IPv4 addresses from given interface.
+ This is done using the netifaces and ipaddress python modules.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_addr_v4()
+ ['172.16.33.30/24']
+ """
+ ipv4 = []
+ if AF_INET in ifaddresses(self.config['ifname']):
+ for v4_addr in ifaddresses(self.config['ifname'])[AF_INET]:
+ # we need to manually assemble a list of IPv4 address/prefix
+ prefix = '/' + \
+ str(IPv4Network('0.0.0.0/' + v4_addr['netmask']).prefixlen)
+ ipv4.append(v4_addr['addr'] + prefix)
+ return ipv4
+
+ def get_addr_v6(self):
+ """
+ Retrieve assigned IPv6 addresses from given interface.
+ This is done using the netifaces and ipaddress python modules.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_addr_v6()
+ ['fe80::20c:29ff:fe11:a174/64']
+ """
+ ipv6 = []
+ if AF_INET6 in ifaddresses(self.config['ifname']):
+ for v6_addr in ifaddresses(self.config['ifname'])[AF_INET6]:
+ # Note that currently expanded netmasks are not supported. That means
+ # 2001:db00::0/24 is a valid argument while 2001:db00::0/ffff:ff00:: not.
+ # see https://docs.python.org/3/library/ipaddress.html
+ prefix = '/' + v6_addr['netmask'].split('/')[-1]
+
+ # we alsoneed to remove the interface suffix on link local
+ # addresses
+ v6_addr['addr'] = v6_addr['addr'].split('%')[0]
+ ipv6.append(v6_addr['addr'] + prefix)
+ return ipv6
+
+ def get_addr(self):
+ """
+ Retrieve assigned IPv4 and IPv6 addresses from given interface.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_addr()
+ ['172.16.33.30/24', 'fe80::20c:29ff:fe11:a174/64']
+ """
+ return self.get_addr_v4() + self.get_addr_v6()
+
+ def add_addr(self, addr):
+ """
+ Add IP(v6) address to interface. Address is only added if it is not
+ already assigned to that interface. Address format must be validated
+ and compressed/normalized before calling this function.
+
+ addr: can be an IPv4 address, IPv6 address, dhcp or dhcpv6!
+ IPv4: add IPv4 address to interface
+ IPv6: add IPv6 address to interface
+ dhcp: start dhclient (IPv4) on interface
+ dhcpv6: start WIDE DHCPv6 (IPv6) on interface
+
+ Returns False if address is already assigned and wasn't re-added.
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> j = Interface('eth0')
+ >>> j.add_addr('192.0.2.1/24')
+ >>> j.add_addr('2001:db8::ffff/64')
+ >>> j.get_addr()
+ ['192.0.2.1/24', '2001:db8::ffff/64']
+ """
+ # XXX: normalize/compress with ipaddress if calling functions don't?
+ # is subnet mask always passed, and in the same way?
+
+ # do not add same address twice
+ if addr in self._addr:
+ return False
+
+ # get interface network namespace if specified
+ netns = self.config.get('netns', None)
+
+ # add to interface
+ if addr == 'dhcp':
+ self.set_dhcp(True)
+ elif addr == 'dhcpv6':
+ self.set_dhcpv6(True)
+ 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
+
+ # add to cache
+ self._addr.append(addr)
+
+ return True
+
+ def del_addr(self, addr):
+ """
+ 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
+ was used when adding the address.
+
+ addr: can be an IPv4 address, IPv6 address, dhcp or dhcpv6!
+ IPv4: delete IPv4 address from interface
+ IPv6: delete IPv6 address from interface
+ dhcp: stop dhclient (IPv4) on interface
+ dhcpv6: stop dhclient (IPv6) on interface
+
+ Returns False if address isn't already assigned and wasn't deleted.
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> j = Interface('eth0')
+ >>> j.add_addr('2001:db8::ffff/64')
+ >>> j.add_addr('192.0.2.1/24')
+ >>> j.get_addr()
+ ['192.0.2.1/24', '2001:db8::ffff/64']
+ >>> j.del_addr('192.0.2.1/24')
+ >>> j.get_addr()
+ ['2001:db8::ffff/64']
+ """
+ if not addr:
+ raise ValueError()
+
+ # get interface network namespace if specified
+ netns = self.config.get('netns', None)
+
+ # remove from interface
+ if addr == 'dhcp':
+ self.set_dhcp(False)
+ elif addr == 'dhcpv6':
+ self.set_dhcpv6(False)
+ elif is_intf_addr_assigned(self.ifname, addr, netns=netns):
+ netns_cmd = f'ip netns exec {netns}' if netns else ''
+ self._cmd(f'{netns_cmd} ip addr del {addr} dev {self.ifname}')
+ else:
+ return False
+
+ # remove from cache
+ if addr in self._addr:
+ self._addr.remove(addr)
+
+ return True
+
+ def flush_addrs(self):
+ """
+ Flush all addresses from an interface, including DHCP.
+
+ Will raise an exception on error.
+ """
+ # stop DHCP(v6) if running
+ self.set_dhcp(False)
+ self.set_dhcpv6(False)
+
+ netns = get_interface_namespace(self.ifname)
+ netns_cmd = f'ip netns exec {netns}' if netns else ''
+ cmd = f'{netns_cmd} ip addr flush dev {self.ifname}'
+ # flush all addresses
+ self._cmd(cmd)
+
+ def add_to_bridge(self, bridge_dict):
+ """
+ Adds the interface to the bridge with the passed port config.
+
+ Returns False if bridge doesn't exist.
+ """
+
+ # 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)
+
+ # set bridge port path cost
+ if 'cost' in bridge_config:
+ self.set_path_cost(bridge_config['cost'])
+
+ # set bridge port path priority
+ if 'priority' in bridge_config:
+ self.set_path_cost(bridge_config['priority'])
+
+ bridge_vlan_filter = Section.klass(bridge)(bridge, create=True).get_vlan_filter()
+
+ if int(bridge_vlan_filter):
+ cur_vlan_ids = get_vlan_ids(ifname)
+ add_vlan = []
+ native_vlan_id = None
+ allowed_vlan_ids= []
+
+ if 'native_vlan' in bridge_config:
+ vlan_id = bridge_config['native_vlan']
+ add_vlan.append(vlan_id)
+ native_vlan_id = vlan_id
+
+ if 'allowed_vlan' in bridge_config:
+ for vlan in bridge_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)
+
+ # 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'
+ self._cmd(cmd)
+
+ for vlan in allowed_vlan_ids:
+ cmd = f'bridge vlan add dev {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'
+ self._cmd(cmd)
+
+ def set_dhcp(self, enable):
+ """
+ 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'
+
+ # Rendered client configuration files require the apsolute config path
+ self.config['isc_dhclient_dir'] = directories['isc_dhclient_dir']
+
+ # 'up' check is mandatory b/c even if the interface is A/D, as soon as
+ # the DHCP client is started the interface will be placed in u/u state.
+ # This is not what we intended to do when disabling an interface.
+ if enable and 'disable' not in self.config:
+ if dict_search('dhcp_options.host_name', self.config) == None:
+ # read configured system hostname.
+ # maybe change to vyos-hostsd client ???
+ hostname = 'vyos'
+ hostname_file = '/etc/hostname'
+ if os.path.isfile(hostname_file):
+ hostname = read_file(hostname_file)
+ tmp = {'dhcp_options' : { 'host_name' : hostname}}
+ self.config = dict_merge(tmp, self.config)
+
+ render(systemd_override_file, 'dhcp-client/override.conf.j2', self.config)
+ render(dhclient_config_file, 'dhcp-client/ipv4.j2', self.config)
+
+ # Reload systemd unit definitons as some options are dynamically generated
+ self._cmd('systemctl daemon-reload')
+
+ # When the DHCP client is restarted a brief outage will occur, as
+ # the old lease is released a new one is acquired (T4203). We will
+ # only restart DHCP client if it's option changed, or if it's not
+ # running, but it should be running (e.g. on system startup)
+ if '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}')
+ # cleanup old config files
+ for file in [dhclient_config_file, systemd_override_file, dhclient_lease_file]:
+ if os.path.isfile(file):
+ os.remove(file)
+
+ return None
+
+ def set_dhcpv6(self, enable):
+ """
+ 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'
+
+ # Rendered client configuration files require additional settings
+ config = deepcopy(self.config)
+ config['dhcp6_client_dir'] = directories['dhcp6_client_dir']
+ config['dhcp6_script_file'] = script_file
+
+ if enable and 'disable' not in config:
+ render(systemd_override_file, 'dhcp-client/ipv6.override.conf.j2', config)
+ render(config_file, 'dhcp-client/ipv6.j2', config)
+ render(script_file, 'dhcp-client/dhcp6c-script.j2', config, permission=0o755)
+
+ # Reload systemd unit definitons as some options are dynamically generated
+ self._cmd('systemctl daemon-reload')
+
+ # 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}')
+ else:
+ if is_systemd_service_active(systemd_service):
+ self._cmd(f'systemctl stop {systemd_service}')
+ if os.path.isfile(config_file):
+ os.remove(config_file)
+ if os.path.isfile(script_file):
+ os.remove(script_file)
+
+ return None
+
+ def set_mirror_redirect(self):
+ # Please refer to the document for details
+ # - https://man7.org/linux/man-pages/man8/tc.8.html
+ # - https://man7.org/linux/man-pages/man8/tc-mirred.8.html
+ # Depening if we are the source or the target interface of the port
+ # mirror we need to setup some variables.
+
+ # Don't allow for netns yet
+ if 'netns' in self.config:
+ return None
+
+ source_if = self.config['ifname']
+
+ mirror_config = None
+ if 'mirror' in self.config:
+ mirror_config = self.config['mirror']
+ if 'is_mirror_intf' in self.config:
+ source_if = next(iter(self.config['is_mirror_intf']))
+ mirror_config = self.config['is_mirror_intf'][source_if].get('mirror', None)
+
+ redirect_config = None
+
+ # clear existing ingess - ignore errors (e.g. "Error: Cannot find specified
+ # qdisc on specified device") - we simply cleanup all stuff here
+ if not 'traffic_policy' in self.config:
+ self._popen(f'tc qdisc del dev {source_if} parent ffff: 2>/dev/null');
+ self._popen(f'tc qdisc del dev {source_if} parent 1: 2>/dev/null');
+
+ # Apply interface mirror policy
+ if mirror_config:
+ for direction, target_if in mirror_config.items():
+ if direction == 'ingress':
+ handle = 'ffff: ingress'
+ parent = 'ffff:'
+ elif direction == 'egress':
+ handle = '1: root prio'
+ parent = '1:'
+
+ # Mirror egress traffic
+ mirror_cmd = f'tc qdisc add dev {source_if} handle {handle}; '
+ # Export the mirrored traffic to the interface
+ mirror_cmd += f'tc filter add dev {source_if} parent {parent} protocol '\
+ f'all prio 10 u32 match u32 0 0 flowid 1:1 action mirred '\
+ f'egress mirror dev {target_if}'
+ _, err = self._popen(mirror_cmd)
+ if err: print('tc qdisc(filter for mirror port failed')
+
+ # Apply interface traffic redirection policy
+ elif 'redirect' in self.config:
+ _, err = self._popen(f'tc qdisc add dev {source_if} handle ffff: ingress')
+ if err: print(f'tc qdisc add for redirect failed!')
+
+ target_if = self.config['redirect']
+ _, err = self._popen(f'tc filter add dev {source_if} parent ffff: protocol '\
+ f'all prio 10 u32 match u32 0 0 flowid 1:1 action mirred '\
+ f'egress redirect dev {target_if}')
+ if err: print('tc filter add for redirect failed')
+
+ def set_per_client_thread(self, enable):
+ """
+ Per-device control to enable/disable the threaded mode for all the napi
+ instances of the given network device, without the need for a device up/down.
+
+ User sets it to 1 or 0 to enable or disable threaded mode.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('wg1').set_per_client_thread(1)
+ """
+ # In the case of a "virtual" interface like wireguard, the sysfs
+ # node is only created once there is a peer configured. We can now
+ # add a verify() code-path for this or make this dynamic without
+ # nagging the user
+ tmp = self._sysfs_get['per_client_thread']['location']
+ if not os.path.exists(tmp):
+ return None
+
+ tmp = self.get_interface('per_client_thread')
+ if tmp == enable:
+ return None
+ self.set_interface('per_client_thread', enable)
+
+ def set_eapol(self) -> None:
+ """ Take care about EAPoL supplicant daemon """
+
+ # XXX: wpa_supplicant works on the source interface
+ cfg_dir = '/run/wpa_supplicant'
+ wpa_supplicant_conf = f'{cfg_dir}/{self.ifname}.conf'
+ eapol_action='stop'
+
+ if 'eapol' in self.config:
+ # The default is a fallback to hw_id which is not present for any interface
+ # other then an ethernet interface. Thus we emulate hw_id by reading back the
+ # Kernel assigned MAC address
+ if 'hw_id' not in self.config:
+ self.config['hw_id'] = read_file(f'/sys/class/net/{self.ifname}/address')
+ render(wpa_supplicant_conf, 'ethernet/wpa_supplicant.conf.j2', self.config)
+
+ cert_file_path = os.path.join(cfg_dir, f'{self.ifname}_cert.pem')
+ cert_key_path = os.path.join(cfg_dir, f'{self.ifname}_cert.key')
+
+ cert_name = self.config['eapol']['certificate']
+ pki_cert = self.config['pki']['certificate'][cert_name]
+
+ loaded_pki_cert = load_certificate(pki_cert['certificate'])
+ loaded_ca_certs = {load_certificate(c['certificate'])
+ for c in self.config['pki']['ca'].values()} if 'ca' in self.config['pki'] else {}
+
+ cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)
+
+ write_file(cert_file_path,
+ '\n'.join(encode_certificate(c) for c in cert_full_chain))
+ write_file(cert_key_path, wrap_private_key(pki_cert['private']['key']))
+
+ if 'ca_certificate' in self.config['eapol']:
+ ca_cert_file_path = os.path.join(cfg_dir, f'{self.ifname}_ca.pem')
+ ca_chains = []
+
+ for ca_cert_name in self.config['eapol']['ca_certificate']:
+ pki_ca_cert = self.config['pki']['ca'][ca_cert_name]
+ loaded_ca_cert = load_certificate(pki_ca_cert['certificate'])
+ ca_full_chain = find_chain(loaded_ca_cert, loaded_ca_certs)
+ ca_chains.append(
+ '\n'.join(encode_certificate(c) for c in ca_full_chain))
+
+ write_file(ca_cert_file_path, '\n'.join(ca_chains))
+
+ eapol_action='reload-or-restart'
+
+ # start/stop WPA supplicant service
+ self._cmd(f'systemctl {eapol_action} wpa_supplicant-wired@{self.ifname}')
+
+ if 'eapol' not in self.config:
+ # delete configuration on interface removal
+ if os.path.isfile(wpa_supplicant_conf):
+ os.unlink(wpa_supplicant_conf)
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ if self.debug:
+ import pprint
+ pprint.pprint(config)
+
+ # Cache the configuration - it will be reused inside e.g. DHCP handler
+ # XXX: maybe pass the option via __init__ in the future and rename this
+ # method to apply()?
+ self.config = config
+
+ # Change interface MAC address - re-set to real hardware address (hw-id)
+ # if custom mac is removed. Skip if bond member.
+ if 'is_bond_member' not in config:
+ mac = config.get('hw_id')
+ if 'mac' in config:
+ mac = config.get('mac')
+ if mac:
+ self.set_mac(mac)
+
+ # If interface is connected to NETNS we don't have to check all other
+ # settings like MTU/IPv6/sysctl values, etc.
+ # Since the interface is pushed onto a separate logical stack
+ # Configure NETNS
+ if dict_search('netns', config) != None:
+ if not is_netns_interface(self.ifname, self.config['netns']):
+ self.set_netns(config.get('netns', ''))
+ else:
+ self.del_netns(config.get('netns', ''))
+
+ # Update interface description
+ self.set_alias(config.get('description', ''))
+
+ # Ignore link state changes
+ value = '2' if 'disable_link_detect' in config else '1'
+ self.set_link_detect(value)
+
+ # Configure assigned interface IP addresses. No longer
+ # configured addresses will be removed first
+ new_addr = config.get('address', [])
+
+ # always ensure DHCP client is stopped (when not configured explicitly)
+ if 'dhcp' not in new_addr:
+ self.del_addr('dhcp')
+
+ # always ensure DHCPv6 client is stopped (when not configured as client
+ # for IPv6 address or prefix delegation)
+ dhcpv6pd = dict_search('dhcpv6_options.pd', config)
+ dhcpv6pd = dhcpv6pd != None and len(dhcpv6pd) != 0
+ if 'dhcpv6' not in new_addr and not dhcpv6pd:
+ self.del_addr('dhcpv6')
+
+ # determine IP addresses which are assigned to the interface and build a
+ # list of addresses which are no longer in the dict so they can be removed
+ if 'address_old' in config:
+ for addr in list_diff(config['address_old'], new_addr):
+ # we will delete all interface specific IP addresses if they are not
+ # explicitly configured on the CLI
+ if is_ipv6_link_local(addr):
+ eui64 = mac2eui64(self.get_mac(), link_local_prefix)
+ if addr != f'{eui64}/64':
+ self.del_addr(addr)
+ 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.
+ 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('')
+
+ 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('')
+ else:
+ self.set_vrf(config.get('vrf', ''))
+
+ # Add this section after vrf T4331
+ for addr in new_addr:
+ self.add_addr(addr)
+
+ # Configure MSS value for IPv4 TCP connections
+ tmp = dict_search('ip.adjust_mss', config)
+ value = tmp if (tmp != None) else '0'
+ self.set_tcp_ipv4_mss(value)
+
+ # Configure ARP cache timeout in milliseconds - has default value
+ tmp = dict_search('ip.arp_cache_timeout', config)
+ value = tmp if (tmp != None) else '30'
+ self.set_arp_cache_tmo(value)
+
+ # Configure ARP filter configuration
+ tmp = dict_search('ip.disable_arp_filter', config)
+ value = '0' if (tmp != None) else '1'
+ self.set_arp_filter(value)
+
+ # Configure ARP accept
+ tmp = dict_search('ip.enable_arp_accept', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_arp_accept(value)
+
+ # Configure ARP announce
+ tmp = dict_search('ip.enable_arp_announce', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_arp_announce(value)
+
+ # Configure ARP ignore
+ tmp = dict_search('ip.enable_arp_ignore', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_arp_ignore(value)
+
+ # Enable proxy-arp on this interface
+ tmp = dict_search('ip.enable_proxy_arp', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_proxy_arp(value)
+
+ # Enable private VLAN proxy ARP on this interface
+ tmp = dict_search('ip.proxy_arp_pvlan', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_proxy_arp_pvlan(value)
+
+ # IPv4 forwarding
+ tmp = dict_search('ip.disable_forwarding', config)
+ value = '0' if (tmp != None) else '1'
+ self.set_ipv4_forwarding(value)
+
+ # IPv4 directed broadcast forwarding
+ tmp = dict_search('ip.enable_directed_broadcast', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_ipv4_directed_broadcast(value)
+
+ # IPv4 source-validation
+ tmp = dict_search('ip.source_validation', config)
+ value = tmp if (tmp != None) else '0'
+ self.set_ipv4_source_validation(value)
+
+ # IPv6 source-validation
+ tmp = dict_search('ipv6.source_validation', config)
+ value = tmp if (tmp != None) else '0'
+ self.set_ipv6_source_validation(value)
+
+ # MTU - Maximum Transfer Unit has a default value. It must ALWAYS be set
+ # before mangling any IPv6 option. If MTU is less then 1280 IPv6 will be
+ # automatically disabled by the kernel. Also MTU must be increased before
+ # configuring any IPv6 address on the interface.
+ if 'mtu' in config and dict_search('dhcp_options.mtu', config) == None:
+ self.set_mtu(config.get('mtu'))
+
+ # Configure MSS value for IPv6 TCP connections
+ tmp = dict_search('ipv6.adjust_mss', config)
+ value = tmp if (tmp != None) else '0'
+ self.set_tcp_ipv6_mss(value)
+
+ # IPv6 forwarding
+ tmp = dict_search('ipv6.disable_forwarding', config)
+ value = '0' if (tmp != None) else '1'
+ self.set_ipv6_forwarding(value)
+
+ # IPv6 router advertisements
+ tmp = dict_search('ipv6.address.autoconf', config)
+ value = '2' if (tmp != None) else '1'
+ if 'dhcpv6' in new_addr:
+ value = '2'
+ self.set_ipv6_accept_ra(value)
+
+ # IPv6 address autoconfiguration
+ tmp = dict_search('ipv6.address.autoconf', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_ipv6_autoconf(value)
+
+ # Whether to accept IPv6 DAD (Duplicate Address Detection) packets
+ tmp = dict_search('ipv6.accept_dad', config)
+ # Not all interface types got this CLI option, but if they do, there
+ # is an XML defaultValue available
+ if (tmp != None): self.set_ipv6_dad_accept(tmp)
+
+ # IPv6 DAD tries
+ tmp = dict_search('ipv6.dup_addr_detect_transmits', config)
+ # Not all interface types got this CLI option, but if they do, there
+ # is an XML defaultValue available
+ if (tmp != None): self.set_ipv6_dad_messages(tmp)
+
+ # Delete old IPv6 EUI64 addresses before changing MAC
+ for addr in (dict_search('ipv6.address.eui64_old', config) or []):
+ self.del_ipv6_eui64_address(addr)
+
+ # Manage IPv6 link-local addresses
+ if dict_search('ipv6.address.no_default_link_local', config) != None:
+ self.del_ipv6_eui64_address(link_local_prefix)
+ else:
+ self.add_ipv6_eui64_address(link_local_prefix)
+
+ # Add IPv6 EUI-based addresses
+ tmp = dict_search('ipv6.address.eui64', config)
+ if tmp:
+ for addr in tmp:
+ self.add_ipv6_eui64_address(addr)
+
+ # Configure IPv6 base time in milliseconds - has default value
+ tmp = dict_search('ipv6.base_reachable_time', config)
+ value = tmp if (tmp != None) else '30'
+ self.set_ipv6_cache_tmo(value)
+
+ # re-add ourselves to any bridge we might have fallen out of
+ if 'is_bridge_member' in config:
+ tmp = config.get('is_bridge_member')
+ self.add_to_bridge(tmp)
+
+ # configure interface mirror or redirection target
+ self.set_mirror_redirect()
+
+ # enable/disable NAPI threading mode
+ tmp = dict_search('per_client_thread', config)
+ value = '1' if (tmp != None) else '0'
+ self.set_per_client_thread(value)
+
+ # 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
+ # often as 'down'. This is required by some interface implementations
+ # as certain parameters can only be changed when the interface is
+ # in admin-down state. This ensures the link does not flap during
+ # reconfiguration.
+ state = 'down' if 'disable' in config else 'up'
+ self.set_admin_state(state)
+
+ # remove no longer required 802.1ad (Q-in-Q VLANs)
+ ifname = config['ifname']
+ for vif_s_id in config.get('vif_s_remove', {}):
+ vif_s_ifname = f'{ifname}.{vif_s_id}'
+ VLANIf(vif_s_ifname).remove()
+
+ # create/update 802.1ad (Q-in-Q VLANs)
+ for vif_s_id, vif_s_config in config.get('vif_s', {}).items():
+ tmp = deepcopy(VLANIf.get_config())
+ tmp['protocol'] = vif_s_config['protocol']
+ tmp['source_interface'] = ifname
+ tmp['vlan_id'] = vif_s_id
+
+ # It is not possible to change the VLAN encapsulation protocol
+ # "on-the-fly". For this "quirk" we need to actively delete and
+ # re-create the VIF-S interface.
+ vif_s_ifname = f'{ifname}.{vif_s_id}'
+ if self.exists(vif_s_ifname):
+ cur_cfg = get_interface_config(vif_s_ifname)
+ protocol = dict_search('linkinfo.info_data.protocol', cur_cfg).lower()
+ if protocol != vif_s_config['protocol']:
+ VLANIf(vif_s_ifname).remove()
+
+ s_vlan = VLANIf(vif_s_ifname, **tmp)
+ s_vlan.update(vif_s_config)
+
+ # remove no longer required client VLAN (vif-c)
+ for vif_c_id in vif_s_config.get('vif_c_remove', {}):
+ vif_c_ifname = f'{vif_s_ifname}.{vif_c_id}'
+ VLANIf(vif_c_ifname).remove()
+
+ # create/update client VLAN (vif-c) interface
+ for vif_c_id, vif_c_config in vif_s_config.get('vif_c', {}).items():
+ tmp = deepcopy(VLANIf.get_config())
+ tmp['source_interface'] = vif_s_ifname
+ tmp['vlan_id'] = vif_c_id
+
+ vif_c_ifname = f'{vif_s_ifname}.{vif_c_id}'
+ c_vlan = VLANIf(vif_c_ifname, **tmp)
+ c_vlan.update(vif_c_config)
+
+ # remove no longer required 802.1q VLAN interfaces
+ for vif_id in config.get('vif_remove', {}):
+ vif_ifname = f'{ifname}.{vif_id}'
+ VLANIf(vif_ifname).remove()
+
+ # create/update 802.1q VLAN interfaces
+ for vif_id, vif_config in config.get('vif', {}).items():
+ vif_ifname = f'{ifname}.{vif_id}'
+ tmp = deepcopy(VLANIf.get_config())
+ tmp['source_interface'] = ifname
+ tmp['vlan_id'] = vif_id
+
+ # We need to ensure that the string format is consistent, and we need to exclude redundant spaces.
+ sep = ' '
+ if 'egress_qos' in vif_config:
+ # Unwrap strings into arrays
+ egress_qos_array = vif_config['egress_qos'].split()
+ # The split array is spliced according to the fixed format
+ tmp['egress_qos'] = sep.join(egress_qos_array)
+
+ if 'ingress_qos' in vif_config:
+ # Unwrap strings into arrays
+ ingress_qos_array = vif_config['ingress_qos'].split()
+ # The split array is spliced according to the fixed format
+ tmp['ingress_qos'] = sep.join(ingress_qos_array)
+
+ # Since setting the QoS control parameters in the later stage will
+ # not completely delete the old settings,
+ # we still need to delete the VLAN encapsulation interface in order to
+ # ensure that the changed settings are effective.
+ cur_cfg = get_interface_config(vif_ifname)
+ qos_str = ''
+ tmp2 = dict_search('linkinfo.info_data.ingress_qos', cur_cfg)
+ if 'ingress_qos' in tmp and tmp2:
+ for item in tmp2:
+ from_key = item['from']
+ to_key = item['to']
+ qos_str += f'{from_key}:{to_key} '
+ if qos_str != tmp['ingress_qos']:
+ if self.exists(vif_ifname):
+ VLANIf(vif_ifname).remove()
+
+ qos_str = ''
+ tmp2 = dict_search('linkinfo.info_data.egress_qos', cur_cfg)
+ if 'egress_qos' in tmp and tmp2:
+ for item in tmp2:
+ from_key = item['from']
+ to_key = item['to']
+ qos_str += f'{from_key}:{to_key} '
+ if qos_str != tmp['egress_qos']:
+ if self.exists(vif_ifname):
+ VLANIf(vif_ifname).remove()
+
+ vlan = VLANIf(vif_ifname, **tmp)
+ vlan.update(vif_config)
+
+
+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}'):
+ return
+
+ # If source_interface or vlan_id was not explicitly defined (e.g. when
+ # calling VLANIf('eth0.1').remove() we can define source_interface and
+ # vlan_id here, as it's quiet obvious that it would be eth0 in that case.
+ if 'source_interface' not in self.config:
+ self.config['source_interface'] = '.'.join(self.ifname.split('.')[:-1])
+ if 'vlan_id' not in self.config:
+ self.config['vlan_id'] = self.ifname.split('.')[-1]
+
+ cmd = 'ip link add link {source_interface} name {ifname} type vlan id {vlan_id}'
+ if 'protocol' in self.config:
+ cmd += ' protocol {protocol}'
+ if 'ingress_qos' in self.config:
+ cmd += ' ingress-qos-map {ingress_qos}'
+ if 'egress_qos' in self.config:
+ cmd += ' egress-qos-map {egress_qos}'
+
+ 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_admin_state(self, state):
+ """
+ Set interface administrative state to be 'up' or 'down'
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0.10').set_admin_state('down')
+ >>> Interface('eth0.10').get_admin_state()
+ 'down'
+ """
+ # A VLAN interface can only be placed in admin up state when
+ # the lower interface is up, too
+ lower_interface = glob(f'/sys/class/net/{self.ifname}/lower*/flags')[0]
+ with open(lower_interface, 'r') as f:
+ flags = f.read()
+ # If parent is not up - bail out as we can not bring up the VLAN.
+ # Flags are defined in kernel source include/uapi/linux/if.h
+ if not int(flags, 16) & 1:
+ return None
+
+ return super().set_admin_state(state)
diff --git a/python/vyos/ifconfig/l2tpv3.py b/python/vyos/ifconfig/l2tpv3.py
new file mode 100644
index 0000000..c1f2803
--- /dev/null
+++ b/python/vyos/ifconfig/l2tpv3.py
@@ -0,0 +1,113 @@
+# Copyright 2019-2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from time import sleep
+from time import time
+
+from vyos.utils.process import run
+from vyos.ifconfig.interface import Interface
+
+def wait_for_add_l2tpv3(timeout=10, sleep_interval=1, cmd=None):
+ '''
+ In some cases, we need to wait until local address is assigned.
+ And only then can the l2tpv3 tunnel be configured.
+ For example when ipv6 address in tentative state
+ or we wait for some routing daemon for remote address.
+ '''
+ start_time = time()
+ test_command = cmd
+ while True:
+ if (start_time + timeout) < time():
+ return None
+ result = run(test_command)
+ if result == 0:
+ return True
+ sleep(sleep_interval)
+
+@Interface.register
+class L2TPv3If(Interface):
+ """
+ The Linux bonding driver provides a method for aggregating multiple network
+ interfaces into a single logical "bonded" interface. The behavior of the
+ bonded interfaces depends upon the mode; generally speaking, modes provide
+ either hot standby or load balancing services. Additionally, link integrity
+ monitoring may be performed.
+ """
+ iftype = 'l2tp'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'l2tpeth',
+ 'prefixes': ['l2tpeth', ],
+ 'bridgeable': True,
+ }
+ }
+
+ def _create(self):
+ # create tunnel interface
+ cmd = 'ip l2tp add tunnel tunnel_id {tunnel_id}'
+ cmd += ' peer_tunnel_id {peer_tunnel_id}'
+ cmd += ' udp_sport {source_port}'
+ cmd += ' udp_dport {destination_port}'
+ cmd += ' encap {encapsulation}'
+ cmd += ' local {source_address}'
+ cmd += ' remote {remote}'
+ c = cmd.format(**self.config)
+ # wait until the local/remote address is available, but no more 10 sec.
+ wait_for_add_l2tpv3(cmd=c)
+
+ # setup session
+ cmd = 'ip l2tp add session name {ifname}'
+ cmd += ' tunnel_id {tunnel_id}'
+ cmd += ' session_id {session_id}'
+ cmd += ' peer_session_id {peer_session_id}'
+ self._cmd(cmd.format(**self.config))
+
+ # No need for interface shut down. There exist no function to permanently enable tunnel.
+ # But you can disable interface permanently with shutdown/disable command.
+ self.set_admin_state('up')
+
+ def remove(self):
+ """
+ Remove interface from operating system. Removing the interface
+ deconfigures all assigned IP addresses.
+ Example:
+ >>> from vyos.ifconfig import L2TPv3If
+ >>> i = L2TPv3If('l2tpeth0')
+ >>> i.remove()
+ """
+
+ if self.exists(self.ifname):
+ self.set_admin_state('down')
+
+ # remove all assigned IP addresses from interface - this is a bit redundant
+ # as the kernel will remove all addresses on interface deletion
+ self.flush_addrs()
+
+ # remove interface from conntrack VRF interface map, here explicitly and do not
+ # rely on the base class implementation as the interface will
+ # vanish as soon as the l2tp session is deleted
+ self._del_interface_from_ct_iface_map()
+
+ if {'tunnel_id', 'session_id'} <= set(self.config):
+ cmd = 'ip l2tp del session tunnel_id {tunnel_id}'
+ cmd += ' session_id {session_id}'
+ self._cmd(cmd.format(**self.config))
+
+ if 'tunnel_id' in self.config:
+ cmd = 'ip l2tp del tunnel tunnel_id {tunnel_id}'
+ self._cmd(cmd.format(**self.config))
+
+ # No need to call the baseclass as the interface is now already gone
diff --git a/python/vyos/ifconfig/loopback.py b/python/vyos/ifconfig/loopback.py
new file mode 100644
index 0000000..e1d0418
--- /dev/null
+++ b/python/vyos/ifconfig/loopback.py
@@ -0,0 +1,70 @@
+# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class LoopbackIf(Interface):
+ """
+ The loopback device is a special, virtual network interface that your router
+ 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,
+ }
+ }
+
+ def remove(self):
+ """
+ Loopback interface can not be deleted from operating system. We can
+ only remove all assigned IP addresses.
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> i = LoopbackIf('lo').remove()
+ """
+ # remove all assigned IP addresses from interface
+ for addr in self.get_addr():
+ if addr in self._persistent_addresses:
+ # Do not allow deletion of the default loopback addresses as
+ # this will cause weird system behavior like snmp/ssh no longer
+ # operating as expected, see https://vyos.dev/T2034.
+ continue
+
+ self.del_addr(addr)
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ address = config.get('address', [])
+ # We must ensure that the loopback addresses are never deleted from the system
+ for tmp in self._persistent_addresses:
+ if tmp not in address:
+ address.append(tmp)
+
+ # Update IP address entry in our dictionary
+ config.update({'address' : address})
+
+ # call base class
+ super().update(config)
diff --git a/python/vyos/ifconfig/macsec.py b/python/vyos/ifconfig/macsec.py
new file mode 100644
index 0000000..3839058
--- /dev/null
+++ b/python/vyos/ifconfig/macsec.py
@@ -0,0 +1,74 @@
+# Copyright 2020-2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class MACsecIf(Interface):
+ """
+ MACsec is an IEEE standard (IEEE 802.1AE) for MAC security, introduced in
+ 2006. It defines a way to establish a protocol independent connection
+ between two hosts with data confidentiality, authenticity and/or integrity,
+ using GCM-AES-128. MACsec operates on the Ethernet layer and as such is a
+ layer 2 protocol, which means it's designed to secure traffic within a
+ layer 2 network, including DHCP or ARP requests. It does not compete with
+ 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,
+ **{
+ 'section': 'macsec',
+ 'prefixes': ['macsec', ],
+ },
+ }
+
+ def _create(self):
+ """
+ Create MACsec interface in OS kernel. Interface is administrative
+ down by default.
+ """
+
+ # create tunnel interface
+ cmd = 'ip link add link {source_interface} {ifname} type {type}'.format(**self.config)
+ cmd += f' cipher {self.config["security"]["cipher"]}'
+
+ if 'encrypt' in self.config["security"]:
+ cmd += ' encrypt on'
+
+ self._cmd(cmd)
+
+ # Check if using static keys
+ if 'static' in self.config["security"]:
+ # Set static TX key
+ cmd = 'ip macsec add {ifname} tx sa 0 pn 1 on key 00'.format(**self.config)
+ cmd += f' {self.config["security"]["static"]["key"]}'
+ self._cmd(cmd)
+
+ for peer, peer_config in self.config["security"]["static"]["peer"].items():
+ if 'disable' in peer_config:
+ continue
+
+ # Create the address
+ cmd = 'ip macsec add {ifname} rx port 1 address'.format(**self.config)
+ cmd += f' {peer_config["mac"]}'
+ self._cmd(cmd)
+ # Add the encryption key to the address
+ cmd += f' sa 0 pn 1 on key 01 {peer_config["key"]}'
+ self._cmd(cmd)
+
+ # interface is always A/D down. It needs to be enabled explicitly
+ self.set_admin_state('down')
diff --git a/python/vyos/ifconfig/macvlan.py b/python/vyos/ifconfig/macvlan.py
new file mode 100644
index 0000000..2266879
--- /dev/null
+++ b/python/vyos/ifconfig/macvlan.py
@@ -0,0 +1,47 @@
+# Copyright 2019-2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class MACVLANIf(Interface):
+ """
+ Abstraction of a Linux MACvlan interface
+ """
+ iftype = 'macvlan'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'pseudo-ethernet',
+ 'prefixes': ['peth', ],
+ },
+ }
+
+ def _create(self):
+ """
+ Create MACvlan interface in OS kernel. Interface is administrative
+ 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}'
+ 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}'
+ return self._cmd(cmd)
diff --git a/python/vyos/ifconfig/operational.py b/python/vyos/ifconfig/operational.py
new file mode 100644
index 0000000..dc27421
--- /dev/null
+++ b/python/vyos/ifconfig/operational.py
@@ -0,0 +1,180 @@
+# Copyright 2019 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 os
+
+from time import time
+from datetime import datetime
+from functools import reduce
+from tabulate import tabulate
+
+from vyos.ifconfig import Control
+
+class Operational(Control):
+ """
+ A class able to load Interface statistics
+ """
+
+ cache_magic = 'XYZZYX'
+
+ _stat_names = {
+ 'rx': ['bytes', 'packets', 'errors', 'dropped', 'overrun', 'mcast'],
+ 'tx': ['bytes', 'packets', 'errors', 'dropped', 'carrier', 'collisions'],
+ }
+
+ _stats_dir = {
+ 'rx': ['rx_bytes', 'rx_packets', 'rx_errors', 'rx_dropped', 'rx_over_errors', 'multicast'],
+ 'tx': ['tx_bytes', 'tx_packets', 'tx_errors', 'tx_dropped', 'tx_carrier_errors', 'collisions'],
+ }
+
+ # a list made of the content of _stats_dir['rx'] + _stats_dir['tx']
+ _stats_all = reduce(lambda x, y: x+y, _stats_dir.values())
+
+ # this is not an interface but will be able to be controlled like one
+ _sysfs_get = {
+ 'oper_state':{
+ 'location': '/sys/class/net/{ifname}/operstate',
+ },
+ }
+
+
+ @classmethod
+ def cachefile (cls, ifname):
+ # the file where we are saving the counters
+ return f'/var/run/vyatta/{ifname}.stats'
+
+
+ def __init__(self, ifname):
+ """
+ Operational provide access to the counters of an interface
+ It behave like an interface when it comes to access sysfs
+
+ interface is an instance of the interface for which we want
+ to look at (a subclass of Interface, such as EthernetIf)
+ """
+
+ # add a self.config to minic Interface behaviour and make
+ # coding similar. Perhaps part of class Interface could be
+ # moved into a shared base class.
+ self.config = {
+ 'ifname': ifname,
+ 'create': False,
+ 'debug': False,
+ }
+ super().__init__(**self.config)
+ self.ifname = ifname
+
+ # adds all the counters of an interface
+ for stat in self._stats_all:
+ self._sysfs_get[stat] = {
+ 'location': '/sys/class/net/{ifname}/statistics/'+stat,
+ }
+
+ def get_state(self):
+ """
+ Get interface operational state
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').operational.get_sate()
+ 'up'
+ """
+ # https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-class-net
+ # "unknown", "notpresent", "down", "lowerlayerdown", "testing", "dormant", "up"
+ return self.get_interface('oper_state')
+
+ @classmethod
+ def strtime (cls, epoc):
+ """
+ represent an epoc/unix date in the format used by operation commands
+ """
+ return datetime.fromtimestamp(epoc).strftime("%a %b %d %R:%S %Z %Y")
+
+ def save_counters(self, stats):
+ """
+ record the provided stats to a file keeping vyatta compatibility
+ """
+
+ with open(self.cachefile(self.ifname), 'w') as f:
+ f.write(self.cache_magic)
+ f.write('\n')
+ f.write(str(int(time())))
+ f.write('\n')
+ for k,v in stats.items():
+ if v:
+ f.write(f'{k},{v}\n')
+
+ def load_counters(self):
+ """
+ load the stats from a file keeping vyatta compatibility
+ return a dict() with the value for each interface counter for the cache
+ """
+ ifname = self.config['ifname']
+
+ stats = {}
+ no_stats = {}
+ for name in self._stats_all:
+ stats[name] = 0
+ no_stats[name] = 0
+
+ try:
+ with open(self.cachefile(self.ifname),'r') as f:
+ magic = f.readline().strip()
+ if magic != self.cache_magic:
+ print(f'bad magic {ifname}')
+ return no_stats
+ stats['timestamp'] = f.readline().strip()
+ for line in f:
+ k, v = line.split(',')
+ stats[k] = int(v)
+ return stats
+ except IOError:
+ return no_stats
+
+ def clear_counters(self):
+ stats = self.get_stats()
+ for counter, value in stats.items():
+ stats[counter] = value
+ self.save_counters(stats)
+
+ def reset_counters(self):
+ try:
+ os.remove(self.cachefile(self.ifname))
+ except FileNotFoundError:
+ pass
+
+ def get_stats(self):
+ """ return a dict() with the value for each interface counter """
+ stats = {}
+ for counter in self._stats_all:
+ stats[counter] = int(self.get_interface(counter))
+ return stats
+
+ def formated_stats(self, indent=4):
+ tabs = []
+ stats = self.get_stats()
+ for rtx in self._stats_dir:
+ tabs.append([f'{rtx.upper()}:', ] + [_ for _ in self._stat_names[rtx]])
+ tabs.append(['', ] + [stats[_] for _ in self._stats_dir[rtx]])
+
+ s = tabulate(
+ tabs,
+ stralign="right",
+ numalign="right",
+ tablefmt="plain"
+ )
+
+ p = ' '*indent
+ return f'{p}' + s.replace('\n', f'\n{p}')
diff --git a/python/vyos/ifconfig/pppoe.py b/python/vyos/ifconfig/pppoe.py
new file mode 100644
index 0000000..febf145
--- /dev/null
+++ b/python/vyos/ifconfig/pppoe.py
@@ -0,0 +1,150 @@
+# Copyright 2020-2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+from vyos.utils.assertion import assert_range
+from vyos.utils.network import get_interface_config
+
+@Interface.register
+class PPPoEIf(Interface):
+ iftype = 'pppoe'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'pppoe',
+ 'prefixes': ['pppoe', ],
+ },
+ }
+
+ _sysfs_get = {
+ **Interface._sysfs_get,**{
+ 'accept_ra_defrtr': {
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/accept_ra_defrtr',
+ }
+ }
+ }
+
+ _sysfs_set = {**Interface._sysfs_set, **{
+ 'accept_ra_defrtr': {
+ 'validate': lambda value: assert_range(value, 0, 2),
+ 'location': '/proc/sys/net/ipv6/conf/{ifname}/accept_ra_defrtr',
+ },
+ }}
+
+ def _remove_routes(self, vrf=None):
+ # Always delete default routes when interface is removed
+ vrf_cmd = ''
+ if vrf:
+ vrf_cmd = f'-c "vrf {vrf}"'
+ self._cmd(f'vtysh -c "conf t" {vrf_cmd} -c "no ip route 0.0.0.0/0 {self.ifname} tag 210"')
+ self._cmd(f'vtysh -c "conf t" {vrf_cmd} -c "no ipv6 route ::/0 {self.ifname} tag 210"')
+
+ def remove(self):
+ """
+ Remove interface from operating system. Removing the interface
+ deconfigures all assigned IP addresses and clear possible DHCP(v6)
+ client processes.
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> i = Interface('pppoe0')
+ >>> i.remove()
+ """
+ vrf = None
+ tmp = get_interface_config(self.ifname)
+ if 'master' in tmp:
+ vrf = tmp['master']
+ self._remove_routes(vrf)
+
+ # remove bond master which places members in disabled state
+ super().remove()
+
+ def _create(self):
+ # we can not create this interface as it is managed outside
+ pass
+
+ def _delete(self):
+ # we can not create this interface as it is managed outside
+ pass
+
+ def del_addr(self, addr):
+ # we can not create this interface as it is managed outside
+ pass
+
+ def get_mac(self):
+ """ Get a synthetic MAC address. """
+ return self.get_mac_synthetic()
+
+ def set_accept_ra_defrtr(self, enable):
+ """
+ Learn default router in Router Advertisement.
+ 1: enabled
+ 0: disable
+
+ Example:
+ >>> from vyos.ifconfig import PPPoEIf
+ >>> PPPoEIf('pppoe1').set_accept_ra_defrtr(0)
+ """
+ tmp = self.get_interface('accept_ra_defrtr')
+ if tmp == enable:
+ return None
+ self.set_interface('accept_ra_defrtr', enable)
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ # Cache the configuration - it will be reused inside e.g. DHCP handler
+ # XXX: maybe pass the option via __init__ in the future and rename this
+ # method to apply()?
+ #
+ # We need to copy this from super().update() as we utilize self.set_dhcpv6()
+ # 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']:
+ self.set_dhcpv6(True)
+ else:
+ self.set_dhcpv6(False)
+
+ super().update(config)
+
+ # generate proper configuration string when VRFs are in use
+ vrf = ''
+ if 'vrf' in config:
+ tmp = config['vrf']
+ vrf = f'-c "vrf {tmp}"'
+
+ # learn default router in Router Advertisement.
+ tmp = '0' if 'no_default_route' in config else '1'
+ self.set_accept_ra_defrtr(tmp)
+
+ if 'no_default_route' not in config:
+ # Set default route(s) pointing to PPPoE interface
+ distance = config['default_route_distance']
+ self._cmd(f'vtysh -c "conf t" {vrf} -c "ip route 0.0.0.0/0 {self.ifname} tag 210 {distance}"')
+ if 'ipv6' in config:
+ self._cmd(f'vtysh -c "conf t" {vrf} -c "ipv6 route ::/0 {self.ifname} tag 210 {distance}"')
diff --git a/python/vyos/ifconfig/section.py b/python/vyos/ifconfig/section.py
new file mode 100644
index 0000000..50273cf
--- /dev/null
+++ b/python/vyos/ifconfig/section.py
@@ -0,0 +1,195 @@
+# Copyright 2020 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 re
+import netifaces
+
+
+class Section:
+ # the known interface prefixes
+ _prefixes = {}
+ _classes = []
+
+ # class need to define: definition['prefixes']
+ # the interface prefixes declared by a class used to name interface with
+ # prefix[0-9]*(\.[0-9]+)?(\.[0-9]+)?, such as lo, eth0 or eth0.1.2
+
+ @classmethod
+ def register(cls, klass):
+ """
+ A function to use as decorator the interfaces classes
+ It register the prefix for the interface (eth, dum, vxlan, ...)
+ with the class which can handle it (EthernetIf, DummyIf,VXLANIf, ...)
+ """
+ if not klass.definition.get('prefixes',[]):
+ raise RuntimeError(f'valid interface prefixes not defined for {klass.__name__}')
+
+ cls._classes.append(klass)
+
+ for ifprefix in klass.definition['prefixes']:
+ if ifprefix in cls._prefixes:
+ raise RuntimeError(f'only one class can be registered for prefix "{ifprefix}" type')
+ cls._prefixes[ifprefix] = klass
+
+ return klass
+
+ @classmethod
+ def _basename(cls, name, vlan, vrrp):
+ """
+ remove the number at the end of interface name
+ name: name of the interface
+ vlan: if vlan is True, do not stop at the vlan number
+ """
+ if vrrp:
+ name = re.sub(r'\d(\d|v|\.)*$', '', name)
+ elif vlan:
+ name = re.sub(r'\d(\d|\.)*$', '', name)
+ else:
+ name = re.sub(r'\d+$', '', name)
+ return name
+
+ @classmethod
+ def section(cls, name, vlan=True, vrrp=True):
+ """
+ return the name of a section an interface should be under
+ name: name of the interface (eth0, dum1, ...)
+ vlan: should we try try to remove the VLAN from the number
+ """
+ name = cls._basename(name, vlan, vrrp)
+
+ if name in cls._prefixes:
+ return cls._prefixes[name].definition['section']
+ return ''
+
+ @classmethod
+ def sections(cls):
+ """
+ return all the sections we found under 'set interfaces'
+ """
+ return list(set([cls._prefixes[_].definition['section'] for _ in cls._prefixes]))
+
+ @classmethod
+ def klass(cls, name, vlan=True, vrrp=True):
+ name = cls._basename(name, vlan, vrrp)
+ if name in cls._prefixes:
+ return cls._prefixes[name]
+ raise ValueError(f'No type found for interface name: {name}')
+
+ @classmethod
+ def _intf_under_section (cls,section='',vlan=True):
+ """
+ return a generator with the name of the configured interface
+ which are under a section
+ """
+ interfaces = netifaces.interfaces()
+
+ for ifname in interfaces:
+ ifsection = cls.section(ifname)
+ if not ifsection and not ifname.startswith('vrrp'):
+ continue
+
+ if section and ifsection != section:
+ continue
+
+ if vlan == False and '.' in ifname:
+ continue
+
+ yield ifname
+
+ @classmethod
+ def _sort_interfaces(cls, generator):
+ """
+ return a list of the sorted interface by number, vlan, qinq
+ """
+ def key(ifname):
+ value = 0
+ parts = re.split(r'([^0-9]+)([0-9]+)[.]?([0-9]+)?[.]?([0-9]+)?', ifname)
+ length = len(parts)
+ name = parts[1] if length >= 3 else parts[0]
+ # the +1 makes sure eth0.0.0 after eth0.0
+ number = int(parts[2]) + 1 if length >= 4 and parts[2] is not None else 0
+ vlan = int(parts[3]) + 1 if length >= 5 and parts[3] is not None else 0
+ qinq = int(parts[4]) + 1 if length >= 6 and parts[4] is not None else 0
+
+ # so that "lo" (or short names) are handled (as "loa")
+ for n in (name + 'aaa')[:3]:
+ value *= 100
+ value += (ord(n) - ord('a'))
+ value += number
+ # vlan are 16 bits, so this can not overflow
+ value = (value << 16) + vlan
+ value = (value << 16) + qinq
+ return value
+
+ l = list(generator)
+ l.sort(key=key)
+ return l
+
+ @classmethod
+ def interfaces(cls, section='', vlan=True):
+ """
+ return a list of the name of the configured interface which are under a section
+ if no section is provided, then it returns all configured interfaces.
+ If vlan is True, also Vlan subinterfaces will be returned
+ """
+
+ return cls._sort_interfaces(cls._intf_under_section(section, vlan))
+
+ @classmethod
+ def _intf_with_feature(cls, feature=''):
+ """
+ return a generator with the name of the configured interface which have
+ a particular feature set in their definition such as:
+ bondable, broadcast, bridgeable, ...
+ """
+ for klass in cls._classes:
+ if klass.definition[feature]:
+ yield klass.definition['section']
+
+ @classmethod
+ def feature(cls, feature=''):
+ """
+ return list with the name of the configured interface which have
+ a particular feature set in their definition such as:
+ bondable, broadcast, bridgeable, ...
+ """
+ return list(cls._intf_with_feature(feature))
+
+ @classmethod
+ def reserved(cls):
+ """
+ return list with the interface name prefixes
+ eth, lo, vxlan, dum, ...
+ """
+ return list(cls._prefixes.keys())
+
+ @classmethod
+ def get_config_path(cls, name):
+ """
+ get config path to interface with .vif or .vif-s.vif-c
+ example: eth0.1.2 -> 'ethernet eth0 vif-s 1 vif-c 2'
+ Returns False if interface name is invalid (not found in sections)
+ """
+ sect = cls.section(name)
+ if sect:
+ splinterface = name.split('.')
+ intfpath = f'{sect} {splinterface[0]}'
+ if len(splinterface) == 2:
+ intfpath += f' vif {splinterface[1]}'
+ elif len(splinterface) == 3:
+ intfpath += f' vif-s {splinterface[1]} vif-c {splinterface[2]}'
+ return intfpath
+ else:
+ return False
diff --git a/python/vyos/ifconfig/sstpc.py b/python/vyos/ifconfig/sstpc.py
new file mode 100644
index 0000000..50fc6ee
--- /dev/null
+++ b/python/vyos/ifconfig/sstpc.py
@@ -0,0 +1,40 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class SSTPCIf(Interface):
+ iftype = 'sstpc'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'sstpc',
+ 'prefixes': ['sstpc', ],
+ 'eternal': 'sstpc[0-9]+$',
+ },
+ }
+
+ def _create(self):
+ # we can not create this interface as it is managed outside
+ pass
+
+ def _delete(self):
+ # we can not create this interface as it is managed outside
+ pass
+
+ def get_mac(self):
+ """ Get a synthetic MAC address. """
+ return self.get_mac_synthetic()
diff --git a/python/vyos/ifconfig/tunnel.py b/python/vyos/ifconfig/tunnel.py
new file mode 100644
index 0000000..9ba7b31
--- /dev/null
+++ b/python/vyos/ifconfig/tunnel.py
@@ -0,0 +1,178 @@
+# Copyright 2019-2021 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/>.
+
+# https://developers.redhat.com/blog/2019/05/17/an-introduction-to-linux-virtual-interfaces-tunnels/
+# https://community.hetzner.com/tutorials/linux-setup-gre-tunnel
+
+from vyos.ifconfig.interface import Interface
+from vyos.utils.dict import dict_search
+from vyos.utils.assertion import assert_list
+
+def enable_to_on(value):
+ if value == 'enable':
+ return 'on'
+ if value == 'disable':
+ return 'off'
+ raise ValueError(f'expect enable or disable but got "{value}"')
+
+@Interface.register
+class TunnelIf(Interface):
+ """
+ Tunnel: private base class for tunnels
+ https://git.kernel.org/pub/scm/network/iproute2/iproute2.git/tree/ip/tunnel.c
+ https://git.kernel.org/pub/scm/network/iproute2/iproute2.git/tree/ip/ip6tunnel.c
+ """
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'tunnel',
+ 'prefixes': ['tun',],
+ 'bridgeable': True,
+ },
+ }
+
+ # This table represents a mapping from VyOS internal config dict to
+ # arguments used by iproute2. For more information please refer to:
+ # - https://man7.org/linux/man-pages/man8/ip-link.8.html
+ # - https://man7.org/linux/man-pages/man8/ip-tunnel.8.html
+ mapping = {
+ 'source_address' : 'local',
+ 'source_interface' : 'dev',
+ 'remote' : 'remote',
+ 'parameters.ip.key' : 'key',
+ 'parameters.ip.tos' : 'tos',
+ 'parameters.ip.ttl' : 'ttl',
+ }
+ mapping_ipv4 = {
+ 'parameters.ip.key' : 'key',
+ 'parameters.ip.no_pmtu_discovery' : 'nopmtudisc',
+ 'parameters.ip.ignore_df' : 'ignore-df',
+ 'parameters.ip.tos' : 'tos',
+ 'parameters.ip.ttl' : 'ttl',
+ 'parameters.erspan.direction' : 'erspan_dir',
+ 'parameters.erspan.hw_id' : 'erspan_hwid',
+ 'parameters.erspan.index' : 'erspan',
+ 'parameters.erspan.version' : 'erspan_ver',
+ }
+ mapping_ipv6 = {
+ 'parameters.ipv6.encaplimit' : 'encaplimit',
+ 'parameters.ipv6.flowlabel' : 'flowlabel',
+ 'parameters.ipv6.hoplimit' : 'hoplimit',
+ 'parameters.ipv6.tclass' : 'tclass',
+ }
+
+ # TODO: This is surely used for more than tunnels
+ # TODO: could be refactored elsewhere
+ _command_set = {
+ **Interface._command_set,
+ **{
+ 'multicast': {
+ 'validate': lambda v: assert_list(v, ['enable', 'disable']),
+ 'convert': enable_to_on,
+ 'shellcmd': 'ip link set dev {ifname} multicast {value}',
+ },
+ }
+ }
+
+ def __init__(self, ifname, **kargs):
+ # 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']:
+ # no multicast, ttl or tos for gretap
+ self.definition = {
+ **TunnelIf.definition,
+ **{
+ 'bridgeable': True,
+ },
+ }
+
+ super().__init__(ifname, **kargs)
+
+ def _create(self):
+ if self.config['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre']:
+ mapping = { **self.mapping, **self.mapping_ipv6 }
+ else:
+ mapping = { **self.mapping, **self.mapping_ipv4 }
+
+ cmd = 'ip tunnel add {ifname} mode {encapsulation}'
+ if self.iftype 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']:
+ cmd += ' seq'
+
+ 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
+ # by using isinstance()
+ tmp = dict_search(vyos_key, self.config)
+ if isinstance(tmp, dict):
+ cmd += f' {iproute2_key}'
+ elif tmp != None:
+ cmd += f' {iproute2_key} {tmp}'
+
+ self._cmd(cmd.format(**self.config))
+
+ self.set_admin_state('down')
+
+ def _change_options(self):
+ # gretap interfaces do not support changing any parameter
+ if self.iftype in ['gretap', 'ip6gretap', 'erspan', 'ip6erspan']:
+ return
+
+ if self.config['encapsulation'] in ['ipip6', 'ip6ip6', 'ip6gre']:
+ mapping = { **self.mapping, **self.mapping_ipv6 }
+ else:
+ mapping = { **self.mapping, **self.mapping_ipv4 }
+
+ cmd = 'ip tunnel change {ifname} mode {encapsulation}'
+ 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
+ # by using isinstance()
+ tmp = dict_search(vyos_key, self.config)
+ if isinstance(tmp, dict):
+ cmd += f' {iproute2_key}'
+ elif tmp != None:
+ cmd += f' {iproute2_key} {tmp}'
+
+ self._cmd(cmd.format(**self.config))
+
+ def get_mac(self):
+ """ Get a synthetic MAC address. """
+ return self.get_mac_synthetic()
+
+ def set_multicast(self, enable):
+ """ Change the MULTICAST flag on the device """
+ return self.set_interface('multicast', enable)
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+ # Adjust iproute2 tunnel parameters if necessary
+ self._change_options()
+
+ # IP Multicast
+ tmp = dict_search('enable_multicast', config)
+ value = 'enable' if (tmp != None) else 'disable'
+ self.set_multicast(value)
+
+ # call base class first
+ super().update(config)
diff --git a/python/vyos/ifconfig/veth.py b/python/vyos/ifconfig/veth.py
new file mode 100644
index 0000000..aafbf22
--- /dev/null
+++ b/python/vyos/ifconfig/veth.py
@@ -0,0 +1,54 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+
+@Interface.register
+class VethIf(Interface):
+ """
+ Abstraction of a Linux veth interface
+ """
+ iftype = 'veth'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'virtual-ethernet',
+ 'prefixes': ['veth', ],
+ 'bridgeable': True,
+ },
+ }
+
+ def _create(self):
+ """
+ Create veth interface in OS kernel. Interface is administrative
+ down by default.
+ """
+ # check before create, as we have 2 veth interfaces in our CLI
+ # interface virtual-ethernet veth0 peer-name 'veth1'
+ # interface virtual-ethernet veth1 peer-name 'veth0'
+ #
+ # but iproute2 creates the pair with one command:
+ # ip link add vet0 type veth peer name veth1
+ if self.exists(self.config['peer_name']):
+ return
+
+ # create virtual-ethernet interface
+ cmd = 'ip link add {ifname} type {type}'.format(**self.config)
+ cmd += f' peer name {self.config["peer_name"]}'
+ self._cmd(cmd)
+
+ # interface is always A/D down. It needs to be enabled explicitly
+ self.set_admin_state('down')
diff --git a/python/vyos/ifconfig/vrrp.py b/python/vyos/ifconfig/vrrp.py
new file mode 100644
index 0000000..ee9336d
--- /dev/null
+++ b/python/vyos/ifconfig/vrrp.py
@@ -0,0 +1,156 @@
+# 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
+# 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 os
+import json
+import signal
+
+from time import time
+from tabulate import tabulate
+
+from vyos.configquery import ConfigTreeQuery
+from vyos.utils.convert import seconds_to_human
+from vyos.utils.file import read_file
+from vyos.utils.file import wait_for_file_write_complete
+from vyos.utils.process import process_running
+
+class VRRPError(Exception):
+ pass
+
+class VRRPNoData(VRRPError):
+ pass
+
+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',
+ }
+
+ _signal = {
+ 'state': signal.SIGUSR1,
+ 'stats': signal.SIGUSR2,
+ 'json': signal.SIGRTMIN + 2,
+ }
+
+ _name = {
+ 'state': 'information',
+ 'stats': 'statistics',
+ 'json': 'data',
+ }
+
+ state = {
+ 0: 'INIT',
+ 1: 'BACKUP',
+ 2: 'MASTER',
+ 3: 'FAULT',
+ # UNKNOWN
+ }
+
+ def __init__(self,ifname):
+ self.ifname = ifname
+
+ def enabled(self):
+ return self.ifname in self.active_interfaces()
+
+ @classmethod
+ def active_interfaces(cls):
+ if not os.path.exists(cls.location['pid']):
+ return []
+ data = cls.collect('json')
+ return [group['data']['ifp_ifname'] for group in json.loads(data)]
+
+ @classmethod
+ def decode_state(cls, code):
+ return cls.state.get(code,'UNKNOWN')
+
+ # used in conf mode
+ @classmethod
+ def is_running(cls):
+ if not os.path.exists(cls.location['pid']):
+ return False
+ return process_running(cls.location['pid'])
+
+ @classmethod
+ def collect(cls, what):
+ fname = cls.location[what]
+ 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)
+
+ return read_file(fname)
+ 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)")
+ except Exception:
+ name = cls._name[what]
+ raise VRRPError(f'VRRP {name} is not available')
+ finally:
+ if os.path.exists(fname):
+ os.remove(fname)
+
+ @classmethod
+ def disabled(cls):
+ disabled = []
+ base = ['high-availability', 'vrrp']
+ 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)
+
+ # 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', ''])
+
+ # return list with disabled instances
+ return disabled
+
+ @classmethod
+ def format(cls, data):
+ headers = ["Name", "Interface", "VRID", "State", "Priority", "Last Transition"]
+ groups = []
+
+ data = json.loads(data)
+ for group in data:
+ data = group['data']
+
+ name = data['iname']
+ intf = data['ifp_ifname']
+ vrid = data['vrid']
+ state = cls.decode_state(data["state"])
+ priority = data['effective_priority']
+
+ since = int(time() - float(data['last_transition']))
+ last = seconds_to_human(since)
+
+ groups.append([name, intf, vrid, state, priority, last])
+
+ # add to the active list disabled instances
+ groups.extend(cls.disabled())
+ return(tabulate(groups, headers))
diff --git a/python/vyos/ifconfig/vti.py b/python/vyos/ifconfig/vti.py
new file mode 100644
index 0000000..251cbeb
--- /dev/null
+++ b/python/vyos/ifconfig/vti.py
@@ -0,0 +1,80 @@
+# Copyright 2021-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/>.
+
+from vyos.ifconfig.interface import Interface
+from vyos.utils.dict import dict_search
+from vyos.utils.vti_updown_db import vti_updown_db_exists, open_vti_updown_db_readonly
+
+@Interface.register
+class VTIIf(Interface):
+ iftype = 'vti'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'vti',
+ 'prefixes': ['vti', ],
+ },
+ }
+
+ def __init__(self, ifname, **kwargs):
+ self.bypass_vti_updown_db = kwargs.pop("bypass_vti_updown_db", False)
+ super().__init__(ifname, **kwargs)
+
+ def _create(self):
+ # This table represents a mapping from VyOS internal config dict to
+ # arguments used by iproute2. For more information please refer to:
+ # - https://man7.org/linux/man-pages/man8/ip-link.8.html
+ # - https://man7.org/linux/man-pages/man8/ip-tunnel.8.html
+ mapping = {
+ 'source_interface' : 'dev',
+ }
+ if_id = self.ifname.lstrip('vti')
+ # The key defaults to 0 and will match any policies which similarly do
+ # not have a lookup key configuration - thus we shift the key by one
+ # to also support a vti0 interface
+ if_id = str(int(if_id) +1)
+ cmd = f'ip link add {self.ifname} type xfrm if_id {if_id}'
+ 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
+ # by using isinstance()
+ tmp = dict_search(vyos_key, self.config)
+ if isinstance(tmp, dict):
+ cmd += f' {iproute2_key}'
+ elif tmp != None:
+ cmd += f' {iproute2_key} {tmp}'
+
+ self._cmd(cmd.format(**self.config))
+
+ # interface is always A/D down. It needs to be enabled explicitly
+ self.set_interface('admin_state', 'down')
+
+ def set_admin_state(self, state):
+ """
+ Set interface administrative state to be 'up' or 'down'.
+
+ The interface will only be brought 'up' if ith is attached to an
+ active ipsec site-to-site connection or remote access connection.
+ """
+ if state == 'down' or self.bypass_vti_updown_db:
+ super().set_admin_state(state)
+ elif vti_updown_db_exists():
+ with open_vti_updown_db_readonly() as db:
+ if db.wantsInterfaceUp(self.ifname):
+ super().set_admin_state(state)
+
+ def get_mac(self):
+ """ Get a synthetic MAC address. """
+ return self.get_mac_synthetic()
diff --git a/python/vyos/ifconfig/vtun.py b/python/vyos/ifconfig/vtun.py
new file mode 100644
index 0000000..6fb414e
--- /dev/null
+++ b/python/vyos/ifconfig/vtun.py
@@ -0,0 +1,49 @@
+# Copyright 2020-2021 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class VTunIf(Interface):
+ iftype = 'vtun'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'openvpn',
+ 'prefixes': ['vtun', ],
+ 'bridgeable': True,
+ },
+ }
+
+ def _create(self):
+ """ Depending on OpenVPN operation mode the interface is created
+ immediately (e.g. Server mode) or once the connection to the server is
+ established (client mode). The latter will only be brought up once the
+ server can be reached, thus we might need to create this interface in
+ advance for the service to be operational. """
+ try:
+ cmd = 'openvpn --mktun --dev-type {device_type} --dev {ifname}'.format(**self.config)
+ return self._cmd(cmd)
+ except PermissionError:
+ # interface created by OpenVPN daemon in the meantime ...
+ pass
+
+ def add_addr(self, addr):
+ # IP addresses are managed by OpenVPN daemon
+ pass
+
+ def del_addr(self, addr):
+ # IP addresses are managed by OpenVPN daemon
+ pass
diff --git a/python/vyos/ifconfig/vxlan.py b/python/vyos/ifconfig/vxlan.py
new file mode 100644
index 0000000..1023c58
--- /dev/null
+++ b/python/vyos/ifconfig/vxlan.py
@@ -0,0 +1,211 @@
+# 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
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.configdict import list_diff
+from vyos.ifconfig import Interface
+from vyos.utils.assertion import assert_list
+from vyos.utils.dict import dict_search
+from vyos.utils.network import get_interface_config
+from vyos.utils.network import get_vxlan_vlan_tunnels
+from vyos.utils.network import get_vxlan_vni_filter
+
+@Interface.register
+class VXLANIf(Interface):
+ """
+ The VXLAN protocol is a tunnelling protocol designed to solve the
+ problem of limited VLAN IDs (4096) in IEEE 802.1q. With VXLAN the
+ size of the identifier is expanded to 24 bits (16777216).
+
+ VXLAN is described by IETF RFC 7348, and has been implemented by a
+ number of vendors. The protocol runs over UDP using a single
+ destination port. This document describes the Linux kernel tunnel
+ device, there is also a separate implementation of VXLAN for
+ Openvswitch.
+
+ Unlike most tunnels, a VXLAN is a 1 to N network, not just point to
+ point. A VXLAN device can learn the IP address of the other endpoint
+ either dynamically in a manner similar to a learning bridge, or make
+ use of statically-configured forwarding entries.
+
+ For more information please refer to:
+ https://www.kernel.org/doc/Documentation/networking/vxlan.txt
+ """
+
+ iftype = 'vxlan'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'vxlan',
+ 'prefixes': ['vxlan', ],
+ 'bridgeable': True,
+ }
+ }
+
+ _command_set = {**Interface._command_set, **{
+ 'neigh_suppress': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'shellcmd': 'bridge link set dev {ifname} neigh_suppress {value} learning off',
+ },
+ 'vlan_tunnel': {
+ 'validate': lambda v: assert_list(v, ['on', 'off']),
+ 'shellcmd': 'bridge link set dev {ifname} vlan_tunnel {value}',
+ },
+ }}
+
+ def _create(self):
+ # This table represents a mapping from VyOS internal config dict to
+ # arguments used by iproute2. For more information please refer to:
+ # - https://man7.org/linux/man-pages/man8/ip-link.8.html
+ mapping = {
+ 'group' : 'group',
+ 'gpe' : 'gpe',
+ 'parameters.external' : 'external',
+ 'parameters.ip.df' : 'df',
+ 'parameters.ip.tos' : 'tos',
+ 'parameters.ip.ttl' : 'ttl',
+ 'parameters.ipv6.flowlabel' : 'flowlabel',
+ 'parameters.nolearning' : 'nolearning',
+ 'parameters.vni_filter' : 'vnifilter',
+ 'remote' : 'remote',
+ 'source_address' : 'local',
+ 'source_interface' : 'dev',
+ 'vni' : 'id',
+ }
+
+ # IPv6 flowlabels can only be used on IPv6 tunnels, thus we need to
+ # ensure that at least the first remote IP address is passed to the
+ # tunnel creation command. Subsequent tunnel remote addresses can later
+ # be added to the FDB
+ remote_list = None
+ if 'remote' in self.config:
+ # skip first element as this is already configured as remote
+ remote_list = self.config['remote'][1:]
+ self.config['remote'] = self.config['remote'][0]
+
+ cmd = 'ip link add {ifname} type {type} 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
+ # by using isinstance()
+ tmp = dict_search(vyos_key, self.config)
+ if isinstance(tmp, dict):
+ cmd += f' {iproute2_key}'
+ elif tmp != None:
+ cmd += f' {iproute2_key} {tmp}'
+
+ self._cmd(cmd.format(**self.config))
+ # interface is always A/D down. It needs to be enabled explicitly
+ self.set_admin_state('down')
+
+ # VXLAN tunnel is always recreated on any change - see interfaces_vxlan.py
+ if remote_list:
+ for remote in remote_list:
+ cmd = f'bridge fdb append to 00:00:00:00:00:00 dst {remote} ' \
+ 'port {port} dev {ifname}'
+ self._cmd(cmd.format(**self.config))
+
+ def set_neigh_suppress(self, state):
+ """
+ Controls whether neigh discovery (arp and nd) proxy and suppression
+ is enabled on the port. By default this flag is off.
+ """
+
+ # Determine current OS Kernel neigh_suppress setting - only adjust when needed
+ tmp = get_interface_config(self.ifname)
+ cur_state = 'on' if dict_search(f'linkinfo.info_slave_data.neigh_suppress', tmp) == True else 'off'
+ new_state = 'on' if state else 'off'
+ if cur_state != new_state:
+ self.set_interface('neigh_suppress', state)
+
+ def set_vlan_vni_mapping(self, state):
+ """
+ Controls whether vlan to tunnel mapping is enabled on the port.
+ By default this flag is off.
+ """
+ def range_to_dict(vlan_to_vni):
+ """ Converts dict of ranges to dict """
+ result_dict = {}
+ for vlan, vlan_conf in vlan_to_vni.items():
+ vni = vlan_conf['vni']
+ vlan_range, vni_range = vlan.split('-'), vni.split('-')
+ if len(vlan_range) > 1:
+ vlan_range = range(int(vlan_range[0]), int(vlan_range[1]) + 1)
+ vni_range = range(int(vni_range[0]), int(vni_range[1]) + 1)
+ dict_to_add = {str(k): {'vni': str(v)} for k, v in zip(vlan_range, vni_range)}
+ result_dict.update(dict_to_add)
+ return result_dict
+
+ if not isinstance(state, bool):
+ raise ValueError('Value out of range')
+
+ if 'vlan_to_vni_removed' in self.config:
+ cur_vni_filter = None
+ if dict_search('parameters.vni_filter', self.config) != None:
+ cur_vni_filter = get_vxlan_vni_filter(self.ifname)
+
+ for vlan, vlan_config in range_to_dict(self.config['vlan_to_vni_removed']).items():
+ # If VNI filtering is enabled, remove matching VNI filter
+ if cur_vni_filter != None:
+ vni = vlan_config['vni']
+ if vni in cur_vni_filter:
+ self._cmd(f'bridge vni delete dev {self.ifname} vni {vni}')
+ self._cmd(f'bridge vlan del dev {self.ifname} vid {vlan}')
+
+ # Determine current OS Kernel vlan_tunnel setting - only adjust when needed
+ tmp = get_interface_config(self.ifname)
+ cur_state = 'on' if dict_search(f'linkinfo.info_slave_data.vlan_tunnel', tmp) == True else 'off'
+ new_state = 'on' if state else 'off'
+ if cur_state != new_state:
+ self.set_interface('vlan_tunnel', new_state)
+
+ if 'vlan_to_vni' in self.config:
+ # Determine current OS Kernel configured VLANs
+ vlan_vni_mapping = range_to_dict(self.config['vlan_to_vni'])
+ os_configured_vlan_ids = get_vxlan_vlan_tunnels(self.ifname)
+ add_vlan = list_diff(list(vlan_vni_mapping.keys()), os_configured_vlan_ids)
+
+ for vlan, vlan_config in vlan_vni_mapping.items():
+ # VLAN mapping already exists - skip
+ if vlan not in add_vlan:
+ continue
+
+ vni = vlan_config['vni']
+ # The following commands must be run one after another,
+ # they can not be combined with linux 6.1 and iproute2 6.1
+ self._cmd(f'bridge vlan add dev {self.ifname} vid {vlan}')
+ self._cmd(f'bridge vlan add dev {self.ifname} vid {vlan} tunnel_info id {vni}')
+
+ # If VNI filtering is enabled, install matching VNI filter
+ if dict_search('parameters.vni_filter', self.config) != None:
+ self._cmd(f'bridge vni add dev {self.ifname} vni {vni}')
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ # call base class last
+ super().update(config)
+
+ # Enable/Disable VLAN tunnel mapping
+ # This is only possible after the interface was assigned to the bridge
+ self.set_vlan_vni_mapping(dict_search('vlan_to_vni', config) != None)
+
+ # Enable/Disable neighbor suppression and learning, there is no need to
+ # explicitly "disable" it, as VXLAN interface will be recreated if anything
+ # under "parameters" changes.
+ if dict_search('parameters.neighbor_suppress', config) != None:
+ self.set_neigh_suppress('on')
diff --git a/python/vyos/ifconfig/wireguard.py b/python/vyos/ifconfig/wireguard.py
new file mode 100644
index 0000000..9030b13
--- /dev/null
+++ b/python/vyos/ifconfig/wireguard.py
@@ -0,0 +1,243 @@
+# 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
+# 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 os
+import time
+
+from datetime import timedelta
+from tempfile import NamedTemporaryFile
+
+from hurry.filesize import size
+from hurry.filesize import alternative
+
+from vyos.ifconfig import Interface
+from vyos.ifconfig import Operational
+from vyos.template import is_ipv6
+
+
+class WireGuardOperational(Operational):
+ def _dump(self):
+ """Dump wireguard data in a python friendly way."""
+ last_device = None
+ output = {}
+
+ # Dump wireguard connection data
+ _f = self._cmd('wg show all dump')
+ for line in _f.split('\n'):
+ if not line:
+ # Skip empty lines and last line
+ continue
+ items = line.split('\t')
+
+ if last_device != items[0]:
+ # We are currently entering a new node
+ device, private_key, public_key, listen_port, fw_mark = items
+ last_device = device
+
+ output[device] = {
+ 'private_key': None if private_key == '(none)' else private_key,
+ 'public_key': None if public_key == '(none)' else public_key,
+ 'listen_port': int(listen_port),
+ 'fw_mark': None if fw_mark == 'off' else int(fw_mark),
+ 'peers': {},
+ }
+ else:
+ # We are entering a peer
+ (
+ device,
+ public_key,
+ preshared_key,
+ endpoint,
+ allowed_ips,
+ latest_handshake,
+ transfer_rx,
+ transfer_tx,
+ persistent_keepalive,
+ ) = items
+ if allowed_ips == '(none)':
+ allowed_ips = []
+ else:
+ allowed_ips = allowed_ips.split('\t')
+ output[device]['peers'][public_key] = {
+ 'preshared_key': None if preshared_key == '(none)' else preshared_key,
+ 'endpoint': None if endpoint == '(none)' else endpoint,
+ 'allowed_ips': allowed_ips,
+ 'latest_handshake': None if latest_handshake == '0' else int(latest_handshake),
+ 'transfer_rx': int(transfer_rx),
+ 'transfer_tx': int(transfer_tx),
+ 'persistent_keepalive': None if persistent_keepalive == 'off' else int(persistent_keepalive),
+ }
+ return output
+
+ def show_interface(self):
+ from vyos.config import Config
+
+ c = Config()
+
+ wgdump = self._dump().get(self.config['ifname'], None)
+
+ c.set_level(['interfaces', 'wireguard', self.config['ifname']])
+ description = c.return_effective_value(['description'])
+ ips = c.return_effective_values(['address'])
+
+ 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']):
+ if wgdump['peers']:
+ pubkey = c.return_effective_value(['peer', peer, 'public-key'])
+ if pubkey in wgdump['peers']:
+ wgpeer = wgdump['peers'][pubkey]
+
+ answer += ' peer: {}\n'.format(peer)
+ answer += ' public key: {}\n'.format(pubkey)
+
+ """ figure out if the tunnel is recently active or not """
+ status = 'inactive'
+ if wgpeer['latest_handshake'] is None:
+ """ no handshake ever """
+ status = 'inactive'
+ else:
+ if int(wgpeer['latest_handshake']) > 0:
+ delta = timedelta(
+ seconds=int(time.time() - wgpeer['latest_handshake'])
+ )
+ answer += ' latest handshake: {}\n'.format(delta)
+ if time.time() - int(wgpeer['latest_handshake']) < (60 * 5):
+ """ Five minutes and the tunnel is still active """
+ status = 'active'
+ else:
+ """ it's been longer than 5 minutes """
+ status = 'inactive'
+ elif int(wgpeer['latest_handshake']) == 0:
+ """ no handshake ever """
+ status = 'inactive'
+ answer += ' status: {}\n'.format(status)
+
+ if wgpeer['endpoint'] is not None:
+ answer += ' endpoint: {}\n'.format(wgpeer['endpoint'])
+
+ if wgpeer['allowed_ips'] is not None:
+ answer += ' allowed ips: {}\n'.format(
+ ','.join(wgpeer['allowed_ips']).replace(',', ', ')
+ )
+
+ if wgpeer['transfer_rx'] > 0 or wgpeer['transfer_tx'] > 0:
+ rx_size = size(wgpeer['transfer_rx'], system=alternative)
+ tx_size = size(wgpeer['transfer_tx'], system=alternative)
+ answer += ' transfer: {} received, {} sent\n'.format(
+ rx_size, tx_size
+ )
+
+ if wgpeer['persistent_keepalive'] is not None:
+ answer += ' persistent keepalive: every {} seconds\n'.format(
+ wgpeer['persistent_keepalive']
+ )
+ answer += '\n'
+ return answer
+
+
+@Interface.register
+class WireGuardIf(Interface):
+ OperationalClass = WireGuardOperational
+ iftype = 'wireguard'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'wireguard',
+ 'prefixes': [
+ 'wg',
+ ],
+ 'bridgeable': False,
+ },
+ }
+
+ def get_mac(self):
+ """Get a synthetic MAC address."""
+ return self.get_mac_synthetic()
+
+ def update(self, config):
+ """General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface."""
+
+ tmp_file = NamedTemporaryFile('w')
+ tmp_file.write(config['private_key'])
+ tmp_file.flush()
+
+ # Wireguard base command is identical for every peer
+ base_cmd = 'wg set {ifname}'
+ if 'port' in config:
+ base_cmd += ' listen-port {port}'
+ if 'fwmark' in config:
+ base_cmd += ' fwmark {fwmark}'
+
+ 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:
+ 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)
+
+ # call base class
+ super().update(config)
diff --git a/python/vyos/ifconfig/wireless.py b/python/vyos/ifconfig/wireless.py
new file mode 100644
index 0000000..88eaa77
--- /dev/null
+++ b/python/vyos/ifconfig/wireless.py
@@ -0,0 +1,65 @@
+# Copyright 2020-2021 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class WiFiIf(Interface):
+ """
+ Handle WIFI/WLAN interfaces.
+ """
+ iftype = 'wifi'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'wireless',
+ 'prefixes': ['wlan', ],
+ 'bridgeable': True,
+ }
+ }
+ def _create(self):
+ # all interfaces will be added in monitor mode
+ cmd = 'iw phy {physical_device} interface add {ifname} type monitor'
+ self._cmd(cmd.format(**self.config))
+
+ # wireless interface is administratively down by default
+ self.set_admin_state('down')
+
+ def _delete(self):
+ cmd = 'iw dev {ifname} del' \
+ .format(**self.config)
+ self._cmd(cmd)
+
+ def update(self, config):
+ """ General helper function which works on a dictionary retrived by
+ get_config_dict(). It's main intention is to consolidate the scattered
+ interface setup code and provide a single point of entry when workin
+ on any interface. """
+
+ # We can not call add_to_bridge() until wpa_supplicant is running, thus
+ # we will remove the key from the config dict and react to this special
+ # case in this derived class.
+ # re-add ourselves to any bridge we might have fallen out of
+ bridge_member = None
+ if 'is_bridge_member' in config:
+ bridge_member = config['is_bridge_member']
+ del config['is_bridge_member']
+
+ # call base class first
+ super().update(config)
+
+ # re-add ourselves to any bridge we might have fallen out of
+ if bridge_member:
+ self.add_to_bridge(bridge_member)
diff --git a/python/vyos/ifconfig/wwan.py b/python/vyos/ifconfig/wwan.py
new file mode 100644
index 0000000..845c9be
--- /dev/null
+++ b/python/vyos/ifconfig/wwan.py
@@ -0,0 +1,45 @@
+# Copyright 2021 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.ifconfig.interface import Interface
+
+@Interface.register
+class WWANIf(Interface):
+ iftype = 'wwan'
+ definition = {
+ **Interface.definition,
+ **{
+ 'section': 'wwan',
+ 'prefixes': ['wwan', ],
+ 'eternal': 'wwan[0-9]+$',
+ },
+ }
+
+ def remove(self):
+ """
+ Remove interface from config. Removing the interface deconfigures all
+ assigned IP addresses.
+ Example:
+ >>> from vyos.ifconfig import WWANIf
+ >>> i = WWANIf('wwan0')
+ >>> i.remove()
+ """
+
+ if self.exists(self.ifname):
+ # interface is placed in A/D state when removed from config! It
+ # will remain visible for the operating system.
+ self.set_admin_state('down')
+
+ super().remove()
diff --git a/python/vyos/iflag.py b/python/vyos/iflag.py
new file mode 100644
index 0000000..3ce73c1
--- /dev/null
+++ b/python/vyos/iflag.py
@@ -0,0 +1,36 @@
+# 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
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from enum import IntEnum
+
+class IFlag(IntEnum):
+ """ net/if.h interface flags """
+
+ IFF_UP = 0x1 #: Interface up/down status
+ IFF_BROADCAST = 0x2 #: Broadcast address valid
+ IFF_DEBUG = 0x4, #: Debugging
+ IFF_LOOPBACK = 0x8 #: Is loopback network
+ IFF_POINTOPOINT = 0x10 #: Is point-to-point link
+ IFF_NOTRAILERS = 0x20 #: Avoid use of trailers
+ IFF_RUNNING = 0x40 #: Resources allocated
+ IFF_NOARP = 0x80 #: No address resolution protocol
+ IFF_PROMISC = 0x100 #: Promiscuous mode
+ IFF_ALLMULTI = 0x200 #: Receive all multicast
+ IFF_MASTER = 0x400 #: Load balancer master
+ IFF_SLAVE = 0x800 #: Load balancer slave
+ IFF_MULTICAST = 0x1000 #: Supports multicast
+ IFF_PORTSEL = 0x2000 #: Media type adjustable
+ IFF_AUTOMEDIA = 0x4000 #: Automatic media type enabled
+ IFF_DYNAMIC = 0x8000 #: Is a dial-up device with dynamic address
diff --git a/python/vyos/initialsetup.py b/python/vyos/initialsetup.py
new file mode 100644
index 0000000..cb6b9e4
--- /dev/null
+++ b/python/vyos/initialsetup.py
@@ -0,0 +1,72 @@
+# initialsetup -- functions for setting common values in config file,
+# for use in installation and first boot scripts
+#
+# Copyright (C) 2018-2024 VyOS maintainers and contributors
+#
+# 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, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+from vyos.utils.auth import make_password_hash
+from vyos.utils.auth import split_ssh_public_key
+
+def set_interface_address(config, intf, addr, intf_type="ethernet"):
+ config.set(["interfaces", intf_type, intf, "address"], value=addr)
+ config.set_tag(["interfaces", intf_type])
+
+def set_host_name(config, hostname):
+ config.set(["system", "host-name"], value=hostname)
+
+def set_name_servers(config, servers):
+ for s in servers:
+ config.set(["system", "name-server"], replace=False, value=s)
+
+def set_default_gateway(config, gateway):
+ config.set(["protocols", "static", "route", "0.0.0.0/0", "next-hop", gateway])
+ config.set_tag(["protocols", "static", "route"])
+ config.set_tag(["protocols", "static", "route", "0.0.0.0/0", "next-hop"])
+
+def set_user_password(config, user, password):
+ # Make a password hash
+ hash = make_password_hash(password)
+
+ config.set(["system", "login", "user", user, "authentication", "encrypted-password"], value=hash)
+ config.set(["system", "login", "user", user, "authentication", "plaintext-password"], value="")
+
+def disable_user_password(config, user):
+ config.set(["system", "login", "user", user, "authentication", "encrypted-password"], value="!")
+ config.set(["system", "login", "user", user, "authentication", "plaintext-password"], value="")
+
+def set_user_level(config, user, level):
+ config.set(["system", "login", "user", user, "level"], value=level)
+
+def set_user_ssh_key(config, user, key_string):
+ key = split_ssh_public_key(key_string, defaultname=user)
+
+ config.set(["system", "login", "user", user, "authentication", "public-keys", key["name"], "key"], value=key["data"])
+ config.set(["system", "login", "user", user, "authentication", "public-keys", key["name"], "type"], value=key["type"])
+ config.set_tag(["system", "login", "user", user, "authentication", "public-keys"])
+
+def create_user(config, user, password=None, key=None, level="admin"):
+ config.set(["system", "login", "user", user])
+ config.set_tag(["system", "login", "user", user])
+
+ if not key and not password:
+ raise ValueError("Must set at least password or SSH public key")
+
+ if password:
+ set_user_password(config, user, password)
+ else:
+ disable_user_password(config, user)
+
+ if key:
+ set_user_ssh_key(config, user, key)
+
+ set_user_level(config, user, level)
diff --git a/python/vyos/ioctl.py b/python/vyos/ioctl.py
new file mode 100644
index 0000000..51574c1
--- /dev/null
+++ b/python/vyos/ioctl.py
@@ -0,0 +1,35 @@
+# 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
+# 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 os
+import socket
+import fcntl
+import struct
+
+SIOCGIFFLAGS = 0x8913
+
+def get_terminal_size():
+ """ pull the terminal size """
+ """ rows,cols = vyos.ioctl.get_terminal_size() """
+ columns, rows = os.get_terminal_size(0)
+ return (rows,columns)
+
+def get_interface_flags(intf):
+ """ Pull the SIOCGIFFLAGS """
+ nullif = '\0'*256
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ raw = fcntl.ioctl(sock.fileno(), SIOCGIFFLAGS, intf + nullif)
+ flags, = struct.unpack('H', raw[16:18])
+ return flags
diff --git a/python/vyos/ipsec.py b/python/vyos/ipsec.py
new file mode 100644
index 0000000..28f7756
--- /dev/null
+++ b/python/vyos/ipsec.py
@@ -0,0 +1,247 @@
+# 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/>.
+
+# Package to communicate with Strongswan VICI
+
+
+class ViciInitiateError(Exception):
+ """
+ VICI can't initiate a session.
+ """
+
+ pass
+
+
+class ViciCommandError(Exception):
+ """
+ VICI can't execute a command by any reason.
+ """
+
+ pass
+
+
+def get_vici_sas():
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec not initialized')
+ try:
+ sas = list(session.list_sas())
+ return sas
+ except Exception:
+ raise ViciCommandError('Failed to get SAs')
+
+
+def get_vici_connections():
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec not initialized')
+ try:
+ connections = list(session.list_conns())
+ return connections
+ except Exception:
+ raise ViciCommandError('Failed to get connections')
+
+
+def get_vici_sas_by_name(ike_name: str, tunnel: str) -> list:
+ """
+ Find installed SAs by IKE_SA name and/or CHILD_SA name
+ and return list with SASs info.
+ If tunnel is not None return a list contained only
+ CHILD_SAs wich names equal tunnel value.
+ :param ike_name: IKE SA name
+ :type ike_name: str
+ :param tunnel: CHILD SA name
+ :type tunnel: str
+ :return: list of Ordinary Dicts with SASs
+ :rtype: list
+ """
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec not initialized')
+ vici_dict = {}
+ if ike_name:
+ vici_dict['ike'] = ike_name
+ if tunnel:
+ vici_dict['child'] = tunnel
+ try:
+ sas = list(session.list_sas(vici_dict))
+ return sas
+ except Exception:
+ raise ViciCommandError('Failed to get SAs')
+
+
+def get_vici_connection_by_name(ike_name: str) -> list:
+ """
+ Find loaded SAs by IKE_SA name and return list with SASs info
+ :param ike_name: IKE SA name
+ :type ike_name: str
+ :return: list of Ordinary Dicts with SASs
+ :rtype: list
+ """
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec is not initialized')
+ vici_dict = {}
+ if ike_name:
+ vici_dict['ike'] = ike_name
+ try:
+ sas = list(session.list_conns(vici_dict))
+ return sas
+ except Exception:
+ raise ViciCommandError('Failed to get SAs')
+
+
+def terminate_vici_ikeid_list(ike_id_list: list) -> None:
+ """
+ Terminate IKE SAs by their id that contained in the list
+ :param ike_id_list: list of IKE SA id
+ :type ike_id_list: list
+ """
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec is not initialized')
+ try:
+ for ikeid in ike_id_list:
+ session_generator = session.terminate({'ike-id': ikeid, 'timeout': '-1'})
+ # a dummy `for` loop is required because of requirements
+ # from vici. Without a full iteration on the output, the
+ # command to vici may not be executed completely
+ for _ in session_generator:
+ pass
+ except Exception:
+ raise ViciCommandError(f'Failed to terminate SA for IKE ids {ike_id_list}')
+
+
+def terminate_vici_by_name(ike_name: str, child_name: str) -> None:
+ """
+ Terminate IKE SAs by name if CHILD SA name is None.
+ Terminate CHILD SAs by name if CHILD SA name is specified
+ :param ike_name: IKE SA name
+ :type ike_name: str
+ :param child_name: CHILD SA name
+ :type child_name: str
+ """
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec is not initialized')
+ try:
+ vici_dict: dict = {}
+ if ike_name:
+ vici_dict['ike'] = ike_name
+ if child_name:
+ vici_dict['child'] = child_name
+ session_generator = session.terminate(vici_dict)
+ # a dummy `for` loop is required because of requirements
+ # from vici. Without a full iteration on the output, the
+ # command to vici may not be executed completely
+ for _ in session_generator:
+ pass
+ except Exception:
+ if child_name:
+ raise ViciCommandError(f'Failed to terminate SA for IPSEC {child_name}')
+ else:
+ raise ViciCommandError(f'Failed to terminate SA for IKE {ike_name}')
+
+
+def vici_initiate_all_child_sa_by_ike(ike_sa_name: str, child_sa_list: list) -> bool:
+ """
+ Initiate IKE SA with scpecified CHILD_SAs in list
+
+ Args:
+ ike_sa_name (str): an IKE SA connection name
+ child_sa_list (list): a list of child SA names
+
+ Returns:
+ bool: a result of initiation command
+ """
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec is not initialized')
+
+ try:
+ for child_sa_name in child_sa_list:
+ session_generator = session.initiate(
+ {'ike': ike_sa_name, 'child': child_sa_name, 'timeout': '-1'}
+ )
+ # a dummy `for` loop is required because of requirements
+ # from vici. Without a full iteration on the output, the
+ # command to vici may not be executed completely
+ for _ in session_generator:
+ pass
+ return True
+ except Exception:
+ raise ViciCommandError(f'Failed to initiate SA for IKE {ike_sa_name}')
+
+
+def vici_initiate(
+ ike_sa_name: str, child_sa_name: str, src_addr: str, dst_addr: str
+) -> bool:
+ """Initiate IKE SA with one child_sa connection with specific peer
+
+ Args:
+ ike_sa_name (str): an IKE SA connection name
+ child_sa_name (str): a child SA profile name
+ src_addr (str): source address
+ dst_addr (str): remote address
+
+ Returns:
+ bool: a result of initiation command
+ """
+ from vici import Session as vici_session
+
+ try:
+ session = vici_session()
+ except Exception:
+ raise ViciInitiateError('IPsec is not initialized')
+
+ try:
+ session_generator = session.initiate(
+ {
+ 'ike': ike_sa_name,
+ 'child': child_sa_name,
+ 'timeout': '-1',
+ 'my-host': src_addr,
+ 'other-host': dst_addr,
+ }
+ )
+ # a dummy `for` loop is required because of requirements
+ # from vici. Without a full iteration on the output, the
+ # command to vici may not be executed completely
+ for _ in session_generator:
+ pass
+ return True
+ except Exception:
+ raise ViciCommandError(f'Failed to initiate SA for IKE {ike_sa_name}')
diff --git a/python/vyos/kea.py b/python/vyos/kea.py
new file mode 100644
index 0000000..addfdba
--- /dev/null
+++ b/python/vyos/kea.py
@@ -0,0 +1,364 @@
+# Copyright 2023-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/>.
+
+import json
+import os
+import socket
+
+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
+from vyos.utils.process import run
+
+kea4_options = {
+ 'name_server': 'domain-name-servers',
+ 'domain_name': 'domain-name',
+ 'domain_search': 'domain-search',
+ 'ntp_server': 'ntp-servers',
+ 'pop_server': 'pop-server',
+ 'smtp_server': 'smtp-server',
+ 'time_server': 'time-servers',
+ 'wins_server': 'netbios-name-servers',
+ 'default_router': 'routers',
+ 'server_identifier': 'dhcp-server-identifier',
+ 'tftp_server_name': 'tftp-server-name',
+ 'bootfile_size': 'boot-size',
+ 'time_offset': 'time-offset',
+ 'wpad_url': 'wpad-url',
+ 'ipv6_only_preferred': 'v6-only-preferred',
+ 'captive_portal': 'v4-captive-portal'
+}
+
+kea6_options = {
+ 'info_refresh_time': 'information-refresh-time',
+ 'name_server': 'dns-servers',
+ 'domain_search': 'domain-search',
+ 'nis_domain': 'nis-domain-name',
+ 'nis_server': 'nis-servers',
+ 'nisplus_domain': 'nisp-domain-name',
+ 'nisplus_server': 'nisp-servers',
+ 'sntp_server': 'sntp-servers',
+ 'captive_portal': 'v6-captive-portal'
+}
+
+kea_ctrl_socket = '/run/kea/dhcp{inet}-ctrl-socket'
+
+def kea_parse_options(config):
+ options = []
+
+ for node, option_name in kea4_options.items():
+ if node not in config:
+ continue
+
+ 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'])})
+
+ if 'ip_forwarding' in config:
+ 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)})
+
+ 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")
+
+ 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')
+ if unifi_controller:
+ 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 = []
+
+ if 'option' in config:
+ out['option-data'] = kea_parse_options(config['option'])
+
+ if 'bootfile_name' in config['option']:
+ out['boot-file-name'] = config['option']['bootfile_name']
+
+ if 'bootfile_server' in config['option']:
+ out['next-server'] = config['option']['bootfile_server']
+
+ if 'ignore_client_id' in config:
+ out['match-client-id'] = False
+
+ if 'lease' in config:
+ out['valid-lifetime'] = int(config['lease'])
+ out['max-valid-lifetime'] = int(config['lease'])
+
+ 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}'
+ }
+
+ if 'option' in range_config:
+ pool['option-data'] = kea_parse_options(range_config['option'])
+
+ if 'bootfile_name' in range_config['option']:
+ pool['boot-file-name'] = range_config['option']['bootfile_name']
+
+ if 'bootfile_server' in range_config['option']:
+ pool['next-server'] = range_config['option']['bootfile_server']
+
+ pools.append(pool)
+ out['pools'] = pools
+
+ if 'static_mapping' in config:
+ reservations = []
+ for host, host_config in config['static_mapping'].items():
+ if 'disable' in host_config:
+ continue
+
+ reservation = {
+ 'hostname': host,
+ }
+
+ if 'mac' in host_config:
+ reservation['hw-address'] = host_config['mac']
+
+ if 'duid' in host_config:
+ reservation['duid'] = host_config['duid']
+
+ if 'ip_address' in host_config:
+ reservation['ip-address'] = host_config['ip_address']
+
+ if 'option' in host_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']
+
+ if 'bootfile_server' in host_config['option']:
+ reservation['next-server'] = host_config['option']['bootfile_server']
+
+ reservations.append(reservation)
+ out['reservations'] = reservations
+
+ return out
+
+def kea6_parse_options(config):
+ options = []
+
+ for node, option_name in kea6_options.items():
+ if node not in config:
+ continue
+
+ value = ", ".join(config[node]) if isinstance(config[node], list) else config[node]
+ options.append({'name': option_name, 'data': value})
+
+ if 'sip_server' in config:
+ sip_servers = config['sip_server']
+
+ addrs = []
+ hosts = []
+
+ for server in sip_servers:
+ if is_ipv6(server):
+ addrs.append(server)
+ else:
+ hosts.append(server)
+
+ if addrs:
+ options.append({'name': 'sip-server-addr', 'data': ", ".join(addrs)})
+
+ if 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})
+
+ return options
+
+def kea6_parse_subnet(subnet, config):
+ out = {'subnet': subnet, 'id': int(config['subnet_id'])}
+
+ if 'option' in config:
+ out['option-data'] = kea6_parse_options(config['option'])
+
+ if 'interface' in config:
+ out['interface'] = config['interface']
+
+ if 'range' in config:
+ pools = []
+ for num, range_config in config['range'].items():
+ pool = {}
+
+ if 'prefix' in range_config:
+ pool['pool'] = range_config['prefix']
+
+ if 'start' in range_config:
+ start = range_config['start']
+ stop = range_config['stop']
+ pool['pool'] = f'{start} - {stop}'
+
+ if 'option' in range_config:
+ pool['option-data'] = kea6_parse_options(range_config['option'])
+
+ pools.append(pool)
+
+ out['pools'] = pools
+
+ if 'prefix_delegation' in config:
+ pd_pools = []
+
+ if 'prefix' in config['prefix_delegation']:
+ for prefix, pd_conf in config['prefix_delegation']['prefix'].items():
+ pd_pool = {
+ 'prefix': prefix,
+ 'prefix-len': int(pd_conf['prefix_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_pools.append(pd_pool)
+
+ out['pd-pools'] = pd_pools
+
+ if 'lease_time' in config:
+ if 'default' in config['lease_time']:
+ out['valid-lifetime'] = int(config['lease_time']['default'])
+ if 'maximum' in config['lease_time']:
+ out['max-valid-lifetime'] = int(config['lease_time']['maximum'])
+ if 'minimum' in config['lease_time']:
+ out['min-valid-lifetime'] = int(config['lease_time']['minimum'])
+
+ if 'static_mapping' in config:
+ reservations = []
+ for host, host_config in config['static_mapping'].items():
+ if 'disable' in host_config:
+ continue
+
+ reservation = {
+ 'hostname': host
+ }
+
+ if 'mac' in host_config:
+ reservation['hw-address'] = host_config['mac']
+
+ if 'duid' in host_config:
+ reservation['duid'] = host_config['duid']
+
+ if 'ipv6_address' in host_config:
+ reservation['ip-addresses'] = [ host_config['ipv6_address'] ]
+
+ if 'ipv6_prefix' in host_config:
+ reservation['prefixes'] = [ host_config['ipv6_prefix'] ]
+
+ if 'option' in host_config:
+ reservation['option-data'] = kea6_parse_options(host_config['option'])
+
+ reservations.append(reservation)
+
+ out['reservations'] = reservations
+
+ return out
+
+def _ctrl_socket_command(inet, command, args=None):
+ path = kea_ctrl_socket.format(inet=inet)
+
+ if not os.path.exists(path):
+ return None
+
+ if file_permissions(path) != '0775':
+ run(f'sudo chmod 775 {path}')
+
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
+ sock.connect(path)
+
+ payload = {'command': command}
+ if args:
+ payload['arguments'] = args
+
+ sock.send(bytes(json.dumps(payload), 'utf-8'))
+ result = b''
+ while True:
+ data = sock.recv(4096)
+ result += data
+ if len(data) < 4096:
+ break
+
+ return json.loads(result.decode('utf-8'))
+
+def kea_get_leases(inet):
+ leases = _ctrl_socket_command(inet, f'lease{inet}-get-all')
+
+ if not leases or 'result' not in leases or leases['result'] != 0:
+ return []
+
+ return leases['arguments']['leases']
+
+def kea_delete_lease(inet, ip_address):
+ args = {'ip-address': ip_address}
+
+ result = _ctrl_socket_command(inet, f'lease{inet}-del', args)
+
+ if result and 'result' in result:
+ return result['result'] == 0
+
+ return False
+
+def kea_get_active_config(inet):
+ config = _ctrl_socket_command(inet, 'config-get')
+
+ if not config or 'result' not in config or config['result'] != 0:
+ return None
+
+ return config
+
+def kea_get_pool_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):
+ return network['name']
+
+ return None
diff --git a/python/vyos/limericks.py b/python/vyos/limericks.py
new file mode 100644
index 0000000..3c67448
--- /dev/null
+++ b/python/vyos/limericks.py
@@ -0,0 +1,72 @@
+# Copyright 2015, 2018 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 random
+
+limericks = [
+
+"""
+A programmer whose name was Searle
+once wrote a long program in Perl.
+Despite very few quirks,
+no one got how it works.
+Not even the interpreter perl(1).
+""",
+
+"""
+There was a young lady of Maine
+who set up IPsec VPN.
+Problems didn't arise
+till other vendors' device
+had to add she to that VPN.
+""",
+
+"""
+One day a programmer from York
+started his own Vyatta fork.
+Though he was a huge geek,
+it still took him a week
+to get the damn build scripts to work.
+""",
+
+"""
+A network admin from Hong Kong
+knew MPPE cipher's not strong.
+But he was behind NAT,
+so he put up with that,
+sad network admin from Hong Kong.
+""",
+
+"""
+A network admin named Drake
+greeted friends with a three-way handshake
+and refused to proceed
+if they didn't complete it,
+that standards-compliant guy Drake.
+""",
+
+"""
+A network admin from Nantucket
+used hierarchy token buckets.
+Bandwidth limits he set
+slowed down his net,
+users drove him away from Nantucket.
+"""
+
+]
+
+
+def get_random():
+ return limericks[random.randint(0, len(limericks) - 1)]
diff --git a/python/vyos/load_config.py b/python/vyos/load_config.py
new file mode 100644
index 0000000..b910a2f
--- /dev/null
+++ b/python/vyos/load_config.py
@@ -0,0 +1,181 @@
+# Copyright 2023-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/>.
+
+"""This module abstracts the loading of a config file into the running
+config. It provides several varieties of loading a config file, from the
+legacy version to the developing versions, as a means of offering
+alternatives for competing use cases, and a base for profiling the
+performance of each.
+"""
+
+import sys
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import Union, Literal, TypeAlias, get_type_hints, get_args
+
+from vyos.config import Config
+from vyos.configtree import ConfigTree, DiffTree
+from vyos.configsource import ConfigSourceSession, VyOSError
+from vyos.migrate import ConfigMigrate, ConfigMigrateError
+from vyos.utils.process import popen, DEVNULL
+
+Variety: TypeAlias = Literal['explicit', 'batch', 'tree', 'legacy']
+ConfigObj: TypeAlias = Union[str, ConfigTree]
+
+thismod = sys.modules[__name__]
+
+class LoadConfigError(Exception):
+ """Raised when an error occurs loading a config file.
+ """
+
+# utility functions
+
+def get_running_config(config: Config) -> ConfigTree:
+ return config.get_config_tree(effective=True)
+
+def get_proposed_config(config_file: str = None) -> ConfigTree:
+ config_str = Path(config_file).read_text()
+ return ConfigTree(config_str)
+
+def check_session(strict: bool, switch: Variety) -> None:
+ """Check if we are in a config session, with no uncommitted changes, if
+ strict. This is not needed for legacy load, as these checks are
+ implicit.
+ """
+
+ if switch == 'legacy':
+ return
+
+ context = ConfigSourceSession()
+
+ if not context.in_session():
+ raise LoadConfigError('not in a config session')
+
+ if strict and context.session_changed():
+ raise LoadConfigError('commit or discard changes before loading config')
+
+# methods to call for each variety
+
+# explicit
+def diff_to_commands(ctree: ConfigTree, ntree: ConfigTree) -> list:
+ """Calculate the diff between the current and proposed config."""
+ # Calculate the diff between the current and new config tree
+ commands = DiffTree(ctree, ntree).to_commands()
+ # on an empty set of 'add' or 'delete' commands, to_commands
+ # returns '\n'; prune below
+ command_list = commands.splitlines()
+ command_list = [c for c in command_list if c]
+ return command_list
+
+def set_commands(cmds: list) -> None:
+ """Set commands in the config session."""
+ if not cmds:
+ print('no commands to set')
+ return
+ error_out = []
+ for op in cmds:
+ out, rc = popen(f'/opt/vyatta/sbin/my_{op}', shell=True, stderr=DEVNULL)
+ if rc != 0:
+ error_out.append(out)
+ continue
+ if error_out:
+ out = '\n'.join(error_out)
+ raise LoadConfigError(out)
+
+# legacy
+class LoadConfig(ConfigSourceSession):
+ """A subclass for calling 'loadFile'.
+ """
+ def load_config(self, file_name):
+ return self._run(['/bin/cli-shell-api','loadFile', file_name])
+
+# end methods to call for each variety
+
+def migrate(config_obj: ConfigObj) -> ConfigObj:
+ """Migrate a config object to the current version.
+ """
+ if isinstance(config_obj, ConfigTree):
+ config_file = NamedTemporaryFile(delete=False).name
+ Path(config_file).write_text(config_obj.to_string())
+ else:
+ config_file = config_obj
+
+ config_migrate = ConfigMigrate(config_file)
+ try:
+ config_migrate.run()
+ except ConfigMigrateError as e:
+ raise LoadConfigError(e) from e
+ else:
+ if isinstance(config_obj, ConfigTree):
+ return ConfigTree(Path(config_file).read_text())
+ return config_file
+ finally:
+ if isinstance(config_obj, ConfigTree):
+ Path(config_file).unlink()
+
+def load_explicit(config_obj: ConfigObj):
+ """Explicit load from file or configtree.
+ """
+ config = Config()
+ ctree = get_running_config(config)
+ if isinstance(config_obj, ConfigTree):
+ ntree = config_obj
+ else:
+ ntree = get_proposed_config(config_obj)
+ # Calculate the diff between the current and proposed config
+ cmds = diff_to_commands(ctree, ntree)
+ # Set the commands in the config session
+ set_commands(cmds)
+
+def load_batch(config_obj: ConfigObj):
+ # requires legacy backend patch
+ raise NotImplementedError('batch loading not implemented')
+
+def load_tree(config_obj: ConfigObj):
+ # requires vyconf backend patch
+ raise NotImplementedError('tree loading not implemented')
+
+def load_legacy(config_obj: ConfigObj):
+ """Legacy load from file or configtree.
+ """
+ if isinstance(config_obj, ConfigTree):
+ config_file = NamedTemporaryFile(delete=False).name
+ Path(config_file).write_text(config_obj.to_string())
+ else:
+ config_file = config_obj
+
+ config = LoadConfig()
+
+ try:
+ config.load_config(config_file)
+ except VyOSError as e:
+ raise LoadConfigError(e) from e
+ finally:
+ if isinstance(config_obj, ConfigTree):
+ Path(config_file).unlink()
+
+def load(config_obj: ConfigObj, strict: bool = True,
+ switch: Variety = 'legacy'):
+ type_hints = get_type_hints(load)
+ switch_choice = get_args(type_hints['switch'])
+ if switch not in switch_choice:
+ raise ValueError(f'invalid switch: {switch}')
+
+ check_session(strict, switch)
+
+ config_obj = migrate(config_obj)
+
+ func = getattr(thismod, f'load_{switch}')
+ func(config_obj)
diff --git a/python/vyos/logger.py b/python/vyos/logger.py
new file mode 100644
index 0000000..f7cc964
--- /dev/null
+++ b/python/vyos/logger.py
@@ -0,0 +1,143 @@
+# Copyright 2020 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/>.
+
+# A wrapper class around logging to make it easier to use
+
+# for a syslog logger:
+# from vyos.logger import syslog
+# syslog.critical('message')
+
+# for a stderr logger:
+# from vyos.logger import stderr
+# stderr.critical('message')
+
+# for a custom logger (syslog and file):
+# from vyos.logger import getLogger
+# combined = getLogger(__name__, syslog=True, stream=sys.stdout, filename='/tmp/test')
+# combined.critical('message')
+
+import sys
+import logging
+import logging.handlers as handlers
+
+TIMED = '%(asctime)s: %(message)s'
+SHORT = '%(filename)s: %(message)s'
+CLEAR = '%(levelname) %(asctime)s %(filename)s: %(message)s'
+
+_levels = {
+ 'CRITICAL': logging.CRITICAL,
+ 'ERROR': logging.CRITICAL,
+ 'WARNING': logging.WARNING,
+ 'INFO': logging.INFO,
+ 'DEBUG': logging.DEBUG,
+ 'NOTSET': logging.NOTSET,
+}
+
+# prevent recreation of already created logger
+_created = {}
+
+def getLogger(name=None, **kwargs):
+ if name in _created:
+ if len(kwargs) == 0:
+ return _created[name]
+ raise ValueError('a logger with the name "{name} already exists')
+
+ logger = logging.getLogger(name)
+ logger.setLevel(_levels[kwargs.get('level', 'DEBUG')])
+
+ if 'address' in kwargs or kwargs.get('syslog', False):
+ logger.addHandler(_syslog(**kwargs))
+ if 'stream' in kwargs:
+ logger.addHandler(_stream(**kwargs))
+ if 'filename' in kwargs:
+ logger.addHandler(_file(**kwargs))
+
+ _created[name] = logger
+ return logger
+
+
+def _syslog(**kwargs):
+ formating = kwargs.get('format', SHORT)
+ handler = handlers.SysLogHandler(
+ address=kwargs.get('address', '/dev/log'),
+ facility=kwargs.get('facility', 'syslog'),
+ )
+ handler.setFormatter(logging.Formatter(formating))
+ return handler
+
+
+def _stream(**kwargs):
+ formating = kwargs.get('format', CLEAR)
+ handler = logging.StreamHandler(
+ stream=kwargs.get('stream', sys.stderr),
+ )
+ handler.setFormatter(logging.Formatter(formating))
+ return handler
+
+
+def _file(**kwargs):
+ formating = kwargs.get('format', CLEAR)
+ handler = handlers.RotatingFileHandler(
+ filename=kwargs.get('filename', 1048576),
+ maxBytes=kwargs.get('maxBytes', 1048576),
+ backupCount=kwargs.get('backupCount', 3),
+ )
+ handler.setFormatter(logging.Formatter(formating))
+ return handler
+
+
+# exported pre-built logger, please keep in mind that the names
+# must be unique otherwise the logger are shared
+
+# a logger for stderr
+stderr = getLogger(
+ 'VyOS Syslog',
+ format=SHORT,
+ stream=sys.stderr,
+ address='/dev/log'
+)
+
+# a logger to syslog
+syslog = getLogger(
+ 'VyOS StdErr',
+ format='%(message)s',
+ address='/dev/log'
+)
+
+
+# testing
+if __name__ == '__main__':
+ # from vyos.logger import getLogger
+ formating = '%(asctime)s (%(filename)s) %(levelname)s: %(message)s'
+
+ # syslog logger
+ # syslog=True if no 'address' field is provided
+ syslog = getLogger(__name__ + '.1', syslog=True, format=formating)
+ syslog.info('syslog test')
+
+ # steam logger
+ stream = getLogger(__name__ + '.2', stream=sys.stdout, level='ERROR')
+ stream.info('steam test')
+
+ # file logger
+ filelog = getLogger(__name__ + '.3', filename='/tmp/test')
+ filelog.info('file test')
+
+ # create a combined logger
+ getLogger('VyOS', syslog=True, stream=sys.stdout, filename='/tmp/test')
+
+ # recover the created logger from name
+ combined = getLogger('VyOS')
+ combined.info('combined test')
diff --git a/python/vyos/migrate.py b/python/vyos/migrate.py
new file mode 100644
index 0000000..9d16136
--- /dev/null
+++ b/python/vyos/migrate.py
@@ -0,0 +1,283 @@
+# 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
+# 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 os
+import re
+import json
+import logging
+from pathlib import Path
+from grp import getgrnam
+
+from vyos.component_version import VersionInfo
+from vyos.component_version import version_info_from_system
+from vyos.component_version import version_info_from_file
+from vyos.component_version import version_info_copy
+from vyos.component_version import version_info_prune_component
+from vyos.compose_config import ComposeConfig
+from vyos.compose_config import ComposeConfigError
+from vyos.configtree import ConfigTree
+from vyos.defaults import directories as default_dir
+from vyos.defaults import component_version_json
+
+
+log_file = Path(default_dir['config']).joinpath('vyos-migrate.log')
+
+class ConfigMigrateError(Exception):
+ """Raised on error in config migration."""
+
+class ConfigMigrate:
+ # pylint: disable=too-many-instance-attributes
+ # the number is reasonable in this case
+ def __init__(self, config_file: str, force=False,
+ output_file: str = None, checkpoint_file: str = None):
+ self.config_file: str = config_file
+ self.force: bool = force
+ self.system_version: VersionInfo = version_info_from_system()
+ self.file_version: VersionInfo = version_info_from_file(self.config_file)
+ self.compose = None
+ self.output_file = output_file
+ self.checkpoint_file = checkpoint_file
+ self.logger = None
+ self.config_modified = True
+
+ if self.file_version is None:
+ raise ConfigMigrateError(f'failed to read config file {self.config_file}')
+
+ def migration_needed(self) -> bool:
+ return self.system_version.component != self.file_version.component
+
+ def release_update_needed(self) -> bool:
+ return self.system_version.release != self.file_version.release
+
+ def syntax_update_needed(self) -> bool:
+ return self.system_version.vintage != self.file_version.vintage
+
+ def update_release(self):
+ """
+ Update config file release version.
+ """
+ self.file_version.update_release(self.system_version.release)
+
+ def update_syntax(self):
+ """
+ Update config file syntax.
+ """
+ self.file_version.update_syntax()
+
+ @staticmethod
+ def normalize_config_body(version_info: VersionInfo):
+ """
+ This is an interim workaround for the cosmetic issue of node
+ ordering when composing operations on the internal config_tree:
+ ordering is performed on parsing, hence was maintained in the old
+ system which would parse/write on each application of a migration
+ script (~200). Here, we will take the cost of one extra parsing to
+ reorder before save, for easier review.
+ """
+ if not version_info.config_body_is_none():
+ ct = ConfigTree(version_info.config_body)
+ version_info.update_config_body(ct.to_string())
+
+ def write_config(self):
+ if self.output_file is not None:
+ config_file = self.output_file
+ else:
+ config_file = self.config_file
+
+ try:
+ self.file_version.write(config_file)
+ except ValueError as e:
+ raise ConfigMigrateError(f'failed to write {config_file}: {e}') from e
+
+ def init_logger(self):
+ self.logger = logging.getLogger(__name__)
+ self.logger.setLevel(logging.DEBUG)
+
+ fh = ConfigMigrate.group_perm_file_handler(log_file,
+ group='vyattacfg',
+ mode='w')
+ fh.setLevel(logging.INFO)
+ fh_formatter = logging.Formatter('%(message)s')
+ fh.setFormatter(fh_formatter)
+ self.logger.addHandler(fh)
+ ch = logging.StreamHandler()
+ ch.setLevel(logging.WARNING)
+ ch_formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
+ ch.setFormatter(ch_formatter)
+ self.logger.addHandler(ch)
+
+ @staticmethod
+ def group_perm_file_handler(filename, group=None, mode='a'):
+ # pylint: disable=consider-using-with
+ if group is None:
+ return logging.FileHandler(filename, mode)
+ gid = getgrnam(group).gr_gid
+ if not os.path.exists(filename):
+ open(filename, 'a').close()
+ os.chown(filename, -1, gid)
+ os.chmod(filename, 0o664)
+ return logging.FileHandler(filename, mode)
+
+ @staticmethod
+ def sort_function():
+ """
+ Define sort function for migration files as tuples (n, m) for file
+ n-to-m.
+ """
+ numbers = re.compile(r'(\d+)')
+ def func(p: Path):
+ parts = numbers.split(p.stem)
+ return list(map(int, parts[1::2]))
+ return func
+
+ @staticmethod
+ def file_ext(file_path: Path) -> str:
+ """
+ Return an identifier from file name for checkpoint file extension.
+ """
+ return f'{file_path.parent.stem}_{file_path.stem}'
+
+ def run_migration_scripts(self):
+ """
+ Call migration files iteratively.
+ """
+ os.environ['VYOS_MIGRATION'] = '1'
+
+ self.init_logger()
+ self.logger.info("List of applied migration modules:")
+
+ components = list(self.system_version.component)
+ components.sort()
+
+ # T4382: 'bgp' needs to follow 'quagga':
+ if 'bgp' in components and 'quagga' in components:
+ components.insert(components.index('quagga'),
+ components.pop(components.index('bgp')))
+
+ revision: VersionInfo = version_info_copy(self.file_version)
+ # prune retired, for example, zone-policy
+ version_info_prune_component(revision, self.system_version)
+
+ migrate_dir = Path(default_dir['migrate'])
+ sort_func = ConfigMigrate.sort_function()
+
+ for key in components:
+ p = migrate_dir.joinpath(key)
+ script_list = list(p.glob('*-to-*'))
+ script_list = sorted(script_list, key=sort_func)
+
+ if not self.file_version.component_is_none() and not self.force:
+ start = self.file_version.component.get(key, 0)
+ script_list = list(filter(lambda x, st=start: sort_func(x)[0] >= st,
+ script_list))
+
+ if not script_list: # no applicable migration scripts
+ revision.update_component(key, self.system_version.component[key])
+ continue
+
+ for file in script_list:
+ f = file.as_posix()
+ self.logger.info(f'applying {f}')
+ try:
+ self.compose.apply_file(f, func_name='migrate')
+ except ComposeConfigError as e:
+ self.logger.error(e)
+ if self.checkpoint_file:
+ check = f'{self.checkpoint_file}_{ConfigMigrate.file_ext(file)}'
+ revision.update_config_body(self.compose.to_string())
+ ConfigMigrate.normalize_config_body(revision)
+ revision.write(check)
+ break
+ else:
+ revision.update_component(key, sort_func(file)[1])
+
+ revision.update_config_body(self.compose.to_string())
+ ConfigMigrate.normalize_config_body(revision)
+ self.file_version = version_info_copy(revision)
+
+ if revision.component != self.system_version.component:
+ raise ConfigMigrateError(f'incomplete migration: check {log_file} for error')
+
+ del os.environ['VYOS_MIGRATION']
+
+ def save_json_record(self):
+ """
+ Write component versions to a json file
+ """
+ version_file = component_version_json
+
+ try:
+ with open(version_file, 'w') as f:
+ f.write(json.dumps(self.system_version.component,
+ indent=2, sort_keys=True))
+ except OSError:
+ pass
+
+ def load_config(self):
+ """
+ Instantiate a ComposeConfig object with the config string.
+ """
+
+ self.compose = ComposeConfig(self.file_version.config_body, self.checkpoint_file)
+
+ def run(self):
+ """
+ If migration needed, run migration scripts and update config file.
+ If only release version update needed, update release version.
+ """
+ # save system component versions in json file for reference
+ self.save_json_record()
+
+ if not self.migration_needed():
+ if self.release_update_needed():
+ self.update_release()
+ self.write_config()
+ else:
+ self.config_modified = False
+ return
+
+ if self.syntax_update_needed():
+ self.update_syntax()
+ self.write_config()
+
+ self.load_config()
+
+ self.run_migration_scripts()
+
+ self.update_release()
+ self.write_config()
+
+ def run_script(self, test_script: str):
+ """
+ Run a single migration script. For testing this simply provides the
+ body for loading and writing the result; the component string is not
+ updated.
+ """
+
+ self.load_config()
+ self.init_logger()
+
+ os.environ['VYOS_MIGRATION'] = '1'
+
+ try:
+ self.compose.apply_file(test_script, func_name='migrate')
+ except ComposeConfigError as e:
+ self.logger.error(f'config-migration error in {test_script}: {e}')
+ else:
+ self.file_version.update_config_body(self.compose.to_string())
+
+ del os.environ['VYOS_MIGRATION']
+
+ self.write_config()
diff --git a/python/vyos/nat.py b/python/vyos/nat.py
new file mode 100644
index 0000000..5fab3c2
--- /dev/null
+++ b/python/vyos/nat.py
@@ -0,0 +1,317 @@
+# Copyright (C) 2022 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/>.
+
+from vyos.template import is_ip_network
+from vyos.utils.dict import dict_search_args
+from vyos.template import bracketize_ipv6
+
+
+def parse_nat_rule(rule_conf, rule_id, nat_type, ipv6=False):
+ output = []
+ ip_prefix = 'ip6' if ipv6 else 'ip'
+ log_prefix = ('DST' if nat_type == 'destination' else 'SRC') + f'-NAT-{rule_id}'
+ log_suffix = ''
+
+ if ipv6:
+ log_prefix = log_prefix.replace("NAT-", "NAT66-")
+
+ ignore_type_addr = False
+ translation_str = ''
+
+ if 'inbound_interface' in rule_conf:
+ operator = ''
+ if 'name' in rule_conf['inbound_interface']:
+ iiface = rule_conf['inbound_interface']['name']
+ if iiface[0] == '!':
+ operator = '!='
+ iiface = iiface[1:]
+ output.append(f'iifname {operator} {{{iiface}}}')
+ else:
+ iiface = rule_conf['inbound_interface']['group']
+ if iiface[0] == '!':
+ operator = '!='
+ iiface = iiface[1:]
+ output.append(f'iifname {operator} @I_{iiface}')
+
+ if 'outbound_interface' in rule_conf:
+ operator = ''
+ if 'name' in rule_conf['outbound_interface']:
+ oiface = rule_conf['outbound_interface']['name']
+ if oiface[0] == '!':
+ operator = '!='
+ oiface = oiface[1:]
+ output.append(f'oifname {operator} {{{oiface}}}')
+ else:
+ oiface = rule_conf['outbound_interface']['group']
+ if oiface[0] == '!':
+ operator = '!='
+ oiface = oiface[1:]
+ output.append(f'oifname {operator} @I_{oiface}')
+
+ if 'protocol' in rule_conf and rule_conf['protocol'] != 'all':
+ protocol = rule_conf['protocol']
+ if protocol == 'tcp_udp':
+ protocol = '{ tcp, udp }'
+ output.append(f'meta l4proto {protocol}')
+
+ if 'packet_type' in rule_conf:
+ output.append(f'pkttype ' + rule_conf['packet_type'])
+
+ if 'exclude' in rule_conf:
+ translation_str = 'return'
+ log_suffix = '-EXCL'
+ elif 'translation' in rule_conf:
+ addr = dict_search_args(rule_conf, 'translation', 'address')
+ port = dict_search_args(rule_conf, 'translation', 'port')
+ if 'redirect' in rule_conf['translation']:
+ translation_output = [f'redirect']
+ redirect_port = dict_search_args(rule_conf, 'translation', 'redirect', 'port')
+ if redirect_port:
+ translation_output.append(f'to {redirect_port}')
+ else:
+
+ translation_prefix = nat_type[:1]
+ translation_output = [f'{translation_prefix}nat']
+
+ if addr and is_ip_network(addr):
+ if not ipv6:
+ map_addr = dict_search_args(rule_conf, nat_type, 'address')
+ if map_addr:
+ if port:
+ translation_output.append(f'{ip_prefix} prefix to {ip_prefix} {translation_prefix}addr map {{ {map_addr} : {addr} . {port} }}')
+ else:
+ translation_output.append(f'{ip_prefix} prefix to {ip_prefix} {translation_prefix}addr map {{ {map_addr} : {addr} }}')
+ ignore_type_addr = True
+ else:
+ translation_output.append(f'prefix to {addr}')
+ else:
+ translation_output.append(f'prefix to {addr}')
+ elif addr == 'masquerade':
+ if port:
+ addr = f'{addr} to '
+ translation_output = [addr]
+ log_suffix = '-MASQ'
+ else:
+ translation_output.append('to')
+ if addr:
+ addr = bracketize_ipv6(addr)
+ translation_output.append(addr)
+
+ options = []
+ addr_mapping = dict_search_args(rule_conf, 'translation', 'options', 'address_mapping')
+ port_mapping = dict_search_args(rule_conf, 'translation', 'options', 'port_mapping')
+ if addr_mapping == 'persistent':
+ options.append('persistent')
+ if port_mapping and port_mapping != 'none':
+ options.append(port_mapping)
+
+ if ((not addr) or (addr and not is_ip_network(addr))) and port:
+ translation_str = " ".join(translation_output) + (f':{port}')
+ else:
+ translation_str = " ".join(translation_output)
+
+ if options:
+ translation_str += f' {",".join(options)}'
+
+ if not ipv6 and 'backend' in rule_conf['load_balance']:
+ hash_input_items = []
+ current_prob = 0
+ nat_map = []
+
+ for trans_addr, addr in rule_conf['load_balance']['backend'].items():
+ item_prob = int(addr['weight'])
+ upper_limit = current_prob + item_prob - 1
+ hash_val = str(current_prob) + '-' + str(upper_limit)
+ element = hash_val + " : " + trans_addr
+ nat_map.append(element)
+ current_prob = current_prob + item_prob
+
+ elements = ' , '.join(nat_map)
+
+ if 'hash' in rule_conf['load_balance'] and 'random' in rule_conf['load_balance']['hash']:
+ translation_str += ' numgen random mod 100 map ' + '{ ' + f'{elements}' + ' }'
+ else:
+ for input_param in rule_conf['load_balance']['hash']:
+ if input_param == 'source-address':
+ param = 'ip saddr'
+ elif input_param == 'destination-address':
+ param = 'ip daddr'
+ elif input_param == 'source-port':
+ prot = rule_conf['protocol']
+ param = f'{prot} sport'
+ elif input_param == 'destination-port':
+ prot = rule_conf['protocol']
+ param = f'{prot} dport'
+ hash_input_items.append(param)
+ hash_input = ' . '.join(hash_input_items)
+ translation_str += f' jhash ' + f'{hash_input}' + ' mod 100 map ' + '{ ' + f'{elements}' + ' }'
+
+ for target in ['source', 'destination']:
+ if target not in rule_conf:
+ continue
+
+ side_conf = rule_conf[target]
+ prefix = target[:1]
+
+ addr = dict_search_args(side_conf, 'address')
+ if addr and not (ignore_type_addr and target == nat_type):
+ operator = ''
+ if addr[:1] == '!':
+ operator = '!='
+ addr = addr[1:]
+ output.append(f'{ip_prefix} {prefix}addr {operator} {addr}')
+
+ addr_prefix = dict_search_args(side_conf, 'prefix')
+ if addr_prefix and ipv6:
+ operator = ''
+ if addr_prefix[:1] == '!':
+ operator = '!='
+ addr_prefix = addr_prefix[1:]
+ output.append(f'ip6 {prefix}addr {operator} {addr_prefix}')
+
+ port = dict_search_args(side_conf, 'port')
+ if port:
+ protocol = rule_conf['protocol']
+ if protocol == 'tcp_udp':
+ protocol = 'th'
+ operator = ''
+ if port[:1] == '!':
+ operator = '!='
+ port = port[1:]
+ output.append(f'{protocol} {prefix}port {operator} {{ {port} }}')
+
+ if 'group' in side_conf:
+ group = side_conf['group']
+ if 'address_group' in group and not (ignore_type_addr and target == nat_type):
+ group_name = group['address_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ if ipv6:
+ output.append(f'{ip_prefix} {prefix}addr {operator} @A6_{group_name}')
+ else:
+ output.append(f'{ip_prefix} {prefix}addr {operator} @A_{group_name}')
+ # Generate firewall group domain-group
+ elif 'domain_group' in group and not (ignore_type_addr and target == nat_type):
+ group_name = group['domain_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'{ip_prefix} {prefix}addr {operator} @D_{group_name}')
+ elif 'network_group' in group and not (ignore_type_addr and target == nat_type):
+ group_name = group['network_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ if ipv6:
+ output.append(f'{ip_prefix} {prefix}addr {operator} @N6_{group_name}')
+ else:
+ output.append(f'{ip_prefix} {prefix}addr {operator} @N_{group_name}')
+ if 'mac_group' in group:
+ group_name = group['mac_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'ether {prefix}addr {operator} @M_{group_name}')
+ if 'port_group' in group:
+ proto = rule_conf['protocol']
+ group_name = group['port_group']
+
+ if proto == 'tcp_udp':
+ proto = 'th'
+
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+
+ output.append(f'{proto} {prefix}port {operator} @P_{group_name}')
+
+ output.append('counter')
+
+ if 'log' in rule_conf:
+ output.append(f'log prefix "[{log_prefix}{log_suffix}]"')
+
+ if translation_str:
+ output.append(translation_str)
+
+ output.append(f'comment "{log_prefix}"')
+
+ return " ".join(output)
+
+def parse_nat_static_rule(rule_conf, rule_id, nat_type):
+ output = []
+ log_prefix = ('STATIC-DST' if nat_type == 'destination' else 'STATIC-SRC') + f'-NAT-{rule_id}'
+ log_suffix = ''
+
+ ignore_type_addr = False
+ translation_str = ''
+
+ if 'inbound_interface' in rule_conf:
+ ifname = rule_conf['inbound_interface']
+ ifprefix = 'i' if nat_type == 'destination' else 'o'
+ if ifname != 'any':
+ output.append(f'{ifprefix}ifname "{ifname}"')
+
+ if 'exclude' in rule_conf:
+ translation_str = 'return'
+ log_suffix = '-EXCL'
+ elif 'translation' in rule_conf:
+ translation_prefix = nat_type[:1]
+ translation_output = [f'{translation_prefix}nat']
+ addr = dict_search_args(rule_conf, 'translation', 'address')
+ map_addr = dict_search_args(rule_conf, 'destination', 'address')
+
+ if nat_type == 'source':
+ addr, map_addr = map_addr, addr # Swap
+
+ if addr and is_ip_network(addr):
+ translation_output.append(f'ip prefix to ip {translation_prefix}addr map {{ {map_addr} : {addr} }}')
+ ignore_type_addr = True
+ elif addr:
+ translation_output.append(f'to {addr}')
+
+ options = []
+ addr_mapping = dict_search_args(rule_conf, 'translation', 'options', 'address_mapping')
+ port_mapping = dict_search_args(rule_conf, 'translation', 'options', 'port_mapping')
+ if addr_mapping == 'persistent':
+ options.append('persistent')
+ if port_mapping and port_mapping != 'none':
+ options.append(port_mapping)
+
+ if options:
+ translation_output.append(",".join(options))
+
+ translation_str = " ".join(translation_output)
+
+ prefix = nat_type[:1]
+ addr = dict_search_args(rule_conf, 'translation' if nat_type == 'source' else nat_type, 'address')
+ if addr and not ignore_type_addr:
+ output.append(f'ip {prefix}addr {addr}')
+
+ output.append('counter')
+
+ if 'log' in rule_conf:
+ output.append(f'log prefix "[{log_prefix}{log_suffix}]"')
+
+ if translation_str:
+ output.append(translation_str)
+
+ output.append(f'comment "{log_prefix}"')
+
+ return " ".join(output)
diff --git a/python/vyos/opmode.py b/python/vyos/opmode.py
new file mode 100644
index 0000000..066c805
--- /dev/null
+++ b/python/vyos/opmode.py
@@ -0,0 +1,285 @@
+# Copyright 2022-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/>.
+
+import re
+import sys
+import typing
+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.
+
+ 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.
+ """
+ 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.
+ """
+ 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.
+ """
+ pass
+
+class PermissionDenied(Error):
+ """ 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.
+ """
+ pass
+
+class UnsupportedOperation(Error):
+ """ 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.
+ """
+ pass
+
+class CommitInProgress(Error):
+ """ 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.
+ """
+ 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):
+ return True
+ else:
+ return False
+
+def _capture_output(name):
+ if re.match(r"^(show|generate)", name):
+ return True
+ else:
+ return False
+
+def _get_op_mode_functions(module):
+ from inspect import getmembers, isfunction
+
+ # Get all functions in that module
+ funcs = getmembers(module, isfunction)
+
+ # getmembers returns (name, func) tuples
+ funcs = list(filter(lambda ft: _is_op_mode_function_name(ft[0]), funcs))
+
+ funcs_dict = {}
+ 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):
+ 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
+
+ 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)
+
+ if typing.get_origin(t) == typing.Literal:
+ return True
+
+ return False
+
+def _get_literal_values(t):
+ """ Returns the tuple of allowed values for a Literal type
+ """
+ if not _is_literal_type(t):
+ return tuple()
+ if _is_optional_type(t):
+ t = _get_arg_type(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)
+ name = str(name)
+
+ # Replace all separators with underscores
+ name = re.sub(r'(\s|[\(\)\[\]\{\}\-\.\,:\"\'\`])+', '_', name)
+
+ # Replace specific characters with textual descriptions
+ name = re.sub(r'@', '_at_', name)
+ name = re.sub(r'%', '_percentage_', name)
+ name = re.sub(r'~', '_tilde_', name)
+
+ # Force all letters to lowercase
+ name = name.lower()
+
+ # Remove leading and trailing underscores, if any
+ name = re.sub(r'(^(_+)(?=[^_])|_+$)', '', name)
+
+ # Ensure there are only single underscores
+ name = re.sub(r'_+', '_', name)
+
+ return name
+
+def _normalize_dict_field_names(old_dict):
+ new_dict = {}
+
+ for key in old_dict:
+ new_key = _normalize_field_name(key)
+ new_dict[new_key] = _normalize_field_names(old_dict[key])
+
+ # Sanity check
+ if len(old_dict) != len(new_dict):
+ 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)
+ elif isinstance(value, list):
+ return list(map(lambda v: _normalize_field_names(v), 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")
+
+ for function_name in functions:
+ 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:
+ del type_hints['return']
+ for opt in type_hints:
+ th = type_hints[opt]
+
+ # Function argument names use underscores as separators
+ # but command-line options should use hyphens
+ # 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')
+ else:
+ if _is_optional_type(th):
+ if _is_literal_type(th):
+ 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)
+ else:
+ if _is_literal_type(th):
+ 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)
+
+ # 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!")
+ parser.print_usage()
+ sys.exit(1)
+
+ 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"]
+
+ # 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 _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"]:
+ 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}")
+ res = decamelize(res)
+ res = _normalize_field_names(res)
+ from json import dumps
+ return dumps(res, indent=4)
+ else:
+ # Other functions should not return anything,
+ # although they may print their own warnings or status messages
+ func(**args)
diff --git a/python/vyos/pki.py b/python/vyos/pki.py
new file mode 100644
index 0000000..5a0e2dd
--- /dev/null
+++ b/python/vyos/pki.py
@@ -0,0 +1,453 @@
+# Copyright (C) 2023-2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# 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 datetime
+import ipaddress
+
+from cryptography import x509
+from cryptography.exceptions import InvalidSignature
+from cryptography.x509.extensions import ExtensionNotFound
+from cryptography.x509.oid import NameOID
+from cryptography.x509.oid import ExtendedKeyUsageOID
+from cryptography.x509.oid import ExtensionOID
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import dh
+from cryptography.hazmat.primitives.asymmetric import dsa
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.asymmetric import padding
+from cryptography.hazmat.primitives.asymmetric import rsa
+
+CERT_BEGIN='-----BEGIN CERTIFICATE-----\n'
+CERT_END='\n-----END CERTIFICATE-----'
+KEY_BEGIN='-----BEGIN PRIVATE KEY-----\n'
+KEY_END='\n-----END 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'
+KEY_PUB_END='\n-----END PUBLIC KEY-----'
+CRL_BEGIN='-----BEGIN X509 CRL-----\n'
+CRL_END='\n-----END X509 CRL-----'
+CSR_BEGIN='-----BEGIN CERTIFICATE REQUEST-----\n'
+CSR_END='\n-----END CERTIFICATE REQUEST-----'
+DH_BEGIN='-----BEGIN DH PARAMETERS-----\n'
+DH_END='\n-----END DH PARAMETERS-----'
+OVPN_BEGIN = '-----BEGIN OpenVPN Static key V{0}-----\n'
+OVPN_END = '\n-----END OpenVPN Static key V{0}-----'
+OPENSSH_KEY_BEGIN='-----BEGIN OPENSSH PRIVATE KEY-----\n'
+OPENSSH_KEY_END='\n-----END OPENSSH PRIVATE KEY-----'
+
+# Print functions
+
+encoding_map = {
+ 'PEM': serialization.Encoding.PEM,
+ 'OpenSSH': serialization.Encoding.OpenSSH
+}
+
+public_format_map = {
+ 'SubjectPublicKeyInfo': serialization.PublicFormat.SubjectPublicKeyInfo,
+ 'OpenSSH': serialization.PublicFormat.OpenSSH
+}
+
+private_format_map = {
+ 'PKCS8': serialization.PrivateFormat.PKCS8,
+ 'OpenSSH': serialization.PrivateFormat.OpenSSH
+}
+
+hash_map = {
+ 'sha256': hashes.SHA256,
+ 'sha384': hashes.SHA384,
+ 'sha512': hashes.SHA512,
+}
+
+def get_certificate_fingerprint(cert, hash):
+ hash_algorithm = hash_map[hash]()
+ fp = cert.fingerprint(hash_algorithm)
+
+ return fp.hex(':').upper()
+
+def encode_certificate(cert):
+ return cert.public_bytes(encoding=serialization.Encoding.PEM).decode('utf-8')
+
+def encode_public_key(cert, encoding='PEM', key_format='SubjectPublicKeyInfo'):
+ if encoding not in encoding_map:
+ encoding = 'PEM'
+ if key_format not in public_format_map:
+ key_format = 'SubjectPublicKeyInfo'
+ return cert.public_bytes(
+ encoding=encoding_map[encoding],
+ format=public_format_map[key_format]).decode('utf-8')
+
+def encode_private_key(private_key, encoding='PEM', key_format='PKCS8', passphrase=None):
+ if encoding not in encoding_map:
+ encoding = 'PEM'
+ if key_format not in private_format_map:
+ key_format = 'PKCS8'
+ encryption = serialization.NoEncryption() if not passphrase else serialization.BestAvailableEncryption(bytes(passphrase, 'utf-8'))
+ return private_key.private_bytes(
+ encoding=encoding_map[encoding],
+ format=private_format_map[key_format],
+ encryption_algorithm=encryption).decode('utf-8')
+
+def encode_dh_parameters(dh_parameters):
+ return dh_parameters.parameter_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.ParameterFormat.PKCS3).decode('utf-8')
+
+# EC Helper
+
+def get_elliptic_curve(size):
+ curve_func = None
+ name = f'SECP{size}R1'
+ if hasattr(ec, name):
+ curve_func = getattr(ec, name)
+ else:
+ curve_func = ec.SECP256R1() # Default to SECP256R1
+ return curve_func()
+
+# Creation functions
+
+def create_private_key(key_type, key_size=None):
+ private_key = None
+ if key_type == 'rsa':
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
+ elif key_type == 'dsa':
+ private_key = dsa.generate_private_key(key_size=key_size)
+ elif key_type == 'ec':
+ curve = get_elliptic_curve(key_size)
+ private_key = ec.generate_private_key(curve)
+ return private_key
+
+def create_certificate_request(subject, private_key, subject_alt_names=[]):
+ subject_obj = x509.Name([
+ x509.NameAttribute(NameOID.COUNTRY_NAME, subject['country']),
+ x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, subject['state']),
+ x509.NameAttribute(NameOID.LOCALITY_NAME, subject['locality']),
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, subject['organization']),
+ x509.NameAttribute(NameOID.COMMON_NAME, subject['common_name'])])
+
+ builder = x509.CertificateSigningRequestBuilder() \
+ .subject_name(subject_obj)
+
+ if subject_alt_names:
+ alt_names = []
+ for obj in subject_alt_names:
+ if isinstance(obj, ipaddress.IPv4Address) or isinstance(obj, ipaddress.IPv6Address):
+ alt_names.append(x509.IPAddress(obj))
+ elif isinstance(obj, str):
+ alt_names.append(x509.RFC822Name(obj) if '@' in obj else x509.DNSName(obj))
+ if alt_names:
+ builder = builder.add_extension(x509.SubjectAlternativeName(alt_names), critical=False)
+
+ return builder.sign(private_key, hashes.SHA256())
+
+def add_key_identifier(ca_cert):
+ try:
+ ski_ext = ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier)
+ return x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski_ext.value)
+ except:
+ return x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_cert.public_key())
+
+def create_certificate(cert_req, ca_cert, ca_private_key, valid_days=365, cert_type='server', is_ca=False, is_sub_ca=False):
+ ext_key_usage = []
+ if is_ca:
+ ext_key_usage = [ExtendedKeyUsageOID.CLIENT_AUTH, ExtendedKeyUsageOID.SERVER_AUTH]
+ elif cert_type == 'client':
+ ext_key_usage = [ExtendedKeyUsageOID.CLIENT_AUTH]
+ elif cert_type == 'server':
+ ext_key_usage = [ExtendedKeyUsageOID.SERVER_AUTH]
+
+ builder = x509.CertificateBuilder() \
+ .subject_name(cert_req.subject) \
+ .issuer_name(ca_cert.subject) \
+ .public_key(cert_req.public_key()) \
+ .serial_number(x509.random_serial_number()) \
+ .not_valid_before(datetime.datetime.utcnow()) \
+ .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=int(valid_days)))
+
+ builder = builder.add_extension(x509.BasicConstraints(ca=is_ca, path_length=0 if is_sub_ca else None), critical=True)
+ builder = builder.add_extension(x509.KeyUsage(
+ digital_signature=True,
+ content_commitment=False,
+ key_encipherment=False,
+ data_encipherment=False,
+ key_agreement=False,
+ key_cert_sign=is_ca,
+ crl_sign=is_ca,
+ encipher_only=False,
+ decipher_only=False), critical=True)
+ builder = builder.add_extension(x509.ExtendedKeyUsage(ext_key_usage), critical=False)
+ builder = builder.add_extension(x509.SubjectKeyIdentifier.from_public_key(cert_req.public_key()), critical=False)
+
+ if not is_ca or is_sub_ca:
+ builder = builder.add_extension(add_key_identifier(ca_cert), critical=False)
+
+ for ext in cert_req.extensions:
+ builder = builder.add_extension(ext.value, critical=False)
+
+ return builder.sign(ca_private_key, hashes.SHA256())
+
+def create_certificate_revocation_list(ca_cert, ca_private_key, serial_numbers=[]):
+ if not serial_numbers:
+ return False
+
+ builder = x509.CertificateRevocationListBuilder() \
+ .issuer_name(ca_cert.subject) \
+ .last_update(datetime.datetime.today()) \
+ .next_update(datetime.datetime.today() + datetime.timedelta(1, 0, 0))
+
+ for serial_number in serial_numbers:
+ revoked_cert = x509.RevokedCertificateBuilder() \
+ .serial_number(serial_number) \
+ .revocation_date(datetime.datetime.today()) \
+ .build()
+ builder = builder.add_revoked_certificate(revoked_cert)
+
+ return builder.sign(private_key=ca_private_key, algorithm=hashes.SHA256())
+
+def create_dh_parameters(bits=2048):
+ if not bits or bits < 512:
+ print("Invalid DH parameter key size")
+ return False
+
+ return dh.generate_parameters(generator=2, key_size=int(bits))
+
+# Wrap functions
+
+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_openssh_public_key(raw_data, type):
+ return f'{type} {raw_data}'
+
+def wrap_openssh_private_key(raw_data):
+ return OPENSSH_KEY_BEGIN + raw_data + OPENSSH_KEY_END
+
+def wrap_certificate_request(raw_data):
+ return CSR_BEGIN + raw_data + CSR_END
+
+def wrap_certificate(raw_data):
+ return CERT_BEGIN + raw_data + CERT_END
+
+def wrap_crl(raw_data):
+ return CRL_BEGIN + raw_data + CRL_END
+
+def wrap_dh_parameters(raw_data):
+ return DH_BEGIN + raw_data + DH_END
+
+def wrap_openvpn_key(raw_data, version='1'):
+ return OVPN_BEGIN.format(version) + raw_data + OVPN_END.format(version)
+
+# Load functions
+def load_public_key(raw_data, wrap_tags=True):
+ if wrap_tags:
+ raw_data = wrap_public_key(raw_data)
+
+ try:
+ return serialization.load_pem_public_key(bytes(raw_data, 'utf-8'))
+ 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)
+
+ 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):
+ return False
+
+def load_openssh_public_key(raw_data, type):
+ try:
+ return serialization.load_ssh_public_key(bytes(f'{type} {raw_data}', 'utf-8'))
+ except ValueError:
+ return False
+
+def load_openssh_private_key(raw_data, passphrase=None, wrap_tags=True):
+ if wrap_tags:
+ raw_data = wrap_openssh_private_key(raw_data)
+
+ try:
+ return serialization.load_ssh_private_key(bytes(raw_data, 'utf-8'), password=passphrase)
+ except ValueError:
+ return False
+
+def load_certificate_request(raw_data, wrap_tags=True):
+ if wrap_tags:
+ raw_data = wrap_certificate_request(raw_data)
+
+ try:
+ return x509.load_pem_x509_csr(bytes(raw_data, 'utf-8'))
+ except ValueError:
+ return False
+
+def load_certificate(raw_data, wrap_tags=True):
+ if wrap_tags:
+ raw_data = wrap_certificate(raw_data)
+
+ try:
+ return x509.load_pem_x509_certificate(bytes(raw_data, 'utf-8'))
+ except ValueError:
+ return False
+
+def load_crl(raw_data, wrap_tags=True):
+ if wrap_tags:
+ raw_data = wrap_crl(raw_data)
+
+ try:
+ return x509.load_pem_x509_crl(bytes(raw_data, 'utf-8'))
+ except ValueError:
+ return False
+
+def load_dh_parameters(raw_data, wrap_tags=True):
+ if wrap_tags:
+ raw_data = wrap_dh_parameters(raw_data)
+
+ try:
+ return serialization.load_pem_parameters(bytes(raw_data, 'utf-8'))
+ except ValueError:
+ return False
+
+# Verify
+
+def is_ca_certificate(cert):
+ if not cert:
+ return False
+
+ try:
+ ext = cert.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS)
+ return ext.value.ca
+ except ExtensionNotFound:
+ return False
+
+def verify_certificate(cert, ca_cert):
+ # Verify certificate was signed by specified CA
+ if ca_cert.subject != cert.issuer:
+ return False
+
+ ca_public_key = ca_cert.public_key()
+ try:
+ if isinstance(ca_public_key, rsa.RSAPublicKeyWithSerialization):
+ ca_public_key.verify(
+ cert.signature,
+ cert.tbs_certificate_bytes,
+ padding=padding.PKCS1v15(),
+ algorithm=cert.signature_hash_algorithm)
+ elif isinstance(ca_public_key, dsa.DSAPublicKeyWithSerialization):
+ ca_public_key.verify(
+ cert.signature,
+ cert.tbs_certificate_bytes,
+ algorithm=cert.signature_hash_algorithm)
+ elif isinstance(ca_public_key, ec.EllipticCurvePublicKeyWithSerialization):
+ ca_public_key.verify(
+ cert.signature,
+ cert.tbs_certificate_bytes,
+ signature_algorithm=ec.ECDSA(cert.signature_hash_algorithm))
+ else:
+ return False # We cannot verify it
+ return True
+ except InvalidSignature:
+ return False
+
+def verify_crl(crl, ca_cert):
+ # Verify CRL was signed by specified CA
+ if ca_cert.subject != crl.issuer:
+ return False
+
+ ca_public_key = ca_cert.public_key()
+ try:
+ if isinstance(ca_public_key, rsa.RSAPublicKeyWithSerialization):
+ ca_public_key.verify(
+ crl.signature,
+ crl.tbs_certlist_bytes,
+ padding=padding.PKCS1v15(),
+ algorithm=crl.signature_hash_algorithm)
+ elif isinstance(ca_public_key, dsa.DSAPublicKeyWithSerialization):
+ ca_public_key.verify(
+ crl.signature,
+ crl.tbs_certlist_bytes,
+ algorithm=crl.signature_hash_algorithm)
+ elif isinstance(ca_public_key, ec.EllipticCurvePublicKeyWithSerialization):
+ ca_public_key.verify(
+ crl.signature,
+ crl.tbs_certlist_bytes,
+ signature_algorithm=ec.ECDSA(crl.signature_hash_algorithm))
+ else:
+ return False # We cannot verify it
+ return True
+ except InvalidSignature:
+ return False
+
+def verify_ca_chain(sorted_names, pki_node):
+ if len(sorted_names) == 1: # Single cert, no chain
+ return True
+
+ for name in sorted_names:
+ cert = load_certificate(pki_node[name]['certificate'])
+ verified = False
+ for ca_name in sorted_names:
+ if name == ca_name:
+ continue
+ ca_cert = load_certificate(pki_node[ca_name]['certificate'])
+ if verify_certificate(cert, ca_cert):
+ verified = True
+ break
+ if not verified and name != sorted_names[-1]:
+ # Only permit top-most certificate to fail verify (e.g. signed by public CA not explicitly in chain)
+ return False
+ return True
+
+# Certificate chain
+
+def find_parent(cert, ca_certs):
+ for ca_cert in ca_certs:
+ if verify_certificate(cert, ca_cert):
+ return ca_cert
+ return None
+
+def find_chain(cert, ca_certs):
+ remaining = ca_certs.copy()
+ chain = [cert]
+
+ while remaining:
+ parent = find_parent(chain[-1], remaining)
+ if parent is None:
+ # No parent in the list of remaining certificates or there's a circular dependency
+ break
+ elif parent == chain[-1]:
+ # Self-signed: must be root CA (end of chain)
+ break
+ else:
+ remaining.remove(parent)
+ chain.append(parent)
+
+ return chain
+
+def sort_ca_chain(ca_names, pki_node):
+ def ca_cmp(ca_name1, ca_name2, pki_node):
+ cert1 = load_certificate(pki_node[ca_name1]['certificate'])
+ cert2 = load_certificate(pki_node[ca_name2]['certificate'])
+
+ if verify_certificate(cert1, cert2): # cert1 is child of cert2
+ return -1
+ return 1
+
+ from functools import cmp_to_key
+ return sorted(ca_names, key=cmp_to_key(lambda cert1, cert2: ca_cmp(cert1, cert2, pki_node)))
diff --git a/python/vyos/priority.py b/python/vyos/priority.py
new file mode 100644
index 0000000..ab4e6d4
--- /dev/null
+++ b/python/vyos/priority.py
@@ -0,0 +1,75 @@
+# Copyright 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/>.
+
+from pathlib import Path
+from typing import List
+
+from vyos.xml_ref import load_reference
+from vyos.base import Warning as Warn
+
+def priority_data(d: dict) -> list:
+ def func(d, path, res, hier):
+ for k,v in d.items():
+ if not 'node_data' in v:
+ continue
+ subpath = path + [k]
+ hier_prio = hier
+ data = v.get('node_data')
+ o = data.get('owner')
+ p = data.get('priority')
+ # a few interface-definitions have priority preceding owner
+ # attribute, instead of within properties; pass in descent
+ if p is not None and o is None:
+ hier_prio = p
+ if o is not None and p is None:
+ p = hier_prio
+ if o is not None and p is not None:
+ o = Path(o.split()[0]).name
+ p = int(p)
+ res.append((subpath, o, p))
+ if isinstance(v, dict):
+ func(v, subpath, res, hier_prio)
+ return res
+ ret = func(d, [], [], 0)
+ ret = sorted(ret, key=lambda x: x[0])
+ ret = sorted(ret, key=lambda x: x[2])
+ return ret
+
+def get_priority_data() -> list:
+ xml = load_reference()
+ return priority_data(xml.ref)
+
+def priority_sort(sections: List[list[str]] = None,
+ owners: List[str] = None,
+ reverse=False) -> List:
+ if sections is not None:
+ index = 0
+ collection: List = sections
+ elif owners is not None:
+ index = 1
+ collection = owners
+ else:
+ raise ValueError('one of sections or owners is required')
+
+ l = get_priority_data()
+ m = [item for item in l if item[index] in collection]
+ n = sorted(m, key=lambda x: x[2], reverse=reverse)
+ o = [item[index] for item in n]
+ # sections are unhashable; use comprehension
+ missed = [j for j in collection if j not in o]
+ if missed:
+ Warn(f'No priority available for elements {missed}')
+
+ return o
diff --git a/python/vyos/progressbar.py b/python/vyos/progressbar.py
new file mode 100644
index 0000000..8d10426
--- /dev/null
+++ b/python/vyos/progressbar.py
@@ -0,0 +1,77 @@
+# Copyright 2023-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/>.
+
+import math
+import os
+import signal
+import subprocess
+
+from vyos.utils.io import is_dumb_terminal
+from vyos.utils.io import print_error
+
+class Progressbar:
+ def __init__(self, step=None):
+ self.total = 0.0
+ self.step = step
+ # Silently ignore all calls if terminal capabilities are lacking.
+ # This will also prevent the output from littering Ansible logs,
+ # as `ansible.netcommon.network_cli' coaxes the terminal into believing
+ # it is interactive.
+ self._dumb = is_dumb_terminal()
+ def __enter__(self):
+ if not self._dumb:
+ # Recalculate terminal width with every window resize.
+ signal.signal(signal.SIGWINCH, lambda signum, frame: self._update_cols())
+ # Disable line wrapping to prevent the staircase effect.
+ subprocess.run(['tput', 'rmam'], check=False)
+ self._update_cols()
+ # Print an empty progressbar with entry.
+ self.progress(0, 1)
+ return self
+ def __exit__(self, exc_type, kexc_val, exc_tb):
+ if not self._dumb:
+ # Revert to the default SIGWINCH handler (ie nothing).
+ signal.signal(signal.SIGWINCH, signal.SIG_DFL)
+ # Reenable line wrapping.
+ subprocess.run(['tput', 'smam'], check=False)
+ def _update_cols(self):
+ # `os.get_terminal_size()' is fast enough for our purposes.
+ self.col = max(os.get_terminal_size().columns - 15, 20)
+ def increment(self):
+ """
+ Stateful progressbar taking the step fraction at init and no input at
+ callback (for FTP)
+ """
+ if self.step:
+ if self.total < 1.0:
+ self.total += self.step
+ if self.total >= 1.0:
+ self.total = 1.0
+ # Ignore superfluous calls caused by fuzzy FTP size calculations.
+ self.step = None
+ self.progress(self.total, 1.0)
+ def progress(self, done, total):
+ """
+ Stateless progressbar taking no input at init and current progress with
+ final size at callback (for SSH)
+ """
+ if done <= total and not self._dumb:
+ length = math.ceil(self.col * done / total)
+ percentage = str(math.ceil(100 * done / total)).rjust(3)
+ # Carriage return at the end will make sure the line will get overwritten.
+ print_error(f'[{length * "#"}{(self.col - length) * "_"}] {percentage}%', end='\r')
+ # Print a newline to make sure the full progressbar doesn't get overwritten by the next line.
+ if done == total:
+ print_error()
diff --git a/python/vyos/qos/__init__.py b/python/vyos/qos/__init__.py
new file mode 100644
index 0000000..a2980cc
--- /dev/null
+++ b/python/vyos/qos/__init__.py
@@ -0,0 +1,28 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+from vyos.qos.cake import CAKE
+from vyos.qos.droptail import DropTail
+from vyos.qos.fairqueue import FairQueue
+from vyos.qos.fqcodel import FQCodel
+from vyos.qos.limiter import Limiter
+from vyos.qos.netem import NetEm
+from vyos.qos.priority import Priority
+from vyos.qos.randomdetect import RandomDetect
+from vyos.qos.ratelimiter import RateLimiter
+from vyos.qos.roundrobin import RoundRobin
+from vyos.qos.trafficshaper import TrafficShaper
+from vyos.qos.trafficshaper import TrafficShaperHFSC
diff --git a/python/vyos/qos/base.py b/python/vyos/qos/base.py
new file mode 100644
index 0000000..98e486e
--- /dev/null
+++ b/python/vyos/qos/base.py
@@ -0,0 +1,440 @@
+# Copyright 2022-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/>.
+
+import os
+import jmespath
+
+from vyos.base import Warning
+from vyos.utils.process import cmd
+from vyos.utils.dict import dict_search
+from vyos.utils.file import read_file
+
+from vyos.utils.network import get_protocol_by_name
+
+
+class QoSBase:
+ _debug = False
+ _direction = ['egress']
+ _parent = 0xffff
+ _dsfields = {
+ "default": 0x0,
+ "lowdelay": 0x10,
+ "throughput": 0x08,
+ "reliability": 0x04,
+ "mincost": 0x02,
+ "priority": 0x20,
+ "immediate": 0x40,
+ "flash": 0x60,
+ "flash-override": 0x80,
+ "critical": 0x0A,
+ "internet": 0xC0,
+ "network": 0xE0,
+ "AF11": 0x28,
+ "AF12": 0x30,
+ "AF13": 0x38,
+ "AF21": 0x48,
+ "AF22": 0x50,
+ "AF23": 0x58,
+ "AF31": 0x68,
+ "AF32": 0x70,
+ "AF33": 0x78,
+ "AF41": 0x88,
+ "AF42": 0x90,
+ "AF43": 0x98,
+ "CS1": 0x20,
+ "CS2": 0x40,
+ "CS3": 0x60,
+ "CS4": 0x80,
+ "CS5": 0xA0,
+ "CS6": 0xC0,
+ "CS7": 0xE0,
+ "EF": 0xB8
+ }
+ qostype = None
+
+ def __init__(self, interface):
+ if os.path.exists('/tmp/vyos.qos.debug'):
+ self._debug = True
+ self._interface = interface
+
+ def _cmd(self, command):
+ if self._debug:
+ print(f'DEBUG/QoS: {command}')
+ return cmd(command)
+
+ def get_direction(self) -> list:
+ return self._direction
+
+ def _get_class_max_id(self, config) -> int:
+ if 'class' in config:
+ tmp = list(config['class'].keys())
+ tmp.sort(key=lambda ii: int(ii))
+ return tmp[-1]
+ return None
+
+ def _get_dsfield(self, value):
+ if value in self._dsfields:
+ return self._dsfields[value]
+ else:
+ return value
+
+ def _calc_random_detect_queue_params(self, avg_pkt, max_thr, limit=None, min_thr=None,
+ mark_probability=None, precedence=0):
+ params = dict()
+ avg_pkt = int(avg_pkt)
+ max_thr = int(max_thr)
+ mark_probability = int(mark_probability)
+ limit = int(limit) if limit else 4 * max_thr
+ min_thr = int(min_thr) if min_thr else ((9 + precedence) * max_thr) // 18
+
+ params['avg_pkt'] = avg_pkt
+ params['limit'] = limit * avg_pkt
+ params['min_val'] = min_thr * avg_pkt
+ params['max_val'] = max_thr * avg_pkt
+ params['burst'] = (2 * min_thr + max_thr) // 3
+ params['probability'] = 1 / mark_probability
+
+ return params
+
+ def _build_base_qdisc(self, config : dict, cls_id : int):
+ """
+ Add/replace qdisc for every class (also default is a class). This is
+ a genetic method which need an implementation "per" queue-type.
+
+ This matches the old mapping as defined in Perl here:
+ https://github.com/vyos/vyatta-cfg-qos/blob/equuleus/lib/Vyatta/Qos/ShaperClass.pm#L223-L229
+ """
+ queue_type = dict_search('queue_type', config)
+ default_tc = f'tc qdisc replace dev {self._interface} parent {self._parent}:{cls_id:x}'
+
+ if queue_type == 'priority':
+ handle = 0x4000 + cls_id
+ default_tc += f' handle {handle:x}: prio'
+ self._cmd(default_tc)
+
+ queue_limit = dict_search('queue_limit', config)
+ for ii in range(1, 4):
+ tmp = f'tc qdisc replace dev {self._interface} parent {handle:x}:{ii:x} pfifo'
+ if queue_limit: tmp += f' limit {queue_limit}'
+ self._cmd(tmp)
+
+ elif queue_type == 'fair-queue':
+ default_tc += f' sfq'
+
+ tmp = dict_search('queue_limit', config)
+ if tmp: default_tc += f' limit {tmp}'
+
+ self._cmd(default_tc)
+
+ elif queue_type == 'fq-codel':
+ default_tc += f' fq_codel'
+ tmp = dict_search('codel_quantum', config)
+ if tmp: default_tc += f' quantum {tmp}'
+
+ tmp = dict_search('flows', config)
+ if tmp: default_tc += f' flows {tmp}'
+
+ tmp = dict_search('interval', config)
+ if tmp: default_tc += f' interval {tmp}ms'
+
+ tmp = dict_search('queue_limit', config)
+ if tmp: default_tc += f' limit {tmp}'
+
+ tmp = dict_search('target', config)
+ if tmp: default_tc += f' target {tmp}ms'
+
+ default_tc += f' noecn'
+
+ self._cmd(default_tc)
+
+ elif queue_type == 'random-detect':
+ 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),
+ limit=dict_search('queue_limit', config),
+ min_thr=dict_search('minimum_threshold', config),
+ mark_probability=dict_search('mark_probability', config)
+ )
+
+ default_tc += f' limit {qparams["limit"]} avpkt {qparams["avg_pkt"]}'
+ default_tc += f' max {qparams["max_val"]} min {qparams["min_val"]}'
+ default_tc += f' burst {qparams["burst"]} probability {qparams["probability"]}'
+
+ self._cmd(default_tc)
+
+ elif queue_type == 'drop-tail':
+ default_tc += f' pfifo'
+
+ tmp = dict_search('queue_limit', config)
+ if tmp: default_tc += f' limit {tmp}'
+
+ self._cmd(default_tc)
+
+ def _rate_convert(self, rate) -> int:
+ rates = {
+ 'bit' : 1,
+ 'kbit' : 1000,
+ 'mbit' : 1000000,
+ 'gbit' : 1000000000,
+ 'tbit' : 1000000000000,
+ }
+
+ if rate == 'auto' or rate.endswith('%'):
+ speed = 1000
+ default_speed = speed
+ # Not all interfaces have valid entries in the speed file. PPPoE
+ # interfaces have the appropriate speed file, but you can not read it:
+ # cat: /sys/class/net/pppoe7/speed: Invalid argument
+ try:
+ speed = read_file(f'/sys/class/net/{self._interface}/speed')
+ if not speed.isnumeric():
+ Warning('Interface speed cannot be determined (assuming 1000 Mbit/s)')
+ if int(speed) < 1:
+ speed = default_speed
+ if rate.endswith('%'):
+ percent = rate.rstrip('%')
+ speed = int(speed) * int(percent) // 100
+ except:
+ pass
+
+ return int(speed) *1000000 # convert to MBit/s
+
+ rate_numeric = int(''.join([n for n in rate if n.isdigit()]))
+ rate_scale = ''.join([n for n in rate if not n.isdigit()])
+
+ if int(rate_numeric) <= 0:
+ raise ValueError(f'{rate_numeric} is not a valid bandwidth <= 0')
+
+ if rate_scale:
+ return int(rate_numeric * rates[rate_scale])
+ else:
+ # No suffix implies Kbps just as Cisco IOS
+ return int(rate_numeric * 1000)
+
+ def update(self, config, direction, priority=None):
+ """ method must be called from derived class after it has completed qdisc setup """
+ if self._debug:
+ import pprint
+ pprint.pprint(config)
+
+ if 'class' in config:
+ for cls, cls_config in config['class'].items():
+ self._build_base_qdisc(cls_config, int(cls))
+
+ # every match criteria has it's tc instance
+ filter_cmd_base = f'tc filter add dev {self._interface} parent {self._parent:x}:'
+
+ if priority:
+ filter_cmd_base += f' prio {cls}'
+ elif 'priority' in cls_config:
+ prio = cls_config['priority']
+ filter_cmd_base += f' prio {prio}'
+
+ filter_cmd_base += ' protocol all'
+
+ if 'match' in cls_config:
+ has_filter = False
+ 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']:
+ if key in match_config:
+ has_filter = True
+ break
+
+ if self.qostype == 'shaper' 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})"'
+
+ for af in ['ip', 'ipv6']:
+ tc_af = af
+ if af == 'ipv6':
+ tc_af = 'ip6'
+
+ 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)
+
+ 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:
+ # For "vif" "basic match" is used instead of "action police" T5961
+ if not match_vlan:
+ filter_cmd += f' action police'
+
+ if 'exceed' in cls_config:
+ action = cls_config['exceed']
+ filter_cmd += f' conform-exceed {action}'
+ if 'not_exceed' in cls_config:
+ action = cls_config['not_exceed']
+ filter_cmd += f'/{action}'
+
+ if 'bandwidth' in cls_config:
+ rate = self._rate_convert(cls_config['bandwidth'])
+ filter_cmd += f' rate {rate}'
+
+ if 'burst' in cls_config:
+ burst = cls_config['burst']
+ filter_cmd += f' burst {burst}'
+
+ if 'mtu' in cls_config:
+ mtu = cls_config['mtu']
+ filter_cmd += f' mtu {mtu}'
+
+ cls = int(cls)
+ filter_cmd += f' flowid {self._parent:x}:{cls:x}'
+ self._cmd(filter_cmd)
+
+ # The police block allows limiting of the byte or packet rate of
+ # traffic matched by the filter it is attached to.
+ # https://man7.org/linux/man-pages/man8/tc-police.8.html
+
+ # T5295: We do not handle rate via tc filter directly,
+ # but rather set the tc filter to direct traffic to the correct tc class flow.
+ #
+ # if any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in cls_config):
+ # filter_cmd += f' action police'
+ #
+ # if 'exceed' in cls_config:
+ # action = cls_config['exceed']
+ # filter_cmd += f' conform-exceed {action}'
+ # if 'not_exceed' in cls_config:
+ # action = cls_config['not_exceed']
+ # filter_cmd += f'/{action}'
+ #
+ # if 'bandwidth' in cls_config:
+ # rate = self._rate_convert(cls_config['bandwidth'])
+ # filter_cmd += f' rate {rate}'
+ #
+ # if 'burst' in cls_config:
+ # burst = cls_config['burst']
+ # filter_cmd += f' burst {burst}'
+
+ if 'default' in config:
+ default_cls_id = 1
+ if 'class' in config:
+ class_id_max = self._get_class_max_id(config)
+ default_cls_id = int(class_id_max) +1
+ self._build_base_qdisc(config['default'], default_cls_id)
+
+ if self.qostype == 'limiter':
+ if 'default' in config:
+ filter_cmd = f'tc filter replace dev {self._interface} parent {self._parent:x}: '
+ filter_cmd += 'prio 255 protocol all basic'
+
+ # The police block allows limiting of the byte or packet rate of
+ # traffic matched by the filter it is attached to.
+ # https://man7.org/linux/man-pages/man8/tc-police.8.html
+ if any(tmp in ['exceed', 'bandwidth', 'burst'] for tmp in
+ config['default']):
+ filter_cmd += f' action police'
+
+ if 'exceed' in config['default']:
+ action = config['default']['exceed']
+ filter_cmd += f' conform-exceed {action}'
+ if 'not_exceed' in config['default']:
+ action = config['default']['not_exceed']
+ filter_cmd += f'/{action}'
+
+ if 'bandwidth' in config['default']:
+ rate = self._rate_convert(config['default']['bandwidth'])
+ filter_cmd += f' rate {rate}'
+
+ if 'burst' in config['default']:
+ burst = config['default']['burst']
+ filter_cmd += f' burst {burst}'
+
+ if 'mtu' in config['default']:
+ mtu = config['default']['mtu']
+ filter_cmd += f' mtu {mtu}'
+
+ if 'class' in config:
+ filter_cmd += f' flowid {self._parent:x}:{default_cls_id:x}'
+
+ self._cmd(filter_cmd)
diff --git a/python/vyos/qos/cake.py b/python/vyos/qos/cake.py
new file mode 100644
index 0000000..1ee7d0f
--- /dev/null
+++ b/python/vyos/qos/cake.py
@@ -0,0 +1,57 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class CAKE(QoSBase):
+ _direction = ['egress']
+
+ # https://man7.org/linux/man-pages/man8/tc-cake.8.html
+ def update(self, config, direction):
+ tmp = f'tc qdisc add dev {self._interface} root handle 1: cake {direction}'
+ if 'bandwidth' in config:
+ bandwidth = self._rate_convert(config['bandwidth'])
+ tmp += f' bandwidth {bandwidth}'
+
+ if 'rtt' in config:
+ rtt = config['rtt']
+ 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'
+
+ self._cmd(tmp)
+
+ # call base class
+ super().update(config, direction)
diff --git a/python/vyos/qos/droptail.py b/python/vyos/qos/droptail.py
new file mode 100644
index 0000000..427d43d
--- /dev/null
+++ b/python/vyos/qos/droptail.py
@@ -0,0 +1,28 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class DropTail(QoSBase):
+ # https://man7.org/linux/man-pages/man8/tc-pfifo.8.html
+ def update(self, config, direction):
+ tmp = f'tc qdisc add dev {self._interface} root pfifo'
+ if 'queue_limit' in config:
+ limit = config["queue_limit"]
+ tmp += f' limit {limit}'
+ self._cmd(tmp)
+
+ # call base class
+ super().update(config, direction)
diff --git a/python/vyos/qos/fairqueue.py b/python/vyos/qos/fairqueue.py
new file mode 100644
index 0000000..f41d098
--- /dev/null
+++ b/python/vyos/qos/fairqueue.py
@@ -0,0 +1,31 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class FairQueue(QoSBase):
+ # https://man7.org/linux/man-pages/man8/tc-sfq.8.html
+ def update(self, config, direction):
+ tmp = f'tc qdisc add dev {self._interface} root sfq'
+
+ if 'hash_interval' in config:
+ tmp += f' perturb {config["hash_interval"]}'
+ if 'queue_limit' in config:
+ tmp += f' limit {config["queue_limit"]}'
+
+ self._cmd(tmp)
+
+ # call base class
+ super().update(config, direction)
diff --git a/python/vyos/qos/fqcodel.py b/python/vyos/qos/fqcodel.py
new file mode 100644
index 0000000..cd2340a
--- /dev/null
+++ b/python/vyos/qos/fqcodel.py
@@ -0,0 +1,40 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class FQCodel(QoSBase):
+ # https://man7.org/linux/man-pages/man8/tc-fq_codel.8.html
+ def update(self, config, direction):
+ tmp = f'tc qdisc add dev {self._interface} root fq_codel'
+
+ if 'codel_quantum' in config:
+ tmp += f' quantum {config["codel_quantum"]}'
+ if 'flows' in config:
+ tmp += f' flows {config["flows"]}'
+ if 'interval' in config:
+ interval = int(config['interval']) * 1000
+ tmp += f' interval {interval}'
+ if 'queue_limit' in config:
+ tmp += f' limit {config["queue_limit"]}'
+ if 'target' in config:
+ target = int(config['target']) * 1000
+ tmp += f' target {target}'
+
+ tmp += f' noecn'
+ self._cmd(tmp)
+
+ # call base class
+ super().update(config, direction)
diff --git a/python/vyos/qos/limiter.py b/python/vyos/qos/limiter.py
new file mode 100644
index 0000000..3f5c111
--- /dev/null
+++ b/python/vyos/qos/limiter.py
@@ -0,0 +1,28 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class Limiter(QoSBase):
+ _direction = ['ingress']
+ qostype = 'limiter'
+
+ def update(self, config, direction):
+ tmp = f'tc qdisc add dev {self._interface} handle {self._parent:x}: {direction}'
+ self._cmd(tmp)
+
+ # base class must be called last
+ super().update(config, direction)
+
diff --git a/python/vyos/qos/netem.py b/python/vyos/qos/netem.py
new file mode 100644
index 0000000..8bdef30
--- /dev/null
+++ b/python/vyos/qos/netem.py
@@ -0,0 +1,53 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class NetEm(QoSBase):
+ # https://man7.org/linux/man-pages/man8/tc-netem.8.html
+ def update(self, config, direction):
+ tmp = f'tc qdisc add dev {self._interface} root netem'
+ if 'bandwidth' in config:
+ rate = self._rate_convert(config["bandwidth"])
+ tmp += f' rate {rate}'
+
+ if 'queue_limit' in config:
+ limit = config["queue_limit"]
+ tmp += f' limit {limit}'
+
+ if 'delay' in config:
+ delay = config["delay"]
+ tmp += f' delay {delay}ms'
+
+ if 'loss' in config:
+ drop = config["loss"]
+ tmp += f' drop {drop}%'
+
+ if 'corruption' in config:
+ corrupt = config["corruption"]
+ tmp += f' corrupt {corrupt}%'
+
+ if 'reordering' in config:
+ reorder = config["reordering"]
+ tmp += f' reorder {reorder}%'
+
+ if 'duplicate' in config:
+ duplicate = config["duplicate"]
+ tmp += f' duplicate {duplicate}%'
+
+ self._cmd(tmp)
+
+ # call base class
+ super().update(config, direction)
diff --git a/python/vyos/qos/priority.py b/python/vyos/qos/priority.py
new file mode 100644
index 0000000..7f0a670
--- /dev/null
+++ b/python/vyos/qos/priority.py
@@ -0,0 +1,40 @@
+# Copyright 2022-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/>.
+
+from vyos.qos.base import QoSBase
+
+class Priority(QoSBase):
+ _parent = 1
+
+ # 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
+
+ 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)
+
+ for cls in config['class']:
+ cls = int(cls)
+ tmp = f'tc qdisc add dev {self._interface} parent {self._parent:x}:{cls:x} pfifo'
+ self._cmd(tmp)
+
+ # base class must be called last
+ super().update(config, direction, priority=True)
diff --git a/python/vyos/qos/randomdetect.py b/python/vyos/qos/randomdetect.py
new file mode 100644
index 0000000..a3a39da
--- /dev/null
+++ b/python/vyos/qos/randomdetect.py
@@ -0,0 +1,46 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class RandomDetect(QoSBase):
+ _parent = 1
+
+ # https://man7.org/linux/man-pages/man8/tc.8.html
+ def update(self, config, direction):
+
+ # # Generalized Random Early Detection
+ handle = self._parent
+ tmp = f'tc qdisc add dev {self._interface} root handle {self._parent}:0 gred setup DPs 8 default 0 grio'
+ self._cmd(tmp)
+ bandwidth = self._rate_convert(config['bandwidth'])
+
+ # set VQ (virtual queue) parameters
+ for precedence, precedence_config in config['precedence'].items():
+ precedence = int(precedence)
+ qparams = self._calc_random_detect_queue_params(
+ avg_pkt=precedence_config.get('average_packet'),
+ max_thr=precedence_config.get('maximum_threshold'),
+ limit=precedence_config.get('queue_limit'),
+ min_thr=precedence_config.get('minimum_threshold'),
+ mark_probability=precedence_config.get('mark_probability'),
+ precedence=precedence
+ )
+ tmp = f'tc qdisc change dev {self._interface} handle {handle}:0 gred limit {qparams["limit"]} min {qparams["min_val"]} max {qparams["max_val"]} avpkt {qparams["avg_pkt"]} '
+ tmp += f'burst {qparams["burst"]} bandwidth {bandwidth} probability {qparams["probability"]} DP {precedence} prio {8 - precedence:x}'
+ self._cmd(tmp)
+
+ # call base class
+ super().update(config, direction)
diff --git a/python/vyos/qos/ratelimiter.py b/python/vyos/qos/ratelimiter.py
new file mode 100644
index 0000000..a4f80a1
--- /dev/null
+++ b/python/vyos/qos/ratelimiter.py
@@ -0,0 +1,37 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class RateLimiter(QoSBase):
+ # https://man7.org/linux/man-pages/man8/tc-tbf.8.html
+ def update(self, config, direction):
+ # call base class
+ super().update(config, direction)
+
+ tmp = f'tc qdisc add dev {self._interface} root tbf'
+ if 'bandwidth' in config:
+ rate = self._rate_convert(config['bandwidth'])
+ tmp += f' rate {rate}'
+
+ if 'burst' in config:
+ burst = config['burst']
+ tmp += f' burst {burst}'
+
+ if 'latency' in config:
+ latency = config['latency']
+ tmp += f' latency {latency}ms'
+
+ self._cmd(tmp)
diff --git a/python/vyos/qos/roundrobin.py b/python/vyos/qos/roundrobin.py
new file mode 100644
index 0000000..80814dd
--- /dev/null
+++ b/python/vyos/qos/roundrobin.py
@@ -0,0 +1,44 @@
+# Copyright 2022 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.qos.base import QoSBase
+
+class RoundRobin(QoSBase):
+ _parent = 1
+
+ # https://man7.org/linux/man-pages/man8/tc-drr.8.html
+ def update(self, config, direction):
+ tmp = f'tc qdisc add dev {self._interface} root handle 1: drr'
+ self._cmd(tmp)
+
+ if 'class' in config:
+ for cls in config['class']:
+ cls = int(cls)
+ tmp = f'tc class replace dev {self._interface} parent 1:1 classid 1:{cls:x} drr'
+ self._cmd(tmp)
+
+ tmp = f'tc qdisc replace dev {self._interface} parent 1:{cls:x} pfifo'
+ self._cmd(tmp)
+
+ if 'default' in config:
+ class_id_max = self._get_class_max_id(config)
+ default_cls_id = int(class_id_max) +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)
+
+ # call base class
+ super().update(config, direction, priority=True)
diff --git a/python/vyos/qos/trafficshaper.py b/python/vyos/qos/trafficshaper.py
new file mode 100644
index 0000000..8b0333c
--- /dev/null
+++ b/python/vyos/qos/trafficshaper.py
@@ -0,0 +1,216 @@
+# Copyright 2022-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/>.
+
+from math import ceil
+from vyos.qos.base import QoSBase
+
+# Kernel limits on quantum (bytes)
+MAXQUANTUM = 200000
+MINQUANTUM = 1000
+
+class TrafficShaper(QoSBase):
+ _parent = 1
+ qostype = 'shaper'
+
+ # https://man7.org/linux/man-pages/man8/tc-htb.8.html
+ def update(self, config, direction):
+ class_id_max = 0
+ if 'class' in config:
+ tmp = list(config['class'])
+ # Convert strings to integers
+ tmp = [int(x) for x in tmp]
+ class_id_max = max(tmp)
+
+ r2q = 10
+ # bandwidth is a mandatory CLI node
+ speed = self._rate_convert(config['bandwidth'])
+ speed_bps = int(speed) // 8
+
+ # 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
+
+ while (r2q > 1) and (min_speed // r2q) < MINQUANTUM:
+ tmp = r2q -1
+ if (speed_bps // tmp) >= MAXQUANTUM:
+ break
+ r2q = tmp
+
+
+ default_minor_id = int(class_id_max) +1
+ tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: htb r2q {r2q} default {default_minor_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 htb rate {speed}'
+ 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)
+
+ # bandwidth is a mandatory CLI node
+ # T5296 if bandwidth 'auto' or 'xx%' get value from config shaper total "bandwidth"
+ # i.e from set shaper test bandwidth '300mbit'
+ # without it, it tries to get value from qos.base /sys/class/net/{self._interface}/speed
+ if cls_config['bandwidth'] == 'auto':
+ rate = self._rate_convert(config['bandwidth'])
+ elif cls_config['bandwidth'].endswith('%'):
+ percent = cls_config['bandwidth'].rstrip('%')
+ rate = self._rate_convert(config['bandwidth']) * int(percent) // 100
+ else:
+ rate = self._rate_convert(cls_config['bandwidth'])
+
+ burst = cls_config['burst']
+ quantum = cls_config['codel_quantum']
+
+ tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} htb rate {rate} burst {burst} quantum {quantum}'
+ if 'priority' in cls_config:
+ priority = cls_config['priority']
+ tmp += f' prio {priority}'
+
+ if 'ceiling' in cls_config:
+ f_ceil = self._rate_convert(cls_config['ceiling'])
+ tmp += f' ceil {f_ceil}'
+ self._cmd(tmp)
+
+ tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq'
+ self._cmd(tmp)
+
+ if 'default' in config:
+ if config['default']['bandwidth'].endswith('%'):
+ percent = config['default']['bandwidth'].rstrip('%')
+ rate = self._rate_convert(config['bandwidth']) * int(percent) // 100
+ else:
+ rate = self._rate_convert(config['default']['bandwidth'])
+ burst = config['default']['burst']
+ quantum = config['default']['codel_quantum']
+ tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{default_minor_id:x} htb rate {rate} burst {burst} quantum {quantum}'
+ if 'priority' in config['default']:
+ priority = config['default']['priority']
+ tmp += f' prio {priority}'
+ if 'ceiling' in config['default']:
+ if config['default']['ceiling'].endswith('%'):
+ percent = config['default']['ceiling'].rstrip('%')
+ f_ceil = self._rate_convert(config['bandwidth']) * int(percent) // 100
+ else:
+ f_ceil = self._rate_convert(config['default']['ceiling'])
+ tmp += f' ceil {f_ceil}'
+ self._cmd(tmp)
+
+ tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{default_minor_id:x} sfq'
+ self._cmd(tmp)
+
+ # call base class
+ super().update(config, direction)
+
+class TrafficShaperHFSC(QoSBase):
+ _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]
+
+ r2q = 10
+ # bandwidth is a mandatory CLI node
+ speed = self._rate_convert(config['bandwidth'])
+ speed_bps = int(speed) // 8
+
+ # 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
+
+ while (r2q > 1) and (min_speed // r2q) < MINQUANTUM:
+ tmp = r2q -1
+ if (speed_bps // tmp) >= MAXQUANTUM:
+ break
+ r2q = tmp
+
+ 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
+ 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)
+
+ 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)
+
+ 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)
+
+ # call base class
+ super().update(config, direction)
diff --git a/python/vyos/raid.py b/python/vyos/raid.py
new file mode 100644
index 0000000..7fb7948
--- /dev/null
+++ b/python/vyos/raid.py
@@ -0,0 +1,71 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.utils.disk import device_from_id
+from vyos.utils.process import cmd
+
+def raid_sets():
+ """
+ Returns a list of RAID sets
+ """
+ with open('/proc/mdstat') as f:
+ return [line.split()[0].rstrip(':') for line in f if line.startswith('md')]
+
+def raid_set_members(raid_set_name: str):
+ """
+ Returns a list of members of a RAID set
+ """
+ with open('/proc/mdstat') as f:
+ for line in f:
+ if line.startswith(raid_set_name):
+ return [l.split('[')[0] for l in line.split()[4:]]
+ return []
+
+def partitions():
+ """
+ Returns a list of partitions
+ """
+ with open('/proc/partitions') as f:
+ p = [l.strip().split()[-1] for l in list(f) if l.strip()]
+ p.remove('name')
+ return p
+
+def add_raid_member(raid_set_name: str, member: str, by_id: bool = False):
+ """
+ Add a member to an existing RAID set
+ """
+ if by_id:
+ member = device_from_id(member)
+ if raid_set_name not in raid_sets():
+ raise ValueError(f"RAID set {raid_set_name} does not exist")
+ if member not in partitions():
+ raise ValueError(f"Partition {member} does not exist")
+ if member in raid_set_members(raid_set_name):
+ raise ValueError(f"Partition {member} is already a member of RAID set {raid_set_name}")
+ cmd(f'mdadm --add /dev/{raid_set_name} /dev/{member}')
+ disk = cmd(f'lsblk -ndo PKNAME /dev/{member}')
+ cmd(f'grub-install /dev/{disk}')
+
+def delete_raid_member(raid_set_name: str, member: str, by_id: bool = False):
+ """
+ Delete a member from an existing RAID set
+ """
+ if by_id:
+ member = device_from_id(member)
+ if raid_set_name not in raid_sets():
+ raise ValueError(f"RAID set {raid_set_name} does not exist")
+ if member not in raid_set_members(raid_set_name):
+ raise ValueError(f"Partition {member} is not a member of RAID set {raid_set_name}")
+ cmd(f'mdadm --remove /dev/{raid_set_name} /dev/{member}')
diff --git a/python/vyos/range_regex.py b/python/vyos/range_regex.py
new file mode 100644
index 0000000..81e9d2e
--- /dev/null
+++ b/python/vyos/range_regex.py
@@ -0,0 +1,141 @@
+'''Copyright (c) 2013, Dmitry Voronin
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+'''
+
+# coding=utf8
+
+# Split range to ranges that has its unique pattern.
+# Example for 12-345:
+#
+# 12- 19: 1[2-9]
+# 20- 99: [2-9]\d
+# 100-299: [1-2]\d{2}
+# 300-339: 3[0-3]\d
+# 340-345: 34[0-5]
+
+def range_to_regex(inpt_range):
+ if isinstance(inpt_range, str):
+ range_list = inpt_range.split('-')
+ # Check input arguments
+ if len(range_list) == 2:
+ # The first element in range must be higher then the second
+ if int(range_list[0]) < int(range_list[1]):
+ return regex_for_range(int(range_list[0]), int(range_list[1]))
+
+ return None
+
+def bounded_regex_for_range(min_, max_):
+ return r'\b({})\b'.format(regex_for_range(min_, max_))
+
+def regex_for_range(min_, max_):
+ """
+ > regex_for_range(12, 345)
+ '1[2-9]|[2-9]\d|[1-2]\d{2}|3[0-3]\d|34[0-5]'
+ """
+ positive_subpatterns = []
+ negative_subpatterns = []
+
+ if min_ < 0:
+ min__ = 1
+ if max_ < 0:
+ min__ = abs(max_)
+ max__ = abs(min_)
+
+ negative_subpatterns = split_to_patterns(min__, max__)
+ min_ = 0
+
+ if max_ >= 0:
+ positive_subpatterns = split_to_patterns(min_, max_)
+
+ negative_only_subpatterns = ['-' + val for val in negative_subpatterns if val not in positive_subpatterns]
+ positive_only_subpatterns = [val for val in positive_subpatterns if val not in negative_subpatterns]
+ intersected_subpatterns = ['-?' + val for val in negative_subpatterns if val in positive_subpatterns]
+
+ subpatterns = negative_only_subpatterns + intersected_subpatterns + positive_only_subpatterns
+ return '|'.join(subpatterns)
+
+
+def split_to_patterns(min_, max_):
+ subpatterns = []
+
+ start = min_
+ for stop in split_to_ranges(min_, max_):
+ subpatterns.append(range_to_pattern(start, stop))
+ start = stop + 1
+
+ return subpatterns
+
+
+def split_to_ranges(min_, max_):
+ stops = {max_}
+
+ nines_count = 1
+ stop = fill_by_nines(min_, nines_count)
+ while min_ <= stop < max_:
+ stops.add(stop)
+
+ nines_count += 1
+ stop = fill_by_nines(min_, nines_count)
+
+ zeros_count = 1
+ stop = fill_by_zeros(max_ + 1, zeros_count) - 1
+ while min_ < stop <= max_:
+ stops.add(stop)
+
+ zeros_count += 1
+ stop = fill_by_zeros(max_ + 1, zeros_count) - 1
+
+ stops = list(stops)
+ stops.sort()
+
+ return stops
+
+
+def fill_by_nines(integer, nines_count):
+ return int(str(integer)[:-nines_count] + '9' * nines_count)
+
+
+def fill_by_zeros(integer, zeros_count):
+ return integer - integer % 10 ** zeros_count
+
+
+def range_to_pattern(start, stop):
+ pattern = ''
+ any_digit_count = 0
+
+ for start_digit, stop_digit in zip(str(start), str(stop)):
+ if start_digit == stop_digit:
+ pattern += start_digit
+ elif start_digit != '0' or stop_digit != '9':
+ pattern += '[{}-{}]'.format(start_digit, stop_digit)
+ else:
+ any_digit_count += 1
+
+ if any_digit_count:
+ pattern += r'\d'
+
+ if any_digit_count > 1:
+ pattern += '{{{}}}'.format(any_digit_count)
+
+ return pattern
diff --git a/python/vyos/remote.py b/python/vyos/remote.py
new file mode 100644
index 0000000..d87fd24
--- /dev/null
+++ b/python/vyos/remote.py
@@ -0,0 +1,479 @@
+# Copyright 2021 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 os
+import pwd
+import shutil
+import socket
+import ssl
+import stat
+import sys
+import tempfile
+import urllib.parse
+
+from contextlib import contextmanager
+from pathlib import Path
+
+from ftplib import FTP
+from ftplib import FTP_TLS
+
+from paramiko import SSHClient, SSHException
+from paramiko import MissingHostKeyPolicy
+
+from requests import Session
+from requests.adapters import HTTPAdapter
+from requests.packages.urllib3 import PoolManager
+
+from vyos.progressbar import Progressbar
+from vyos.utils.io import ask_yes_no
+from vyos.utils.io import is_interactive
+from vyos.utils.io import print_error
+from vyos.utils.misc import begin
+from vyos.utils.process import cmd, rc_cmd
+from vyos.version import get_version
+from vyos.base import Warning
+
+CHUNK_SIZE = 8192
+
+class InteractivePolicy(MissingHostKeyPolicy):
+ """
+ Paramiko policy for interactively querying the user on whether to proceed
+ with SSH connections to unknown hosts.
+ """
+ def missing_host_key(self, client, hostname, key):
+ print_error(f"Host '{hostname}' not found in known hosts.")
+ print_error('Fingerprint: ' + key.get_fingerprint().hex())
+ if not sys.stdin.isatty():
+ return
+ if not ask_yes_no('Do you wish to continue?'):
+ raise SSHException(f"Cannot connect to unknown host '{hostname}'.")
+ if client._host_keys_filename is None:
+ Warning('no \'known_hosts\' file; create to store keys permanently')
+ return
+ if ask_yes_no('Do you wish to permanently add this host/key pair to known_hosts file?'):
+ client._host_keys.add(hostname, key.get_name(), key)
+ client.save_host_keys(client._host_keys_filename)
+
+class SourceAdapter(HTTPAdapter):
+ """
+ urllib3 transport adapter for setting source addresses per session.
+ """
+ def __init__(self, source_pair, *args, **kwargs):
+ # A source pair is a tuple of a source host string and source port respectively.
+ # Supply '' and 0 respectively for default values.
+ self._source_pair = source_pair
+ super(SourceAdapter, self).__init__(*args, **kwargs)
+
+ def init_poolmanager(self, connections, maxsize, block=False):
+ self.poolmanager = PoolManager(
+ num_pools=connections, maxsize=maxsize,
+ block=block, source_address=self._source_pair)
+
+@contextmanager
+def umask(mask: int):
+ """
+ Context manager that temporarily sets the process umask.
+ """
+ import os
+ oldmask = os.umask(mask)
+ try:
+ yield
+ finally:
+ os.umask(oldmask)
+
+def check_storage(path, size):
+ """
+ Check whether `path` has enough storage space for a transfer of `size` bytes.
+ """
+ path = os.path.abspath(os.path.expanduser(path))
+ directory = path if os.path.isdir(path) else (os.path.dirname(os.path.expanduser(path)) or os.getcwd())
+ # `size` can be None or 0 to indicate unknown size.
+ if not size:
+ print_error('Warning: Cannot determine size of remote file. Bravely continuing regardless.')
+ return
+
+ if size < 1024 * 1024:
+ print_error(f'The file is {size / 1024.0:.3f} KiB.')
+ else:
+ print_error(f'The file is {size / (1024.0 * 1024.0):.3f} MiB.')
+
+ # Will throw `FileNotFoundError' if `directory' is absent.
+ if size > shutil.disk_usage(directory).free:
+ raise OSError(f'Not enough disk space available in "{directory}".')
+
+
+class FtpC:
+ def __init__(self,
+ url,
+ progressbar=False,
+ check_space=False,
+ source_host='',
+ source_port=0,
+ timeout=10):
+ self.secure = url.scheme == 'ftps'
+ self.hostname = url.hostname
+ self.path = url.path
+ self.username = url.username or os.getenv('REMOTE_USERNAME', 'anonymous')
+ self.password = url.password or os.getenv('REMOTE_PASSWORD', '')
+ self.port = url.port or 21
+ self.source = (source_host, source_port)
+ self.progressbar = progressbar
+ self.check_space = check_space
+ self.timeout = timeout
+
+ def _establish(self):
+ if self.secure:
+ return FTP_TLS(source_address=self.source,
+ context=ssl.create_default_context(),
+ timeout=self.timeout)
+ else:
+ return FTP(source_address=self.source, timeout=self.timeout)
+
+ def download(self, location: str):
+ # Open the file upfront before establishing connection.
+ with open(location, 'wb') as f, self._establish() as conn:
+ conn.connect(self.hostname, self.port)
+ conn.login(self.username, self.password)
+ # Set secure connection over TLS.
+ if self.secure:
+ conn.prot_p()
+ # Almost all FTP servers support the `SIZE' command.
+ size = conn.size(self.path)
+ if self.check_space:
+ check_storage(location, size)
+ # No progressbar if we can't determine the size or if the file is too small.
+ if self.progressbar and size and size > CHUNK_SIZE:
+ with Progressbar(CHUNK_SIZE / size) as p:
+ callback = lambda block: begin(f.write(block), p.increment())
+ conn.retrbinary('RETR ' + self.path, callback, CHUNK_SIZE)
+ else:
+ conn.retrbinary('RETR ' + self.path, f.write, CHUNK_SIZE)
+
+ def upload(self, location: str):
+ size = os.path.getsize(location)
+ with open(location, 'rb') as f, self._establish() as conn:
+ conn.connect(self.hostname, self.port)
+ conn.login(self.username, self.password)
+ if self.secure:
+ conn.prot_p()
+ if self.progressbar and size and size > CHUNK_SIZE:
+ with Progressbar(CHUNK_SIZE / size) as p:
+ conn.storbinary('STOR ' + self.path, f, CHUNK_SIZE, lambda block: p.increment())
+ else:
+ conn.storbinary('STOR ' + self.path, f, CHUNK_SIZE)
+
+class SshC:
+ known_hosts = os.path.expanduser('~/.ssh/known_hosts')
+ def __init__(self,
+ url,
+ progressbar=False,
+ check_space=False,
+ source_host='',
+ source_port=0,
+ timeout=10.0):
+ self.hostname = url.hostname
+ self.path = url.path
+ self.username = url.username or os.getenv('REMOTE_USERNAME')
+ self.password = url.password or os.getenv('REMOTE_PASSWORD')
+ self.port = url.port or 22
+ self.source = (source_host, source_port)
+ self.progressbar = progressbar
+ self.check_space = check_space
+ self.timeout = timeout
+
+ def _establish(self):
+ ssh = SSHClient()
+ ssh.load_system_host_keys()
+ # Try to load from a user-local known hosts file if one exists.
+ if os.path.exists(self.known_hosts):
+ ssh.load_host_keys(self.known_hosts)
+ ssh.set_missing_host_key_policy(InteractivePolicy())
+ # `socket.create_connection()` automatically picks a NIC and an IPv4/IPv6 address family
+ # for us on dual-stack systems.
+ sock = socket.create_connection((self.hostname, self.port), self.timeout, self.source)
+ ssh.connect(self.hostname, self.port, self.username, self.password, sock=sock)
+ return ssh
+
+ def download(self, location: str):
+ with self._establish() as ssh, ssh.open_sftp() as sftp:
+ if self.check_space:
+ check_storage(location, sftp.stat(self.path).st_size)
+ if self.progressbar:
+ with Progressbar() as p:
+ sftp.get(self.path, location, callback=p.progress)
+ else:
+ sftp.get(self.path, location)
+
+ def upload(self, location: str):
+ with self._establish() as ssh, ssh.open_sftp() as sftp:
+ try:
+ # If the remote path is a directory, use the original filename.
+ if stat.S_ISDIR(sftp.stat(self.path).st_mode):
+ path = os.path.join(self.path, os.path.basename(location))
+ # A file exists at this destination. We're simply going to clobber it.
+ else:
+ path = self.path
+ # This path doesn't point at any existing file. We can freely use this filename.
+ except IOError:
+ path = self.path
+ finally:
+ if self.progressbar:
+ with Progressbar() as p:
+ sftp.put(location, path, callback=p.progress)
+ else:
+ sftp.put(location, path)
+
+
+class HttpC:
+ def __init__(self,
+ url,
+ progressbar=False,
+ check_space=False,
+ source_host='',
+ source_port=0,
+ timeout=10.0):
+ self.urlstring = urllib.parse.urlunsplit(url)
+ self.progressbar = progressbar
+ self.check_space = check_space
+ self.source_pair = (source_host, source_port)
+ self.username = url.username or os.getenv('REMOTE_USERNAME')
+ self.password = url.password or os.getenv('REMOTE_PASSWORD')
+ self.timeout = timeout
+
+ def _establish(self):
+ session = Session()
+ session.mount(self.urlstring, SourceAdapter(self.source_pair))
+ session.headers.update({'User-Agent': 'VyOS/' + get_version()})
+ if self.username:
+ session.auth = self.username, self.password
+ return session
+
+ def download(self, location: str):
+ with self._establish() as s:
+ # We ask for uncompressed downloads so that we don't have to deal with decoding.
+ # Not only would it potentially mess up with the progress bar but
+ # `shutil.copyfileobj(request.raw, file)` does not handle automatic decoding.
+ s.headers.update({'Accept-Encoding': 'identity'})
+ with s.head(self.urlstring,
+ allow_redirects=True,
+ timeout=self.timeout) as r:
+ # Abort early if the destination is inaccessible.
+ r.raise_for_status()
+ # If the request got redirected, keep the last URL we ended up with.
+ final_urlstring = r.url
+ if r.history and self.progressbar:
+ print_error('Redirecting to ' + final_urlstring)
+ # Check for the prospective file size.
+ try:
+ size = int(r.headers['Content-Length'])
+ # In case the server does not supply the header.
+ except KeyError:
+ size = None
+ if self.check_space:
+ check_storage(location, size)
+ with s.get(final_urlstring, stream=True,
+ timeout=self.timeout) as r, open(location, 'wb') as f:
+ if self.progressbar and size:
+ with Progressbar(CHUNK_SIZE / size) as p:
+ for chunk in iter(lambda: begin(p.increment(), r.raw.read(CHUNK_SIZE)), b''):
+ f.write(chunk)
+ else:
+ # We'll try to stream the download directly with `copyfileobj()` so that large
+ # files (like entire VyOS images) don't occupy much memory.
+ shutil.copyfileobj(r.raw, f)
+
+ def upload(self, location: str):
+ # Does not yet support progressbars.
+ with self._establish() as s, open(location, 'rb') as f:
+ s.post(self.urlstring,
+ data=f,
+ allow_redirects=True,
+ timeout=self.timeout)
+
+
+class TftpC:
+ # We simply allow `curl` to take over because
+ # 1. TFTP is rather simple.
+ # 2. Since there's no concept authentication, we don't need to deal with keys/passwords.
+ # 3. It would be a waste to import, audit and maintain a third-party library for TFTP.
+ # 4. I'd rather not implement the entire protocol here, no matter how simple it is.
+ def __init__(self,
+ url,
+ progressbar=False,
+ check_space=False,
+ source_host=None,
+ source_port=0,
+ timeout=10):
+ source_option = f'--interface {source_host} --local-port {source_port}' if source_host else ''
+ progress_flag = '--progress-bar' if progressbar else '-s'
+ self.command = f'curl {source_option} {progress_flag} --connect-timeout {timeout}'
+ self.urlstring = urllib.parse.urlunsplit(url)
+
+ def download(self, location: str):
+ with open(location, 'wb') as f:
+ f.write(cmd(f'{self.command} "{self.urlstring}"').encode())
+
+ def upload(self, location: str):
+ with open(location, 'rb') as f:
+ cmd(f'{self.command} -T - "{self.urlstring}"', input=f.read())
+
+class GitC:
+ def __init__(self,
+ url,
+ progressbar=False,
+ check_space=False,
+ source_host=None,
+ source_port=0,
+ timeout=10,
+ ):
+ self.command = 'git'
+ self.url = url
+ self.urlstring = urllib.parse.urlunsplit(url)
+ if self.urlstring.startswith("git+"):
+ self.urlstring = self.urlstring.replace("git+", "", 1)
+
+ def download(self, location: str):
+ raise NotImplementedError("not supported")
+
+ @umask(0o077)
+ def upload(self, location: str):
+ scheme = self.url.scheme
+ _, _, scheme = scheme.partition("+")
+ netloc = self.url.netloc
+ url = Path(self.url.path).parent
+ with tempfile.TemporaryDirectory(prefix="git-commit-archive-") as directory:
+ # Determine username, fullname, email for Git commit
+ pwd_entry = pwd.getpwuid(os.getuid())
+ user = pwd_entry.pw_name
+ name = pwd_entry.pw_gecos.split(",")[0] or user
+ fqdn = socket.getfqdn()
+ email = f"{user}@{fqdn}"
+
+ # environment vars for our git commands
+ env = {
+ "GIT_TERMINAL_PROMPT": "0",
+ "GIT_AUTHOR_NAME": name,
+ "GIT_AUTHOR_EMAIL": email,
+ "GIT_COMMITTER_NAME": name,
+ "GIT_COMMITTER_EMAIL": email,
+ }
+
+ # build ssh command for git
+ ssh_command = ["ssh"]
+
+ # if we are not interactive, we use StrictHostKeyChecking=yes to avoid any prompts
+ if not sys.stdout.isatty():
+ ssh_command += ["-o", "StrictHostKeyChecking=yes"]
+
+ env["GIT_SSH_COMMAND"] = " ".join(ssh_command)
+
+ # git clone
+ path_repository = Path(directory) / "repository"
+ scheme = f"{scheme}://" if scheme else ""
+ rc, out = rc_cmd(
+ [self.command, "clone", f"{scheme}{netloc}{url}", str(path_repository), "--depth=1"],
+ env=env,
+ shell=False,
+ )
+ if rc:
+ raise Exception(out)
+
+ # git add
+ filename = Path(Path(self.url.path).name).stem
+ dst = path_repository / filename
+ shutil.copy2(location, dst)
+ rc, out = rc_cmd(
+ [self.command, "-C", str(path_repository), "add", filename],
+ env=env,
+ shell=False,
+ )
+
+ # git commit -m
+ commit_message = os.environ.get("COMMIT_COMMENT", "commit")
+ rc, out = rc_cmd(
+ [self.command, "-C", str(path_repository), "commit", "-m", commit_message],
+ env=env,
+ shell=False,
+ )
+
+ # git push
+ rc, out = rc_cmd(
+ [self.command, "-C", str(path_repository), "push"],
+ env=env,
+ shell=False,
+ )
+ if rc:
+ raise Exception(out)
+
+
+def urlc(urlstring, *args, **kwargs):
+ """
+ Dynamically dispatch the appropriate protocol class.
+ """
+ url_classes = {
+ "http": HttpC,
+ "https": HttpC,
+ "ftp": FtpC,
+ "ftps": FtpC,
+ "sftp": SshC,
+ "ssh": SshC,
+ "scp": SshC,
+ "tftp": TftpC,
+ "git": GitC,
+ }
+ url = urllib.parse.urlsplit(urlstring)
+ scheme, _, _ = url.scheme.partition("+")
+ try:
+ return url_classes[scheme](url, *args, **kwargs)
+ except KeyError:
+ raise ValueError(f'Unsupported URL scheme: "{scheme}"')
+
+def download(local_path, urlstring, progressbar=False, check_space=False,
+ source_host='', source_port=0, timeout=10.0, raise_error=False):
+ try:
+ progressbar = progressbar and is_interactive()
+ urlc(urlstring, progressbar, check_space, source_host, source_port, timeout).download(local_path)
+ except Exception as err:
+ if raise_error:
+ raise
+ print_error(f'Unable to download "{urlstring}": {err}')
+ sys.exit(1)
+ except KeyboardInterrupt:
+ print_error('\nDownload aborted by user.')
+ sys.exit(1)
+
+def upload(local_path, urlstring, progressbar=False,
+ source_host='', source_port=0, timeout=10.0):
+ try:
+ progressbar = progressbar and is_interactive()
+ urlc(urlstring, progressbar, False, source_host, source_port, timeout).upload(local_path)
+ except Exception as err:
+ print_error(f'Unable to upload "{urlstring}": {err}')
+ sys.exit(1)
+ except KeyboardInterrupt:
+ print_error('\nUpload aborted by user.')
+ sys.exit(1)
+
+def get_remote_config(urlstring, source_host='', source_port=0):
+ """
+ Quietly download a file and return it as a string.
+ """
+ temp = tempfile.NamedTemporaryFile(delete=False).name
+ try:
+ download(temp, urlstring, False, False, source_host, source_port)
+ with open(temp, 'r') as f:
+ return f.read()
+ finally:
+ os.remove(temp)
diff --git a/python/vyos/snmpv3_hashgen.py b/python/vyos/snmpv3_hashgen.py
new file mode 100644
index 0000000..324c327
--- /dev/null
+++ b/python/vyos/snmpv3_hashgen.py
@@ -0,0 +1,50 @@
+# Copyright 2020 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/>.
+
+# Documentation / Inspiration
+# - https://tools.ietf.org/html/rfc3414#appendix-A.3
+# - https://github.com/TheMysteriousX/SNMPv3-Hash-Generator
+
+key_length = 1048576
+
+def random(l):
+ # os.urandom(8) returns 8 bytes of random data
+ import os
+ from binascii import hexlify
+ return hexlify(os.urandom(l)).decode('utf-8')
+
+def expand(s, l):
+ """ repead input string (s) as long as we reach the desired length in bytes """
+ from itertools import repeat
+ reps = l // len(s) + 1 # approximation; worst case: overrun = l + len(s)
+ return ''.join(list(repeat(s, reps)))[:l].encode('utf-8')
+
+def plaintext_to_md5(passphrase, engine):
+ """ Convert input plaintext passphrase to MD5 hashed version usable by net-snmp """
+ from hashlib import md5
+ tmp = expand(passphrase, key_length)
+ hash = md5(tmp).digest()
+ engine = bytearray.fromhex(engine)
+ out = b''.join([hash, engine, hash])
+ return md5(out).digest().hex()
+
+def plaintext_to_sha1(passphrase, engine):
+ """ Convert input plaintext passphrase to SHA1hashed version usable by net-snmp """
+ from hashlib import sha1
+ tmp = expand(passphrase, key_length)
+ hash = sha1(tmp).digest()
+ engine = bytearray.fromhex(engine)
+ out = b''.join([hash, engine, hash])
+ return sha1(out).digest().hex()
diff --git a/python/vyos/system/__init__.py b/python/vyos/system/__init__.py
new file mode 100644
index 0000000..0c91330
--- /dev/null
+++ b/python/vyos/system/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2023 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/>.
+
+__all_: list[str] = ['disk', 'grub', 'image']
+# define image-tools version
+SYSTEM_CFG_VER = 1
diff --git a/python/vyos/system/compat.py b/python/vyos/system/compat.py
new file mode 100644
index 0000000..d35bdde
--- /dev/null
+++ b/python/vyos/system/compat.py
@@ -0,0 +1,337 @@
+# Copyright 2023-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/>.
+
+from pathlib import Path
+from re import compile, MULTILINE, DOTALL
+from functools import wraps
+from copy import deepcopy
+from typing import Union
+
+from vyos.system import disk, grub, image, SYSTEM_CFG_VER
+from vyos.template import render
+
+TMPL_GRUB_COMPAT: str = 'grub/grub_compat.j2'
+
+# define regexes and variables
+REGEX_VERSION = r'^menuentry "[^\n]*{\n[^}]*\s+linux /boot/(?P<version>\S+)/[^}]*}'
+REGEX_MENUENTRY = r'^menuentry "[^\n]*{\n[^}]*\s+linux /boot/(?P<version>\S+)/vmlinuz (?P<options>[^\n]+)\n[^}]*}'
+REGEX_CONSOLE = r'^.*console=(?P<console_type>[^\s\d]+)(?P<console_num>[\d]+)(,(?P<console_speed>[\d]+))?.*$'
+REGEX_SANIT_CONSOLE = r'\ ?console=[^\s\d]+[\d]+(,\d+)?\ ?'
+REGEX_SANIT_INIT = r'\ ?init=\S*\ ?'
+REGEX_SANIT_QUIET = r'\ ?quiet\ ?'
+PW_RESET_OPTION = 'init=/opt/vyatta/sbin/standalone_root_pw_reset'
+
+
+class DowngradingImageTools(Exception):
+ """Raised when attempting to add an image with an earlier version
+ of image-tools than the current system, as indicated by the value
+ of SYSTEM_CFG_VER or absence thereof."""
+ pass
+
+
+def mode():
+ if grub.get_cfg_ver() >= SYSTEM_CFG_VER:
+ return False
+
+ return True
+
+
+def find_versions(menu_entries: list) -> list:
+ """Find unique VyOS versions from menu entries
+
+ Args:
+ menu_entries (list): a list with menu entries
+
+ Returns:
+ list: List of installed versions
+ """
+ versions = []
+ for vyos_ver in menu_entries:
+ versions.append(vyos_ver.get('version'))
+ # remove duplicates
+ versions = list(set(versions))
+ return versions
+
+
+def filter_unparsed(grub_path: str) -> str:
+ """Find currently installed VyOS version
+
+ Args:
+ grub_path (str): a path to the grub.cfg file
+
+ Returns:
+ str: unparsed grub.cfg items
+ """
+ config_text = Path(grub_path).read_text()
+ regex_filter = compile(REGEX_VERSION, MULTILINE | DOTALL)
+ filtered = regex_filter.sub('', config_text)
+ regex_filter = compile(grub.REGEX_GRUB_VARS, MULTILINE)
+ filtered = regex_filter.sub('', filtered)
+ regex_filter = compile(grub.REGEX_GRUB_MODULES, MULTILINE)
+ filtered = regex_filter.sub('', filtered)
+ # strip extra new lines
+ filtered = filtered.strip()
+ return filtered
+
+
+def get_search_root(unparsed: str) -> str:
+ unparsed_lines = unparsed.splitlines()
+ search_root = next((x for x in unparsed_lines if 'search' in x), '')
+ return search_root
+
+
+def sanitize_boot_opts(boot_opts: str) -> str:
+ """Sanitize boot options from console and init
+
+ Args:
+ boot_opts (str): boot options
+
+ Returns:
+ str: sanitized boot options
+ """
+ regex_filter = compile(REGEX_SANIT_CONSOLE)
+ boot_opts = regex_filter.sub('', boot_opts)
+ regex_filter = compile(REGEX_SANIT_INIT)
+ boot_opts = regex_filter.sub('', boot_opts)
+ # legacy tools add 'quiet' on add system image; this is not desired
+ regex_filter = compile(REGEX_SANIT_QUIET)
+ boot_opts = regex_filter.sub(' ', boot_opts)
+
+ return boot_opts
+
+
+def parse_entry(entry: tuple) -> dict:
+ """Parse GRUB menuentry
+
+ Args:
+ entry (tuple): tuple of (version, options)
+
+ Returns:
+ dict: dictionary with parsed options
+ """
+ # save version to dict
+ entry_dict = {'version': entry[0]}
+ # detect boot mode type
+ if PW_RESET_OPTION in entry[1]:
+ entry_dict['bootmode'] = 'pw_reset'
+ else:
+ entry_dict['bootmode'] = 'normal'
+ # find console type and number
+ regex_filter = compile(REGEX_CONSOLE)
+ entry_dict.update(regex_filter.match(entry[1]).groupdict())
+ speed = entry_dict.get('console_speed', None)
+ entry_dict['console_speed'] = speed if speed is not None else '115200'
+ entry_dict['boot_opts'] = sanitize_boot_opts(entry[1])
+
+ return entry_dict
+
+
+def parse_menuentries(grub_path: str) -> list:
+ """Parse all GRUB menuentries
+
+ Args:
+ grub_path (str): a path to GRUB config file
+
+ Returns:
+ list: list with menu items (each item is a dict)
+ """
+ menuentries = []
+ # read configuration file
+ config_text = Path(grub_path).read_text()
+ # parse menuentries to tuples (version, options)
+ regex_filter = compile(REGEX_MENUENTRY, MULTILINE)
+ filter_results = regex_filter.findall(config_text)
+ # parse each entry
+ for entry in filter_results:
+ menuentries.append(parse_entry(entry))
+
+ return menuentries
+
+
+def prune_vyos_versions(root_dir: str = '') -> None:
+ """Delete vyos-versions files of registered images subsequently deleted
+ or renamed by legacy image-tools
+
+ Args:
+ root_dir (str): an optional path to the root directory
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ version_files = Path(f'{root_dir}/{grub.GRUB_DIR_VYOS_VERS}').glob('*.cfg')
+
+ for file in version_files:
+ version = Path(file).stem
+ if not Path(f'{root_dir}/boot/{version}').is_dir():
+ grub.version_del(version, root_dir)
+
+
+def update_cfg_ver(root_dir:str = '') -> int:
+ """Get minumum version of image-tools across all installed images
+
+ Args:
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ int: minimum version of image-tools
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ prune_vyos_versions(root_dir)
+
+ images_details = image.get_images_details()
+ cfg_version = min(d['tools_version'] for d in images_details)
+
+ return cfg_version
+
+
+def get_default(data: dict, root_dir: str = '') -> Union[int, None]:
+ """Translate default version to menuentry index
+
+ Args:
+ data (dict): boot data
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ int: index of default version in menu_entries or None
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+
+ menu_entries = data.get('versions', [])
+ console_type = data.get('console_type', 'tty')
+ console_num = data.get('console_num', '0')
+ image_name = image.get_default_image()
+
+ sublist = list(filter(lambda x: (x.get('version') == image_name and
+ x.get('console_type') == console_type and
+ x.get('bootmode') == 'normal'),
+ menu_entries))
+
+ if sublist:
+ return menu_entries.index(sublist[0])
+
+ return None
+
+
+def update_version_list(root_dir: str = '') -> list[dict]:
+ """Update list of dicts of installed version boot data
+
+ Args:
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ list: list of dicts of installed version boot data
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+
+ # get list of versions in menuentries
+ menu_entries = parse_menuentries(grub_cfg_main)
+ menu_versions = find_versions(menu_entries)
+
+ # remove deprecated console-type ttyUSB
+ menu_entries = list(filter(lambda x: x.get('console_type') != 'ttyUSB',
+ menu_entries))
+
+ # get list of versions added/removed by image-tools
+ current_versions = grub.version_list(root_dir)
+
+ remove = list(set(menu_versions) - set(current_versions))
+ for ver in remove:
+ menu_entries = list(filter(lambda x: x.get('version') != ver,
+ menu_entries))
+
+ # reset boot_opts in case of config update
+ for entry in menu_entries:
+ entry['boot_opts'] = grub.get_boot_opts(entry['version'])
+
+ add = list(set(current_versions) - set(menu_versions))
+ for ver in add:
+ last = menu_entries[0].get('version')
+ new = deepcopy(list(filter(lambda x: x.get('version') == last,
+ menu_entries)))
+ for e in new:
+ boot_opts = grub.get_boot_opts(ver)
+ e.update({'version': ver, 'boot_opts': boot_opts})
+
+ menu_entries = new + menu_entries
+
+ return menu_entries
+
+
+def grub_cfg_fields(root_dir: str = '') -> dict:
+ """Gather fields for rendering grub.cfg
+
+ Args:
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ dict: dictionary for rendering TMPL_GRUB_COMPAT
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+ grub_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}'
+
+ fields = grub.vars_read(grub_vars)
+ # 'default' and 'timeout' from legacy grub.cfg resets 'default' to
+ # index, rather than uuid
+ fields |= grub.vars_read(grub_cfg_main)
+
+ fields['tools_version'] = SYSTEM_CFG_VER
+ menu_entries = update_version_list(root_dir)
+ fields['versions'] = menu_entries
+
+ default = get_default(fields, root_dir)
+ if default is not None:
+ fields['default'] = default
+
+ modules = grub.modules_read(grub_cfg_main)
+ fields['modules'] = modules
+
+ unparsed = filter_unparsed(grub_cfg_main).splitlines()
+ search_root = next((x for x in unparsed if 'search' in x), '')
+ fields['search_root'] = search_root
+
+ return fields
+
+
+def render_grub_cfg(root_dir: str = '') -> None:
+ """Render grub.cfg for legacy compatibility"""
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+
+ fields = grub_cfg_fields(root_dir)
+ render(grub_cfg_main, TMPL_GRUB_COMPAT, fields)
+
+
+def grub_cfg_update(func):
+ """Decorator to update grub.cfg after function call"""
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ ret = func(*args, **kwargs)
+ if mode():
+ render_grub_cfg()
+ return ret
+ return wrapper
diff --git a/python/vyos/system/disk.py b/python/vyos/system/disk.py
new file mode 100644
index 0000000..c8908cd
--- /dev/null
+++ b/python/vyos/system/disk.py
@@ -0,0 +1,242 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from json import loads as json_loads
+from os import sync
+from dataclasses import dataclass
+from time import sleep
+
+from psutil import disk_partitions
+
+from vyos.utils.process import run, cmd
+
+
+@dataclass
+class DiskDetails:
+ """Disk details"""
+ name: str
+ partition: dict[str, str]
+
+
+def disk_cleanup(drive_path: str) -> None:
+ """Clean up disk partition table (MBR and GPT)
+ Remove partition and device signatures.
+ Zeroize primary and secondary headers - first and last 17408 bytes
+ (512 bytes * 34 LBA) on a drive
+
+ Args:
+ drive_path (str): path to a drive that needs to be cleaned
+ """
+ partitions: list[str] = partition_list(drive_path)
+ for partition in partitions:
+ run(f'wipefs -af {partition}')
+ run(f'wipefs -af {drive_path}')
+ run(f'sgdisk -Z {drive_path}')
+
+
+def find_persistence() -> str:
+ """Find a mountpoint for persistence storage
+
+ Returns:
+ str: Path where 'persistance' pertition is mounted, Empty if not found
+ """
+ mounted_partitions = disk_partitions()
+ for partition in mounted_partitions:
+ if partition.mountpoint.endswith('/persistence'):
+ return partition.mountpoint
+ return ''
+
+
+def parttable_create(drive_path: str, root_size: int) -> None:
+ """Create a hybrid MBR/GPT partition table
+ 0-2047 first sectors are free
+ 2048-4095 sectors - BIOS Boot Partition
+ 4096 + 256 MB - EFI system partition
+ Everything else till the end of a drive - Linux partition
+
+ Args:
+ drive_path (str): path to a drive
+ """
+ if not root_size:
+ root_size_text: str = '+100%'
+ else:
+ root_size_text: str = str(root_size)
+ command = f'sgdisk -a1 -n1:2048:4095 -t1:EF02 -n2:4096:+256M -t2:EF00 \
+ -n3:0:+{root_size_text}K -t3:8300 {drive_path}'
+
+ run(command)
+ # update partitons in kernel
+ sync()
+ run(f'partx -u {drive_path}')
+
+ partitions: list[str] = partition_list(drive_path)
+
+ disk: DiskDetails = DiskDetails(
+ name = drive_path,
+ partition = {
+ 'efi': next(x for x in partitions if x.endswith('2')),
+ 'root': next(x for x in partitions if x.endswith('3'))
+ }
+ )
+
+ return disk
+
+
+def partition_list(drive_path: str) -> list[str]:
+ """Get a list of partitions on a drive
+
+ Args:
+ drive_path (str): path to a drive
+
+ Returns:
+ list[str]: a list of partition paths
+ """
+ lsblk: str = cmd(f'lsblk -Jp {drive_path}')
+ drive_info: dict = json_loads(lsblk)
+ device: list = drive_info.get('blockdevices')
+ children: list[str] = device[0].get('children', []) if device else []
+ partitions: list[str] = [child.get('name') for child in children]
+ return partitions
+
+
+def partition_parent(partition_path: str) -> str:
+ """Get a parent device for a partition
+
+ Args:
+ partition (str): path to a partition
+
+ Returns:
+ str: path to a parent device
+ """
+ parent: str = cmd(f'lsblk -ndpo pkname {partition_path}')
+ return parent
+
+
+def from_partition(partition_path: str) -> DiskDetails:
+ drive_path: str = partition_parent(partition_path)
+ partitions: list[str] = partition_list(drive_path)
+
+ disk: DiskDetails = DiskDetails(
+ name = drive_path,
+ partition = {
+ 'efi': next(x for x in partitions if x.endswith('2')),
+ 'root': next(x for x in partitions if x.endswith('3'))
+ }
+ )
+
+ return disk
+
+def filesystem_create(partition: str, fstype: str) -> None:
+ """Create a filesystem on a partition
+
+ Args:
+ partition (str): path to a partition (for example: '/dev/sda1')
+ fstype (str): filesystem type ('efi' or 'ext4')
+ """
+ if fstype == 'efi':
+ command = 'mkfs -t fat -n EFI'
+ run(f'{command} {partition}')
+ if fstype == 'ext4':
+ command = 'mkfs -t ext4 -L persistence'
+ run(f'{command} {partition}')
+
+
+def partition_mount(partition: str,
+ path: str,
+ fsype: str = '',
+ overlay_params: dict[str, str] = {}) -> bool:
+ """Mount a partition into a path
+
+ Args:
+ partition (str): path to a partition (for example: '/dev/sda1')
+ path (str): a path where to mount
+ fsype (str): optionally, set fstype ('squashfs', 'overlay', 'iso9660')
+ overlay_params (dict): optionally, set overlay parameters.
+ Defaults to None.
+
+ Returns:
+ bool: True on success
+ """
+ if fsype in ['squashfs', 'iso9660']:
+ command: str = f'mount -o loop,ro -t {fsype} {partition} {path}'
+ if fsype == 'overlay' and overlay_params:
+ command: str = f'mount -t overlay -o noatime,\
+ upperdir={overlay_params["upperdir"]},\
+ lowerdir={overlay_params["lowerdir"]},\
+ workdir={overlay_params["workdir"]} overlay {path}'
+
+ else:
+ command = f'mount {partition} {path}'
+
+ rc = run(command)
+ if rc == 0:
+ return True
+
+ return False
+
+
+def partition_umount(partition: str = '', path: str = '') -> None:
+ """Umount a partition by a partition name or a path
+
+ Args:
+ partition (str): path to a partition (for example: '/dev/sda1')
+ path (str): a path where a partition is mounted
+ """
+ if partition:
+ command = f'umount {partition}'
+ run(command)
+ if path:
+ command = f'umount {path}'
+ run(command)
+
+
+def find_device(mountpoint: str) -> str:
+ """Find a device by mountpoint
+
+ Returns:
+ str: Path to device, Empty if not found
+ """
+ mounted_partitions = disk_partitions(all=True)
+ for partition in mounted_partitions:
+ if partition.mountpoint == mountpoint:
+ return partition.mountpoint
+ return ''
+
+
+def wait_for_umount(mountpoint: str = '') -> None:
+ """Wait (within reason) for umount to complete
+ """
+ i = 0
+ while find_device(mountpoint):
+ i += 1
+ if i == 5:
+ print(f'Warning: {mountpoint} still mounted')
+ break
+ sleep(1)
+
+
+def disks_size() -> dict[str, int]:
+ """Get a dictionary with physical disks and their sizes
+
+ Returns:
+ dict[str, int]: a dictionary with name: size mapping
+ """
+ disks_size: dict[str, int] = {}
+ lsblk: str = cmd('lsblk -Jbp')
+ blk_list = json_loads(lsblk)
+ for device in blk_list.get('blockdevices'):
+ if device['type'] == 'disk':
+ disks_size.update({device['name']: device['size']})
+ return disks_size
diff --git a/python/vyos/system/grub.py b/python/vyos/system/grub.py
new file mode 100644
index 0000000..de8303e
--- /dev/null
+++ b/python/vyos/system/grub.py
@@ -0,0 +1,464 @@
+# Copyright 2023-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/>.
+
+import platform
+
+from pathlib import Path
+from re import MULTILINE, compile as re_compile
+from shutil import copy2
+from uuid import uuid5, NAMESPACE_URL, UUID
+
+from vyos.template import render
+from vyos.utils.process import cmd, rc_cmd
+from vyos.system import disk
+
+# Define variables
+GRUB_DIR_MAIN: str = '/boot/grub'
+GRUB_CFG_MAIN: str = f'{GRUB_DIR_MAIN}/grub.cfg'
+GRUB_DIR_VYOS: str = f'{GRUB_DIR_MAIN}/grub.cfg.d'
+CFG_VYOS_HEADER: str = f'{GRUB_DIR_VYOS}/00-vyos-header.cfg'
+CFG_VYOS_MODULES: str = f'{GRUB_DIR_VYOS}/10-vyos-modules-autoload.cfg'
+CFG_VYOS_VARS: str = f'{GRUB_DIR_VYOS}/20-vyos-defaults-autoload.cfg'
+CFG_VYOS_COMMON: str = f'{GRUB_DIR_VYOS}/25-vyos-common-autoload.cfg'
+CFG_VYOS_PLATFORM: str = f'{GRUB_DIR_VYOS}/30-vyos-platform-autoload.cfg'
+CFG_VYOS_MENU: str = f'{GRUB_DIR_VYOS}/40-vyos-menu-autoload.cfg'
+CFG_VYOS_OPTIONS: str = f'{GRUB_DIR_VYOS}/50-vyos-options.cfg'
+GRUB_DIR_VYOS_VERS: str = f'{GRUB_DIR_VYOS}/vyos-versions'
+
+TMPL_VYOS_VERSION: str = 'grub/grub_vyos_version.j2'
+TMPL_GRUB_VARS: str = 'grub/grub_vars.j2'
+TMPL_GRUB_MAIN: str = 'grub/grub_main.j2'
+TMPL_GRUB_MENU: str = 'grub/grub_menu.j2'
+TMPL_GRUB_MODULES: str = 'grub/grub_modules.j2'
+TMPL_GRUB_OPTS: str = 'grub/grub_options.j2'
+TMPL_GRUB_COMMON: str = 'grub/grub_common.j2'
+
+# default boot options
+BOOT_OPTS_STEM: str = 'boot=live rootdelay=5 noautologin net.ifnames=0 biosdevname=0 vyos-union=/boot/'
+
+# prepare regexes
+REGEX_GRUB_VARS: str = r'^set (?P<variable_name>\w+)=[\'"]?(?P<variable_value>.*)(?<![\'"])[\'"]?$'
+REGEX_GRUB_MODULES: str = r'^insmod (?P<module_name>.+)$'
+REGEX_KERNEL_CMDLINE: str = r'^BOOT_IMAGE=/(?P<boot_type>boot|live)/((?P<image_version>.+)/)?vmlinuz.*$'
+REGEX_GRUB_BOOT_OPTS: str = r'^\s*set boot_opts="(?P<boot_opts>[^$]+)"$'
+
+
+def install(drive_path: str, boot_dir: str, efi_dir: str, id: str = 'VyOS', chroot : str = "") -> None:
+ """Install GRUB for both BIOS and EFI modes (hybrid boot)
+
+ Args:
+ drive_path (str): path to a drive where GRUB must be installed
+ boot_dir (str): a path to '/boot' directory
+ efi_dir (str): a path to '/boot/efi' directory
+ """
+
+ if chroot:
+ chroot_cmd = f"chroot {chroot}"
+ else:
+ chroot_cmd = ""
+
+ efi_installation_arch = "x86_64"
+ if platform.machine() == "aarch64":
+ efi_installation_arch = "arm64"
+ elif platform.machine() == "x86_64":
+ cmd(
+ f'{chroot_cmd} grub-install --no-floppy --target=i386-pc \
+ --boot-directory={boot_dir} {drive_path} --force'
+ )
+
+ cmd(
+ f'{chroot_cmd} grub-install --no-floppy --recheck --target={efi_installation_arch}-efi \
+ --force-extra-removable --boot-directory={boot_dir} \
+ --efi-directory={efi_dir} --bootloader-id="{id}" \
+ --uefi-secure-boot'
+ )
+
+
+def gen_version_uuid(version_name: str) -> str:
+ """Generate unique ID from version name
+
+ Use UUID5 / NAMESPACE_URL with prefix `uuid5-`
+
+ Args:
+ version_name (str): version name
+
+ Returns:
+ str: generated unique ID
+ """
+ ver_uuid: UUID = uuid5(NAMESPACE_URL, version_name)
+ ver_id: str = f'uuid5-{ver_uuid}'
+ return ver_id
+
+
+def version_add(version_name: str,
+ root_dir: str = '',
+ boot_opts: str = '',
+ boot_opts_config = None) -> None:
+ """Add a new VyOS version to GRUB loader configuration
+
+ Args:
+ vyos_version (str): VyOS version name
+ root_dir (str): an optional path to the root directory.
+ Defaults to empty.
+ boot_opts (str): an optional boot options for Linux kernel.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ version_config: str = f'{root_dir}/{GRUB_DIR_VYOS_VERS}/{version_name}.cfg'
+ render(
+ version_config, TMPL_VYOS_VERSION, {
+ 'version_name': version_name,
+ 'version_uuid': gen_version_uuid(version_name),
+ 'boot_opts_default': BOOT_OPTS_STEM + version_name,
+ 'boot_opts': boot_opts,
+ 'boot_opts_config': boot_opts_config
+ })
+
+
+def version_del(vyos_version: str, root_dir: str = '') -> None:
+ """Delete a VyOS version from GRUB loader configuration
+
+ Args:
+ vyos_version (str): VyOS version name
+ root_dir (str): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ version_config: str = f'{root_dir}/{GRUB_DIR_VYOS_VERS}/{vyos_version}.cfg'
+ Path(version_config).unlink(missing_ok=True)
+
+
+def version_list(root_dir: str = '') -> list[str]:
+ """Generate a list with installed VyOS versions
+
+ Args:
+ root_dir (str): an optional path to the root directory.
+ Defaults to empty.
+
+ Returns:
+ list: A list with versions names
+
+ N.B. coreutils stat reports st_birthtime, but not available in
+ Path.stat()/os.stat()
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ versions_files = Path(f'{root_dir}/{GRUB_DIR_VYOS_VERS}').glob('*.cfg')
+ versions_order: dict[str, int] = {}
+ for file in versions_files:
+ p = Path(root_dir).joinpath('boot').joinpath(file.stem)
+ command = f'stat -c %W {p.as_posix()}'
+ rc, out = rc_cmd(command)
+ if rc == 0:
+ versions_order[file.stem] = int(out)
+ versions_order = sorted(versions_order, key=versions_order.get, reverse=True)
+ versions_list: list[str] = list(versions_order)
+
+ return versions_list
+
+
+def read_env(env_file: str = '') -> dict[str, str]:
+ """Read GRUB environment
+
+ Args:
+ env_file (str, optional): a path to grub environment file.
+ Defaults to empty.
+
+ Returns:
+ dict: dictionary with GRUB environment
+ """
+ if not env_file:
+ root_dir: str = disk.find_persistence()
+ env_file = f'{root_dir}/{GRUB_DIR_MAIN}/grubenv'
+
+ env_content: str = cmd(f'grub-editenv {env_file} list').splitlines()
+ regex_filter = re_compile(r'^(?P<variable_name>.*)=(?P<variable_value>.*)$')
+ env_dict: dict[str, str] = {}
+ for env_item in env_content:
+ search_result = regex_filter.fullmatch(env_item)
+ if search_result:
+ search_result_dict: dict[str, str] = search_result.groupdict()
+ variable_name: str = search_result_dict.get('variable_name', '')
+ variable_value: str = search_result_dict.get('variable_value', '')
+ if variable_name and variable_value:
+ env_dict.update({variable_name: variable_value})
+ return env_dict
+
+
+def get_cfg_ver(root_dir: str = '') -> int:
+ """Get current version of GRUB configuration
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+
+ Returns:
+ int: a configuration version
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ cfg_ver: str = vars_read(f'{root_dir}/{CFG_VYOS_HEADER}').get(
+ 'VYOS_CFG_VER')
+ if cfg_ver:
+ cfg_ver_int: int = int(cfg_ver)
+ else:
+ cfg_ver_int: int = 0
+ return cfg_ver_int
+
+
+def write_cfg_ver(cfg_ver: int, root_dir: str = '') -> None:
+ """Write version number of GRUB configuration
+
+ Args:
+ cfg_ver (int): a version number to write
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+
+ Returns:
+ int: a configuration version
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{CFG_VYOS_HEADER}'
+ vars_current: dict[str, str] = vars_read(vars_file)
+ vars_current['VYOS_CFG_VER'] = str(cfg_ver)
+ vars_write(vars_file, vars_current)
+
+
+def vars_read(grub_cfg: str) -> dict[str, str]:
+ """Read variables from a GRUB configuration file
+
+ Args:
+ grub_cfg (str): a path to the GRUB config file
+
+ Returns:
+ dict: a dictionary with variables and values
+ """
+ vars_dict: dict[str, str] = {}
+ regex_filter = re_compile(REGEX_GRUB_VARS)
+ try:
+ config_text: list[str] = Path(grub_cfg).read_text().splitlines()
+ except FileNotFoundError:
+ return vars_dict
+ for line in config_text:
+ search_result = regex_filter.fullmatch(line)
+ if search_result:
+ search_dict = search_result.groupdict()
+ variable_name: str = search_dict.get('variable_name', '')
+ variable_value: str = search_dict.get('variable_value', '')
+ if variable_name and variable_value:
+ vars_dict.update({variable_name: variable_value})
+ return vars_dict
+
+
+def modules_read(grub_cfg: str) -> list[str]:
+ """Read modules list from a GRUB configuration file
+
+ Args:
+ grub_cfg (str): a path to the GRUB config file
+
+ Returns:
+ list: a list with modules to load
+ """
+ mods_list: list[str] = []
+ regex_filter = re_compile(REGEX_GRUB_MODULES, MULTILINE)
+ try:
+ config_text = Path(grub_cfg).read_text()
+ except FileNotFoundError:
+ return mods_list
+ mods_list = regex_filter.findall(config_text)
+
+ return mods_list
+
+
+def modules_write(grub_cfg: str, mods_list: list[str]) -> None:
+ """Write modules list to a GRUB configuration file (overwrite everything)
+
+ Args:
+ grub_cfg (str): a path to GRUB configuration file
+ mods_list (list): a list with modules to load
+ """
+ render(grub_cfg, TMPL_GRUB_MODULES, {'mods_list': mods_list})
+
+
+def vars_write(grub_cfg: str, grub_vars: dict[str, str]) -> None:
+ """Write variables to a GRUB configuration file (overwrite everything)
+
+ Args:
+ grub_cfg (str): a path to GRUB configuration file
+ grub_vars (dict): a dictionary with new variables
+ """
+ render(grub_cfg, TMPL_GRUB_VARS, {'vars': grub_vars})
+
+def get_boot_opts(version_name: str, root_dir: str = '') -> str:
+ """Read boot_opts setting from version file; return default setting on
+ any failure.
+
+ Args:
+ version_name (str): version name
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ boot_opts_default: str = BOOT_OPTS_STEM + version_name
+ boot_opts: str = ''
+ regex_filter = re_compile(REGEX_GRUB_BOOT_OPTS)
+ version_config: str = f'{root_dir}/{GRUB_DIR_VYOS_VERS}/{version_name}.cfg'
+ try:
+ config_text: list[str] = Path(version_config).read_text().splitlines()
+ except FileNotFoundError:
+ return boot_opts_default
+ for line in config_text:
+ search_result = regex_filter.fullmatch(line)
+ if search_result:
+ search_dict = search_result.groupdict()
+ boot_opts = search_dict.get('boot_opts', '')
+ break
+
+ if not boot_opts:
+ boot_opts = boot_opts_default
+
+ return boot_opts
+
+def set_default(version_name: str, root_dir: str = '') -> None:
+ """Set version as default boot entry
+
+ Args:
+ version_name (str): version name
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file = f'{root_dir}/{CFG_VYOS_VARS}'
+ vars_current = vars_read(vars_file)
+ vars_current['default'] = gen_version_uuid(version_name)
+ vars_write(vars_file, vars_current)
+
+
+def common_write(root_dir: str = '', grub_common: dict[str, str] = {}) -> None:
+ """Write common GRUB configuration file (overwrite everything)
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ common_config = f'{root_dir}/{CFG_VYOS_COMMON}'
+ render(common_config, TMPL_GRUB_COMMON, grub_common)
+
+
+def create_structure(root_dir: str = '') -> None:
+ """Create GRUB directories structure
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to ''.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ Path(f'{root_dir}/{GRUB_DIR_VYOS_VERS}').mkdir(parents=True, exist_ok=True)
+
+
+def set_console_type(console_type: str, root_dir: str = '') -> None:
+ """Write default console type to GRUB configuration
+
+ Args:
+ console_type (str): a default console type
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{CFG_VYOS_VARS}'
+ vars_current: dict[str, str] = vars_read(vars_file)
+ vars_current['console_type'] = str(console_type)
+ vars_write(vars_file, vars_current)
+
+def set_console_speed(console_speed: str, root_dir: str = '') -> None:
+ """Write default console speed to GRUB configuration
+
+ Args:
+ console_speed (str): default console speed
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{CFG_VYOS_VARS}'
+ vars_current: dict[str, str] = vars_read(vars_file)
+ vars_current['console_speed'] = str(console_speed)
+ vars_write(vars_file, vars_current)
+
+def set_kernel_cmdline_options(cmdline_options: str, version_name: str,
+ root_dir: str = '') -> None:
+ """Write additional cmdline options to GRUB configuration
+
+ Args:
+ cmdline_options (str): cmdline options to add to default boot line
+ version_name (str): image version name
+ root_dir (str, optional): an optional path to the root directory.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ version_add(version_name=version_name, root_dir=root_dir,
+ boot_opts_config=cmdline_options)
+
+
+def sort_inodes(dir_path: str) -> None:
+ """Sort inodes for files inside a folder
+ Regenerate inodes for each file to get the same order for both inodes
+ and file names
+
+ GRUB iterates files by inodes, not alphabetically. Therefore, if we
+ want to read them in proper order, we need to sort inodes for all
+ config files in a folder.
+
+ Args:
+ dir_path (str): a path to directory
+ """
+ dir_content: list[Path] = sorted(Path(dir_path).iterdir())
+ temp_list_old: list[Path] = []
+ temp_list_new: list[Path] = []
+
+ # create a copy of all files, to get new inodes
+ for item in dir_content:
+ # skip directories
+ if item.is_dir():
+ continue
+ # create a new copy of file with a temporary name
+ copy_path = Path(f'{item.as_posix()}_tmp')
+ copy2(item, Path(copy_path))
+ temp_list_old.append(item)
+ temp_list_new.append(copy_path)
+
+ # delete old files and rename new ones
+ for item in temp_list_old:
+ item.unlink()
+ for item in temp_list_new:
+ new_name = Path(f'{item.as_posix()[0:-4]}')
+ item.rename(new_name)
diff --git a/python/vyos/system/grub_util.py b/python/vyos/system/grub_util.py
new file mode 100644
index 0000000..4a3d879
--- /dev/null
+++ b/python/vyos/system/grub_util.py
@@ -0,0 +1,70 @@
+# Copyright 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/>.
+
+from vyos.system import disk, grub, image, compat
+
+@compat.grub_cfg_update
+def set_console_speed(console_speed: str, root_dir: str = '') -> None:
+ """Write default console speed to GRUB configuration
+
+ Args:
+ console_speed (str): default console speed
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub.set_console_speed(console_speed, root_dir)
+
+@image.if_not_live_boot
+def update_console_speed(console_speed: str, root_dir: str = '') -> None:
+ """Update console_speed if different from current value"""
+
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{grub.CFG_VYOS_VARS}'
+ vars_current: dict[str, str] = grub.vars_read(vars_file)
+ console_speed_current = vars_current.get('console_speed', None)
+ if console_speed != console_speed_current:
+ set_console_speed(console_speed, root_dir)
+
+@compat.grub_cfg_update
+def set_kernel_cmdline_options(cmdline_options: str, version: str = '',
+ root_dir: str = '') -> None:
+ """Write Kernel CLI cmdline options to GRUB configuration"""
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ if not version:
+ version = image.get_running_image()
+
+ grub.set_kernel_cmdline_options(cmdline_options, version, root_dir)
+
+@image.if_not_live_boot
+def update_kernel_cmdline_options(cmdline_options: str,
+ root_dir: str = '') -> 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}'
+
+ if boot_opts_proposed != boot_opts_current:
+ set_kernel_cmdline_options(cmdline_options, version, root_dir)
diff --git a/python/vyos/system/image.py b/python/vyos/system/image.py
new file mode 100644
index 0000000..aae52e7
--- /dev/null
+++ b/python/vyos/system/image.py
@@ -0,0 +1,283 @@
+# Copyright 2023-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/>.
+
+from pathlib import Path
+from re import compile as re_compile
+from functools import wraps
+from tempfile import TemporaryDirectory
+from typing import TypedDict
+from json import loads
+
+from vyos.defaults import directories
+from vyos.system import disk, grub
+
+# Define variables
+GRUB_DIR_MAIN: str = '/boot/grub'
+GRUB_DIR_VYOS: str = f'{GRUB_DIR_MAIN}/grub.cfg.d'
+CFG_VYOS_VARS: str = f'{GRUB_DIR_VYOS}/20-vyos-defaults-autoload.cfg'
+GRUB_DIR_VYOS_VERS: str = f'{GRUB_DIR_VYOS}/vyos-versions'
+# prepare regexes
+REGEX_KERNEL_CMDLINE: str = r'^BOOT_IMAGE=/(?P<boot_type>boot|live)/((?P<image_version>.+)/)?vmlinuz.*$'
+REGEX_SYSTEM_CFG_VER: str = r'(\r\n|\r|\n)SYSTEM_CFG_VER\s*=\s*(?P<cfg_ver>\d+)(\r\n|\r|\n)'
+
+
+# structures definitions
+class ImageDetails(TypedDict):
+ name: str
+ version: str
+ tools_version: int
+ disk_ro: int
+ disk_rw: int
+ disk_total: int
+
+
+class BootDetails(TypedDict):
+ image_default: str
+ image_running: str
+ images_available: list[str]
+ console_type: str
+ console_num: int
+
+
+def bootmode_detect() -> str:
+ """Detect system boot mode
+
+ Returns:
+ str: 'bios' or 'efi'
+ """
+ if Path('/sys/firmware/efi/').exists():
+ return 'efi'
+ else:
+ return 'bios'
+
+
+def get_image_version(mount_path: str) -> str:
+ """Extract version name from rootfs mounted at mount_path
+
+ Args:
+ mount_path (str): mount path of rootfs
+
+ Returns:
+ str: version name
+ """
+ version_file: str = Path(
+ f'{mount_path}/opt/vyatta/etc/version').read_text()
+ version_name: str = version_file.lstrip('Version: ').strip()
+
+ return version_name
+
+
+def get_image_tools_version(mount_path: str) -> int:
+ """Extract image-tools version from rootfs mounted at mount_path
+
+ Args:
+ mount_path (str): mount path of rootfs
+
+ Returns:
+ str: image-tools version
+ """
+ try:
+ version_file: str = Path(
+ f'{mount_path}/usr/lib/python3/dist-packages/vyos/system/__init__.py').read_text()
+ except FileNotFoundError:
+ system_cfg_ver: int = 0
+ else:
+ res = re_compile(REGEX_SYSTEM_CFG_VER).search(version_file)
+ system_cfg_ver: int = int(res.groupdict().get('cfg_ver', 0))
+
+ return system_cfg_ver
+
+
+def get_versions(image_name: str, root_dir: str = '') -> dict[str, str]:
+ """Return versions of image and image-tools
+
+ Args:
+ image_name (str): a name of an image
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to ''.
+
+ Returns:
+ dict[str, int]: a dictionary with versions of image and image-tools
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ squashfs_file: str = next(
+ Path(f'{root_dir}/boot/{image_name}').glob('*.squashfs')).as_posix()
+ with TemporaryDirectory() as squashfs_mounted:
+ disk.partition_mount(squashfs_file, squashfs_mounted, 'squashfs')
+
+ image_version: str = get_image_version(squashfs_mounted)
+ image_tools_version: int = get_image_tools_version(squashfs_mounted)
+
+ disk.partition_umount(squashfs_file)
+
+ versions: dict[str, int] = {
+ 'image': image_version,
+ 'image-tools': image_tools_version
+ }
+
+ return versions
+
+
+def get_details(image_name: str, root_dir: str = '') -> ImageDetails:
+ """Return information about image
+
+ Args:
+ image_name (str): a name of an image
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to ''.
+
+ Returns:
+ ImageDetails: a dictionary with details about an image (name, size)
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ versions = get_versions(image_name, root_dir)
+ image_version: str = versions.get('image', '')
+ image_tools_version: int = versions.get('image-tools', 0)
+
+ image_path: Path = Path(f'{root_dir}/boot/{image_name}')
+ image_path_rw: Path = Path(f'{root_dir}/boot/{image_name}/rw')
+
+ image_disk_ro: int = int()
+ for item in image_path.iterdir():
+ if not item.is_symlink():
+ image_disk_ro += item.stat().st_size
+
+ image_disk_rw: int = int()
+ for item in image_path_rw.rglob('*'):
+ if not item.is_symlink():
+ image_disk_rw += item.stat().st_size
+
+ image_details: ImageDetails = {
+ 'name': image_name,
+ 'version': image_version,
+ 'tools_version': image_tools_version,
+ 'disk_ro': image_disk_ro,
+ 'disk_rw': image_disk_rw,
+ 'disk_total': image_disk_ro + image_disk_rw
+ }
+
+ return image_details
+
+
+def get_images_details() -> list[ImageDetails]:
+ """Return information about all images
+
+ Returns:
+ list[ImageDetails]: a list of dictionaries with details about images
+ """
+ images: list[str] = grub.version_list()
+ images_details: list[ImageDetails] = list()
+ for image_name in images:
+ images_details.append(get_details(image_name))
+
+ return images_details
+
+
+def get_running_image() -> str:
+ """Find currently running image name
+
+ Returns:
+ str: image name
+ """
+ running_image: str = ''
+ regex_filter = re_compile(REGEX_KERNEL_CMDLINE)
+ cmdline: str = Path('/proc/cmdline').read_text()
+ running_image_result = regex_filter.match(cmdline)
+ if running_image_result:
+ running_image: str = running_image_result.groupdict().get(
+ 'image_version', '')
+ # we need to have a fallback for live systems:
+ # explicit read from version file
+ if not running_image:
+ json_data: str = Path(directories['data']).joinpath('version.json').read_text()
+ dict_data: dict = loads(json_data)
+ running_image: str = dict_data['version']
+
+ return running_image
+
+
+def get_default_image(root_dir: str = '') -> str:
+ """Get default boot entry
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ Returns:
+ str: a version name
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{CFG_VYOS_VARS}'
+ vars_current: dict[str, str] = grub.vars_read(vars_file)
+ default_uuid: str = vars_current.get('default', '')
+ if default_uuid:
+ images_list: list[str] = grub.version_list(root_dir)
+ for image_name in images_list:
+ if default_uuid == grub.gen_version_uuid(image_name):
+ return image_name
+ return ''
+ else:
+ return ''
+
+
+def validate_name(image_name: str) -> bool:
+ """Validate image name
+
+ Args:
+ image_name (str): suggested image name
+
+ Returns:
+ bool: validation result
+ """
+ regex_filter = re_compile(r'^[\w\.+-]{1,64}$')
+ if regex_filter.match(image_name):
+ return True
+ return False
+
+
+def is_live_boot() -> bool:
+ """Detect live booted system
+
+ Returns:
+ bool: True if the system currently booted in live mode
+ """
+ regex_filter = re_compile(REGEX_KERNEL_CMDLINE)
+ cmdline: str = Path('/proc/cmdline').read_text()
+ running_image_result = regex_filter.match(cmdline)
+ if running_image_result:
+ boot_type: str = running_image_result.groupdict().get('boot_type', '')
+ if boot_type == 'boot':
+ return False
+ return True
+
+def if_not_live_boot(func):
+ """Decorator to call function only if not live boot"""
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ if not is_live_boot():
+ ret = func(*args, **kwargs)
+ return ret
+ return None
+ return wrapper
+
+def is_running_as_container() -> bool:
+ if Path('/.dockerenv').exists():
+ return True
+ return False
diff --git a/python/vyos/system/raid.py b/python/vyos/system/raid.py
new file mode 100644
index 0000000..5b33d34
--- /dev/null
+++ b/python/vyos/system/raid.py
@@ -0,0 +1,122 @@
+# Copyright 2023 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/>.
+
+"""RAID related functions"""
+
+from pathlib import Path
+from shutil import copy
+from dataclasses import dataclass
+
+from vyos.utils.process import cmd, run
+from vyos.system import disk
+
+
+@dataclass
+class RaidDetails:
+ """RAID type"""
+ name: str
+ level: str
+ members: list[str]
+ disks: list[disk.DiskDetails]
+
+
+def raid_create(raid_members: list[str],
+ raid_name: str = 'md0',
+ raid_level: str = 'raid1') -> None:
+ """Create a RAID array
+
+ Args:
+ raid_name (str): a name of array (data, backup, test, etc.)
+ raid_members (list[str]): a list of array members
+ raid_level (str, optional): an array level. Defaults to 'raid1'.
+ """
+ raid_devices_num: int = len(raid_members)
+ raid_members_str: str = ' '.join(raid_members)
+ for part in raid_members:
+ drive: str = disk.partition_parent(part)
+ # set partition type GUID for raid member; cf.
+ # https://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_type_GUIDs
+ command: str = f'sgdisk --typecode=3:A19D880F-05FC-4D3B-A006-743F0F84911E {drive}'
+ cmd(command)
+ command: str = f'mdadm --create /dev/{raid_name} -R --metadata=1.0 \
+ --raid-devices={raid_devices_num} --level={raid_level} \
+ {raid_members_str}'
+
+ cmd(command)
+
+ raid = RaidDetails(
+ name = f'/dev/{raid_name}',
+ level = raid_level,
+ members = raid_members,
+ disks = [disk.from_partition(m) for m in raid_members]
+ )
+
+ return raid
+
+def clear():
+ """Deactivate all RAID arrays"""
+ command: str = 'mdadm --examine --scan'
+ raid_config = cmd(command)
+ if not raid_config:
+ return
+ command: str = 'mdadm --run /dev/md?*'
+ run(command)
+ command: str = 'mdadm --assemble --scan --auto=yes --symlink=no'
+ run(command)
+ command: str = 'mdadm --stop --scan'
+ run(command)
+
+
+def update_initramfs() -> None:
+ """Update initramfs"""
+ mdadm_script = '/etc/initramfs-tools/scripts/local-top/mdadm'
+ copy('/usr/share/initramfs-tools/scripts/local-block/mdadm', mdadm_script)
+ p = Path(mdadm_script)
+ p.write_text(p.read_text().replace('$((COUNT + 1))', '20'))
+ command: str = 'update-initramfs -u'
+ cmd(command)
+
+def update_default(target_dir: str) -> None:
+ """Update /etc/default/mdadm to start MD monitoring daemon at boot
+ """
+ source_mdadm_config = '/etc/default/mdadm'
+ target_mdadm_config = Path(target_dir).joinpath('/etc/default/mdadm')
+ target_mdadm_config_dir = Path(target_mdadm_config).parent
+ Path.mkdir(target_mdadm_config_dir, parents=True, exist_ok=True)
+ s = Path(source_mdadm_config).read_text().replace('START_DAEMON=false',
+ 'START_DAEMON=true')
+ Path(target_mdadm_config).write_text(s)
+
+def get_uuid(device: str) -> str:
+ """Get UUID of a device"""
+ command: str = f'tune2fs -l {device}'
+ l = cmd(command).splitlines()
+ uuid = next((x for x in l if x.startswith('Filesystem UUID')), '')
+ return uuid.split(':')[1].strip() if uuid else ''
+
+def get_uuids(raid_details: RaidDetails) -> tuple[str]:
+ """Get UUIDs of RAID members
+
+ Args:
+ raid_name (str): a name of array (data, backup, test, etc.)
+
+ Returns:
+ tuple[str]: root_disk uuid, root_md uuid
+ """
+ raid_name: str = raid_details.name
+ root_partition: str = raid_details.members[0]
+ uuid_root_disk: str = get_uuid(root_partition)
+ uuid_root_md: str = get_uuid(raid_name)
+ return uuid_root_disk, uuid_root_md
diff --git a/python/vyos/template.py b/python/vyos/template.py
new file mode 100644
index 0000000..be9f781
--- /dev/null
+++ b/python/vyos/template.py
@@ -0,0 +1,990 @@
+# 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
+# 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 functools
+import os
+
+from jinja2 import Environment
+from jinja2 import FileSystemLoader
+from jinja2 import ChainableUndefined
+from vyos.defaults import directories
+from vyos.utils.dict import dict_search_args
+from vyos.utils.file import makedir
+from vyos.utils.permission import chmod
+from vyos.utils.permission import chown
+
+# We use a mutable global variable for the default template directory
+# to make it possible to call scripts from this repository
+# outside of live VyOS systems.
+# If something (like the image build scripts)
+# want to call a script, they can modify the default location
+# to the repository path.
+DEFAULT_TEMPLATE_DIR = directories["templates"]
+
+# Holds template filters registered via register_filter()
+_FILTERS = {}
+_TESTS = {}
+
+# reuse Environments with identical settings to improve performance
+@functools.lru_cache(maxsize=2)
+def _get_environment(location=None):
+ from os import getenv
+
+ if location is None:
+ loc_loader=FileSystemLoader(DEFAULT_TEMPLATE_DIR)
+ else:
+ loc_loader=FileSystemLoader(location)
+ env = Environment(
+ # Don't check if template files were modified upon re-rendering
+ auto_reload=False,
+ # Cache up to this number of templates for quick re-rendering
+ cache_size=100,
+ loader=loc_loader,
+ trim_blocks=True,
+ undefined=ChainableUndefined,
+ extensions=['jinja2.ext.loopcontrols']
+ )
+ env.filters.update(_FILTERS)
+ env.tests.update(_TESTS)
+ return env
+
+
+def register_filter(name, func=None):
+ """Register a function to be available as filter 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 filter 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_filter, name)
+ if _get_environment.cache_info().currsize:
+ raise RuntimeError(
+ "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")
+ _FILTERS[name] = func
+ return func
+
+def register_test(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_test, name)
+ if _get_environment.cache_info().currsize:
+ raise RuntimeError(
+ "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")
+ _TESTS[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.
+
+ :param template: the path to the template relative to the template folder
+ :param content: the dictionary of variables to put into rendering context
+ :param formater:
+ if given, it has to be a callable the rendered string is passed through
+
+ The parsed template files are cached, so rendering the same file multiple times
+ does not cause as too much overhead.
+ If used everywhere, it could be changed to load the template from Python
+ environment variables from an importable Python module generated when the Debian
+ package is build (recovering the load time and overhead caused by having the
+ file out of the code).
+ """
+ template = _get_environment(location).get_template(template)
+ rendered = template.render(content)
+ if formater is not None:
+ rendered = formater(rendered)
+ return rendered
+
+
+def render(
+ destination,
+ template,
+ content,
+ formater=None,
+ permission=None,
+ user=None,
+ group=None,
+ location=None,
+):
+ """Render a template from the template directory to a file, raise on any errors.
+
+ :param destination: path to the file to save the rendered template in
+ :param permission: permission bitmask to set for the output file
+ :param user: user to own the output file
+ :param group: group to own the output file
+
+ All other parameters are as for :func:`render_to_string`.
+ """
+ # Create the directory if it does not exist
+ folder = os.path.dirname(destination)
+ makedir(folder, user, group)
+
+ # 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)
+
+ # Write to file
+ with open(destination, "w") as file:
+ chmod(file.fileno(), permission)
+ chown(file.fileno(), user, group)
+ file.write(rendered)
+
+
+##################################
+# Custom template filters follow #
+##################################
+@register_filter('force_to_list')
+def force_to_list(value):
+ """ Convert scalars to single-item lists and leave lists untouched """
+ if isinstance(value, list):
+ return value
+ else:
+ return [value]
+
+@register_filter('seconds_to_human')
+def seconds_to_human(seconds, separator=""):
+ """ Convert seconds to human-readable values like 1d6h15m23s """
+ from vyos.utils.convert import seconds_to_human
+ return seconds_to_human(seconds, separator=separator)
+
+@register_filter('bytes_to_human')
+def bytes_to_human(bytes, initial_exponent=0, precision=2):
+ """ Convert bytes to human-readable values like 1.44M """
+ from vyos.utils.convert import bytes_to_human
+ return bytes_to_human(bytes, initial_exponent=initial_exponent, precision=precision)
+
+@register_filter('human_to_bytes')
+def human_to_bytes(value):
+ """ Convert a data amount with a unit suffix to bytes, like 2K to 2048 """
+ from vyos.utils.convert import human_to_bytes
+ return human_to_bytes(value)
+
+@register_filter('ip_from_cidr')
+def ip_from_cidr(prefix):
+ """ Take an IPv4/IPv6 CIDR host and strip cidr mask.
+ Example:
+ 192.0.2.1/24 -> 192.0.2.1, 2001:db8::1/64 -> 2001:db8::1
+ """
+ from ipaddress import ip_interface
+ return str(ip_interface(prefix).ip)
+
+@register_filter('address_from_cidr')
+def address_from_cidr(prefix):
+ """ Take an IPv4/IPv6 CIDR prefix and convert the network to an "address".
+ Example:
+ 192.0.2.0/24 -> 192.0.2.0, 2001:db8::/48 -> 2001:db8::
+ """
+ from ipaddress import ip_network
+ return str(ip_network(prefix).network_address)
+
+@register_filter('bracketize_ipv6')
+def bracketize_ipv6(address):
+ """ Place a passed IPv6 address into [] brackets, do nothing for IPv4 """
+ if is_ipv6(address):
+ return f'[{address}]'
+ return address
+
+@register_filter('dot_colon_to_dash')
+def dot_colon_to_dash(text):
+ """ Replace dot and colon to dash for string
+ Example:
+ 192.0.2.1 => 192-0-2-1, 2001:db8::1 => 2001-db8--1
+ """
+ text = text.replace(":", "-")
+ text = text.replace(".", "-")
+ return text
+
+@register_filter('generate_uuid4')
+def generate_uuid4(text):
+ """ Generate random unique ID
+ Example:
+ % uuid4()
+ UUID('958ddf6a-ef14-4e81-8cfb-afb12456d1c5')
+ """
+ from uuid import uuid4
+ return uuid4()
+
+@register_filter('netmask_from_cidr')
+def netmask_from_cidr(prefix):
+ """ Take CIDR prefix and convert the prefix length to a "subnet mask".
+ Example:
+ - 192.0.2.0/24 -> 255.255.255.0
+ - 2001:db8::/48 -> ffff:ffff:ffff::
+ """
+ from ipaddress import ip_network
+ return str(ip_network(prefix).netmask)
+
+@register_filter('netmask_from_ipv4')
+def netmask_from_ipv4(address):
+ """ Take IP address and search all attached interface IP addresses for the
+ given one. After address has been found, return the associated netmask.
+
+ Example:
+ - 172.18.201.10 -> 255.255.255.128
+ """
+ from netifaces import interfaces
+ from netifaces import ifaddresses
+ from netifaces import AF_INET
+ for interface in interfaces():
+ tmp = ifaddresses(interface)
+ if AF_INET in tmp:
+ for af_addr in tmp[AF_INET]:
+ if 'addr' in af_addr:
+ if af_addr['addr'] == address:
+ return af_addr['netmask']
+
+ raise ValueError
+
+@register_filter('is_ip_network')
+def is_ip_network(addr):
+ """ Take IP(v4/v6) address and validate if the passed argument is a network
+ or a host address.
+
+ Example:
+ - 192.0.2.0 -> False
+ - 192.0.2.10/24 -> False
+ - 192.0.2.0/24 -> True
+ - 2001:db8:: -> False
+ - 2001:db8::100 -> False
+ - 2001:db8::/48 -> True
+ - 2001:db8:1000::/64 -> True
+ """
+ try:
+ from ipaddress import ip_network
+ # input variables must contain a / to indicate its CIDR notation
+ if len(addr.split('/')) != 2:
+ raise ValueError()
+ ip_network(addr)
+ return True
+ except:
+ return False
+
+@register_filter('network_from_ipv4')
+def network_from_ipv4(address):
+ """ Take IP address and search all attached interface IP addresses for the
+ given one. After address has been found, return the associated network
+ address.
+
+ Example:
+ - 172.18.201.10 has mask 255.255.255.128 -> network is 172.18.201.0
+ """
+ netmask = netmask_from_ipv4(address)
+ from ipaddress import ip_interface
+ cidr_prefix = ip_interface(f'{address}/{netmask}').network
+ return address_from_cidr(cidr_prefix)
+
+@register_filter('is_interface')
+def is_interface(interface):
+ """ Check if parameter is a valid local interface name """
+ from vyos.utils.network import interface_exists
+ return interface_exists(interface)
+
+@register_filter('is_ip')
+def is_ip(addr):
+ """ Check addr if it is an IPv4 or IPv6 address """
+ return is_ipv4(addr) or is_ipv6(addr)
+
+@register_filter('is_ipv4')
+def is_ipv4(text):
+ """ Filter IP address, return True on IPv4 address, False otherwise """
+ from ipaddress import ip_interface
+ try: return ip_interface(text).version == 4
+ except: return False
+
+@register_filter('is_ipv6')
+def is_ipv6(text):
+ """ Filter IP address, return True on IPv6 address, False otherwise """
+ from ipaddress import ip_interface
+ try: return ip_interface(text).version == 6
+ except: return False
+
+@register_filter('first_host_address')
+def first_host_address(prefix):
+ """ Return first usable (host) IP address from given prefix.
+ Example:
+ - 10.0.0.0/24 -> 10.0.0.1
+ - 2001:db8::/64 -> 2001:db8::
+ """
+ from ipaddress import ip_interface
+ tmp = ip_interface(prefix).network
+ return str(tmp.network_address +1)
+
+@register_filter('last_host_address')
+def last_host_address(text):
+ """ Return first usable IP address from given prefix.
+ Example:
+ - 10.0.0.0/24 -> 10.0.0.254
+ - 2001:db8::/64 -> 2001:db8::ffff:ffff:ffff:ffff
+ """
+ from ipaddress import ip_interface
+ from ipaddress import IPv4Network
+ from ipaddress import IPv6Network
+
+ addr = ip_interface(text)
+ if addr.version == 4:
+ return str(IPv4Network(addr).broadcast_address - 1)
+
+ return str(IPv6Network(addr).broadcast_address)
+
+@register_filter('inc_ip')
+def inc_ip(address, increment):
+ """ Increment given IP address by 'increment'
+
+ Example (inc by 2):
+ - 10.0.0.0/24 -> 10.0.0.2
+ - 2001:db8::/64 -> 2001:db8::2
+ """
+ from ipaddress import ip_interface
+ return str(ip_interface(address).ip + int(increment))
+
+@register_filter('dec_ip')
+def dec_ip(address, decrement):
+ """ Decrement given IP address by 'decrement'
+
+ Example (inc by 2):
+ - 10.0.0.0/24 -> 10.0.0.2
+ - 2001:db8::/64 -> 2001:db8::2
+ """
+ from ipaddress import ip_interface
+ return str(ip_interface(address).ip - int(decrement))
+
+@register_filter('compare_netmask')
+def compare_netmask(netmask1, netmask2):
+ """
+ Compare two IP netmask if they have the exact same size.
+
+ compare_netmask('10.0.0.0/8', '20.0.0.0/8') -> True
+ compare_netmask('10.0.0.0/8', '20.0.0.0/16') -> False
+ """
+ from ipaddress import ip_network
+ try:
+ return ip_network(netmask1).netmask == ip_network(netmask2).netmask
+ 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):
+ return os.path.isfile(filename)
+ return False
+
+@register_filter('get_dhcp_router')
+def get_dhcp_router(interface):
+ """ Static routes can point to a router received by a DHCP reply. This
+ helper is used to get the current default router from the DHCP reply.
+
+ Returns False of no router is found, returns the IP address as string if
+ a router is found.
+ """
+ lease_file = directories['isc_dhclient_dir'] + f'/dhclient_{interface}.leases'
+ if not os.path.exists(lease_file):
+ return None
+
+ from vyos.utils.file import read_file
+ for line in read_file(lease_file).splitlines():
+ if 'option routers' in line:
+ (_, _, address) = line.split()
+ return address.rstrip(';')
+
+@register_filter('natural_sort')
+def natural_sort(iterable):
+ import re
+ from jinja2.runtime import Undefined
+
+ if isinstance(iterable, Undefined) or iterable is None:
+ return list()
+
+ def convert(text):
+ return int(text) if text.isdigit() else text.lower()
+ def alphanum_key(key):
+ return [convert(c) for c in re.split('([0-9]+)', str(key))]
+
+ return sorted(iterable, key=alphanum_key)
+
+@register_filter('get_ipv4')
+def get_ipv4(interface):
+ """ Get interface IPv4 addresses"""
+ from vyos.ifconfig import Interface
+ return Interface(interface).get_addr_v4()
+
+@register_filter('get_ipv6')
+def get_ipv6(interface):
+ """ Get interface IPv6 addresses"""
+ from vyos.ifconfig import Interface
+ return Interface(interface).get_addr_v6()
+
+@register_filter('get_ip')
+def get_ip(interface):
+ """ Get interface IP addresses"""
+ from vyos.ifconfig import Interface
+ return Interface(interface).get_addr()
+
+def get_first_ike_dh_group(ike_group):
+ if ike_group and 'proposal' in ike_group:
+ for priority, proposal in ike_group['proposal'].items():
+ if 'dh_group' in proposal:
+ return 'dh-group' + proposal['dh_group']
+ return 'dh-group2' # Fallback on dh-group2
+
+@register_filter('get_esp_ike_cipher')
+def get_esp_ike_cipher(group_config, ike_group=None):
+ pfs_lut = {
+ 'dh-group1' : 'modp768',
+ 'dh-group2' : 'modp1024',
+ 'dh-group5' : 'modp1536',
+ 'dh-group14' : 'modp2048',
+ 'dh-group15' : 'modp3072',
+ 'dh-group16' : 'modp4096',
+ 'dh-group17' : 'modp6144',
+ 'dh-group18' : 'modp8192',
+ 'dh-group19' : 'ecp256',
+ 'dh-group20' : 'ecp384',
+ 'dh-group21' : 'ecp521',
+ 'dh-group22' : 'modp1024s160',
+ 'dh-group23' : 'modp2048s224',
+ 'dh-group24' : 'modp2048s256',
+ 'dh-group25' : 'ecp192',
+ 'dh-group26' : 'ecp224',
+ 'dh-group27' : 'ecp224bp',
+ 'dh-group28' : 'ecp256bp',
+ 'dh-group29' : 'ecp384bp',
+ 'dh-group30' : 'ecp512bp',
+ 'dh-group31' : 'curve25519',
+ 'dh-group32' : 'curve448'
+ }
+
+ ciphers = []
+ if 'proposal' in group_config:
+ for priority, proposal in group_config['proposal'].items():
+ # both encryption and hash need to be specified for a proposal
+ if not {'encryption', 'hash'} <= set(proposal):
+ continue
+
+ tmp = '{encryption}-{hash}'.format(**proposal)
+ if 'prf' in proposal:
+ tmp += '-' + proposal['prf']
+ if 'dh_group' in proposal:
+ tmp += '-' + pfs_lut[ 'dh-group' + proposal['dh_group'] ]
+ elif 'pfs' in group_config and group_config['pfs'] != 'disable':
+ group = group_config['pfs']
+ if group_config['pfs'] == 'enable':
+ group = get_first_ike_dh_group(ike_group)
+ tmp += '-' + pfs_lut[group]
+
+ ciphers.append(tmp)
+ return ciphers
+
+@register_filter('get_uuid')
+def get_uuid(seed):
+ """ Get interface IP addresses"""
+ if seed:
+ from hashlib import md5
+ from uuid import UUID
+ tmp = md5()
+ tmp.update(seed.encode('utf-8'))
+ return str(UUID(tmp.hexdigest()))
+ else:
+ from uuid import uuid1
+ return uuid1()
+
+openvpn_translate = {
+ 'des': 'des-cbc',
+ '3des': 'des-ede3-cbc',
+ 'bf128': 'bf-cbc',
+ 'bf256': 'bf-cbc',
+ 'aes128gcm': 'aes-128-gcm',
+ 'aes128': 'aes-128-cbc',
+ 'aes192gcm': 'aes-192-gcm',
+ 'aes192': 'aes-192-cbc',
+ 'aes256gcm': 'aes-256-gcm',
+ 'aes256': 'aes-256-cbc'
+}
+
+@register_filter('openvpn_cipher')
+def get_openvpn_cipher(cipher):
+ if cipher in openvpn_translate:
+ return openvpn_translate[cipher].upper()
+ return cipher.upper()
+
+@register_filter('openvpn_data_ciphers')
+def get_openvpn_data_ciphers(ciphers):
+ out = []
+ for cipher in ciphers:
+ if cipher in openvpn_translate:
+ out.append(openvpn_translate[cipher])
+ else:
+ out.append(cipher)
+ return ':'.join(out).upper()
+
+@register_filter('snmp_auth_oid')
+def snmp_auth_oid(type):
+ if type not in ['md5', 'sha', 'aes', 'des', 'none']:
+ raise ValueError()
+
+ OIDs = {
+ 'md5' : '.1.3.6.1.6.3.10.1.1.2',
+ 'sha' : '.1.3.6.1.6.3.10.1.1.3',
+ 'aes' : '.1.3.6.1.6.3.10.1.2.4',
+ 'des' : '.1.3.6.1.6.3.10.1.2.2',
+ 'none': '.1.3.6.1.6.3.10.1.2.1'
+ }
+ return OIDs[type]
+
+@register_filter('nft_action')
+def nft_action(vyos_action):
+ if vyos_action == 'accept':
+ return 'return'
+ return vyos_action
+
+@register_filter('nft_rule')
+def nft_rule(rule_conf, fw_hook, fw_name, rule_id, ip_name='ip'):
+ from vyos.firewall import parse_rule
+ return parse_rule(rule_conf, fw_hook, fw_name, rule_id, ip_name)
+
+@register_filter('nft_default_rule')
+def nft_default_rule(fw_conf, fw_name, family):
+ output = ['counter']
+ default_action = fw_conf['default_action']
+ #family = 'ipv6' if ipv6 else 'ipv4'
+
+ if 'default_log' in fw_conf:
+ action_suffix = default_action[:1].upper()
+ output.append(f'log prefix "[{family}-{fw_name[:19]}-default-{action_suffix}]"')
+
+ #output.append(nft_action(default_action))
+ output.append(f'{default_action}')
+ if 'default_jump_target' in fw_conf:
+ target = fw_conf['default_jump_target']
+ def_suffix = '6' if family == 'ipv6' else ''
+ output.append(f'NAME{def_suffix}_{target}')
+
+ output.append(f'comment "{fw_name} default-action {default_action}"')
+ return " ".join(output)
+
+@register_filter('nft_state_policy')
+def nft_state_policy(conf, state):
+ out = [f'ct state {state}']
+
+ if 'log' in conf:
+ log_state = state[:3].upper()
+ log_action = (conf['action'] if 'action' in conf else 'accept')[:1].upper()
+ out.append(f'log prefix "[STATE-POLICY-{log_state}-{log_action}]"')
+
+ if 'log_level' in conf:
+ log_level = conf['log_level']
+ out.append(f'level {log_level}')
+
+ out.append('counter')
+
+ if 'action' in conf:
+ out.append(conf['action'])
+
+ return " ".join(out)
+
+@register_filter('nft_intra_zone_action')
+def nft_intra_zone_action(zone_conf, ipv6=False):
+ if 'intra_zone_filtering' in zone_conf:
+ intra_zone = zone_conf['intra_zone_filtering']
+ fw_name = 'ipv6_name' if ipv6 else 'name'
+ name_prefix = 'NAME6_' if ipv6 else 'NAME_'
+
+ if 'action' in intra_zone:
+ if intra_zone['action'] == 'accept':
+ return 'return'
+ return intra_zone['action']
+ elif dict_search_args(intra_zone, 'firewall', fw_name):
+ name = dict_search_args(intra_zone, 'firewall', fw_name)
+ return f'jump {name_prefix}{name}'
+ return 'return'
+
+@register_filter('nft_nested_group')
+def nft_nested_group(out_list, includes, groups, key):
+ if not vyos_defined(out_list):
+ out_list = []
+
+ def add_includes(name):
+ if key in groups[name]:
+ for item in groups[name][key]:
+ if item in out_list:
+ continue
+ out_list.append(item)
+
+ if 'include' in groups[name]:
+ for name_inc in groups[name]['include']:
+ add_includes(name_inc)
+
+ for name in includes:
+ add_includes(name)
+ return out_list
+
+@register_filter('nat_rule')
+def nat_rule(rule_conf, rule_id, nat_type, ipv6=False):
+ from vyos.nat import parse_nat_rule
+ return parse_nat_rule(rule_conf, rule_id, nat_type, ipv6)
+
+@register_filter('nat_static_rule')
+def nat_static_rule(rule_conf, rule_id, nat_type):
+ from vyos.nat import parse_nat_static_rule
+ return parse_nat_static_rule(rule_conf, rule_id, nat_type)
+
+@register_filter('conntrack_rule')
+def conntrack_rule(rule_conf, rule_id, action, ipv6=False):
+ ip_prefix = 'ip6' if ipv6 else 'ip'
+ def_suffix = '6' if ipv6 else ''
+ output = []
+
+ if 'inbound_interface' in rule_conf:
+ ifname = rule_conf['inbound_interface']
+ if ifname != 'any':
+ output.append(f'iifname {ifname}')
+
+ if 'protocol' in rule_conf:
+ if action != 'timeout':
+ proto = rule_conf['protocol']
+ else:
+ for protocol, protocol_config in rule_conf['protocol'].items():
+ proto = protocol
+ if proto != 'all':
+ output.append(f'meta l4proto {proto}')
+
+ tcp_flags = dict_search_args(rule_conf, 'tcp', 'flags')
+ if tcp_flags and action != 'timeout':
+ from vyos.firewall import parse_tcp_flags
+ output.append(parse_tcp_flags(tcp_flags))
+
+ for side in ['source', 'destination']:
+ if side in rule_conf:
+ side_conf = rule_conf[side]
+ prefix = side[0]
+
+ if 'address' in side_conf:
+ address = side_conf['address']
+ operator = ''
+ if address[0] == '!':
+ operator = '!='
+ address = address[1:]
+ output.append(f'{ip_prefix} {prefix}addr {operator} {address}')
+
+ if 'port' in side_conf:
+ port = side_conf['port']
+ operator = ''
+ if port[0] == '!':
+ operator = '!='
+ port = port[1:]
+ output.append(f'th {prefix}port {operator} {port}')
+
+ if 'group' in side_conf:
+ group = side_conf['group']
+
+ if 'address_group' in group:
+ group_name = group['address_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'{ip_prefix} {prefix}addr {operator} @A{def_suffix}_{group_name}')
+ # Generate firewall group domain-group
+ elif 'domain_group' in group:
+ group_name = group['domain_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'{ip_prefix} {prefix}addr {operator} @D_{group_name}')
+ elif 'network_group' in group:
+ group_name = group['network_group']
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+ output.append(f'{ip_prefix} {prefix}addr {operator} @N{def_suffix}_{group_name}')
+ if 'port_group' in group:
+ group_name = group['port_group']
+
+ if proto == 'tcp_udp':
+ proto = 'th'
+
+ operator = ''
+ if group_name[0] == '!':
+ operator = '!='
+ group_name = group_name[1:]
+
+ output.append(f'{proto} {prefix}port {operator} @P_{group_name}')
+
+ if action == 'ignore':
+ output.append('counter notrack')
+ output.append(f'comment "ignore-{rule_id}"')
+ else:
+ output.append(f'counter ct timeout set ct-timeout-{rule_id}')
+ output.append(f'comment "timeout-{rule_id}"')
+
+ return " ".join(output)
+
+@register_filter('conntrack_ct_policy')
+def conntrack_ct_policy(protocol_conf):
+ output = []
+ for item in protocol_conf:
+ item_value = protocol_conf[item]
+ output.append(f'{item}: {item_value}')
+
+ return ", ".join(output)
+
+@register_filter('range_to_regex')
+def range_to_regex(num_range):
+ """Convert range of numbers or list of ranges
+ to regex
+
+ % range_to_regex('11-12')
+ '(1[1-2])'
+ % range_to_regex(['11-12', '14-15'])
+ '(1[1-2]|1[4-5])'
+ """
+ from vyos.range_regex import range_to_regex
+ if isinstance(num_range, list):
+ data = []
+ for entry in num_range:
+ if '-' not in entry:
+ data.append(entry)
+ else:
+ data.append(range_to_regex(entry))
+ return f'({"|".join(data)})'
+
+ if '-' not in num_range:
+ return num_range
+
+ regex = range_to_regex(num_range)
+ return f'({regex})'
+
+@register_filter('kea_address_json')
+def kea_address_json(addresses):
+ from json import dumps
+ from vyos.utils.network import is_addr_assigned
+
+ out = []
+
+ for address in addresses:
+ ifname = is_addr_assigned(address, return_ifname=True, include_vrf=True)
+
+ if not ifname:
+ continue
+
+ out.append(f'{ifname}/{address}')
+
+ return dumps(out)
+
+@register_filter('kea_high_availability_json')
+def kea_high_availability_json(config):
+ from json import dumps
+
+ source_addr = config['source_address']
+ remote_addr = config['remote']
+ ha_mode = 'hot-standby' if config['mode'] == 'active-passive' else 'load-balancing'
+ ha_role = config['status']
+
+ if ha_role == 'primary':
+ peer1_role = 'primary'
+ peer2_role = 'standby' if ha_mode == 'hot-standby' else 'secondary'
+ else:
+ peer1_role = 'standby' if ha_mode == 'hot-standby' else 'secondary'
+ peer2_role = 'primary'
+
+ data = {
+ 'this-server-name': os.uname()[1],
+ 'mode': ha_mode,
+ 'heartbeat-delay': 10000,
+ 'max-response-delay': 10000,
+ 'max-ack-delay': 5000,
+ 'max-unacked-clients': 0,
+ 'peers': [
+ {
+ 'name': os.uname()[1],
+ 'url': f'http://{source_addr}:647/',
+ 'role': peer1_role,
+ 'auto-failover': True
+ },
+ {
+ 'name': config['name'],
+ 'url': f'http://{remote_addr}:647/',
+ 'role': peer2_role,
+ 'auto-failover': True
+ }]
+ }
+
+ if 'ca_cert_file' in config:
+ data['trust-anchor'] = config['ca_cert_file']
+
+ if 'cert_file' in config:
+ data['cert-file'] = config['cert_file']
+
+ if 'cert_key_file' in config:
+ data['key-file'] = config['cert_key_file']
+
+ return dumps(data)
+
+@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 json import dumps
+ out = []
+
+ for name, config in shared_networks.items():
+ if 'disable' in config:
+ continue
+
+ network = {
+ 'name': name,
+ 'authoritative': ('authoritative' in config),
+ 'subnet4': []
+ }
+
+ if 'option' in config:
+ network['option-data'] = kea_parse_options(config['option'])
+
+ if 'bootfile_name' in config['option']:
+ network['boot-file-name'] = config['option']['bootfile_name']
+
+ if 'bootfile_server' in config['option']:
+ network['next-server'] = config['option']['bootfile_server']
+
+ if 'subnet' in config:
+ for subnet, subnet_config in config['subnet'].items():
+ if 'disable' in subnet_config:
+ continue
+ network['subnet4'].append(kea_parse_subnet(subnet, subnet_config))
+
+ out.append(network)
+
+ return dumps(out, indent=4)
+
+@register_filter('kea6_shared_network_json')
+def kea6_shared_network_json(shared_networks):
+ from vyos.kea import kea6_parse_options
+ from vyos.kea import kea6_parse_subnet
+ from json import dumps
+ out = []
+
+ for name, config in shared_networks.items():
+ if 'disable' in config:
+ continue
+
+ network = {
+ 'name': name,
+ 'subnet6': []
+ }
+
+ if 'option' in config:
+ network['option-data'] = kea6_parse_options(config['option'])
+
+ if 'interface' in config:
+ network['interface'] = config['interface']
+
+ if 'subnet' in config:
+ for subnet, subnet_config in config['subnet'].items():
+ network['subnet6'].append(kea6_parse_subnet(subnet, subnet_config))
+
+ out.append(network)
+
+ return dumps(out, indent=4)
+
+@register_test('vyos_defined')
+def vyos_defined(value, test_value=None, var_type=None):
+ """
+ Jinja2 plugin to test if a variable is defined and not none - vyos_defined
+ will test value if defined and is not none and return true or false.
+
+ If test_value is supplied, the value must also pass == test_value to return true.
+ If var_type is supplied, the value must also be of the specified class/type
+
+ Examples:
+ 1. Test if var is defined and not none:
+ {% if foo is vyos_defined %}
+ ...
+ {% endif %}
+
+ 2. Test if variable is defined, not none and has value "something"
+ {% if bar is vyos_defined("something") %}
+ ...
+ {% endif %}
+
+ Parameters
+ ----------
+ value : any
+ Value to test from ansible
+ test_value : any, optional
+ Value to test in addition of defined and not none, by default None
+ var_type : ['float', 'int', 'str', 'list', 'dict', 'tuple', 'bool'], optional
+ Type or Class to test for
+
+ Returns
+ -------
+ boolean
+ True if variable matches criteria, False in other cases.
+
+ Implementation inspired and re-used from https://github.com/aristanetworks/ansible-avd/
+ """
+
+ from jinja2 import Undefined
+
+ if isinstance(value, Undefined) or value is None:
+ # Invalid value - return false
+ return False
+ elif test_value is not None and value != test_value:
+ # Valid value but not matching the optional argument
+ return False
+ elif str(var_type).lower() in ['float', 'int', 'str', 'list', 'dict', 'tuple', 'bool'] and str(var_type).lower() != type(value).__name__:
+ # Invalid class - return false
+ return False
+ else:
+ # Valid value and is matching optional argument if provided - return true
+ return True
diff --git a/python/vyos/tpm.py b/python/vyos/tpm.py
new file mode 100644
index 0000000..a24f149
--- /dev/null
+++ b/python/vyos/tpm.py
@@ -0,0 +1,96 @@
+# 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
+import tempfile
+
+from vyos.utils.process import rc_cmd
+
+default_pcrs = ['0','2','4','7']
+tpm_handle = 0x81000000
+
+def init_tpm(clear=False):
+ """
+ Initialize TPM
+ """
+ code, output = rc_cmd('tpm2_startup' + (' -c' if clear else ''))
+ if code != 0:
+ raise Exception('init_tpm: Failed to initialize TPM')
+
+def clear_tpm_key():
+ """
+ Clear existing key on TPM
+ """
+ code, output = rc_cmd(f'tpm2_evictcontrol -C o -c {tpm_handle}')
+ if code != 0:
+ raise Exception('clear_tpm_key: Failed to clear TPM key')
+
+def read_tpm_key(index=0, pcrs=default_pcrs):
+ """
+ Read existing key on TPM
+ """
+ with tempfile.TemporaryDirectory() as tpm_dir:
+ pcr_str = ",".join(pcrs)
+
+ tpm_key_file = os.path.join(tpm_dir, 'tpm_key.key')
+ code, output = rc_cmd(f'tpm2_unseal -c {tpm_handle + index} -p pcr:sha256:{pcr_str} -o {tpm_key_file}')
+ if code != 0:
+ raise Exception('read_tpm_key: Failed to read key from TPM')
+
+ with open(tpm_key_file, 'rb') as f:
+ tpm_key = f.read()
+
+ return tpm_key
+
+def write_tpm_key(key, index=0, pcrs=default_pcrs):
+ """
+ Saves key to TPM
+ """
+ with tempfile.TemporaryDirectory() as tpm_dir:
+ pcr_str = ",".join(pcrs)
+
+ policy_file = os.path.join(tpm_dir, 'policy.digest')
+ code, output = rc_cmd(f'tpm2_createpolicy --policy-pcr -l sha256:{pcr_str} -L {policy_file}')
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to create policy digest')
+
+ primary_context_file = os.path.join(tpm_dir, 'primary.ctx')
+ code, output = rc_cmd(f'tpm2_createprimary -C e -g sha256 -G rsa -c {primary_context_file}')
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to create primary key')
+
+ key_file = os.path.join(tpm_dir, 'crypt.key')
+ with open(key_file, 'wb') as f:
+ f.write(key)
+
+ public_obj = os.path.join(tpm_dir, 'obj.pub')
+ private_obj = os.path.join(tpm_dir, 'obj.key')
+ code, output = rc_cmd(
+ f'tpm2_create -g sha256 \
+ -u {public_obj} -r {private_obj} \
+ -C {primary_context_file} -L {policy_file} -i {key_file}')
+
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to create object')
+
+ load_context_file = os.path.join(tpm_dir, 'load.ctx')
+ code, output = rc_cmd(f'tpm2_load -C {primary_context_file} -u {public_obj} -r {private_obj} -c {load_context_file}')
+
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to load object')
+
+ code, output = rc_cmd(f'tpm2_evictcontrol -c {load_context_file} -C o {tpm_handle + index}')
+
+ if code != 0:
+ raise Exception('write_tpm_key: Failed to write object to TPM')
diff --git a/python/vyos/utils/__init__.py b/python/vyos/utils/__init__.py
new file mode 100644
index 0000000..3759b21
--- /dev/null
+++ b/python/vyos/utils/__init__.py
@@ -0,0 +1,33 @@
+# Copyright 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/>.
+
+from vyos.utils import assertion
+from vyos.utils import auth
+from vyos.utils import boot
+from vyos.utils import commit
+from vyos.utils import configfs
+from vyos.utils import convert
+from vyos.utils import cpu
+from vyos.utils import dict
+from vyos.utils import file
+from vyos.utils import io
+from vyos.utils import kernel
+from vyos.utils import list
+from vyos.utils import locking
+from vyos.utils import misc
+from vyos.utils import network
+from vyos.utils import permission
+from vyos.utils import process
+from vyos.utils import system
diff --git a/python/vyos/utils/assertion.py b/python/vyos/utils/assertion.py
new file mode 100644
index 0000000..c7fa220
--- /dev/null
+++ b/python/vyos/utils/assertion.py
@@ -0,0 +1,81 @@
+# Copyright 2023 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/>.
+
+def assert_boolean(b):
+ if int(b) not in (0, 1):
+ raise ValueError(f'Value {b} out of range')
+
+def assert_range(value, lower=0, count=3):
+ if int(value, 16) not in range(lower, lower+count):
+ raise ValueError("Value out of range")
+
+def assert_list(s, l):
+ if s not in l:
+ o = ' or '.join([f'"{n}"' for n in l])
+ raise ValueError(f'state must be {o}, got {s}')
+
+def assert_number(n):
+ if not str(n).isnumeric():
+ raise ValueError(f'{n} must be a number')
+
+def assert_positive(n, smaller=0):
+ assert_number(n)
+ if int(n) < smaller:
+ raise ValueError(f'{n} is smaller than {smaller}')
+
+def assert_mtu(mtu, ifname):
+ assert_number(mtu)
+
+ import json
+ from vyos.utils.process import cmd
+ out = cmd(f'ip -j -d link show dev {ifname}')
+ # [{"ifindex":2,"ifname":"eth0","flags":["BROADCAST","MULTICAST","UP","LOWER_UP"],"mtu":1500,"qdisc":"pfifo_fast","operstate":"UP","linkmode":"DEFAULT","group":"default","txqlen":1000,"link_type":"ether","address":"08:00:27:d9:5b:04","broadcast":"ff:ff:ff:ff:ff:ff","promiscuity":0,"min_mtu":46,"max_mtu":16110,"inet6_addr_gen_mode":"none","num_tx_queues":1,"num_rx_queues":1,"gso_max_size":65536,"gso_max_segs":65535}]
+ parsed = json.loads(out)[0]
+ min_mtu = int(parsed.get('min_mtu', '0'))
+ # cur_mtu = parsed.get('mtu',0),
+ max_mtu = int(parsed.get('max_mtu', '0'))
+ cur_mtu = int(mtu)
+
+ if (min_mtu and cur_mtu < min_mtu) or cur_mtu < 68:
+ raise ValueError(f'MTU is too small for interface "{ifname}": {mtu} < {min_mtu}')
+ if (max_mtu and cur_mtu > max_mtu) or cur_mtu > 65536:
+ raise ValueError(f'MTU is too small for interface "{ifname}": {mtu} > {max_mtu}')
+
+def assert_mac(m, test_all_zero=True):
+ split = m.split(':')
+ size = len(split)
+
+ # a mac address consits out of 6 octets
+ if size != 6:
+ raise ValueError(f'wrong number of MAC octets ({size}): {m}')
+
+ octets = []
+ try:
+ for octet in split:
+ octets.append(int(octet, 16))
+ except ValueError:
+ raise ValueError(f'invalid hex number "{octet}" in : {m}')
+
+ # validate against the first mac address byte if it's a multicast
+ # address
+ if octets[0] & 1:
+ raise ValueError(f'{m} is a multicast MAC address')
+
+ # overall mac address is not allowed to be 00:00:00:00:00:00
+ if test_all_zero and sum(octets) == 0:
+ raise ValueError('00:00:00:00:00:00 is not a valid MAC address')
+
+ if octets[:5] == (0, 0, 94, 0, 1):
+ raise ValueError(f'{m} is a VRRP MAC address')
diff --git a/python/vyos/utils/auth.py b/python/vyos/utils/auth.py
new file mode 100644
index 0000000..a0b3e1c
--- /dev/null
+++ b/python/vyos/utils/auth.py
@@ -0,0 +1,51 @@
+# authutils -- miscelanneous functions for handling passwords and publis keys
+#
+# Copyright (C) 2023-2024 VyOS maintainers and contributors
+#
+# This library is free software; you can redistribute it and/or modify it under the terms of
+# the GNU Lesser General Public License as published by the Free Software Foundation;
+# either version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+# See the GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along with this library;
+# if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+
+import re
+
+from vyos.utils.process import cmd
+
+def make_password_hash(password):
+ """ Makes a password hash for /etc/shadow using mkpasswd """
+
+ mkpassword = 'mkpasswd --method=sha-512 --stdin'
+ return cmd(mkpassword, input=password, timeout=5)
+
+def split_ssh_public_key(key_string, defaultname=""):
+ """ Splits an SSH public key into its components """
+
+ key_string = key_string.strip()
+ parts = re.split(r'\s+', key_string)
+
+ if len(parts) == 3:
+ key_type, key_data, key_name = parts[0], parts[1], parts[2]
+ else:
+ key_type, key_data, key_name = parts[0], parts[1], defaultname
+
+ if key_type not in ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519']:
+ raise ValueError("Bad key type \'{0}\', must be one of must be one of ssh-rsa, ssh-dss, ecdsa-sha2-nistp<256|384|521> or ssh-ed25519".format(key_type))
+
+ return({"type": key_type, "data": key_data, "name": key_name})
+
+def get_current_user() -> str:
+ import os
+ current_user = 'nobody'
+ # During CLI "owner" script execution we use SUDO_USER
+ if 'SUDO_USER' in os.environ:
+ current_user = os.environ['SUDO_USER']
+ # During op-mode or config-mode interactive CLI we use USER
+ elif 'USER' in os.environ:
+ current_user = os.environ['USER']
+ return current_user
diff --git a/python/vyos/utils/boot.py b/python/vyos/utils/boot.py
new file mode 100644
index 0000000..708bef1
--- /dev/null
+++ b/python/vyos/utils/boot.py
@@ -0,0 +1,39 @@
+# Copyright 2023-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/>.
+
+import os
+
+def boot_configuration_complete() -> bool:
+ """ Check if the boot config loader has completed
+ """
+ from vyos.defaults import config_status
+ if os.path.isfile(config_status):
+ return True
+ return False
+
+def boot_configuration_success() -> bool:
+ from vyos.defaults import config_status
+ try:
+ with open(config_status) as f:
+ res = f.read().strip()
+ except FileNotFoundError:
+ return False
+ if int(res) == 0:
+ return True
+ return False
+
+def is_uefi_system() -> bool:
+ efi_fw_dir = '/sys/firmware/efi'
+ return os.path.exists(efi_fw_dir) and os.path.isdir(efi_fw_dir)
diff --git a/python/vyos/utils/commit.py b/python/vyos/utils/commit.py
new file mode 100644
index 0000000..105aed8
--- /dev/null
+++ b/python/vyos/utils/commit.py
@@ -0,0 +1,60 @@
+# Copyright 2023 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/>.
+
+def commit_in_progress():
+ """ Not to be used in normal op mode scripts! """
+
+ # The CStore backend locks the config by opening a file
+ # The file is not removed after commit, so just checking
+ # if it exists is insufficient, we need to know if it's open by anyone
+
+ # There are two ways to check if any other process keeps a file open.
+ # The first one is to try opening it and see if the OS objects.
+ # That's faster but prone to race conditions and can be intrusive.
+ # The other one is to actually check if any process keeps it open.
+ # It's non-intrusive but needs root permissions, else you can't check
+ # processes of other users.
+ #
+ # Since this will be used in scripts that modify the config outside of the CLI
+ # framework, those knowingly have root permissions.
+ # For everything else, we add a safeguard.
+ from psutil import process_iter
+ from psutil import NoSuchProcess
+ from getpass import getuser
+ from vyos.defaults import commit_lock
+
+ if getuser() != 'root':
+ raise OSError('This functions needs to be run as root to return correct results!')
+
+ for proc in process_iter():
+ try:
+ files = proc.open_files()
+ if files:
+ for f in files:
+ if f.path == commit_lock:
+ return True
+ except NoSuchProcess as err:
+ # Process died before we could examine it
+ pass
+ # Default case
+ return False
+
+
+def wait_for_commit_lock():
+ """ Not to be used in normal op mode scripts! """
+ from time import sleep
+ # Very synchronous approach to multiprocessing
+ while commit_in_progress():
+ sleep(1)
diff --git a/python/vyos/utils/config.py b/python/vyos/utils/config.py
new file mode 100644
index 0000000..3304701
--- /dev/null
+++ b/python/vyos/utils/config.py
@@ -0,0 +1,39 @@
+# Copyright 2023-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/>.
+
+import os
+from vyos.defaults import directories
+
+config_file = os.path.join(directories['config'], 'config.boot')
+
+def read_saved_value(path: list):
+ if not isinstance(path, list) or not path:
+ return ''
+ from vyos.configtree import ConfigTree
+ try:
+ with open(config_file) as f:
+ config_string = f.read()
+ ct = ConfigTree(config_string)
+ except Exception:
+ return ''
+ if not ct.exists(path):
+ return ''
+ res = ct.return_values(path)
+ if len(res) == 1:
+ return res[0]
+ res = ct.list_nodes(path)
+ if len(res) == 1:
+ return ' '.join(res)
+ return res
diff --git a/python/vyos/utils/configfs.py b/python/vyos/utils/configfs.py
new file mode 100644
index 0000000..8617f01
--- /dev/null
+++ b/python/vyos/utils/configfs.py
@@ -0,0 +1,37 @@
+# Copyright 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/>.
+
+import os
+
+def delete_cli_node(cli_path: list):
+ from shutil import rmtree
+ for config_dir in ['VYATTA_TEMP_CONFIG_DIR', 'VYATTA_CHANGES_ONLY_DIR']:
+ tmp = os.path.join(os.environ[config_dir], '/'.join(cli_path))
+ # delete CLI node
+ if os.path.exists(tmp):
+ rmtree(tmp)
+
+def add_cli_node(cli_path: list, value: str=None):
+ from vyos.utils.auth import get_current_user
+ from vyos.utils.file import write_file
+
+ current_user = get_current_user()
+ for config_dir in ['VYATTA_TEMP_CONFIG_DIR', 'VYATTA_CHANGES_ONLY_DIR']:
+ # store new value
+ tmp = os.path.join(os.environ[config_dir], '/'.join(cli_path))
+ write_file(f'{tmp}/node.val', value, user=current_user, group='vyattacfg', mode=0o664)
+ # mark CLI node as modified
+ if config_dir == 'VYATTA_CHANGES_ONLY_DIR':
+ write_file(f'{tmp}/.modified', '', user=current_user, group='vyattacfg', mode=0o664)
diff --git a/python/vyos/utils/convert.py b/python/vyos/utils/convert.py
new file mode 100644
index 0000000..dd4266f
--- /dev/null
+++ b/python/vyos/utils/convert.py
@@ -0,0 +1,237 @@
+# Copyright 2023-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/>.
+import re
+
+# Define the number of seconds in each time unit
+time_units = {
+ 'y': 60 * 60 * 24 * 365.25, # year
+ 'w': 60 * 60 * 24 * 7, # week
+ 'd': 60 * 60 * 24, # day
+ 'h': 60 * 60, # hour
+ 'm': 60, # minute
+ 's': 1 # second
+}
+
+
+def human_to_seconds(time_str):
+ """ Converts a human-readable interval such as 1w4d18h35m59s
+ to number of seconds
+ """
+
+ time_patterns = {
+ 'y': r'(\d+)\s*y',
+ 'w': r'(\d+)\s*w',
+ 'd': r'(\d+)\s*d',
+ 'h': r'(\d+)\s*h',
+ 'm': r'(\d+)\s*m',
+ 's': r'(\d+)\s*s'
+ }
+
+ total_seconds = 0
+
+ for unit, pattern in time_patterns.items():
+ match = re.search(pattern, time_str)
+ if match:
+ value = int(match.group(1))
+ total_seconds += value * time_units[unit]
+
+ return int(total_seconds)
+
+
+def seconds_to_human(s, separator=""):
+ """ Converts number of seconds passed to a human-readable
+ interval such as 1w4d18h35m59s
+ """
+ s = int(s)
+ result = []
+
+ years = s // time_units['y']
+ if years > 0:
+ result.append(f'{int(years)}y')
+ s = int(s % time_units['y'])
+
+ weeks = s // time_units['w']
+ if weeks > 0:
+ result.append(f'{weeks}w')
+ s = s % time_units['w']
+
+ days = s // time_units['d']
+ if days > 0:
+ result.append(f'{days}d')
+ s = s % time_units['d']
+
+ hours = s // time_units['h']
+ if hours > 0:
+ result.append(f'{hours}h')
+ s = s % time_units['h']
+
+ minutes = s // time_units['m']
+ if minutes > 0:
+ result.append(f'{minutes}m')
+ s = s % 60
+
+ seconds = s
+ if seconds > 0:
+ result.append(f'{seconds}s')
+
+ return separator.join(result)
+
+
+def bytes_to_human(bytes, initial_exponent=0, precision=2,
+ int_below_exponent=0):
+ """ Converts a value in bytes to a human-readable size string like 640 KB
+
+ The initial_exponent parameter is the exponent of 2,
+ e.g. 10 (1024) for kilobytes, 20 (1024 * 1024) for megabytes.
+ """
+
+ if bytes == 0:
+ return "0 B"
+
+ from math import log2
+
+ bytes = bytes * (2**initial_exponent)
+
+ # log2 is a float, while range checking requires an int
+ exponent = int(log2(bytes))
+ if exponent < int_below_exponent:
+ precision = 0
+
+ if exponent < 10:
+ value = bytes
+ suffix = "B"
+ elif exponent in range(10, 20):
+ value = bytes / 1024
+ suffix = "KB"
+ elif exponent in range(20, 30):
+ value = bytes / 1024**2
+ suffix = "MB"
+ elif exponent in range(30, 40):
+ value = bytes / 1024**3
+ suffix = "GB"
+ else:
+ value = bytes / 1024**4
+ suffix = "TB"
+ # Add a new case when the first machine with petabyte RAM
+ # hits the market.
+
+ size_string = "{0:.{1}f} {2}".format(value, precision, suffix)
+ return size_string
+
+def human_to_bytes(value):
+ """ Converts a data amount with a unit suffix to bytes, like 2K to 2048 """
+
+ from re import match as re_match
+
+ res = re_match(r'^\s*(\d+(?:\.\d+)?)\s*([a-zA-Z]+)\s*$', value)
+
+ if not res:
+ raise ValueError(f"'{value}' is not a valid data amount")
+ else:
+ amount = float(res.group(1))
+ unit = res.group(2).lower()
+
+ if unit == 'b':
+ res = amount
+ elif (unit == 'k') or (unit == 'kb'):
+ res = amount * 1024
+ elif (unit == 'm') or (unit == 'mb'):
+ res = amount * 1024**2
+ elif (unit == 'g') or (unit == 'gb'):
+ res = amount * 1024**3
+ elif (unit == 't') or (unit == 'tb'):
+ res = amount * 1024**4
+ else:
+ raise ValueError(f"Unsupported data unit '{unit}'")
+
+ # There cannot be fractional bytes, so we convert them to integer.
+ # However, truncating causes problems with conversion back to human unit,
+ # so we round instead -- that seems to work well enough.
+ return round(res)
+
+def mac_to_eui64(mac, prefix=None):
+ """
+ Convert a MAC address to a EUI64 address or, with prefix provided, a full
+ IPv6 address.
+ Thankfully copied from https://gist.github.com/wido/f5e32576bb57b5cc6f934e177a37a0d3
+ """
+ import re
+ from ipaddress import ip_network
+ # http://tools.ietf.org/html/rfc4291#section-2.5.1
+ eui64 = re.sub(r'[.:-]', '', mac).lower()
+ eui64 = eui64[0:6] + 'fffe' + eui64[6:]
+ eui64 = hex(int(eui64[0:2], 16) ^ 2)[2:].zfill(2) + eui64[2:]
+
+ if prefix is None:
+ return ':'.join(re.findall(r'.{4}', eui64))
+ else:
+ try:
+ net = ip_network(prefix, strict=False)
+ euil = int('0x{0}'.format(eui64), 16)
+ return str(net[euil])
+ except: # pylint: disable=bare-except
+ return
+
+
+def convert_data(data) -> dict | list | tuple | str | int | float | bool | None:
+ """Filter and convert multiple types of data to types usable in CLI/API
+
+ WARNING: Must not be used for anything except formatting output for API or CLI
+
+ On the output allowed everything supported in JSON.
+
+ Args:
+ data (Any): input data
+
+ Returns:
+ dict | list | tuple | str | int | float | bool | None: converted data
+ """
+ from base64 import b64encode
+
+ # return original data for types which do not require conversion
+ if isinstance(data, str | int | float | bool | None):
+ return data
+
+ if isinstance(data, list):
+ list_tmp = []
+ for item in data:
+ list_tmp.append(convert_data(item))
+ return list_tmp
+
+ if isinstance(data, tuple):
+ list_tmp = list(data)
+ tuple_tmp = tuple(convert_data(list_tmp))
+ return tuple_tmp
+
+ if isinstance(data, bytes | bytearray):
+ try:
+ return data.decode()
+ except UnicodeDecodeError:
+ return b64encode(data).decode()
+
+ if isinstance(data, set | frozenset):
+ list_tmp = convert_data(list(data))
+ return list_tmp
+
+ if isinstance(data, dict):
+ dict_tmp = {}
+ for key, value in data.items():
+ dict_tmp[key] = convert_data(value)
+ return dict_tmp
+
+ # do not return anything for other types
+ # which cannot be converted to JSON
+ # for example: complex | range | memoryview
+ return
diff --git a/python/vyos/utils/cpu.py b/python/vyos/utils/cpu.py
new file mode 100644
index 0000000..3bea5ac
--- /dev/null
+++ b/python/vyos/utils/cpu.py
@@ -0,0 +1,101 @@
+# Copyright (C) 2022-2024 VyOS maintainers and contributors
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+Retrieves (or at least attempts to retrieve) the total number of real CPU cores
+installed in a Linux system.
+
+The issue of core count is complicated by existence of SMT, e.g. Intel's Hyper Threading.
+GNU nproc returns the number of LOGICAL cores,
+which is 2x of the real cores if SMT is enabled.
+
+The idea is to find all physical CPUs and add up their core counts.
+It has special cases for x86_64 and MAY work correctly on other architectures,
+but nothing is certain.
+"""
+
+import re
+
+def _read_cpuinfo():
+ with open('/proc/cpuinfo', 'r') as f:
+ lines = f.read().strip()
+ return re.split(r'\n+', lines)
+
+def _split_line(l):
+ l = l.strip()
+ parts = re.split(r'\s*:\s*', l)
+ return (parts[0], ":".join(parts[1:]))
+
+def _find_cpus(cpuinfo_lines):
+ # Make a dict because it's more convenient to work with later,
+ # when we need to find physicall distinct CPUs there.
+ cpus = {}
+
+ cpu_number = 0
+
+ for l in cpuinfo_lines:
+ key, value = _split_line(l)
+ if key == 'processor':
+ cpu_number = value
+ cpus[cpu_number] = {}
+ else:
+ cpus[cpu_number][key] = value
+
+ return cpus
+
+def _find_physical_cpus():
+ cpus = _find_cpus(_read_cpuinfo())
+
+ phys_cpus = {}
+
+ for num in cpus:
+ if 'physical id' in cpus[num]:
+ # On at least some architectures, CPUs in different sockets
+ # have different 'physical id' field, e.g. on x86_64.
+ phys_id = cpus[num]['physical id']
+ if phys_id not in phys_cpus:
+ phys_cpus[phys_id] = cpus[num]
+ else:
+ # On other architectures, e.g. on ARM, there's no such field.
+ # We just assume they are different CPUs,
+ # whether single core ones or cores of physical CPUs.
+ phys_cpus[num] = cpus[num]
+
+ return phys_cpus
+
+def get_cpus():
+ """ Returns a list of /proc/cpuinfo entries that belong to different CPUs.
+ """
+ cpus_dict = _find_physical_cpus()
+ return list(cpus_dict.values())
+
+def get_core_count():
+ """ Returns the total number of physical CPU cores
+ (even if Hyper-Threading or another SMT is enabled and has inflated
+ the number of cores in /proc/cpuinfo)
+ """
+ physical_cpus = _find_physical_cpus()
+
+ core_count = 0
+
+ for num in physical_cpus:
+ # Some architectures, e.g. x86_64, include a field for core count.
+ # Since we found unique physical CPU entries, we can sum their core counts.
+ if 'cpu cores' in physical_cpus[num]:
+ core_count += int(physical_cpus[num]['cpu cores'])
+ else:
+ core_count += 1
+
+ return core_count
diff --git a/python/vyos/utils/dict.py b/python/vyos/utils/dict.py
new file mode 100644
index 0000000..1a7a6b9
--- /dev/null
+++ b/python/vyos/utils/dict.py
@@ -0,0 +1,374 @@
+# Copyright 2023 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/>.
+
+def colon_separated_to_dict(data_string, uniquekeys=False):
+ """ Converts a string containing newline-separated entries
+ of colon-separated key-value pairs into a dict.
+
+ Such files are common in Linux /proc filesystem
+
+ Args:
+ data_string (str): data string
+ uniquekeys (bool): whether to insist that keys are unique or not
+
+ Returns: dict
+
+ Raises:
+ ValueError: if uniquekeys=True and the data string has
+ duplicate keys.
+
+ Note:
+ If uniquekeys=True, then dict entries are always strings,
+ otherwise they are always lists of strings.
+ """
+ import re
+ key_value_re = re.compile(r'([^:]+)\s*\:\s*(.*)')
+
+ data_raw = re.split('\n', data_string)
+
+ data = {}
+
+ for l in data_raw:
+ l = l.strip()
+ if l:
+ match = re.match(key_value_re, l)
+ if match and (len(match.groups()) == 2):
+ key = match.groups()[0].strip()
+ value = match.groups()[1].strip()
+ else:
+ raise ValueError(f"""Line "{l}" could not be parsed a colon-separated pair """, l)
+ if key in data.keys():
+ if uniquekeys:
+ raise ValueError("Data string has duplicate keys: {0}".format(key))
+ else:
+ data[key].append(value)
+ else:
+ if uniquekeys:
+ data[key] = value
+ else:
+ data[key] = [value]
+ else:
+ pass
+
+ return data
+
+def mangle_dict_keys(data, regex, replacement, abs_path=None, no_tag_node_value_mangle=False):
+ """ Mangles dict keys according to a regex and replacement character.
+ Some libraries like Jinja2 do not like certain characters in dict keys.
+ This function can be used for replacing all offending characters
+ with something acceptable.
+
+ Args:
+ data (dict): Original dict to mangle
+ regex, replacement (str): arguments to re.sub(regex, replacement, ...)
+ abs_path (list): if data is a config dict and no_tag_node_value_mangle is True
+ then abs_path should be the absolute config path to the first
+ keys of data, non-inclusive
+ no_tag_node_value_mangle (bool): do not mangle keys of tag node values
+
+ Returns: dict
+ """
+ import re
+ from vyos.xml_ref import is_tag_value
+
+ if abs_path is None:
+ abs_path = []
+
+ new_dict = type(data)()
+
+ for k in data.keys():
+ if no_tag_node_value_mangle and is_tag_value(abs_path + [k]):
+ new_key = k
+ else:
+ new_key = re.sub(regex, replacement, k)
+
+ value = data[k]
+
+ if isinstance(value, dict):
+ new_dict[new_key] = mangle_dict_keys(value, regex, replacement,
+ abs_path=abs_path + [k],
+ no_tag_node_value_mangle=no_tag_node_value_mangle)
+ else:
+ new_dict[new_key] = value
+
+ return new_dict
+
+def _get_sub_dict(d, lpath):
+ k = lpath[0]
+ if k not in d.keys():
+ return {}
+ c = {k: d[k]}
+ lpath = lpath[1:]
+ if not lpath:
+ return c
+ elif not isinstance(c[k], dict):
+ return {}
+ return _get_sub_dict(c[k], lpath)
+
+def get_sub_dict(source, lpath, get_first_key=False):
+ """ Returns the sub-dict of a nested dict, defined by path of keys.
+
+ Args:
+ source (dict): Source dict to extract from
+ lpath (list[str]): sequence of keys
+
+ Returns: source, if lpath is empty, else
+ {key : source[..]..[key]} for key the last element of lpath, if exists
+ {} otherwise
+ """
+ if not isinstance(source, dict):
+ raise TypeError("source must be of type dict")
+ if not isinstance(lpath, list):
+ raise TypeError("path must be of type list")
+ if not lpath:
+ return source
+
+ ret = _get_sub_dict(source, lpath)
+
+ if get_first_key and lpath and ret:
+ tmp = next(iter(ret.values()))
+ if not isinstance(tmp, dict):
+ raise TypeError("Data under node is not of type dict")
+ ret = tmp
+
+ return ret
+
+def dict_search(path, dict_object):
+ """ Traverse Python dictionary (dict_object) delimited by dot (.).
+ Return value of key if found, None otherwise.
+
+ This is faster implementation then jmespath.search('foo.bar', dict_object)"""
+ if not isinstance(dict_object, dict) or not path:
+ return None
+
+ parts = path.split('.')
+ inside = parts[:-1]
+ if not inside:
+ if path not in dict_object:
+ return None
+ return dict_object[path]
+ c = dict_object
+ for p in parts[:-1]:
+ c = c.get(p, {})
+ return c.get(parts[-1], None)
+
+def dict_search_args(dict_object, *path):
+ # Traverse dictionary using variable arguments
+ # Added due to above function not allowing for '.' in the key names
+ # Example: dict_search_args(some_dict, 'key', 'subkey', 'subsubkey', ...)
+ if not isinstance(dict_object, dict) or not path:
+ return None
+
+ for item in path:
+ if item not in dict_object:
+ return None
+ dict_object = dict_object[item]
+ return dict_object
+
+def dict_search_recursive(dict_object, key, path=[]):
+ """ Traverse a dictionary recurisvely and return the value of the key
+ we are looking for.
+
+ Thankfully copied from https://stackoverflow.com/a/19871956
+
+ Modified to yield optional path to found keys
+ """
+ if isinstance(dict_object, list):
+ for i in dict_object:
+ new_path = path + [i]
+ for x in dict_search_recursive(i, key, new_path):
+ yield x
+ elif isinstance(dict_object, dict):
+ if key in dict_object:
+ new_path = path + [key]
+ yield dict_object[key], new_path
+ for k, j in dict_object.items():
+ new_path = path + [k]
+ for x in dict_search_recursive(j, key, new_path):
+ yield x
+
+
+def dict_set(key_path, value, dict_object):
+ """ Set value to Python dictionary (dict_object) using path to key delimited by dot (.).
+ The key will be added if it does not exist.
+ """
+ path_list = key_path.split(".")
+ dynamic_dict = dict_object
+ if len(path_list) > 0:
+ for i in range(0, len(path_list)-1):
+ dynamic_dict = dynamic_dict[path_list[i]]
+ dynamic_dict[path_list[len(path_list)-1]] = value
+
+def dict_delete(key_path, dict_object):
+ """ Delete key in Python dictionary (dict_object) using path to key delimited by dot (.).
+ """
+ path_dict = dict_object
+ path_list = key_path.split('.')
+ inside = path_list[:-1]
+ if not inside:
+ del dict_object[path_list]
+ else:
+ for key in path_list[:-1]:
+ path_dict = path_dict[key]
+ del path_dict[path_list[len(path_list)-1]]
+
+def dict_to_list(d, save_key_to=None):
+ """ Convert a dict to a list of dicts.
+
+ Optionally, save the original key of the dict inside
+ dicts stores in that list.
+ """
+ def save_key(i, k):
+ if isinstance(i, dict):
+ i[save_key_to] = k
+ return
+ elif isinstance(i, list):
+ for _i in i:
+ save_key(_i, k)
+ else:
+ raise ValueError(f"Cannot save the key: the item is {type(i)}, not a dict")
+
+ collect = []
+
+ for k,_ in d.items():
+ item = d[k]
+ if save_key_to is not None:
+ save_key(item, k)
+ if isinstance(item, list):
+ collect += item
+ else:
+ collect.append(item)
+
+ return collect
+
+def dict_to_paths_values(conf: dict) -> dict:
+ """
+ Convert nested dictionary to simple dictionary, where key is a path is delimited by dot (.).
+ """
+ list_of_paths = []
+ dict_of_options ={}
+ for path in dict_to_key_paths(conf):
+ str_path = '.'.join(path)
+ list_of_paths.append(str_path)
+
+ for path in list_of_paths:
+ dict_of_options[path] = dict_search(path,conf)
+
+ return dict_of_options
+
+def dict_to_key_paths(d: dict) -> list:
+ """ Generator to return list of key paths from dict of list[str]|str
+ """
+ def func(d, path):
+ if isinstance(d, dict):
+ if not d:
+ yield path
+ for k, v in d.items():
+ for r in func(v, path + [k]):
+ yield r
+ elif isinstance(d, list):
+ yield path
+ elif isinstance(d, str):
+ yield path
+ else:
+ raise ValueError('object is not a dict of strings/list of strings')
+ for r in func(d, []):
+ yield r
+
+def dict_to_paths(d: dict) -> list:
+ """ Generator to return list of paths from dict of list[str]|str
+ """
+ def func(d, path):
+ if isinstance(d, dict):
+ if not d:
+ yield path
+ for k, v in d.items():
+ for r in func(v, path + [k]):
+ yield r
+ elif isinstance(d, list):
+ for i in d:
+ for r in func(i, path):
+ yield r
+ elif isinstance(d, str):
+ yield path + [d]
+ else:
+ raise ValueError('object is not a dict of strings/list of strings')
+ for r in func(d, []):
+ yield r
+
+def embed_dict(p: list[str], d: dict) -> dict:
+ path = p.copy()
+ ret = d
+ while path:
+ ret = {path.pop(): ret}
+ return ret
+
+def check_mutually_exclusive_options(d, keys, required=False):
+ """ Checks if a dict has at most one or only one of
+ mutually exclusive keys.
+ """
+ present_keys = []
+
+ for k in d:
+ if k in keys:
+ present_keys.append(k)
+
+ # Un-mangle the keys to make them match CLI option syntax
+ from re import sub
+ orig_keys = list(map(lambda s: sub(r'_', '-', s), keys))
+ orig_present_keys = list(map(lambda s: sub(r'_', '-', s), present_keys))
+
+ if len(present_keys) > 1:
+ raise ValueError(f"Options {orig_keys} are mutually-exclusive but more than one of them is present: {orig_present_keys}")
+
+ if required and (len(present_keys) < 1):
+ raise ValueError(f"At least one of the following options is required: {orig_keys}")
+
+class FixedDict(dict):
+ """
+ FixedDict: A dictionnary not allowing new keys to be created after initialisation.
+
+ >>> f = FixedDict(**{'count':1})
+ >>> f['count'] = 2
+ >>> f['king'] = 3
+ File "...", line ..., in __setitem__
+ raise ConfigError(f'Option "{k}" has no defined default')
+ """
+
+ from vyos import ConfigError
+
+ def __init__(self, **options):
+ self._allowed = options.keys()
+ super().__init__(**options)
+
+ def __setitem__(self, k, v):
+ """
+ __setitem__ is a builtin which is called by python when setting dict values:
+ >>> d = dict()
+ >>> d['key'] = 'value'
+ >>> d
+ {'key': 'value'}
+
+ is syntaxic sugar for
+
+ >>> d = dict()
+ >>> d.__setitem__('key','value')
+ >>> d
+ {'key': 'value'}
+ """
+ if k not in self._allowed:
+ raise ConfigError(f'Option "{k}" has no defined default')
+ super().__setitem__(k, v)
+
diff --git a/python/vyos/utils/disk.py b/python/vyos/utils/disk.py
new file mode 100644
index 0000000..d4271eb
--- /dev/null
+++ b/python/vyos/utils/disk.py
@@ -0,0 +1,72 @@
+# Copyright 2023-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/>.
+
+from pathlib import Path
+
+def device_from_id(id):
+ """ Return the device name from (partial) disk id """
+ path = Path('/dev/disk/by-id')
+ for device in path.iterdir():
+ if device.name.endswith(id):
+ return device.readlink().stem
+
+def get_storage_stats(directory, human_units=True):
+ """ Return basic storage stats for given directory """
+ from re import sub as re_sub
+ from vyos.utils.process import cmd
+ from vyos.utils.convert import human_to_bytes
+
+ # XXX: using `df -h` and converting human units to bytes
+ # may seem pointless, but there's a reason.
+ # df uses different header field names with `-h` and without it ("Size" vs "1K-blocks")
+ # and outputs values in 1K blocks without `-h`,
+ # so some amount of conversion is needed anyway.
+ # Using `df -h` by default seems simpler.
+ #
+ # This is what the output looks like, as of Debian Buster/Bullseye:
+ # $ df -h -t ext4 --output=source,size,used,avail,pcent
+ # Filesystem Size Used Avail Use%
+ # /dev/sda1 16G 7.6G 7.3G 51%
+
+ out = cmd(f"df -h --output=source,size,used,avail,pcent {directory}")
+ lines = out.splitlines()
+ lists = [l.split() for l in lines]
+ res = {lists[0][i]: lists[1][i] for i in range(len(lists[0]))}
+
+ convert = (lambda x: x) if human_units else human_to_bytes
+
+ stats = {}
+
+ stats["filesystem"] = res["Filesystem"]
+ stats["size"] = convert(res["Size"])
+ stats["used"] = convert(res["Used"])
+ stats["avail"] = convert(res["Avail"])
+ stats["use_percentage"] = re_sub(r'%', '', res["Use%"])
+
+ return stats
+
+def get_persistent_storage_stats(human_units=True):
+ from os.path import exists as path_exists
+
+ persistence_dir = "/usr/lib/live/mount/persistence"
+ if path_exists(persistence_dir):
+ stats = get_storage_stats(persistence_dir, human_units=human_units)
+ else:
+ # If the persistence path doesn't exist,
+ # the system is running from a live CD
+ # and the concept of persistence storage stats is not applicable
+ stats = None
+
+ return stats
diff --git a/python/vyos/utils/error.py b/python/vyos/utils/error.py
new file mode 100644
index 0000000..8d4709b
--- /dev/null
+++ b/python/vyos/utils/error.py
@@ -0,0 +1,24 @@
+# Copyright 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/>.
+
+from enum import IntEnum
+
+class cli_shell_api_err(IntEnum):
+ """ vyatta-cfg/src/vyos-errors.h """
+ VYOS_SUCCESS = 0
+ VYOS_GENERAL_FAILURE = 1
+ VYOS_INVALID_PATH = 2
+ VYOS_EMPTY_CONFIG = 3
+ VYOS_CONFIG_PARSE_ERROR = 4
diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py
new file mode 100644
index 0000000..eaebb57
--- /dev/null
+++ b/python/vyos/utils/file.py
@@ -0,0 +1,214 @@
+# Copyright 2023 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 os
+from vyos.utils.permission import chown
+
+def makedir(path, user=None, group=None):
+ if os.path.exists(path):
+ return
+ os.makedirs(path, mode=0o755)
+ chown(path, user, group)
+
+def file_is_persistent(path):
+ import re
+ location = r'^(/config|/opt/vyatta/etc/config)'
+ absolute = os.path.abspath(os.path.dirname(path))
+ return re.match(location,absolute)
+
+def read_file(fname, defaultonfailure=None):
+ """
+ read the content of a file, stripping any end characters (space, newlines)
+ should defaultonfailure be not None, it is returned on failure to read
+ """
+ try:
+ """ Read a file to string """
+ with open(fname, 'r') as f:
+ data = f.read().strip()
+ return data
+ except Exception as e:
+ if defaultonfailure is not None:
+ return defaultonfailure
+ raise e
+
+def write_file(fname, data, defaultonfailure=None, user=None, group=None, mode=None, append=False):
+ """
+ Write content of data to given fname, should defaultonfailure be not None,
+ it is returned on failure to read.
+
+ If directory of file is not present, it is auto-created.
+ """
+ dirname = os.path.dirname(fname)
+ if dirname and not os.path.isdir(dirname):
+ os.makedirs(dirname, mode=0o755, exist_ok=False)
+ chown(dirname, user, group)
+
+ try:
+ """ Write a file to string """
+ bytes = 0
+ with open(fname, 'w' if not append else 'a') as f:
+ bytes = f.write(data)
+ chown(fname, user, group)
+ chmod(fname, mode)
+ return bytes
+ except Exception as e:
+ if defaultonfailure is not None:
+ return defaultonfailure
+ raise e
+
+def read_json(fname, defaultonfailure=None):
+ """
+ read and json decode the content of a file
+ should defaultonfailure be not None, it is returned on failure to read
+ """
+ import json
+ try:
+ with open(fname, 'r') as f:
+ data = json.load(f)
+ return data
+ except Exception as e:
+ if defaultonfailure is not None:
+ return defaultonfailure
+ raise e
+
+def chown(path, user=None, group=None, recursive=False):
+ """ change file/directory owner """
+ from pwd import getpwnam
+ from grp import getgrnam
+
+ if user is None and group is None:
+ return False
+
+ # path may also be an open file descriptor
+ if not isinstance(path, int) and not os.path.exists(path):
+ return False
+
+ # keep current value if not specified otherwise
+ uid = -1
+ gid = -1
+
+ if user:
+ uid = getpwnam(user).pw_uid
+ if group:
+ gid = getgrnam(group).gr_gid
+
+ if recursive:
+ for dirpath, dirnames, filenames in os.walk(path):
+ os.chown(dirpath, uid, gid)
+ for filename in filenames:
+ os.chown(os.path.join(dirpath, filename), uid, gid)
+ else:
+ os.chown(path, uid, gid)
+ return True
+
+
+def chmod(path, bitmask):
+ # path may also be an open file descriptor
+ if not isinstance(path, int) and not os.path.exists(path):
+ return
+ if bitmask is None:
+ return
+ os.chmod(path, bitmask)
+
+
+def chmod_600(path):
+ """ Make file only read/writable by owner """
+ from stat import S_IRUSR, S_IWUSR
+
+ bitmask = S_IRUSR | S_IWUSR
+ chmod(path, bitmask)
+
+
+def chmod_750(path):
+ """ Make file/directory only executable to user and group """
+ from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IXGRP
+
+ bitmask = S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP
+ chmod(path, bitmask)
+
+
+def chmod_755(path):
+ """ Make file executable by all """
+ from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IXGRP, S_IROTH, S_IXOTH
+
+ bitmask = S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP | \
+ S_IROTH | S_IXOTH
+ chmod(path, bitmask)
+
+def chmod_2775(path):
+ """ user/group permissions with set-group-id bit set """
+ from stat import S_ISGID, S_IRWXU, S_IRWXG, S_IROTH, S_IXOTH
+
+ bitmask = S_ISGID | S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH
+ chmod(path, bitmask)
+
+def chmod_775(path):
+ """ Make file executable by all """
+ from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IWGRP, S_IXGRP, S_IROTH, S_IXOTH
+
+ bitmask = S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IWGRP | S_IXGRP | \
+ S_IROTH | S_IXOTH
+ chmod(path, bitmask)
+
+def file_permissions(path):
+ """ Return file permissions in string format, e.g '0755' """
+ return oct(os.stat(path).st_mode)[4:]
+
+def makedir(path, user=None, group=None):
+ if os.path.exists(path):
+ return
+ os.makedirs(path, mode=0o755)
+ chown(path, user, group)
+
+def wait_for_inotify(file_path, pre_hook=None, event_type=None, timeout=None, sleep_interval=0.1):
+ """ Waits for an inotify event to occur """
+ if not os.path.dirname(file_path):
+ raise ValueError(
+ "File path {} does not have a directory part (required for inotify watching)".format(file_path))
+ if not os.path.basename(file_path):
+ raise ValueError(
+ "File path {} does not have a file part, do not know what to watch for".format(file_path))
+
+ from inotify.adapters import Inotify
+ from time import time
+ from time import sleep
+
+ time_start = time()
+
+ i = Inotify()
+ i.add_watch(os.path.dirname(file_path))
+
+ if pre_hook:
+ pre_hook()
+
+ for event in i.event_gen(yield_nones=True):
+ if (timeout is not None) and ((time() - time_start) > timeout):
+ # If the function didn't return until this point,
+ # the file failed to have been written to and closed within the timeout
+ raise OSError("Waiting for file {} to be written has failed".format(file_path))
+
+ # Most such events don't take much time, so it's better to check right away
+ # and sleep later.
+ if event is not None:
+ (_, type_names, path, filename) = event
+ if filename == os.path.basename(file_path):
+ if event_type in type_names:
+ return
+ sleep(sleep_interval)
+
+def wait_for_file_write_complete(file_path, pre_hook=None, timeout=None, sleep_interval=0.1):
+ """ Waits for a process to close a file after opening it in write mode. """
+ wait_for_inotify(file_path,
+ event_type='IN_CLOSE_WRITE', pre_hook=pre_hook, timeout=timeout, sleep_interval=sleep_interval)
diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py
new file mode 100644
index 0000000..205210b
--- /dev/null
+++ b/python/vyos/utils/io.py
@@ -0,0 +1,113 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Callable, Optional
+
+def print_error(str='', end='\n'):
+ """
+ Print `str` to stderr, terminated with `end`.
+ Used for warnings and out-of-band messages to avoid mangling precious
+ stdout output.
+ """
+ import sys
+ sys.stderr.write(str)
+ sys.stderr.write(end)
+ sys.stderr.flush()
+
+def ask_input(question, default='', numeric_only=False, valid_responses=[],
+ no_echo=False, non_empty=False):
+ from getpass import getpass
+ question_out = question
+ if default:
+ question_out += f' (Default: {default})'
+ response = ''
+ while True:
+ if not no_echo:
+ response = input(question_out + ' ').strip()
+ else:
+ response = getpass(question_out + ' ').strip()
+ if not response and default:
+ return default
+ if numeric_only:
+ if not response.isnumeric():
+ print("Invalid value, try again.")
+ continue
+ response = int(response)
+ if valid_responses and response not in valid_responses:
+ print("Invalid value, try again.")
+ continue
+ if non_empty and not response:
+ print("Non-empty value required; try again.")
+ continue
+ break
+ return response
+
+def ask_yes_no(question, default=False) -> bool:
+ """Ask a yes/no question via input() and return their answer."""
+ from sys import stdout
+ default_msg = "[Y/n]" if default else "[y/N]"
+ while True:
+ try:
+ stdout.write("%s %s " % (question, default_msg))
+ c = input().lower()
+ if c == '':
+ return default
+ elif c in ("y", "ye", "yes"):
+ return True
+ elif c in ("n", "no"):
+ return False
+ else:
+ stdout.write("Please respond with yes/y or no/n\n")
+ except EOFError:
+ stdout.write("\nPlease respond with yes/y or no/n\n")
+ except KeyboardInterrupt:
+ return False
+
+def is_interactive():
+ """Try to determine if the routine was called from an interactive shell."""
+ import os, sys
+ return os.getenv('TERM', default=False) and sys.stderr.isatty() and sys.stdout.isatty()
+
+def is_dumb_terminal():
+ """Check if the current TTY is dumb, so that we can disable advanced terminal features."""
+ import os
+ return os.getenv('TERM') in ['vt100', 'dumb']
+
+def select_entry(l: list, list_msg: str = '', prompt_msg: str = '',
+ list_format: Optional[Callable] = None,
+ default_entry: Optional[int] = None) -> str:
+ """Select an entry from a list
+
+ Args:
+ l (list): a list of entries
+ list_msg (str): a message to print before listing the entries
+ prompt_msg (str): a message to print as prompt for selection
+
+ Returns:
+ str: a selected entry
+ """
+ en = list(enumerate(l, 1))
+ print(list_msg)
+ for i, e in en:
+ if list_format:
+ print(f'\t{i}: {list_format(e)}')
+ else:
+ print(f'\t{i}: {e}')
+ valid_entry = range(1, len(l)+1)
+ if default_entry and default_entry not in valid_entry:
+ default_entry = None
+ select = ask_input(prompt_msg, default=default_entry, numeric_only=True,
+ valid_responses=valid_entry)
+ return next(filter(lambda x: x[0] == select, en))[1]
diff --git a/python/vyos/utils/kernel.py b/python/vyos/utils/kernel.py
new file mode 100644
index 0000000..847f801
--- /dev/null
+++ b/python/vyos/utils/kernel.py
@@ -0,0 +1,113 @@
+# Copyright 2023-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/>.
+
+import os
+
+def check_kmod(k_mod):
+ """ Common utility function to load required kernel modules on demand """
+ from vyos import ConfigError
+ from vyos.utils.process import call
+ if isinstance(k_mod, str):
+ k_mod = k_mod.split()
+ for module in k_mod:
+ if not os.path.exists(f'/sys/module/{module}'):
+ if call(f'modprobe {module}') != 0:
+ raise ConfigError(f'Loading Kernel module {module} failed')
+
+def unload_kmod(k_mod):
+ """ Common utility function to unload required kernel modules on demand """
+ from vyos import ConfigError
+ from vyos.utils.process import call
+ if isinstance(k_mod, str):
+ k_mod = k_mod.split()
+ for module in k_mod:
+ if os.path.exists(f'/sys/module/{module}'):
+ if call(f'rmmod {module}') != 0:
+ raise ConfigError(f'Unloading Kernel module {module} failed')
+
+def list_loaded_modules():
+ """ Returns the list of currently loaded kernel modules """
+ from os import listdir
+ return listdir('/sys/module/')
+
+def get_module_data(module: str):
+ """ Retrieves information about a module """
+ from os import listdir
+ from os.path import isfile, dirname, basename, join
+ from vyos.utils.file import read_file
+
+ def _get_file(path):
+ # Some files inside some modules are not readable at all,
+ # we just skip them.
+ try:
+ return read_file(path)
+ except PermissionError:
+ return None
+
+ mod_path = join('/sys/module', module)
+ mod_data = {"name": module, "fields": {}, "parameters": {}}
+
+ for f in listdir(mod_path):
+ if f in ["sections", "notes", "uevent"]:
+ # The uevent file is not readable
+ # and module build info and memory layout
+ # in notes and sections generally aren't useful
+ # for anything but kernel debugging.
+ pass
+ elif f == "drivers":
+ # Drivers are dir symlinks,
+ # we just list them
+ drivers = listdir(join(mod_path, f))
+ if drivers:
+ mod_data["drivers"] = drivers
+ elif f == "holders":
+ # Holders (module that use this one)
+ # are always symlink to other modules.
+ # We only need the list.
+ holders = listdir(join(mod_path, f))
+ if holders:
+ mod_data["holders"] = holders
+ elif f == "parameters":
+ # Many modules keep their configuration
+ # in the "parameters" subdir.
+ ppath = join(mod_path, "parameters")
+ ps = listdir(ppath)
+ for p in ps:
+ data = _get_file(join(ppath, p))
+ if data:
+ mod_data["parameters"][p] = data
+ else:
+ # Everything else...
+ # There are standard fields like refcount and initstate,
+ # but many modules also keep custom information or settings
+ # in top-level fields.
+ # For now we don't separate well-known and custom fields.
+ if isfile(join(mod_path, f)):
+ data = _get_file(join(mod_path, f))
+ if data:
+ mod_data["fields"][f] = data
+ else:
+ raise RuntimeError(f"Unexpected directory inside module {module}: {f}")
+
+ return mod_data
+
+def lsmod():
+ """ Returns information about all loaded modules.
+ Like lsmod(8), but more detailed.
+ """
+ mods_data = []
+ for m in list_loaded_modules():
+ mods_data.append(get_module_data(m))
+ return mods_data
diff --git a/python/vyos/utils/list.py b/python/vyos/utils/list.py
new file mode 100644
index 0000000..63ef720
--- /dev/null
+++ b/python/vyos/utils/list.py
@@ -0,0 +1,20 @@
+# Copyright 2023 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/>.
+
+def is_list_equal(first: list, second: list) -> bool:
+ """ Check if 2 lists are equal and list not empty """
+ if len(first) != len(second) or len(first) == 0:
+ return False
+ return sorted(first) == sorted(second)
diff --git a/python/vyos/utils/locking.py b/python/vyos/utils/locking.py
new file mode 100644
index 0000000..63cb1a8
--- /dev/null
+++ b/python/vyos/utils/locking.py
@@ -0,0 +1,115 @@
+# Copyright 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/>.
+
+import fcntl
+import re
+import time
+from pathlib import Path
+
+
+class LockTimeoutError(Exception):
+ """Custom exception raised when lock acquisition times out."""
+
+ pass
+
+
+class InvalidLockNameError(Exception):
+ """Custom exception raised when the lock name is invalid."""
+
+ pass
+
+
+class Lock:
+ """Lock class to acquire and release a lock file"""
+
+ def __init__(self, lock_name: str) -> None:
+ """Lock class constructor
+
+ Args:
+ lock_name (str): Name of the lock file
+
+ Raises:
+ InvalidLockNameError: If the lock name is invalid
+ """
+ # Validate lock name
+ if not re.match(r'^[a-zA-Z0-9_\-]+$', lock_name):
+ raise InvalidLockNameError(f'Invalid lock name: {lock_name}')
+
+ self.__lock_dir = Path('/run/vyos/lock')
+ self.__lock_dir.mkdir(parents=True, exist_ok=True)
+
+ self.__lock_file_path: Path = self.__lock_dir / f'{lock_name}.lock'
+ self.__lock_file = None
+
+ self._is_locked = False
+
+ def __del__(self) -> None:
+ """Ensure the lock file is removed when the object is deleted"""
+ self.release()
+
+ @property
+ def is_locked(self) -> bool:
+ """Check if the lock is acquired
+
+ Returns:
+ bool: True if the lock is acquired, False otherwise
+ """
+ return self._is_locked
+
+ def __unlink_lockfile(self) -> None:
+ """Remove the lock file if it is not currently locked."""
+ try:
+ with self.__lock_file_path.open('w') as f:
+ fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ self.__lock_file_path.unlink(missing_ok=True)
+ except IOError:
+ # If we cannot acquire the lock, it means another process has it, so we do nothing.
+ pass
+
+ def acquire(self, timeout: int = 0) -> None:
+ """Acquire a lock file
+
+ Args:
+ timeout (int, optional): A time to wait for lock. Defaults to 0.
+
+ Raises:
+ LockTimeoutError: If lock could not be acquired within timeout
+ """
+ start_time: float = time.time()
+ while True:
+ try:
+ self.__lock_file = self.__lock_file_path.open('w')
+ fcntl.flock(self.__lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ self._is_locked = True
+ return
+ except IOError:
+ if timeout > 0 and (time.time() - start_time) >= timeout:
+ if self.__lock_file:
+ self.__lock_file.close()
+ raise LockTimeoutError(
+ f'Could not acquire lock within {timeout} seconds'
+ )
+ time.sleep(0.1)
+
+ def release(self) -> None:
+ """Release a lock file"""
+ if self.__lock_file and self._is_locked:
+ try:
+ fcntl.flock(self.__lock_file, fcntl.LOCK_UN)
+ self._is_locked = False
+ finally:
+ self.__lock_file.close()
+ self.__lock_file = None
+ self.__unlink_lockfile()
diff --git a/python/vyos/utils/misc.py b/python/vyos/utils/misc.py
new file mode 100644
index 0000000..d826559
--- /dev/null
+++ b/python/vyos/utils/misc.py
@@ -0,0 +1,66 @@
+# Copyright 2023 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/>.
+
+def begin(*args):
+ """
+ Evaluate arguments in order and return the result of the *last* argument.
+ For combining multiple expressions in one statement. Useful for lambdas.
+ """
+ return args[-1]
+
+def begin0(*args):
+ """
+ Evaluate arguments in order and return the result of the *first* argument.
+ For combining multiple expressions in one statement. Useful for lambdas.
+ """
+ return args[0]
+
+def install_into_config(conf, config_paths, override_prompt=True):
+ # Allows op-mode scripts to install values if called from an active config session
+ # config_paths: dict of config paths
+ # override_prompt: if True, user will be prompted before existing nodes are overwritten
+ if not config_paths:
+ return None
+
+ from vyos.config import Config
+ from vyos.utils.io import ask_yes_no
+ from vyos.utils.process import cmd
+ if not Config().in_session():
+ print('You are not in configure mode, commands to install manually from configure mode:')
+ for path in config_paths:
+ print(f'set {path}')
+ return None
+
+ count = 0
+ failed = []
+
+ for path in config_paths:
+ if override_prompt and conf.exists(path) and not conf.is_multi(path):
+ if not ask_yes_no(f'Config node "{node}" already exists. Do you want to overwrite it?'):
+ continue
+
+ try:
+ cmd(f'/opt/vyatta/sbin/my_set {path}')
+ count += 1
+ except:
+ failed.append(path)
+
+ if failed:
+ print(f'Failed to install {len(failed)} value(s). Commands to manually install:')
+ for path in failed:
+ print(f'set {path}')
+
+ if count > 0:
+ print(f'{count} value(s) installed. Use "compare" to see the pending changes, and "commit" to apply.')
diff --git a/python/vyos/utils/network.py b/python/vyos/utils/network.py
new file mode 100644
index 0000000..8fce08d
--- /dev/null
+++ b/python/vyos/utils/network.py
@@ -0,0 +1,599 @@
+# Copyright 2023 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/>.
+
+def _are_same_ip(one, two):
+ from socket import AF_INET
+ from socket import AF_INET6
+ from socket import inet_pton
+ from vyos.template import is_ipv4
+ # compare the binary representation of the IP
+ f_one = AF_INET if is_ipv4(one) else AF_INET6
+ s_two = AF_INET if is_ipv4(two) else AF_INET6
+ return inet_pton(f_one, one) == inet_pton(f_one, two)
+
+def get_protocol_by_name(protocol_name):
+ """Get protocol number by protocol name
+
+ % get_protocol_by_name('tcp')
+ % 6
+ """
+ import socket
+ try:
+ protocol_number = socket.getprotobyname(protocol_name)
+ return protocol_number
+ except socket.error:
+ return protocol_name
+
+def interface_exists(interface) -> bool:
+ import os
+ return os.path.exists(f'/sys/class/net/{interface}')
+
+def is_netns_interface(interface, netns):
+ from vyos.utils.process import rc_cmd
+ rc, out = rc_cmd(f'sudo ip netns exec {netns} ip link show dev {interface}')
+ if rc == 0:
+ return True
+ return False
+
+def get_netns_all() -> list:
+ from json import loads
+ from vyos.utils.process import cmd
+ tmp = loads(cmd('ip --json netns ls'))
+ return [ netns['name'] for netns in tmp ]
+
+def get_vrf_members(vrf: str) -> list:
+ """
+ Get list of interface VRF members
+ :param vrf: str
+ :return: list
+ """
+ import json
+ from vyos.utils.process import cmd
+ interfaces = []
+ try:
+ if not interface_exists(vrf):
+ raise ValueError(f'VRF "{vrf}" does not exist!')
+ output = cmd(f'ip --json --brief link show vrf {vrf}')
+ answer = json.loads(output)
+ for data in answer:
+ if 'ifname' in data:
+ interfaces.append(data.get('ifname'))
+ except:
+ pass
+ return interfaces
+
+def get_interface_vrf(interface):
+ """ Returns VRF of given interface """
+ from vyos.utils.dict import dict_search
+ from vyos.utils.network import get_interface_config
+ tmp = get_interface_config(interface)
+ if dict_search('linkinfo.info_slave_kind', tmp) == 'vrf':
+ return tmp['master']
+ return 'default'
+
+def get_vrf_tableid(interface: str):
+ """ Return VRF table ID for given interface name or None """
+ from vyos.utils.dict import dict_search
+ table = None
+ tmp = get_interface_config(interface)
+ # Check if we are "the" VRF interface
+ if dict_search('linkinfo.info_kind', tmp) == 'vrf':
+ table = tmp['linkinfo']['info_data']['table']
+ # or an interface bound to a VRF
+ elif dict_search('linkinfo.info_slave_kind', tmp) == 'vrf':
+ table = tmp['linkinfo']['info_slave_data']['table']
+ return table
+
+def get_interface_config(interface):
+ """ Returns the used encapsulation protocol for given interface.
+ If interface does not exist, None is returned.
+ """
+ if not interface_exists(interface):
+ return None
+ from json import loads
+ from vyos.utils.process import cmd
+ tmp = loads(cmd(f'ip --detail --json link show dev {interface}'))[0]
+ return tmp
+
+def get_interface_address(interface):
+ """ Returns the used encapsulation protocol for given interface.
+ If interface does not exist, None is returned.
+ """
+ if not interface_exists(interface):
+ return None
+ from json import loads
+ from vyos.utils.process import cmd
+ tmp = loads(cmd(f'ip --detail --json addr show dev {interface}'))[0]
+ return tmp
+
+def get_interface_namespace(interface: str):
+ """
+ Returns wich netns the interface belongs to
+ """
+ from json import loads
+ from vyos.utils.process import cmd
+
+ # Bail out early if netns does not exist
+ tmp = cmd(f'ip --json netns ls')
+ if not tmp: return None
+
+ for ns in loads(tmp):
+ netns = f'{ns["name"]}'
+ # Search interface in each netns
+ data = loads(cmd(f'ip netns exec {netns} ip --json link show'))
+ for tmp in data:
+ if interface == tmp["ifname"]:
+ return netns
+
+def is_ipv6_tentative(iface: str, ipv6_address: str) -> bool:
+ """Check if IPv6 address is in tentative state.
+
+ This function checks if an IPv6 address on a specific network interface is
+ in the tentative state. IPv6 tentative addresses are not fully configured
+ and are undergoing Duplicate Address Detection (DAD) to ensure they are
+ unique on the network.
+
+ Args:
+ iface (str): The name of the network interface.
+ ipv6_address (str): The IPv6 address to check.
+
+ Returns:
+ bool: True if the IPv6 address is tentative, False otherwise.
+ """
+ import json
+ from vyos.utils.process import rc_cmd
+
+ rc, out = rc_cmd(f'ip -6 --json address show dev {iface}')
+ if rc:
+ return False
+
+ data = json.loads(out)
+ for addr_info in data[0]['addr_info']:
+ if (
+ addr_info.get('local') == ipv6_address and
+ addr_info.get('tentative', False)
+ ):
+ return True
+ return False
+
+def is_wwan_connected(interface):
+ """ Determine if a given WWAN interface, e.g. wwan0 is connected to the
+ carrier network or not """
+ import json
+ from vyos.utils.dict import dict_search
+ from vyos.utils.process import cmd
+ from vyos.utils.process import is_systemd_service_active
+
+ if not interface.startswith('wwan'):
+ raise ValueError(f'Specified interface "{interface}" is not a WWAN interface')
+
+ # ModemManager is required for connection(s) - if service is not running,
+ # there won't be any connection at all!
+ if not is_systemd_service_active('ModemManager.service'):
+ return False
+
+ modem = interface.lstrip('wwan')
+
+ tmp = cmd(f'mmcli --modem {modem} --output-json')
+ tmp = json.loads(tmp)
+
+ # return True/False if interface is in connected state
+ return dict_search('modem.generic.state', tmp) == 'connected'
+
+def get_bridge_fdb(interface):
+ """ Returns the forwarding database entries for a given interface """
+ if not interface_exists(interface):
+ return None
+ from json import loads
+ from vyos.utils.process import cmd
+ tmp = loads(cmd(f'bridge -j fdb show dev {interface}'))
+ return tmp
+
+def get_all_vrfs():
+ """ Return a dictionary of all system wide known VRF instances """
+ from json import loads
+ from vyos.utils.process import cmd
+ tmp = loads(cmd('ip --json vrf list'))
+ # Result is of type [{"name":"red","table":1000},{"name":"blue","table":2000}]
+ # so we will re-arrange it to a more nicer representation:
+ # {'red': {'table': 1000}, 'blue': {'table': 2000}}
+ data = {}
+ for entry in tmp:
+ name = entry.pop('name')
+ data[name] = entry
+ return data
+
+def interface_list() -> list:
+ from vyos.ifconfig import Section
+ """
+ Get list of interfaces in system
+ :rtype: list
+ """
+ return Section.interfaces()
+
+
+def vrf_list() -> list:
+ """
+ Get list of VRFs in system
+ :rtype: list
+ """
+ return list(get_all_vrfs().keys())
+
+def mac2eui64(mac, prefix=None):
+ """
+ Convert a MAC address to a EUI64 address or, with prefix provided, a full
+ IPv6 address.
+ Thankfully copied from https://gist.github.com/wido/f5e32576bb57b5cc6f934e177a37a0d3
+ """
+ import re
+ from ipaddress import ip_network
+ # http://tools.ietf.org/html/rfc4291#section-2.5.1
+ eui64 = re.sub(r'[.:-]', '', mac).lower()
+ eui64 = eui64[0:6] + 'fffe' + eui64[6:]
+ eui64 = hex(int(eui64[0:2], 16) ^ 2)[2:].zfill(2) + eui64[2:]
+
+ if prefix is None:
+ return ':'.join(re.findall(r'.{4}', eui64))
+ else:
+ try:
+ net = ip_network(prefix, strict=False)
+ euil = int('0x{0}'.format(eui64), 16)
+ return str(net[euil])
+ except: # pylint: disable=bare-except
+ return
+
+def check_port_availability(ipaddress, port, protocol):
+ """
+ Check if port is available and not used by any service
+ Return False if a port is busy or IP address does not exists
+ Should be used carefully for services that can start listening
+ dynamically, because IP address may be dynamic too
+ """
+ from socketserver import TCPServer, UDPServer
+ from ipaddress import ip_address
+
+ # verify arguments
+ try:
+ ipaddress = ip_address(ipaddress).compressed
+ except:
+ raise ValueError(f'The {ipaddress} 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')
+ if protocol not in ['tcp', 'udp']:
+ raise ValueError(f'The protocol {protocol} is not supported. Only tcp and udp are allowed')
+
+ # check port availability
+ 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:
+ return False
+
+ return True
+
+def is_listen_port_bind_service(port: int, service: str) -> bool:
+ """Check if listen port bound to expected program name
+ :param port: Bind port
+ :param service: Program name
+ :return: bool
+
+ Example:
+ % is_listen_port_bind_service(443, 'nginx')
+ True
+ % is_listen_port_bind_service(443, 'ocserv-main')
+ False
+ """
+ from psutil import net_connections as connections
+ from psutil import Process as process
+ for connection in connections():
+ addr = connection.laddr
+ pid = connection.pid
+ pid_name = process(pid).name()
+ pid_port = addr.port
+ if service == pid_name and port == pid_port:
+ return True
+ return False
+
+def is_ipv6_link_local(addr):
+ """ Check if addrsss is an IPv6 link-local address. Returns True/False """
+ from ipaddress import ip_interface
+ from vyos.template import is_ipv6
+ addr = addr.split('%')[0]
+ if is_ipv6(addr):
+ if ip_interface(addr).is_link_local:
+ return True
+
+ return False
+
+def is_addr_assigned(ip_address, vrf=None, return_ifname=False, include_vrf=False) -> bool | str:
+ """ Verify if the given IPv4/IPv6 address is assigned to any interface """
+ from netifaces import interfaces
+ from vyos.utils.network import get_interface_config
+ from vyos.utils.dict import dict_search
+
+ for interface in interfaces():
+ # Check if interface belongs to the requested VRF, if this is not the
+ # case there is no need to proceed with this data set - continue loop
+ # with next element
+ tmp = get_interface_config(interface)
+ if dict_search('master', tmp) != vrf and not include_vrf:
+ continue
+
+ if is_intf_addr_assigned(interface, ip_address):
+ return interface if return_ifname else True
+
+ return False
+
+def is_intf_addr_assigned(ifname: str, addr: str, netns: str=None) -> bool:
+ """
+ Verify if the given IPv4/IPv6 address is assigned to specific interface.
+ It can check both a single IP address (e.g. 192.0.2.1 or a assigned CIDR
+ address 192.0.2.1/24.
+ """
+ import json
+ import jmespath
+
+ from vyos.utils.process import rc_cmd
+ from ipaddress import ip_interface
+
+ netns_cmd = f'ip netns exec {netns}' if netns else ''
+ rc, out = rc_cmd(f'{netns_cmd} ip --json address show dev {ifname}')
+ if rc == 0:
+ json_out = json.loads(out)
+ addresses = jmespath.search("[].addr_info[].{family: family, address: local, prefixlen: prefixlen}", json_out)
+ for address_info in addresses:
+ family = address_info['family']
+ address = address_info['address']
+ prefixlen = address_info['prefixlen']
+ # Remove the interface name if present in the given address
+ if '%' in addr:
+ addr = addr.split('%')[0]
+ interface = ip_interface(f"{address}/{prefixlen}")
+ if ip_interface(addr) == interface or address == addr:
+ return True
+
+ return False
+
+def is_loopback_addr(addr):
+ """ Check if supplied IPv4/IPv6 address is a loopback address """
+ from ipaddress import ip_address
+ return ip_address(addr).is_loopback
+
+def is_wireguard_key_pair(private_key: str, public_key:str) -> bool:
+ """
+ Checks if public/private keys are keypair
+ :param private_key: Wireguard private key
+ :type private_key: str
+ :param public_key: Wireguard public key
+ :type public_key: str
+ :return: If public/private keys are keypair returns True else False
+ :rtype: bool
+ """
+ from vyos.utils.process import cmd
+ gen_public_key = cmd('wg pubkey', input=private_key)
+ if gen_public_key == public_key:
+ return True
+ else:
+ return False
+
+def is_subnet_connected(subnet, primary=False):
+ """
+ Verify is the given IPv4/IPv6 subnet is connected to any interface on this
+ system.
+
+ primary check if the subnet is reachable via the primary IP address of this
+ interface, or in other words has a broadcast address configured. ISC DHCP
+ for instance will complain if it should listen on non broadcast interfaces.
+
+ Return True/False
+ """
+ from ipaddress import ip_address
+ from ipaddress import ip_network
+
+ from netifaces import ifaddresses
+ from netifaces import interfaces
+ from netifaces import AF_INET
+ from netifaces import AF_INET6
+
+ from vyos.template import is_ipv6
+
+ # determine IP version (AF_INET or AF_INET6) depending on passed address
+ addr_type = AF_INET
+ if is_ipv6(subnet):
+ addr_type = AF_INET6
+
+ for interface in interfaces():
+ # check if the requested address type is configured at all
+ if addr_type not in ifaddresses(interface).keys():
+ continue
+
+ # An interface can have multiple addresses, but some software components
+ # only support the primary address :(
+ if primary:
+ ip = ifaddresses(interface)[addr_type][0]['addr']
+ if ip_address(ip) in ip_network(subnet):
+ return True
+ else:
+ # Check every assigned IP address if it is connected to the subnet
+ # in question
+ for ip in ifaddresses(interface)[addr_type]:
+ # remove interface extension (e.g. %eth0) that gets thrown on the end of _some_ addrs
+ addr = ip['addr'].split('%')[0]
+ if ip_address(addr) in ip_network(subnet):
+ return True
+
+ return False
+
+def is_afi_configured(interface: str, afi):
+ """ Check if given address family is configured, or in other words - an IP
+ address is assigned to the interface. """
+ from netifaces import ifaddresses
+ from netifaces import AF_INET
+ from netifaces import AF_INET6
+
+ if afi not in [AF_INET, AF_INET6]:
+ raise ValueError('Address family must be in [AF_INET, AF_INET6]')
+
+ try:
+ addresses = ifaddresses(interface)
+ except ValueError as e:
+ print(e)
+ return False
+
+ return afi in addresses
+
+def get_vxlan_vlan_tunnels(interface: str) -> list:
+ """ Return a list of strings with VLAN IDs configured in the Kernel """
+ from json import loads
+ from vyos.utils.process import cmd
+
+ if not interface.startswith('vxlan'):
+ raise ValueError('Only applicable for VXLAN interfaces!')
+
+ # Determine current OS Kernel configured VLANs
+ #
+ # $ bridge -j -p vlan tunnelshow dev vxlan0
+ # [ {
+ # "ifname": "vxlan0",
+ # "tunnels": [ {
+ # "vlan": 10,
+ # "vlanEnd": 11,
+ # "tunid": 10010,
+ # "tunidEnd": 10011
+ # },{
+ # "vlan": 20,
+ # "tunid": 10020
+ # } ]
+ # } ]
+ #
+ os_configured_vlan_ids = []
+ tmp = loads(cmd(f'bridge --json vlan tunnelshow dev {interface}'))
+ if tmp:
+ for tunnel in tmp[0].get('tunnels', {}):
+ vlanStart = tunnel['vlan']
+ if 'vlanEnd' in tunnel:
+ vlanEnd = tunnel['vlanEnd']
+ # Build a real list for user VLAN IDs
+ vlan_list = list(range(vlanStart, vlanEnd +1))
+ # Convert list of integers to list or strings
+ os_configured_vlan_ids.extend(map(str, vlan_list))
+ # Proceed with next tunnel - this one is complete
+ continue
+
+ # Add single tunel id - not part of a range
+ os_configured_vlan_ids.append(str(vlanStart))
+
+ return os_configured_vlan_ids
+
+def get_vxlan_vni_filter(interface: str) -> list:
+ """ Return a list of strings with VNIs configured in the Kernel"""
+ from json import loads
+ from vyos.utils.process import cmd
+
+ if not interface.startswith('vxlan'):
+ raise ValueError('Only applicable for VXLAN interfaces!')
+
+ # Determine current OS Kernel configured VNI filters in VXLAN interface
+ #
+ # $ bridge -j vni show dev vxlan1
+ # [{"ifname":"vxlan1","vnis":[{"vni":100},{"vni":200},{"vni":300,"vniEnd":399}]}]
+ #
+ # Example output: ['10010', '10020', '10021', '10022']
+ os_configured_vnis = []
+ tmp = loads(cmd(f'bridge --json vni show dev {interface}'))
+ if tmp:
+ for tunnel in tmp[0].get('vnis', {}):
+ vniStart = tunnel['vni']
+ if 'vniEnd' in tunnel:
+ vniEnd = tunnel['vniEnd']
+ # Build a real list for user VNIs
+ vni_list = list(range(vniStart, vniEnd +1))
+ # Convert list of integers to list or strings
+ os_configured_vnis.extend(map(str, vni_list))
+ # Proceed with next tunnel - this one is complete
+ continue
+
+ # Add single tunel id - not part of a range
+ os_configured_vnis.append(str(vniStart))
+
+ return os_configured_vnis
+
+# Calculate prefix length of an IPv6 range, where possible
+# Python-ified from source: https://gitlab.isc.org/isc-projects/dhcp/-/blob/master/keama/confparse.c#L4591
+def ipv6_prefix_length(low, high):
+ import socket
+
+ bytemasks = [0x80, 0xc0, 0xe0, 0xf0, 0xf8, 0xfc, 0xfe, 0xff]
+
+ try:
+ lo = bytearray(socket.inet_pton(socket.AF_INET6, low))
+ hi = bytearray(socket.inet_pton(socket.AF_INET6, high))
+ except:
+ return None
+
+ xor = bytearray(a ^ b for a, b in zip(lo, hi))
+
+ plen = 0
+ while plen < 128 and xor[plen // 8] == 0:
+ plen += 8
+
+ if plen == 128:
+ return plen
+
+ for i in range((plen // 8) + 1, 16):
+ if xor[i] != 0:
+ return None
+
+ for i in range(8):
+ msk = ~xor[plen // 8] & 0xff
+
+ if msk == bytemasks[i]:
+ return plen + i + 1
+
+ return None
+
+def get_nft_vrf_zone_mapping() -> dict:
+ """
+ Retrieve current nftables conntrack mapping list from Kernel
+
+ returns: [{'interface': 'red', 'vrf_tableid': 1000},
+ {'interface': 'eth2', 'vrf_tableid': 1000},
+ {'interface': 'blue', 'vrf_tableid': 2000}]
+ """
+ from json import loads
+ from jmespath import search
+ from vyos.utils.process import cmd
+ output = []
+ tmp = loads(cmd('sudo nft -j list table inet vrf_zones'))
+ # {'nftables': [{'metainfo': {'json_schema_version': 1,
+ # 'release_name': 'Old Doc Yak #3',
+ # 'version': '1.0.9'}},
+ # {'table': {'family': 'inet', 'handle': 6, 'name': 'vrf_zones'}},
+ # {'map': {'elem': [['eth0', 666],
+ # ['dum0', 666],
+ # ['wg500', 666],
+ # ['bond10.666', 666]],
+ vrf_list = search('nftables[].map.elem | [0]', tmp)
+ if not vrf_list:
+ return output
+ for (vrf_name, vrf_id) in vrf_list:
+ output.append({'interface' : vrf_name, 'vrf_tableid' : vrf_id})
+ return output
diff --git a/python/vyos/utils/permission.py b/python/vyos/utils/permission.py
new file mode 100644
index 0000000..d938b49
--- /dev/null
+++ b/python/vyos/utils/permission.py
@@ -0,0 +1,78 @@
+# Copyright 2023 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 os
+
+def chown(path, user, group):
+ """ change file/directory owner """
+ from pwd import getpwnam
+ from grp import getgrnam
+
+ if user is None or group is None:
+ return False
+
+ # path may also be an open file descriptor
+ if not isinstance(path, int) and not os.path.exists(path):
+ return False
+
+ uid = getpwnam(user).pw_uid
+ gid = getgrnam(group).gr_gid
+ os.chown(path, uid, gid)
+ return True
+
+def chmod(path, bitmask):
+ # path may also be an open file descriptor
+ if not isinstance(path, int) and not os.path.exists(path):
+ return
+ if bitmask is None:
+ return
+ os.chmod(path, bitmask)
+
+def chmod_600(path):
+ """ make file only read/writable by owner """
+ from stat import S_IRUSR, S_IWUSR
+
+ bitmask = S_IRUSR | S_IWUSR
+ chmod(path, bitmask)
+
+def chmod_750(path):
+ """ make file/directory only executable to user and group """
+ from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IXGRP
+
+ bitmask = S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP
+ chmod(path, bitmask)
+
+def chmod_755(path):
+ """ make file executable by all """
+ from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IXGRP, S_IROTH, S_IXOTH
+
+ bitmask = S_IRUSR | S_IWUSR | S_IXUSR | S_IRGRP | S_IXGRP | \
+ S_IROTH | S_IXOTH
+ chmod(path, bitmask)
+
+def is_admin() -> bool:
+ """Look if current user is in sudo group"""
+ from getpass import getuser
+ from grp import getgrnam
+ current_user = getuser()
+ (_, _, _, admin_group_members) = getgrnam('sudo')
+ return current_user in admin_group_members
+
+def get_cfg_group_id():
+ from grp import getgrnam
+ from vyos.defaults import cfg_group
+
+ group_data = getgrnam(cfg_group)
+ return group_data.gr_gid
diff --git a/python/vyos/utils/process.py b/python/vyos/utils/process.py
new file mode 100644
index 0000000..ce880f4
--- /dev/null
+++ b/python/vyos/utils/process.py
@@ -0,0 +1,262 @@
+# Copyright 2023 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 os
+
+from subprocess import Popen
+from subprocess import PIPE
+from subprocess import STDOUT
+from subprocess import DEVNULL
+
+def popen(command, flag='', shell=None, input=None, timeout=None, env=None,
+ stdout=PIPE, stderr=PIPE, decode='utf-8'):
+ """
+ popen is a wrapper helper aound 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
+
+ it can be affected by the following flags:
+ shell: do not try to auto-detect if a shell is required
+ for example if a pipe (|) or redirection (>, >>) is used
+ input: data to sent to the child process via STDIN
+ the data should be bytes but string will be converted
+ timeout: time after which the command will be considered to have failed
+ env: mapping that defines the environment variables for the new process
+ stdout: define how the output of the program should be handled
+ - PIPE (default), sends stdout to the output
+ - DEVNULL, discard the output
+ stderr: define how the output of the program should be handled
+ - None (default), send/merge the data to/with stderr
+ - PIPE, popen will append it to output
+ - STDOUT, send the data to be merged with stdout
+ - 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
+
+ usage:
+ get both stdout and stderr: popen('command', stdout=PIPE, stderr=STDOUT)
+ discard stdout and get stderr: popen('command', stdout=DEVNUL, stderr=PIPE)
+ """
+
+ # airbag must be left as an import in the function as otherwise we have a
+ # a circual import dependency
+ from vyos import debug
+ from vyos import airbag
+
+ # log if the flag is set, otherwise log if command is set
+ 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:
+ use_shell = False
+ if ' ' in command:
+ use_shell = True
+ if env:
+ use_shell = True
+
+ if input:
+ stdin = PIPE
+ input = input.encode() if type(input) is str else input
+
+ p = Popen(command, stdin=stdin, stdout=stdout, stderr=stderr,
+ env=env, shell=use_shell)
+
+ pipe = p.communicate(input, timeout)
+
+ pipe_out = b''
+ if stdout == PIPE:
+ pipe_out = pipe[0]
+
+ pipe_err = b''
+ if stderr == PIPE:
+ pipe_err = pipe[1]
+
+ str_out = pipe_out.decode(decode).replace('\r\n', '\n').strip()
+ str_err = pipe_err.decode(decode).replace('\r\n', '\n').strip()
+
+ out_msg = f"returned (out):\n{str_out}"
+ if str_out:
+ debug.message(out_msg, flag)
+
+ if str_err:
+ from sys import stderr
+ err_msg = f"returned (err):\n{str_err}"
+ # this message will also be send to syslog via airbag
+ debug.message(err_msg, flag, destination=stderr)
+
+ # should something go wrong, report this too via airbag
+ airbag.noteworthy(cmd_msg)
+ airbag.noteworthy(out_msg)
+ airbag.noteworthy(err_msg)
+
+ return str_out, p.returncode
+
+
+def run(command, flag='', shell=None, input=None, timeout=None, env=None,
+ stdout=DEVNULL, stderr=PIPE, decode='utf-8'):
+ """
+ A wrapper around popen, which discard the stdout and
+ will return the error code of a command
+ """
+ _, code = popen(
+ command, flag,
+ stdout=stdout, stderr=stderr,
+ input=input, timeout=timeout,
+ env=env, shell=shell,
+ decode=decode,
+ )
+ 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]):
+ """
+ A wrapper around popen, which returns the stdout and
+ will raise the error code of a command
+
+ raising: specify which call should be used when raising
+ the class should only require a string as parameter
+ (default is OSError) with the error code
+ expect: a list of error codes to consider as normal
+ """
+ decoded, code = popen(
+ command, flag,
+ stdout=stdout, stderr=stderr,
+ input=input, timeout=timeout,
+ env=env, shell=shell,
+ decode=decode,
+ )
+ if code not in expect:
+ feedback = message + '\n' if message else ''
+ feedback += f'failed to run command: {command}\n'
+ feedback += f'returned: {decoded}\n'
+ feedback += f'exit code: {code}'
+ if raising is None:
+ # error code can be recovered with .errno
+ raise OSError(code, feedback)
+ else:
+ raise raising(feedback)
+ return decoded
+
+
+def rc_cmd(command, flag='', shell=None, input=None, timeout=None, env=None,
+ stdout=PIPE, stderr=STDOUT, decode='utf-8'):
+ """
+ A wrapper around popen, which returns the return code
+ of a command and stdout
+
+ % rc_cmd('uname')
+ (0, 'Linux')
+ % rc_cmd('ip link show dev eth99')
+ (1, 'Device "eth99" does not exist.')
+ """
+ out, code = popen(
+ command, flag,
+ stdout=stdout, stderr=stderr,
+ input=input, timeout=timeout,
+ env=env, shell=shell,
+ decode=decode,
+ )
+ return code, out
+
+def call(command, flag='', shell=None, input=None, timeout=None, env=None,
+ stdout=None, stderr=None, decode='utf-8'):
+ """
+ A wrapper around popen, which print the stdout and
+ will return the error code of a command
+ """
+ out, code = popen(
+ command, flag,
+ stdout=stdout, stderr=stderr,
+ input=input, timeout=timeout,
+ env=env, shell=shell,
+ decode=decode,
+ )
+ 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
+ if not os.path.isfile(pid_file):
+ return False
+ with open(pid_file, 'r') as f:
+ pid = f.read().strip()
+ return pid_exists(int(pid))
+
+def process_named_running(name: str, cmdline: str=None, timeout: int=0):
+ """ Checks if process with given name is running and returns its PID.
+ If Process is not running, return None
+ """
+ from psutil import process_iter
+ def check_process(name, cmdline):
+ for p in process_iter(['name', 'pid', 'cmdline']):
+ if cmdline:
+ if name in p.info['name'] and cmdline in p.info['cmdline']:
+ return p.info['pid']
+ elif name in p.info['name']:
+ return p.info['pid']
+ return None
+ if timeout:
+ import time
+ time_expire = time.time() + timeout
+ while True:
+ tmp = check_process(name, cmdline)
+ if not tmp:
+ if time.time() > time_expire:
+ break
+ time.sleep(0.100) # wait 100ms
+ continue
+ return tmp
+ else:
+ return check_process(name, cmdline)
+ return None
+
+def is_systemd_service_active(service):
+ """ Test is a specified systemd service is activated.
+ Returns True if service is active, false otherwise.
+ Copied from: https://unix.stackexchange.com/a/435317 """
+ tmp = cmd(f'systemctl show --value -p ActiveState {service}')
+ return bool((tmp == 'active'))
+
+def is_systemd_service_running(service):
+ """ Test is a specified systemd service is actually running.
+ Returns True if service is running, false otherwise.
+ Copied from: https://unix.stackexchange.com/a/435317 """
+ tmp = cmd(f'systemctl show --value -p SubState {service}')
+ return bool((tmp == 'running'))
+
+def ip_cmd(args, json=True):
+ """ A helper for easily calling iproute2 commands """
+ if json:
+ from json import loads
+ res = cmd(f"ip --json {args}").strip()
+ if res:
+ return loads(res)
+ else:
+ # Many mutation commands like "ip link set"
+ # return an empty string
+ return None
+ else:
+ res = cmd(f"ip {args}")
+ return res
diff --git a/python/vyos/utils/serial.py b/python/vyos/utils/serial.py
new file mode 100644
index 0000000..b646f88
--- /dev/null
+++ b/python/vyos/utils/serial.py
@@ -0,0 +1,118 @@
+# Copyright 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/>.
+
+import os, re, json
+from typing import List
+
+from vyos.base import Warning
+from vyos.utils.io import ask_yes_no
+from vyos.utils.process import cmd
+
+GLOB_GETTY_UNITS = 'serial-getty@*.service'
+RE_GETTY_DEVICES = re.compile(r'.+@(.+).service$')
+
+SD_UNIT_PATH = '/run/systemd/system'
+UTMP_PATH = '/run/utmp'
+
+def get_serial_units(include_devices=[]):
+ # Since we cannot depend on the current config for decommissioned ports,
+ # we just grab everything that systemd knows about.
+ tmp = cmd(f'systemctl list-units {GLOB_GETTY_UNITS} --all --output json --no-pager')
+ getty_units = json.loads(tmp)
+ for sdunit in getty_units:
+ m = RE_GETTY_DEVICES.search(sdunit['unit'])
+ if m is None:
+ Warning(f'Serial console unit name "{sdunit["unit"]}" is malformed and cannot be checked for activity!')
+ continue
+
+ getty_device = m.group(1)
+ if include_devices and getty_device not in include_devices:
+ continue
+
+ sdunit['device'] = getty_device
+
+ return getty_units
+
+def get_authenticated_ports(units):
+ connected = []
+ ports = [ x['device'] for x in units if 'device' in x ]
+ #
+ # utmpdump just gives us an easily parseable dump of currently logged-in sessions, for eg:
+ # $ utmpdump /run/utmp
+ # Utmp dump of /run/utmp
+ # [2] [00000] [~~ ] [reboot ] [~ ] [6.6.31-amd64-vyos ] [0.0.0.0 ] [2024-06-18T13:56:53,958484+00:00]
+ # [1] [00051] [~~ ] [runlevel] [~ ] [6.6.31-amd64-vyos ] [0.0.0.0 ] [2024-06-18T13:57:01,790808+00:00]
+ # [6] [03178] [tty1] [LOGIN ] [tty1 ] [ ] [0.0.0.0 ] [2024-06-18T13:57:31,015392+00:00]
+ # [7] [37151] [ts/0] [vyos ] [pts/0 ] [10.9.8.7 ] [10.9.8.7 ] [2024-07-04T13:42:08,760892+00:00]
+ # [8] [24812] [ts/1] [ ] [pts/1 ] [10.9.8.7 ] [10.9.8.7 ] [2024-06-20T18:10:07,309365+00:00]
+ #
+ # We can safely skip blank or LOGIN sessions with valid device names.
+ #
+ for line in cmd(f'utmpdump {UTMP_PATH}').splitlines():
+ row = line.split('] [')
+ user_name = row[3].strip()
+ user_term = row[4].strip()
+ if user_name and user_name != 'LOGIN' and user_term in ports:
+ connected.append(user_term)
+
+ return connected
+
+def restart_login_consoles(prompt_user=False, quiet=True, devices: List[str]=[]):
+ # restart_login_consoles() is called from both conf- and op-mode scripts, including
+ # the warning messages and user prompts common to both.
+ #
+ # The default case, called with no arguments, is a simple serial-getty restart &
+ # cleanup wrapper with no output or prompts that can be used from anywhere.
+ #
+ # quiet and prompt_user args have been split from an original "no_prompt", in
+ # order to support the completely silent default use case. "no_prompt" would
+ # only suppress the user interactive prompt.
+ #
+ # quiet intentionally does not suppress a vyos.base.Warning() for malformed
+ # device names in _get_serial_units().
+ #
+ cmd('systemctl daemon-reload')
+
+ units = get_serial_units(devices)
+ connected = get_authenticated_ports(units)
+
+ if connected:
+ if not quiet:
+ Warning('There are user sessions connected via serial console that '\
+ 'will be terminated when serial console settings are changed!')
+ if not prompt_user:
+ # This flag is used by conf_mode/system_console.py to reset things, if there's
+ # a problem, the user should issue a manual restart for serial-getty.
+ Warning('Please ensure all settings are committed and saved before issuing a ' \
+ '"restart serial console" command to apply new configuration!')
+ if not prompt_user:
+ return False
+ if not ask_yes_no('Any uncommitted changes from these sessions will be lost\n' \
+ 'and in-progress actions may be left in an inconsistent state.\n'\
+ '\nContinue?'):
+ return False
+
+ for unit in units:
+ if 'device' not in unit:
+ continue # malformed or filtered.
+ unit_name = unit['unit']
+ unit_device = unit['device']
+ if os.path.exists(os.path.join(SD_UNIT_PATH, unit_name)):
+ cmd(f'systemctl restart {unit_name}')
+ else:
+ # Deleted stubs don't need to be restarted, just shut them down.
+ cmd(f'systemctl stop {unit_name}')
+
+ return True
diff --git a/python/vyos/utils/strip_config.py b/python/vyos/utils/strip_config.py
new file mode 100644
index 0000000..7a9c78c
--- /dev/null
+++ b/python/vyos/utils/strip_config.py
@@ -0,0 +1,210 @@
+#!/usr/bin/python3
+#
+# Copyright 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/>.
+
+# XXX: these functions assume that the config is at the top level,
+# and aren't capable of anonymizing config subtress.
+# They shouldn't be used as a basis for a strip-private filter
+# until we figure out if we can pass the config path information to the filter.
+
+import copy
+
+import vyos.configtree
+
+
+def __anonymize_password(v):
+ return "<PASSWORD REDACTED>"
+
+def __anonymize_key(v):
+ return "<KEY DATA REDACTED>"
+
+def __anonymize_data(v):
+ return "<DATA REDACTED>"
+
+__secret_paths = [
+ # System user password hashes
+ {"base_path": ['system', 'login', 'user'], "secret_path": ["authentication", "encrypted-password"], "func": __anonymize_password},
+
+ # PKI data
+ {"base_path": ["pki", "ca"], "secret_path": ["private", "key"], "func": __anonymize_key},
+ {"base_path": ["pki", "ca"], "secret_path": ["certificate"], "func": __anonymize_key},
+ {"base_path": ["pki", "ca"], "secret_path": ["crl"], "func": __anonymize_key},
+ {"base_path": ["pki", "certificate"], "secret_path": ["private", "key"], "func": __anonymize_key},
+ {"base_path": ["pki", "certificate"], "secret_path": ["certificate"], "func": __anonymize_key},
+ {"base_path": ["pki", "certificate"], "secret_path": ["acme", "email"], "func": __anonymize_data},
+ {"base_path": ["pki", "key-pair"], "secret_path": ["private", "key"], "func": __anonymize_key},
+ {"base_path": ["pki", "key-pair"], "secret_path": ["public", "key"], "func": __anonymize_key},
+ {"base_path": ["pki", "openssh"], "secret_path": ["private", "key"], "func": __anonymize_key},
+ {"base_path": ["pki", "openssh"], "secret_path": ["public", "key"], "func": __anonymize_key},
+ {"base_path": ["pki", "openvpn", "shared-secret"], "secret_path": ["key"], "func": __anonymize_key},
+ {"base_path": ["pki", "dh"], "secret_path": ["parameters"], "func": __anonymize_key},
+
+ # IPsec pre-shared secrets
+ {"base_path": ['vpn', 'ipsec', 'authentication', 'psk'], "secret_path": ["secret"], "func": __anonymize_password},
+
+ # IPsec x509 passphrases
+ {"base_path": ['vpn', 'ipsec', 'site-to-site', 'peer'], "secret_path": ['authentication', 'x509'], "func": __anonymize_password},
+
+ # IPsec remote-access secrets and passwords
+ {"base_path": ["vpn", "ipsec", "remote-access", "connection"], "secret_path": ["authentication", "pre-shared-secret"], "func": __anonymize_password},
+ # Passwords in remote-access IPsec local users have their own fixup
+ # due to deeper nesting.
+
+ # PPTP passwords
+ {"base_path": ['vpn', 'pptp', 'remote-access', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password},
+
+ # L2TP passwords
+ {"base_path": ['vpn', 'l2tp', 'remote-access', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password},
+ {"path": ['vpn', 'l2tp', 'remote-access', 'ipsec-settings', 'authentication', 'pre-shared-secret'], "func": __anonymize_password},
+
+ # SSTP passwords
+ {"base_path": ['vpn', 'sstp', 'remote-access', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password},
+
+ # OpenConnect passwords
+ {"base_path": ['vpn', 'openconnect', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password},
+
+ # PPPoE server passwords
+ {"base_path": ['service', 'pppoe-server', 'authentication', 'local-users', 'username'], "secret_path": ['password'], "func": __anonymize_password},
+
+ # RADIUS PSKs for VPN services
+ {"base_path": ["vpn", "sstp", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password},
+ {"base_path": ["vpn", "l2tp", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password},
+ {"base_path": ["vpn", "pptp", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password},
+ {"base_path": ["vpn", "openconnect", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password},
+ {"base_path": ["service", "ipoe-server", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password},
+ {"base_path": ["service", "pppoe-server", "authentication", "radius", "server"], "secret_path": ["key"], "func": __anonymize_password},
+
+ # VRRP passwords
+ {"base_path": ['high-availability', 'vrrp', 'group'], "secret_path": ['authentication', 'password'], "func": __anonymize_password},
+
+ # BGP neighbor and peer group passwords
+ {"base_path": ['protocols', 'bgp', 'neighbor'], "secret_path": ["password"], "func": __anonymize_password},
+ {"base_path": ['protocols', 'bgp', 'peer-group'], "secret_path": ["password"], "func": __anonymize_password},
+
+ # WireGuard private keys
+ {"base_path": ["interfaces", "wireguard"], "secret_path": ["private-key"], "func": __anonymize_password},
+
+ # NHRP passwords
+ {"base_path": ["protocols", "nhrp", "tunnel"], "secret_path": ["cisco-authentication"], "func": __anonymize_password},
+
+ # RIP passwords
+ {"base_path": ["protocols", "rip", "interface"], "secret_path": ["authentication", "plaintext-password"], "func": __anonymize_password},
+
+ # IS-IS passwords
+ {"path": ["protocols", "isis", "area-password", "plaintext-password"], "func": __anonymize_password},
+ {"base_path": ["protocols", "isis", "interface"], "secret_path": ["password", "plaintext-password"], "func": __anonymize_password},
+
+ # HTTP API servers
+ {"base_path": ["service", "https", "api", "keys", "id"], "secret_path": ["key"], "func": __anonymize_password},
+
+ # Telegraf
+ {"path": ["service", "monitoring", "telegraf", "prometheus-client", "authentication", "password"], "func": __anonymize_password},
+ {"path": ["service", "monitoring", "telegraf", "influxdb", "authentication", "token"], "func": __anonymize_password},
+ {"path": ["service", "monitoring", "telegraf", "azure-data-explorer", "authentication", "client-secret"], "func": __anonymize_password},
+ {"path": ["service", "monitoring", "telegraf", "splunk", "authentication", "token"], "func": __anonymize_password},
+
+ # SNMPv3 passwords
+ {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["privacy", "encrypted-password"], "func": __anonymize_password},
+ {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["privacy", "plaintext-password"], "func": __anonymize_password},
+ {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["auth", "encrypted-password"], "func": __anonymize_password},
+ {"base_path": ["service", "snmp", "v3", "user"], "secret_path": ["auth", "encrypted-password"], "func": __anonymize_password},
+]
+
+def __prepare_secret_paths(config_tree, secret_paths):
+ """ Generate a list of secret paths for the current system,
+ adjusted for variable parts such as VRFs and remote access IPsec instances
+ """
+
+ # Fixup for remote-access IPsec local users that are nested under two tag nodes
+ # We generate the list of their paths dynamically
+ ipsec_ra_base = {"base_path": ["vpn", "ipsec", "remote-access", "connection"], "func": __anonymize_password}
+ if config_tree.exists(ipsec_ra_base["base_path"]):
+ for conn in config_tree.list_nodes(ipsec_ra_base["base_path"]):
+ if config_tree.exists(ipsec_ra_base["base_path"] + [conn] + ["authentication", "local-users", "username"]):
+ for u in config_tree.list_nodes(ipsec_ra_base["base_path"] + [conn] + ["authentication", "local-users", "username"]):
+ p = copy.copy(ipsec_ra_base)
+ p["base_path"] = p["base_path"] + [conn] + ["authentication", "local-users", "username"]
+ p["secret_path"] = ["password"]
+ secret_paths.append(p)
+
+ # Fixup for VRFs that may contain routing protocols and other nodes nested under them
+ vrf_paths = []
+ vrf_base_path = ["vrf", "name"]
+ if config_tree.exists(vrf_base_path):
+ for v in config_tree.list_nodes(vrf_base_path):
+ vrf_secret_paths = copy.deepcopy(secret_paths)
+ for sp in vrf_secret_paths:
+ if "base_path" in sp:
+ sp["base_path"] = vrf_base_path + [v] + sp["base_path"]
+ elif "path" in sp:
+ sp["path"] = vrf_base_path + [v] + sp["path"]
+ vrf_paths.append(sp)
+
+ secret_paths = secret_paths + vrf_paths
+
+ # Fixup for user SSH keys, that are nested under a tag node
+ #ssh_key_base_path = {"base_path": ['system', 'login', 'user'], "secret_path": ["authentication", "encrypted-password"], "func": __anonymize_password},
+ user_base_path = ['system', 'login', 'user']
+ ssh_key_paths = []
+ if config_tree.exists(user_base_path):
+ for u in config_tree.list_nodes(user_base_path):
+ kp = {"base_path": user_base_path + [u, "authentication", "public-keys"], "secret_path": ["key"], "func": __anonymize_key}
+ ssh_key_paths.append(kp)
+
+ secret_paths = secret_paths + ssh_key_paths
+
+ # Fixup for OSPF passwords and keys that are nested under OSPF interfaces
+ ospf_base_path = ["protocols", "ospf", "interface"]
+ ospf_paths = []
+ if config_tree.exists(ospf_base_path):
+ for i in config_tree.list_nodes(ospf_base_path):
+ # Plaintext password, there can be only one
+ opp = {"path": ospf_base_path + [i, "authentication", "plaintext-password"], "func": __anonymize_password}
+ md5kp = {"base_path": ospf_base_path + [i, "authentication", "md5", "key-id"], "secret_path": ["md5-key"], "func": __anonymize_password}
+ ospf_paths.append(opp)
+ ospf_paths.append(md5kp)
+
+ secret_paths = secret_paths + ospf_paths
+
+ return secret_paths
+
+def __strip_private(ct, secret_paths):
+ for sp in secret_paths:
+ if "base_path" in sp:
+ if ct.exists(sp["base_path"]):
+ for n in ct.list_nodes(sp["base_path"]):
+ if ct.exists(sp["base_path"] + [n] + sp["secret_path"]):
+ secret = ct.return_value(sp["base_path"] + [n] + sp["secret_path"])
+ ct.set(sp["base_path"] + [n] + sp["secret_path"], value=sp["func"](secret))
+ elif "path" in sp:
+ if ct.exists(sp["path"]):
+ secret = ct.return_value(sp["path"])
+ ct.set(sp["path"], value=sp["func"](secret))
+ else:
+ raise ValueError("Malformed secret path dict, has neither base_path nor path in it ")
+
+ return ct.to_string()
+
+def strip_config_source(config_source):
+ config_tree = vyos.configtree.ConfigTree(config_source)
+ secret_paths = __prepare_secret_paths(config_tree, __secret_paths)
+ stripped_config = __strip_private(config_tree, secret_paths)
+
+ return stripped_config
+
+def strip_config_tree(config_tree):
+ secret_paths = __prepare_secret_paths(config_tree, __secret_paths)
+ return __strip_private(config_tree, secret_paths)
diff --git a/python/vyos/utils/system.py b/python/vyos/utils/system.py
new file mode 100644
index 0000000..7b12efb
--- /dev/null
+++ b/python/vyos/utils/system.py
@@ -0,0 +1,149 @@
+# Copyright 2023-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/>.
+
+import os
+from subprocess import run
+
+def sysctl_read(name: str) -> str:
+ """Read and return current value of sysctl() option
+
+ Args:
+ name (str): sysctl key name
+
+ Returns:
+ str: sysctl key value
+ """
+ tmp = run(['sysctl', '-nb', name], capture_output=True)
+ return tmp.stdout.decode()
+
+def sysctl_write(name: str, value: str | int) -> bool:
+ """Change value via sysctl()
+
+ Args:
+ name (str): sysctl key name
+ value (str | int): sysctl key value
+
+ Returns:
+ bool: True if changed, False otherwise
+ """
+ # convert other types to string before comparison
+ if not isinstance(value, str):
+ value = str(value)
+ # do not change anything if a value is already configured
+ if sysctl_read(name) == value:
+ return True
+ # return False if sysctl call failed
+ if run(['sysctl', '-wq', f'{name}={value}']).returncode != 0:
+ return False
+ # compare old and new values
+ # sysctl may apply value, but its actual value will be
+ # different from requested
+ if sysctl_read(name) == value:
+ return True
+ # False in other cases
+ return False
+
+def sysctl_apply(sysctl_dict: dict[str, str], revert: bool = True) -> bool:
+ """Apply sysctl values.
+
+ Args:
+ sysctl_dict (dict[str, str]): dictionary with sysctl keys with values
+ revert (bool, optional): Revert to original values if new were not
+ applied. Defaults to True.
+
+ Returns:
+ bool: True if all params configured properly, False in other cases
+ """
+ # get current values
+ sysctl_original: dict[str, str] = {}
+ for key_name in sysctl_dict.keys():
+ sysctl_original[key_name] = sysctl_read(key_name)
+ # apply new values and revert in case one of them was not applied
+ for key_name, value in sysctl_dict.items():
+ if not sysctl_write(key_name, value):
+ if revert:
+ sysctl_apply(sysctl_original, revert=False)
+ return False
+ # everything applied
+ return True
+
+def find_device_file(device):
+ """ Recurively search /dev for the given device file and return its full path.
+ If no device file was found 'None' is returned """
+ from fnmatch import fnmatch
+
+ for root, dirs, files in os.walk('/dev'):
+ for basename in files:
+ if fnmatch(basename, device):
+ return os.path.join(root, basename)
+
+ return None
+
+def load_as_module(name: str, path: str):
+ import importlib.util
+
+ spec = importlib.util.spec_from_file_location(name, path)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+def load_as_module_source(name: str, path: str):
+ """ Necessary modification of load_as_module for files without *.py
+ extension """
+ import importlib.util
+ from importlib.machinery import SourceFileLoader
+
+ loader = SourceFileLoader(name, path)
+ spec = importlib.util.spec_from_loader(name, loader)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+def get_uptime_seconds():
+ """ Returns system uptime in seconds """
+ from re import search
+ from vyos.utils.file import read_file
+
+ data = read_file("/proc/uptime")
+ seconds = search(r"([0-9\.]+)\s", data).group(1)
+ res = int(float(seconds))
+
+ return res
+
+def get_load_averages():
+ """ Returns load averages for 1, 5, and 15 minutes as a dict """
+ from re import search
+ from vyos.utils.file import read_file
+ from vyos.utils.cpu import get_core_count
+
+ data = read_file("/proc/loadavg")
+ matches = search(r"\s*(?P<one>[0-9\.]+)\s+(?P<five>[0-9\.]+)\s+(?P<fifteen>[0-9\.]+)\s*", data)
+
+ core_count = get_core_count()
+
+ res = {}
+ res[1] = float(matches["one"]) / core_count
+ res[5] = float(matches["five"]) / core_count
+ res[15] = float(matches["fifteen"]) / core_count
+
+ return res
+
+def get_secure_boot_state() -> bool:
+ from vyos.utils.process import cmd
+ from vyos.utils.boot import is_uefi_system
+ if not is_uefi_system():
+ return False
+ tmp = cmd('mokutil --sb-state')
+ return bool('enabled' in tmp)
diff --git a/python/vyos/utils/vti_updown_db.py b/python/vyos/utils/vti_updown_db.py
new file mode 100644
index 0000000..b491fc6
--- /dev/null
+++ b/python/vyos/utils/vti_updown_db.py
@@ -0,0 +1,194 @@
+# Copyright 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/>.
+
+import os
+
+from contextlib import contextmanager
+from syslog import syslog
+
+VTI_WANT_UP_IFLIST = '/tmp/ipsec_vti_interfaces'
+
+def vti_updown_db_exists():
+ """ Returns true if the database exists """
+ return os.path.exists(VTI_WANT_UP_IFLIST)
+
+@contextmanager
+def open_vti_updown_db_for_create_or_update():
+ """ Opens the database for reading and writing, creating the database if it does not exist """
+ if vti_updown_db_exists():
+ f = open(VTI_WANT_UP_IFLIST, 'r+')
+ else:
+ f = open(VTI_WANT_UP_IFLIST, 'x+')
+ try:
+ db = VTIUpDownDB(f)
+ yield db
+ finally:
+ f.close()
+
+@contextmanager
+def open_vti_updown_db_for_update():
+ """ Opens the database for reading and writing, returning an error if it does not exist """
+ f = open(VTI_WANT_UP_IFLIST, 'r+')
+ try:
+ db = VTIUpDownDB(f)
+ yield db
+ finally:
+ f.close()
+
+@contextmanager
+def open_vti_updown_db_readonly():
+ """ Opens the database for reading, returning an error if it does not exist """
+ f = open(VTI_WANT_UP_IFLIST, 'r')
+ try:
+ db = VTIUpDownDB(f)
+ yield db
+ finally:
+ f.close()
+
+def remove_vti_updown_db():
+ """ Brings down any interfaces referenced by the database and removes the database """
+ # We need to process the DB first to bring down any interfaces still up
+ with open_vti_updown_db_for_update() as db:
+ db.removeAllOtherInterfaces([])
+ # this usage of commit will only ever bring down interfaces,
+ # do not need to provide a functional interface dict supplier
+ db.commit(lambda _: None)
+
+ os.unlink(VTI_WANT_UP_IFLIST)
+
+class VTIUpDownDB:
+ # The VTI Up-Down DB is a text-based database of space-separated "ifspecs".
+ #
+ # ifspecs can come in one of the two following formats:
+ #
+ # persistent format: <interface name>
+ # indicates the named interface should always be up.
+ #
+ # connection format: <interface name>:<connection name>:<protocol>
+ # indicates the named interface wants to be up due to an established
+ # connection <connection name> using the <protocol> protocol.
+ #
+ # The configuration tree and ipsec daemon connection up-down hook
+ # modify this file as needed and use it to determine when a
+ # particular event or configuration change should lead to changing
+ # the interface state.
+
+ def __init__(self, f):
+ self._fileHandle = f
+ self._ifspecs = set([entry.strip() for entry in f.read().split(" ") if entry and not entry.isspace()])
+ self._ifsUp = set()
+ self._ifsDown = set()
+
+ def add(self, interface, connection = None, protocol = None):
+ """
+ Adds a new entry to the DB.
+
+ If an interface name, connection name, and protocol are supplied,
+ creates a connection entry.
+
+ If only an interface name is specified, creates a persistent entry
+ for the given interface.
+ """
+ ifspec = f"{interface}:{connection}:{protocol}" if (connection is not None and protocol is not None) else interface
+ if ifspec not in self._ifspecs:
+ self._ifspecs.add(ifspec)
+ self._ifsUp.add(interface)
+ self._ifsDown.discard(interface)
+
+ def remove(self, interface, connection = None, protocol = None):
+ """
+ Removes a matching entry from the DB.
+
+ If no matching entry can be fonud, the operation returns successfully.
+ """
+ ifspec = f"{interface}:{connection}:{protocol}" if (connection is not None and protocol is not None) else interface
+ if ifspec in self._ifspecs:
+ self._ifspecs.remove(ifspec)
+ interface_remains = False
+ for ifspec in self._ifspecs:
+ if ifspec.split(':')[0] == interface:
+ interface_remains = True
+
+ if not interface_remains:
+ self._ifsDown.add(interface)
+ self._ifsUp.discard(interface)
+
+ def wantsInterfaceUp(self, interface):
+ """ Returns whether the DB contains at least one entry referencing the given interface """
+ for ifspec in self._ifspecs:
+ if ifspec.split(':')[0] == interface:
+ return True
+
+ return False
+
+ def removeAllOtherInterfaces(self, interface_list):
+ """ Removes all interfaces not included in the given list from the DB """
+ updated_ifspecs = set([ifspec for ifspec in self._ifspecs if ifspec.split(':')[0] in interface_list])
+ removed_ifspecs = self._ifspecs - updated_ifspecs
+ self._ifspecs = updated_ifspecs
+ interfaces_to_bring_down = [ifspec.split(':')[0] for ifspec in removed_ifspecs]
+ self._ifsDown.update(interfaces_to_bring_down)
+ self._ifsUp.difference_update(interfaces_to_bring_down)
+
+ def setPersistentInterfaces(self, interface_list):
+ """ Updates the set of persistently up interfaces to match the given list """
+ new_presistent_interfaces = set(interface_list)
+ current_presistent_interfaces = set([ifspec for ifspec in self._ifspecs if ':' not in ifspec])
+ added_presistent_interfaces = new_presistent_interfaces - current_presistent_interfaces
+ removed_presistent_interfaces = current_presistent_interfaces - new_presistent_interfaces
+
+ for interface in added_presistent_interfaces:
+ self.add(interface)
+
+ for interface in removed_presistent_interfaces:
+ self.remove(interface)
+
+ def commit(self, interface_dict_supplier):
+ """
+ Writes the DB to disk and brings interfaces up and down as needed.
+
+ Only interfaces referenced by entries modified in this DB session
+ are manipulated. If an interface is called to be brought up, the
+ provided interface_config_supplier function is invoked and expected
+ to return the config dictionary for the interface.
+ """
+ from vyos.ifconfig import VTIIf
+ from vyos.utils.process import call
+ from vyos.utils.network import get_interface_config
+
+ self._fileHandle.seek(0)
+ self._fileHandle.write(' '.join(self._ifspecs))
+ self._fileHandle.truncate()
+
+ for interface in self._ifsDown:
+ vti_link = get_interface_config(interface)
+ vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False)
+ if vti_link_up:
+ call(f'sudo ip link set {interface} down')
+ syslog(f'Interface {interface} is admin down ...')
+
+ self._ifsDown.clear()
+
+ for interface in self._ifsUp:
+ vti_link = get_interface_config(interface)
+ vti_link_up = (vti_link['operstate'] != 'DOWN' if 'operstate' in vti_link else False)
+ if not vti_link_up:
+ vti = interface_dict_supplier(interface)
+ if 'disable' not in vti:
+ tmp = VTIIf(interface, bypass_vti_updown_db = True)
+ tmp.update(vti)
+ syslog(f'Interface {interface} is admin up ...')
+
+ self._ifsUp.clear()
diff --git a/python/vyos/version.py b/python/vyos/version.py
new file mode 100644
index 0000000..86e96d0
--- /dev/null
+++ b/python/vyos/version.py
@@ -0,0 +1,142 @@
+# Copyright 2017-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/>.
+
+"""
+VyOS version data access library.
+
+VyOS stores its version data, which include the version number and some
+additional information in a JSON file. This module provides a convenient
+interface to reading it.
+
+Example of the version data dict::
+ {
+ 'built_by': 'autobuild@vyos.net',
+ 'build_id': '021ac2ee-cd07-448b-9991-9c68d878cddd',
+ 'version': '1.2.0-rolling+201806200337',
+ 'built_on': 'Wed 20 Jun 2018 03:37 UTC'
+ }
+"""
+
+import os
+
+import requests
+import vyos.defaults
+from vyos.system.image import is_live_boot
+
+from vyos.utils.file import read_file
+from vyos.utils.file import read_json
+from vyos.utils.process import popen
+from vyos.utils.process import DEVNULL
+
+version_file = os.path.join(vyos.defaults.directories['data'], 'version.json')
+
+def get_version_data(fname=version_file):
+ """
+ Get complete version data
+
+ Args:
+ file (str): path to the version file
+
+ Returns:
+ dict: version data, if it can not be found and empty dict
+
+ The optional ``file`` argument comes in handy in upgrade scripts
+ that need to retrieve information from images other than the running image.
+ It should not be used on a running system since the location of that file
+ is an implementation detail and may change in the future, while the interface
+ of this module will stay the same.
+ """
+ return read_json(fname, {})
+
+
+def get_version(fname=version_file):
+ """
+ Get the version number, or an empty string if it could not be determined
+ """
+ return get_version_data(fname=fname).get('version', '')
+
+
+def get_full_version_data(fname=version_file):
+ version_data = get_version_data(fname)
+
+ # Get system architecture (well, kernel architecture rather)
+ version_data['system_arch'], _ = popen('uname -m', stderr=DEVNULL)
+
+ hypervisor,code = popen('hvinfo', stderr=DEVNULL)
+ if code == 1:
+ # hvinfo returns 1 if it cannot detect any hypervisor
+ version_data['system_type'] = 'bare metal'
+ else:
+ version_data['system_type'] = f"{hypervisor} guest"
+
+ # Get boot type, it can be livecd or installed image
+ # In installed images, the squashfs image file is named after its image version,
+ # while on livecd it's just "filesystem.squashfs", that's how we tell a livecd boot
+ # from an installed image
+ if is_live_boot():
+ boot_via = "livecd"
+ else:
+ boot_via = "installed image"
+ version_data['boot_via'] = boot_via
+
+ # Get hardware details from DMI
+ dmi = '/sys/class/dmi/id'
+ version_data['hardware_vendor'] = read_file(dmi + '/sys_vendor', 'Unknown')
+ version_data['hardware_model'] = read_file(dmi +'/product_name','Unknown')
+
+ # These two assume script is run as root, normal users can't access those files
+ subsystem = '/sys/class/dmi/id/subsystem/id'
+ version_data['hardware_serial'] = read_file(subsystem + '/product_serial','Unknown')
+ version_data['hardware_uuid'] = read_file(subsystem + '/product_uuid', 'Unknown')
+
+ return version_data
+
+def get_remote_version(url):
+ """
+ Get remote available JSON file from remote URL
+ An example of the image-version.json
+
+ [
+ {
+ "arch":"amd64",
+ "flavors":[
+ "generic"
+ ],
+ "image":"vyos-rolling-latest.iso",
+ "latest":true,
+ "lts":false,
+ "release_date":"2022-09-06",
+ "release_train":"sagitta",
+ "url":"http://xxx/rolling/current/vyos-rolling-latest.iso",
+ "version":"vyos-1.4-rolling-202209060217"
+ }
+ ]
+ """
+ headers = {}
+ try:
+ remote_data = requests.get(url=url, headers=headers)
+ remote_data.raise_for_status()
+ if remote_data.status_code != 200:
+ return False
+ return remote_data.json()
+ except requests.exceptions.HTTPError as errh:
+ print ("HTTP Error:", errh)
+ except requests.exceptions.ConnectionError as errc:
+ print ("Connecting error:", errc)
+ except requests.exceptions.Timeout as errt:
+ print ("Timeout error:", errt)
+ except requests.exceptions.RequestException as err:
+ print ("Unable to get remote data", err)
+ return False
diff --git a/python/vyos/xml_ref/__init__.py b/python/vyos/xml_ref/__init__.py
new file mode 100644
index 0000000..99d8432
--- /dev/null
+++ b/python/vyos/xml_ref/__init__.py
@@ -0,0 +1,112 @@
+# Copyright 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/>.
+
+from typing import Optional, Union, TYPE_CHECKING
+from vyos.xml_ref import definition
+from vyos.xml_ref import op_definition
+
+if TYPE_CHECKING:
+ from vyos.config import ConfigDict
+
+def load_reference(cache=[]):
+ if cache:
+ return cache[0]
+
+ xml = definition.Xml()
+
+ try:
+ from vyos.xml_ref.cache import reference
+ except Exception:
+ raise ImportError('no xml reference cache !!')
+
+ if not reference:
+ raise ValueError('empty xml reference cache !!')
+
+ xml.define(reference)
+ cache.append(xml)
+
+ return xml
+
+def is_tag(path: list) -> bool:
+ return load_reference().is_tag(path)
+
+def is_tag_value(path: list) -> bool:
+ return load_reference().is_tag_value(path)
+
+def is_multi(path: list) -> bool:
+ return load_reference().is_multi(path)
+
+def is_valueless(path: list) -> bool:
+ return load_reference().is_valueless(path)
+
+def is_leaf(path: list) -> bool:
+ return load_reference().is_leaf(path)
+
+def owner(path: list, with_tag=False) -> str:
+ return load_reference().owner(path, with_tag=with_tag)
+
+def priority(path: list) -> str:
+ return load_reference().priority(path)
+
+def cli_defined(path: list, node: str, non_local=False) -> bool:
+ return load_reference().cli_defined(path, node, non_local=non_local)
+
+def component_version() -> dict:
+ return load_reference().component_version()
+
+def default_value(path: list) -> Optional[Union[str, list]]:
+ return load_reference().default_value(path)
+
+def multi_to_list(rpath: list, conf: dict) -> dict:
+ return load_reference().multi_to_list(rpath, conf)
+
+def get_defaults(path: list, get_first_key=False, recursive=False) -> dict:
+ return load_reference().get_defaults(path, get_first_key=get_first_key,
+ recursive=recursive)
+
+def relative_defaults(rpath: list, conf: dict, get_first_key=False,
+ recursive=False) -> dict:
+
+ return load_reference().relative_defaults(rpath, conf,
+ get_first_key=get_first_key,
+ recursive=recursive)
+
+def from_source(d: dict, path: list) -> bool:
+ return definition.from_source(d, path)
+
+def ext_dict_merge(source: dict, destination: Union[dict, 'ConfigDict']):
+ return definition.ext_dict_merge(source, destination)
+
+def load_op_reference(op_cache=[]):
+ if op_cache:
+ return op_cache[0]
+
+ op_xml = op_definition.OpXml()
+
+ try:
+ from vyos.xml_ref.op_cache import op_reference
+ except Exception:
+ raise ImportError('no xml op reference cache !!')
+
+ if not op_reference:
+ raise ValueError('empty xml op reference cache !!')
+
+ op_xml.define(op_reference)
+ op_cache.append(op_xml)
+
+ return op_xml
+
+def get_op_ref_path(path: list) -> list[op_definition.PathData]:
+ return load_op_reference()._get_op_ref_path(path)
diff --git a/python/vyos/xml_ref/definition.py b/python/vyos/xml_ref/definition.py
new file mode 100644
index 0000000..5ff28da
--- /dev/null
+++ b/python/vyos/xml_ref/definition.py
@@ -0,0 +1,339 @@
+# Copyright 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/>.
+
+from typing import Optional, Union, Any, TYPE_CHECKING
+
+# https://peps.python.org/pep-0484/#forward-references
+# for type 'ConfigDict'
+if TYPE_CHECKING:
+ from vyos.config import ConfigDict
+
+def set_source_recursive(o: Union[dict, str, list], b: bool):
+ d = {}
+ if not isinstance(o, dict):
+ d = {'_source': b}
+ else:
+ for k, v in o.items():
+ d[k] = set_source_recursive(v, b)
+ d |= {'_source': b}
+ return d
+
+def source_dict_merge(src: dict, dest: dict):
+ from copy import deepcopy
+ dst = deepcopy(dest)
+ from_src = {}
+
+ for key, value in src.items():
+ if key not in dst:
+ dst[key] = value
+ from_src[key] = set_source_recursive(value, True)
+ elif isinstance(src[key], dict):
+ dst[key], f = source_dict_merge(src[key], dst[key])
+ f |= {'_source': False}
+ from_src[key] = f
+
+ return dst, from_src
+
+def ext_dict_merge(src: dict, dest: Union[dict, 'ConfigDict']):
+ d, f = source_dict_merge(src, dest)
+ if hasattr(d, '_from_defaults'):
+ setattr(d, '_from_defaults', f)
+ return d
+
+def from_source(d: dict, path: list) -> bool:
+ for key in path:
+ d = d[key] if key in d else {}
+ if not d or not isinstance(d, dict):
+ return False
+ return d.get('_source', False)
+
+class Xml:
+ def __init__(self):
+ self.ref = {}
+
+ def define(self, ref: dict):
+ self.ref = ref
+
+ def _get_ref_node_data(self, node: dict, data: str) -> Union[bool, str]:
+ res = node.get('node_data', {})
+ if not res:
+ raise ValueError("non-existent node data")
+ if data not in res:
+ raise ValueError("non-existent data field")
+
+ return res.get(data)
+
+ def _get_ref_path(self, path: list) -> dict:
+ ref_path = path.copy()
+ d = self.ref
+ while ref_path and d:
+ d = d.get(ref_path[0], {})
+ ref_path.pop(0)
+ if self._is_tag_node(d) and ref_path:
+ ref_path.pop(0)
+
+ return d
+
+ def _is_tag_node(self, node: dict) -> bool:
+ res = self._get_ref_node_data(node, 'node_type')
+ return res == 'tag'
+
+ def is_tag(self, path: list) -> bool:
+ ref_path = path.copy()
+ d = self.ref
+ while ref_path and d:
+ d = d.get(ref_path[0], {})
+ ref_path.pop(0)
+ if self._is_tag_node(d) and ref_path:
+ if len(ref_path) == 1:
+ return False
+ ref_path.pop(0)
+
+ return self._is_tag_node(d)
+
+ def is_tag_value(self, path: list) -> bool:
+ if len(path) < 2:
+ return False
+
+ return self.is_tag(path[:-1])
+
+ def _is_multi_node(self, node: dict) -> bool:
+ b = self._get_ref_node_data(node, 'multi')
+ assert isinstance(b, bool)
+ return b
+
+ def is_multi(self, path: list) -> bool:
+ d = self._get_ref_path(path)
+ return self._is_multi_node(d)
+
+ def _is_valueless_node(self, node: dict) -> bool:
+ b = self._get_ref_node_data(node, 'valueless')
+ assert isinstance(b, bool)
+ return b
+
+ def is_valueless(self, path: list) -> bool:
+ d = self._get_ref_path(path)
+ return self._is_valueless_node(d)
+
+ def _is_leaf_node(self, node: dict) -> bool:
+ res = self._get_ref_node_data(node, 'node_type')
+ return res == 'leaf'
+
+ def is_leaf(self, path: list) -> bool:
+ d = self._get_ref_path(path)
+ return self._is_leaf_node(d)
+
+ def _least_upper_data(self, path: list, name: str) -> str:
+ ref_path = path.copy()
+ d = self.ref
+ data = ''
+ tag = ''
+ while ref_path and d:
+ tag_val = ''
+ d = d.get(ref_path[0], {})
+ ref_path.pop(0)
+ if self._is_tag_node(d) and ref_path:
+ tag_val = ref_path[0]
+ ref_path.pop(0)
+ if self._is_leaf_node(d) and ref_path:
+ ref_path.pop(0)
+ res = self._get_ref_node_data(d, name)
+ if res is not None:
+ data = res
+ tag = tag_val
+
+ return data, tag
+
+ def owner(self, path: list, with_tag=False) -> str:
+ from pathlib import Path
+ data, tag = self._least_upper_data(path, 'owner')
+ tag_ext = f'_{tag}' if tag else ''
+ if data:
+ if with_tag:
+ data = Path(data.split()[0]).stem
+ data = f'{data}{tag_ext}'
+ else:
+ data = Path(data.split()[0]).name
+ return data
+
+ def priority(self, path: list) -> str:
+ data, _ = self._least_upper_data(path, 'priority')
+ return data
+
+ @staticmethod
+ def _dict_get(d: dict, path: list) -> dict:
+ for i in path:
+ d = d.get(i, {})
+ if not isinstance(d, dict):
+ return {}
+ if not d:
+ break
+ return d
+
+ def _dict_find(self, d: dict, key: str, non_local=False) -> bool:
+ for k in list(d):
+ if k in ('node_data', 'component_version'):
+ continue
+ if k == key:
+ return True
+ if non_local and isinstance(d[k], dict):
+ if self._dict_find(d[k], key):
+ return True
+ return False
+
+ def cli_defined(self, path: list, node: str, non_local=False) -> bool:
+ d = self._dict_get(self.ref, path)
+ return self._dict_find(d, node, non_local=non_local)
+
+ def component_version(self) -> dict:
+ d = {}
+ for k, v in self.ref['component_version'].items():
+ d[k] = int(v)
+ return d
+
+ def multi_to_list(self, rpath: list, conf: dict) -> dict:
+ res: Any = {}
+
+ for k in list(conf):
+ d = self._get_ref_path(rpath + [k])
+ if self._is_leaf_node(d):
+ if self._is_multi_node(d) and not isinstance(conf[k], list):
+ res[k] = [conf[k]]
+ else:
+ res[k] = conf[k]
+ else:
+ res[k] = self.multi_to_list(rpath + [k], conf[k])
+
+ return res
+
+ def _get_default_value(self, node: dict) -> Optional[str]:
+ return self._get_ref_node_data(node, "default_value")
+
+ def _get_default(self, node: dict) -> Optional[Union[str, list]]:
+ default = self._get_default_value(node)
+ if default is None:
+ return None
+ if self._is_multi_node(node):
+ return default.split()
+ return default
+
+ def default_value(self, path: list) -> Optional[Union[str, list]]:
+ d = self._get_ref_path(path)
+ default = self._get_default_value(d)
+ if default is None:
+ return None
+ if self._is_multi_node(d) or self._is_tag_node(d):
+ return default.split()
+ return default
+
+ def get_defaults(self, path: list, get_first_key=False, recursive=False) -> dict:
+ """Return dict containing default values below path
+
+ Note that descent below path will not proceed beyond an encountered
+ tag node, as no tag node value is known. For a default dict relative
+ to an existing config dict containing tag node values, see function:
+ 'relative_defaults'
+ """
+ res: dict = {}
+ if self.is_tag(path):
+ return res
+
+ d = self._get_ref_path(path)
+
+ if self._is_leaf_node(d):
+ default_value = self._get_default(d)
+ if default_value is not None:
+ return {path[-1]: default_value} if path else {}
+
+ for k in list(d):
+ if k in ('node_data', 'component_version') :
+ continue
+ if self._is_leaf_node(d[k]):
+ default_value = self._get_default(d[k])
+ if default_value is not None:
+ res |= {k: default_value}
+ elif self.is_tag(path + [k]):
+ # tag node defaults are used as suggestion, not default value;
+ # should this change, append to path and continue if recursive
+ pass
+ else:
+ if recursive:
+ pos = self.get_defaults(path + [k], recursive=True)
+ res |= pos
+ if res:
+ if get_first_key or not path:
+ return res
+ return {path[-1]: res}
+
+ return {}
+
+ def _well_defined(self, path: list, conf: dict) -> bool:
+ # test disjoint path + conf for sensible config paths
+ def step(c):
+ return [next(iter(c.keys()))] if c else []
+ try:
+ tmp = step(conf)
+ if tmp and self.is_tag_value(path + tmp):
+ c = conf[tmp[0]]
+ if not isinstance(c, dict):
+ raise ValueError
+ tmp = tmp + step(c)
+ self._get_ref_path(path + tmp)
+ else:
+ self._get_ref_path(path + tmp)
+ except ValueError:
+ return False
+ return True
+
+ def _relative_defaults(self, rpath: list, conf: dict, recursive=False) -> dict:
+ res: dict = {}
+ res = self.get_defaults(rpath, recursive=recursive,
+ get_first_key=True)
+ for k in list(conf):
+ if isinstance(conf[k], dict):
+ step = self._relative_defaults(rpath + [k], conf=conf[k],
+ recursive=recursive)
+ res |= step
+
+ if res:
+ return {rpath[-1]: res} if rpath else res
+
+ return {}
+
+ def relative_defaults(self, path: list, conf: dict, get_first_key=False,
+ recursive=False) -> dict:
+ """Return dict containing defaults along paths of a config dict
+ """
+ if not conf:
+ return self.get_defaults(path, get_first_key=get_first_key,
+ recursive=recursive)
+ if not self._well_defined(path, conf):
+ # adjust for possible overlap:
+ if path and path[-1] in list(conf):
+ conf = conf[path[-1]]
+ conf = {} if not isinstance(conf, dict) else conf
+ if not self._well_defined(path, conf):
+ print('path to config dict does not define full config paths')
+ return {}
+
+ res = self._relative_defaults(path, conf, recursive=recursive)
+
+ if get_first_key and path:
+ if res.values():
+ res = next(iter(res.values()))
+ else:
+ res = {}
+
+ return res
diff --git a/python/vyos/xml_ref/generate_cache.py b/python/vyos/xml_ref/generate_cache.py
new file mode 100644
index 0000000..5f3f84d
--- /dev/null
+++ b/python/vyos/xml_ref/generate_cache.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023-2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# 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 sys
+import json
+from argparse import ArgumentParser
+from argparse import ArgumentTypeError
+from os.path import join
+from os.path import abspath
+from os.path import dirname
+from xmltodict import parse
+
+_here = dirname(__file__)
+
+sys.path.append(join(_here, '..'))
+from configtree import reference_tree_to_json, ConfigTreeError
+
+xml_cache_json = 'xml_cache.json'
+xml_tmp = join('/tmp', xml_cache_json)
+pkg_cache = abspath(join(_here, 'pkg_cache'))
+ref_cache = abspath(join(_here, 'cache.py'))
+
+node_data_fields = ("node_type", "multi", "valueless", "default_value",
+ "owner", "priority")
+
+def trim_node_data(cache: dict):
+ for k in list(cache):
+ if k == "node_data":
+ for l in list(cache[k]):
+ if l not in node_data_fields:
+ del cache[k][l]
+ else:
+ if isinstance(cache[k], dict):
+ trim_node_data(cache[k])
+
+def non_trivial(s):
+ if not s:
+ raise ArgumentTypeError("Argument must be non empty string")
+ return s
+
+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('--package-name', type=non_trivial, default='vyos-1x',
+ help='name of current package')
+ parser.add_argument('--output-path', help='path to generated cache')
+ args = vars(parser.parse_args())
+
+ xml_dir = abspath(args['xml_dir'])
+ pkg_name = args['package_name'].replace('-','_')
+ cache_name = pkg_name + '_cache.py'
+ out_path = args['output_path']
+ path = out_path if out_path is not None else pkg_cache
+ xml_cache = abspath(join(path, cache_name))
+
+ try:
+ reference_tree_to_json(xml_dir, xml_tmp)
+ except ConfigTreeError as e:
+ print(e)
+ sys.exit(1)
+
+ with open(xml_tmp) as f:
+ d = json.loads(f.read())
+
+ trim_node_data(d)
+
+ syntax_version = join(xml_dir, 'xml-component-version.xml')
+ try:
+ with open(syntax_version) as f:
+ component = f.read()
+ except FileNotFoundError:
+ if pkg_name != 'vyos_1x':
+ component = ''
+ else:
+ print("\nWARNING: missing xml-component-version.xml\n")
+ sys.exit(1)
+
+ if component:
+ parsed = parse(component)
+ else:
+ parsed = None
+ version = {}
+ # addon package definitions may have empty (== 0) version info
+ if parsed is not None and parsed['interfaceDefinition'] is not None:
+ converted = parsed['interfaceDefinition']['syntaxVersion']
+ if not isinstance(converted, list):
+ converted = [converted]
+ for i in converted:
+ tmp = {i['@component']: i['@version']}
+ version |= tmp
+
+ version = {"component_version": version}
+
+ d |= version
+
+ with open(xml_cache, 'w') as f:
+ f.write(f'reference = {str(d)}')
+
+ print(cache_name)
+
+if __name__ == '__main__':
+ main()
diff --git a/python/vyos/xml_ref/generate_op_cache.py b/python/vyos/xml_ref/generate_op_cache.py
new file mode 100644
index 0000000..cd2ac89
--- /dev/null
+++ b/python/vyos/xml_ref/generate_op_cache.py
@@ -0,0 +1,174 @@
+#!/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 re
+import sys
+import json
+import glob
+
+from argparse import ArgumentParser
+from os.path import join
+from os.path import abspath
+from os.path import dirname
+from xml.etree import ElementTree as ET
+from xml.etree.ElementTree import Element
+from typing import TypeAlias
+from typing import Optional
+
+_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'))
+
+OptElement: TypeAlias = Optional[Element]
+DEBUG = False
+
+
+def translate_exec(s: str) -> str:
+ s = s.replace('${vyos_op_scripts_dir}', directories['op_mode'])
+ s = s.replace('${vyos_libexec_dir}', directories['base'])
+ return s
+
+
+def translate_position(s: str, pos: list[str]) -> str:
+ pos = pos.copy()
+ pat: re.Pattern = re.compile(r'(?:\")?\${?([0-9]+)}?(?:\")?')
+ t: str = pat.sub(r'_place_holder_\1_', s)
+
+ # preferred to .format(*list) to avoid collisions with braces
+ for i, p in enumerate(pos):
+ t = t.replace(f'_place_holder_{i+1}_', p)
+
+ return t
+
+
+def translate_command(s: str, pos: list[str]) -> str:
+ s = translate_exec(s)
+ s = translate_position(s, pos)
+ return s
+
+
+def translate_op_script(s: str) -> str:
+ s = s.replace('${vyos_completion_dir}', directories['completion_dir'])
+ s = s.replace('${vyos_op_scripts_dir}', directories['op_mode'])
+ return s
+
+
+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')
+ command: OptElement = n.find('command')
+ # name is not None as required by schema
+ name: str = n.get('name', 'schema_error')
+ node_type: str = n.tag
+ if path is None:
+ path = []
+
+ path.append(name)
+ if node_type == 'tagNode':
+ path.append(f'{name}-tag_value')
+
+ help_prop: OptElement = None if prop is None else prop.find('help')
+ help_text = None if help_prop is None else help_prop.text
+ command_text = None if command is None else command.text
+ if command_text is not None:
+ command_text = translate_command(command_text, path)
+
+ comp_help = None
+ if prop is not None:
+ 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)
+
+ if children is not None:
+ inner_nodes = children.iterfind("*")
+ for inner_n in inner_nodes:
+ inner_path = path[:]
+ insert_node(inner_n, inner_l, inner_path)
+
+
+def parse_file(file_path, l):
+ tree = ET.parse(file_path)
+ root = tree.getroot()
+ 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')
+
+ args = vars(parser.parse_args())
+
+ xml_dir = abspath(args['xml_dir'])
+
+ l = []
+
+ for fname in glob.glob(f'{xml_dir}/*.xml'):
+ parse_file(fname, l)
+
+ with open(xml_op_tmp, 'w') as f:
+ json.dump(l, f, indent=2)
+
+ with open(op_ref_cache, 'w') as f:
+ f.write(f'op_reference = {str(l)}')
+
+if __name__ == '__main__':
+ main()
diff --git a/python/vyos/xml_ref/op_definition.py b/python/vyos/xml_ref/op_definition.py
new file mode 100644
index 0000000..914f3a1
--- /dev/null
+++ b/python/vyos/xml_ref/op_definition.py
@@ -0,0 +1,49 @@
+# Copyright 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/>.
+
+from typing import TypedDict
+from typing import TypeAlias
+from typing import Optional
+from typing import Union
+
+
+class NodeData(TypedDict):
+ node_type: Optional[str]
+ help_text: Optional[str]
+ comp_help: Optional[dict[str, list]]
+ command: Optional[str]
+ path: Optional[list[str]]
+
+
+PathData: TypeAlias = dict[str, Union[NodeData|list['PathData']]]
+
+
+class OpXml:
+ def __init__(self):
+ self.op_ref = {}
+
+ def define(self, op_ref: list[PathData]) -> None:
+ self.op_ref = op_ref
+
+ def _get_op_ref_path(self, path: list[str]) -> list[PathData]:
+ def _get_path_list(path: list[str], l: list[PathData]) -> list[PathData]:
+ if not path:
+ return l
+ for d in l:
+ if path[0] in list(d):
+ return _get_path_list(path[1:], d[path[0]])
+ return []
+ l = self.op_ref
+ return _get_path_list(path, l)
diff --git a/python/vyos/xml_ref/pkg_cache/__init__.py b/python/vyos/xml_ref/pkg_cache/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/python/vyos/xml_ref/pkg_cache/__init__.py
diff --git a/python/vyos/xml_ref/update_cache.py b/python/vyos/xml_ref/update_cache.py
new file mode 100644
index 0000000..0842bcb
--- /dev/null
+++ b/python/vyos/xml_ref/update_cache.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2023 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 copy import deepcopy
+from generate_cache import pkg_cache
+from generate_cache import ref_cache
+
+def dict_merge(source, destination):
+ dest = deepcopy(destination)
+
+ for key, value in source.items():
+ if key not in dest:
+ dest[key] = value
+ elif isinstance(source[key], dict):
+ dest[key] = dict_merge(source[key], dest[key])
+
+ return dest
+
+def main():
+ res = {}
+ cache_dir = os.path.basename(pkg_cache)
+ for mod in os.listdir(pkg_cache):
+ mod = os.path.splitext(mod)[0]
+ if not mod.endswith('_cache'):
+ continue
+ d = getattr(__import__(f'{cache_dir}.{mod}', fromlist=[mod]), 'reference')
+ if mod == 'vyos_1x_cache':
+ res = dict_merge(res, d)
+ else:
+ res = dict_merge(d, res)
+
+ with open(ref_cache, 'w') as f:
+ f.write(f'reference = {str(res)}')
+
+if __name__ == '__main__':
+ main()