summaryrefslogtreecommitdiff
path: root/src/helpers
diff options
context:
space:
mode:
Diffstat (limited to 'src/helpers')
-rw-r--r--src/helpers/add-system-version.py20
-rw-r--r--src/helpers/commit-confirm-notify.py31
-rw-r--r--src/helpers/config_dependency.py113
-rw-r--r--src/helpers/geoip-update.py44
-rw-r--r--src/helpers/priority.py42
-rw-r--r--src/helpers/read-saved-value.py30
-rw-r--r--src/helpers/run-config-activation.py83
-rw-r--r--src/helpers/run-config-migration.py78
-rw-r--r--src/helpers/simple-download.py20
-rw-r--r--src/helpers/strip-private.py153
-rw-r--r--src/helpers/vyos-boot-config-loader.py179
-rw-r--r--src/helpers/vyos-certbot-renew-pki.sh3
-rw-r--r--src/helpers/vyos-check-wwan.py35
-rw-r--r--src/helpers/vyos-config-encrypt.py273
-rw-r--r--src/helpers/vyos-domain-resolver.py178
-rw-r--r--src/helpers/vyos-failover.py235
-rw-r--r--src/helpers/vyos-interface-rescan.py206
-rw-r--r--src/helpers/vyos-load-config.py99
-rw-r--r--src/helpers/vyos-merge-config.py108
-rw-r--r--src/helpers/vyos-save-config.py72
-rw-r--r--src/helpers/vyos-sudo.py33
-rw-r--r--src/helpers/vyos-vrrp-conntracksync.sh156
-rw-r--r--src/helpers/vyos_config_sync.py205
-rw-r--r--src/helpers/vyos_net_name276
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')