diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/conf_mode/firewall.py | 9 | ||||
-rwxr-xr-x | src/conf_mode/high-availability.py | 37 | ||||
-rwxr-xr-x | src/conf_mode/nat.py | 18 | ||||
-rwxr-xr-x | src/helpers/vyos-config-encrypt.py | 276 | ||||
-rwxr-xr-x | src/init/vyos-router | 71 | ||||
-rwxr-xr-x | src/op_mode/clear_dhcp_lease.py | 88 | ||||
-rwxr-xr-x | src/op_mode/dhcp.py | 23 | ||||
-rwxr-xr-x | src/op_mode/image_installer.py | 79 | ||||
-rwxr-xr-x | src/op_mode/image_manager.py | 21 | ||||
-rwxr-xr-x | src/services/vyos-http-api-server | 3 |
10 files changed, 505 insertions, 120 deletions
diff --git a/src/conf_mode/firewall.py b/src/conf_mode/firewall.py index acb7dfa41..3c27655b0 100755 --- a/src/conf_mode/firewall.py +++ b/src/conf_mode/firewall.py @@ -282,6 +282,15 @@ def verify_rule(firewall, rule_conf, ipv6): if direction in rule_conf: if 'name' in rule_conf[direction] and 'group' in rule_conf[direction]: raise ConfigError(f'Cannot specify both interface group and interface name for {direction}') + if 'group' in rule_conf[direction]: + group_name = rule_conf[direction]['group'] + if group_name[0] == '!': + group_name = group_name[1:] + group_obj = dict_search_args(firewall, 'group', 'interface_group', group_name) + if group_obj is None: + raise ConfigError(f'Invalid interface group "{group_name}" on firewall rule') + if not group_obj: + Warning(f'interface-group "{group_name}" has no members!') def verify_nested_group(group_name, group, groups, seen): if 'include' not in group: diff --git a/src/conf_mode/high-availability.py b/src/conf_mode/high-availability.py index 59d49ea67..c726db8b2 100755 --- a/src/conf_mode/high-availability.py +++ b/src/conf_mode/high-availability.py @@ -86,16 +86,7 @@ def verify(ha): raise ConfigError(f'Authentication requires both type and passwortd to be set in VRRP group "{group}"') if 'health_check' in group_config: - health_check_types = ["script", "ping"] - from vyos.utils.dict import check_mutually_exclusive_options - try: - check_mutually_exclusive_options(group_config["health_check"], health_check_types, required=True) - except ValueError: - Warning(f'Health check configuration for VRRP group "{group}" will remain unused ' \ - f'until it has one of the following options: {health_check_types}') - # XXX: health check has default options so we need to remove it - # to avoid generating useless config statements in keepalived.conf - del group_config["health_check"] + _validate_health_check(group, group_config) # Keepalived doesn't allow mixing IPv4 and IPv6 in one group, so we mirror that restriction # We also need to make sure VRID is not used twice on the same interface with the @@ -146,11 +137,22 @@ def verify(ha): # Check sync groups if 'vrrp' in ha and 'sync_group' in ha['vrrp']: for sync_group, sync_config in ha['vrrp']['sync_group'].items(): + if 'health_check' in sync_config: + _validate_health_check(sync_group, sync_config) + if 'member' in sync_config: for member in sync_config['member']: if member not in ha['vrrp']['group']: raise ConfigError(f'VRRP sync-group "{sync_group}" refers to VRRP group "{member}", '\ 'but it does not exist!') + else: + ha['vrrp']['group'][member]['_is_sync_group_member'] = True + if ha['vrrp']['group'][member].get('health_check') is not None: + raise ConfigError( + f'Health check configuration for VRRP group "{member}" will remain unused ' + f'while it has member of sync group "{sync_group}" ' + f'Only sync group health check will be used' + ) # Virtual-server if 'virtual_server' in ha: @@ -172,6 +174,21 @@ def verify(ha): raise ConfigError(f'Port is required but not set for virtual-server "{vs}" real-server "{rs}"') +def _validate_health_check(group, group_config): + health_check_types = ["script", "ping"] + from vyos.utils.dict import check_mutually_exclusive_options + try: + check_mutually_exclusive_options(group_config["health_check"], + health_check_types, required=True) + except ValueError: + Warning( + f'Health check configuration for VRRP group "{group}" will remain unused ' \ + f'until it has one of the following options: {health_check_types}') + # XXX: health check has default options so we need to remove it + # to avoid generating useless config statements in keepalived.conf + del group_config["health_check"] + + def generate(ha): if not ha or 'disable' in ha: if os.path.isfile(systemd_override): diff --git a/src/conf_mode/nat.py b/src/conf_mode/nat.py index 26822b755..b3f38c04a 100755 --- a/src/conf_mode/nat.py +++ b/src/conf_mode/nat.py @@ -153,6 +153,15 @@ def verify(nat): elif 'name' in config['outbound_interface']: if config['outbound_interface']['name'] not in 'any' and config['outbound_interface']['name'] not in interfaces(): Warning(f'NAT interface "{config["outbound_interface"]["name"]}" for source NAT rule "{rule}" does not exist!') + else: + group_name = config['outbound_interface']['group'] + if group_name[0] == '!': + group_name = group_name[1:] + group_obj = dict_search_args(nat['firewall_group'], 'interface_group', group_name) + if group_obj is None: + raise ConfigError(f'Invalid interface group "{group_name}" on source nat rule') + if not group_obj: + Warning(f'interface-group "{group_name}" has no members!') if not dict_search('translation.address', config) and not dict_search('translation.port', config): if 'exclude' not in config and 'backend' not in config['load_balance']: @@ -177,6 +186,15 @@ def verify(nat): elif 'name' in config['inbound_interface']: if config['inbound_interface']['name'] not in 'any' and config['inbound_interface']['name'] not in interfaces(): Warning(f'NAT interface "{config["inbound_interface"]["name"]}" for destination NAT rule "{rule}" does not exist!') + else: + group_name = config['inbound_interface']['group'] + if group_name[0] == '!': + group_name = group_name[1:] + group_obj = dict_search_args(nat['firewall_group'], 'interface_group', group_name) + if group_obj is None: + raise ConfigError(f'Invalid interface group "{group_name}" on destination nat rule') + if not group_obj: + Warning(f'interface-group "{group_name}" has no members!') if not dict_search('translation.address', config) and not dict_search('translation.port', config) and 'redirect' not in config['translation']: if 'exclude' not in config and 'backend' not in config['load_balance']: 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/init/vyos-router b/src/init/vyos-router index eac3e7e47..adf892371 100755 --- a/src/init/vyos-router +++ b/src/init/vyos-router @@ -64,6 +64,69 @@ disabled () { grep -q -w no-vyos-$1 /proc/cmdline } +# Load encrypted config volume +mount_encrypted_config() { + persist_path=$(/opt/vyatta/sbin/vyos-persistpath) + if [ $? == 0 ]; then + if [ -e $persist_path/boot ]; then + image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//') + + if [ -z "$image_name" ]; then + return + fi + + if [ ! -f $persist_path/luks/$image_name ]; then + return + fi + + vyos_tpm_key=$(python3 -c 'from vyos.tpm import read_tpm_key; print(read_tpm_key().decode())' 2>/dev/null) + + if [ $? -ne 0 ]; then + echo "ERROR: Failed to fetch encryption key from TPM. Encrypted config volume has not been mounted" + echo "Use 'encryption load' to load volume with recovery key" + echo "or 'encryption disable' to decrypt volume with recovery key" + return + fi + + echo $vyos_tpm_key | tr -d '\r\n' | cryptsetup open $persist_path/luks/$image_name vyos_config --key-file=- + + if [ $? -ne 0 ]; then + echo "ERROR: Failed to decrypt config volume. Encrypted config volume has not been mounted" + echo "Use 'encryption load' to load volume with recovery key" + echo "or 'encryption disable' to decrypt volume with recovery key" + return + fi + + mount /dev/mapper/vyos_config /config + mount /dev/mapper/vyos_config $vyatta_sysconfdir/config + + echo "Mounted encrypted config volume" + fi + fi +} + +unmount_encrypted_config() { + persist_path=$(/opt/vyatta/sbin/vyos-persistpath) + if [ $? == 0 ]; then + if [ -e $persist_path/boot ]; then + image_name=$(cat /proc/cmdline | sed -e s+^.*vyos-union=/boot/++ | sed -e 's/ .*$//') + + if [ -z "$image_name" ]; then + return + fi + + if [ ! -f $persist_path/luks/$image_name ]; then + return + fi + + umount /config + umount $vyatta_sysconfdir/config + + cryptsetup close vyos_config + fi + fi +} + # if necessary, provide initial config init_bootfile () { if [ ! -r $BOOTFILE ] ; then @@ -218,8 +281,8 @@ cleanup_post_commit_hooks () { # note that this approach only supports hooks that are "configured", # i.e., it does not support hooks that need to always be present. cpostdir=$(cli-shell-api getPostCommitHookDir) - # exclude commits hooks from vyatta-cfg - excluded="10vyatta-log-commit.pl 99vyos-user-postcommit-hooks" + # exclude commit hooks that need to always be present + excluded="00vyos-sync 10vyatta-log-commit.pl 99vyos-user-postcommit-hooks" if [ -d "$cpostdir" ]; then for f in $cpostdir/*; do if [[ ! $excluded =~ $(basename $f) ]]; then @@ -402,6 +465,8 @@ start () && chgrp ${GROUP} ${vyatta_configdir} log_action_end_msg $? + mount_encrypted_config + # T5239: early read of system hostname as this value is read-only once during # FRR initialisation tmp=$(${vyos_libexec_dir}/read-saved-value.py --path "system host-name") @@ -470,6 +535,8 @@ stop() log_action_end_msg $? systemctl stop frr.service + + unmount_encrypted_config } case "$action" in diff --git a/src/op_mode/clear_dhcp_lease.py b/src/op_mode/clear_dhcp_lease.py deleted file mode 100755 index 7d4b47104..000000000 --- a/src/op_mode/clear_dhcp_lease.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. If not, see <http://www.gnu.org/licenses/>. - -import argparse -import re - -from vyos.configquery import ConfigTreeQuery -from vyos.kea import kea_parse_leases -from vyos.utils.io import ask_yes_no -from vyos.utils.process import call -from vyos.utils.commit import commit_in_progress - -# TODO: Update to use Kea control socket command "lease4-del" - -config = ConfigTreeQuery() -base = ['service', 'dhcp-server'] -lease_file = '/config/dhcp/dhcp4-leases.csv' - - -def del_lease_ip(address): - """ - Read lease_file and write data to this file - without specific section "lease ip" - Delete section "lease x.x.x.x { x;x;x; }" - """ - with open(lease_file, encoding='utf-8') as f: - data = f.read().rstrip() - pattern = rf"^{address},[^\n]+\n" - # Delete lease for ip block - data = re.sub(pattern, '', data) - - # Write new data to original lease_file - with open(lease_file, 'w', encoding='utf-8') as f: - f.write(data) - -def is_ip_in_leases(address): - """ - Return True if address found in the lease file - """ - leases = kea_parse_leases(lease_file) - for lease in leases: - if address == lease['address']: - return True - print(f'Address "{address}" not found in "{lease_file}"') - return False - -if not config.exists(base): - print('DHCP-server not configured!') - exit(0) - -if config.exists(base + ['failover']): - print('Lease cannot be reset in failover mode!') - exit(0) - - -if __name__ == '__main__': - parser = argparse.ArgumentParser() - parser.add_argument('--ip', help='IPv4 address', action='store', required=True) - - args = parser.parse_args() - address = args.ip - - if not is_ip_in_leases(address): - exit(1) - - if commit_in_progress(): - print('Cannot clear DHCP lease while a commit is in progress') - exit(1) - - if not ask_yes_no(f'This will restart DHCP server.\nContinue?'): - exit(1) - else: - del_lease_ip(address) - call('systemctl restart kea-dhcp4-server.service') diff --git a/src/op_mode/dhcp.py b/src/op_mode/dhcp.py index 1d9ad0e76..d27e1baf7 100755 --- a/src/op_mode/dhcp.py +++ b/src/op_mode/dhcp.py @@ -31,6 +31,7 @@ from vyos.configquery import ConfigTreeQuery from vyos.kea import kea_get_active_config from vyos.kea import kea_get_leases from vyos.kea import kea_get_pool_from_subnet_id +from vyos.kea import kea_delete_lease from vyos.utils.process import is_systemd_service_running time_string = "%a %b %d %H:%M:%S %Z %Y" @@ -360,6 +361,28 @@ def show_server_static_mappings(raw: bool, family: ArgFamily, pool: typing.Optio else: return _get_formatted_server_static_mappings(static_mappings, family=family) +def _lease_valid(inet, address): + leases = kea_get_leases(inet) + for lease in leases: + if address == lease['ip-address']: + return True + return False + +@_verify +def clear_dhcp_server_lease(family: ArgFamily, address: str): + v = 'v6' if family == 'inet6' else '' + inet = '6' if family == 'inet6' else '4' + + if not _lease_valid(inet, address): + print(f'Lease not found on DHCP{v} server') + return None + + if not kea_delete_lease(inet, address): + print(f'Failed to clear lease for "{address}"') + return None + + print(f'Lease "{address}" has been cleared') + def _get_raw_client_leases(family='inet', interface=None): from time import mktime from datetime import datetime diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index d677c2cf8..85ebd19ba 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -65,6 +65,8 @@ MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root part MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial, U: USB-Serial)?' MSG_INPUT_COPY_DATA: str = 'Would you like to copy data to the new image?' MSG_INPUT_CHOOSE_COPY_DATA: str = 'From which image would you like to save config information?' +MSG_INPUT_COPY_ENC_DATA: str = 'Would you like to copy the encrypted config to the new image?' +MSG_INPUT_CHOOSE_COPY_ENC_DATA: str = 'From which image would you like to copy the encrypted config?' MSG_WARN_ISO_SIGN_INVALID: str = 'Signature is not valid. Do you want to continue with installation?' MSG_WARN_ISO_SIGN_UNAVAL: str = 'Signature is not available. Do you want to continue with installation?' MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.' @@ -212,14 +214,17 @@ def search_previous_installation(disks: list[str]) -> None: disks (list[str]): a list of available disks """ mnt_config = '/mnt/config' + mnt_encrypted_config = '/mnt/encrypted_config' mnt_ssh = '/mnt/ssh' mnt_tmp = '/mnt/tmp' rmtree(Path(mnt_config), ignore_errors=True) rmtree(Path(mnt_ssh), ignore_errors=True) Path(mnt_tmp).mkdir(exist_ok=True) + Path(mnt_encrypted_config).unlink(missing_ok=True) print('Searching for data from previous installations') image_data = [] + encrypted_configs = [] for disk_name in disks: for partition in disk.partition_list(disk_name): if disk.partition_mount(partition, mnt_tmp): @@ -227,32 +232,61 @@ def search_previous_installation(disks: list[str]) -> None: for path in Path(mnt_tmp + '/boot').iterdir(): if path.joinpath('rw/config/.vyatta_config').exists(): image_data.append((path.name, partition)) + if Path(mnt_tmp + '/luks').exists(): + for path in Path(mnt_tmp + '/luks').iterdir(): + encrypted_configs.append((path.name, partition)) disk.partition_umount(partition) - if len(image_data) == 1: - image_name, image_drive = image_data[0] - print('Found data from previous installation:') - print(f'\t{image_name} on {image_drive}') - if not ask_yes_no(MSG_INPUT_COPY_DATA, default=True): - return - - elif len(image_data) > 1: - print('Found data from previous installations') - if not ask_yes_no(MSG_INPUT_COPY_DATA, default=True): - return - - image_name, image_drive = select_entry(image_data, - 'Available versions:', - MSG_INPUT_CHOOSE_COPY_DATA, - search_format_selection) + image_name = None + image_drive = None + encrypted = False + + if len(image_data) > 0: + if len(image_data) == 1: + print('Found data from previous installation:') + print(f'\t{" on ".join(image_data[0])}') + if ask_yes_no(MSG_INPUT_COPY_DATA, default=True): + image_name, image_drive = image_data[0] + + elif len(image_data) > 1: + print('Found data from previous installations') + if ask_yes_no(MSG_INPUT_COPY_DATA, default=True): + image_name, image_drive = select_entry(image_data, + 'Available versions:', + MSG_INPUT_CHOOSE_COPY_DATA, + search_format_selection) + elif len(encrypted_configs) > 0: + if len(encrypted_configs) == 1: + print('Found encrypted config from previous installation:') + print(f'\t{" on ".join(encrypted_configs[0])}') + if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True): + image_name, image_drive = encrypted_configs[0] + encrypted = True + + elif len(encrypted_configs) > 1: + print('Found encrypted configs from previous installations') + if ask_yes_no(MSG_INPUT_COPY_ENC_DATA, default=True): + image_name, image_drive = select_entry(encrypted_configs, + 'Available versions:', + MSG_INPUT_CHOOSE_COPY_ENC_DATA, + search_format_selection) + encrypted = True + else: print('No previous installation found') return + if not image_name: + return + disk.partition_mount(image_drive, mnt_tmp) - copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + if not encrypted: + copytree(f'{mnt_tmp}/boot/{image_name}/rw/config', mnt_config) + else: + copy(f'{mnt_tmp}/luks/{image_name}', mnt_encrypted_config) + Path(mnt_ssh).mkdir() host_keys: list[str] = glob(f'{mnt_tmp}/boot/{image_name}/rw/etc/ssh/ssh_host*') for host_key in host_keys: @@ -279,6 +313,12 @@ def copy_previous_installation_data(target_dir: str) -> None: dirs_exist_ok=True) +def copy_previous_encrypted_config(target_dir: str, image_name: str) -> None: + if Path('/mnt/encrypted_config').exists(): + Path(target_dir).mkdir(exist_ok=True) + copy('/mnt/encrypted_config', Path(target_dir).joinpath(image_name)) + + def ask_single_disk(disks_available: dict[str, int]) -> str: """Ask user to select a disk for installation @@ -712,6 +752,9 @@ def install_image() -> None: # owner restored on copy of config data by chmod_2775, above copy_previous_installation_data(f'{DIR_DST_ROOT}/boot/{image_name}/rw') + # copy saved encrypted config volume + copy_previous_encrypted_config(f'{DIR_DST_ROOT}/luks', image_name) + if is_raid_install(install_target): write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw' raid.update_default(write_dir) diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py index e64a85b95..1510a667c 100755 --- a/src/op_mode/image_manager.py +++ b/src/op_mode/image_manager.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io> +# Copyright 2023-2024 VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # @@ -95,6 +95,15 @@ def delete_image(image_name: Optional[str] = None, except Exception as err: exit(f'Unable to remove the image "{image_name}": {err}') + # remove LUKS volume if it exists + luks_path: Path = Path(f'{persistence_storage}/luks/{image_name}') + if luks_path.is_file(): + try: + luks_path.unlink() + print(f'The encrypted config for "{image_name}" was successfully deleted') + except Exception as err: + exit(f'Unable to remove the encrypted config for "{image_name}": {err}') + @compat.grub_cfg_update def set_image(image_name: Optional[str] = None, @@ -174,6 +183,16 @@ def rename_image(name_old: str, name_new: str) -> None: except Exception as err: exit(f'Unable to rename image "{name_old}" to "{name_new}": {err}') + # rename LUKS volume if it exists + old_luks_path: Path = Path(f'{persistence_storage}/luks/{name_old}') + if old_luks_path.is_file(): + try: + new_luks_path: Path = Path(f'{persistence_storage}/luks/{name_new}') + old_luks_path.rename(new_luks_path) + print(f'The encrypted config for "{name_old}" was successfully renamed to "{name_new}"') + except Exception as err: + exit(f'Unable to rename the encrypted config for "{name_old}" to "{name_new}": {err}') + def list_images() -> None: """Print list of available images for CLI hints""" diff --git a/src/services/vyos-http-api-server b/src/services/vyos-http-api-server index 40d442e30..a7b14a1a3 100755 --- a/src/services/vyos-http-api-server +++ b/src/services/vyos-http-api-server @@ -459,7 +459,6 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel, request: Request, background_tasks: BackgroundTasks): session = app.state.vyos_session env = session.get_session_env() - config = Config(session_env=env) endpoint = request.url.path @@ -474,6 +473,8 @@ def _configure_op(data: Union[ConfigureModel, ConfigureListModel, # so the lock is really global lock.acquire() + config = Config(session_env=env) + status = 200 msg = None error_msg = None |