diff options
Diffstat (limited to 'src/helpers')
24 files changed, 2672 insertions, 0 deletions
diff --git a/src/helpers/add-system-version.py b/src/helpers/add-system-version.py new file mode 100644 index 0000000..5270ee7 --- /dev/null +++ b/src/helpers/add-system-version.py @@ -0,0 +1,20 @@ +#!/usr/bin/python3 + +# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library. If not, see <http://www.gnu.org/licenses/>. + +from vyos.component_version import add_system_version + +add_system_version() diff --git a/src/helpers/commit-confirm-notify.py b/src/helpers/commit-confirm-notify.py new file mode 100644 index 0000000..8d7626c --- /dev/null +++ b/src/helpers/commit-confirm-notify.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +import os +import sys +import time + +# Minutes before reboot to trigger notification. +intervals = [1, 5, 15, 60] + +def notify(interval): + s = "" if interval == 1 else "s" + time.sleep((minutes - interval) * 60) + message = ('"[commit-confirm] System is going to reboot in ' + f'{interval} minute{s} to rollback the last commit.\n' + 'Confirm your changes to cancel the reboot."') + os.system("wall -n " + message) + +if __name__ == "__main__": + # Must be run as root to call wall(1) without a banner. + if len(sys.argv) != 2 or os.getuid() != 0: + print('This script requires superuser privileges.', file=sys.stderr) + exit(1) + minutes = int(sys.argv[1]) + # Drop the argument from the list so that the notification + # doesn't kick in immediately. + if minutes in intervals: + intervals.remove(minutes) + for interval in sorted(intervals, reverse=True): + if minutes >= interval: + notify(interval) + minutes -= (minutes - interval) + exit(0) diff --git a/src/helpers/config_dependency.py b/src/helpers/config_dependency.py new file mode 100644 index 0000000..817bcc6 --- /dev/null +++ b/src/helpers/config_dependency.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# + +import os +import sys +import json +from argparse import ArgumentParser +from argparse import ArgumentTypeError +from graphlib import TopologicalSorter, CycleError + +# addon packages will need to specify the dependency directory +data_dir = '/usr/share/vyos/' +dependency_dir = os.path.join(data_dir, 'config-mode-dependencies') + +def dict_merge(source, destination): + from copy import deepcopy + tmp = deepcopy(destination) + + for key, value in source.items(): + if key not in tmp: + tmp[key] = value + elif isinstance(source[key], dict): + tmp[key] = dict_merge(source[key], tmp[key]) + + return tmp + +def read_dependency_dict(dependency_dir: str = dependency_dir) -> dict: + res = {} + for dep_file in os.listdir(dependency_dir): + if not dep_file.endswith('.json'): + continue + path = os.path.join(dependency_dir, dep_file) + with open(path) as f: + d = json.load(f) + if dep_file == 'vyos-1x.json': + res = dict_merge(res, d) + else: + res = dict_merge(d, res) + + return res + +def graph_from_dependency_dict(d: dict) -> dict: + g = {} + for k in list(d): + g[k] = set() + # add the dependencies for every sub-case; should there be cases + # that are mutally exclusive in the future, the graphs will be + # distinguished + for el in list(d[k]): + g[k] |= set(d[k][el]) + + return g + +def is_acyclic(d: dict) -> bool: + g = graph_from_dependency_dict(d) + ts = TopologicalSorter(g) + try: + # get node iterator + order = ts.static_order() + # try iteration + _ = [*order] + except CycleError: + return False + + return True + +def check_dependency_graph(dependency_dir: str = dependency_dir, + supplement: str = None) -> bool: + d = read_dependency_dict(dependency_dir=dependency_dir) + if supplement is not None: + with open(supplement) as f: + d = dict_merge(json.load(f), d) + + return is_acyclic(d) + +def path_exists(s): + if not os.path.exists(s): + raise ArgumentTypeError("Must specify a valid vyos-1x dependency directory") + return s + +def main(): + parser = ArgumentParser(description='generate and save dict from xml defintions') + parser.add_argument('--dependency-dir', type=path_exists, + default=dependency_dir, + help='location of vyos-1x dependency directory') + parser.add_argument('--supplement', type=str, + help='supplemental dependency file') + args = vars(parser.parse_args()) + + if not check_dependency_graph(**args): + print("dependency error: cycle exists") + sys.exit(1) + + print("dependency graph acyclic") + sys.exit(0) + +if __name__ == '__main__': + main() diff --git a/src/helpers/geoip-update.py b/src/helpers/geoip-update.py new file mode 100644 index 0000000..34accf2 --- /dev/null +++ b/src/helpers/geoip-update.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import 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'] + + 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) + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("--force", help="Force update", action="store_true") + args = parser.parse_args() + + firewall = get_config() + + if not geoip_update(firewall, force=args.force): + sys.exit(1) diff --git a/src/helpers/priority.py b/src/helpers/priority.py new file mode 100644 index 0000000..0418610 --- /dev/null +++ b/src/helpers/priority.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# + +import sys +from argparse import ArgumentParser +from tabulate import tabulate + +from vyos.priority import get_priority_data + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('--legacy-format', action='store_true', + help="format output for comparison with legacy 'priority.pl'") + args = parser.parse_args() + + prio_list = get_priority_data() + if args.legacy_format: + for p in prio_list: + print(f'{p[2]} {"/".join(p[0])}') + sys.exit(0) + + l = [] + for p in prio_list: + l.append((p[2], p[1], p[0])) + headers = ['priority', 'owner', 'path'] + out = tabulate(l, headers, numalign='right') + print(out) diff --git a/src/helpers/read-saved-value.py b/src/helpers/read-saved-value.py new file mode 100644 index 0000000..1463e9f --- /dev/null +++ b/src/helpers/read-saved-value.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# + +from argparse import ArgumentParser +from vyos.utils.config import read_saved_value + +if __name__ == '__main__': + parser = ArgumentParser() + parser.add_argument('--path', nargs='*') + args = parser.parse_args() + + out = read_saved_value(args.path) if args.path else '' + if isinstance(out, list): + out = ' '.join(out) + print(out) diff --git a/src/helpers/run-config-activation.py b/src/helpers/run-config-activation.py new file mode 100644 index 0000000..5829370 --- /dev/null +++ b/src/helpers/run-config-activation.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import re +import logging +from pathlib import Path +from argparse import ArgumentParser + +from vyos.compose_config import ComposeConfig +from vyos.compose_config import ComposeConfigError +from vyos.defaults import directories + +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") + +args = parser.parse_args() + +checkpoint_file = '/run/vyos-activate-checkpoint' +log_file = Path(directories['config']).joinpath('vyos-activate.log') + +logger = logging.getLogger(__name__) +fh = logging.FileHandler(log_file) +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 + logger.setLevel(logging.DEBUG) +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 Path(file_name).stem + +script_dir = Path(directories['activate']) + +if args.test_script: + script_list = [script_dir.joinpath(args.test_script)] +else: + script_list = sorted(script_dir.glob('*.py'), key=sort_key) + +config_file = args.config_file +config_str = Path(config_file).read_text() + +compose = ComposeConfig(config_str, checkpoint_file=debug) + +for file in script_list: + file = file.as_posix() + 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) diff --git a/src/helpers/run-config-migration.py b/src/helpers/run-config-migration.py new file mode 100644 index 0000000..e6ce973 --- /dev/null +++ b/src/helpers/run-config-migration.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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 sys +import time +from argparse import ArgumentParser +from shutil import copyfile + +from vyos.migrate import ConfigMigrate +from vyos.migrate import ConfigMigrateError + +parser = ArgumentParser() +parser.add_argument('config_file', type=str, + help="configuration file to migrate") +parser.add_argument('--test-script', type=str, + help="test named script") +parser.add_argument('--output-file', type=str, + help="write to named output file instead of config file") +parser.add_argument('--force', action='store_true', + help="force run of all migration scripts") + +args = parser.parse_args() + +config_file = args.config_file +out_file = args.output_file +test_script = args.test_script +force = args.force + +if not os.access(config_file, os.R_OK): + print(f"Config file '{config_file}' not readable") + sys.exit(1) + +if out_file is None: + if not os.access(config_file, os.W_OK): + print(f"Config file '{config_file}' not writeable") + sys.exit(1) +else: + try: + open(out_file, 'w').close() + except OSError: + print(f"Output file '{out_file}' not writeable") + sys.exit(1) + +config_migrate = ConfigMigrate(config_file, force=force, output_file=out_file) + +if test_script: + # run_script and exit + config_migrate.run_script(test_script) + sys.exit(0) + +backup = None +if out_file is None: + timestr = time.strftime("%Y%m%d-%H%M%S") + backup = f'{config_file}.{timestr}.pre-migration' + copyfile(config_file, backup) + +try: + config_migrate.run() +except ConfigMigrateError as e: + print(f'Error: {e}') + sys.exit(1) + +if backup is not None and not config_migrate.config_modified: + os.unlink(backup) diff --git a/src/helpers/simple-download.py b/src/helpers/simple-download.py new file mode 100644 index 0000000..501af75 --- /dev/null +++ b/src/helpers/simple-download.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 + +import sys +from argparse import ArgumentParser +from vyos.remote import download + +parser = ArgumentParser() +parser.add_argument('--local-file', help='local file', required=True) +parser.add_argument('--remote-path', help='remote path', required=True) + +args = parser.parse_args() + +try: + download(args.local_file, args.remote_path, + check_space=True, raise_error=True) +except Exception as e: + print(e) + sys.exit(1) + +sys.exit() diff --git a/src/helpers/strip-private.py b/src/helpers/strip-private.py new file mode 100644 index 0000000..cb29069 --- /dev/null +++ b/src/helpers/strip-private.py @@ -0,0 +1,153 @@ +#!/usr/bin/python3 + +# Copyright 2021-2023 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import re +import sys + +from netaddr import IPNetwork, AddrFormatError + +parser = argparse.ArgumentParser(description='strip off private information from VyOS config') + +strictness = parser.add_mutually_exclusive_group() +strictness.add_argument('--loose', action='store_true', help='remove only information specified as arguments') +strictness.add_argument('--strict', action='store_true', help='remove any private information (implies all arguments below). This is the default behavior.') + +parser.add_argument('--mac', action='store_true', help='strip off MAC addresses') +parser.add_argument('--hostname', action='store_true', help='strip off system host and domain names') +parser.add_argument('--username', action='store_true', help='strip off user names') +parser.add_argument('--dhcp', action='store_true', help='strip off DHCP shared network and static mapping names') +parser.add_argument('--domain', action='store_true', help='strip off domain names') +parser.add_argument('--asn', action='store_true', help='strip off BGP ASNs') +parser.add_argument('--snmp', action='store_true', help='strip off SNMP location information') +parser.add_argument('--lldp', action='store_true', help='strip off LLDP location information') + +address_preserval = parser.add_mutually_exclusive_group() +address_preserval.add_argument('--address', action='store_true', help='strip off all IPv4 and IPv6 addresses') +address_preserval.add_argument('--public-address', action='store_true', help='only strip off public IPv4 and IPv6 addresses') +address_preserval.add_argument('--keep-address', action='store_true', help='preserve all IPv4 and IPv6 addresses') + +# Censor the first half of the address. +ipv4_re = re.compile(r'(\d{1,3}\.){2}(\d{1,3}\.\d{1,3})') +ipv4_subst = r'xxx.xxx.\2' + +# Censor all but the first two fields. +ipv6_re = re.compile(r'([0-9a-fA-F]{1,4}\:){2}([0-9a-fA-F:]+)') +ipv6_subst = r'xxxx:xxxx:\2' + +def ip_match(match: re.Match, subst: str) -> str: + """ + Take a Match and a substitution pattern, check if the match contains a valid IP address, strip + information if it is. This routine is intended to be passed to `re.sub' as a replacement pattern. + """ + result = match.group(0) + # Is this a valid IP address? + try: + addr = IPNetwork(result).ip + # No? Then we've got nothing to do with it. + except AddrFormatError: + return result + # Should we strip it? + if args.address or (args.public_address and not addr.is_private()): + return match.expand(subst) + # No? Then we'll leave it as is. + else: + return result + +def strip_address(line: str) -> str: + """ + Strip IPv4 and IPv6 addresses from the given string. + """ + return ipv4_re.sub(lambda match: ip_match(match, ipv4_subst), ipv6_re.sub(lambda match: ip_match(match, ipv6_subst), line)) + +def strip_lines(rules: tuple) -> None: + """ + Read stdin line by line and apply the given stripping rules. + """ + try: + for line in sys.stdin: + if not args.keep_address: + line = strip_address(line) + for (condition, regexp, subst) in rules: + if condition: + line = regexp.sub(subst, line) + print(line, end='') + # stdin can be cut for any reason, such as user interrupt or the pager terminating before the text can be read. + # All we can do is gracefully exit. + except (BrokenPipeError, EOFError, KeyboardInterrupt): + sys.exit(1) + +if __name__ == "__main__": + args = parser.parse_args() + # Strict mode is the default and the absence of loose mode implies presence of strict mode. + if not args.loose: + args.mac = args.domain = args.hostname = args.username = args.dhcp = args.asn = args.snmp = args.lldp = True + if not args.public_address and not args.keep_address: + args.address = True + elif not args.address and not args.public_address: + args.keep_address = True + + # (condition, precompiled regexp, substitution string) + stripping_rules = [ + # Strip passwords + (True, re.compile(r'password \S+'), 'password xxxxxx'), + (True, re.compile(r'cisco-authentication \S+'), 'cisco-authentication xxxxxx'), + # Strip public key information + (True, re.compile(r'public-keys \S+'), 'public-keys xxxx@xxx.xxx'), + (True, re.compile(r'type \'ssh-(rsa|dss)\''), 'type ssh-xxx'), + (True, re.compile(r' key \S+'), ' key xxxxxx'), + # Strip bucket + (True, re.compile(r' bucket \S+'), ' bucket xxxxxx'), + # Strip tokens + (True, re.compile(r' token \S+'), ' token xxxxxx'), + # Strip OpenVPN secrets + (True, re.compile(r'(shared-secret-key-file|ca-cert-file|cert-file|dh-file|key-file|client) (\S+)'), r'\1 xxxxxx'), + # Strip IPSEC secrets + (True, re.compile(r'pre-shared-secret \S+'), 'pre-shared-secret xxxxxx'), + (True, re.compile(r'secret \S+'), 'secret xxxxxx'), + # Strip OSPF md5-key + (True, re.compile(r'md5-key \S+'), 'md5-key xxxxxx'), + # Strip WireGuard private-key + (True, re.compile(r'private-key \S+'), 'private-key xxxxxx'), + + # Strip MAC addresses + (args.mac, re.compile(r'([0-9a-fA-F]{2}\:){5}([0-9a-fA-F]{2}((\:{0,1})){3})'), r'xx:xx:xx:xx:xx:\2'), + + # Strip host-name, domain-name, domain-search and url + (args.hostname, re.compile(r'(host-name|domain-name|domain-search|url) \S+'), r'\1 xxxxxx'), + + # Strip user-names + (args.username, re.compile(r'(user|username|user-id) \S+'), r'\1 xxxxxx'), + # Strip full-name + (args.username, re.compile(r'(full-name) [ -_A-Z a-z]+'), r'\1 xxxxxx'), + + # Strip DHCP static-mapping and shared network names + (args.dhcp, re.compile(r'(shared-network-name|static-mapping) \S+'), r'\1 xxxxxx'), + + # Strip host/domain names + (args.domain, re.compile(r' (peer|remote-host|local-host|server) ([\w-]+\.)+[\w-]+'), r' \1 xxxxx.tld'), + + # Strip BGP ASNs + (args.asn, re.compile(r'(bgp|remote-as) (\d+)'), r'\1 XXXXXX'), + + # Strip LLDP location parameters + (args.lldp, re.compile(r'(altitude|datum|latitude|longitude|ca-value|country-code) (\S+)'), r'\1 xxxxxx'), + + # Strip SNMP location + (args.snmp, re.compile(r'(location) \S+'), r'\1 xxxxxx'), + ] + strip_lines(stripping_rules) diff --git a/src/helpers/vyos-boot-config-loader.py b/src/helpers/vyos-boot-config-loader.py new file mode 100644 index 0000000..42de696 --- /dev/null +++ b/src/helpers/vyos-boot-config-loader.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019 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 sys +import pwd +import grp +import traceback +from datetime import datetime + +from vyos.defaults import directories, config_status +from vyos.configsession import ConfigSession, ConfigSessionError +from vyos.configtree import ConfigTree +from vyos.utils.process import cmd + +STATUS_FILE = config_status +TRACE_FILE = '/tmp/boot-config-trace' + +CFG_GROUP = 'vyattacfg' + +trace_config = False + +if 'log' in directories: + LOG_DIR = directories['log'] +else: + LOG_DIR = '/var/log/vyatta' + +LOG_FILE = LOG_DIR + '/vyos-boot-config-loader.log' + +try: + with open('/proc/cmdline', 'r') as f: + cmdline = f.read() + if 'vyos-debug' in cmdline: + os.environ['VYOS_DEBUG'] = 'yes' + if 'vyos-config-debug' in cmdline: + os.environ['VYOS_DEBUG'] = 'yes' + trace_config = True +except Exception as e: + print('{0}'.format(e)) + +def write_config_status(status): + try: + with open(STATUS_FILE, 'w') as f: + f.write('{0}\n'.format(status)) + except Exception as e: + print('{0}'.format(e)) + +def trace_to_file(trace_file_name): + try: + with open(trace_file_name, 'w') as trace_file: + traceback.print_exc(file=trace_file) + except Exception as e: + print('{0}'.format(e)) + +def failsafe(config_file_name): + fail_msg = """ + !!!!! + There were errors loading the configuration + Please examine the errors in + {0} + and correct + !!!!! + """.format(TRACE_FILE) + + print(fail_msg, file=sys.stderr) + + users = [x[0] for x in pwd.getpwall()] + if 'vyos' in users: + return + + try: + with open(config_file_name, 'r') as f: + config_file = f.read() + except Exception as e: + print("Catastrophic: no default config file " + "'{0}'".format(config_file_name)) + sys.exit(1) + + config = ConfigTree(config_file) + if not config.exists(['system', 'login', 'user', 'vyos', + 'authentication', 'encrypted-password']): + print("No password entry in default config file;") + print("unable to recover password for user 'vyos'.") + sys.exit(1) + else: + passwd = config.return_value(['system', 'login', 'user', 'vyos', + 'authentication', + 'encrypted-password']) + + cmd(f"useradd --create-home --no-user-group --shell /bin/vbash --password '{passwd}' "\ + "--groups frr,frrvty,vyattacfg,sudo,adm,dip,disk vyos") + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Must specify boot config file.") + sys.exit(1) + else: + file_name = sys.argv[1] + + # Set user and group options, so that others will be able to commit + # Currently, the only caller does 'sg CFG_GROUP', but that may change + cfg_group = grp.getgrnam(CFG_GROUP) + os.setgid(cfg_group.gr_gid) + + # Need to set file permissions to 775 so that every vyattacfg group + # member has write access to the running config + os.umask(0o002) + + session = ConfigSession(os.getpid(), 'vyos-boot-config-loader') + env = session.get_session_env() + + default_file_name = env['vyatta_sysconfdir'] + '/config.boot.default' + + try: + with open(file_name, 'r') as f: + config_file = f.read() + except Exception: + write_config_status(1) + if trace_config: + failsafe(default_file_name) + trace_to_file(TRACE_FILE) + sys.exit(1) + + try: + time_begin_load = datetime.now() + load_out = session.load_config(file_name) + time_end_load = datetime.now() + time_begin_commit = datetime.now() + commit_out = session.commit() + time_end_commit = datetime.now() + write_config_status(0) + except ConfigSessionError: + # If here, there is no use doing session.discard, as we have no + # recoverable config environment, and will only throw an error + write_config_status(1) + if trace_config: + failsafe(default_file_name) + trace_to_file(TRACE_FILE) + sys.exit(1) + + time_elapsed_load = time_end_load - time_begin_load + time_elapsed_commit = time_end_commit - time_begin_commit + + try: + if not os.path.exists(LOG_DIR): + os.mkdir(LOG_DIR) + with open(LOG_FILE, 'a') as f: + f.write('\n\n') + f.write('{0} Begin config load\n' + ''.format(time_begin_load)) + f.write(load_out) + f.write('{0} End config load\n' + ''.format(time_end_load)) + f.write('Elapsed time for config load: {0}\n' + ''.format(time_elapsed_load)) + f.write('{0} Begin config commit\n' + ''.format(time_begin_commit)) + f.write(commit_out) + f.write('{0} End config commit\n' + ''.format(time_end_commit)) + f.write('Elapsed time for config commit: {0}\n' + ''.format(time_elapsed_commit)) + except Exception as e: + print('{0}'.format(e)) diff --git a/src/helpers/vyos-certbot-renew-pki.sh b/src/helpers/vyos-certbot-renew-pki.sh new file mode 100644 index 0000000..d0b663f --- /dev/null +++ b/src/helpers/vyos-certbot-renew-pki.sh @@ -0,0 +1,3 @@ +#!/bin/sh +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 new file mode 100644 index 0000000..334f08d --- /dev/null +++ b/src/helpers/vyos-check-wwan.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from vyos.configquery import VbashOpRun +from vyos.configquery import ConfigTreeQuery + +from vyos.utils.network import is_wwan_connected + +conf = ConfigTreeQuery() +dict = conf.get_config_dict(['interfaces', 'wwan'], key_mangling=('-', '_'), + get_first_key=True) + +for interface, interface_config in dict.items(): + if not is_wwan_connected(interface): + if 'disable' in interface_config: + # do not restart this interface as it's disabled by the user + continue + + op = VbashOpRun() + op.run(['connect', 'interface', interface]) + +exit(0) diff --git a/src/helpers/vyos-config-encrypt.py b/src/helpers/vyos-config-encrypt.py new file mode 100644 index 0000000..84860bd --- /dev/null +++ b/src/helpers/vyos-config-encrypt.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import shutil +import sys + +from argparse import ArgumentParser +from cryptography.fernet import Fernet +from tempfile import NamedTemporaryFile +from tempfile import TemporaryDirectory + +from vyos.tpm import clear_tpm_key +from vyos.tpm import read_tpm_key +from vyos.tpm import write_tpm_key +from vyos.utils.io import ask_input, ask_yes_no +from vyos.utils.process import cmd + +persistpath_cmd = '/opt/vyatta/sbin/vyos-persistpath' +mount_paths = ['/config', '/opt/vyatta/etc/config'] +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_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 + + with NamedTemporaryFile(dir='/dev/shm', delete=False) as f: + f.write(key) + key_file = f.name + + 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}') + + os.unlink(key_file) + + return True + +def encrypt_config(key, recovery_key): + if is_opened(): + raise Exception('An encrypted config volume is already mapped') + + # Clear and write key to 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) + + luks_folder = os.path.join(persist_path, 'luks') + + if not os.path.isdir(luks_folder): + os.mkdir(luks_folder) + + image_name = get_current_image() + image_path = os.path.join(luks_folder, image_name) + + # 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 + + # 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 + + 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') + + 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) + + cmd(f'umount {d}') + + os.unlink(key_file) + + 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}') + + return True + +def decrypt_config(key): + if not key: + return + + persist_path = cmd(persistpath_cmd).strip() + image_name = get_current_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 + + 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}') + + # 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') + + # 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') + + cmd(f'umount {d}') + + # Close encrypted volume + cmd('cryptsetup -q close vyos_config') + + # Remove encrypted volume image file and key + if key_file: + os.unlink(key_file) + os.unlink(image_path) + + try: + clear_tpm_key() + except: + pass + + return True + +if __name__ == '__main__': + if len(sys.argv) < 2: + print("Must specify action.") + 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() + + tpm_exists = os.path.exists('/sys/class/tpm/tpm0') + + key = None + recovery_key = None + need_recovery = False + + 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: + 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 + + 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"') + + 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() + + if len(recovery_key) >= 32: + break + + print('Invalid key - must be at least 32 characters, try again.') + else: + recovery_key = Fernet.generate_key() + + try: + if args.disable: + decrypt_config(key or recovery_key) + + print('Encrypted config volume has been disabled') + print('Contents have been migrated to /config 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') + elif args.enable and tpm_exists: + encrypt_config(key, recovery_key) + + print('Encrypted config volume has been enabled with TPM') + print('Backup the recovery key in a safe place!') + print('Recovery key: ' + recovery_key.decode()) + elif args.enable: + encrypt_config(recovery_key) + + print('Encrypted config volume has been enabled without TPM') + print('Backup the key in a safe place!') + print('Key: ' + recovery_key.decode()) + except Exception as e: + word = 'decrypt' if args.disable or args.load else 'encrypt' + print(f'Failed to {word} config: {e}') diff --git a/src/helpers/vyos-domain-resolver.py b/src/helpers/vyos-domain-resolver.py new file mode 100644 index 0000000..57cfcab --- /dev/null +++ b/src/helpers/vyos-domain-resolver.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import json +import time + +from vyos.configdict import dict_merge +from vyos.configquery import ConfigTreeQuery +from vyos.firewall import fqdn_config_parse +from vyos.firewall import fqdn_resolve +from vyos.utils.commit import commit_in_progress +from vyos.utils.dict import dict_search_args +from vyos.utils.process import cmd +from vyos.utils.process import run +from vyos.xml_ref import get_defaults + +base = ['firewall'] +timeout = 300 +cache = False + +domain_state = {} + +ipv4_tables = { + 'ip vyos_mangle', + 'ip vyos_filter', + 'ip vyos_nat', + 'ip raw' +} + +ipv6_tables = { + 'ip6 vyos_mangle', + 'ip6 vyos_filter', + 'ip6 raw' +} + +def get_config(conf): + firewall = conf.get_config_dict(base, key_mangling=('-', '_'), get_first_key=True, + no_tag_node_value_mangle=True) + + default_values = get_defaults(base, get_first_key=True) + + firewall = dict_merge(default_values, firewall) + + global timeout, cache + + if 'resolver_interval' in firewall: + timeout = int(firewall['resolver_interval']) + + if 'resolver_cache' in firewall: + cache = True + + fqdn_config_parse(firewall) + + return firewall + +def resolve(domains, ipv6=False): + global domain_state + + ip_list = set() + + for domain in domains: + resolved = fqdn_resolve(domain, ipv6=ipv6) + + if resolved and cache: + domain_state[domain] = resolved + elif not resolved: + if domain not in domain_state: + continue + resolved = domain_state[domain] + + ip_list = ip_list | resolved + return ip_list + +def nft_output(table, set_name, ip_list): + output = [f'flush set {table} {set_name}'] + if ip_list: + ip_str = ','.join(ip_list) + output.append(f'add element {table} {set_name} {{ {ip_str} }}') + return output + +def nft_valid_sets(): + try: + valid_sets = [] + sets_json = cmd('nft --json list sets') + sets_obj = json.loads(sets_json) + + for obj in sets_obj['nftables']: + if 'set' in obj: + family = obj['set']['family'] + table = obj['set']['table'] + name = obj['set']['name'] + valid_sets.append((f'{family} {table}', name)) + + return valid_sets + except: + return [] + +def update(firewall): + conf_lines = [] + count = 0 + + valid_sets = nft_valid_sets() + + domain_groups = dict_search_args(firewall, 'group', 'domain_group') + if domain_groups: + for set_name, domain_config in domain_groups.items(): + if 'address' not in domain_config: + continue + + nft_set_name = f'D_{set_name}' + domains = domain_config['address'] + + ip_list = resolve(domains, ipv6=False) + for table in ipv4_tables: + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) + + ip6_list = resolve(domains, ipv6=True) + for table in ipv6_tables: + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip6_list) + count += 1 + + for set_name, domain in firewall['ip_fqdn'].items(): + table = 'ip vyos_filter' + nft_set_name = f'FQDN_{set_name}' + + ip_list = resolve([domain], ipv6=False) + + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) + count += 1 + + for set_name, domain in firewall['ip6_fqdn'].items(): + table = 'ip6 vyos_filter' + nft_set_name = f'FQDN_{set_name}' + + ip_list = resolve([domain], ipv6=True) + if (table, nft_set_name) in valid_sets: + conf_lines += nft_output(table, nft_set_name, ip_list) + count += 1 + + nft_conf_str = "\n".join(conf_lines) + "\n" + code = run(f'nft --file -', input=nft_conf_str) + + print(f'Updated {count} sets - result: {code}') + +if __name__ == '__main__': + print(f'VyOS domain resolver') + + count = 1 + while commit_in_progress(): + if ( count % 60 == 0 ): + print(f'Commit still in progress after {count}s - waiting') + count += 1 + time.sleep(1) + + conf = ConfigTreeQuery() + firewall = get_config(conf) + + print(f'interval: {timeout}s - cache: {cache}') + + while True: + update(firewall) + time.sleep(timeout) diff --git a/src/helpers/vyos-failover.py b/src/helpers/vyos-failover.py new file mode 100644 index 0000000..3489743 --- /dev/null +++ b/src/helpers/vyos-failover.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2022-2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import argparse +import json +import socket +import time + +from vyos.utils.process import rc_cmd +from pathlib import Path +from systemd import journal + + +my_name = Path(__file__).stem + + +def is_route_exists(route, gateway, interface, metric): + """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}') + if rc == 0: + data = json.loads(data) + if len(data) > 0: + return True + 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): + """ + Check connection to remote host and port + Return True if host alive + + % 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 + the specified protocol ICMP, ARP, TCP + + Args: + target_list (list): A list of IP addresses or hostnames to check. + 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. + policy (str): The policy to use for the check. Options are 'any-available' or 'all-available'. + + Returns: + bool: True if all targets are reachable according to the policy, False otherwise. + + Example: + % is_target_alive(['192.0.2.1', '192.0.2.5'], 'eth1', proto='arp', policy='all-available') + True + """ + if iface != '': + iface = f'-I {iface}' + + num_reachable_targets = 0 + for target in target_list: + match proto: + case 'icmp': + command = f'/usr/bin/ping -q {target} {iface} -n -c 2 -W 1' + rc, response = rc_cmd(command) + if debug: + print(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}' + rc, response = rc_cmd(command) + if debug: + print(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): + num_reachable_targets += 1 + if policy == 'any-available': + return True + + case _: + return False + + if policy == 'all-available' and num_reachable_targets == len(target_list): + 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) + + 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}' + ) + 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 + + while(True): + + for route, route_config in config.get('route').items(): + + exists_gateway, exists_iface, exists_metric = get_best_route_options(route, debug=debug) + + 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 '' + + # 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}]") + # 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 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}') + 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) + 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)) diff --git a/src/helpers/vyos-interface-rescan.py b/src/helpers/vyos-interface-rescan.py new file mode 100644 index 0000000..0123572 --- /dev/null +++ b/src/helpers/vyos-interface-rescan.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 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 stat +import argparse +import logging +import netaddr + +from vyos.configtree import ConfigTree +from vyos.defaults import directories +from vyos.utils.permission import get_cfg_group_id + +debug = False + +vyos_udev_dir = directories['vyos_udev_dir'] +vyos_log_dir = directories['log'] +log_file = os.path.splitext(os.path.basename(__file__))[0] +vyos_log_file = os.path.join(vyos_log_dir, log_file) + +logger = logging.getLogger(__name__) +handler = logging.FileHandler(vyos_log_file, mode='a') +formatter = logging.Formatter('%(levelname)s: %(message)s') +handler.setFormatter(formatter) +logger.addHandler(handler) + +passlist = { + '02:07:01' : 'Interlan', + '02:60:60' : '3Com', + '02:60:8c' : '3Com', + '02:a0:c9' : 'Intel', + '02:aa:3c' : 'Olivetti', + '02:cf:1f' : 'CMC', + '02:e0:3b' : 'Prominet', + '02:e6:d3' : 'BTI', + '52:54:00' : 'Realtek', + '52:54:4c' : 'Novell 2000', + '52:54:ab' : 'Realtec', + 'e2:0c:0f' : 'Kingston Technologies' +} + +def is_multicast(addr: netaddr.eui.EUI) -> bool: + return bool(addr.words[0] & 0b1) + +def is_locally_administered(addr: netaddr.eui.EUI) -> bool: + return bool(addr.words[0] & 0b10) + +def is_on_passlist(hwid: str) -> bool: + top = hwid.rsplit(':', 3)[0] + if top in list(passlist): + return True + return False + +def is_persistent(hwid: str) -> bool: + addr = netaddr.EUI(hwid) + if is_multicast(addr): + return False + if is_locally_administered(addr) and not is_on_passlist(hwid): + return False + return True + +def get_wireless_physical_device(intf: str) -> str: + if 'wlan' not in intf: + return '' + try: + tmp = os.readlink(f'/sys/class/net/{intf}/phy80211') + except OSError: + logger.critical(f"Failed to read '/sys/class/net/{intf}/phy80211'") + return '' + phy = os.path.basename(tmp) + logger.info(f"wireless phy is {phy}") + return phy + +def get_interface_type(intf: str) -> str: + if 'eth' in intf: + intf_type = 'ethernet' + elif 'wlan' in intf: + intf_type = 'wireless' + else: + logger.critical('Unrecognized interface type!') + intf_type = '' + return intf_type + +def get_new_interfaces() -> dict: + """ Read any new interface data left in /run/udev/vyos by vyos_net_name + """ + interfaces = {} + + 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 + interfaces[intf] = hwid + + # reverse sort to simplify insertion in config + interfaces = {key: value for key, value in sorted(interfaces.items(), + reverse=True)} + return interfaces + +def filter_interfaces(intfs: dict) -> dict: + """ Ignore no longer existing interfaces or non-persistent mac addresses + """ + filtered = {} + + for intf, hwid in intfs.items(): + if not os.path.isdir(os.path.join('/sys/class/net', intf)): + continue + if not is_persistent(hwid): + continue + filtered[intf] = hwid + + return filtered + +def interface_rescan(config_path: str): + """ Read new data and update config file + """ + interfaces = get_new_interfaces() + + logger.debug(f"interfaces from udev: {interfaces}") + + interfaces = filter_interfaces(interfaces) + + logger.debug(f"filtered interfaces: {interfaces}") + + try: + with open(config_path) as f: + config_file = f.read() + except OSError as e: + logger.critical(f"OSError {e}") + exit(1) + + config = ConfigTree(config_file) + + for intf, hwid in interfaces.items(): + logger.info(f"Writing '{intf}' '{hwid}' to config file") + intf_type = get_interface_type(intf) + if not intf_type: + continue + if not config.exists(['interfaces', intf_type]): + config.set(['interfaces', intf_type]) + config.set_tag(['interfaces', intf_type]) + config.set(['interfaces', intf_type, intf, 'hw-id'], value=hwid) + + if intf_type == 'wireless': + phy = get_wireless_physical_device(intf) + if not phy: + continue + config.set(['interfaces', intf_type, intf, 'physical-device'], + value=phy) + + try: + with open(config_path, 'w') as f: + f.write(config.to_string()) + except OSError as e: + logger.critical(f"OSError {e}") + +def main(): + global debug + + argparser = argparse.ArgumentParser( + formatter_class=argparse.RawTextHelpFormatter) + argparser.add_argument('configfile', type=str) + argparser.add_argument('--debug', action='store_true') + args = argparser.parse_args() + + if args.debug: + debug = True + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + + configfile = args.configfile + + # preserve vyattacfg group write access to running config + os.setgid(get_cfg_group_id()) + os.umask(0o002) + + # log file perms are not automatic; this could be cleaner by moving to a + # logging config file + os.chown(vyos_log_file, 0, get_cfg_group_id()) + os.chmod(vyos_log_file, + stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH) + + interface_rescan(configfile) + +if __name__ == '__main__': + main() diff --git a/src/helpers/vyos-load-config.py b/src/helpers/vyos-load-config.py new file mode 100644 index 0000000..16083fd --- /dev/null +++ b/src/helpers/vyos-load-config.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2019-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/>. +# +# + +"""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 tempfile +import vyos.defaults +import vyos.remote +from vyos.configsource import ConfigSourceSession, VyOSError +from vyos.migrate import ConfigMigrate +from vyos.migrate import ConfigMigrateError + +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'] + +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}'") + + 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) + + 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) + +config = LoadConfig() + +print(f"Loading configuration from '{file_name}'") + +with tempfile.NamedTemporaryFile() as fp: + with open(fp.name, 'w') as fd: + fd.write(config_string) + + config_migrate = ConfigMigrate(fp.name) + try: + config_migrate.run() + except ConfigMigrateError as err: + sys.exit(err) + + try: + config.load_config(fp.name) + except VyOSError as err: + sys.exit(err) + +if config.session_changed(): + print("Load complete. Use 'commit' to make changes effective.") +else: + print("No configuration changes to commit.") diff --git a/src/helpers/vyos-merge-config.py b/src/helpers/vyos-merge-config.py new file mode 100644 index 0000000..5ef845a --- /dev/null +++ b/src/helpers/vyos-merge-config.py @@ -0,0 +1,108 @@ +#!/usr/bin/python3 + +# Copyright 2019-2024 VyOS maintainers and contributors <maintainers@vyos.io> +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. If not, see <http://www.gnu.org/licenses/>. + +import sys +import tempfile +import vyos.defaults +import vyos.remote + +from vyos.config import Config +from vyos.configtree import ConfigTree +from vyos.migrate import ConfigMigrate +from vyos.migrate import ConfigMigrateError +from vyos.utils.process import cmd +from vyos.utils.process import DEVNULL + +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] + +configdir = vyos.defaults.directories['config'] + +protocols = ['scp', 'sftp', 'http', 'https', 'ftp', 'tftp'] + +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) + try: + config_migrate.run() + except ConfigMigrateError as e: + sys.exit(e) + +merge_config_tree = ConfigTree(config_file) + +effective_config = Config() +effective_config_tree = effective_config._running_config + +effective_cmds = effective_config_tree.to_commands() +merge_cmds = merge_config_tree.to_commands() + +effective_cmd_list = effective_cmds.splitlines() +merge_cmd_list = merge_cmds.splitlines() + +effective_cmd_set = set(effective_cmd_list) +add_cmds = [ cmd for cmd in merge_cmd_list if cmd not in effective_cmd_set ] + +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 path: + add_cmds = [ cmd for cmd in add_cmds if path in cmd ] + +for add in add_cmds: + try: + cmd(f'/opt/vyatta/sbin/my_{add}', shell=True, stderr=DEVNULL) + except OSError as err: + print(err) + +if effective_config.session_changed(): + print("Merge complete. Use 'commit' to make changes effective.") +else: + print("No configuration changes to commit.") diff --git a/src/helpers/vyos-save-config.py b/src/helpers/vyos-save-config.py new file mode 100644 index 0000000..fa2ea0c --- /dev/null +++ b/src/helpers/vyos-save-config.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# +import os +import re +import sys +from tempfile import NamedTemporaryFile +from argparse import ArgumentParser + +from vyos.config import Config +from vyos.remote import urlc +from vyos.component_version import add_system_version +from vyos.defaults import directories + +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') +args = parser.parse_args() +file = args.file +json_file = args.write_json_file + +if file is not None: + save_file = file +else: + save_file = DEFAULT_CONFIG_PATH + +if re.match(r'\w+:/', save_file): + try: + remote_save = urlc(save_file) + except ValueError as e: + sys.exit(e) + +config = Config() +ct = config.get_config_tree(effective=True) + +# pylint: disable=consider-using-with +write_file = 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 json_file is not None and ct is not None: + try: + with open(json_file, 'w') as f: + f.write(ct.to_json()) + except OSError as e: + print(f'failed to write JSON file: {e}') + +if remote_save is not None: + try: + remote_save.upload(write_file) + finally: + os.remove(write_file) diff --git a/src/helpers/vyos-sudo.py b/src/helpers/vyos-sudo.py new file mode 100644 index 0000000..75dd7f2 --- /dev/null +++ b/src/helpers/vyos-sudo.py @@ -0,0 +1,33 @@ +#!/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-vrrp-conntracksync.sh b/src/helpers/vyos-vrrp-conntracksync.sh new file mode 100644 index 0000000..90fa77f --- /dev/null +++ b/src/helpers/vyos-vrrp-conntracksync.sh @@ -0,0 +1,156 @@ +#!/bin/sh +# +# (C) 2008 by Pablo Neira Ayuso <pablo@netfilter.org> +# +# This software may be used and distributed according to the terms +# of the GNU General Public License, incorporated herein by reference. +# +# Description: +# +# This is the script for primary-backup setups for keepalived +# (http://www.keepalived.org). You may adapt it to make it work with other +# high-availability managers. +# +# Modified by : Mohit Mehta <mohit@vyatta.com> +# Slight modifications were made to this script for running with Vyatta +# The original script came from 0.9.14 debian conntrack-tools package + +CONNTRACKD_BIN=/usr/sbin/conntrackd +CONNTRACKD_LOCK=/var/lock/conntrack.lock +CONNTRACKD_CONFIG=/run/conntrackd/conntrackd.conf +FACILITY=daemon +LEVEL=notice +TAG=conntrack-tools +LOGCMD="logger -t $TAG -p $FACILITY.$LEVEL" +VRRP_GRP="VRRP sync-group [$2]" +FAILOVER_STATE="/var/run/vyatta-conntrackd-failover-state" + +$LOGCMD "vyos-vrrp-conntracksync invoked at `date`" + +if ! systemctl is-active --quiet conntrackd.service; then + echo "conntrackd service not running" + exit 1 +fi + +if [ ! -e $FAILOVER_STATE ]; then + mkdir -p /var/run + touch $FAILOVER_STATE +fi + +case "$1" in + master) + echo MASTER at `date` > $FAILOVER_STATE + $LOGCMD "`uname -n` transitioning to MASTER state for $VRRP_GRP" + # + # commit the external cache into the kernel table + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -c + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -c" + fi + + # + # commit the expect entries to the kernel + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -c exp + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -ce exp" + fi + + # + # flush the internal and the external caches + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -f + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -f" + fi + + # + # resynchronize my internal cache to the kernel table + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -R + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -R" + fi + + # + # send a bulk update to backups + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -B + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -B" + fi + ;; + backup) + echo BACKUP at `date` > $FAILOVER_STATE + $LOGCMD "`uname -n` transitioning to BACKUP state for $VRRP_GRP" + # + # is conntrackd running? request some statistics to check it + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -s + if [ $? -eq 1 ] + then + # + # something's wrong, do we have a lock file? + # + if [ -f $CONNTRACKD_LOCK ] + then + $LOGCMD "WARNING: conntrackd was not cleanly stopped." + $LOGCMD "If you suspect that it has crashed:" + $LOGCMD "1) Enable coredumps" + $LOGCMD "2) Try to reproduce the problem" + $LOGCMD "3) Post the coredump to netfilter-devel@vger.kernel.org" + rm -f $CONNTRACKD_LOCK + fi + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -d + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: cannot launch conntrackd" + exit 1 + fi + fi + # + # shorten kernel conntrack timers to remove the zombie entries. + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -t + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -t" + fi + + # + # request resynchronization with master firewall replica (if any) + # Note: this does nothing in the alarm approach. + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -n + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -n" + fi + ;; + fault) + echo FAULT at `date` > $FAILOVER_STATE + $LOGCMD "`uname -n` transitioning to FAULT state for $VRRP_GRP" + # + # shorten kernel conntrack timers to remove the zombie entries. + # + $CONNTRACKD_BIN -C $CONNTRACKD_CONFIG -t + if [ $? -eq 1 ] + then + $LOGCMD "ERROR: failed to invoke conntrackd -t" + fi + ;; + *) + echo UNKNOWN at `date` > $FAILOVER_STATE + $LOGCMD "ERROR: `uname -n` unknown state transition for $VRRP_GRP" + echo "Usage: vyos-vrrp-conntracksync.sh {master|backup|fault}" + exit 1 + ;; +esac + +exit 0 diff --git a/src/helpers/vyos_config_sync.py b/src/helpers/vyos_config_sync.py new file mode 100644 index 0000000..9d9aec3 --- /dev/null +++ b/src/helpers/vyos_config_sync.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023-2024 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. +# +# + +import os +import json +import requests +import urllib3 +import logging +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.template import bracketize_ipv6 + + +CONFIG_FILE = '/run/config_sync_conf.conf' + +# Logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +logger.name = os.path.basename(__file__) + +# API +API_HEADERS = {'Content-Type': 'application/json'} + + +def post_request(url: str, + data: str, + headers: Dict[str, str]) -> requests.Response: + """Sends a POST request to the specified URL + + Args: + url (str): The URL to send the POST request to. + data (Dict[str, Any]): The data to send with the POST request. + headers (Dict[str, str]): The headers to include with the POST request. + + Returns: + 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) + return response + + + +def retrieve_config(sections: List[list[str]]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Retrieves the configuration from the local server. + + Args: + sections: List[list[str]]: The list of sections of the configuration + to retrieve, given as list of paths. + + Returns: + Tuple[Dict[str, Any],Dict[str,Any]]: The tuple (mask, config) where: + - mask: The tree of paths of sections, as a dictionary. + - 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) + config_dict = json.loads(masked.to_json()) + + return mask_dict, config_dict + +def set_remote_config( + address: str, + key: str, + op: str, + mask: Dict[str, Any], + config: Dict[str, Any], + port: int) -> Optional[Dict[str, Any]]: + """Loads the VyOS configuration in JSON format to a remote host. + + Args: + address (str): The address of the remote host. + key (str): The key to use for loading the configuration. + op (str): The operation to perform (set or load). + mask (dict): The dict of paths in sections. + config (dict): The dict of masked config data. + port (int): The remote API port + + Returns: + Optional[Dict[str, Any]]: The response from the remote host as a + dictionary, or None if a RequestException occurred. + """ + + headers = {'Content-Type': 'application/json'} + + # Disable the InsecureRequestWarning + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + url = f'https://{address}:{port}/configure-section' + data = json.dumps({ + 'op': op, + 'mask': mask, + 'config': config, + 'key': key + }) + + try: + config = post_request(url, data, headers) + return config.json() + except requests.exceptions.RequestException as e: + print(f"An error occurred: {e}") + logger.error(f"An error occurred: {e}") + return None + + +def is_section_revised(section: List[str]) -> bool: + from vyos.config_mgmt import is_node_revised + return is_node_revised(section) + + +def config_sync(secondary_address: str, + secondary_key: str, + sections: List[list[str]], + mode: str, + secondary_port: int): + """Retrieve a config section from primary router in JSON format and send it to + secondary router + """ + if not any(map(is_section_revised, sections)): + return + + logger.info( + f"Config synchronization: Mode={mode}, Secondary={secondary_address}" + ) + + # Sync sections ("nat", "firewall", etc) + mask_dict, config_dict = retrieve_config(sections) + logger.debug( + f"Retrieved config for sections '{sections}': {config_dict}") + + set_config = set_remote_config(address=secondary_address, + key=secondary_key, + op=mode, + mask=mask_dict, + config=config_dict, + port=secondary_port) + + logger.debug(f"Set config for sections '{sections}': {set_config}") + + +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) + + with open(CONFIG_FILE, 'r') as f: + config_data = f.read() + + config = json.loads(config_data) + + mode = config.get('mode') + secondary_address = config.get('secondary', {}).get('address') + secondary_address = bracketize_ipv6(secondary_address) + secondary_key = config.get('secondary', {}).get('key') + secondary_port = int(config.get('secondary', {}).get('port', 443)) + sections = config.get('section') + timeout = int(config.get('secondary', {}).get('timeout')) + + if not all([mode, secondary_address, secondary_key, sections]): + logger.error("Missing required configuration data for config synchronization.") + exit(0) + + # Generate list_sections of sections/subsections + # [ + # ['interfaces', 'pseudo-ethernet'], ['interfaces', 'virtual-ethernet'], ['nat'], ['nat66'] + # ] + list_sections = [] + for section, subsections in sections.items(): + if subsections: + for subsection in subsections: + list_sections.append([section, subsection]) + else: + list_sections.append([section]) + + config_sync(secondary_address, secondary_key, list_sections, mode, secondary_port) diff --git a/src/helpers/vyos_net_name b/src/helpers/vyos_net_name new file mode 100644 index 0000000..f5de182 --- /dev/null +++ b/src/helpers/vyos_net_name @@ -0,0 +1,276 @@ +#!/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') |
