diff --git a/smoketest/scripts/cli/base_vyostest_shim.py b/smoketest/scripts/cli/base_vyostest_shim.py index d95071d1a..9cf6a653a 100644 --- a/smoketest/scripts/cli/base_vyostest_shim.py +++ b/smoketest/scripts/cli/base_vyostest_shim.py @@ -1,192 +1,196 @@ # Copyright (C) 2021-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 unittest import paramiko import pprint from time import sleep from time import time from typing import Type from vyos.configsession import ConfigSession from vyos.configsession import ConfigSessionError from vyos import ConfigError from vyos.defaults import commit_lock from vyos.utils.process import cmd from vyos.utils.process import run +from vyos.utils.process import process_named_running save_config = '/tmp/vyos-smoketest-save' # This class acts as shim between individual Smoketests developed for VyOS and # the Python UnitTest framework. Before every test is loaded, we dump the current # system configuration and reload it after the test - despite the test results. # # Using this approach we can not render a live system useless while running any # kind of smoketest. In addition it adds debug capabilities like printing the # command used to execute the test. class VyOSUnitTestSHIM: class TestCase(unittest.TestCase): # if enabled in derived class, print out each and every set/del command # on the CLI. This is usefull to grap all the commands required to # trigger the certain failure condition. # Use "self.debug = True" in derived classes setUp() method debug = False commit_guard = time() @classmethod def setUpClass(cls): cls._session = ConfigSession(os.getpid()) cls._session.save_config(save_config) if os.path.exists('/tmp/vyos.smoketest.debug'): cls.debug = True pass @classmethod def tearDownClass(cls): # discard any pending changes which might caused a messed up config cls._session.discard() # ... and restore the initial state cls._session.migrate_and_load_config(save_config) try: cls._session.commit() except (ConfigError, ConfigSessionError): cls._session.discard() cls.fail(cls) def cli_set(self, config): if self.debug: print('set ' + ' '.join(config)) self._session.set(config) def cli_delete(self, config): if self.debug: print('del ' + ' '.join(config)) self._session.delete(config) def cli_discard(self): if self.debug: print('DISCARD') self._session.discard() def cli_commit(self): if self.debug: print('commit') self._session.commit() # during a commit there is a process opening commit_lock, and run() returns 0 while run(f'sudo lsof -nP {commit_lock}') == 0: sleep(0.250) + # wait for FRR reload to be complete + while process_named_running('frr-reload.py'): + sleep(0.250) # reset getFRRconfig() guard timer self.commit_guard = time() def op_mode(self, path : list) -> None: """ Execute OP-mode command and return stdout """ if self.debug: print('commit') path = ' '.join(path) out = cmd(f'/opt/vyatta/bin/vyatta-op-cmd-wrapper {path}') if self.debug: print(f'\n\ncommand "{path}" returned:\n') pprint.pprint(out) return out def getFRRconfig(self, string=None, end='$', endsection='^!', daemon='', guard_time=10, empty_retry=0): """ Retrieve current "running configuration" from FRR """ # Sometimes FRR needs some time after reloading the configuration to # appear in vtysh. This is a workaround addiung a 10 second guard timer # between the last cli_commit() and the first read of FRR config via vtysh while (time() - self.commit_guard) < guard_time: sleep(0.250) # wait 250 milliseconds command = f'vtysh -c "show run {daemon} no-header"' if string: command += f' | sed -n "/^{string}{end}/,/{endsection}/p"' out = cmd(command) if self.debug: print(f'\n\ncommand "{command}" returned:\n') pprint.pprint(out) if empty_retry: retry_count = 0 while not out and retry_count < empty_retry: if self.debug and retry_count % 10 == 0: print(f"Attempt {retry_count}: FRR config is still empty. Retrying...") retry_count += 1 sleep(1) out = cmd(command) if not out: print(f'FRR configuration still empty after {empty_retry} retires!') return out @staticmethod def ssh_send_cmd(command, username, password, hostname='localhost'): """ SSH command execution helper """ # Try to login via SSH ssh_client = paramiko.SSHClient() ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh_client.connect(hostname=hostname, username=username, password=password) _, stdout, stderr = ssh_client.exec_command(command) output = stdout.read().decode().strip() error = stderr.read().decode().strip() ssh_client.close() return output, error # Verify nftables output def verify_nftables(self, nftables_search, table, inverse=False, args=''): nftables_output = cmd(f'sudo nft {args} list table {table}') for search in nftables_search: matched = False for line in nftables_output.split("\n"): if all(item in line for item in search): matched = True break self.assertTrue(not matched if inverse else matched, msg=search) def verify_nftables_chain(self, nftables_search, table, chain, inverse=False, args=''): nftables_output = cmd(f'sudo nft {args} list chain {table} {chain}') for search in nftables_search: matched = False for line in nftables_output.split("\n"): if all(item in line for item in search): matched = True break self.assertTrue(not matched if inverse else matched, msg=search) # Verify ip rule output def verify_rules(self, rules_search, inverse=False, addr_family='inet'): rule_output = cmd(f'ip -family {addr_family} rule show') for search in rules_search: matched = False for line in rule_output.split("\n"): if all(item in line for item in search): matched = True break self.assertTrue(not matched if inverse else matched, msg=search) # standard construction; typing suggestion: https://stackoverflow.com/a/70292317 def ignore_warning(warning: Type[Warning]): import warnings from functools import wraps def inner(f): @wraps(f) def wrapped(*args, **kwargs): with warnings.catch_warnings(): warnings.simplefilter("ignore", category=warning) return f(*args, **kwargs) return wrapped return inner diff --git a/smoketest/scripts/cli/test_service_dns_dynamic.py b/smoketest/scripts/cli/test_service_dns_dynamic.py index 3ce459e44..28380f6d4 100755 --- a/smoketest/scripts/cli/test_service_dns_dynamic.py +++ b/smoketest/scripts/cli/test_service_dns_dynamic.py @@ -1,350 +1,350 @@ #!/usr/bin/env python3 # # Copyright (C) 2019-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 unittest import tempfile from base_vyostest_shim import VyOSUnitTestSHIM from vyos.configsession import ConfigSessionError from vyos.utils.process import cmd from vyos.utils.process import process_named_running DDCLIENT_SYSTEMD_UNIT = '/run/systemd/system/ddclient.service.d/override.conf' DDCLIENT_CONF = '/run/ddclient/ddclient.conf' DDCLIENT_PNAME = 'ddclient' base_path = ['service', 'dns', 'dynamic'] name_path = base_path + ['name'] server = 'ddns.vyos.io' hostname = 'test.ddns.vyos.io' zone = 'vyos.io' username = 'vyos_user' password = 'paSS_@4ord' ttl = '300' interface = 'eth0' class TestServiceDDNS(VyOSUnitTestSHIM.TestCase): def setUp(self): # Always start with a clean CLI instance self.cli_delete(base_path) def tearDown(self): # Check for running process - self.assertTrue(process_named_running(DDCLIENT_PNAME)) + self.assertTrue(process_named_running(DDCLIENT_PNAME, timeout=5)) # Delete DDNS configuration self.cli_delete(base_path) self.cli_commit() # Check for process not running anymore self.assertFalse(process_named_running(DDCLIENT_PNAME)) # IPv4 standard DDNS service configuration def test_01_dyndns_service_standard(self): services = {'cloudflare': {'protocol': 'cloudflare'}, 'freedns': {'protocol': 'freedns', 'username': username}, 'zoneedit': {'protocol': 'zoneedit1', 'username': username}} for svc, details in services.items(): self.cli_set(name_path + [svc, 'address', 'interface', interface]) self.cli_set(name_path + [svc, 'host-name', hostname]) self.cli_set(name_path + [svc, 'password', password]) for opt, value in details.items(): self.cli_set(name_path + [svc, opt, value]) # 'zone' option is supported by 'cloudfare' and 'zoneedit1', but not 'freedns' self.cli_set(name_path + [svc, 'zone', zone]) if details['protocol'] in ['cloudflare', 'zoneedit1']: pass else: # exception is raised for unsupported ones with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_delete(name_path + [svc, 'zone']) # 'ttl' option is supported by 'cloudfare', but not 'freedns' and 'zoneedit' self.cli_set(name_path + [svc, 'ttl', ttl]) if details['protocol'] == 'cloudflare': pass else: # exception is raised for unsupported ones with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_delete(name_path + [svc, 'ttl']) # commit changes self.cli_commit() # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') # default value 300 seconds self.assertIn(f'daemon=300', ddclient_conf) self.assertIn(f'usev4=ifv4', ddclient_conf) self.assertIn(f'ifv4={interface}', ddclient_conf) self.assertIn(f'password=\'{password}\'', ddclient_conf) for opt in details.keys(): if opt == 'username': login = details[opt] self.assertIn(f'login={login}', ddclient_conf) else: tmp = details[opt] self.assertIn(f'{opt}={tmp}', ddclient_conf) # IPv6 only DDNS service configuration def test_02_dyndns_service_ipv6(self): interval = '60' svc_path = name_path + ['dynv6'] proto = 'dyndns2' ip_version = 'ipv6' wait_time = '600' expiry_time_good = '3600' expiry_time_bad = '360' self.cli_set(base_path + ['interval', interval]) self.cli_set(svc_path + ['address', 'interface', interface]) self.cli_set(svc_path + ['ip-version', ip_version]) self.cli_set(svc_path + ['protocol', proto]) self.cli_set(svc_path + ['server', server]) self.cli_set(svc_path + ['username', username]) self.cli_set(svc_path + ['password', password]) self.cli_set(svc_path + ['host-name', hostname]) self.cli_set(svc_path + ['wait-time', wait_time]) # expiry-time must be greater than wait-time, exception is raised otherwise with self.assertRaises(ConfigSessionError): self.cli_set(svc_path + ['expiry-time', expiry_time_bad]) self.cli_commit() self.cli_set(svc_path + ['expiry-time', expiry_time_good]) # commit changes self.cli_commit() # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') self.assertIn(f'daemon={interval}', ddclient_conf) self.assertIn(f'usev6=ifv6', ddclient_conf) self.assertIn(f'ifv6={interface}', ddclient_conf) self.assertIn(f'protocol={proto}', ddclient_conf) self.assertIn(f'server={server}', ddclient_conf) self.assertIn(f'login={username}', ddclient_conf) self.assertIn(f'password=\'{password}\'', ddclient_conf) self.assertIn(f'min-interval={wait_time}', ddclient_conf) self.assertIn(f'max-interval={expiry_time_good}', ddclient_conf) # IPv4+IPv6 dual DDNS service configuration def test_03_dyndns_service_dual_stack(self): services = {'cloudflare': {'protocol': 'cloudflare', 'zone': zone}, 'freedns': {'protocol': 'freedns', 'username': username}, 'google': {'protocol': 'googledomains', 'username': username}} ip_version = 'both' for name, details in services.items(): self.cli_set(name_path + [name, 'address', 'interface', interface]) self.cli_set(name_path + [name, 'host-name', hostname]) self.cli_set(name_path + [name, 'password', password]) for opt, value in details.items(): self.cli_set(name_path + [name, opt, value]) # Dual stack is supported by 'cloudfare' and 'freedns' but not 'googledomains' # exception is raised for unsupported ones self.cli_set(name_path + [name, 'ip-version', ip_version]) if details['protocol'] not in ['cloudflare', 'freedns']: with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_delete(name_path + [name, 'ip-version']) # commit changes self.cli_commit() # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') if details['protocol'] not in ['cloudflare', 'freedns']: self.assertIn(f'usev4=ifv4', ddclient_conf) self.assertIn(f'ifv4={interface}', ddclient_conf) else: self.assertIn(f'usev4=ifv4', ddclient_conf) self.assertIn(f'usev6=ifv6', ddclient_conf) self.assertIn(f'ifv4={interface}', ddclient_conf) self.assertIn(f'ifv6={interface}', ddclient_conf) self.assertIn(f'password=\'{password}\'', ddclient_conf) for opt in details.keys(): if opt == 'username': login = details[opt] self.assertIn(f'login={login}', ddclient_conf) else: tmp = details[opt] self.assertIn(f'{opt}={tmp}', ddclient_conf) def test_04_dyndns_rfc2136(self): # Check if DDNS service can be configured and runs svc_path = name_path + ['vyos'] proto = 'nsupdate' with tempfile.NamedTemporaryFile(prefix='/config/auth/') as key_file: key_file.write(b'S3cretKey') self.cli_set(svc_path + ['address', 'interface', interface]) self.cli_set(svc_path + ['protocol', proto]) self.cli_set(svc_path + ['server', server]) self.cli_set(svc_path + ['zone', zone]) self.cli_set(svc_path + ['key', key_file.name]) self.cli_set(svc_path + ['ttl', ttl]) self.cli_set(svc_path + ['host-name', hostname]) # commit changes self.cli_commit() # Check some generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') self.assertIn(f'use=if', ddclient_conf) self.assertIn(f'if={interface}', ddclient_conf) self.assertIn(f'protocol={proto}', ddclient_conf) self.assertIn(f'server={server}', ddclient_conf) self.assertIn(f'zone={zone}', ddclient_conf) self.assertIn(f'password=\'{key_file.name}\'', ddclient_conf) self.assertIn(f'ttl={ttl}', ddclient_conf) def test_05_dyndns_hostname(self): # Check if DDNS service can be configured and runs svc_path = name_path + ['namecheap'] proto = 'namecheap' hostnames = ['@', 'www', hostname, f'@.{hostname}'] for name in hostnames: self.cli_set(svc_path + ['address', 'interface', interface]) self.cli_set(svc_path + ['protocol', proto]) self.cli_set(svc_path + ['server', server]) self.cli_set(svc_path + ['username', username]) self.cli_set(svc_path + ['password', password]) self.cli_set(svc_path + ['host-name', name]) # commit changes self.cli_commit() # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') self.assertIn(f'protocol={proto}', ddclient_conf) self.assertIn(f'server={server}', ddclient_conf) self.assertIn(f'login={username}', ddclient_conf) self.assertIn(f'password=\'{password}\'', ddclient_conf) self.assertIn(f'{name}', ddclient_conf) def test_06_dyndns_web_options(self): # Check if DDNS service can be configured and runs svc_path = name_path + ['cloudflare'] proto = 'cloudflare' web_url = 'https://ifconfig.me/ip' web_skip = 'Current IP Address:' self.cli_set(svc_path + ['protocol', proto]) self.cli_set(svc_path + ['zone', zone]) self.cli_set(svc_path + ['password', password]) self.cli_set(svc_path + ['host-name', hostname]) # not specifying either 'interface' or 'web' will raise an exception with self.assertRaises(ConfigSessionError): self.cli_commit() self.cli_set(svc_path + ['address', 'web']) # specifying both 'interface' and 'web' will raise an exception as well with self.assertRaises(ConfigSessionError): self.cli_set(svc_path + ['address', 'interface', interface]) self.cli_commit() self.cli_delete(svc_path + ['address', 'interface']) self.cli_commit() # web option 'skip' is useless without the option 'url' with self.assertRaises(ConfigSessionError): self.cli_set(svc_path + ['address', 'web', 'skip', web_skip]) self.cli_commit() self.cli_set(svc_path + ['address', 'web', 'url', web_url]) self.cli_commit() # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') self.assertIn(f'usev4=webv4', ddclient_conf) self.assertIn(f'webv4={web_url}', ddclient_conf) self.assertIn(f'webv4-skip=\'{web_skip}\'', ddclient_conf) self.assertIn(f'protocol={proto}', ddclient_conf) self.assertIn(f'zone={zone}', ddclient_conf) self.assertIn(f'password=\'{password}\'', ddclient_conf) self.assertIn(f'{hostname}', ddclient_conf) def test_07_dyndns_dynamic_interface(self): # Check if DDNS service can be configured and runs svc_path = name_path + ['namecheap'] proto = 'namecheap' dyn_interface = 'pppoe587' self.cli_set(svc_path + ['address', 'interface', dyn_interface]) self.cli_set(svc_path + ['protocol', proto]) self.cli_set(svc_path + ['server', server]) self.cli_set(svc_path + ['username', username]) self.cli_set(svc_path + ['password', password]) self.cli_set(svc_path + ['host-name', hostname]) # Dynamic interface will raise a warning but still go through # XXX: We should have idiomatic class "ConfigSessionWarning" wrapping # "Warning" similar to "ConfigSessionError". # with self.assertWarns(Warning): # self.cli_commit() self.cli_commit() # Check the generating config parameters ddclient_conf = cmd(f'sudo cat {DDCLIENT_CONF}') self.assertIn(f'ifv4={dyn_interface}', ddclient_conf) self.assertIn(f'protocol={proto}', ddclient_conf) self.assertIn(f'server={server}', ddclient_conf) self.assertIn(f'login={username}', ddclient_conf) self.assertIn(f'password=\'{password}\'', ddclient_conf) self.assertIn(f'{hostname}', ddclient_conf) def test_08_dyndns_vrf(self): # Table number randomized, but should be within range 100-65535 vrf_table = '58710' vrf_name = f'vyos-test-{vrf_table}' svc_path = name_path + ['cloudflare'] proto = 'cloudflare' self.cli_set(['vrf', 'name', vrf_name, 'table', vrf_table]) self.cli_set(base_path + ['vrf', vrf_name]) self.cli_set(svc_path + ['address', 'interface', interface]) self.cli_set(svc_path + ['protocol', proto]) self.cli_set(svc_path + ['host-name', hostname]) self.cli_set(svc_path + ['zone', zone]) self.cli_set(svc_path + ['password', password]) # commit changes self.cli_commit() # Check for process in VRF systemd_override = cmd(f'cat {DDCLIENT_SYSTEMD_UNIT}') self.assertIn(f'ExecStart=ip vrf exec {vrf_name} /usr/bin/ddclient -file {DDCLIENT_CONF}', systemd_override) # Check for process in VRF proc = cmd(f'ip vrf pids {vrf_name}') self.assertIn(DDCLIENT_PNAME, proc) # Cleanup VRF self.cli_delete(['vrf', 'name', vrf_name]) if __name__ == '__main__': unittest.main(verbosity=2)