summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--python/vyos/config_mgmt.py7
-rw-r--r--python/vyos/configtree.py3
-rw-r--r--python/vyos/load_config.py200
-rw-r--r--python/vyos/utils/io.py9
-rwxr-xr-xsmoketest/scripts/cli/test_service_dns_dynamic.py39
-rwxr-xr-xsrc/conf_mode/dns_dynamic.py24
-rwxr-xr-xsrc/migration-scripts/nat/6-to-78
-rwxr-xr-xsrc/op_mode/dhcp.py10
-rwxr-xr-xsrc/op_mode/image_installer.py3
-rwxr-xr-xsrc/op_mode/nat.py6
-rwxr-xr-xsrc/validators/bgp-large-community-list8
11 files changed, 280 insertions, 37 deletions
diff --git a/python/vyos/config_mgmt.py b/python/vyos/config_mgmt.py
index 950f14d4f..fd0fa7a75 100644
--- a/python/vyos/config_mgmt.py
+++ b/python/vyos/config_mgmt.py
@@ -125,6 +125,7 @@ class ConfigMgmt:
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', '')
@@ -233,7 +234,7 @@ Proceed ?'''
msg = ''
if not self._check_revision_number(rev):
- msg = f'Invalid revision number {rev}: must be 0 < rev < {self.max_revisions}'
+ msg = f'Invalid revision number {rev}: must be 0 < rev < {self.num_revisions}'
return msg, 1
prompt_str = 'Proceed with reboot ?'
@@ -560,8 +561,8 @@ Proceed ?'''
return len(l)
def _check_revision_number(self, rev: int) -> bool:
- maxrev = self._get_number_of_revisions()
- if not 0 <= rev < maxrev:
+ self.num_revisions = self._get_number_of_revisions()
+ if not 0 <= rev < self.num_revisions:
return False
return True
diff --git a/python/vyos/configtree.py b/python/vyos/configtree.py
index 09cfd43d3..d048901f0 100644
--- a/python/vyos/configtree.py
+++ b/python/vyos/configtree.py
@@ -160,6 +160,9 @@ class ConfigTree(object):
def _get_config(self):
return self.__config
+ def get_version_string(self):
+ return self.__version
+
def to_string(self, ordered_values=False):
config_string = self.__to_string(self.__config, ordered_values).decode()
config_string = "{0}\n{1}".format(config_string, self.__version)
diff --git a/python/vyos/load_config.py b/python/vyos/load_config.py
new file mode 100644
index 000000000..af563614d
--- /dev/null
+++ b/python/vyos/load_config.py
@@ -0,0 +1,200 @@
+# 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/>.
+
+"""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.component_version import from_string as version_from_string
+from vyos.component_version import from_system as version_from_system
+from vyos.migrator import Migrator, VirtualMigrator, MigratorError
+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 migration_needed(config_obj: ConfigObj) -> bool:
+ """Check if a migration is needed for the config object.
+ """
+ if not isinstance(config_obj, ConfigTree):
+ atree = get_proposed_config(config_obj)
+ else:
+ atree = config_obj
+ version_str = atree.get_version_string()
+ if not version_str:
+ return True
+ aversion = version_from_string(version_str.splitlines()[1])
+ bversion = version_from_system()
+ return aversion != bversion
+
+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
+
+ virtual_migration = VirtualMigrator(config_file)
+ migration = Migrator(config_file)
+ try:
+ virtual_migration.run()
+ migration.run()
+ except MigratorError 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)
+
+ if migration_needed(config_obj):
+ config_obj = migrate(config_obj)
+
+ func = getattr(thismod, f'load_{switch}')
+ func(config_obj)
diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py
index 74099b502..0afaf695c 100644
--- a/python/vyos/utils/io.py
+++ b/python/vyos/utils/io.py
@@ -26,13 +26,18 @@ def print_error(str='', end='\n'):
sys.stderr.write(end)
sys.stderr.flush()
-def ask_input(question, default='', numeric_only=False, valid_responses=[]):
+def ask_input(question, default='', numeric_only=False, valid_responses=[],
+ no_echo=False):
+ from getpass import getpass
question_out = question
if default:
question_out += f' (Default: {default})'
response = ''
while True:
- response = input(question_out + ' ').strip()
+ 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:
diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py
index 3c7303f32..ae46b18ba 100755
--- a/smoketest/scripts/cli/test_service_dns_dynamic.py
+++ b/smoketest/scripts/cli/test_service_dns_dynamic.py
@@ -17,8 +17,6 @@
import os
import unittest
import tempfile
-import random
-import string
from base_vyostest_shim import VyOSUnitTestSHIM
@@ -67,14 +65,12 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
self.cli_set(name_path + [svc, 'address', interface])
self.cli_set(name_path + [svc, 'host-name', hostname])
self.cli_set(name_path + [svc, 'password', password])
- self.cli_set(name_path + [svc, 'zone', zone])
- self.cli_set(name_path + [svc, 'ttl', ttl])
for opt, value in details.items():
self.cli_set(name_path + [svc, opt, value])
- # 'zone' option is supported and required by 'cloudfare', but not 'freedns' and 'zoneedit'
+ # 'zone' option is supported by 'cloudfare' and 'zoneedit1', but not 'freedns'
self.cli_set(name_path + [svc, 'zone', zone])
- if details['protocol'] == 'cloudflare':
+ if details['protocol'] in ['cloudflare', 'zoneedit1']:
pass
else:
# exception is raised for unsupported ones
@@ -292,7 +288,36 @@ class TestServiceDDNS(VyOSUnitTestSHIM.TestCase):
self.assertIn(f'password=\'{password}\'', ddclient_conf)
self.assertIn(f'{hostname}', ddclient_conf)
- def test_07_dyndns_vrf(self):
+ def test_07_dyndns_dynamic_interface(self):
+ # Check if DDNS service can be configured and runs
+ svc_path = name_path + ['namecheap']
+ proto = 'namecheap'
+ dyn_interface = 'pppoe587'
+
+ self.cli_set(svc_path + ['address', dyn_interface])
+ self.cli_set(svc_path + ['protocol', proto])
+ self.cli_set(svc_path + ['server', server])
+ self.cli_set(svc_path + ['username', username])
+ self.cli_set(svc_path + ['password', password])
+ self.cli_set(svc_path + ['host-name', hostname])
+
+ # Dynamic interface will raise a warning but still go through
+ # XXX: We should have idiomatic class "ConfigSessionWarning" wrapping
+ # "Warning" similar to "ConfigSessionError".
+ # with self.assertWarns(Warning):
+ # self.cli_commit()
+ self.cli_commit()
+
+ # Check the generating config parameters
+ ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}')
+ self.assertIn(f'ifv4={dyn_interface}', ddclient_conf)
+ self.assertIn(f'protocol={proto}', ddclient_conf)
+ self.assertIn(f'server={server}', ddclient_conf)
+ self.assertIn(f'login={username}', ddclient_conf)
+ self.assertIn(f'password=\'{password}\'', ddclient_conf)
+ self.assertIn(f'{hostname}', ddclient_conf)
+
+ def test_08_dyndns_vrf(self):
# Table number randomized, but should be within range 100-65535
vrf_table = '58710'
vrf_name = f'vyos-test-{vrf_table}'
diff --git a/src/conf_mode/dns_dynamic.py b/src/conf_mode/dns_dynamic.py
index c4dcb76ed..809c650d9 100755
--- a/src/conf_mode/dns_dynamic.py
+++ b/src/conf_mode/dns_dynamic.py
@@ -30,6 +30,9 @@ airbag.enable()
config_file = r'/run/ddclient/ddclient.conf'
systemd_override = r'/run/systemd/system/ddclient.service.d/override.conf'
+# Dynamic interfaces that might not exist when the configuration is loaded
+dynamic_interfaces = ('pppoe', 'sstpc')
+
# Protocols that require zone
zone_necessary = ['cloudflare', 'digitalocean', 'godaddy', 'hetzner', 'gandi',
'nfsn', 'nsupdate']
@@ -86,17 +89,19 @@ def verify(dyndns):
if field not in config:
raise ConfigError(f'"{field.replace("_", "-")}" {error_msg_req}')
- # If dyndns address is an interface, ensure that it exists
+ # If dyndns address is an interface, ensure
+ # that the interface exists (or just warn if dynamic interface)
# and that web-options are not set
if config['address'] != 'web':
# exclude check interface for dynamic interfaces
- interface_filter = ('pppoe', 'sstpc')
- if config['address'].startswith(interface_filter):
- Warning(f'interface {config["address"]} does not exist!')
+ if config['address'].startswith(dynamic_interfaces):
+ Warning(f'Interface "{config["address"]}" does not exist yet and cannot '
+ f'be used for Dynamic DNS service "{service}" until it is up!')
else:
verify_interface_exists(config['address'])
if 'web_options' in config:
- raise ConfigError(f'"web-options" is applicable only when using HTTP(S) web request to obtain the IP address')
+ raise ConfigError(f'"web-options" is applicable only when using HTTP(S) '
+ f'web request to obtain the IP address')
# RFC2136 uses 'key' instead of 'password'
if config['protocol'] != 'nsupdate' and 'password' not in config:
@@ -124,13 +129,16 @@ def verify(dyndns):
if config['ip_version'] == 'both':
if config['protocol'] not in dualstack_supported:
- raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} with protocol "{config["protocol"]}"')
+ raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} '
+ f'with protocol "{config["protocol"]}"')
# dyndns2 protocol in ddclient honors dual stack only for dyn.com (dyndns.org)
if config['protocol'] == 'dyndns2' and 'server' in config and config['server'] not in dyndns_dualstack_servers:
- raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} for "{config["server"]}" with protocol "{config["protocol"]}"')
+ raise ConfigError(f'Both IPv4 and IPv6 at the same time {error_msg_uns} '
+ f'for "{config["server"]}" with protocol "{config["protocol"]}"')
if {'wait_time', 'expiry_time'} <= config.keys() and int(config['expiry_time']) < int(config['wait_time']):
- raise ConfigError(f'"expiry-time" must be greater than "wait-time" for Dynamic DNS service "{service}"')
+ raise ConfigError(f'"expiry-time" must be greater than "wait-time" for '
+ f'Dynamic DNS service "{service}"')
return None
diff --git a/src/migration-scripts/nat/6-to-7 b/src/migration-scripts/nat/6-to-7
index b5f6328ef..a2e735394 100755
--- a/src/migration-scripts/nat/6-to-7
+++ b/src/migration-scripts/nat/6-to-7
@@ -21,6 +21,7 @@
# to
# 'set nat [source|destination] rule X [inbound-interface|outbound interface] name <iface>'
# 'set nat [source|destination] rule X [inbound-interface|outbound interface] group <iface_group>'
+# Also remove command if interface == any
from sys import argv,exit
from vyos.configtree import ConfigTree
@@ -56,8 +57,11 @@ for direction in ['source', 'destination']:
if config.exists(base + [iface]):
if config.exists(base + [iface, 'interface-name']):
tmp = config.return_value(base + [iface, 'interface-name'])
- config.delete(base + [iface, 'interface-name'])
- config.set(base + [iface, 'name'], value=tmp)
+ if tmp != 'any':
+ config.delete(base + [iface, 'interface-name'])
+ config.set(base + [iface, 'name'], value=tmp)
+ else:
+ config.delete(base + [iface])
try:
with open(file_name, 'w') as f:
diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py
index bd2c522ca..a9271ea79 100755
--- a/src/op_mode/dhcp.py
+++ b/src/op_mode/dhcp.py
@@ -102,11 +102,11 @@ def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], orig
if family == 'inet':
data_lease['mac'] = lease['hwaddr']
- data_lease['start'] = lease['start_timestamp']
+ data_lease['start'] = lease['start_timestamp'].timestamp()
data_lease['hostname'] = lease['hostname']
if family == 'inet6':
- data_lease['last_communication'] = lease['start_timestamp']
+ data_lease['last_communication'] = lease['start_timestamp'].timestamp()
data_lease['iaid_duid'] = _format_hex_string(lease['duid'])
lease_types_long = {'0': 'non-temporary', '1': 'temporary', '2': 'prefix delegation'}
data_lease['type'] = lease_types_long[lease['lease_type']]
@@ -123,7 +123,7 @@ def _get_raw_server_leases(family='inet', pool=None, sorted=None, state=[], orig
# Do not add old leases
if data_lease['remaining'] != '' and data_lease['pool'] in pool and data_lease['state'] != 'free':
- if not state or data_lease['state'] in state:
+ if not state or state == 'all' or data_lease['state'] in state:
data.append(data_lease)
# deduplicate
@@ -151,7 +151,7 @@ def _get_formatted_server_leases(raw_data, family='inet'):
ipaddr = lease.get('ip')
hw_addr = lease.get('mac')
state = lease.get('state')
- start = lease.get('start').timestamp()
+ start = lease.get('start')
start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S')
end = lease.get('end')
end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S') if end else '-'
@@ -168,7 +168,7 @@ def _get_formatted_server_leases(raw_data, family='inet'):
for lease in raw_data:
ipaddr = lease.get('ip')
state = lease.get('state')
- start = lease.get('last_communication').timestamp()
+ start = lease.get('last_communication')
start = _utc_to_local(start).strftime('%Y/%m/%d %H:%M:%S')
end = lease.get('end')
end = _utc_to_local(end).strftime('%Y/%m/%d %H:%M:%S')
diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py
index b3e6e518c..9452c5e28 100755
--- a/src/op_mode/image_installer.py
+++ b/src/op_mode/image_installer.py
@@ -611,7 +611,8 @@ def install_image() -> None:
print(MSG_WARN_IMAGE_NAME_WRONG)
# ask for password
- user_password: str = ask_input(MSG_INPUT_PASSWORD, default='vyos')
+ user_password: str = ask_input(MSG_INPUT_PASSWORD, default='vyos',
+ no_echo=True)
# ask for default console
console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE,
diff --git a/src/op_mode/nat.py b/src/op_mode/nat.py
index 71a40c0e1..2bc7e24fe 100755
--- a/src/op_mode/nat.py
+++ b/src/op_mode/nat.py
@@ -28,9 +28,6 @@ from vyos.configquery import ConfigTreeQuery
from vyos.utils.process import cmd
from vyos.utils.dict import dict_search
-base = 'nat'
-unconf_message = 'NAT is not configured'
-
ArgDirection = typing.Literal['source', 'destination']
ArgFamily = typing.Literal['inet', 'inet6']
@@ -293,8 +290,9 @@ def _verify(func):
@wraps(func)
def _wrapper(*args, **kwargs):
config = ConfigTreeQuery()
+ base = 'nat66' if 'inet6' in sys.argv[1:] else 'nat'
if not config.exists(base):
- raise vyos.opmode.UnconfiguredSubsystem(unconf_message)
+ raise vyos.opmode.UnconfiguredSubsystem(f'{base.upper()} is not configured')
return func(*args, **kwargs)
return _wrapper
diff --git a/src/validators/bgp-large-community-list b/src/validators/bgp-large-community-list
index 80112dfdc..9ba5b27eb 100755
--- a/src/validators/bgp-large-community-list
+++ b/src/validators/bgp-large-community-list
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
#
-# Copyright (C) 2021 VyOS maintainers and contributors
+# Copyright (C) 2021-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
@@ -17,9 +17,8 @@
import re
import sys
-from vyos.template import is_ipv4
-
pattern = '(.*):(.*):(.*)'
+allowedChars = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '*', '?', '^', '$', '(', ')', '[', ']', '{', '}', '|', '\\', ':', '-' }
if __name__ == '__main__':
if len(sys.argv) != 2:
@@ -29,8 +28,7 @@ if __name__ == '__main__':
if not len(value) == 3:
sys.exit(1)
- if not (re.match(pattern, sys.argv[1]) and
- (is_ipv4(value[0]) or value[0].isdigit()) and (value[1].isdigit() or value[1] == '*')):
+ if not (re.match(pattern, sys.argv[1]) and set(sys.argv[1]).issubset(allowedChars)):
sys.exit(1)
sys.exit(0)