diff options
Diffstat (limited to 'src/helpers')
30 files changed, 1502 insertions, 779 deletions
diff --git a/src/helpers/add-system-version.py b/src/helpers/add-system-version.py index 5270ee7d3..70bbd2202 100755 --- a/src/helpers/add-system-version.py +++ b/src/helpers/add-system-version.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/src/helpers/config_dependency.py b/src/helpers/config_dependency.py index 817bcc65a..4a7383cc2 100755 --- a/src/helpers/config_dependency.py +++ b/src/helpers/config_dependency.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -59,7 +59,7 @@ def graph_from_dependency_dict(d: dict) -> dict: 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 + # that are mutually exclusive in the future, the graphs will be # distinguished for el in list(d[k]): g[k] |= set(d[k][el]) @@ -94,7 +94,7 @@ def path_exists(s): return s def main(): - parser = ArgumentParser(description='generate and save dict from xml defintions') + parser = ArgumentParser(description='generate and save dict from xml definitions') parser.add_argument('--dependency-dir', type=path_exists, default=dependency_dir, help='location of vyos-1x dependency directory') diff --git a/src/helpers/geoip-update.py b/src/helpers/geoip-update.py index 34accf2cc..1879ab7ef 100755 --- a/src/helpers/geoip-update.py +++ b/src/helpers/geoip-update.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,32 +13,77 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# Note: script is used both in opmode and by build hook `40-init-geoip-database.chroot` import argparse import sys from vyos.configquery import ConfigTreeQuery -from vyos.firewall import geoip_update - -def get_config(config=None): - if config: - conf = config - else: - conf = ConfigTreeQuery() - base = ['firewall'] +from vyos.geoip import geoip_download_dbip +from vyos.geoip import geoip_download_maxmind +from vyos.geoip import db_initialise +from vyos.geoip import db_is_initialised +from vyos.geoip import db_import_dbip_ranges +from vyos.geoip import db_import_maxmind_ranges +from vyos.geoip import geoip_update - if not conf.exists(base): - return None - - return conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, - no_tag_node_value_mangle=True) +def get_config(conf): + return ( + conf.get_config_dict(['firewall', 'global-options', 'geoip'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True, with_defaults=True), + conf.get_config_dict(['firewall'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) if conf.exists(['firewall']) else None, + conf.get_config_dict(['policy'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) if conf.exists(['policy']) else None, + ) if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument("--force", help="Force update", action="store_true") + parser.add_argument("--init", help="Initialise", action="store_true") args = parser.parse_args() - firewall = get_config() + if args.init: + db_initialise() + db_import_dbip_ranges(delete_file=True) + sys.exit(0) + + conf = ConfigTreeQuery() + + if not conf.exists(['system', 'name-server']): + print('There are no system name-servers configured') + sys.exit(1) + + options, firewall, policy = get_config(conf) + + if not db_is_initialised(): + db_initialise() + + if options['provider'] == 'db-ip': + print('Downloading latest DB-IP database...') + if not geoip_download_dbip(): + print('Failed to download, aborting.') + sys.exit(1) + + print('Extracting database...') + if not db_import_dbip_ranges(delete_file=True): + print('Failed to extract, aborting.') + sys.exit(1) + + elif options['provider'] == 'maxmind': + account_id = options['maxmind_account_id'] + license_key = options['maxmind_license_key'] + lite = 'maxmind_lite' in options + + print('Downloading latest MaxMind database...') + if not geoip_download_maxmind(account_id, license_key, lite): + print('Failed to download, aborting.') + sys.exit(1) + + print('Extracting database...') + if not db_import_maxmind_ranges(delete_file=True): + print('Failed to extract, aborting.') + sys.exit(1) - if not geoip_update(firewall, force=args.force): + if not geoip_update(firewall=firewall, policy=policy): sys.exit(1) diff --git a/src/helpers/priority.py b/src/helpers/priority.py index 04186104c..6630889af 100755 --- a/src/helpers/priority.py +++ b/src/helpers/priority.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/read-saved-value.py b/src/helpers/read-saved-value.py index 1463e9ffe..f4048f373 100755 --- a/src/helpers/read-saved-value.py +++ b/src/helpers/read-saved-value.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/reset_section.py b/src/helpers/reset_section.py new file mode 100755 index 000000000..2d5695d6c --- /dev/null +++ b/src/helpers/reset_section.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# + + +import argparse +import sys +import os +import grp + +from vyos.configsession import ConfigSession +from vyos.config import Config +from vyos.configdiff import get_config_diff +from vyos.xml_ref import is_leaf +from vyos.utils.commit import wait_for_commit_lock + + +CFG_GROUP = 'vyattacfg' +DEBUG = False + + +def type_str_to_list(value): + if isinstance(value, str): + return value.split() + raise argparse.ArgumentTypeError('path must be a whitespace separated string') + + +parser = argparse.ArgumentParser() +parser.add_argument('path', type=type_str_to_list, help='section to reload/rollback') +parser.add_argument('--pid', help='pid of config session') + +group = parser.add_mutually_exclusive_group() +group.add_argument('--reload', action='store_true', help='retry proposed commit') +group.add_argument( + '--rollback', action='store_true', default=True, help='rollback to stable commit' +) + +args = parser.parse_args() + +path = args.path +reload = args.reload +rollback = args.rollback +pid = args.pid + +try: + if is_leaf(path): + sys.exit('path is leaf node: neither allowed nor useful') +except ValueError: + if DEBUG: + sys.exit('nonexistent path: neither allowed nor useful') + else: + sys.exit() + +test = Config() +in_session = test.in_session() + +if in_session: + if reload: + sys.exit('reset_section reload not available inside of a config session') + + diff = get_config_diff(test) + if not diff.is_node_changed(path): + # No discrepancies at path after commit, hence no error to revert. + sys.exit() + + del diff +else: + if not reload: + sys.exit('reset_section rollback not available outside of a config session') + +del test + + +session_id = int(pid) if pid else os.getpid() + +if in_session: + # check hint left by vyshim when ConfigError is from apply stage + hint_name = f'/tmp/apply_{session_id}' + if not os.path.exists(hint_name): + # no apply error; exit + sys.exit() + else: + # cleanup hint and continue with reset + os.unlink(hint_name) + +cfg_group = grp.getgrnam(CFG_GROUP) +os.setgid(cfg_group.gr_gid) +os.umask(0o002) + +wait_for_commit_lock() + +shared = not bool(reload) + +session = ConfigSession(session_id, shared=shared) + +session_env = session.get_session_env() +config = Config(session_env) + +d = config.get_config_dict(path, effective=True, get_first_key=True) + +if in_session: + session.discard() + +session.delete(path) +session.commit() + +if not d: + # nothing more to do in either case of reload/rollback + sys.exit() + +session.set_section(path, d) +out = session.commit() +print(out) diff --git a/src/helpers/run-config-activation.py b/src/helpers/run-config-activation.py index 58293702a..7e7a6571e 100755 --- a/src/helpers/run-config-activation.py +++ b/src/helpers/run-config-activation.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -14,20 +14,33 @@ # 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 logging + from pathlib import Path from argparse import ArgumentParser from vyos.compose_config import ComposeConfig from vyos.compose_config import ComposeConfigError +from vyos.utils.activate import refresh_activation_list +from vyos.utils.activate import get_activation_scripts +from vyos.utils.activate import get_activation +from vyos.utils.activate import set_activation +from vyos.utils.activate import is_active +from vyos.utils.system import load_as_module +from vyos.utils.func import FalseCallable from vyos.defaults import directories +from vyos.defaults import activation_list + parser = ArgumentParser() -parser.add_argument('config_file', type=str, - help="configuration file to modify with system-specific settings") -parser.add_argument('--test-script', type=str, - help="test effect of named script") +parser.add_argument( + 'config_file', + type=str, + help='configuration file to modify with system-specific settings', +) +parser.add_argument('--test-script', type=str, help='test effect of named script') args = parser.parse_args() @@ -40,6 +53,7 @@ formatter = logging.Formatter('%(message)s') fh.setFormatter(formatter) logger.addHandler(fh) + if 'vyos-activate-debug' in Path('/proc/cmdline').read_text(): print(f'\nactivate-debug enabled: file {checkpoint_file}_* on error') debug = checkpoint_file @@ -48,36 +62,67 @@ else: debug = None logger.setLevel(logging.INFO) -def sort_key(s: Path): - s = s.stem - pre, rem = re.match(r'(\d*)(?:-)?(.+)', s).groups() - return int(pre or 0), rem def file_ext(file_name: str) -> str: - """Return an identifier from file name for checkpoint file extension. - """ + """Return an identifier from file name for checkpoint file extension.""" return Path(file_name).stem + +refresh_activation_list() + +if not Path(activation_list).exists(): + logger.error('Missing config activation list!') + sys.exit(1) + script_dir = Path(directories['activate']) if args.test_script: - script_list = [script_dir.joinpath(args.test_script)] + script_list = [script_dir.joinpath(args.test_script).stem] else: - script_list = sorted(script_dir.glob('*.py'), key=sort_key) + script_list = list(get_activation_scripts()) config_file = args.config_file config_str = Path(config_file).read_text() compose = ComposeConfig(config_str, checkpoint_file=debug) +false_call = FalseCallable() + +update_config = False for file in script_list: - file = file.as_posix() + if not is_active(file): + continue + logger.info(f'calling {file}') - try: - compose.apply_file(file, func_name='activate') - except ComposeConfigError as e: - if debug: - compose.write(f'{compose.checkpoint_file}_{file_ext(file)}') - logger.error(f'config-activation error in {file}: {e}') - -compose.write(config_file, with_version=True) + + mod_name = Path(file).stem.replace('-', '_') + mod = load_as_module(mod_name, script_dir.joinpath(f'{file}.py').as_posix()) + + pre_condition = getattr(mod, 'pre_condition', false_call) + post_condition = getattr(mod, 'post_condition', false_call) + activate = getattr(mod, 'activate', false_call) + + if not activate: + logger.error(f'missing activate function in {file}') + continue + + if not pre_condition or pre_condition(): + try: + compose.apply_func(activate) + except ComposeConfigError as e: + if debug: + compose.write(f'{compose.checkpoint_file}_{file_ext(file)}') + logger.error(f'config-activation error in {file}: {e}') + update_config = False + break + + if post_condition: + post_condition() + + if get_activation(file) == 'once': + set_activation(file, 'off') + + update_config = True + +if update_config: + compose.write(config_file, with_version=True) diff --git a/src/helpers/run-config-migration.py b/src/helpers/run-config-migration.py index e6ce97363..6a3533644 100755 --- a/src/helpers/run-config-migration.py +++ b/src/helpers/run-config-migration.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -19,6 +19,7 @@ import sys import time from argparse import ArgumentParser from shutil import copyfile +from vyos.utils.file import read_file from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError @@ -76,3 +77,9 @@ except ConfigMigrateError as e: if backup is not None and not config_migrate.config_modified: os.unlink(backup) + +# T1771: add knob on Kernel command-line to simulate failed config migrator run +# used to test if the automatic image reboot works. +kernel_cmdline = read_file('/proc/cmdline') +if 'vyos-fail-migration' in kernel_cmdline.split(): + sys.exit(1) diff --git a/src/helpers/set_vyconf_backend.py b/src/helpers/set_vyconf_backend.py new file mode 100755 index 000000000..dddbe12f6 --- /dev/null +++ b/src/helpers/set_vyconf_backend.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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/>. +# +# + +# N.B. only for use within testing framework; explicit invocation will leave +# system in inconsistent state. + +import os +import sys +from argparse import ArgumentParser + +from vyos.utils.backend import set_vyconf_backend + +if os.getuid() != 0: + sys.exit('Requires root privileges') + +parser = ArgumentParser() +parser.add_argument('--disable', action='store_true', + help='enable/disable vyconf backend') +parser.add_argument('--no-prompt', action='store_true', + help='confirm without prompt') + +args = parser.parse_args() + +match args.disable: + case False: + set_vyconf_backend(True, no_prompt=args.no_prompt) + case True: + set_vyconf_backend(False, no_prompt=args.no_prompt) diff --git a/src/helpers/show_commit_data.py b/src/helpers/show_commit_data.py index d507ed9a4..85ee64cb1 100755 --- a/src/helpers/show_commit_data.py +++ b/src/helpers/show_commit_data.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py index cb29069cf..71b7c079a 100755 --- a/src/helpers/strip-private.py +++ b/src/helpers/strip-private.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2021-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public diff --git a/src/helpers/teardown-config-session.py b/src/helpers/teardown-config-session.py new file mode 100755 index 000000000..49a3f5bc5 --- /dev/null +++ b/src/helpers/teardown-config-session.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 + +from vyos.vyconf_session import VyconfSession + +if len(sys.argv) < 2: + sys.exit('session pid is required') + +pid = sys.argv[1] + +try: + vc = VyconfSession(pid=int(pid), extant=True) +except ValueError: + # as this script is called on any config session exit, ignore non-existent + pass +else: + vc.teardown() diff --git a/src/helpers/test_commit.py b/src/helpers/test_commit.py index 00a413687..cfff85b9d 100755 --- a/src/helpers/test_commit.py +++ b/src/helpers/test_commit.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2025 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/validate-config.py b/src/helpers/validate-config.py new file mode 100755 index 000000000..1c77d2240 --- /dev/null +++ b/src/helpers/validate-config.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 argparse + +from vyos.configtree import ConfigTree +from vyos.configtree import validate_tree_filter +from vyos.component_version import add_system_version + + +parser = argparse.ArgumentParser() +parser.add_argument('config_file', help='config file to validate') +parser.add_argument('--filtered-config', help='write valid subset of config file') + +args = parser.parse_args() + +config_file = args.config_file +filtered_config = args.filtered_config + +with open(config_file) as f: + config_str = f.read() + +config_tree = ConfigTree(config_str) + +valid_tree, out = validate_tree_filter(config_tree) + +if filtered_config: + add_system_version(valid_tree.to_string(), filtered_config) + +if out: + print(out) + +sys.exit(int(bool(out))) diff --git a/src/helpers/vyconf_cli.py b/src/helpers/vyconf_cli.py new file mode 100755 index 000000000..9a614675b --- /dev/null +++ b/src/helpers/vyconf_cli.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> +# +# 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 sys + +from vyos.vyconf_session import VyconfSession + + +pid = os.getppid() + +vs = VyconfSession(pid=pid) + +script_path = sys.argv[0] +script_name = os.path.basename(script_path) +# drop prefix 'vy_' if present +if script_name.startswith('vy_'): + func_name = script_name[3:] +else: + func_name = script_name + +if hasattr(vs, func_name): + func = getattr(vs, func_name) +else: + sys.exit(f'Call unimplemented: {func_name}') + +res = func() +if isinstance(res, bool): + # for use in shell scripts + sys.exit(int(not res)) + +if isinstance(res, tuple): + out, err = res + print(out) + sys.exit(err) diff --git a/src/helpers/vyos-boot-config-loader.py b/src/helpers/vyos-boot-config-loader.py index 42de696ce..9826a6d3e 100755 --- a/src/helpers/vyos-boot-config-loader.py +++ b/src/helpers/vyos-boot-config-loader.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -13,8 +13,6 @@ # # 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 sys @@ -23,10 +21,13 @@ import grp import traceback from datetime import datetime -from vyos.defaults import directories, config_status -from vyos.configsession import ConfigSession, ConfigSessionError +from vyos.defaults import directories +from vyos.defaults import config_status +from vyos.configsession import ConfigSession +from vyos.configsession import ConfigSessionError from vyos.configtree import ConfigTree from vyos.utils.process import cmd +from vyos.utils.file import write_file STATUS_FILE = config_status TRACE_FILE = '/tmp/boot-config-trace' @@ -68,16 +69,16 @@ def trace_to_file(trace_file_name): print('{0}'.format(e)) def failsafe(config_file_name): - fail_msg = """ + fail_msg = f""" !!!!! There were errors loading the configuration - Please examine the errors in - {0} - and correct + Please examine the errors in: + {TRACE_FILE} !!!!! - """.format(TRACE_FILE) + """ print(fail_msg, file=sys.stderr) + write_file('/run/motd.d/9999-boot-config-error', fail_msg) users = [x[0] for x in pwd.getpwall()] if 'vyos' in users: diff --git a/src/helpers/vyos-certbot-renew-pki.sh b/src/helpers/vyos-certbot-renew-pki.sh deleted file mode 100755 index 1c273d2fa..000000000 --- a/src/helpers/vyos-certbot-renew-pki.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/vbash -source /opt/vyatta/etc/functions/script-template -/usr/libexec/vyos/conf_mode/pki.py certbot_renew diff --git a/src/helpers/vyos-check-wwan.py b/src/helpers/vyos-check-wwan.py index 334f08dd3..4768ddf3f 100755 --- a/src/helpers/vyos-check-wwan.py +++ b/src/helpers/vyos-check-wwan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/vyos-config-encrypt.py b/src/helpers/vyos-config-encrypt.py index 84860bd6a..e035040e1 100755 --- a/src/helpers/vyos-config-encrypt.py +++ b/src/helpers/vyos-config-encrypt.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -20,43 +20,32 @@ import sys from argparse import ArgumentParser from cryptography.fernet import Fernet -from tempfile import NamedTemporaryFile -from tempfile import TemporaryDirectory +from tempfile import NamedTemporaryFile, TemporaryDirectory -from vyos.tpm import clear_tpm_key -from vyos.tpm import read_tpm_key -from vyos.tpm import write_tpm_key +from vyos.system.image import is_live_boot, get_running_image +from vyos.tpm import clear_tpm_key, read_tpm_key, write_tpm_key from vyos.utils.io import ask_input, ask_yes_no -from vyos.utils.process import cmd +from vyos.utils.process import cmd, run +from vyos.defaults import directories persistpath_cmd = '/opt/vyatta/sbin/vyos-persistpath' -mount_paths = ['/config', '/opt/vyatta/etc/config'] +# mount_path is /opt/vyatta/etc/config as of this writing +mount_path = directories['config'] +mount_path_old = f'{mount_path}.old' dm_device = '/dev/mapper/vyos_config' + def is_opened(): return os.path.exists(dm_device) -def get_current_image(): - with open('/proc/cmdline', 'r') as f: - args = f.read().split(" ") - for arg in args: - if 'vyos-union' in arg: - k, v = arg.split("=") - path_split = v.split("/") - return path_split[-1] - return None - def load_config(key): if not key: return persist_path = cmd(persistpath_cmd).strip() - image_name = get_current_image() + image_name = get_running_image() image_path = os.path.join(persist_path, 'luks', image_name) - if not os.path.exists(image_path): - raise Exception("Encrypted config volume doesn't exist") - if is_opened(): print('Encrypted config volume is already mounted') return @@ -67,24 +56,22 @@ def load_config(key): cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') - for path in mount_paths: - cmd(f'mount /dev/mapper/vyos_config {path}') - cmd(f'chgrp -R vyattacfg {path}') + run(f'umount -l {mount_path}') + cmd(f'mount /dev/mapper/vyos_config {mount_path}') + cmd(f'chgrp -R vyattacfg {mount_path}') os.unlink(key_file) return True -def encrypt_config(key, recovery_key): - if is_opened(): - raise Exception('An encrypted config volume is already mapped') - +def encrypt_config(key, recovery_key=None, is_tpm=True): # Clear and write key to TPM - try: - clear_tpm_key() - except: - pass - write_tpm_key(key) + if is_tpm: + try: + clear_tpm_key() + except: + pass + write_tpm_key(key) persist_path = cmd(persistpath_cmd).strip() size = ask_input('Enter size of encrypted config partition (MB): ', numeric_only=True, default=512) @@ -94,38 +81,49 @@ def encrypt_config(key, recovery_key): if not os.path.isdir(luks_folder): os.mkdir(luks_folder) - image_name = get_current_image() + image_name = get_running_image() image_path = os.path.join(luks_folder, image_name) - # Create file for encrypted config - cmd(f'fallocate -l {size}M {image_path}') + try: + # Create file for encrypted config + cmd(f'fallocate -l {size}M {image_path}') - # Write TPM key for slot #1 - with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: - f.write(key) - key_file = f.name + # Write TPM key for slot #1 + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name - # Format and add main key to volume - cmd(f'cryptsetup -q luksFormat {image_path} {key_file}') + # Format and add main key to volume + cmd(f'cryptsetup -q luksFormat {image_path} {key_file}') - if recovery_key: - # Write recovery key for slot 2 - with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: - f.write(recovery_key) - recovery_key_file = f.name + if recovery_key: + # Write recovery key for slot 2 + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(recovery_key) + recovery_key_file = f.name - cmd(f'cryptsetup -q luksAddKey {image_path} {recovery_key_file} --key-file={key_file}') + cmd(f'cryptsetup -q luksAddKey {image_path} {recovery_key_file} --key-file={key_file}') - # Open encrypted volume and format with ext4 - cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') - cmd('mkfs.ext4 /dev/mapper/vyos_config') + # Open encrypted volume and format with ext4 + cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') + cmd('mkfs.ext4 /dev/mapper/vyos_config') + except Exception as e: + print('An error occurred while creating the encrypted config volume, aborting.') + + if os.path.exists('/dev/mapper/vyos_config'): + run('cryptsetup -q close vyos_config') + + if os.path.exists(image_path): + os.unlink(image_path) + + raise e with TemporaryDirectory() as d: cmd(f'mount /dev/mapper/vyos_config {d}') - # Move /config to encrypted volume - shutil.copytree('/config', d, copy_function=shutil.move, dirs_exist_ok=True) - + # Move mount_path to encrypted volume + shutil.copytree(mount_path, d, copy_function=shutil.move, dirs_exist_ok=True) + cmd(f'chgrp -R vyattacfg {d}') cmd(f'umount {d}') os.unlink(key_file) @@ -133,22 +131,53 @@ def encrypt_config(key, recovery_key): if recovery_key: os.unlink(recovery_key_file) - for path in mount_paths: - cmd(f'mount /dev/mapper/vyos_config {path}') - cmd(f'chgrp vyattacfg {path}') + run(f'umount -l {mount_path}') + cmd(f'mount /dev/mapper/vyos_config {mount_path}') + cmd(f'chgrp vyattacfg {mount_path}') return True -def decrypt_config(key): +def config_backup_folder(base): + # Get next available backup folder + if not os.path.exists(base): + return base + + idx = 1 + while os.path.exists(f'{base}.{idx}'): + idx += 1 + return f'{base}.{idx}' + +def test_decrypt(key): if not key: return persist_path = cmd(persistpath_cmd).strip() - image_name = get_current_image() + image_name = get_running_image() image_path = os.path.join(persist_path, 'luks', image_name) - if not os.path.exists(image_path): - raise Exception("Encrypted config volume doesn't exist") + key_file = None + + if not is_opened(): + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name + + try: + cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') + os.unlink(key_file) + return True + except: + return False + return False + +def decrypt_config(key): + if not key: + return + + persist_path = cmd(persistpath_cmd).strip() + image_name = get_running_image() + image_path = os.path.join(persist_path, 'luks', image_name) + original_config_path = os.path.join(persist_path, 'boot', image_name, 'rw', 'opt', 'vyatta', 'etc', 'config') key_file = None @@ -160,22 +189,26 @@ def decrypt_config(key): cmd(f'cryptsetup -q open {image_path} vyos_config --key-file={key_file}') # unmount encrypted volume mount points - for path in mount_paths: - if os.path.ismount(path): - cmd(f'umount {path}') + run(f'umount -Alq /dev/mapper/vyos_config') + + # If /opt/vyatta/etc/config is populated, move to /opt/vyatta/etc/config.old + if len(os.listdir(mount_path)) > 0: + backup_path = config_backup_folder(mount_path_old) + print(f'Moving existing {mount_path} folder to {backup_path}') + shutil.move(mount_path, backup_path) - # If /config is populated, move to /config.old - if len(os.listdir('/config')) > 0: - print('Moving existing /config folder to /config.old') - shutil.move('/config', '/config.old') + # Mount original persistence config path + if not os.path.exists(mount_path): + os.mkdir(mount_path) + cmd(f'mount --bind {original_config_path} {mount_path}') # Temporarily mount encrypted volume and migrate files to /config on rootfs with TemporaryDirectory() as d: cmd(f'mount /dev/mapper/vyos_config {d}') - # Move encrypted volume to /config - shutil.copytree(d, '/config', copy_function=shutil.move, dirs_exist_ok=True) - cmd(f'chgrp -R vyattacfg /config') + # Move encrypted volume to /opt/vyatta/etc/config + shutil.copytree(d, mount_path, copy_function=shutil.move, dirs_exist_ok=True) + cmd(f'chgrp -R vyattacfg {mount_path}') cmd(f'umount {d}') @@ -188,7 +221,8 @@ def decrypt_config(key): os.unlink(image_path) try: - clear_tpm_key() + if ask_yes_no('Do you want to clear the TPM? This will cause issues if other system images use the key'): + clear_tpm_key() except: pass @@ -199,12 +233,28 @@ if __name__ == '__main__': print("Must specify action.") sys.exit(1) + if is_live_boot(): + print("Config encryption not available on live-ISO environment") + sys.exit(1) + parser = ArgumentParser(description='Config encryption') parser.add_argument('--disable', help='Disable encryption', action="store_true") parser.add_argument('--enable', help='Enable encryption', action="store_true") parser.add_argument('--load', help='Load encrypted config volume', action="store_true") args = parser.parse_args() + if args.disable or args.load: + persist_path = cmd(persistpath_cmd).strip() + image_name = get_running_image() + image_path = os.path.join(persist_path, 'luks', image_name) + + if not os.path.exists(image_path): + print('Encrypted config volume does not exist, aborting.') + sys.exit(0) + elif args.enable and is_opened(): + print('An encrypted config volume is already mapped, aborting.') + sys.exit(0) + tpm_exists = os.path.exists('/sys/class/tpm/tpm0') key = None @@ -213,30 +263,43 @@ if __name__ == '__main__': question_key_str = 'recovery key' if tpm_exists else 'key' - if tpm_exists: - if args.enable: - key = Fernet.generate_key() - elif args.disable or args.load: + if not is_opened(): + if tpm_exists: + existing_key = None + try: - key = read_tpm_key() - need_recovery = False - except: - print('Failed to read key from TPM, recovery key required') - need_recovery = True - else: - need_recovery = True + existing_key = read_tpm_key() + except: pass + + if args.enable: + if existing_key: + print('WARNING: An encryption key already exists in the TPM.') + print('If you choose not to use the existing key, any system image') + print('using the old key will need the recovery key.') + if existing_key and ask_yes_no('Do you want to use the existing TPM key?'): + key = existing_key + else: + key = Fernet.generate_key() + elif args.disable or args.load: + if existing_key and test_decrypt(existing_key): + need_recovery = False + else: + print('TPM key invalid or not found, recovery key required') + need_recovery = True + else: + need_recovery = True if args.enable and not tpm_exists: print('WARNING: VyOS will boot into a default config when encrypted without a TPM') print('You will need to manually login with default credentials and use "encryption load"') - print('to mount the encrypted volume and use "load /config/config.boot"') + print(f'to mount the encrypted volume and use "load {mount_path}/config.boot"') if not ask_yes_no('Are you sure you want to proceed?'): sys.exit(0) if need_recovery or (args.enable and not ask_yes_no(f'Automatically generate a {question_key_str}?', default=True)): while True: - recovery_key = ask_input(f'Enter {question_key_str}:', default=None).encode() + recovery_key = ask_input(f'Enter {question_key_str}:', default=None, no_echo=True).encode() if len(recovery_key) >= 32: break @@ -250,12 +313,12 @@ if __name__ == '__main__': decrypt_config(key or recovery_key) print('Encrypted config volume has been disabled') - print('Contents have been migrated to /config on rootfs') + print(f'Contents have been migrated to {mount_path} on rootfs') elif args.load: load_config(key or recovery_key) print('Encrypted config volume has been mounted') - print('Use "load /config/config.boot" to load configuration') + print(f'Use "load {mount_path}/config.boot" to load configuration') elif args.enable and tpm_exists: encrypt_config(key, recovery_key) @@ -263,7 +326,10 @@ if __name__ == '__main__': print('Backup the recovery key in a safe place!') print('Recovery key: ' + recovery_key.decode()) elif args.enable: - encrypt_config(recovery_key) + if recovery_key != ask_input('Confirm key:', default=None, no_echo=True).encode(): + raise ValueError("Keys did not match!") + + encrypt_config(recovery_key, is_tpm=False) print('Encrypted config volume has been enabled without TPM') print('Backup the key in a safe place!') diff --git a/src/helpers/vyos-failover.py b/src/helpers/vyos-failover.py index 348974364..22f8f1d35 100755 --- a/src/helpers/vyos-failover.py +++ b/src/helpers/vyos-failover.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2022-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -15,22 +15,50 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import argparse +import atexit import json -import socket +import signal import time +from collections import namedtuple +from vyos.template import get_dhcp_router from vyos.utils.process import rc_cmd +from vyos.utils.process import run from pathlib import Path from systemd import journal my_name = Path(__file__).stem +# Timeout between configuration reading +# When no checks timeouts worked (e.g. no config files) +config_timeout = 1 -def is_route_exists(route, gateway, interface, metric): +# Useful debug info to console, use debug = True +# sudo systemctl stop vyos-failover.service +# sudo /usr/libexec/vyos/vyos-failover.py --config /run/vyos-failover.conf +debug = False +debug_output_journal = False +debug_output_print = True + + +def print_debug(*args, **kwargs): + if debug: + if debug_output_print: + print(*args, **kwargs) + if debug_output_journal: + journal.send(*args, **kwargs, SYSLOG_IDENTIFIER=my_name) + + +def wrap_vrf(command, vrf): + if not vrf: + return command + return f"sudo ip vrf exec {vrf} {command}" + + +def is_route_exists(ip_args): """Check if route with expected gateway, dev and metric exists""" - rc, data = rc_cmd(f'ip --json route show protocol failover {route} ' - f'via {gateway} dev {interface} metric {metric}') + rc, data = rc_cmd(f'ip --json route show {ip_args}') if rc == 0: data = json.loads(data) if len(data) > 0: @@ -38,41 +66,7 @@ def is_route_exists(route, gateway, interface, metric): return False -def get_best_route_options(route, debug=False): - """ - Return current best route ('gateway, interface, metric) - - % get_best_route_options('203.0.113.1') - ('192.168.0.1', 'eth1', 1) - - % get_best_route_options('203.0.113.254') - (None, None, None) - """ - rc, data = rc_cmd(f'ip --detail --json route show protocol failover {route}') - if rc == 0: - data = json.loads(data) - if len(data) == 0: - print(f'\nRoute {route} for protocol failover was not found') - return None, None, None - # Fake metric 999 by default - # Search route with the lowest metric - best_metric = 999 - for entry in data: - if debug: print('\n', entry) - metric = entry.get('metric') - gateway = entry.get('gateway') - iface = entry.get('dev') - if metric < best_metric: - best_metric = metric - best_gateway = gateway - best_interface = iface - if debug: - print(f'### Best_route exists: {route}, best_gateway: {best_gateway}, ' - f'best_metric: {best_metric}, best_iface: {best_interface}') - return best_gateway, best_interface, best_metric - - -def is_port_open(ip, port): +def is_port_open(ip, port, vrf=''): """ Check connection to remote host and port Return True if host alive @@ -80,33 +74,29 @@ def is_port_open(ip, port): % is_port_open('example.com', 8080) True """ - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP) - s.settimeout(2) - try: - s.connect((ip, int(port))) - s.shutdown(socket.SHUT_RDWR) - return True - except: - return False - finally: - s.close() - - -def is_target_alive(target_list=None, - iface='', - proto='icmp', - port=None, - debug=False, - policy='any-available') -> bool: - """Check the availability of each target in the target_list using + cmd = wrap_vrf(f"nc -w2 -z {ip} {port}", vrf) + rc, data = rc_cmd(cmd) + return rc == 0 + + +def is_target_alive( + targets=None, + iface='', + proto='icmp', + port=None, + nexthop_vrf='', + policy='any-available', +) -> bool: + """Check the availability of each target in the target_dict using the specified protocol ICMP, ARP, TCP Args: - target_list (list): A list of IP addresses or hostnames to check. + targets (tuple of TargetNamedTuple): A dict: keys are IP addresses to check, values - dicts with options. + Possible keys (all optional): 'vrf' and 'interface'. iface (str): The name of the network interface to use for the check. proto (str): The protocol to use for the check. Options are 'icmp', 'arp', or 'tcp'. port (int): The port number to use for the TCP check. Only applicable if proto is 'tcp'. - debug (bool): If True, print debug information during the check. + nexthop_vrf (str): Nexthop VRF name - if specific vrf is not given for target, use this one policy (str): The policy to use for the check. Options are 'any-available' or 'all-available'. Returns: @@ -120,30 +110,41 @@ def is_target_alive(target_list=None, iface = f'-I {iface}' num_reachable_targets = 0 - for target in target_list: + for options in targets: + target = options.target + vrf = options.vrf if options.vrf else nexthop_vrf + # don't use nexthop interface if 'vrf' is given + iface_opt = iface if vrf == nexthop_vrf else '' + # in any case if 'interface' is given, use it + if options.interface: + iface_opt = f'-I {options.interface}' match proto: case 'icmp': - command = f'/usr/bin/ping -q {target} {iface} -n -c 2 -W 1' + command = f'/usr/bin/ping -q {target} {iface_opt} -n -c 2 -W 1' + command = wrap_vrf(command, vrf) rc, response = rc_cmd(command) - if debug: - print(f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]') + print_debug( + f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]' + ) if rc == 0: num_reachable_targets += 1 if policy == 'any-available': return True case 'arp': - command = f'/usr/bin/arping -b -c 2 -f -w 1 -i 1 {iface} {target}' + command = f'/usr/bin/arping -b -c 2 -f -w 1 -i 1 {iface_opt} {target}' + command = wrap_vrf(command, vrf) rc, response = rc_cmd(command) - if debug: - print(f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]') + print_debug( + f' [ CHECK-TARGET ]: [{command}] -- return-code [RC: {rc}]' + ) if rc == 0: num_reachable_targets += 1 if policy == 'any-available': return True case _ if proto == 'tcp' and port is not None: - if is_port_open(target, port): + if is_port_open(target, port, vrf=vrf): num_reachable_targets += 1 if policy == 'any-available': return True @@ -151,85 +152,426 @@ def is_target_alive(target_list=None, case _: return False - if policy == 'all-available' and num_reachable_targets == len(target_list): + if policy == 'all-available' and num_reachable_targets == len(targets): return True return False -if __name__ == '__main__': - # Parse command arguments and get config - parser = argparse.ArgumentParser() - parser.add_argument('-c', - '--config', - action='store', - help='Path to protocols failover configuration', - required=True, - type=Path) +TargetNamedTuple = namedtuple( + 'TargetConfig', + [ + 'target', + 'vrf', + 'interface', + ], +) + +NextHopNamedTuple = namedtuple( + 'NextHopConfig', + [ + 'route', + 'dhcp_interface', + 'next_hop', + 'vrf', + 'vrf_opt', + 'conf_iface', + 'conf_metric', + 'port', + 'port_opt', + 'policy', + 'proto', + 'targets', + 'pretty_targets', + 'timeout', + 'onlink', + ], +) + + +def get_nexthop_config_vars( + destination, vrf, vrf_opt, nexthop_config, next_hop, dhcp_interface +): + port = nexthop_config.get('check').get('port') + + targets = tuple( + TargetNamedTuple( + target=target, + vrf=target_config.get('vrf', None), + interface=target_config.get('interface', None), + ) + for target, target_config in nexthop_config.get('check').get('target').items() + ) + + # for print to journal and debug + pretty_targets = [] + for target in targets: + p = target.target + options = [] + if target.vrf: + options.append(f"vrf: {target.vrf}") + if target.interface: + options.append(f"interface: {target.interface}") + if options: + p += ' (' + ', '.join(options) + ')' + pretty_targets.append(p) + pretty_targets = ', '.join(pretty_targets) + + return NextHopNamedTuple( + route=destination, + dhcp_interface=dhcp_interface, + next_hop=next_hop, + vrf=vrf, + vrf_opt=vrf_opt, + # For next-hop interface is mandatory + # For dhcp-interface it may be not given, then dhcp-interface is used + conf_iface=nexthop_config.get('interface', dhcp_interface), + conf_metric=int(nexthop_config.get('metric')), + port=port, + port_opt=f'port {port}' if port else '', + policy=nexthop_config.get('check').get('policy'), + proto=nexthop_config.get('check').get('type'), + targets=targets, + pretty_targets=pretty_targets, + timeout=nexthop_config.get('check').get('timeout'), + onlink='onlink' if 'onlink' in nexthop_config else '', + ) + + +RouteNamedTuple = namedtuple( + 'RouteConfig', + [ + 'destination', + 'vrf', + 'vrf_opt', + 'config_path', + 'nexthops', + ], +) + + +def get_route_config(route, route_config, config_path, vrf): + vrf_opt = f'vrf {vrf}' if vrf else '' + nexthops = [] + if route_config.get('next_hop'): + nexthops.extend( + get_nexthop_config_vars(route, vrf, vrf_opt, nexthop_config, next_hop, None) + for next_hop, nexthop_config in route_config.get('next_hop').items() + ) + if route_config.get('dhcp_interface'): + nexthops.extend( + get_nexthop_config_vars( + route, vrf, vrf_opt, dhcp_nexthop_config, None, interface + ) + for interface, dhcp_nexthop_config in route_config.get( + 'dhcp_interface' + ).items() + ) + nexthops = tuple(nexthops) + return RouteNamedTuple( + destination=route, + vrf=vrf, + vrf_opt=vrf_opt, + config_path=config_path, + nexthops=nexthops, + ) + + +def parse_config(config, path): + parsed = [] + vrf = config.get('vrf_context', '') + for route, route_config in config.get('route').items(): + parsed.append(get_route_config(route, route_config, path, vrf)) + return parsed + + +def flush_all_routes(): + print_debug("flush_all_routes called") + flush_cmd = 'ip route flush protocol failover table all' + run(flush_cmd) + journal.send( + flush_cmd, + SYSLOG_IDENTIFIER=my_name, + ) + + +kill_called = False + + +def kill_handler(*args): + global kill_called + if kill_called: + return + kill_called = True + print_debug(f"kill_handler called for signal {args[0]}") + + +def get_ip_command_args(nhc): + return ( + f'{nhc.route} via {nhc.next_hop} dev {nhc.conf_iface} ' + f'{nhc.onlink} metric {nhc.conf_metric} {nhc.vrf_opt} proto failover' + ) + + +def delete_route(ip_args): + print_debug(f' [ DEL ] -- ip route del {ip_args} [DELETE]') + rc_cmd(f'ip route del {ip_args}') + journal.send( + f'ip route del {ip_args}', + SYSLOG_IDENTIFIER=my_name, + ) + + +def update_configuration(last_modification_times, all_routes, config_dir): + """ + Updates configuration: + rechecks config_dir for new/updated files, + deletes routes that were deleted from configuration + + Args: + last_modification_times(dict): keys: relative path to file, value: last modification time. + Is updated. + all_routes(list): list of routes that were configured in previous call. + Is updated. + config_dir(Path): path to configuration directory + """ - args = parser.parse_args() try: - config_path = Path(args.config) - config = json.loads(config_path.read_text()) - except Exception as err: - print( - f'Configuration file "{config_path}" does not exist or malformed: {err}' - ) + # First check if there are any changes at all + have_changes = False + for child in config_dir.iterdir(): + file_key = str(child) + if file_key not in last_modification_times: + have_changes = True + print_debug(f"New file '{child}', have changes, rereading all") + break + modtime = child.stat().st_mtime_ns + if modtime != last_modification_times[file_key]: + have_changes = True + print_debug(f"File '{child} modified, have changes, rereading all...") + break + + if not have_changes: + print_debug("No changes in configuration detected.") + return + + last_modification_times.clear() + new_routes = [] + + # It is important that in configuration directory there MUST be + # only files generated by conf_mode/protocols_failover.py - otherwise + # the script won't be able to detect when all VRFs are disabled and + # won't be able to stop the service gracefully + for child in config_dir.iterdir(): + if not child.is_file(): + print( + f"Path {child} under configuration dir is not a file! Please clean configuration directory {config_dir}." + ) + exit(1) + + modtime = child.stat().st_mtime_ns + file_key = str(child) + last_modification_times[file_key] = modtime + + try: + config = json.loads(child.read_text()) + print_debug(f"Config from '{child}': {config}") + except OSError as err: + print(f'Configuration file "{child}" could not be read: {err}') + exit(1) + except json.JSONDecodeError as err: + print( + f'Configuration file "{child}" could not be parsed as JSON: {err}' + ) + exit(1) + except UnicodeDecodeError as err: + print(f'Configuration file "{child}" has Unicode errors: {err}') + exit(1) + + parsed_config = parse_config(config, file_key) + new_routes.extend(parsed_config) + except OSError as err: + print(f'Configuration dir "{config_dir}" does not exist or not readable: {err}') exit(1) - # Useful debug info to console, use debug = True - # sudo systemctl stop vyos-failover.service - # sudo /usr/libexec/vyos/vyos-failover.py --config /run/vyos-failover.conf - debug = False + old_routes_set = set(all_routes) + new_routes_set = set(new_routes) + + delete_routes = old_routes_set - new_routes_set + add_routes = new_routes_set - old_routes_set + + # Delete not needed routes + for route_config in delete_routes: + print_debug( + f"Deleting route {route_config}, not present in updated configuration" + ) + for nhc in route_config.nexthops: + ip_args = get_ip_command_args(nhc) + if is_route_exists(ip_args): + delete_route(ip_args) + all_routes.remove(route_config) + + # Add new routes + print_debug(f"Adding routes {add_routes}, new in updated configuration") + all_routes.extend(add_routes) - while(True): + print_debug(f"All routes: {all_routes}") - for route, route_config in config.get('route').items(): - exists_gateway, exists_iface, exists_metric = get_best_route_options(route, debug=debug) +def process_dhcp_interface(nhc, nexthop_by_dhcp_nexthop): + """ + Processes NextHopNamedTuple with dhcp_interface != None + Return NextHopNamedTuple with next_hop equal to DHCP gateway of nhc. + If there is no gateway for nhc, return None + + Args: + nhc(NextHopNamedTuple): configuration with dhcp_interface + nexthop_by_dhcp_nexthop(dict): dict with previous returned values + """ + cur_dhcpgw = get_dhcp_router(nhc.dhcp_interface) + if not cur_dhcpgw: + cur_dhcpgw = False + + if nhc in nexthop_by_dhcp_nexthop: + prev_dhcpgw = nexthop_by_dhcp_nexthop[nhc].next_hop + else: + prev_dhcpgw = False + + # Equal - do nothing, just return previous value + if prev_dhcpgw == cur_dhcpgw: + if not cur_dhcpgw: + return None + return nexthop_by_dhcp_nexthop[nhc] + + print_debug( + f"DHCP Gateway changed for interface {nhc.dhcp_interface} from '{prev_dhcpgw}' to '{cur_dhcpgw}'" + ) + + # dhcpgw differ and there was previous dhcpgw + if prev_dhcpgw: + prevnhc = nexthop_by_dhcp_nexthop.pop(nhc) + print_debug( + f"Deleting previous nexthop {prevnhc} because of DHCP interface change" + ) + ip_args = get_ip_command_args(prevnhc) + if is_route_exists(ip_args): + delete_route(ip_args) + + newnhc = None + # dhcpgw differ and there is new dhcpgw + if cur_dhcpgw: + newnhc = nhc._replace(next_hop=cur_dhcpgw, dhcp_interface=None) + print_debug(f"Saving new nexthop {newnhc} because of DHCP interface change") + nexthop_by_dhcp_nexthop[nhc] = newnhc + + return newnhc - for next_hop, nexthop_config in route_config.get('next_hop').items(): - conf_iface = nexthop_config.get('interface') - conf_metric = int(nexthop_config.get('metric')) - port = nexthop_config.get('check').get('port') - port_opt = f'port {port}' if port else '' - policy = nexthop_config.get('check').get('policy') - proto = nexthop_config.get('check').get('type') - target = nexthop_config.get('check').get('target') - timeout = nexthop_config.get('check').get('timeout') - onlink = 'onlink' if 'onlink' in nexthop_config else '' + +if __name__ == '__main__': + print_debug(f"{my_name} started") + + # Parse command arguments and get config + parser = argparse.ArgumentParser() + parser.add_argument( + '-c', + '--config-dir', + action='store', + help='Path to protocols failover configuration dir', + required=True, + type=Path, + ) + + args = parser.parse_args() + config_dir = Path(args.config_dir) + + last_modification_times = {} + all_routes = [] + + + # Clean all `failover` routes now and at exit + flush_all_routes() + atexit.register(flush_all_routes) + signal.signal(signal.SIGINT, kill_handler) + signal.signal(signal.SIGTERM, kill_handler) + + # keys: NextHopNamedTuple with dhcp_interface != None + # values: NextHopNamedTuple with next_hop != None + # Translates nexthop with dhcp_interface to usual nexthop + nexthop_by_dhcp_nexthop = {} + + had_sleeps = True + while not kill_called: + # Check in case daemon was launched without routes + if not had_sleeps: + time.sleep(int(config_timeout)) + + update_configuration(last_modification_times, all_routes, config_dir) + had_sleeps = False + + for route_config in all_routes: + if kill_called: + break + route = route_config.destination + vrf = route_config.vrf + vrf_opt = route_config.vrf_opt + + for nhc in route_config.nexthops: + if nhc.dhcp_interface: + nhc = process_dhcp_interface(nhc, nexthop_by_dhcp_nexthop) + if not nhc: + continue + + next_hop = nhc.next_hop + ip_args = get_ip_command_args(nhc) + + is_alive = is_target_alive( + nhc.targets, + nhc.conf_iface, + nhc.proto, + nhc.port, + nexthop_vrf=vrf, + policy=nhc.policy, + ) # Route not found in the current routing table - if not is_route_exists(route, next_hop, conf_iface, conf_metric): - if debug: print(f" [NEW_ROUTE_DETECTED] route: [{route}]") + if not is_route_exists(ip_args): + print_debug(f" [NEW_ROUTE_DETECTED] route: [{route} {vrf_opt}]") # Add route if check-target alive - if is_target_alive(target, conf_iface, proto, port, debug=debug, policy=policy): - if debug: print(f' [ ADD ] -- ip route add {route} via {next_hop} dev {conf_iface} ' - f'metric {conf_metric} proto failover\n###') - rc, command = rc_cmd(f'ip route add {route} via {next_hop} dev {conf_iface} ' - f'{onlink} metric {conf_metric} proto failover') + if is_alive: + print_debug(f' [ ADD ] -- ip route add {ip_args}\n###') + rc, command = rc_cmd(f'ip route add {ip_args}') # If something is wrong and gateway not added # Example: Error: Next-hop has invalid gateway. - if rc !=0: - if debug: print(f'{command} -- return-code [RC: {rc}] {next_hop} dev {conf_iface}') + if rc != 0: + print_debug( + f'{command} -- return-code [RC: {rc}] {next_hop} dev {nhc.conf_iface}' + ) else: - journal.send(f'ip route add {route} via {next_hop} dev {conf_iface} ' - f'{onlink} metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name) + journal.send( + f'ip route add {ip_args}', + SYSLOG_IDENTIFIER=my_name, + ) else: - if debug: print(f' [ TARGET_FAIL ] target checks fails for [{target}], do nothing') - journal.send(f'Check fail for route {route} target {target} proto {proto} ' - f'{port_opt}', SYSLOG_IDENTIFIER=my_name) - - # Route was added, check if the target is alive - # We should delete route if check fails only if route exists in the routing table - if not is_target_alive(target, conf_iface, proto, port, debug=debug, policy=policy) and \ - is_route_exists(route, next_hop, conf_iface, conf_metric): - if debug: - print(f'Nexh_hop {next_hop} fail, target not response') - print(f' [ DEL ] -- ip route del {route} via {next_hop} dev {conf_iface} ' - f'metric {conf_metric} proto failover [DELETE]') - rc_cmd(f'ip route del {route} via {next_hop} dev {conf_iface} metric {conf_metric} proto failover') - journal.send(f'ip route del {route} via {next_hop} dev {conf_iface} ' - f'metric {conf_metric} proto failover', SYSLOG_IDENTIFIER=my_name) - - time.sleep(int(timeout)) + print_debug( + f' [ TARGET_FAIL ] target checks fails for [{nhc.pretty_targets}], do nothing' + ) + journal.send( + f'Check fail for route {route} interface "{nhc.conf_iface}" target {nhc.pretty_targets} proto {nhc.proto} ' + f'{nhc.port_opt}', + SYSLOG_IDENTIFIER=my_name, + ) + else: + # Route was added, check if the target is alive + # We should delete route if check fails only if route exists in the routing table + if not is_alive: + print_debug( + f"Next_hop {next_hop} fail, target check didn't pass" + ) + delete_route(ip_args) + + had_sleeps = True + time.sleep(int(nhc.timeout)) + if kill_called: + break + + print_debug(f"Out of main loop, {kill_called=}") diff --git a/src/helpers/vyos-interface-rescan.py b/src/helpers/vyos-interface-rescan.py index 012357259..fea9bca1c 100755 --- a/src/helpers/vyos-interface-rescan.py +++ b/src/helpers/vyos-interface-rescan.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2021 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 diff --git a/src/helpers/vyos-load-balancer.py b/src/helpers/vyos-load-balancer.py index 30329fd5c..127ce05e5 100755 --- a/src/helpers/vyos-load-balancer.py +++ b/src/helpers/vyos-load-balancer.py @@ -1,6 +1,6 @@ #!/usr/bin/python3 -# Copyright 2024-2025 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public @@ -24,6 +24,7 @@ import time from vyos.config import Config from vyos.template import render from vyos.utils.commit import commit_in_progress +from vyos.utils.dict import dict_search_args from vyos.utils.network import get_interface_address from vyos.utils.process import rc_cmd from vyos.utils.process import run @@ -102,15 +103,36 @@ def get_ipv4_address(ifname): return addr_json['addr_info'][0]['local'] return None +def get_dynamic_nexthop(ifname: str) -> str | None | bool: + ''' + Resolve the dynamic next-hop for a WAN interface. + + Determines the current default gateway learned dynamically on the interface: + - PPPoE interfaces (`pppoe*`): uses `parse_ppp_nexthop`. + - Other interfaces (e.g. DHCP): uses `parse_dhcp_nexthop`. + + Return values: + - str: IPv4 next-hop address + - None: when DHCP lease has no router value + - False: when PPPoE nexthop state file is missing + + Args: + ifname: Interface name (e.g. 'pppoe0', 'eth0'). + + Returns: + See above for possible values. + ''' + if ifname.startswith('pppoe'): + return parse_ppp_nexthop(ifname) + else: + return parse_dhcp_nexthop(ifname) + def dynamic_nexthop_update(lb, ifname): # Update on DHCP/PPP address/nexthop changes # Return True if nftables needs to be updated - IP change if 'dhcp_nexthop' in lb['health_state'][ifname]: - if ifname[:5] == 'pppoe': - dhcp_nexthop_addr = parse_ppp_nexthop(ifname) - else: - dhcp_nexthop_addr = parse_dhcp_nexthop(ifname) + dhcp_nexthop_addr = get_dynamic_nexthop(ifname) table_num = lb['health_state'][ifname]['table_number'] @@ -125,6 +147,40 @@ def dynamic_nexthop_update(lb, ifname): return False +def restore_default_route(lb: dict, ifname: str) -> None: + """ + Restores a missing default route for a WAN interface in its policy routing table. + + When a link flap or DHCP/PPP renegotiation removes the per-interface default route, + this function checks the interface’s assigned table for an existing default entry. + If none is found, it determines the proper next-hop (from DHCP, PPP, or static config) + and reinstalls the route using: + ip route replace table <table_num> default dev <ifname> via <nexthop> + + @param lb Load-balancer state/config dictionary. + @param ifname Interface name whose default route should be verified and restored. + @returns None — exits quietly if the table number or next-hop cannot be found. + """ + table_num = dict_search_args(lb, 'health_state', ifname, 'table_number') + if not table_num: + return + + rc, out = rc_cmd(f'ip -j route show default table {table_num}') + if rc == 0: + rt_table = json.loads(out) + if len(rt_table) > 0: + return + else: + if 'dhcp_nexthop' in lb['health_state'][ifname]: + nexthop_addr = get_dynamic_nexthop(ifname) + else: + nexthop_addr = dict_search_args(lb, 'interface_health', ifname, 'nexthop') + + if nexthop_addr: + run(f'ip route replace table {table_num} default dev {ifname} via {nexthop_addr}') + else: + return + def nftables_update(lb): # Atomically reload nftables table from template if not os.path.exists(nftables_wlb_conf): @@ -148,7 +204,17 @@ def cleanup(lb): index = 1 for ifname, health_conf in lb['interface_health'].items(): table_num = lb['mark_offset'] + index + suppress_prio = lb['mark_offset'] + index + table_prio = suppress_prio + 100 run(f'ip route del table {table_num} default') + run( + f'ip rule del fwmark {hex(table_num)} table main ' + f'suppress_prefixlength 0 priority {suppress_prio}' + ) + run( + f'ip rule del fwmark {hex(table_num)} table {table_num} ' + f'priority {table_prio}' + ) run(f'ip rule del fwmark {hex(table_num)} table {table_num}') index += 1 @@ -160,6 +226,14 @@ def get_config(): lb = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, with_recursive_defaults=True) + lb['firewall_group'] = conf.get_config_dict(['firewall', 'group'], key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + # prune limit key if not set by user + for rule in lb.get('rule', []): + if lb.from_defaults(['rule', rule, 'limit']): + del lb['rule'][rule]['limit'] + lb['test_defaults'] = get_defaults(base + ['interface-health', 'A', 'test', 'B'], get_first_key=True) return lb @@ -199,7 +273,19 @@ if __name__ == '__main__': else: run(f'ip route replace table {table_num} default dev {ifname} via {health_conf["nexthop"]}') - run(f'ip rule add fwmark {hex(table_num)} table {table_num}') + suppress_prio = lb['mark_offset'] + index + table_prio = suppress_prio + 100 + if 'only_default_route' in lb: + run( + f'ip rule add fwmark {hex(table_num)} table main ' + f'suppress_prefixlength 0 priority {suppress_prio}' + ) + run( + f'ip rule add fwmark {hex(table_num)} table {table_num} ' + f'priority {table_prio}' + ) + else: + run(f'ip rule add fwmark {hex(table_num)} table {table_num}') index += 1 @@ -208,8 +294,8 @@ if __name__ == '__main__': run('ip route flush cache') if 'flush_connections' in lb: - run('conntrack --delete') - run('conntrack -F expect') + for _state in lb['health_state'].values(): + run(f'conntrack --delete --mark {_state["table_number"]}') with open(wlb_status_file, 'w') as f: f.write(json.dumps(lb['health_state'])) @@ -246,6 +332,7 @@ if __name__ == '__main__': # Main loop + init = True; try: while True: ip_change = False @@ -274,6 +361,11 @@ if __name__ == '__main__': state['state'] = False state['state_changed'] = True + #Force state changed to trigger the first write + if init == True: + state['state_changed'] = True + init = False + if state['state_changed']: state['if_addr'] = get_ipv4_address(ifname) on_state_change(lb, ifname, state['state']) @@ -281,6 +373,8 @@ if __name__ == '__main__': if dynamic_nexthop_update(lb, ifname): ip_change = True + restore_default_route(lb, ifname) + if any(state['state_changed'] for ifname, state in lb['health_state'].items()): if not nftables_update(lb): break @@ -288,8 +382,8 @@ if __name__ == '__main__': run('ip route flush cache') if 'flush_connections' in lb: - run('conntrack --delete') - run('conntrack -F expect') + for _state in lb['health_state'].values(): + run(f'conntrack --delete --mark {_state["table_number"]}') with open(wlb_status_file, 'w') as f: f.write(json.dumps(lb['health_state'])) diff --git a/src/helpers/vyos-load-config.py b/src/helpers/vyos-load-config.py index 16083fd41..7c49e9c3a 100755 --- a/src/helpers/vyos-load-config.py +++ b/src/helpers/vyos-load-config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2019-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -16,84 +16,60 @@ # # -"""Load config file from within config session. -Config file specified by URI or path (without scheme prefix). -Example: load https://somewhere.net/some.config - or - load /tmp/some.config -""" - import os import sys -import gzip +import argparse import tempfile -import vyos.defaults -import vyos.remote -from vyos.configsource import ConfigSourceSession, VyOSError + +from vyos.remote import get_config_file +from vyos.config import Config from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError +from vyos.load_config import load as load_config +from vyos.defaults import directories -class LoadConfig(ConfigSourceSession): - """A subclass for calling 'loadFile'. - This does not belong in configsource.py, and only has a single caller. - """ - def load_config(self, path): - return self._run(['/bin/cli-shell-api','loadFile',path]) -file_name = sys.argv[1] if len(sys.argv) > 1 else 'config.boot' -configdir = vyos.defaults.directories['config'] -protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp'] +default_config_file = os.path.join(directories['config'], 'config.boot') -def get_local_config(filename): - if os.path.isfile(filename): - fname = filename - elif os.path.isfile(os.path.join(configdir, filename)): - fname = os.path.join(configdir, filename) - else: - sys.exit(f"No such file '{filename}'") +parser = argparse.ArgumentParser() +parser.add_argument('config_file', nargs='?', help='config file to load') +parser.add_argument( + '--migrate', action='store_true', help='migrate config file before merge' +) - if fname.endswith('.gz'): - with gzip.open(fname, 'rb') as f: - try: - config_str = f.read().decode() - except OSError as e: - sys.exit(e) - else: - with open(fname, 'r') as f: - try: - config_str = f.read() - except OSError as e: - sys.exit(e) +args = parser.parse_args() - return config_str - -if any(file_name.startswith(f'{x}://') for x in protocols): - config_string = vyos.remote.get_remote_config(file_name) - if not config_string: - sys.exit(f"No such config file at '{file_name}'") -else: - config_string = get_local_config(file_name) +file_name = args.config_file if args.config_file else default_config_file -config = LoadConfig() +# pylint: disable=consider-using-with +file_path = tempfile.NamedTemporaryFile(delete=False).name +err = get_config_file(file_name, file_path) +if err: + os.remove(file_path) + sys.exit(err) -print(f"Loading configuration from '{file_name}'") +if args.migrate: + migrate = ConfigMigrate(file_path) + try: + migrate.run() + except ConfigMigrateError as e: + os.remove(file_path) + sys.exit(e) -with tempfile.NamedTemporaryFile() as fp: - with open(fp.name, 'w') as fd: - fd.write(config_string) +config = Config() - config_migrate = ConfigMigrate(fp.name) - try: - config_migrate.run() - except ConfigMigrateError as err: - sys.exit(err) +if config.vyconf_session is not None: + out, err = config.vyconf_session.load_config(file_path) + if err: + os.remove(file_path) + sys.exit(out) + print(out) +else: + load_config(file_path) - try: - config.load_config(fp.name) - except VyOSError as err: - sys.exit(err) +os.remove(file_path) if config.session_changed(): print("Load complete. Use 'commit' to make changes effective.") else: - print("No configuration changes to commit.") + print('No configuration changes to commit.') diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py index 5ef845ac2..e8a696eb5 100755 --- a/src/helpers/vyos-merge-config.py +++ b/src/helpers/vyos-merge-config.py @@ -1,108 +1,101 @@ #!/usr/bin/python3 -# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. +# This 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 library is distributed in the hope that it will be useful, +# 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 -# Lesser General Public License for more details. +# 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/>. +# # -# 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 +import shlex +import argparse import tempfile -import vyos.defaults -import vyos.remote +from vyos.remote import get_config_file from vyos.config import Config from vyos.configtree import ConfigTree +from vyos.configtree import mask_inclusive +from vyos.configtree import merge from vyos.migrate import ConfigMigrate from vyos.migrate import ConfigMigrateError -from vyos.utils.process import cmd -from vyos.utils.process import DEVNULL +from vyos.load_config import load_explicit -if (len(sys.argv) < 2): - print("Need config file name to merge.") - print("Usage: merge <config file> [config path]") - sys.exit(0) -file_name = sys.argv[1] +parser = argparse.ArgumentParser() +parser.add_argument('config_file', help='config file to merge from') +parser.add_argument( + '--destructive', action='store_true', help='replace values with those of merge file' +) +parser.add_argument('--paths', nargs='+', help='only merge from listed paths') +parser.add_argument( + '--migrate', action='store_true', help='migrate config file before merge' +) -configdir = vyos.defaults.directories['config'] +args = parser.parse_args() -protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp'] +file_name = args.config_file +paths = [shlex.split(s) for s in args.paths] if args.paths else [] -if any(x in file_name for x in protocols): - config_file = vyos.remote.get_remote_config(file_name) - if not config_file: - sys.exit("No config file by that name.") -else: - canonical_path = "{0}/{1}".format(configdir, file_name) - first_err = None - try: - with open(canonical_path, 'r') as f: - config_file = f.read() - except Exception as err: - first_err = err - try: - with open(file_name, 'r') as f: - config_file = f.read() - except Exception as err: - print(first_err) - print(err) - sys.exit(1) - -with tempfile.NamedTemporaryFile() as file_to_migrate: - with open(file_to_migrate.name, 'w') as fd: - fd.write(config_file) - - config_migrate = ConfigMigrate(file_to_migrate.name) +# pylint: disable=consider-using-with +file_path = tempfile.NamedTemporaryFile(delete=False).name +err = get_config_file(file_name, file_path) +if err: + os.remove(file_path) + sys.exit(err) + +if args.migrate: + migrate = ConfigMigrate(file_path) try: - config_migrate.run() + migrate.run() except ConfigMigrateError as e: + os.remove(file_path) sys.exit(e) -merge_config_tree = ConfigTree(config_file) +with open(file_path) as f: + merge_str = f.read() + +merge_ct = ConfigTree(merge_str) -effective_config = Config() -effective_config_tree = effective_config._running_config +if paths: + mask = ConfigTree('') + for p in paths: + mask.set(p) -effective_cmds = effective_config_tree.to_commands() -merge_cmds = merge_config_tree.to_commands() + merge_ct = mask_inclusive(merge_ct, mask) -effective_cmd_list = effective_cmds.splitlines() -merge_cmd_list = merge_cmds.splitlines() +with open(file_path, 'w') as f: + f.write(merge_ct.to_string()) -effective_cmd_set = set(effective_cmd_list) -add_cmds = [ cmd for cmd in merge_cmd_list if cmd not in effective_cmd_set ] +config = Config() -path = None -if (len(sys.argv) > 2): - path = sys.argv[2:] - if (not effective_config_tree.exists(path) and not - merge_config_tree.exists(path)): - print("path {} does not exist in either effective or merge" - " config; will use root.".format(path)) - path = None - else: - path = " ".join(path) +if config.vyconf_session is not None: + out, err = config.vyconf_session.merge_config( + file_path, destructive=args.destructive + ) + if err: + os.remove(file_path) + sys.exit(out) + print(out) +else: + session_ct = config.get_config_tree() + merge_res = merge(session_ct, merge_ct, destructive=args.destructive) -if path: - add_cmds = [ cmd for cmd in add_cmds if path in cmd ] + load_explicit(merge_res) -for add in add_cmds: - try: - cmd(f'/opt/vyatta/sbin/my_{add}', shell=True, stderr=DEVNULL) - except OSError as err: - print(err) +os.remove(file_path) -if effective_config.session_changed(): +if config.session_changed(): print("Merge complete. Use 'commit' to make changes effective.") else: - print("No configuration changes to commit.") + print('No configuration changes to commit.') diff --git a/src/helpers/vyos-request-configd-update.py b/src/helpers/vyos-request-configd-update.py new file mode 100755 index 000000000..55ed0eef9 --- /dev/null +++ b/src/helpers/vyos-request-configd-update.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +import json +import zmq + +from vyos.utils.commit import wait_for_commit_lock +from vyos.defaults import vyos_configd_socket_path + +context = zmq.Context() + +request = { + 'type': 'node', + 'last': True, + 'data': '/usr/libexec/vyos/conf_mode/protocols_static.py', +} +request = json.dumps(request) + +print("Waiting for commit lock...") +wait_for_commit_lock() + +print("Connecting to vyos-configd server...") +socket = context.socket(zmq.REQ) +socket.connect(vyos_configd_socket_path) + +print(f"Sending request {request}...") +socket.send_string(request) + +message = socket.recv() +print(f"Received reply {request} [ {message} ]") + +print("All done") diff --git a/src/helpers/vyos-save-config.py b/src/helpers/vyos-save-config.py index fa2ea0ce4..208fb8ae8 100755 --- a/src/helpers/vyos-save-config.py +++ b/src/helpers/vyos-save-config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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 @@ -23,15 +23,22 @@ from argparse import ArgumentParser from vyos.config import Config from vyos.remote import urlc -from vyos.component_version import add_system_version +from vyos.component_version import add_system_version_string from vyos.defaults import directories +from vyos.utils.file import write_file +from vyos.utils.file import write_file_sync +from vyos.utils.file import write_file_atomic +from vyos.utils.file import file_is_persistent DEFAULT_CONFIG_PATH = os.path.join(directories['config'], 'config.boot') remote_save = None parser = ArgumentParser(description='Save configuration') parser.add_argument('file', type=str, nargs='?', help='Save configuration to file') -parser.add_argument('--write-json-file', type=str, help='Save JSON of configuration to file') +parser.add_argument( + '--write-json-file', type=str, help='Save JSON of configuration to file' +) + args = parser.parse_args() file = args.file json_file = args.write_json_file @@ -47,16 +54,43 @@ if re.match(r'\w+:/', save_file): except ValueError as e: sys.exit(e) + config = Config() ct = config.get_config_tree(effective=True) +# The effective config is None before boot configuration is complete. +# Nothing to write, nor do we want to invite saving an empty string: +# exit gracefully. +if ct is None: + sys.exit() + +config_str = ct.to_string() +versioned_config_str = add_system_version_string(config_str) + # pylint: disable=consider-using-with -write_file = save_file if remote_save is None else NamedTemporaryFile(delete=False).name +file_to_write = ( + save_file if remote_save is None else NamedTemporaryFile(delete=False).name +) -# config_tree is None before boot configuration is complete; -# automated saves should check boot_configuration_complete -config_str = None if ct is None else ct.to_string() -add_system_version(config_str, write_file) +if file_is_persistent(file_to_write): + if os.geteuid() == 0: + try: + write_file_atomic(file_to_write, versioned_config_str) + except OSError as e: + print(f'failed to write config file, write_file_atomic: {e}') + sys.exit(1) + else: + try: + write_file_sync(file_to_write, versioned_config_str) + except OSError as e: + print(f'failed to write config file, write_file_sync: {e}') + sys.exit(1) +else: + try: + write_file(file_to_write, versioned_config_str) + except Exception as e: # pylint: disable=broad-exception-caught + print(f'failed to write config file, write_file: {e}') + sys.exit(1) if json_file is not None and ct is not None: try: @@ -67,6 +101,6 @@ if json_file is not None and ct is not None: if remote_save is not None: try: - remote_save.upload(write_file) + remote_save.upload(file_to_write) finally: - os.remove(write_file) + os.remove(file_to_write) diff --git a/src/helpers/vyos-sudo.py b/src/helpers/vyos-sudo.py deleted file mode 100755 index 75dd7f29d..000000000 --- a/src/helpers/vyos-sudo.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 - -# 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 vyos.utils.permission import is_admin - - -if __name__ == '__main__': - if len(sys.argv) < 2: - print('Missing command argument') - sys.exit(1) - - if not is_admin(): - print('This account is not authorized to run this command') - sys.exit(1) - - os.execvp('sudo', ['sudo'] + sys.argv[1:]) diff --git a/src/helpers/vyos_config_sync.py b/src/helpers/vyos_config_sync.py index 9d9aec376..9e425d7c4 100755 --- a/src/helpers/vyos_config_sync.py +++ b/src/helpers/vyos_config_sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023-2024 VyOS maintainers and contributors +# Copyright VyOS maintainers and contributors <maintainers@vyos.io> # # 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,6 +17,7 @@ # import os +import sys import json import requests import urllib3 @@ -26,6 +27,9 @@ from typing import Optional, List, Tuple, Dict, Any from vyos.config import Config from vyos.configtree import ConfigTree from vyos.configtree import mask_inclusive +from vyos.configtree import mask_exclusive +from vyos.defaults import config_sync_exclusion_list +from vyos.derivedtree import subtree_from_list_of_partial_paths from vyos.template import bracketize_ipv6 @@ -40,9 +44,12 @@ logger.name = os.path.basename(__file__) API_HEADERS = {'Content-Type': 'application/json'} -def post_request(url: str, - data: str, - headers: Dict[str, str]) -> requests.Response: +def post_request( + url: str, + data: str, + params: Dict[str, Any], + headers: Dict[str, str], +) -> requests.Response: """Sends a POST request to the specified URL Args: @@ -54,16 +61,20 @@ def post_request(url: str, requests.Response: The response object representing the server's response to the request """ - response = requests.post(url, - data=data, - headers=headers, - verify=False, - timeout=timeout) + response = requests.post( + url, + data=data, + params=params, + headers=headers, + verify=False, + timeout=timeout, + ) return response - -def retrieve_config(sections: List[list[str]]) -> Tuple[Dict[str, Any], Dict[str, Any]]: +def retrieve_config( + sections: List[list[str]], exclusions: List[list[str]] +) -> Tuple[Dict[str, Any], Dict[str, Any]]: """Retrieves the configuration from the local server. Args: @@ -76,18 +87,32 @@ def retrieve_config(sections: List[list[str]]) -> Tuple[Dict[str, Any], Dict[str - config: The subtree of masked config data, as a dictionary. """ - mask = ConfigTree('') - for section in sections: - mask.set(section) - mask_dict = json.loads(mask.to_json()) - config = Config() config_tree = config.get_config_tree() - masked = mask_inclusive(config_tree, mask) + + # set inclusion mask + mask_in = ConfigTree('') + for section in sections: + mask_in.set(section) + mask_in_str = mask_in.write_internal_string() + + ## set exclusion mask + # pass global settings, read at startup: + exclude_list = exclusions + # read local settings from Config + # ... exclude_list += ... + mask_ex = subtree_from_list_of_partial_paths(config_tree, exclude_list) + mask_ex_str = json.dumps(exclude_list) + + masked = mask_inclusive(config_tree, mask_in) + masked = mask_exclusive(masked, mask_ex) + + mask_dict = {'inclusive': mask_in_str, 'exclusive': mask_ex_str} config_dict = json.loads(masked.to_json()) return mask_dict, config_dict + def set_remote_config( address: str, key: str, @@ -116,6 +141,10 @@ def set_remote_config( urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) url = f'https://{address}:{port}/configure-section' + params = { + # Ask the remote API to perform the configure and commit workflow asynchronously + 'in_background': True, + } data = json.dumps({ 'op': op, 'mask': mask, @@ -124,7 +153,7 @@ def set_remote_config( }) try: - config = post_request(url, data, headers) + config = post_request(url, data, params, headers) return config.json() except requests.exceptions.RequestException as e: print(f"An error occurred: {e}") @@ -137,14 +166,19 @@ def is_section_revised(section: List[str]) -> bool: return is_node_revised(section) -def config_sync(secondary_address: str, - secondary_key: str, - sections: List[list[str]], - mode: str, - secondary_port: int): +def config_sync( + secondary_address: str, + secondary_key: str, + sections: List[list[str]], + mode: str, + secondary_port: int, + exclusions: List[list[str]], +): """Retrieve a config section from primary router in JSON format and send it to secondary router """ + # pylint: disable=too-many-arguments + if not any(map(is_section_revised, sections)): return @@ -153,7 +187,7 @@ def config_sync(secondary_address: str, ) # Sync sections ("nat", "firewall", etc) - mask_dict, config_dict = retrieve_config(sections) + mask_dict, config_dict = retrieve_config(sections, exclusions) logger.debug( f"Retrieved config for sections '{sections}': {config_dict}") @@ -171,7 +205,19 @@ if __name__ == '__main__': # Read configuration from file if not os.path.exists(CONFIG_FILE): logger.error(f"Post-commit: No config file '{CONFIG_FILE}' exists") - exit(0) + sys.exit() + + try: + with open(config_sync_exclusion_list) as f: + exclude_list = json.load(f) + except FileNotFoundError: + logger.error(f"Exclusion list '{config_sync_exclusion_list}' not found") + sys.exit() + except (json.JSONDecodeError, OSError) as e: + logger.error( + f"Failed to load config-sync exclusion list '{config_sync_exclusion_list}': {e}" + ) + sys.exit() with open(CONFIG_FILE, 'r') as f: config_data = f.read() @@ -188,7 +234,7 @@ if __name__ == '__main__': if not all([mode, secondary_address, secondary_key, sections]): logger.error("Missing required configuration data for config synchronization.") - exit(0) + sys.exit() # Generate list_sections of sections/subsections # [ @@ -202,4 +248,11 @@ if __name__ == '__main__': else: list_sections.append([section]) - config_sync(secondary_address, secondary_key, list_sections, mode, secondary_port) + config_sync( + secondary_address, + secondary_key, + list_sections, + mode, + secondary_port, + exclude_list, + ) diff --git a/src/helpers/vyos_net_name b/src/helpers/vyos_net_name deleted file mode 100755 index f5de182c6..000000000 --- a/src/helpers/vyos_net_name +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env python3 -# -# 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 os -import re -import time -import logging -import logging.handlers -import tempfile -from pathlib import Path -from sys import argv - -from vyos.configtree import ConfigTree -from vyos.defaults import directories -from vyos.utils.process import cmd -from vyos.utils.boot import boot_configuration_complete -from vyos.utils.locking import Lock -from vyos.migrate import ConfigMigrate - -# Define variables -vyos_udev_dir = directories['vyos_udev_dir'] -config_path = '/opt/vyatta/etc/config/config.boot' - - -def is_available(intfs: dict, intf_name: str) -> bool: - """Check if interface name is already assigned""" - if intf_name in list(intfs.values()): - return False - return True - - -def find_available(intfs: dict, prefix: str) -> str: - """Find lowest indexed iterface name that is not assigned""" - index_list = [ - int(x.replace(prefix, '')) for x in list(intfs.values()) if prefix in x - ] - index_list.sort() - # find 'holes' in list, if any - missing = sorted(set(range(index_list[0], index_list[-1])) - set(index_list)) - if missing: - return f'{prefix}{missing[0]}' - - return f'{prefix}{len(index_list)}' - - -def mod_ifname(ifname: str) -> str: - """Check interface with names eX and return ifname on the next format eth{ifindex} - 2""" - if re.match('^e[0-9]+$', ifname): - intf = ifname.split('e') - if intf[1]: - if int(intf[1]) >= 2: - return 'eth' + str(int(intf[1]) - 2) - else: - return 'eth' + str(intf[1]) - - return ifname - - -def get_biosdevname(ifname: str) -> str: - """Use legacy vyatta-biosdevname to query for name - - This is carried over for compatability only, and will likely be dropped - going forward. - XXX: This throws an error, and likely has for a long time, unnoticed - since vyatta_net_name redirected stderr to /dev/null. - """ - intf = mod_ifname(ifname) - - if 'eth' not in intf: - return intf - if os.path.isdir('/proc/xen'): - return intf - - time.sleep(1) - - try: - biosname = cmd(f'/sbin/biosdevname --policy all_ethN -i {ifname}') - except Exception as e: - logger.error(f'biosdevname error: {e}') - biosname = '' - - return intf if biosname == '' else biosname - - -def leave_rescan_hint(intf_name: str, hwid: str): - """Write interface information reported by udev - - This script is called while the root mount is still read-only. Leave - information in /run/udev: file name, the interface; contents, the - hardware id. - """ - try: - os.mkdir(vyos_udev_dir) - except FileExistsError: - pass - except Exception as e: - logger.critical(f'Error creating rescan hint directory: {e}') - exit(1) - - try: - with open(os.path.join(vyos_udev_dir, intf_name), 'w') as f: - f.write(hwid) - except OSError as e: - logger.critical(f'OSError {e}') - - -def get_configfile_interfaces() -> dict: - """Read existing interfaces from config file""" - interfaces: dict = {} - - if not os.path.isfile(config_path): - # If the case, then we are running off of livecd; return empty - return interfaces - - try: - with open(config_path) as f: - config_file = f.read() - except OSError as e: - logger.critical(f'OSError {e}') - exit(1) - - try: - config = ConfigTree(config_file) - except Exception: - try: - logger.debug('updating component version string syntax') - # this will update the component version string syntax, - # required for updates 1.2 --> 1.3/1.4 - with tempfile.NamedTemporaryFile() as fp: - with open(fp.name, 'w') as fd: - fd.write(config_file) - config_migrate = ConfigMigrate(fp.name) - if config_migrate.syntax_update_needed(): - config_migrate.update_syntax() - config_migrate.write_config() - with open(fp.name) as fd: - config_file = fd.read() - - config = ConfigTree(config_file) - - except Exception as e: - logger.critical(f'ConfigTree error: {e}') - exit(1) - - base = ['interfaces', 'ethernet'] - if config.exists(base): - eth_intfs = config.list_nodes(base) - for intf in eth_intfs: - path = base + [intf, 'hw-id'] - if not config.exists(path): - logger.warning(f"no 'hw-id' entry for {intf}") - continue - hwid = config.return_value(path) - if hwid in list(interfaces): - logger.warning( - f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}' - ) - continue - interfaces[hwid] = intf - - base = ['interfaces', 'wireless'] - if config.exists(base): - wlan_intfs = config.list_nodes(base) - for intf in wlan_intfs: - path = base + [intf, 'hw-id'] - if not config.exists(path): - logger.warning(f"no 'hw-id' entry for {intf}") - continue - hwid = config.return_value(path) - if hwid in list(interfaces): - logger.warning( - f'multiple entries for {hwid}: {interfaces[hwid]}, {intf}' - ) - continue - interfaces[hwid] = intf - - logger.debug(f'config file entries: {interfaces}') - - return interfaces - - -def add_assigned_interfaces(intfs: dict): - """Add interfaces found by previous invocation of udev rule""" - if not os.path.isdir(vyos_udev_dir): - return - - for intf in os.listdir(vyos_udev_dir): - path = os.path.join(vyos_udev_dir, intf) - try: - with open(path) as f: - hwid = f.read().rstrip() - except OSError as e: - logger.error(f'OSError {e}') - continue - intfs[hwid] = intf - - -def on_boot_event(intf_name: str, hwid: str, predefined: str = '') -> str: - """Called on boot by vyos-router: 'coldplug' in vyatta_net_name""" - logger.info(f'lookup {intf_name}, {hwid}') - interfaces = get_configfile_interfaces() - logger.debug(f'config file interfaces are {interfaces}') - - if hwid in list(interfaces): - logger.info(f"use mapping from config file: '{hwid}' -> '{interfaces[hwid]}'") - return interfaces[hwid] - - add_assigned_interfaces(interfaces) - logger.debug(f'adding assigned interfaces: {interfaces}') - - if predefined: - newname = predefined - logger.info(f"predefined interface name for '{intf_name}' is '{newname}'") - else: - newname = get_biosdevname(intf_name) - logger.info(f"biosdevname returned '{newname}' for '{intf_name}'") - - if not is_available(interfaces, newname): - prefix = re.sub(r'\d+$', '', newname) - newname = find_available(interfaces, prefix) - - logger.info(f"new name for '{intf_name}' is '{newname}'") - - leave_rescan_hint(newname, hwid) - - return newname - - -def hotplug_event(): - # Not yet implemented, since interface-rescan will only be run on boot. - pass - - -if __name__ == '__main__': - # Set up logging to syslog - syslog_handler = logging.handlers.SysLogHandler(address='/dev/log') - formatter = logging.Formatter(f'{Path(__file__).name}: %(message)s') - syslog_handler.setFormatter(formatter) - - logger = logging.getLogger() - logger.addHandler(syslog_handler) - logger.setLevel(logging.DEBUG) - - logger.debug(f'Started with arguments: {argv}') - - if len(argv) > 3: - predef_name = argv[3] - else: - predef_name = '' - - lock = Lock('vyos_net_name') - # Wait 60 seconds for other running scripts to finish - lock.acquire(60) - - if not boot_configuration_complete(): - res = on_boot_event(argv[1], argv[2], predefined=predef_name) - logger.debug(f'on boot, returned name is {res}') - print(res) - else: - logger.debug('boot configuration complete') - - lock.release() - logger.debug('Finished') diff --git a/src/helpers/write-config-file-value.py b/src/helpers/write-config-file-value.py new file mode 100644 index 000000000..f3993223c --- /dev/null +++ b/src/helpers/write-config-file-value.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# +# Copyright (C) VyOS Inc. +# +# 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 argparse import ArgumentParser +from shlex import split as shlex_split + +from vyos.utils.config import write_saved_value + +def _split_quoted(s: str) -> list[str]: + parts = shlex_split(s) + if not parts: + raise ValueError('empty string') + return parts + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument( + '--path', + required=True, + help='Quoted CLI path, e.g. "system console device ttyS1 speed"', + ) + parser.add_argument( + '--value', + required=False, + help='Value for the node, e.g. "9600". If omitted, creates a valueless node.', + ) + parser.add_argument( + '--config-file', + required=True, + help=f'Path to saved config.boot', + ) + args = parser.parse_args() + + path = _split_quoted(args.path) + write_saved_value(path, value=args.value, config_path=args.config_file) |
