diff --git a/debian/vyos-1x.postinst b/debian/vyos-1x.postinst index ba97f37f6..fde58651a 100644 --- a/debian/vyos-1x.postinst +++ b/debian/vyos-1x.postinst @@ -1,290 +1,278 @@ #!/bin/bash # Turn off Debian default for %sudo sed -i -e '/^%sudo/d' /etc/sudoers || true # Add minion user for salt-minion if ! grep -q '^minion' /etc/passwd; then adduser --quiet --firstuid 100 --system --disabled-login --ingroup vyattacfg \ --gecos "salt minion user" --shell /bin/vbash minion adduser --quiet minion frrvty adduser --quiet minion sudo adduser --quiet minion adm adduser --quiet minion dip adduser --quiet minion disk adduser --quiet minion users adduser --quiet minion frr fi # OpenVPN should get its own user if ! grep -q '^openvpn' /etc/passwd; then adduser --quiet --firstuid 100 --system --group --shell /usr/sbin/nologin openvpn fi # node_exporter should get its own user if ! grep -q '^node_exporter' /etc/passwd; then adduser --quiet --firstuid 100 --system --group --shell /bin/false node_exporter fi # We need to have a group for RADIUS service users to use it inside PAM rules if ! grep -q '^radius' /etc/group; then addgroup --firstgid 1000 --quiet radius fi # Remove TACACS user added by base package - we use our own UID range and group # assignments - see below if grep -q '^tacacs' /etc/passwd; then if [ $(id -u tacacs0) -ge 1000 ]; then level=0 vyos_group=vyattaop while [ $level -lt 16 ]; do userdel tacacs${level} || true rm -rf /home/tacacs${level} || true level=$(( level+1 )) done 2>&1 fi fi # Remove TACACS+ PAM default profile if [[ -e /usr/share/pam-configs/tacplus ]]; then rm /usr/share/pam-configs/tacplus fi # Add TACACS system users required for TACACS based system authentication if ! grep -q '^tacacs' /etc/passwd; then # Add the tacacs group and all 16 possible tacacs privilege-level users to # the password file, home directories, etc. The accounts are not enabled # for local login, since they are only used to provide uid/gid/homedir for # the mapped TACACS+ logins (and lookups against them). The tacacs15 user # is also added to the sudo group, and vyattacfg group rather than vyattaop # (used for tacacs0-14). level=0 vyos_group=vyattaop while [ $level -lt 16 ]; do adduser --quiet --system --firstuid 900 --disabled-login --ingroup tacacs \ --no-create-home --gecos "TACACS+ mapped user at privilege level ${level}" \ --shell /bin/vbash tacacs${level} adduser --quiet tacacs${level} frrvty adduser --quiet tacacs${level} adm adduser --quiet tacacs${level} dip adduser --quiet tacacs${level} users if [ $level -lt 15 ]; then adduser --quiet tacacs${level} vyattaop adduser --quiet tacacs${level} operator else adduser --quiet tacacs${level} vyattacfg adduser --quiet tacacs${level} sudo adduser --quiet tacacs${level} disk adduser --quiet tacacs${level} frr adduser --quiet tacacs${level} _kea fi level=$(( level+1 )) done 2>&1 | grep -v "User tacacs${level} already exists" fi # Add RADIUS operator user for RADIUS authenticated users to map to if ! grep -q '^radius_user' /etc/passwd; then adduser --quiet --firstuid 1000 --disabled-login --ingroup radius \ --no-create-home --gecos "RADIUS mapped user at privilege level operator" \ --shell /sbin/radius_shell radius_user adduser --quiet radius_user frrvty adduser --quiet radius_user vyattaop adduser --quiet radius_user operator adduser --quiet radius_user adm adduser --quiet radius_user dip adduser --quiet radius_user users fi # Add RADIUS admin user for RADIUS authenticated users to map to if ! grep -q '^radius_priv_user' /etc/passwd; then adduser --quiet --firstuid 1000 --disabled-login --ingroup radius \ --no-create-home --gecos "RADIUS mapped user at privilege level admin" \ --shell /sbin/radius_shell radius_priv_user adduser --quiet radius_priv_user frrvty adduser --quiet radius_priv_user vyattacfg adduser --quiet radius_priv_user sudo adduser --quiet radius_priv_user adm adduser --quiet radius_priv_user dip adduser --quiet radius_priv_user disk adduser --quiet radius_priv_user users adduser --quiet radius_priv_user frr adduser --quiet radius_priv_user _kea fi # add hostsd group for vyos-hostsd if ! grep -q '^hostsd' /etc/group; then addgroup --quiet --system hostsd fi # Add _kea user for kea-dhcp{4,6}-server to vyattacfg # The user should exist via kea-common installed as transitive dependency if grep -q '^_kea' /etc/passwd; then adduser --quiet _kea vyattacfg fi # ensure the proxy user has a proper shell chsh -s /bin/sh proxy # Set file capabilities setcap cap_net_admin=pe /sbin/ethtool setcap cap_net_admin=pe /sbin/tc setcap cap_net_admin=pe /bin/ip setcap cap_net_admin=pe /sbin/xtables-legacy-multi setcap cap_net_admin=pe /sbin/xtables-nft-multi setcap cap_net_admin=pe /usr/sbin/conntrack setcap cap_net_admin=pe /usr/sbin/arp setcap cap_net_raw=pe /usr/bin/tcpdump setcap cap_net_admin,cap_sys_admin=pe /sbin/sysctl setcap cap_sys_module=pe /bin/kmod setcap cap_sys_time=pe /bin/date # create needed directories mkdir -p /var/log/user mkdir -p /var/core mkdir -p /opt/vyatta/etc/config/auth mkdir -p /opt/vyatta/etc/config/scripts mkdir -p /opt/vyatta/etc/config/user-data mkdir -p /opt/vyatta/etc/config/support chown -R root:vyattacfg /opt/vyatta/etc/config chmod -R 775 /opt/vyatta/etc/config mkdir -p /opt/vyatta/etc/logrotate mkdir -p /opt/vyatta/etc/netdevice.d touch /etc/environment if [ ! -f /etc/bash_completion ]; then echo "source /etc/bash_completion.d/10vyatta-op" > /etc/bash_completion echo "source /etc/bash_completion.d/20vyatta-cfg" >> /etc/bash_completion fi sed -i 's/^set /builtin set /' /etc/bash_completion # Fix up PAM configuration for login so that invalid users are prompted # for password sed -i 's/requisite[ \t][ \t]*pam_securetty.so/required pam_securetty.so/' $rootfsdir/etc/pam.d/login # Change default shell for new accounts sed -i -e ':^DSHELL:s:/bin/bash:/bin/vbash:' /etc/adduser.conf # Do not allow users to change full name field (controlled by vyos-1x) sed -i -e 's/^CHFN_RESTRICT/#&/' /etc/login.defs # Only allow root to use passwd command if ! grep -q 'pam_succeed_if.so' /etc/pam.d/passwd ; then sed -i -e '/^@include/i \ password requisite pam_succeed_if.so user = root ' /etc/pam.d/passwd fi # remove unnecessary ddclient script in /etc/ppp/ip-up.d/ # this logs unnecessary messages trying to start ddclient rm -f /etc/ppp/ip-up.d/ddclient # create /opt/vyatta/etc/config/scripts/vyos-preconfig-bootup.script PRECONFIG_SCRIPT=/opt/vyatta/etc/config/scripts/vyos-preconfig-bootup.script if [ ! -x $PRECONFIG_SCRIPT ]; then mkdir -p $(dirname $PRECONFIG_SCRIPT) touch $PRECONFIG_SCRIPT chmod 755 $PRECONFIG_SCRIPT cat <<EOF >>$PRECONFIG_SCRIPT #!/bin/sh # This script is executed at boot time before VyOS configuration is applied. # Any modifications required to work around unfixed bugs or use # services not available through the VyOS CLI system can be placed here. EOF fi -# cracklib-runtime default database location -CRACKLIB_DIR=/var/cache/cracklib -CRACKLIB_DB=cracklib_dict - # create /opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script POSTCONFIG_SCRIPT=/opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script if [ ! -x $POSTCONFIG_SCRIPT ]; then mkdir -p $(dirname $POSTCONFIG_SCRIPT) touch $POSTCONFIG_SCRIPT chmod 755 $POSTCONFIG_SCRIPT cat <<EOF >>$POSTCONFIG_SCRIPT #!/bin/sh # This script is executed at boot time after VyOS configuration is fully applied. # Any modifications required to work around unfixed bugs # or use services not available through the VyOS CLI system can be placed here. -# -# T6353 - Just in case, check if cracklib was installed properly -# If the database file is missing, re-install the runtime package -# -if [ ! -f "${CRACKLIB_DIR}/${CRACKLIB_DB}.pwd" ]; then - mkdir -p $CRACKLIB_DIR - /usr/sbin/create-cracklib-dict -o $CRACKLIB_DIR/$CRACKLIB_DB \ - /usr/share/dict/cracklib-small -fi + EOF fi # symlink destination is deleted during ISO assembly - this generates some noise # when the system boots: systemd-sysv-generator[1881]: stat() failed on # /etc/init.d/README, ignoring: No such file or directory. Thus we simply drop # the file. if [ -L /etc/init.d/README ]; then rm -f /etc/init.d/README fi # Remove unwanted daemon files from /etc # conntackd # pmacct # fastnetmon # ntp DELETE="/etc/logrotate.d/conntrackd.distrib /etc/init.d/conntrackd /etc/default/conntrackd /etc/default/pmacctd /etc/pmacct /etc/networks_list /etc/networks_whitelist /etc/fastnetmon.conf /etc/ntp.conf /etc/default/ssh /etc/avahi/avahi-daemon.conf /etc/avahi/hosts /etc/powerdns /etc/default/pdns-recursor /etc/ppp/ip-up.d/0000usepeerdns /etc/ppp/ip-down.d/0000usepeerdns" for tmp in $DELETE; do if [ -e ${tmp} ]; then rm -rf ${tmp} fi done # Remove logrotate items controlled via CLI and VyOS defaults sed -i '/^\/var\/log\/messages$/d' /etc/logrotate.d/rsyslog sed -i '/^\/var\/log\/auth.log$/d' /etc/logrotate.d/rsyslog # Fix FRR pam.d "vtysh_pam" vtysh_pam: Failed in account validation T5110 if test -f /etc/pam.d/frr; then if grep -q 'pam_rootok.so' /etc/pam.d/frr; then sed -i -re 's/rootok/permit/' /etc/pam.d/frr fi fi # Enable Cloud-init pre-configuration service systemctl enable vyos-config-cloud-init.service # Enable Podman API systemctl enable podman.service # Generate API GraphQL schema /usr/libexec/vyos/services/api/graphql/generate/generate_schema.py # Update XML cache python3 /usr/lib/python3/dist-packages/vyos/xml_ref/update_cache.py # Generate hardlinks for systemd units for multi VRF support # as softlinks will fail in systemd: # symlink target name type "ssh.service" does not match source, rejecting. if [ ! -f /lib/systemd/system/ssh@.service ]; then ln /lib/systemd/system/ssh.service /lib/systemd/system/ssh@.service fi # T4287 - as we have a non-signed kernel use the upstream wireless reulatory database update-alternatives --set regulatory.db /lib/firmware/regulatory.db-upstream # Restart vyos-configd to apply changes in Python scripts/templates if systemctl is-active --quiet vyos-configd; then systemctl restart vyos-configd fi # Restart vyos-domain-resolver if running if systemctl is-active --quiet vyos-domain-resolver; then systemctl restart vyos-domain-resolver fi diff --git a/python/vyos/utils/auth.py b/python/vyos/utils/auth.py index a27d8a28a..5d0e3464a 100644 --- a/python/vyos/utils/auth.py +++ b/python/vyos/utils/auth.py @@ -1,115 +1,121 @@ # authutils -- miscelanneous functions for handling passwords and publis keys # # Copyright (C) 2023-2024 VyOS maintainers and contributors # # 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, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import cracklib import math import re import string from enum import StrEnum from decimal import Decimal from vyos.utils.process import cmd -DEFAULT_PASSWORD = 'vyos' -LOW_ENTROPY_MSG = 'should be at least 8 characters long;' -WEAK_PASSWORD_MSG= 'The password complexity is too low - @MSG@' - +DEFAULT_PASSWORD: str = 'vyos' +LOW_ENTROPY_MSG: str = 'should be at least 8 characters long;' +WEAK_PASSWORD_MSG: str = 'The password complexity is too low - @MSG@' +CRACKLIB_ERROR_MSG: str = 'A following error occurred: @MSG@\n' \ + 'Possibly the cracklib database is corrupted or is missing. ' \ + 'Try reinstalling the python3-cracklib package.' class EPasswdStrength(StrEnum): WEAK = 'Weak' DECENT = 'Decent' STRONG = 'Strong' + ERROR = 'Cracklib Error' def calculate_entropy(charset: str, passwd: str) -> float: """ Calculate the entropy of a password based on the set of characters used Uses E = log2(R**L) formula, where - R is the range (length) of the character set - L is the length of password """ return math.log(math.pow(len(charset), len(passwd)), 2) def evaluate_strength(passwd: str) -> dict[str, str]: """ Evaluates password strength and returns a check result dict """ charset = (cracklib.ASCII_UPPERCASE + cracklib.ASCII_LOWERCASE + string.punctuation + string.digits) result = { 'strength': '', 'error': '', } try: cracklib.FascistCheck(passwd) except ValueError as e: # The password is vulnerable to dictionary attack no matter the entropy if 'is' in str(e): msg = str(e).replace('is', 'should not be') else: msg = f'should not be {e}' result.update(strength=EPasswdStrength.WEAK) result.update(error=WEAK_PASSWORD_MSG.replace('@MSG@', msg)) + except Exception as e: + result.update(strength=EPasswdStrength.ERROR) + result.update(error=CRACKLIB_ERROR_MSG.replace('@MSG@', str(e))) else: # Now check the password's entropy # Cast to Decimal for more precise rounding entropy = Decimal.from_float(calculate_entropy(charset, passwd)) match round(entropy): case e if e in range(0, 59): result.update(strength=EPasswdStrength.WEAK) result.update( error=WEAK_PASSWORD_MSG.replace('@MSG@', LOW_ENTROPY_MSG) ) case e if e in range(60, 119): result.update(strength=EPasswdStrength.DECENT) case e if e >= 120: result.update(strength=EPasswdStrength.STRONG) return result def make_password_hash(password): """ Makes a password hash for /etc/shadow using mkpasswd """ mkpassword = 'mkpasswd --method=sha-512 --stdin' return cmd(mkpassword, input=password, timeout=5) def split_ssh_public_key(key_string, defaultname=""): """ Splits an SSH public key into its components """ key_string = key_string.strip() parts = re.split(r'\s+', key_string) if len(parts) == 3: key_type, key_data, key_name = parts[0], parts[1], parts[2] else: key_type, key_data, key_name = parts[0], parts[1], defaultname if key_type not in ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519']: raise ValueError("Bad key type \'{0}\', must be one of must be one of ssh-rsa, ssh-dss, ecdsa-sha2-nistp<256|384|521> or ssh-ed25519".format(key_type)) return({"type": key_type, "data": key_data, "name": key_name}) def get_current_user() -> str: import os current_user = 'nobody' # During CLI "owner" script execution we use SUDO_USER if 'SUDO_USER' in os.environ: current_user = os.environ['SUDO_USER'] # During op-mode or config-mode interactive CLI we use USER elif 'USER' in os.environ: current_user = os.environ['USER'] return current_user diff --git a/src/conf_mode/system_login.py b/src/conf_mode/system_login.py index 1e6061ecf..3fed6d273 100755 --- a/src/conf_mode/system_login.py +++ b/src/conf_mode/system_login.py @@ -1,439 +1,440 @@ #!/usr/bin/env python3 # # Copyright (C) 2020-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 warnings from passlib.hosts import linux_context from psutil import users from pwd import getpwall from pwd import getpwnam from pwd import getpwuid from sys import exit from time import sleep from vyos.base import Warning from vyos.config import Config from vyos.configverify import verify_vrf from vyos.template import render from vyos.template import is_ipv4 from vyos.utils.auth import ( DEFAULT_PASSWORD, EPasswdStrength, evaluate_strength, get_current_user ) from vyos.utils.configfs import delete_cli_node from vyos.utils.configfs import add_cli_node from vyos.utils.dict import dict_search from vyos.utils.file import chown from vyos.utils.process import cmd from vyos.utils.process import call from vyos.utils.process import run from vyos.utils.process import DEVNULL from vyos import ConfigError from vyos import airbag airbag.enable() autologout_file = "/etc/profile.d/autologout.sh" limits_file = "/etc/security/limits.d/10-vyos.conf" radius_config_file = "/etc/pam_radius_auth.conf" tacacs_pam_config_file = "/etc/tacplus_servers" tacacs_nss_config_file = "/etc/tacplus_nss.conf" nss_config_file = "/etc/nsswitch.conf" # Minimum UID used when adding system users MIN_USER_UID: int = 1000 # Maximim UID used when adding system users MAX_USER_UID: int = 59999 # LOGIN_TIMEOUT from /etc/loign.defs minus 10 sec MAX_RADIUS_TIMEOUT: int = 50 # MAX_RADIUS_TIMEOUT divided by 2 sec (minimum recomended timeout) MAX_RADIUS_COUNT: int = 8 # Maximum number of supported TACACS servers MAX_TACACS_COUNT: int = 8 # Minimum USER id for TACACS users MIN_TACACS_UID = 900 # List of local user accounts that must be preserved SYSTEM_USER_SKIP_LIST: list = ['radius_user', 'radius_priv_user', 'tacacs0', 'tacacs1', 'tacacs2', 'tacacs3', 'tacacs4', 'tacacs5', 'tacacs6', 'tacacs7', 'tacacs8', 'tacacs9', 'tacacs10',' tacacs11', 'tacacs12', 'tacacs13', 'tacacs14', 'tacacs15'] def get_local_users(min_uid=MIN_USER_UID, max_uid=MAX_USER_UID): """Return list of dynamically allocated users (see Debian Policy Manual)""" local_users = [] for s_user in getpwall(): if getpwnam(s_user.pw_name).pw_uid < min_uid: continue if getpwnam(s_user.pw_name).pw_uid > max_uid: continue if s_user.pw_name in SYSTEM_USER_SKIP_LIST: continue local_users.append(s_user.pw_name) return local_users def get_shadow_password(username): with open('/etc/shadow') as f: for user in f.readlines(): items = user.split(":") if username == items[0]: return items[1] return None def get_config(config=None): if config: conf = config else: conf = Config() base = ['system', 'login'] login = conf.get_config_dict(base, key_mangling=('-', '_'), no_tag_node_value_mangle=True, get_first_key=True, with_recursive_defaults=True) # users no longer existing in the running configuration need to be deleted local_users = get_local_users() cli_users = [] if 'user' in login: cli_users = list(login['user']) # prune TACACS global defaults if not set by user if login.from_defaults(['tacacs']): del login['tacacs'] # same for RADIUS if login.from_defaults(['radius']): del login['radius'] # create a list of all users, cli and users all_users = list(set(local_users + cli_users)) # We will remove any normal users that dos not exist in the current # configuration. This can happen if user is added but configuration was not # saved and system is rebooted. rm_users = [tmp for tmp in all_users if tmp not in cli_users] if rm_users: login.update({'rm_users' : rm_users}) # Build TACACS user mapping if 'tacacs' in login: login['exclude_users'] = get_local_users(min_uid=0, max_uid=MIN_TACACS_UID) + cli_users login['tacacs_min_uid'] = MIN_TACACS_UID return login def verify(login): if 'rm_users' in login: # This check is required as the script is also executed from vyos-router # init script and there is no SUDO_USER environment variable available # during system boot. tmp = get_current_user() if tmp in login['rm_users']: raise ConfigError(f'Attempting to delete current user: {tmp}') if 'user' in login: system_users = getpwall() for user, user_config in login['user'].items(): # Linux system users range up until UID 1000, we can not create a # VyOS CLI user which already exists as system user for s_user in system_users: if s_user.pw_name == user and s_user.pw_uid < MIN_USER_UID: raise ConfigError(f'User "{user}" can not be created, conflict with local system account!') # T6353: Check password for complexity using cracklib. # A user password should be sufficiently complex plaintext_password = dict_search( path='authentication.plaintext_password', dict_object=user_config ) or None + failed_check_status = [EPasswdStrength.WEAK, EPasswdStrength.ERROR] if plaintext_password is not None: result = evaluate_strength(plaintext_password) - if result['strength'] == EPasswdStrength.WEAK: + if result['strength'] in failed_check_status: Warning(result['error']) for pubkey, pubkey_options in (dict_search('authentication.public_keys', user_config) or {}).items(): if 'type' not in pubkey_options: raise ConfigError(f'Missing type for public-key "{pubkey}"!') if 'key' not in pubkey_options: raise ConfigError(f'Missing key for public-key "{pubkey}"!') if {'radius', 'tacacs'} <= set(login): raise ConfigError('Using both RADIUS and TACACS at the same time is not supported!') # At lease one RADIUS server must not be disabled if 'radius' in login: if 'server' not in login['radius']: raise ConfigError('No RADIUS server defined!') sum_timeout: int = 0 radius_servers_count: int = 0 fail = True for server, server_config in dict_search('radius.server', login).items(): if 'key' not in server_config: raise ConfigError(f'RADIUS server "{server}" requires key!') if 'disable' not in server_config: sum_timeout += int(server_config['timeout']) radius_servers_count += 1 fail = False if fail: raise ConfigError('All RADIUS servers are disabled') if radius_servers_count > MAX_RADIUS_COUNT: raise ConfigError(f'Number of RADIUS servers exceeded maximum of {MAX_RADIUS_COUNT}!') if sum_timeout > MAX_RADIUS_TIMEOUT: raise ConfigError('Sum of RADIUS servers timeouts ' 'has to be less or eq 50 sec') verify_vrf(login['radius']) if 'source_address' in login['radius']: ipv4_count = 0 ipv6_count = 0 for address in login['radius']['source_address']: if is_ipv4(address): ipv4_count += 1 else: ipv6_count += 1 if ipv4_count > 1: raise ConfigError('Only one IPv4 source-address can be set!') if ipv6_count > 1: raise ConfigError('Only one IPv6 source-address can be set!') if 'tacacs' in login: tacacs_servers_count: int = 0 fail = True for server, server_config in dict_search('tacacs.server', login).items(): if 'key' not in server_config: raise ConfigError(f'TACACS server "{server}" requires key!') if 'disable' not in server_config: tacacs_servers_count += 1 fail = False if fail: raise ConfigError('All RADIUS servers are disabled') if tacacs_servers_count > MAX_TACACS_COUNT: raise ConfigError(f'Number of TACACS servers exceeded maximum of {MAX_TACACS_COUNT}!') verify_vrf(login['tacacs']) if 'max_login_session' in login and 'timeout' not in login: raise ConfigError('"login timeout" must be configured!') return None def generate(login): # calculate users encrypted password if 'user' in login: for user, user_config in login['user'].items(): tmp = dict_search('authentication.plaintext_password', user_config) if tmp: encrypted_password = linux_context.hash(tmp) login['user'][user]['authentication']['encrypted_password'] = encrypted_password del login['user'][user]['authentication']['plaintext_password'] # Set default commands for re-adding user with encrypted password del_user_plain = ['system', 'login', 'user', user, 'authentication', 'plaintext-password'] add_user_encrypt = ['system', 'login', 'user', user, 'authentication', 'encrypted-password'] delete_cli_node(del_user_plain) add_cli_node(add_user_encrypt, value=encrypted_password) else: try: if get_shadow_password(user) == dict_search('authentication.encrypted_password', user_config): # If the current encrypted bassword matches the encrypted password # from the config - do not update it. This will remove the encrypted # value from the system logs. # # The encrypted password will be set only once during the first boot # after an image upgrade. del login['user'][user]['authentication']['encrypted_password'] except: pass ### RADIUS based user authentication if 'radius' in login: render(radius_config_file, 'login/pam_radius_auth.conf.j2', login, permission=0o600, user='root', group='root') else: if os.path.isfile(radius_config_file): os.unlink(radius_config_file) ### TACACS+ based user authentication if 'tacacs' in login: render(tacacs_pam_config_file, 'login/tacplus_servers.j2', login, permission=0o644, user='root', group='root') render(tacacs_nss_config_file, 'login/tacplus_nss.conf.j2', login, permission=0o644, user='root', group='root') else: if os.path.isfile(tacacs_pam_config_file): os.unlink(tacacs_pam_config_file) if os.path.isfile(tacacs_nss_config_file): os.unlink(tacacs_nss_config_file) # NSS must always be present on the system render(nss_config_file, 'login/nsswitch.conf.j2', login, permission=0o644, user='root', group='root') # /etc/security/limits.d/10-vyos.conf if 'max_login_session' in login: render(limits_file, 'login/limits.j2', login, permission=0o644, user='root', group='root') else: if os.path.isfile(limits_file): os.unlink(limits_file) if 'timeout' in login: render(autologout_file, 'login/autologout.j2', login, permission=0o755, user='root', group='root') else: if os.path.isfile(autologout_file): os.unlink(autologout_file) return None def apply(login): enable_otp = False if 'user' in login: for user, user_config in login['user'].items(): # make new user using vyatta shell and make home directory (-m), # default group of 100 (users) command = 'useradd --create-home --no-user-group ' # check if user already exists: if user in get_local_users(): # update existing account command = 'usermod' # all accounts use /bin/vbash command += ' --shell /bin/vbash' # we need to use '' quotes when passing formatted data to the shell # else it will not work as some data parts are lost in translation tmp = dict_search('authentication.encrypted_password', user_config) if tmp: command += f" --password '{tmp}'" tmp = dict_search('full_name', user_config) if tmp: command += f" --comment '{tmp}'" tmp = dict_search('home_directory', user_config) if tmp: command += f" --home '{tmp}'" else: command += f" --home '/home/{user}'" command += f' --groups frr,frrvty,vyattacfg,sudo,adm,dip,disk,_kea {user}' try: cmd(command) # we should not rely on the value stored in user_config['home_directory'], as a # crazy user will choose username root or any other system user which will fail. # # XXX: Should we deny using root at all? home_dir = getpwnam(user).pw_dir # always re-render SSH keys with appropriate permissions render(f'{home_dir}/.ssh/authorized_keys', 'login/authorized_keys.j2', user_config, permission=0o600, formater=lambda _: _.replace(""", '"'), user=user, group='users') except Exception as e: raise ConfigError(f'Adding user "{user}" raised exception: "{e}"') # T5875: ensure UID is properly set on home directory if user is re-added # the home directory will always exist, as it's created above by --create-home, # retrieve current owner of home directory and adjust on demand dir_owner = None try: dir_owner = getpwuid(os.stat(home_dir).st_uid).pw_name except: pass if dir_owner != user: chown(home_dir, user=user, recursive=True) # Generate 2FA/MFA One-Time-Pad configuration if dict_search('authentication.otp.key', user_config): enable_otp = True render(f'{home_dir}/.google_authenticator', 'login/pam_otp_ga.conf.j2', user_config, permission=0o400, user=user, group='users') else: # delete configuration as it's not enabled for the user if os.path.exists(f'{home_dir}/.google_authenticator'): os.remove(f'{home_dir}/.google_authenticator') # Lock/Unlock local user account lock_unlock = '--unlock' if 'disable' in user_config: lock_unlock = '--lock' cmd(f'usermod {lock_unlock} {user}') if 'rm_users' in login: for user in login['rm_users']: try: # Disable user to prevent re-login call(f'usermod -s /sbin/nologin {user}') # Logout user if he is still logged in if user in list(set([tmp[0] for tmp in users()])): print(f'{user} is logged in, forcing logout!') # re-run command until user is logged out while run(f'pkill -HUP -u {user}'): sleep(0.250) # Remove user account but leave home directory in place. Re-run # command until user is removed - userdel might return 8 as # SSH sessions are not all yet properly cleaned away, thus we # simply re-run the command until the account wen't away while run(f'userdel {user}', stderr=DEVNULL): sleep(0.250) except Exception as e: raise ConfigError(f'Deleting user "{user}" raised exception: {e}') # Enable/disable RADIUS in PAM configuration cmd('pam-auth-update --disable radius-mandatory radius-optional') if 'radius' in login: if login['radius'].get('security_mode', '') == 'mandatory': pam_profile = 'radius-mandatory' else: pam_profile = 'radius-optional' cmd(f'pam-auth-update --enable {pam_profile}') # Enable/disable TACACS+ in PAM configuration cmd('pam-auth-update --disable tacplus-mandatory tacplus-optional') if 'tacacs' in login: if login['tacacs'].get('security_mode', '') == 'mandatory': pam_profile = 'tacplus-mandatory' else: pam_profile = 'tacplus-optional' cmd(f'pam-auth-update --enable {pam_profile}') # Enable/disable Google authenticator cmd('pam-auth-update --disable mfa-google-authenticator') if enable_otp: cmd(f'pam-auth-update --enable mfa-google-authenticator') return None if __name__ == '__main__': try: c = get_config() verify(c) generate(c) apply(c) except ConfigError as e: print(e) exit(1) diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py index c6e9c7f6f..82756daec 100755 --- a/src/op_mode/image_installer.py +++ b/src/op_mode/image_installer.py @@ -1,1111 +1,1112 @@ #!/usr/bin/env python3 # # Copyright 2023-2025 VyOS maintainers and contributors <maintainers@vyos.io> # # This file is part of VyOS. # # VyOS is free software: you can redistribute it and/or modify it under the # terms of the GNU General Public License as published by the Free Software # Foundation, either version 3 of the License, or (at your option) any later # version. # # VyOS 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 # VyOS. If not, see <https://www.gnu.org/licenses/>. from argparse import ArgumentParser, Namespace from pathlib import Path from shutil import copy, chown, rmtree, copytree from glob import glob from sys import exit from os import environ from os import readlink from os import getpid, getppid from typing import Union from urllib.parse import urlparse from passlib.hosts import linux_context 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.template import render from vyos.utils.auth import ( DEFAULT_PASSWORD, EPasswdStrength, evaluate_strength ) from vyos.utils.io import ask_input, ask_yes_no, select_entry from vyos.utils.file import chmod_2775 from vyos.utils.process import cmd, run, rc_cmd from vyos.version import get_version_data # define text messages MSG_ERR_NOT_LIVE: str = 'The system is already installed. Please use "add system image" instead.' 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_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_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.' MSG_INFO_INSTALL_DISKS_LIST: str = 'The following disks were found:' MSG_INFO_INSTALL_DISK_SELECT: str = 'Which one should be used for installation?' MSG_INFO_INSTALL_RAID_CONFIGURE: str = 'Would you like to configure RAID-1 mirroring?' MSG_INFO_INSTALL_RAID_FOUND_DISKS: str = 'Would you like to configure RAID-1 mirroring on them?' MSG_INFO_INSTALL_RAID_CHOOSE_DISKS: str = 'Would you like to choose two disks for RAID-1 mirroring?' MSG_INFO_INSTALL_DISK_CONFIRM: str = 'Installation will delete all data on the drive. Continue?' MSG_INFO_INSTALL_RAID_CONFIRM: str = 'Installation will delete all data on both drives. Continue?' MSG_INFO_INSTALL_PARTITONING: str = 'Creating partition table...' MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like to copy it to the new image?' 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_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:' MSG_INPUT_ROOT_SIZE_ALL: str = 'Would you like to use all the free space on the drive?' MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root partition (min is 1.5 GB)?' MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: 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.' 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' '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 CONST_MIN_ROOT_SIZE: int = 1610612736 # 1.5 GB # a reserved space: 2MB for header, 1 MB for BIOS partition, 256 MB for EFI CONST_RESERVED_SPACE: int = (2 + 1 + 256) * 1024**2 # define directories and paths DIR_INSTALLATION: str = '/mnt/installation' DIR_ROOTFS_SRC: str = f'{DIR_INSTALLATION}/root_src' DIR_ROOTFS_DST: str = f'{DIR_INSTALLATION}/root_dst' 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 = '' external_download_script = '/usr/libexec/vyos/simple-download.py' external_latest_image_url_script = '/usr/libexec/vyos/latest-image-url.py' # default boot variables DEFAULT_BOOT_VARS: dict[str, str] = { 'timeout': '5', 'console_type': 'tty', 'console_num': '0', 'console_speed': '115200', 'bootmode': 'normal' } def bytes_to_gb(size: int) -> float: """Convert Bytes to GBytes, rounded to 1 decimal number Args: size (int): input size in bytes Returns: float: size in GB """ return round(size / 1024**3, 1) def gb_to_bytes(size: float) -> int: """Convert GBytes to Bytes Args: size (float): input size in GBytes Returns: int: size in bytes """ return int(size * 1024**3) def find_disks() -> dict[str, int]: """Find a target disk for installation Returns: dict[str, int]: a list of available disks by name and size """ # check for available disks print('Probing disks') disks_available: dict[str, int] = disk.disks_size() for disk_name, disk_size in disks_available.copy().items(): if disk_size < CONST_MIN_DISK_SIZE: del disks_available[disk_name] if not disks_available: print(MSG_ERR_NO_DISK) exit(MSG_INFO_INSTALL_EXIT) num_disks: int = len(disks_available) print(f'{num_disks} disk(s) found') return disks_available def ask_root_size(available_space: int) -> int: """Define a size of root partition Args: available_space (int): available space in bytes for a root partition Returns: int: defined size """ if ask_yes_no(MSG_INPUT_ROOT_SIZE_ALL, default=True): return available_space while True: root_size_gb: str = ask_input(MSG_INPUT_ROOT_SIZE_SET) root_size_kbytes: int = (gb_to_bytes(float(root_size_gb))) // 1024 if root_size_kbytes > available_space: print(MSG_WARN_ROOT_SIZE_TOOBIG) continue if root_size_kbytes < CONST_MIN_ROOT_SIZE / 1024: print(MSG_WARN_ROOT_SIZE_TOOSMALL) continue return root_size_kbytes def create_partitions(target_disk: str, target_size: int, prompt: bool = True) -> None: """Create partitions on a target disk Args: target_disk (str): a target disk target_size (int): size of disk in bytes """ # define target rootfs size in KB (smallest unit acceptable by sgdisk) available_size: int = (target_size - CONST_RESERVED_SPACE) // 1024 if prompt: rootfs_size: int = ask_root_size(available_size) else: rootfs_size: int = available_size print(MSG_INFO_INSTALL_PARTITONING) raid.clear() disk.disk_cleanup(target_disk) disk_details: disk.DiskDetails = disk.parttable_create(target_disk, rootfs_size) return disk_details def search_format_selection(image: tuple[str, str]) -> str: """Format a string for selection of image Args: image (tuple[str, str]): a tuple of image name and drive Returns: str: formatted string """ return f'{image[0]} on {image[1]}' def search_previous_installation(disks: list[str]) -> None: """Search disks for previous installation config and SSH keys Args: 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): if Path(mnt_tmp + '/boot').exists(): 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) 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) 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: copy(host_key, mnt_ssh) disk.partition_umount(image_drive) def copy_preserve_owner(src: str, dst: str, *, follow_symlinks=True): if not Path(src).is_file(): return if Path(dst).is_dir(): dst = Path(dst).joinpath(Path(src).name) st = Path(src).stat() copy(src, dst, follow_symlinks=follow_symlinks) chown(dst, user=st.st_uid) def copy_previous_installation_data(target_dir: str) -> None: if Path('/mnt/config').exists(): copytree('/mnt/config', f'{target_dir}/opt/vyatta/etc/config', dirs_exist_ok=True) if Path('/mnt/ssh').exists(): copytree('/mnt/ssh', f'{target_dir}/etc/ssh', 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 Args: disks_available (dict[str, int]): a list of available disks """ print(MSG_INFO_INSTALL_DISKS_LIST) default_disk: str = list(disks_available)[0] for disk_name, disk_size in disks_available.items(): disk_size_human: str = bytes_to_gb(disk_size) print(f'Drive: {disk_name} ({disk_size_human} GB)') disk_selected: str = ask_input(MSG_INFO_INSTALL_DISK_SELECT, default=default_disk, valid_responses=list(disks_available)) # create partitions if not ask_yes_no(MSG_INFO_INSTALL_DISK_CONFIRM): print(MSG_INFO_INSTALL_EXIT) exit() search_previous_installation(list(disks_available)) disk_details: disk.DiskDetails = create_partitions(disk_selected, disks_available[disk_selected]) disk.filesystem_create(disk_details.partition['efi'], 'efi') disk.filesystem_create(disk_details.partition['root'], 'ext4') return disk_details def check_raid_install(disks_available: dict[str, int]) -> Union[str, None]: """Ask user to select disks for RAID installation Args: disks_available (dict[str, int]): a list of available disks """ if len(disks_available) < 2: return None if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIGURE, default=True): return None def format_selection(disk_name: str) -> str: return f'{disk_name}\t({bytes_to_gb(disks_available[disk_name])} GB)' disk0, disk1 = list(disks_available)[0], list(disks_available)[1] disks_selected: dict[str, int] = { disk0: disks_available[disk0], disk1: disks_available[disk1] } target_size: int = min(disks_selected[disk0], disks_selected[disk1]) print(MSG_INFO_INSTALL_DISKS_LIST) for disk_name, disk_size in disks_selected.items(): disk_size_human: str = bytes_to_gb(disk_size) print(f'\t{disk_name} ({disk_size_human} GB)') if not ask_yes_no(MSG_INFO_INSTALL_RAID_FOUND_DISKS, default=True): if not ask_yes_no(MSG_INFO_INSTALL_RAID_CHOOSE_DISKS, default=True): return None else: disks_selected = {} disk0 = select_entry(list(disks_available), 'Disks available:', 'Select first disk:', format_selection) disks_selected[disk0] = disks_available[disk0] del disks_available[disk0] disk1 = select_entry(list(disks_available), 'Remaining disks:', 'Select second disk:', format_selection) disks_selected[disk1] = disks_available[disk1] target_size: int = min(disks_selected[disk0], disks_selected[disk1]) # create partitions if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIRM): print(MSG_INFO_INSTALL_EXIT) exit() search_previous_installation(list(disks_available)) disks: list[disk.DiskDetails] = [] for disk_selected in list(disks_selected): print(f'Creating partitions on {disk_selected}') disk_details = create_partitions(disk_selected, target_size, prompt=False) disk.filesystem_create(disk_details.partition['efi'], 'efi') disks.append(disk_details) print('Creating RAID array') members = [disk.partition['root'] for disk in disks] raid_details: raid.RaidDetails = raid.raid_create(members) # raid init stuff print('Updating initramfs') raid.update_initramfs() # end init print('Creating filesystem on RAID array') disk.filesystem_create(raid_details.name, 'ext4') return raid_details def prepare_tmp_disr() -> None: """Create temporary directories for installation """ print('Creating temporary directories') for dir in [DIR_ROOTFS_SRC, DIR_ROOTFS_DST, DIR_DST_ROOT]: dirpath = Path(dir) dirpath.mkdir(mode=0o755, parents=True) def setup_grub(root_dir: str) -> None: """Install GRUB configurations Args: root_dir (str): a path to the root of target filesystem """ print('Installing GRUB configuration files') grub_cfg_main = f'{root_dir}/{grub.GRUB_DIR_MAIN}/grub.cfg' grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}' grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}' grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}' grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}' # create new files render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {}) grub.common_write(root_dir) grub.vars_write(grub_cfg_vars, DEFAULT_BOOT_VARS) grub.modules_write(grub_cfg_modules, []) grub.write_cfg_ver(1, root_dir) render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {}) render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {}) def configure_authentication(config_file: str, password: str) -> None: """Write encrypted password to config file Args: config_file (str): path of target config file password (str): plaintext password N.B. this can not be deferred by simply setting the plaintext password and relying on the config mode script to process at boot, as the config will not automatically be saved in that case, thus leaving the plaintext exposed """ encrypted_password = linux_context.hash(password) with open(config_file) as f: config_string = f.read() config = ConfigTree(config_string) config.set([ 'system', 'login', 'user', 'vyos', 'authentication', 'encrypted-password' ], value=encrypted_password, replace=True) config.set_tag(['system', 'login', 'user']) with open(config_file, 'w') as f: f.write(config.to_string()) def validate_signature(file_path: str, sign_type: str) -> None: """Validate a file by signature and delete a signature file Args: file_path (str): a path to file sign_type (str): a signature type """ 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: if run(f'minisign -V -q -p {pubkey} -m {file_path} -x {file_path}.minisig' ) == 0: 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() # warn or pass if not signature_valid: if not ask_yes_no(MSG_WARN_ISO_SIGN_INVALID, default=False): exit(MSG_INFO_INSTALL_EXIT) else: 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 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) def image_fetch(image_path: str, vrf: str = None, username: str = '', password: str = '', no_prompt: bool = False) -> Path: """Fetch an ISO image Args: image_path (str): a path, remote or local 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) if code: print(output) exit(MSG_INFO_INSTALL_EXIT) image_path = output if output else image_path try: # check a type of path if urlparse(image_path).scheme: # download an image 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 sign_file = (False, '') for sign_type in ['minisig', 'asc']: try: download_file(f'{ISO_DOWNLOAD_PATH}.{sign_type}', f'{image_path}.{sign_type}', vrf, username, password) sign_file = (True, sign_type) break except Exception: print(f'{sign_type} signature is not available') # validate a signature if it is available if sign_file[0]: validate_signature(ISO_DOWNLOAD_PATH, sign_file[1]) else: if (not no_prompt and not ask_yes_no(MSG_WARN_ISO_SIGN_UNAVAL, default=False)): cleanup() exit(MSG_INFO_INSTALL_EXIT) return Path(ISO_DOWNLOAD_PATH) else: local_path: Path = Path(image_path) if local_path.is_file(): return local_path else: raise FileNotFoundError except Exception as e: print(f'The image cannot be fetched from: {image_path} {e}') exit(1) def migrate_config() -> bool: """Check for active config and ask user for migration Returns: bool: user's decision """ active_config_path: Path = Path('/opt/vyatta/etc/config/config.boot') if active_config_path.exists(): if ask_yes_no(MSG_INPUT_CONFIG_FOUND, default=True): return True return False def copy_ssh_host_keys() -> bool: """Ask user to copy SSH host keys Returns: bool: user's decision """ if ask_yes_no('Would you like to copy SSH host keys?', default=True): return True return False def console_hint() -> str: pid = getppid() if 'SUDO_USER' in environ else getpid() try: path = readlink(f'/proc/{pid}/fd/1') except OSError: path = '/dev/tty' name = Path(path).name if name == 'ttyS0': return 'S' else: return 'K' def cleanup(mounts: list[str] = [], remove_items: list[str] = []) -> None: """Clean up after installation Args: mounts (list[str], optional): List of mounts to unmount. Defaults to []. remove_items (list[str], optional): List of files or directories to remove. Defaults to []. """ print('Cleaning up') # clean up installation directory by default mounts_all = disk_partitions(all=True) for mounted_device in mounts_all: if mounted_device.mountpoint.startswith(DIR_INSTALLATION) and not ( mounted_device.device in mounts or mounted_device.mountpoint in mounts): mounts.append(mounted_device.mountpoint) # add installation dir to cleanup list if DIR_INSTALLATION not in remove_items: remove_items.append(DIR_INSTALLATION) # also delete an ISO file if Path(ISO_DOWNLOAD_PATH).exists( ) and ISO_DOWNLOAD_PATH not in remove_items: remove_items.append(ISO_DOWNLOAD_PATH) if mounts: print('Unmounting target filesystems') for mountpoint in mounts: disk.partition_umount(mountpoint) for mountpoint in mounts: disk.wait_for_umount(mountpoint) if remove_items: print('Removing temporary files') for remove_item in remove_items: if Path(remove_item).exists(): if Path(remove_item).is_file(): Path(remove_item).unlink() if Path(remove_item).is_dir(): rmtree(remove_item, ignore_errors=True) def cleanup_raid(details: raid.RaidDetails) -> None: efiparts = [] for raid_disk in details.disks: efiparts.append(raid_disk.partition['efi']) cleanup([details.name, *efiparts], ['/mnt/installation']) def is_raid_install(install_object: Union[disk.DiskDetails, raid.RaidDetails]) -> bool: """Check if installation target is a RAID array Args: install_object (Union[disk.DiskDetails, raid.RaidDetails]): a target disk Returns: bool: True if it is a RAID array """ if isinstance(install_object, raid.RaidDetails): return True return False 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 """ 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') 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) 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 """ if not image.is_live_boot(): exit(MSG_ERR_NOT_LIVE) print(MSG_INFO_INSTALL_WELCOME) if not ask_yes_no('Would you like to continue?'): print(MSG_INFO_INSTALL_EXIT) exit() # configure image name running_image_name: str = image.get_running_image() while True: image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, running_image_name) if image.validate_name(image_name): 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'] == EPasswdStrength.WEAK: + 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 console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE, default=console_hint(), valid_responses=['K', 'S']) console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS'} config_boot_list = ['/opt/vyatta/etc/config/config.boot', '/opt/vyatta/etc/config.boot.default'] default_config = config_boot_list[0] disks: dict[str, int] = find_disks() install_target: Union[disk.DiskDetails, raid.RaidDetails, None] = None try: install_target = check_raid_install(disks) if install_target is None: install_target = ask_single_disk(disks) # if previous install was selected in search_previous_installation, # directory /mnt/config was prepared for copy below; if not, prompt: if not Path('/mnt/config').exists(): default_config: str = select_entry(config_boot_list, MSG_INPUT_CONFIG_CHOICE, MSG_INPUT_CONFIG_CHOOSE, default_entry=1) # select_entry indexes from 1 # create directories for installation media prepare_tmp_disr() # mount target filesystem and create required dirs inside print('Mounting new partitions') if is_raid_install(install_target): disk.partition_mount(install_target.name, DIR_DST_ROOT) Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True) else: disk.partition_mount(install_target.partition['root'], DIR_DST_ROOT) Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True) disk.partition_mount(install_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi') # a config dir. It is the deepest one, so the comand will # create all the rest in a single step print('Creating a configuration file') target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw/opt/vyatta/etc/config/' Path(target_config_dir).mkdir(parents=True) chown(target_config_dir, group='vyattacfg') chmod_2775(target_config_dir) # copy config copy(default_config, f'{target_config_dir}/config.boot') configure_authentication(f'{target_config_dir}/config.boot', user_password) Path(f'{target_config_dir}/.vyatta_config').touch() # create a persistence.conf Path(f'{DIR_DST_ROOT}/persistence.conf').write_text('/ union\n') # copy system image and kernel files print('Copying system image files') for file in Path(DIR_KERNEL_SRC).iterdir(): if file.is_file(): copy(file, f'{DIR_DST_ROOT}/boot/{image_name}/') copy(FILE_ROOTFS_SRC, f'{DIR_DST_ROOT}/boot/{image_name}/{image_name}.squashfs') # copy saved config data and SSH keys # 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) setup_grub(DIR_DST_ROOT) # add information about version grub.create_structure() grub.version_add(image_name, DIR_DST_ROOT) grub.set_default(image_name, DIR_DST_ROOT) grub.set_console_type(console_dict[console_type], DIR_DST_ROOT) if is_raid_install(install_target): # add RAID specific modules grub.modules_write(f'{DIR_DST_ROOT}/{grub.CFG_VYOS_MODULES}', ['part_msdos', 'part_gpt', 'diskfilter', 'ext2','mdraid1x']) # install GRUB if is_raid_install(install_target): print('Installing GRUB to the drives') l = install_target.disks 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})') disk.partition_umount(disk_target.partition['efi']) else: print('Installing GRUB to the drive') grub.install(install_target.name, f'{DIR_DST_ROOT}/boot/', f'{DIR_DST_ROOT}/boot/efi') # sort inodes (to make GRUB read config files in alphabetical order) grub.sort_inodes(f'{DIR_DST_ROOT}/{grub.GRUB_DIR_VYOS}') grub.sort_inodes(f'{DIR_DST_ROOT}/{grub.GRUB_DIR_VYOS_VERS}') # umount filesystems and remove temporary files if is_raid_install(install_target): cleanup([install_target.name], ['/mnt/installation']) else: cleanup([install_target.partition['efi'], install_target.partition['root']], ['/mnt/installation']) # we are done print(MSG_INFO_INSTALL_SUCCESS) exit() except Exception as err: print(f'Unable to install VyOS: {err}') # unmount filesystems and clenup try: if install_target is not None: if is_raid_install(install_target): cleanup_raid(install_target) else: cleanup([install_target.partition['efi'], install_target.partition['root']], ['/mnt/installation']) except Exception as err: print(f'Cleanup failed: {err}') exit(1) @compat.grub_cfg_update def add_image(image_path: str, vrf: str = None, username: str = '', password: str = '', no_prompt: bool = False, force: bool = False) -> None: """Add a new image Args: image_path (str): a path to an ISO image """ if image.is_live_boot(): exit(MSG_ERR_LIVE) # fetch an image iso_path: Path = image_fetch(image_path, vrf, username, password, 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, force=force) # check sums print('Validating image checksums') if not Path(DIR_ISO_MOUNT).joinpath('sha256sum.txt').exists(): cleanup() exit(MSG_ERR_IMPROPER_IMAGE) if run(f'cd {DIR_ISO_MOUNT} && sha256sum --status -c sha256sum.txt'): cleanup() exit('Image checksum verification failed.') # mount rootfs (to get a system version) Path(DIR_ROOTFS_SRC).mkdir(mode=0o755, parents=True) disk.partition_mount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs', DIR_ROOTFS_SRC, 'squashfs') cfg_ver: str = image.get_image_tools_version(DIR_ROOTFS_SRC) version_name: str = image.get_image_version(DIR_ROOTFS_SRC) disk.partition_umount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs') if cfg_ver < SYSTEM_CFG_VER: raise compat.DowngradingImageTools( f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed') if not no_prompt: while True: image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name) if image.validate_name(image_name): break print(MSG_WARN_IMAGE_NAME_WRONG) set_as_default: bool = ask_yes_no(MSG_INPUT_IMAGE_DEFAULT, default=True) else: image_name: str = version_name set_as_default: bool = True # find target directory root_dir: str = disk.find_persistence() # a config dir. It is the deepest one, so the comand will # create all the rest in a single step target_config_dir: str = f'{root_dir}/boot/{image_name}/rw/opt/vyatta/etc/config/' # copy config if no_prompt or migrate_config(): print('Copying configuration directory') # copytree preserves perms but not ownership: 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, copy_function=copy_preserve_owner, dirs_exist_ok=True) else: Path(target_config_dir).mkdir(parents=True) chown(target_config_dir, group='vyattacfg') chmod_2775(target_config_dir) Path(f'{target_config_dir}/.vyatta_config').touch() target_ssh_dir: str = f'{root_dir}/boot/{image_name}/rw/etc/ssh/' if no_prompt or copy_ssh_host_keys(): print('Copying SSH host keys') Path(target_ssh_dir).mkdir(parents=True) host_keys: list[str] = glob('/etc/ssh/ssh_host*') for host_key in host_keys: copy(host_key, target_ssh_dir) # copy system image and kernel files print('Copying system image files') for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir(): if file.is_file() and (file.match('initrd*') or file.match('vmlinuz*')): copy(file, f'{root_dir}/boot/{image_name}/') copy(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs', f'{root_dir}/boot/{image_name}/{image_name}.squashfs') # unmount an ISO and cleanup cleanup([str(iso_path)]) # add information about version grub.version_add(image_name, root_dir) if set_as_default: grub.set_default(image_name, root_dir) except OSError as e: # if no space error, remove image dir and cleanup if e.errno == ENOSPC: cleanup(mounts=[str(iso_path)], remove_items=[f'{root_dir}/boot/{image_name}']) else: # unmount an ISO and cleanup cleanup([str(iso_path)]) exit(f'Error: {e}') except Exception as err: # unmount an ISO and cleanup cleanup([str(iso_path)]) exit(f'Error: {err}') def parse_arguments() -> Namespace: """Parse arguments Returns: Namespace: a namespace with parsed arguments """ parser: ArgumentParser = ArgumentParser( description='Install new system images') parser.add_argument('--action', choices=['install', 'add'], required=True, help='action to perform with an image') parser.add_argument('--vrf', help='vrf name for image download') parser.add_argument('--no-prompt', action='store_true', help='perform action non-interactively') parser.add_argument('--username', default='', help='username for image download') parser.add_argument('--password', default='', help='password for image download') 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 if args.action == 'add' and not args.image_path: exit('A path to image is required for add action') return args if __name__ == '__main__': try: args: Namespace = parse_arguments() if args.action == 'install': install_image() if args.action == 'add': add_image(args.image_path, args.vrf, args.username, args.password, args.no_prompt, args.force) exit() except KeyboardInterrupt: print('Stopped by Ctrl+C') cleanup() exit() except Exception as err: exit(f'{err}')