diff options
Diffstat (limited to 'src/helpers')
-rwxr-xr-x | src/helpers/vyos-config-encrypt.py | 276 | ||||
-rwxr-xr-x | src/helpers/vyos_config_sync.py | 26 |
2 files changed, 293 insertions, 9 deletions
diff --git a/src/helpers/vyos-config-encrypt.py b/src/helpers/vyos-config-encrypt.py new file mode 100755 index 000000000..8f7359767 --- /dev/null +++ b/src/helpers/vyos-config-encrypt.py @@ -0,0 +1,276 @@ +#!/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 re +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 init_tpm +from vyos.tpm import read_tpm_key +from vyos.tpm import write_tpm_key +from vyos.util import ask_input +from vyos.util import ask_yes_no +from vyos.util 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_config_sync.py b/src/helpers/vyos_config_sync.py index 7cfa8fe88..572fea61f 100755 --- a/src/helpers/vyos_config_sync.py +++ b/src/helpers/vyos_config_sync.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright (C) 2023 VyOS maintainers and contributors +# 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 @@ -60,6 +60,7 @@ def post_request(url: str, return response + def retrieve_config(section: str = None) -> Optional[Dict[str, Any]]: """Retrieves the configuration from the local server. @@ -71,8 +72,6 @@ def retrieve_config(section: str = None) -> Optional[Dict[str, Any]]: """ if section is None: section = [] - else: - section = section.split() conf = Config() config = conf.get_config_dict(section, get_first_key=True) @@ -101,8 +100,6 @@ def set_remote_config( if path is None: path = [] - else: - path = path.split() headers = {'Content-Type': 'application/json'} # Disable the InsecureRequestWarning @@ -127,17 +124,16 @@ def set_remote_config( def is_section_revised(section: str) -> bool: from vyos.config_mgmt import is_node_revised - return is_node_revised([section]) + return is_node_revised(section) def config_sync(secondary_address: str, secondary_key: str, - sections: List[str], + sections: List[list], mode: str): """Retrieve a config section from primary router in JSON format and send it to secondary router """ - # Config sync only if sections changed if not any(map(is_section_revised, sections)): return @@ -188,5 +184,17 @@ if __name__ == '__main__': "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, - sections, mode) + list_sections, mode) |