diff --git a/tests/platform_tests/test_first_time_boot_password_change/__init__.py b/tests/platform_tests/test_first_time_boot_password_change/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/platform_tests/test_first_time_boot_password_change/conftest.py b/tests/platform_tests/test_first_time_boot_password_change/conftest.py new file mode 100644 index 0000000000..29ae1152ed --- /dev/null +++ b/tests/platform_tests/test_first_time_boot_password_change/conftest.py @@ -0,0 +1,104 @@ +import pytest +import logging +import time +import pexpect +from tests.platform_tests.test_first_time_boot_password_change.manufacture import manufacture +from tests.platform_tests.test_first_time_boot_password_change.default_consts import DefaultConsts + + +def pytest_addoption(parser): + parser.addoption("--feature_enabled", action="store", default='False', help="set to True if the feature is enabled") + + +class CurrentConfigurations: + ''' + @summary: this class will act as a global database to save current configurations and changes the test made. + It will help us track the current state of the system, + and we will be used as part of cleanup fixtures. + ''' + def __init__(self): + self.currentPassword = DefaultConsts.DEFAULT_PASSWORD # initial password + + +currentConfigurations = CurrentConfigurations() +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope='module', autouse=True) +def dut_hostname(request): + ''' + @summary: this function returns the hostname of the dut from the 'host-pattern' + ''' + hostname = request.config.getoption('--host-pattern') + logger.info("Hostname is {}".format(hostname)) + return hostname + + +@pytest.fixture(scope='module', autouse=True) +def is_feature_disabled(request): + ''' + @summary: this fixture will be responsible for + skipping the test if the feature is disabled + ''' + feature_enabled = request.config.getoption("feature_enabled") + if feature_enabled == 'False': + pytest.skip("Feature is disabled, will not run the test") + + +@pytest.fixture(scope='module', autouse=True) +def prepare_system_for_first_boot(request, dut_hostname): + ''' + @summary: will manufacture the dut device to the given image in the parameter --base_image_list, + by installing the image given from ONIE. for detailed information read the documentation + of the manufacture script. + ''' + base_image = request.config.getoption('base_image_list') + if not base_image: + pytest.skip("base_image_list param is empty") + manufacture(dut_hostname, base_image) + + +def change_password(dut_hostname, username, current_password, new_password): + ''' + @summary: this function changes the password for the user given + :param dut_hostname: host name of the dut + :param dut_ip: device under test + :param username: user name to change the password for + :param current_password: current password + :param new_password: new password + ''' + logger.info("Changing password for username:{} to password: {}".format(username, new_password)) + try: + # create a new ssh connection + engine = pexpect.spawn(DefaultConsts.SSH_COMMAND.format(username) + dut_hostname, timeout=15) + # because of race condition + engine.delaybeforesend = 0.2 + engine.delayafterclose = 0.5 + engine.expect(DefaultConsts.PASSWORD_REGEX) + engine.sendline(current_password + '\r') + engine.expect(DefaultConsts.SONIC_PROMPT) + engine.sendline('sudo usermod -p $(openssl passwd -1 {}) {}'.format(new_password, username) + '\r') + engine.expect(DefaultConsts.SONIC_PROMPT) + logger.info("Sleeping for {} secs to apply password change".format(DefaultConsts.APPLY_CONFIGURATIONS)) + time.sleep(DefaultConsts.APPLY_CONFIGURATIONS) + engine.sendline('exit') + engine.close() + except Exception as err: + logger.info('Got an exception while changing the password') + logger.info(str(err)) + + +@pytest.fixture(scope='function', autouse=True) +def restore_original_password(dut_hostname): + ''' + @summary: this function will restore the original password to the default one to allow + the next test to use default password to login to dut. + ''' + yield + logger.info("Sleep {} secs for system stabilization".format(DefaultConsts.STABILIZATION_TIME)) + time.sleep(DefaultConsts.STABILIZATION_TIME) + logger.info("Restore original password") + change_password(dut_hostname, + DefaultConsts.DEFAULT_USER, + currentConfigurations.currentPassword, + DefaultConsts.DEFAULT_PASSWORD) diff --git a/tests/platform_tests/test_first_time_boot_password_change/default_consts.py b/tests/platform_tests/test_first_time_boot_password_change/default_consts.py new file mode 100644 index 0000000000..bd6450de31 --- /dev/null +++ b/tests/platform_tests/test_first_time_boot_password_change/default_consts.py @@ -0,0 +1,49 @@ +''' +This file contains the default consts used by the scripts on the same folder: +manufactue.py and test_first_time_boot_password_change.py +''' + + +class DefaultConsts: + ''' + @summary: a constants class used by the tests + ''' + DEFAULT_USER = 'admin' + DEFAULT_PASSWORD = 'YourPaSsWoRd' + NEW_PASSWORD = 'Jg_GRK9BJB58s_5H' + ONIE_USER = 'root' + ONIE_PASSWORD = 'root' + + # connection command + SSH_COMMAND = 'ssh -tt -q -o ControlMaster=auto -o ControlPersist=60s -o ' \ + 'ControlPath=/tmp/ansible-ssh-%h-%p-%r -o StrictHostKeyChecking=no ' \ + '-o UserKnownHostsFile=/dev/null -o GSSAPIAuthentication=no ' \ + '-o PubkeyAuthentication=no -p 22 -l {} ' + + SCP_COMMNAD = 'scp -o ControlMaster=auto ' \ + '-o ControlPersist=60s -o ControlPath=/tmp/ansible-ssh-%h-%p-%r' \ + ' -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ' \ + 'GSSAPIAuthentication=no -o PubkeyAuthentication=no {} {}@{}:{}' + + ONIE_INSTALL_PATH = 'platform_tests/test_first_time_boot_password_change/onie_install.sh' + # expired password message regex + PASSWORD_REGEX = 'assword' + SONIC_PROMPT = '$' + ONIE_PROMPT = '#' + DEFAULT_PROMPT = [SONIC_PROMPT, ONIE_PROMPT] + LONG_PERIOD = 30 + APPLY_CONFIGURATIONS = 10 + STABILIZATION_TIME = 60 + SLEEP_AFTER_MANUFACTURE = 60 + NEW_PASSWORD_REGEX = 'New password' + RETYPE_PASSWORD_REGEX = 'Retype new password' + # expired password message regex + EXPIRED_PASSWORD_MSG = 'You are required to change your password immediately' + + # visual colors used for manufacture script + OKBLUE = '\033[94m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' diff --git a/tests/platform_tests/test_first_time_boot_password_change/manufacture.py b/tests/platform_tests/test_first_time_boot_password_change/manufacture.py new file mode 100644 index 0000000000..81f3e806bd --- /dev/null +++ b/tests/platform_tests/test_first_time_boot_password_change/manufacture.py @@ -0,0 +1,233 @@ +''' +This script will install a given image passed in the parameters +to the device using ONIE install mode. +Assumptions: + 1. The system is up + 2. Login username is 'admin' and default password: 'YourPaSsWoRd' + 3. ONIE system with either no password to enter ONIE cli or 'root' password + 4. Enough space to upload restore image to ONIE, otherwise it will fail + 5. "onie_insall.sh" script in the same folder as this script + 6. Existing image, will not check if the image path is existing, should be accessible without password! + +Detailed logic of manufacture script: + 1. Connect to dut + 2. upload the "onie_install.sh" file in the same folder to dut + 3. run the bash file, the script "onie_install.sh" is responsible for entering ONIE install mode + 4. upload image to ONIE + 5. install image using onie-nos-install +''' +import pexpect +import time +import logging +from tests.platform_tests.test_first_time_boot_password_change.default_consts import DefaultConsts + + +logger = logging.getLogger(__name__) + + +def print_log(msg, color=''): + ''' + @summary: will print the msg to log, used to add color to messages, + since the manufacture script is long ~6 mins total + :param msg: msg to print to log + :param color: colot to print + ''' + logger.info(color + msg + DefaultConsts.ENDC) + + +def ping_till_state(dut_ip, should_be_alive=True, timeout=300): + ''' + @summary: this function will ping system till the desired + :param dut_ip: device under test ip address + :param should_be_alive: if True, will ping system till alive, if False will ping till down + :param timeout: fail if the desired state is not achieved + ''' + # create an engine + localhost_engine = pexpect.spawn('sudo su', env={'TERM': 'dumb'}) + localhost_engine.expect(['.*#', '.$']) + time_passed = 0 + result = 'Fail' + while time_passed <= timeout: + print_log("Pinging system {} till {}".format(dut_ip, 'alive' if should_be_alive else 'down')) + localhost_engine.sendline('ping -c 1 ' + dut_ip) + response = localhost_engine.expect(['1 packets received', '0 packets received']) + if response == 0: + if should_be_alive: + result = 'Success' + break + else: + if not should_be_alive: + result = 'Success' + break + + print_log("Sleeping 2 secs between pings") + time.sleep(2) + time_passed += 2 + + if result == 'Fail': + fail_msg = "Expected system to be {} after timeout of {} but the system was {}".format( + 'alive' if should_be_alive else 'down', + timeout, + 'down' if should_be_alive else 'alive' + ) + print_log(fail_msg) + localhost_engine.close() + + +def ping_till_alive(dut_ip, timeout=300): + ''' + @summary: this function will ping system till alive + :param dut_ip: device under test ip address + ''' + ping_till_state(dut_ip, should_be_alive=True, timeout=timeout) + + +def ping_till_down(dut_ip, timeout=300): + ''' + @summary: this function will ping system till down + :param dut_ip: device under test ip address + ''' + ping_till_state(dut_ip, should_be_alive=False, timeout=timeout) + + +def create_engine(dut_ip, username, password, timeout=30): + ''' + @summary: this command will create an ssh engine to run command + on the device under test + :param dut_ip: device under test ip address + :param username: user name to login + :param password: password for username + :param timeout: default timeout for engine + ''' + print_log("Creating engine for {} with username: {} and password: {}".format(dut_ip, username, password)) + child = pexpect.spawn(DefaultConsts.SSH_COMMAND.format(username) + dut_ip, env={'TERM': 'dumb'}, timeout=timeout) + index = child.expect([DefaultConsts.PASSWORD_REGEX, + DefaultConsts.DEFAULT_PROMPT[0], + DefaultConsts.DEFAULT_PROMPT[1]]) + if index == 0: + child.sendline(password + '\r') + + print_log("Engine created successfully") + return child + + +def upload_file_to_dut(dut_ip, filename, destination, username, password, timeout=30): + ''' + @summary: this function will upload the given file to dut under destination folder + :param dut_ip: device under test + :param filename: path to filenmae + :param username: username of the device + :param password: password to username + :param timeout: timeout + ''' + print_log('Uploading file {} to dut {} under \'{}\' dir'.format(filename, dut_ip, destination)) + if timeout > DefaultConsts.LONG_PERIOD: + print_log('Please be patient this may take some time') + cmd = DefaultConsts.SCP_COMMNAD.format(filename, username, dut_ip, destination) + child = pexpect.spawn(cmd, timeout=timeout) + # sometimes the system requires password to login into, we need to consider this case + index = child.expect(["100%", + DefaultConsts.PASSWORD_REGEX]) + if index == 0: + print_log('Done Uploading file - 100%', DefaultConsts.OKGREEN) + return + # enter password + child.sendline(password + '\r') + child.expect(['100%']) + print_log('Done Uploading file - 100%', DefaultConsts.OKGREEN) + + +def enter_onie_install_mode(dut_ip): + ''' + @summary: this function will upload the "onie_install.sh" bash script under '/tmp' folder on dut. + The script is found in the same folder of this script. The script is executed from the dut. + The script "onie_install.sh" is responsible for loading ONIE install mode after reboot. + For more info please read the documentation in the bash script and its usage. + :param dut_ip: device under test ip address + ''' + print_log("Entering ONIE install mode by running \"{}\" bash script on DUT".format( + DefaultConsts.ONIE_INSTALL_PATH.split('/')[-1]), + DefaultConsts.WARNING + DefaultConsts.BOLD) + + upload_file_to_dut(dut_ip, DefaultConsts.ONIE_INSTALL_PATH, '/tmp', + DefaultConsts.DEFAULT_USER, + DefaultConsts.DEFAULT_PASSWORD) + # create ssh connection device + sonic_engine = create_engine(dut_ip, DefaultConsts.DEFAULT_USER, DefaultConsts.DEFAULT_PASSWORD) + sonic_engine.sendline('sudo su') + sonic_engine.expect(DefaultConsts.SONIC_PROMPT) + sonic_engine.sendline('cd /tmp') + sonic_engine.expect(DefaultConsts.SONIC_PROMPT) + print_log("Validating file \"{}\" existence".format(DefaultConsts.ONIE_INSTALL_PATH.split('/')[-1])) + # validate the file is there + sonic_engine.sendline('ls') + sonic_engine.expect('{}'.format(DefaultConsts.ONIE_INSTALL_PATH.split('/')[-1])) + # # change permissions + print_log("Executing the bash script uploaded") + sonic_engine.sendline('sudo chmod +777 onie_install.sh') + sonic_engine.expect(DefaultConsts.SONIC_PROMPT) + sonic_engine.sendline('sudo ./onie_install.sh install') + sonic_engine.expect('Reboot will be done after 3 sec') + # # close session, the system will perform reboot + ping_till_down(dut_ip) + print_log("System is Down!", DefaultConsts.BOLD + DefaultConsts.OKGREEN) + sonic_engine.close() + + +def install_image_from_onie(dut_ip, restore_image_path): + ''' + @summary: this function will upload the image given to ONIE and perform + install to the image using "onie-nos-install" + :param dut_ip: device under test ip address + :param restore_image_path: path to restore image should be in the format /../../../your_image_name.bin + ''' + ping_till_alive(dut_ip) + print_log("System is UP!", DefaultConsts.BOLD + DefaultConsts.OKGREEN) + upload_file_to_dut(dut_ip, restore_image_path, '/', DefaultConsts.ONIE_USER, DefaultConsts.ONIE_PASSWORD, + timeout=420) + + restore_image_name = restore_image_path.split('/')[-1] + print_log('restore image name is {}'.format(restore_image_name)) + # SSH to ONIE + child = create_engine(dut_ip, DefaultConsts.ONIE_USER, DefaultConsts.ONIE_PASSWORD) + print_log("Install the image from ONIE") + child.sendline('cd /') + child.expect(DefaultConsts.ONIE_PROMPT) + child.sendline('onie-stop') + child.expect(DefaultConsts.ONIE_PROMPT) + child.sendline('onie-nos-install {}'.format(restore_image_name) + '\r') + print_log("Ping system till down") + ping_till_down(dut_ip) + print_log("Ping system till alive") + ping_till_alive(dut_ip) + child.close() + + +def manufacture(dut_ip, restore_image_path): + ''' + @summary: will remove the installed image and intsall the image given in the restore_image_path + Assumptions: + 1. The system is up + 2. Login username is 'admin' and default password: 'YourPaSsWoRd' + 3. ONIE system with either no password to enter ONIE cli or 'root' password + 4. Enough space to upload restore image to ONIE, otherwise it will fail, and will leave system in ONIE mode! + 5. "onie_insall.sh" script in the same folder as this script, + under "tests/platform_tests/test_first_time_boot_password_change" + 6. Existing image, will not check if the image path is existing, should be accessible without password! + Detailed logic of manufacture script: + 1. Connect to dut + 2. upload the "onie_install.sh" file in the same folder to dut + 3. run the bash file, the script "onie_install.sh" is responsible for entering ONIE install mode + 4. upload image to ONIE + 5. install image using onie-nos-install + :param dut_ip: device to manufacture + :param restore_image_path: path to the image + ''' + # create engine for the localhost running this script + print_log("Manufacture started", DefaultConsts.OKGREEN + DefaultConsts.BOLD) + # perform manufacture + enter_onie_install_mode(dut_ip) + install_image_from_onie(dut_ip, restore_image_path) + print_log("Sleeping for {} secs to stabilize system after reboot".format(DefaultConsts.SLEEP_AFTER_MANUFACTURE)) + time.sleep(DefaultConsts.SLEEP_AFTER_MANUFACTURE) + print_log("Manufacture is completed - SUCCESS", DefaultConsts.OKGREEN + DefaultConsts.BOLD) diff --git a/tests/platform_tests/test_first_time_boot_password_change/onie_install.sh b/tests/platform_tests/test_first_time_boot_password_change/onie_install.sh new file mode 100644 index 0000000000..742f354fe5 --- /dev/null +++ b/tests/platform_tests/test_first_time_boot_password_change/onie_install.sh @@ -0,0 +1,79 @@ +#!/bin/sh + +# By this script, SONiC switch moving to ONIE with specific boot_mode +# The examples of usage: +# onie_install.sh + +onie_mount=/mnt/onie-boot +os_boot=/host +onie_partition= +onie_entry=0 + +enable_onie_access() +{ + onie_partition=$(fdisk -l | grep "ONIE boot" | awk '{print $1}') + if [ ! -d $onie_mount ]; then + mkdir /mnt/onie-boot + fi + mount $onie_partition /mnt/onie-boot + if [ ! -e /lib/onie ]; then + ln -s /mnt/onie-boot/onie/tools/lib/onie /lib/onie + fi + PATH=/sbin:/usr/sbin:/bin:/usr/bin:$onie_mount/onie/tools/bin/ + export PATH +} + +clean_onie_access() +{ + rm -f /lib/onie + umount $onie_partition +} + +# ONIE entry must exist in grub config +find_onie_menuentry() +{ + onie_entry="$(cat $os_boot/grub/grub.cfg | grep -e 'menuentry' | cat -n | awk '$0~/ONIE/ {print $1-1}')" + entries_num="$(echo "$onie_entry" | grep -E '^[0-9]+$' | wc -l)" + if [ $entries_num -eq 1 ] && [ $onie_entry -ge 1 ]; then + return 0 + fi + return 1 +} + + +change_onie_grub_boot_order() +{ + find_onie_menuentry + rc=$? + if [ $rc -eq 0 ]; then + grub-reboot --boot-directory=$os_boot $onie_entry + else + echo "ERROR: ONIE entry wasn't found in grub config" + return 1 + fi + + echo "Set onie mode to install" + grub-editenv $onie_mount/grub/grubenv set onie_mode=install + return 0 +} + + +system_reboot() +{ + echo "Reboot will be done after 3 sec." + sleep 3 + /sbin/reboot +} + + + +rc=$? +enable_onie_access +change_onie_grub_boot_order +clean_onie_access + +if [ $rc -eq 0 ]; then + system_reboot +fi + +exit $rc diff --git a/tests/platform_tests/test_first_time_boot_password_change/test_first_time_boot_password_change.py b/tests/platform_tests/test_first_time_boot_password_change/test_first_time_boot_password_change.py new file mode 100644 index 0000000000..5b92740b65 --- /dev/null +++ b/tests/platform_tests/test_first_time_boot_password_change/test_first_time_boot_password_change.py @@ -0,0 +1,117 @@ +''' +This test case checks default password change after initial reboot. +Due to new law passed in California, each default user must change their default password. + +Important Note: + Please run this test from sonic-mgmt/tests folder, otherwise it will fail. +''' +import pexpect +import time +import pytest +from tests.platform_tests.test_first_time_boot_password_change.default_consts import DefaultConsts +from tests.platform_tests.test_first_time_boot_password_change.conftest import logger, currentConfigurations +from tests.platform_tests.test_first_time_boot_password_change.manufacture import ping_till_down, ping_till_alive + +pytestmark = [ + pytest.mark.topology('any'), + pytest.mark.sanity_check(skip_sanity=True), + pytest.mark.disable_loganalyzer, + pytest.mark.skip_check_dut_health, + pytest.mark.default_password_change_after_initial_boot +] + + +def test_default_password_change_after_first_boot(dut_hostname): + ''' + @summary: in this test case we want to validate the mandatory request of + password change after the first boot of the given image. + According to a new law passed on the united states, default passwords + such as: "admin", "root", "12345", etc. are no longer accepted. + Test Flow: + 1.A message should appear after initial boot, requesting password change for default user. + 2.Password change, it will be tested by relogin to dut with new password and expecting no expire message again + 3. reboot and login again and expect no expiring message + :param dut_hostname: name of device under test + ''' + logger.info("------ STAGE 1 ------") + logger.info("create ssh connection to device after initial boot") + engine = pexpect.spawn(DefaultConsts.SSH_COMMAND.format(DefaultConsts.DEFAULT_USER) + dut_hostname) + # to prevent race condition + engine.delaybeforesend = 0.2 + engine.delayafterclose = 0.5 + # it should require password so password will be sent + engine.expect(DefaultConsts.PASSWORD_REGEX) + engine.sendline(DefaultConsts.DEFAULT_PASSWORD) + # we should expect the expired password regex to appear + logger.info("Expecting expired message printed") + index = engine.expect([DefaultConsts.EXPIRED_PASSWORD_MSG, pexpect.TIMEOUT]) + if index != 0: + engine.close() + raise Exception("We did not catch the message of expired password after initial boot!\n" + "Consider this as a bug or a degradation") + logger.info('Entering current password after the expired message appeared') + engine.sendline(DefaultConsts.DEFAULT_PASSWORD + '\r') + # suggest new password + logger.info('Entering a new password, password used is {}'.format(DefaultConsts.NEW_PASSWORD)) + engine.expect(DefaultConsts.NEW_PASSWORD_REGEX) + engine.sendline(DefaultConsts.NEW_PASSWORD + '\r') + logger.info('Retyping the new password') + engine.expect(DefaultConsts.RETYPE_PASSWORD_REGEX) + engine.sendline(DefaultConsts.NEW_PASSWORD + '\r') + engine.expect(DefaultConsts.DEFAULT_PROMPT) + # update global configuration database, it will be used in cleanup later + currentConfigurations.currentPassword = DefaultConsts.NEW_PASSWORD + logger.info("Exit cli for the default user and re-enter again and expect no password expire message") + # close the session + engine.close() + logger.info("Sleeping for {} secs to allow system update password".format(DefaultConsts.STABILIZATION_TIME)) + time.sleep(DefaultConsts.STABILIZATION_TIME) + logger.info("create a new ssh connection to device") + engine = pexpect.spawn(DefaultConsts.SSH_COMMAND.format(DefaultConsts.DEFAULT_USER) + dut_hostname) + engine.delaybeforesend = 0.2 + engine.delayafterclose = 0.5 + # expect password + engine.expect(DefaultConsts.PASSWORD_REGEX) + # enter new password + engine.sendline(DefaultConsts.NEW_PASSWORD + '\r') + # we should not expect the expired password regex to appear again + index = engine.expect([DefaultConsts.EXPIRED_PASSWORD_MSG] + DefaultConsts.DEFAULT_PROMPT) + if index == 0: + engine.close() + raise Exception("We captured the expiring message again after updating a new password!\n") + engine.close() + + logger.info("------ STAGE 2 ------") + logger.info("Performing reboot to the switch, expecting no expiring password message after reboot") + logger.info("create a new ssh connection to device") + engine = pexpect.spawn(DefaultConsts.SSH_COMMAND.format(DefaultConsts.DEFAULT_USER) + dut_hostname) + engine.delaybeforesend = 0.2 + engine.delayafterclose = 0.5 + # expect password + engine.expect(DefaultConsts.PASSWORD_REGEX) + # enter new password + engine.sendline(DefaultConsts.NEW_PASSWORD + '\r') + # we should get the default prompt + engine.expect(DefaultConsts.DEFAULT_PROMPT) + engine.sendline('sudo reboot') + engine.expect('firmware is up to date') + # pinging till down + ping_till_down(dut_hostname) + engine.close() + # ping till alive + ping_till_alive(dut_hostname) + logger.info("Sleeping {} secs to after reboot".format(DefaultConsts.STABILIZATION_TIME)) + time.sleep(DefaultConsts.STABILIZATION_TIME) + # connect to device + logger.info("create a new ssh connection to device") + engine = pexpect.spawn(DefaultConsts.SSH_COMMAND + dut_hostname) + # expect password + engine.expect(DefaultConsts.PASSWORD_REGEX) + # enter new password + engine.sendline(DefaultConsts.NEW_PASSWORD + '\r') + # we should not expect the expired password regex to appear again + index = engine.expect([DefaultConsts.EXPIRED_PASSWORD_MSG] + DefaultConsts.DEFAULT_PROMPT) + if index == 0: + engine.close() + raise Exception("We captured the expiring message again after reboot\n") + engine.close()