summaryrefslogtreecommitdiff
path: root/src/op_mode/image_installer.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/op_mode/image_installer.py')
-rwxr-xr-xsrc/op_mode/image_installer.py193
1 files changed, 141 insertions, 52 deletions
diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py
index 1da112673..ac5a84419 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-2024 VyOS maintainers and contributors <maintainers@vyos.io>
+# Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io>
#
# This file is part of VyOS.
#
@@ -24,7 +24,9 @@ from glob import glob
from sys import exit
from os import environ
from os import readlink
-from os import getpid, getppid
+from os import getpid
+from os import getppid
+from json import loads
from typing import Union
from urllib.parse import urlparse
from passlib.hosts import linux_context
@@ -32,12 +34,26 @@ from errno import ENOSPC
from psutil import disk_partitions
+from vyos.base import Warning
from vyos.configtree import ConfigTree
from vyos.remote import download
-from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER
+from vyos.system import disk
+from vyos.system import grub
+from vyos.system import image
+from vyos.system import compat
+from vyos.system import raid
+from vyos.system import SYSTEM_CFG_VER
+from vyos.system import grub_util
from vyos.template import render
+from vyos.utils.auth import (
+ DEFAULT_PASSWORD,
+ EPasswdStrength,
+ evaluate_strength
+)
+from vyos.utils.dict import dict_search
from vyos.utils.io import ask_input, ask_yes_no, select_entry
from vyos.utils.file import chmod_2775
+from vyos.utils.file import read_file
from vyos.utils.process import cmd, run, rc_cmd
from vyos.version import get_version_data
@@ -46,7 +62,13 @@ MSG_ERR_NOT_LIVE: str = 'The system is already installed. Please use "add system
MSG_ERR_LIVE: str = 'The system is in live-boot mode. Please use "install image" instead.'
MSG_ERR_NO_DISK: str = 'No suitable disk was found. There must be at least one disk of 2GB or greater size.'
MSG_ERR_IMPROPER_IMAGE: str = 'Missing sha256sum.txt.\nEither this image is corrupted, or of era 1.2.x (md5sum) and would downgrade image tools;\ndisallowed in either case.'
-MSG_ERR_ARCHITECTURE_MISMATCH: str = 'Upgrading to a different image architecture will break your system.'
+MSG_ERR_INCOMPATIBLE_IMAGE: str = 'Image compatibility check failed, aborting installation.'
+MSG_ERR_ARCHITECTURE_MISMATCH: str = 'The current architecture is "{0}", the new image is for "{1}". Upgrading to a different image architecture will break your system.'
+MSG_ERR_FLAVOR_MISMATCH: str = 'The current image flavor is "{0}", the new image is "{1}". Upgrading to a non-matching flavor can have unpredictable consequences.'
+MSG_ERR_MISSING_ARCHITECTURE: str = 'The new image version data does not specify architecture, cannot check compatibility (is it a legacy release image?)'
+MSG_ERR_MISSING_FLAVOR: str = 'The new image version data does not specify flavor, cannot check compatibility (is it a legacy release image?)'
+MSG_ERR_CORRUPT_CURRENT_IMAGE: str = 'Version data in the current image is malformed: missing flavor and/or architecture fields. Upgrade compatibility cannot be checked.'
+MSG_ERR_UNSUPPORTED_SIGNATURE_TYPE: str = 'Unsupported signature type, signature cannot be verified.'
MSG_INFO_INSTALL_WELCOME: str = 'Welcome to VyOS installation!\nThis command will install VyOS to your permanent storage.'
MSG_INFO_INSTALL_EXIT: str = 'Exiting from VyOS installation'
MSG_INFO_INSTALL_SUCCESS: str = 'The image installed successfully; please reboot now.'
@@ -62,6 +84,7 @@ MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like
MSG_INPUT_CONFIG_CHOICE: str = 'The following config files are available for boot:'
MSG_INPUT_CONFIG_CHOOSE: str = 'Which file would you like as boot config?'
MSG_INPUT_IMAGE_NAME: str = 'What would you like to name this image?'
+MSG_INPUT_IMAGE_NAME_TAKEN: str = 'There is already an installed image by that name; please choose again'
MSG_INPUT_IMAGE_DEFAULT: str = 'Would you like to set the new image as the default one for boot?'
MSG_INPUT_PASSWORD: str = 'Please enter a password for the "vyos" user:'
MSG_INPUT_PASSWORD_CONFIRM: str = 'Please confirm password for the "vyos" user:'
@@ -78,8 +101,10 @@ MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.'
MSG_WARN_ROOT_SIZE_TOOSMALL: str = 'The size is too small. Try again'
MSG_WARN_IMAGE_NAME_WRONG: str = 'The suggested name is unsupported!\n'\
'It must be between 1 and 64 characters long and contains only the next characters: .+-_ a-z A-Z 0-9'
+
+MSG_WARN_CHANGE_PASSWORD: str = 'Default password used. Consider changing ' \
+ 'it on next login.'
MSG_WARN_PASSWORD_CONFIRM: str = 'The entered values did not match. Try again'
-MSG_WARN_FLAVOR_MISMATCH: str = 'The running image flavor is "{0}". The new image flavor is "{1}".\n' \
'Installing a different image flavor may cause functionality degradation or break your system.\n' \
'Do you want to continue with installation?'
CONST_MIN_DISK_SIZE: int = 2147483648 # 2 GB
@@ -95,7 +120,7 @@ DIR_ISO_MOUNT: str = f'{DIR_INSTALLATION}/iso_src'
DIR_DST_ROOT: str = f'{DIR_INSTALLATION}/disk_dst'
DIR_KERNEL_SRC: str = '/boot/'
FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs'
-ISO_DOWNLOAD_PATH: str = '/tmp/vyos_installation.iso'
+ISO_DOWNLOAD_PATH: str = ''
external_download_script = '/usr/libexec/vyos/simple-download.py'
external_latest_image_url_script = '/usr/libexec/vyos/latest-image-url.py'
@@ -462,6 +487,29 @@ def setup_grub(root_dir: str) -> None:
render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {})
render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {})
+def get_cli_kernel_options(config_file: str) -> list:
+ config = ConfigTree(read_file(config_file))
+ config_dict = loads(config.to_json())
+ kernel_options = dict_search('system.option.kernel', config_dict)
+ if kernel_options is None:
+ kernel_options = {}
+ cmdline_options = []
+
+ # XXX: This code path and if statements must be kept in sync with the Kernel
+ # option handling in system_options.py:generate(). This occurance is used
+ # for having the appropriate options passed to GRUB after an image upgrade!
+ if 'disable-mitigations' in kernel_options:
+ cmdline_options.append('mitigations=off')
+ if 'disable-power-saving' in kernel_options:
+ cmdline_options.append('intel_idle.max_cstate=0 processor.max_cstate=1')
+ if 'amd-pstate-driver' in kernel_options:
+ mode = kernel_options['amd-pstate-driver']
+ cmdline_options.append(
+ f'initcall_blacklist=acpi_cpufreq_init amd_pstate={mode}')
+ if 'quiet' in kernel_options:
+ cmdline_options.append('quiet')
+
+ return cmdline_options
def configure_authentication(config_file: str, password: str) -> None:
"""Write encrypted password to config file
@@ -476,10 +524,7 @@ def configure_authentication(config_file: str, password: str) -> None:
plaintext exposed
"""
encrypted_password = linux_context.hash(password)
-
- with open(config_file) as f:
- config_string = f.read()
-
+ config_string = read_file(config_file)
config = ConfigTree(config_string)
config.set([
'system', 'login', 'user', 'vyos', 'authentication',
@@ -501,7 +546,6 @@ def validate_signature(file_path: str, sign_type: str) -> None:
"""
print('Validating signature')
signature_valid: bool = False
- # validate with minisig
if sign_type == 'minisig':
pub_key_list = glob('/usr/share/vyos/keys/*.minisign.pub')
for pubkey in pub_key_list:
@@ -510,11 +554,8 @@ def validate_signature(file_path: str, sign_type: str) -> None:
signature_valid = True
break
Path(f'{file_path}.minisig').unlink()
- # validate with GPG
- if sign_type == 'asc':
- if run(f'gpg --verify ${file_path}.asc ${file_path}') == 0:
- signature_valid = True
- Path(f'{file_path}.asc').unlink()
+ else:
+ exit(MSG_ERR_UNSUPPORTED_SIGNATURE_TYPE)
# warn or pass
if not signature_valid:
@@ -524,21 +565,18 @@ def validate_signature(file_path: str, sign_type: str) -> None:
print('Signature is valid')
def download_file(local_file: str, remote_path: str, vrf: str,
- username: str, password: str,
progressbar: bool = False, check_space: bool = False):
- environ['REMOTE_USERNAME'] = username
- environ['REMOTE_PASSWORD'] = password
+ # Server credentials are implicitly passed in environment variables
+ # that are set by add_image
if vrf is None:
download(local_file, remote_path, progressbar=progressbar,
check_space=check_space, raise_error=True)
else:
- remote_auth = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password}'
vrf_cmd = f'ip vrf exec {vrf} {external_download_script} \
--local-file {local_file} --remote-path {remote_path}'
- cmd(vrf_cmd, auth=remote_auth)
+ cmd(vrf_cmd, env=environ)
def image_fetch(image_path: str, vrf: str = None,
- username: str = '', password: str = '',
no_prompt: bool = False) -> Path:
"""Fetch an ISO image
@@ -548,13 +586,17 @@ def image_fetch(image_path: str, vrf: str = None,
Returns:
Path: a path to a local file
"""
+ import os.path
+ from uuid import uuid4
+
+ global ISO_DOWNLOAD_PATH
+
# Latest version gets url from configured "system update-check url"
if image_path == 'latest':
command = external_latest_image_url_script
if vrf:
- command = f'REMOTE_USERNAME={username} REMOTE_PASSWORD={password} \
- ip vrf exec {vrf} ' + command
- code, output = rc_cmd(command)
+ command = f'ip vrf exec {vrf} {command}'
+ code, output = rc_cmd(command, env=environ)
if code:
print(output)
exit(MSG_INFO_INSTALL_EXIT)
@@ -563,23 +605,25 @@ def image_fetch(image_path: str, vrf: str = None,
try:
# check a type of path
if urlparse(image_path).scheme:
- # download an image
+ # Download the image file
+ ISO_DOWNLOAD_PATH = os.path.join(os.path.expanduser("~"), '{0}.iso'.format(uuid4()))
download_file(ISO_DOWNLOAD_PATH, image_path, vrf,
- username, password,
progressbar=True, check_space=True)
- # download a signature
+ # Download the image signature
+ # VyOS only supports minisign signatures at the moment,
+ # but we keep the logic for multiple signatures
+ # in case we add something new in the future
sign_file = (False, '')
- for sign_type in ['minisig', 'asc']:
+ for sign_type in ['minisig']:
try:
download_file(f'{ISO_DOWNLOAD_PATH}.{sign_type}',
- f'{image_path}.{sign_type}', vrf,
- username, password)
+ f'{image_path}.{sign_type}', vrf)
sign_file = (True, sign_type)
break
except Exception:
- print(f'{sign_type} signature is not available')
- # validate a signature if it is available
+ print(f'Could not download {sign_type} signature')
+ # Validate the signature if it is available
if sign_file[0]:
validate_signature(ISO_DOWNLOAD_PATH, sign_file[1])
else:
@@ -701,30 +745,48 @@ def is_raid_install(install_object: Union[disk.DiskDetails, raid.RaidDetails]) -
return False
-def validate_compatibility(iso_path: str) -> None:
+def validate_compatibility(iso_path: str, force: bool = False) -> None:
"""Check architecture and flavor compatibility with the running image
Args:
iso_path (str): a path to the mounted ISO image
"""
- old_data = get_version_data()
- old_flavor = old_data.get('flavor', '')
- old_architecture = old_data.get('architecture') or cmd('dpkg --print-architecture')
+ current_data = get_version_data()
+ current_flavor = current_data.get('flavor')
+ current_architecture = current_data.get('architecture') or cmd('dpkg --print-architecture')
new_data = get_version_data(f'{iso_path}/version.json')
- new_flavor = new_data.get('flavor', '')
- new_architecture = new_data.get('architecture', '')
+ new_flavor = new_data.get('flavor')
+ new_architecture = new_data.get('architecture')
- if not old_architecture == new_architecture:
- print(MSG_ERR_ARCHITECTURE_MISMATCH)
+ if not current_flavor or not current_architecture:
+ # This may only happen if someone modified the version file.
+ # Unlikely but not impossible.
+ print(MSG_ERR_CORRUPT_CURRENT_IMAGE)
cleanup()
exit(MSG_INFO_INSTALL_EXIT)
- if not old_flavor == new_flavor:
- if not ask_yes_no(MSG_WARN_FLAVOR_MISMATCH.format(old_flavor, new_flavor), default=False):
- cleanup()
- exit(MSG_INFO_INSTALL_EXIT)
+ success = True
+
+ if current_architecture != new_architecture:
+ success = False
+ if not new_architecture:
+ print(MSG_ERR_MISSING_ARCHITECTURE)
+ else:
+ print(MSG_ERR_ARCHITECTURE_MISMATCH.format(current_architecture, new_architecture))
+
+ if current_flavor != new_flavor:
+ if not force:
+ success = False
+ if not new_flavor:
+ print(MSG_ERR_MISSING_FLAVOR)
+ else:
+ print(MSG_ERR_FLAVOR_MISMATCH.format(current_flavor, new_flavor))
+ if not success:
+ print(MSG_ERR_INCOMPATIBLE_IMAGE)
+ cleanup()
+ exit(MSG_INFO_INSTALL_EXIT)
def install_image() -> None:
"""Install an image to a disk
@@ -746,14 +808,25 @@ def install_image() -> None:
break
print(MSG_WARN_IMAGE_NAME_WRONG)
+ failed_check_status = [EPasswdStrength.WEAK, EPasswdStrength.ERROR]
# ask for password
while True:
user_password: str = ask_input(MSG_INPUT_PASSWORD, no_echo=True,
non_empty=True)
+
+ if user_password == DEFAULT_PASSWORD:
+ Warning(MSG_WARN_CHANGE_PASSWORD)
+ else:
+ result = evaluate_strength(user_password)
+ if result['strength'] in failed_check_status:
+ Warning(result['error'])
+
confirm: str = ask_input(MSG_INPUT_PASSWORD_CONFIRM, no_echo=True,
non_empty=True)
+
if user_password == confirm:
break
+
print(MSG_WARN_PASSWORD_CONFIRM)
# ask for default console
@@ -849,8 +922,7 @@ def install_image() -> None:
for disk_target in l:
disk.partition_mount(disk_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi')
grub.install(disk_target.name, f'{DIR_DST_ROOT}/boot/',
- f'{DIR_DST_ROOT}/boot/efi',
- id=f'VyOS (RAID disk {l.index(disk_target) + 1})')
+ f'{DIR_DST_ROOT}/boot/efi')
disk.partition_umount(disk_target.partition['efi'])
else:
print('Installing GRUB to the drive')
@@ -893,7 +965,7 @@ def install_image() -> None:
@compat.grub_cfg_update
def add_image(image_path: str, vrf: str = None, username: str = '',
- password: str = '', no_prompt: bool = False) -> None:
+ password: str = '', no_prompt: bool = False, force: bool = False) -> None:
"""Add a new image
Args:
@@ -902,15 +974,18 @@ def add_image(image_path: str, vrf: str = None, username: str = '',
if image.is_live_boot():
exit(MSG_ERR_LIVE)
+ environ['REMOTE_USERNAME'] = username
+ environ['REMOTE_PASSWORD'] = password
+
# fetch an image
- iso_path: Path = image_fetch(image_path, vrf, username, password, no_prompt)
+ iso_path: Path = image_fetch(image_path, vrf, no_prompt)
try:
# mount an ISO
Path(DIR_ISO_MOUNT).mkdir(mode=0o755, parents=True)
disk.partition_mount(iso_path, DIR_ISO_MOUNT, 'iso9660')
print('Validating image compatibility')
- validate_compatibility(DIR_ISO_MOUNT)
+ validate_compatibility(DIR_ISO_MOUNT, force=force)
# check sums
print('Validating image checksums')
@@ -936,8 +1011,12 @@ def add_image(image_path: str, vrf: str = None, username: str = '',
f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed')
if not no_prompt:
+ versions = grub.version_list()
while True:
image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name)
+ if image_name in versions:
+ print(MSG_INPUT_IMAGE_NAME_TAKEN)
+ continue
if image.validate_name(image_name):
break
print(MSG_WARN_IMAGE_NAME_WRONG)
@@ -959,7 +1038,7 @@ def add_image(image_path: str, vrf: str = None, username: str = '',
Path(target_config_dir).mkdir(parents=True)
chown(target_config_dir, group='vyattacfg')
chmod_2775(target_config_dir)
- copytree('/opt/vyatta/etc/config/', target_config_dir,
+ copytree('/opt/vyatta/etc/config/', target_config_dir, symlinks=True,
copy_function=copy_preserve_owner, dirs_exist_ok=True)
else:
Path(target_config_dir).mkdir(parents=True)
@@ -992,6 +1071,12 @@ def add_image(image_path: str, vrf: str = None, username: str = '',
if set_as_default:
grub.set_default(image_name, root_dir)
+ cmdline_options = get_cli_kernel_options(
+ f'{target_config_dir}/config.boot')
+ grub_util.update_kernel_cmdline_options(' '.join(cmdline_options),
+ root_dir=root_dir,
+ version=image_name)
+
except OSError as e:
# if no space error, remove image dir and cleanup
if e.errno == ENOSPC:
@@ -1031,6 +1116,9 @@ def parse_arguments() -> Namespace:
parser.add_argument('--image-path',
help='a path (HTTP or local file) to an image that needs to be installed'
)
+ parser.add_argument('--force', action='store_true',
+ help='Ignore flavor compatibility requirements.'
+ )
# parser.add_argument('--image_new_name', help='a new name for image')
args: Namespace = parser.parse_args()
# Validate arguments
@@ -1047,7 +1135,8 @@ if __name__ == '__main__':
install_image()
if args.action == 'add':
add_image(args.image_path, args.vrf,
- args.username, args.password, args.no_prompt)
+ args.username, args.password,
+ args.no_prompt, args.force)
exit()