diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 598ec8a8d..8c4b5bcbe 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -30,10 +30,30 @@ jobs: name: testrun_package path: testrun*.deb - install_package: + install_package_20: permissions: {} needs: create_package - name: Install + name: Install on Ubuntu 20.04 + runs-on: ubuntu-20.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb + + install_package_22: + permissions: {} + needs: create_package + name: Install on Ubuntu 22.04 runs-on: ubuntu-22.04 timeout-minutes: 15 steps: @@ -49,3 +69,23 @@ jobs: - name: Install package shell: bash {0} run: sudo apt install ./testrun*.deb + + install_package_24: + permissions: {} + needs: create_package + name: Install on Ubuntu 24.04 + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Download package + uses: actions/download-artifact@v4 + with: + name: testrun_package + - name: Install dependencies + shell: bash {0} + run: sudo cmd/prepare + - name: Install package + shell: bash {0} + run: sudo apt install ./testrun*.deb diff --git a/.gitignore b/.gitignore index 68203577f..92779dc04 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ # Ignore generated files from unit tests testing/unit_test/temp/ +testing/unit/conn/output/ testing/unit/dns/output/ testing/unit/nmap/output/ testing/unit/ntp/output/ diff --git a/framework/python/src/common/mqtt.py b/framework/python/src/common/mqtt.py index 32cb421eb..c58d24d3f 100644 --- a/framework/python/src/common/mqtt.py +++ b/framework/python/src/common/mqtt.py @@ -33,6 +33,7 @@ class MQTT: def __init__(self) -> None: self._host = WEBSOCKETS_HOST self._client = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2) + LOGGER.setLevel(logger.logging.INFO) self._client.enable_logger(LOGGER) def _connect(self): diff --git a/framework/python/src/common/session.py b/framework/python/src/common/session.py index 7c2ef5e8f..c7768c652 100644 --- a/framework/python/src/common/session.py +++ b/framework/python/src/common/session.py @@ -17,7 +17,8 @@ import pytz import json import os -from common import util, logger +from fastapi.encoders import jsonable_encoder +from common import util, logger, mqtt from common.risk_profile import RiskProfile from net_orc.ip_control import IPControl @@ -37,6 +38,7 @@ MAX_DEVICE_REPORTS_KEY = 'max_device_reports' CERTS_PATH = 'local/root_certs' CONFIG_FILE_PATH = 'local/system.json' +STATUS_TOPIC = 'status' PROFILE_FORMAT_PATH = 'resources/risk_assessment.json' PROFILES_DIR = 'local/risk_profiles' @@ -44,6 +46,34 @@ LOGGER = logger.get_logger('session') +def session_tracker(method): + """Session changes tracker.""" + def wrapper(self, *args, **kwargs): + + result = method(self, *args, **kwargs) + + if self.get_status() != 'Idle': + self.get_mqtt_client().send_message( + STATUS_TOPIC, + jsonable_encoder(self.to_json()) + ) + + return result + return wrapper + +def apply_session_tracker(cls): + """Applies tracker decorator to class methods""" + for attr in dir(cls): + if (callable(getattr(cls, attr)) + and not attr.startswith('_') + and not attr.startswith('get') + and not attr == 'to_json' + ): + setattr(cls, attr, session_tracker(getattr(cls, attr))) + return cls + + +@apply_session_tracker class TestrunSession(): """Represents the current session of Test Run.""" @@ -109,6 +139,9 @@ def __init__(self, root_dir): self._timezone = tz[0] LOGGER.debug(f'System timezone is {self._timezone}') + # MQTT client + self._mqtt_client = mqtt.MQTT() + def start(self): self.reset() self._status = 'Waiting for Device' @@ -333,6 +366,11 @@ def add_test_result(self, result): if not updated: self._results.append(result) + def set_test_result_error(self, result): + """Set test result error""" + result.result = 'Error' + self._results.append(result) + def add_module_report(self, module_report): self._module_reports.append(module_report) @@ -731,5 +769,8 @@ def detect_network_adapters_change(self) -> dict: self._ifaces = ifaces_new return adapters + def get_mqtt_client(self): + return self._mqtt_client + def get_ifaces(self): return self._ifaces diff --git a/framework/python/src/common/tasks.py b/framework/python/src/common/tasks.py index c71742ced..74a64abd5 100644 --- a/framework/python/src/common/tasks.py +++ b/framework/python/src/common/tasks.py @@ -22,6 +22,7 @@ from common import logger +# Check adapters period seconds # Check adapters period seconds CHECK_NETWORK_ADAPTERS_PERIOD = 5 INTERNET_CONNECTION_TOPIC = 'events/internet' diff --git a/framework/python/src/net_orc/ip_control.py b/framework/python/src/net_orc/ip_control.py index abefe6b09..2a97c6383 100644 --- a/framework/python/src/net_orc/ip_control.py +++ b/framework/python/src/net_orc/ip_control.py @@ -17,6 +17,7 @@ from common import logger from common import util import re +import socket LOGGER = logger.get_logger('ip_ctrl') @@ -45,10 +46,7 @@ def add_namespace(self, namespace): def check_interface_status(self, interface_name): output = util.run_command(cmd=f'ip link show {interface_name}', output=True) - if 'state DOWN ' in output[0]: - return False - else: - return True + return 'state UP ' in output[0] def delete_link(self, interface_name): """Delete an ip link""" @@ -99,6 +97,14 @@ def get_iface_port_stats(self, iface): else: return None + def get_ip_address(self, iface): + addrs = psutil.net_if_addrs() + if iface in addrs: + for addr in addrs[iface]: + if addr.family == socket.AF_INET: + return addr.address + return None + def get_namespaces(self): result = util.run_command('ip netns list') # Strip ID's from the namespace results diff --git a/framework/python/src/net_orc/network_orchestrator.py b/framework/python/src/net_orc/network_orchestrator.py index b5bc995bf..dbfc27df7 100644 --- a/framework/python/src/net_orc/network_orchestrator.py +++ b/framework/python/src/net_orc/network_orchestrator.py @@ -138,6 +138,9 @@ def start_network(self): # Get network ready (via Network orchestrator) LOGGER.debug('Network is ready') + def get_ip_address(self, iface): + return self._ip_ctrl.get_ip_address(iface) + def get_listener(self): return self._listener @@ -494,14 +497,15 @@ def _load_network_module(self, module_dir): # Load network service networking configuration if net_module.enable_container: - net_module.net_config.enable_wan = net_module_json['config']['network'][ - 'enable_wan'] - net_module.net_config.ip_index = net_module_json['config']['network'][ - 'ip_index'] - net_module.net_config.host = False if not 'host' in net_module_json[ 'config']['network'] else net_module_json['config']['network']['host'] + if not net_module.net_config.host: + net_module.net_config.enable_wan = net_module_json['config']['network'][ + 'enable_wan'] + net_module.net_config.ip_index = net_module_json['config']['network'][ + 'ip_index'] + net_module.net_config.ipv4_address = self.network_config.ipv4_network[ net_module.net_config.ip_index] net_module.net_config.ipv4_network = self.network_config.ipv4_network @@ -538,26 +542,29 @@ def _get_network_module(self, name): def _start_network_service(self, net_module): LOGGER.debug('Starting network service ' + net_module.display_name) - network = 'host' if net_module.net_config.host else PRIVATE_DOCKER_NET + network = 'host' if net_module.net_config.host else 'bridge' LOGGER.debug(f"""Network: {network}, image name: {net_module.image_name}, container name: {net_module.container_name}""") try: client = docker.from_env() net_module.container = client.containers.run( - net_module.image_name, - auto_remove=True, - cap_add=['NET_ADMIN'], - name=net_module.container_name, - hostname=net_module.container_name, - network_mode='none', - privileged=True, - detach=True, - mounts=net_module.mounts, - environment={ - 'TZ': self.get_session().get_timezone(), - 'HOST_USER': util.get_host_user() - }) + net_module.image_name, + auto_remove=True, + cap_add=['NET_ADMIN'], + name=net_module.container_name, + hostname=net_module.container_name, + # Undetermined version of docker seems to have broken + # DNS configuration (/etc/resolv.conf) Re-add when/if + # this network is utilized and DNS issue is resolved + network=network, + privileged=True, + detach=True, + mounts=net_module.mounts, + environment={ + 'TZ': self.get_session().get_timezone(), + 'HOST_USER': util.get_host_user() + }) except docker.errors.ContainerError as error: LOGGER.error('Container run error') LOGGER.error(error) @@ -793,9 +800,15 @@ def network_adapters_checker(self, mqtt_client: mqtt.MQTT, topic: str): adapters = self._session.detect_network_adapters_change() if adapters: mqtt_client.send_message(topic, adapters) - except Exception: + except Exception: # pylint: disable=W0718 LOGGER.error(traceback.format_exc()) + def is_device_connected(self): + """Check if device connected""" + return self._ip_ctrl.check_interface_status( + self._session.get_device_interface() + ) + def internet_conn_checker(self, mqtt_client: mqtt.MQTT, topic: str): """Checks internet connection and sends a status to frontend""" @@ -825,7 +838,6 @@ def internet_conn_checker(self, mqtt_client: mqtt.MQTT, topic: str): # Broadcast via MQTT client mqtt_client.send_message(topic, message) - class NetworkModule: """Define all the properties of a Network Module""" diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index aeae604ae..afd748d35 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -30,7 +30,7 @@ LOG_NAME = "test_orc" LOGGER = logger.get_logger("test_orc") RUNTIME_DIR = "runtime" -RUNTIME_TEST_DIR = os.path.join(RUNTIME_DIR,"test") +RUNTIME_TEST_DIR = os.path.join(RUNTIME_DIR, "test") TEST_MODULES_DIR = "modules/test" MODULE_CONFIG = "conf/module_config.json" LOG_REGEX = r"^[A-Z][a-z]{2} [0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} test_" @@ -60,6 +60,8 @@ def __init__(self, session, net_orc): os.path.dirname( os.path.dirname( os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))) + self._test_modules_running = [] + self._current_module = 0 def start(self): LOGGER.debug("Starting test orchestrator") @@ -119,7 +121,13 @@ def run_test_modules(self): # Increment number of tests that will be run self.get_session().add_total_tests(len(module.tests)) - for module in test_modules: + # Store enabled test modules in the TestsOrchectrator object + self._test_modules_running = test_modules + self._current_module = 0 + + for index, module in enumerate(test_modules): + + self._current_module = index self._run_test_module(module) LOGGER.info("All tests complete") @@ -174,9 +182,7 @@ def _write_reports(self, test_report): def _generate_report(self): report = {} - report["testrun"] = { - "version": self.get_session().get_version() - } + report["testrun"] = {"version": self.get_session().get_version()} report["mac_addr"] = self.get_session().get_target_device().mac_addr report["device"] = self.get_session().get_target_device().to_dict() @@ -199,7 +205,10 @@ def _calculate_result(self): for test_result in self._session.get_test_results(): # Check Required tests if (test_result.required_result.lower() == "required" - and test_result.result.lower() != "compliant"): + and test_result.result.lower() not in [ + "compliant", + "error" + ]): result = "Non-Compliant" # Check Required if Applicable tests elif (test_result.required_result.lower() == "required if applicable" @@ -287,25 +296,20 @@ def _timestamp_results(self, device): return completed_results_dir - def zip_results(self, - device, - timestamp, - profile): + def zip_results(self, device, timestamp, profile): try: LOGGER.debug("Archiving test results") - src_path = os.path.join(LOCAL_DEVICE_REPORTS.replace( - "{device_folder}", - device.device_folder), - timestamp) + src_path = os.path.join( + LOCAL_DEVICE_REPORTS.replace("{device_folder}", device.device_folder), + timestamp) # Define temp directory to store files before zipping results_dir = os.path.join(f"/tmp/testrun/{time.time()}") # Define where to save the zip file - zip_location = os.path.join("/tmp/testrun", - timestamp) + zip_location = os.path.join("/tmp/testrun", timestamp) # Delete zip_temp if it already exists if os.path.exists(results_dir): @@ -315,16 +319,13 @@ def zip_results(self, if os.path.exists(zip_location + ".zip"): os.remove(zip_location + ".zip") - shutil.copytree(src_path,results_dir) + shutil.copytree(src_path, results_dir) # Include profile if specified if profile is not None: - LOGGER.debug( - f"Copying profile {profile.name} to results directory") + LOGGER.debug(f"Copying profile {profile.name} to results directory") shutil.copy(profile.get_file_path(), - os.path.join( - results_dir, - "profile.json")) + os.path.join(results_dir, "profile.json")) with open(os.path.join(results_dir, "profile.pdf"), "wb") as f: f.write(profile.to_pdf(device).getvalue()) @@ -341,10 +342,9 @@ def zip_results(self, if os.path.exists(zip_file) else'creation failed'}''') - return zip_file - except Exception as error: # pylint: disable=W0703 + except Exception as error: # pylint: disable=W0703 LOGGER.error("Failed to create zip file") LOGGER.debug(error) return None @@ -379,7 +379,14 @@ def _run_test_module(self, module): LOGGER.info(f"Running test module {module.name}") # Get all tests to be executed and set to in progress - for test in module.tests: + for current_test,test in enumerate(module.tests): + + # Check that device is connected + if not self._net_orc.is_device_connected(): + LOGGER.error("Device was disconnected") + self._set_test_modules_error(current_test) + self._session.set_status("Cancelled") + return test_copy = copy.deepcopy(test) test_copy.result = "In Progress" @@ -411,6 +418,17 @@ def _run_test_module(self, module): device_monitor_capture = os.path.join(device_test_dir, "monitor.pcap") util.run_command(f"chown -R {self._host_user} {device_monitor_capture}") + # Resolve the main docker interface (docker0) for host interaction + # Can't use device or internet iface since these are not in a stable + # state for this type of communication during testing but docker0 has + # to exist and should always be available + external_ip = self._net_orc.get_ip_address("docker0") + LOGGER.debug(f"Using external IP: {external_ip}") + extra_hosts = { + "external.localhost": external_ip + } if external_ip is not None else {} + + #extra_hosts = {"external.localhost":"172.17.0.1"} client = docker.from_env() module.container = client.containers.run( @@ -453,8 +471,10 @@ def _run_test_module(self, module): "IPV4_ADDR": device.ip_addr, "DEVICE_TEST_MODULES": json.dumps(device.test_modules), "IPV4_SUBNET": self._net_orc.network_config.ipv4_network, - "IPV6_SUBNET": self._net_orc.network_config.ipv6_network - }) + "IPV6_SUBNET": self._net_orc.network_config.ipv6_network, + "DEV_IFACE": self._session.get_device_interface() + }, + extra_hosts=extra_hosts) except (docker.errors.APIError, docker.errors.ContainerError) as container_error: LOGGER.error("Test module " + module.name + " has failed to start") @@ -511,19 +531,19 @@ def _run_test_module(self, module): # Convert dict from json into TestCase object test_case = TestCase( - name=test_result["name"], - description=test_result["description"], - expected_behavior=test_result["expected_behavior"], - required_result=test_result["required_result"], - result=test_result["result"]) + name=test_result["name"], + description=test_result["description"], + expected_behavior=test_result["expected_behavior"], + required_result=test_result["required_result"], + result=test_result["result"]) # Any informational test should always report informational if test_case.required_result == "Informational": test_case.result = "Informational" # Add steps to resolve if test is non-compliant - if (test_case.result == "Non-Compliant" and - "recommendations" in test_result): + if (test_case.result == "Non-Compliant" + and "recommendations" in test_result): test_case.recommendations = test_result["recommendations"] else: test_case.recommendations = None @@ -533,7 +553,7 @@ def _run_test_module(self, module): except (FileNotFoundError, PermissionError, json.JSONDecodeError) as results_error: LOGGER.error( - f"Error occurred whilst obtaining results for module {module.name}") + f"Error occurred whilst obtaining results for module {module.name}") LOGGER.error(results_error) # Get the markdown report from the module if generated @@ -651,11 +671,10 @@ def _load_test_module(self, module_dir): for test_case_json in module_json["config"]["tests"]: try: test_case = TestCase( - name=test_case_json["name"], - description=test_case_json["test_description"], - expected_behavior=test_case_json["expected_behavior"], - required_result=test_case_json["required_result"] - ) + name=test_case_json["name"], + description=test_case_json["test_description"], + expected_behavior=test_case_json["expected_behavior"], + required_result=test_case_json["required_result"]) if "recommendations" in test_case_json: test_case.recommendations = test_case_json["recommendations"] @@ -752,3 +771,13 @@ def get_test_case(self, name): def get_session(self): return self._session + + def _set_test_modules_error(self, current_test): + """Set all remaining tests to error""" + for i in range(self._current_module, len(self._test_modules_running)): + start_idx = current_test if i == self._current_module else 0 + for j in range(start_idx, len(self._test_modules_running[i].tests)): + self.get_session().set_test_result_error( + self._test_modules_running[i].tests[j] + ) + diff --git a/modules/network/base/bin/start_module b/modules/network/base/bin/start_module index 8e8cb5e4b..7b1ea8e35 100644 --- a/modules/network/base/bin/start_module +++ b/modules/network/base/bin/start_module @@ -1,99 +1,105 @@ -#!/bin/bash - -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Directory where all binaries will be loaded -BIN_DIR="/testrun/bin" - -# Default interface should be veth0 for all containers -DEFAULT_IFACE=veth0 - -# Create a local user that matches the same as the host -# to be used for correct file ownership for various logs -# HOST_USER mapped in via docker container environemnt variables -useradd $HOST_USER - -# Enable IPv6 for all containers -sysctl net.ipv6.conf.all.disable_ipv6=0 -sysctl -p - -# Read in the config file -CONF_FILE="/testrun/conf/module_config.json" -CONF=`cat $CONF_FILE` - -if [[ -z $CONF ]] -then - echo "No config file present at $CONF_FILE. Exiting startup." - exit 1 -fi - -# Extract the necessary config parameters -MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') -DEFINED_IFACE=$(echo "$CONF" | jq -r '.config.network.interface') -GRPC=$(echo "$CONF" | jq -r '.config.grpc') - -# Validate the module name is present -if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] -then - echo "No module name present in $CONF_FILE. Exiting startup." - exit 1 -fi - -# Select which interace to use -if [[ -z $DEFINED_IFACE || "$DEFINED_IFACE" == "null" ]] -then - echo "No Interface Defined, defaulting to veth0" - INTF=$DEFAULT_IFACE -else - INTF=$DEFINED_IFACE -fi - -# Setup the PYTHONPATH so all imports work as expected -echo "Setting up PYTHONPATH..." -export PYTHONPATH=$($BIN_DIR/setup_python_path) -echo "PYTHONPATH: $PYTHONPATH" - -echo "Configuring binary files..." -$BIN_DIR/setup_binaries $BIN_DIR - -echo "Starting module $MODULE_NAME on local interface $INTF..." - -# Wait for interface to become ready -$BIN_DIR/wait_for_interface $INTF - -# Small pause to let the interface stabalize before starting the capture -#sleep 1 - -# Start network capture -$BIN_DIR/capture $MODULE_NAME $INTF - -# Start the grpc server -if [[ ! -z $GRPC && ! $GRPC == "null" ]] -then - GRPC_PORT=$(echo "$GRPC" | jq -r '.port') - if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] - then - echo "gRPC port resolved from config: $GRPC_PORT" - $BIN_DIR/start_grpc "-p $GRPC_PORT" - else - $BIN_DIR/start_grpc - fi -fi - -# Small pause to let all core services stabalize -sleep 3 - -# Start the networking service +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Directory where all binaries will be loaded +BIN_DIR="/testrun/bin" + +# Default interface should be veth0 for all containers +DEFAULT_IFACE=veth0 + +# Create a local user that matches the same as the host +# to be used for correct file ownership for various logs +# HOST_USER mapped in via docker container environemnt variables +useradd $HOST_USER + +# Enable IPv6 for all containers +sysctl net.ipv6.conf.all.disable_ipv6=0 +sysctl -p + +# Read in the config file +CONF_FILE="/testrun/conf/module_config.json" +CONF=`cat $CONF_FILE` + +if [[ -z $CONF ]] +then + echo "No config file present at $CONF_FILE. Exiting startup." + exit 1 +fi + +# Extract the necessary config parameters +MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') +DEFINED_IFACE=$(echo "$CONF" | jq -r '.config.network.interface') +HOST=$(echo "$CONF" | jq -r '.config.network.host') +GRPC=$(echo "$CONF" | jq -r '.config.grpc') + +# Validate the module name is present +if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] +then + echo "No module name present in $CONF_FILE. Exiting startup." + exit 1 +fi + +# Select which interace to use +if [[ -z $DEFINED_IFACE || "$DEFINED_IFACE" == "null" ]] +then + echo "No Interface Defined, defaulting to veth0" + INTF=$DEFAULT_IFACE +else + INTF=$DEFINED_IFACE +fi + +# Setup the PYTHONPATH so all imports work as expected +echo "Setting up PYTHONPATH..." +export PYTHONPATH=$($BIN_DIR/setup_python_path) +echo "PYTHONPATH: $PYTHONPATH" + +echo "Configuring binary files..." +$BIN_DIR/setup_binaries $BIN_DIR + +echo "Starting module $MODULE_NAME on local interface $INTF..." + +# Only non-host containers will have a specific +# interface for capturing +if [[ "$HOST" != "true" ]]; then + + # Wait for interface to become ready + $BIN_DIR/wait_for_interface $INTF + + # Small pause to let the interface stabalize before starting the capture + #sleep 1 + + # Start network capture + $BIN_DIR/capture $MODULE_NAME $INTF +fi + +# Start the grpc server +if [[ ! -z $GRPC && ! $GRPC == "null" ]] +then + GRPC_PORT=$(echo "$GRPC" | jq -r '.port') + if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] + then + echo "gRPC port resolved from config: $GRPC_PORT" + $BIN_DIR/start_grpc "-p $GRPC_PORT" + else + $BIN_DIR/start_grpc + fi +fi + +# Small pause to let all core services stabalize +sleep 3 + +# Start the networking service $BIN_DIR/start_network_service $MODULE_NAME $INTF \ No newline at end of file diff --git a/modules/network/base/python/src/grpc_server/start_server.py b/modules/network/base/python/src/grpc_server/start_server.py index d372949e5..9c34ec736 100644 --- a/modules/network/base/python/src/grpc_server/start_server.py +++ b/modules/network/base/python/src/grpc_server/start_server.py @@ -46,6 +46,5 @@ def run(): print('gRPC server starting on port ' + port) serve(port) - if __name__ == '__main__': run() diff --git a/modules/network/host/bin/start_network_service b/modules/network/host/bin/start_network_service new file mode 100644 index 000000000..2d5b6780b --- /dev/null +++ b/modules/network/host/bin/start_network_service @@ -0,0 +1,23 @@ +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +echo "Starting Host Service..." + + +# Keep host container running until stopped +while true; do + sleep 3 +done \ No newline at end of file diff --git a/modules/network/host/conf/module_config.json b/modules/network/host/conf/module_config.json new file mode 100644 index 000000000..87ec39a35 --- /dev/null +++ b/modules/network/host/conf/module_config.json @@ -0,0 +1,24 @@ +{ + "config": { + "meta": { + "name": "host", + "display_name": "Host", + "description": "Used to access host level networking operations" + }, + "network": { + "host": true + }, + "grpc":{ + "port": 5001 + }, + "docker": { + "depends_on": "base", + "mounts": [ + { + "source": "runtime/network", + "target": "/runtime/network" + } + ] + } + } +} \ No newline at end of file diff --git a/modules/network/host/host.Dockerfile b/modules/network/host/host.Dockerfile new file mode 100644 index 000000000..579d62a2d --- /dev/null +++ b/modules/network/host/host.Dockerfile @@ -0,0 +1,34 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Image name: test-run/host +FROM test-run/base:latest + +ARG MODULE_NAME=host +ARG MODULE_DIR=modules/network/$MODULE_NAME + +#Update and get all additional requirements not contained in the base image +RUN apt-get update --fix-missing + +# Install all necessary packages +RUN apt-get install -y net-tools ethtool + +# Copy over all configuration files +COPY $MODULE_DIR/conf /testrun/conf + +# Copy over all binary files +COPY $MODULE_DIR/bin /testrun/bin + +# Copy over all python files +COPY $MODULE_DIR/python /testrun/python diff --git a/modules/network/host/python/src/grpc_server/network_service.py b/modules/network/host/python/src/grpc_server/network_service.py new file mode 100644 index 000000000..cbb3a1b7a --- /dev/null +++ b/modules/network/host/python/src/grpc_server/network_service.py @@ -0,0 +1,120 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""gRPC Network Service for the Host network module""" +import proto.grpc_pb2_grpc as pb2_grpc +import proto.grpc_pb2 as pb2 + +import traceback +from common import logger +from common import util + +LOG_NAME = 'network_service' +LOGGER = None + + +class NetworkService(pb2_grpc.HostNetworkModule): + """gRPC endpoints for the Host container""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'host') + + def CheckInterfaceStatus(self, request, context): # pylint: disable=W0613 + try: + status = self.check_interface_status(request.iface_name) + return pb2.CheckInterfaceStatusResponse(code=200, status=status) + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to read iface status: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.CheckInterfaceStatusResponse(code=500, status=False) + + def GetIfaceConnectionStats(self, request, context): # pylint: disable=W0613 + try: + stats = self.get_iface_connection_stats(request.iface_name) + return pb2.GetIfaceStatsResponse(code=200, stats=stats) + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to read connection stats: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.GetIfaceStatsResponse(code=500, stats=False) + + def GetIfacePortStats(self, request, context): # pylint: disable=W0613 + try: + stats = self.get_iface_port_stats(request.iface_name) + return pb2.GetIfaceStatsResponse(code=200, stats=stats) + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to read port stats: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.GetIfaceStatsResponse(code=500, stats=False) + + def SetIfaceDown(self, request, context): # pylint: disable=W0613 + try: + success = self.set_interface_down(request.iface_name) + return pb2.SetIfaceResponse(code=200, success=success) + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to set interface down: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.SetIfaceResponse(code=500, success=False) + + def SetIfaceUp(self, request, context): # pylint: disable=W0613 + try: + success = self.set_interface_up(request.iface_name) + return pb2.SetIfaceResponse(code=200, success=success) + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to set interface up: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.SetIfaceResponse(code=500, success=False) + + def check_interface_status(self, interface_name): + output = util.run_command(cmd=f'ip link show {interface_name}', output=True) + if 'state DOWN ' in output[0]: + return False + else: + return True + + def get_iface_connection_stats(self, iface): + """Extract information about the physical connection""" + response = util.run_command(f'ethtool {iface}') + if len(response[1]) == 0: + return response[0] + else: + return None + + def get_iface_port_stats(self, iface): + """Extract information about packets connection""" + response = util.run_command(f'ethtool -S {iface}') + if len(response[1]) == 0: + return response[0] + else: + return None + + def set_interface_up(self, interface_name): + """Set the interface to the up state""" + response = util.run_command('ip link set dev ' + interface_name + ' up') + if len(response[1]) == 0: + return response[0] + else: + return None + + def set_interface_down(self, interface_name): + """Set the interface to the up state""" + response = util.run_command('ip link set dev ' + interface_name + ' down') + if len(response[1]) == 0: + return response[0] + else: + return None diff --git a/modules/network/host/python/src/grpc_server/proto/grpc.proto b/modules/network/host/python/src/grpc_server/proto/grpc.proto new file mode 100644 index 000000000..c881b13f7 --- /dev/null +++ b/modules/network/host/python/src/grpc_server/proto/grpc.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +service HostNetworkModule { + + rpc CheckInterfaceStatus(CheckInterfaceStatusRequest) returns (CheckInterfaceStatusResponse) {}; + rpc GetIfaceConnectionStats(GetIfaceStatsRequest) returns (GetIfaceStatsResponse) {}; + rpc SetIfaceDown(SetIfaceRequest) returns (SetIfaceResponse) {}; + rpc SetIfaceUp(SetIfaceRequest) returns (SetIfaceResponse) {}; +} + +message CheckInterfaceStatusRequest { + string iface_name = 1; +} + +message CheckInterfaceStatusResponse { + int32 code = 1; + bool status = 2; +} + +message GetIfaceStatsRequest { + string iface_name = 1; +} + +message GetIfaceStatsResponse { + int32 code = 1; + string stats = 2; +} + +message SetIfaceRequest { + string iface_name = 1; +} + +message SetIfaceResponse { + int32 code = 1; + bool success = 2; +} + diff --git a/modules/network/host/python/src/grpc_server/start_server.py b/modules/network/host/python/src/grpc_server/start_server.py new file mode 100644 index 000000000..962277188 --- /dev/null +++ b/modules/network/host/python/src/grpc_server/start_server.py @@ -0,0 +1,50 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Base class for starting the gRPC server for a network module.""" +from concurrent import futures +import grpc +import proto.grpc_pb2_grpc as pb2_grpc +from network_service import NetworkService +import argparse + +DEFAULT_PORT = '5001' + + +def serve(port): + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + pb2_grpc.add_HostNetworkModuleServicer_to_server(NetworkService(), server) + server.add_insecure_port('[::]:' + port) + server.start() + server.wait_for_termination() + + +def run(): + parser = argparse.ArgumentParser( + description='GRPC Server for Network Module', + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('-p', + '--port', + default=DEFAULT_PORT, + help='Define the default port to run the server on.') + + args = parser.parse_args() + + port = args.port + + print('gRPC server starting on port ' + port) + serve(port) + +if __name__ == '__main__': + run() diff --git a/modules/test/base/base.Dockerfile b/modules/test/base/base.Dockerfile index 4d8c0399a..5a9013cf1 100644 --- a/modules/test/base/base.Dockerfile +++ b/modules/test/base/base.Dockerfile @@ -49,6 +49,7 @@ ARG CONTAINER_PROTO_DIR=testrun/python/src/grpc_server/proto COPY $NET_MODULE_DIR/dhcp-1/$NET_MODULE_PROTO_DIR $CONTAINER_PROTO_DIR/dhcp1/ COPY $NET_MODULE_DIR/dhcp-2/$NET_MODULE_PROTO_DIR $CONTAINER_PROTO_DIR/dhcp2/ +COPY $NET_MODULE_DIR/host/$NET_MODULE_PROTO_DIR $CONTAINER_PROTO_DIR/host/ # Copy the cached version of oui.txt incase the download fails RUN mkdir -p /usr/local/etc diff --git a/modules/test/base/bin/setup b/modules/test/base/bin/setup new file mode 100644 index 000000000..23c96c513 --- /dev/null +++ b/modules/test/base/bin/setup @@ -0,0 +1,71 @@ +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Define the local mount point to store local files to +export OUTPUT_DIR="/runtime/output" + +# Directory where all binaries will be loaded +export BIN_DIR="/testrun/bin" + +# Default interface should be veth0 for all containers +export IFACE=veth0 + +# Create a local user that matches the same as the host +# to be used for correct file ownership for various logs +# HOST_USER mapped in via docker container environemnt variables +useradd $HOST_USER + +# Set permissions on the output files +chown -R $HOST_USER $OUTPUT_DIR + +# Enable IPv6 for all containers +sysctl net.ipv6.conf.all.disable_ipv6=0 +sysctl -p + +# Read in the config file +CONF_FILE="/testrun/conf/module_config.json" +CONF=`cat $CONF_FILE` + +if [[ -z $CONF ]] +then + echo "No config file present at $CONF_FILE. Exiting startup." + exit 1 +fi + +# Extract the necessary config parameters +export MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') +export NETWORK_REQUIRED=$(echo "$CONF" | jq -r '.config.network') +export GRPC=$(echo "$CONF" | jq -r '.config.grpc') + +# Validate the module name is present +if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] +then + echo "No module name present in $CONF_FILE. Exiting startup." + exit 1 +fi + +# Setup the PYTHONPATH so all imports work as expected +echo "Setting up PYTHONPATH..." +export PYTHONPATH=$($BIN_DIR/setup_python_path) +echo "PYTHONPATH: $PYTHONPATH" + +echo "Configuring binary files..." +$BIN_DIR/setup_binaries $BIN_DIR + +# Build all gRPC files from the proto for use in +# gRPC clients for communications to network modules +echo "Building gRPC files from available proto files..." +$BIN_DIR/setup_grpc_clients \ No newline at end of file diff --git a/modules/test/base/bin/start b/modules/test/base/bin/start index 37902b868..d1f29989f 100755 --- a/modules/test/base/bin/start +++ b/modules/test/base/bin/start @@ -14,4 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -/testrun/bin/start_module \ No newline at end of file +# Allow one argument which is the unit test file to run +# instead of running the test module +UNIT_TEST_FILE=$1 + +source /testrun/bin/setup + +# Conditionally run start_module based on RUN +if [[ -z "$UNIT_TEST_FILE" ]];then + /testrun/bin/start_module +else + python3 $UNIT_TEST_FILE +fi diff --git a/modules/test/base/bin/start_module b/modules/test/base/bin/start_module index 0ee68fa6a..fb79cc018 100644 --- a/modules/test/base/bin/start_module +++ b/modules/test/base/bin/start_module @@ -1,102 +1,46 @@ -#!/bin/bash - -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Define the local mount point to store local files to -OUTPUT_DIR="/runtime/output" - -# Directory where all binaries will be loaded -BIN_DIR="/testrun/bin" - -# Default interface should be veth0 for all containers -IFACE=veth0 - -# Create a local user that matches the same as the host -# to be used for correct file ownership for various logs -# HOST_USER mapped in via docker container environemnt variables -useradd $HOST_USER - -# Set permissions on the output files -chown -R $HOST_USER $OUTPUT_DIR - -# Enable IPv6 for all containers -sysctl net.ipv6.conf.all.disable_ipv6=0 -sysctl -p - -# Read in the config file -CONF_FILE="/testrun/conf/module_config.json" -CONF=`cat $CONF_FILE` - -if [[ -z $CONF ]] -then - echo "No config file present at $CONF_FILE. Exiting startup." - exit 1 -fi - -# Extract the necessary config parameters -MODULE_NAME=$(echo "$CONF" | jq -r '.config.meta.name') -NETWORK_REQUIRED=$(echo "$CONF" | jq -r '.config.network') -GRPC=$(echo "$CONF" | jq -r '.config.grpc') - -# Validate the module name is present -if [[ -z "$MODULE_NAME" || "$MODULE_NAME" == "null" ]] -then - echo "No module name present in $CONF_FILE. Exiting startup." - exit 1 -fi - -# Setup the PYTHONPATH so all imports work as expected -echo "Setting up PYTHONPATH..." -export PYTHONPATH=$($BIN_DIR/setup_python_path) -echo "PYTHONPATH: $PYTHONPATH" - -# Build all gRPC files from the proto for use in -# gRPC clients for communications to network modules -echo "Building gRPC files from available proto files..." -$BIN_DIR/setup_grpc_clients - -echo "Configuring binary files..." -$BIN_DIR/setup_binaries $BIN_DIR - -echo "Starting module $MODULE_NAME..." - -# Only start network services if the test container needs -# a network connection to run its tests -if [ $NETWORK_REQUIRED == "true" ];then - # Wait for interface to become ready - $BIN_DIR/wait_for_interface $IFACE - - # Start network capture - $BIN_DIR/capture $MODULE_NAME $IFACE -fi - -# Start the grpc server -if [[ ! -z $GRPC && ! $GRPC == "null" ]] -then - GRPC_PORT=$(echo "$GRPC" | jq -r '.port') - if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] - then - echo "gRPC port resolved from config: $GRPC_PORT" - $BIN_DIR/start_grpc "-p $GRPC_PORT" - else - $BIN_DIR/start_grpc - fi -fi - -# Small pause to let all core services stabalize -sleep 3 - -# Start the test module +#!/bin/bash + +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +echo "Starting module $MODULE_NAME..." + +# Only start network services if the test container needs +# a network connection to run its tests +if [ $NETWORK_REQUIRED == "true" ];then + # Wait for interface to become ready + $BIN_DIR/wait_for_interface $IFACE + + # Start network capture + $BIN_DIR/capture $MODULE_NAME $IFACE +fi + +# Start the grpc server +if [[ ! -z $GRPC && ! $GRPC == "null" ]] +then + GRPC_PORT=$(echo "$GRPC" | jq -r '.port') + if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] + then + echo "gRPC port resolved from config: $GRPC_PORT" + $BIN_DIR/start_grpc "-p $GRPC_PORT" + else + $BIN_DIR/start_grpc + fi +fi + +# Small pause to let all core services stabalize +sleep 3 + +# Start the test module $BIN_DIR/start_test_module $MODULE_NAME $IFACE \ No newline at end of file diff --git a/modules/test/base/python/src/grpc/proto/host/client.py b/modules/test/base/python/src/grpc/proto/host/client.py new file mode 100644 index 000000000..e08d3376a --- /dev/null +++ b/modules/test/base/python/src/grpc/proto/host/client.py @@ -0,0 +1,63 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License +"""gRPC client module for the secondary DHCP Server""" +import grpc +import host.grpc_pb2_grpc as pb2_grpc +import host.grpc_pb2 as pb2 + +DEFAULT_PORT = '5001' +DEFAULT_HOST = 'external.localhost' # Default DHCP2 server + + +class Client(): + """gRPC Client for the secondary DHCP server""" + def __init__(self, port=DEFAULT_PORT, host=DEFAULT_HOST): + self._port = port + self._host = host + + # Create a gRPC channel to connect to the server + self._channel = grpc.insecure_channel(self._host + ':' + self._port) + + # Create a gRPC stub + self._stub = pb2_grpc.HostNetworkModuleStub(self._channel) + + def check_interface_status(self, iface_name): + # Create a request message + request = pb2.CheckInterfaceStatusRequest() + request.iface_name = iface_name + + # Make the RPC call + response = self._stub.CheckInterfaceStatus(request) + + return response + + def set_iface_down(self, iface_name): + # Create a request message + request = pb2.SetIfaceRequest() + request.iface_name = iface_name + + # Make the RPC call + response = self._stub.SetIfaceDown(request) + + return response + + def set_iface_up(self, iface_name): + # Create a request message + request = pb2.SetIfaceRequest() + request.iface_name = iface_name + + # Make the RPC call + response = self._stub.SetIfaceUp(request) + + return response diff --git a/modules/test/baseline/baseline.Dockerfile b/modules/test/baseline/baseline.Dockerfile index 5e0288018..f7d21f8c8 100644 --- a/modules/test/baseline/baseline.Dockerfile +++ b/modules/test/baseline/baseline.Dockerfile @@ -24,11 +24,5 @@ COPY $MODULE_DIR/conf /testrun/conf # Copy over all binary files COPY $MODULE_DIR/bin /testrun/bin -# Remove incorrect line endings -RUN dos2unix /testrun/bin/* - -# Make sure all the bin files are executable -RUN chmod u+x /testrun/bin/* - # Copy over all python files COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/test/conn/conf/module_config.json b/modules/test/conn/conf/module_config.json index 5289e7eb0..6583f881e 100644 --- a/modules/test/conn/conf/module_config.json +++ b/modules/test/conn/conf/module_config.json @@ -116,6 +116,12 @@ "Enable shared address space support in the DHCP client" ] }, + { + "name": "connection.dhcp_disconnect", + "test_description": "The device under test issues a new DHCPREQUEST packet after a port ph ysical disconnection and reconnection", + "expected_behavior": "A client SHOULD use DHCP to reacquire or verify its IP address and network parameters whenever the local network parameters may have changed; e.g., at system boot time or after a disconnection from the local network, as the local network configuration may change without the client's or user's knowledge. If a client has knowledge ofa previous network address and is unable to contact a local DHCP server, the client may continue to use the previous network addres until the lease for that address expires. If the lease expires before the client can contact a DHCP server, the client must immediately discontinue use of the previous network address and may inform local users of the problem.", + "required_result": "Required" + }, { "name": "connection.single_ip", "test_description": "The network switch port connected to the device reports only one IP address for the device under test.", diff --git a/modules/test/conn/conn.Dockerfile b/modules/test/conn/conn.Dockerfile index 97c411e63..a9f523e44 100644 --- a/modules/test/conn/conn.Dockerfile +++ b/modules/test/conn/conn.Dockerfile @@ -35,11 +35,5 @@ COPY $MODULE_DIR/conf /testrun/conf # Copy over all binary files COPY $MODULE_DIR/bin /testrun/bin -# Remove incorrect line endings -RUN dos2unix /testrun/bin/* - -# Make sure all the bin files are executable -RUN chmod u+x /testrun/bin/* - # Copy over all python files COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/test/conn/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index aaaf51638..cafd1ebef 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -15,10 +15,12 @@ import util import time import traceback -from scapy.all import rdpcap, DHCP, ARP, Ether, IPv6, ICMPv6ND_NS +import os +from scapy.all import rdpcap, DHCP, ARP, Ether, ICMP, IPv6, ICMPv6ND_NS from test_module import TestModule from dhcp1.client import Client as DHCPClient1 from dhcp2.client import Client as DHCPClient2 +from host.client import Client as HostClient from dhcp_util import DHCPUtil from port_stats_util import PortStatsUtil @@ -39,7 +41,14 @@ class ConnectionModule(TestModule): """Connection Test module""" - def __init__(self, module, log_dir=None, conf_file=None, results_dir=None): + def __init__(self, + module, + log_dir=None, + conf_file=None, + results_dir=None, + startup_capture_file=STARTUP_CAPTURE_FILE, + monitor_capture_file=MONITOR_CAPTURE_FILE): + super().__init__(module_name=module, log_name=LOG_NAME, log_dir=log_dir, @@ -47,9 +56,12 @@ def __init__(self, module, log_dir=None, conf_file=None, results_dir=None): results_dir=results_dir) global LOGGER LOGGER = self._get_logger() + self.startup_capture_file = startup_capture_file + self.monitor_capture_file = monitor_capture_file self._port_stats = PortStatsUtil(logger=LOGGER) self.dhcp1_client = DHCPClient1() self.dhcp2_client = DHCPClient2() + self.host_client = HostClient() self._dhcp_util = DHCPUtil(self.dhcp1_client, self.dhcp2_client, LOGGER) self._lease_wait_time_sec = LEASE_WAIT_TIME_DEFAULT @@ -106,7 +118,8 @@ def _connection_switch_arp_inspection(self): no_arp = True # Read all the pcap files - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) for packet in packets: # We are not interested in packets unless they are ARP packets @@ -123,12 +136,8 @@ def _connection_switch_arp_inspection(self): # Check MAC address matches IP address if (arp_packet.hwsrc == self._device_mac - and (arp_packet.psrc not in ( - self._device_ipv4_addr, - '0.0.0.0' - )) and not arp_packet.psrc.startswith( - '169.254' - )): + and (arp_packet.psrc not in (self._device_ipv4_addr, '0.0.0.0')) + and not arp_packet.psrc.startswith('169.254')): LOGGER.info(f'Bad ARP packet detected for MAC: {self._device_mac}') LOGGER.info(f'''ARP packet from IP {arp_packet.psrc} does not match {self._device_ipv4_addr}''') @@ -145,7 +154,8 @@ def _connection_switch_dhcp_snooping(self): disallowed_dhcp_types = [2, 4, 5, 6, 9, 10, 12, 13, 15, 17] # Read all the pcap files - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) for packet in packets: # We are not interested in packets unless they are DHCP packets @@ -158,6 +168,11 @@ def _connection_switch_dhcp_snooping(self): dhcp_type = self._get_dhcp_type(packet) if dhcp_type in disallowed_dhcp_types: + + # Check if packet is responding with port unreachable + if ICMP in packet and packet[ICMP].type == 3: + continue + return False, 'Device has sent disallowed DHCP message' return True, 'Device does not act as a DHCP server' @@ -220,7 +235,8 @@ def _connection_single_ip(self): return result, 'No MAC address found.' # Read all the pcap files containing DHCP packet information - packets = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + packets = rdpcap(self.startup_capture_file) + rdpcap( + self.monitor_capture_file) # Extract MAC addresses from DHCP packets mac_addresses = set() @@ -366,6 +382,73 @@ def _connection_ipaddr_dhcp_failover(self, config): result = None, 'Network is not ready for this test' return result + def _connection_dhcp_disconnect(self): + LOGGER.info('Running connection.dhcp.disconnect') + result = None + description = '' + dev_iface = os.getenv('DEV_IFACE') + iface_status = self.host_client.check_interface_status(dev_iface) + if iface_status.code == 200: + LOGGER.info('Successfully resolved iface status') + if iface_status.status: + lease = self._dhcp_util.get_cur_lease(mac_address=self._device_mac, + timeout=self._lease_wait_time_sec) + if lease is not None: + LOGGER.info('Current device lease resolved') + if self._dhcp_util.is_lease_active(lease): + + # Disable the device interface + iface_down = self.host_client.set_iface_down(dev_iface) + if iface_down: + LOGGER.info('Device interface set to down state') + + # Wait for the lease to expire + self._dhcp_util.wait_for_lease_expire(lease, + self._lease_wait_time_sec) + + # Wait an additonal 10 seconds to better test a true disconnect + # state + LOGGER.info('Waiting 10 seconds before bringing iface back up') + time.sleep(10) + + # Enable the device interface + iface_up = self.host_client.set_iface_up(dev_iface) + if iface_up: + LOGGER.info('Device interface set to up state') + + # Confirm device receives a new lease + if self._dhcp_util.get_cur_lease( + mac_address=self._device_mac, + timeout=self._lease_wait_time_sec): + if self._dhcp_util.is_lease_active(lease): + result = True + description = ( + 'Device received a DHCP lease after disconnect') + else: + result = False + description = ( + 'Could not confirm DHCP lease active after disconnect') + else: + result = False + description = ( + 'Device did not recieve a DHCP lease after disconnect') + else: + result = 'Error' + description = 'Failed to set device interface to up state' + else: + result = 'Error' + description = 'Failed to set device interface to down state' + else: + result = 'Error' + description = 'No active lease available for device' + else: + result = 'Error' + description = 'Device interface is down' + else: + result = 'Error' + description = 'Device interface could not be resolved' + return result, description + def _get_oui_manufacturer(self, mac_address): # Do some quick fixes on the format of the mac_address # to match the oui file pattern @@ -394,8 +477,9 @@ def _connection_ipv6_slaac(self): return result def _has_slaac_addres(self): - packet_capture = (rdpcap(STARTUP_CAPTURE_FILE) + - rdpcap(MONITOR_CAPTURE_FILE) + rdpcap(DHCP_CAPTURE_FILE)) + packet_capture = (rdpcap(self.startup_capture_file) + + rdpcap(self.monitor_capture_file) + + rdpcap(DHCP_CAPTURE_FILE)) sends_ipv6 = False for packet_number, packet in enumerate(packet_capture, start=1): if IPv6 in packet and packet.src == self._device_mac: @@ -432,7 +516,7 @@ def _ping(self, host, ipv6=False): cmd += ' -6 ' if ipv6 else '' cmd += str(host) #cmd = 'ping -c 1 ' + str(host) - success = util.run_command(cmd, output=False) # pylint: disable=E1120 + success = util.run_command(cmd, output=False) # pylint: disable=E1120 return success def restore_failover_dhcp_server(self, subnet): diff --git a/modules/test/dns/dns.Dockerfile b/modules/test/dns/dns.Dockerfile index 1b0703ad3..0197fd72e 100644 --- a/modules/test/dns/dns.Dockerfile +++ b/modules/test/dns/dns.Dockerfile @@ -30,11 +30,5 @@ COPY $MODULE_DIR/conf /testrun/conf # Copy over all binary files COPY $MODULE_DIR/bin /testrun/bin -# Remove incorrect line endings -RUN dos2unix /testrun/bin/* - -# Make sure all the bin files are executable -RUN chmod u+x /testrun/bin/* - # Copy over all python files COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/test/ntp/ntp.Dockerfile b/modules/test/ntp/ntp.Dockerfile index c0c742596..33b06287e 100644 --- a/modules/test/ntp/ntp.Dockerfile +++ b/modules/test/ntp/ntp.Dockerfile @@ -16,11 +16,5 @@ COPY $MODULE_DIR/conf /testrun/conf # Copy over all binary files COPY $MODULE_DIR/bin /testrun/bin -# Remove incorrect line endings -RUN dos2unix /testrun/bin/* - -# Make sure all the bin files are executable -RUN chmod u+x /testrun/bin/* - # Copy over all python files COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/test/protocol/protocol.Dockerfile b/modules/test/protocol/protocol.Dockerfile index ce7b7b272..6f55520e1 100644 --- a/modules/test/protocol/protocol.Dockerfile +++ b/modules/test/protocol/protocol.Dockerfile @@ -39,11 +39,5 @@ COPY $MODULE_DIR/bin /testrun/bin # Copy over all binary files COPY $MODULE_DIR/bin /testrun/bin -# Remove incorrect line endings -RUN dos2unix /testrun/bin/* - -# Make sure all the bin files are executable -RUN chmod u+x /testrun/bin/* - # Copy over all python files COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/test/services/services.Dockerfile b/modules/test/services/services.Dockerfile index cad8ea314..3a89fc33c 100644 --- a/modules/test/services/services.Dockerfile +++ b/modules/test/services/services.Dockerfile @@ -30,11 +30,5 @@ COPY $MODULE_DIR/conf /testrun/conf # Copy over all binary files COPY $MODULE_DIR/bin /testrun/bin -# Remove incorrect line endings -RUN dos2unix /testrun/bin/* - -# Make sure all the bin files are executable -RUN chmod u+x /testrun/bin/* - # Copy over all python files COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/test/tls/python/src/tls_util.py b/modules/test/tls/python/src/tls_util.py index bee4015af..0364479c6 100644 --- a/modules/test/tls/python/src/tls_util.py +++ b/modules/test/tls/python/src/tls_util.py @@ -529,7 +529,7 @@ def process_hello_packets(self, LOGGER.info('Checking client ciphers: ' + str(packet)) if packet['cipher_support']['ecdh'] and packet['cipher_support'][ 'ecdsa']: - LOGGER.info('Valid ciphers detected') + LOGGER.info('Required ciphers detected') client_hello_results['valid'].append(packet) # If a previous hello packet to the same destination failed, # we can now remove it as it has passed on a different attempt @@ -539,7 +539,7 @@ def process_hello_packets(self, if packet['dst_ip'] in str(invalid_packet): client_hello_results['invalid'].remove(invalid_packet) else: - LOGGER.info('Invalid ciphers detected') + LOGGER.info('Required ciphers not detected') if packet['dst_ip'] not in allowed_protocol_client_ips: if packet['dst_ip'] not in str(client_hello_results['invalid']): client_hello_results['invalid'].append(packet) diff --git a/modules/ui/src/app/interceptors/error.interceptor.spec.ts b/modules/ui/src/app/interceptors/error.interceptor.spec.ts index bd8950993..7271223a2 100644 --- a/modules/ui/src/app/interceptors/error.interceptor.spec.ts +++ b/modules/ui/src/app/interceptors/error.interceptor.spec.ts @@ -50,7 +50,7 @@ describe('ErrorInterceptor', () => { expect(interceptor).toBeTruthy(); }); - it('should notify about backend errors', done => { + it('should notify about backend errors with message if exist', done => { const next: HttpHandler = { handle: () => { return throwError( @@ -61,6 +61,24 @@ describe('ErrorInterceptor', () => { const requestMock = new HttpRequest('GET', '/test'); + interceptor.intercept(requestMock, next).subscribe( + () => ({}), + () => { + expect(notificationServiceMock.notify).toHaveBeenCalledWith('error'); + done(); + } + ); + }); + + it('should notify about backend errors with default message', done => { + const next: HttpHandler = { + handle: () => { + return throwError(new HttpErrorResponse({ status: 500 })); + }, + }; + + const requestMock = new HttpRequest('GET', '/test'); + interceptor.intercept(requestMock, next).subscribe( () => ({}), () => { diff --git a/modules/ui/src/app/interceptors/error.interceptor.ts b/modules/ui/src/app/interceptors/error.interceptor.ts index cfa512a85..9e653895a 100644 --- a/modules/ui/src/app/interceptors/error.interceptor.ts +++ b/modules/ui/src/app/interceptors/error.interceptor.ts @@ -66,7 +66,8 @@ export class ErrorInterceptor implements HttpInterceptor { ); } else { this.notificationService.notify( - 'Something went wrong. Check the Terminal for details.' + error.error?.error || + 'Something went wrong. Check the Terminal for details.' ); console.error(error.error?.error || error.message); } diff --git a/modules/ui/src/app/mocks/profile.mock.ts b/modules/ui/src/app/mocks/profile.mock.ts index f2dbe82c8..d53703809 100644 --- a/modules/ui/src/app/mocks/profile.mock.ts +++ b/modules/ui/src/app/mocks/profile.mock.ts @@ -182,3 +182,30 @@ export const COPY_PROFILE_MOCK: Profile = { }, ], }; + +export const OUTDATED_DRAFT_PROFILE_MOCK: Profile = { + name: 'Outdated profile', + status: ProfileStatus.DRAFT, + questions: [ + { + question: 'Old question', + answer: 'qwerty', + }, + { + question: 'What is the email of the device owner(s)?', + answer: 'boddey@google.com, cmeredith@google.com', + }, + { + question: 'What type of device do you need reviewed?', + answer: 'IoT Sensor', + }, + { + question: 'Another old question', + answer: 'qwerty', + }, + ], +}; + +export const EXPIRED_PROFILE_MOCK: Profile = Object.assign({}, PROFILE_MOCK, { + status: ProfileStatus.EXPIRED, +}); diff --git a/modules/ui/src/app/model/topic.ts b/modules/ui/src/app/model/topic.ts index 955b65f59..d330dbb82 100644 --- a/modules/ui/src/app/model/topic.ts +++ b/modules/ui/src/app/model/topic.ts @@ -1,6 +1,7 @@ export enum Topic { NetworkAdapters = 'events/adapter', InternetConnection = 'events/internet', + Status = 'status', } export interface InternetConnection { diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts index 4a64d2b2c..54a87e3b9 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.spec.ts @@ -21,6 +21,7 @@ import { COPY_PROFILE_MOCK, NEW_PROFILE_MOCK, NEW_PROFILE_MOCK_DRAFT, + OUTDATED_DRAFT_PROFILE_MOCK, PROFILE_FORM, PROFILE_MOCK, PROFILE_MOCK_2, @@ -416,6 +417,24 @@ describe('ProfileFormComponent', () => { }); describe('Class tests', () => { + describe('with outdated draft profile', () => { + beforeEach(() => { + component.selectedProfile = OUTDATED_DRAFT_PROFILE_MOCK; + fixture.detectChanges(); + }); + + it('should have an error when uses the name of copy profile', () => { + expect(component.profileForm.value).toEqual({ + 0: '', + 1: 'IoT Sensor', + 2: '', + 3: { 0: false, 1: false, 2: false }, + 4: '', + name: 'Outdated profile', + }); + }); + }); + describe('with profile', () => { beforeEach(() => { component.selectedProfile = PROFILE_MOCK; diff --git a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts index 684bc67da..567eb6c34 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-form/profile-form.component.ts @@ -207,16 +207,19 @@ export class ProfileFormComponent implements OnInit { fillProfileForm(profileFormat: ProfileFormat[], profile: Profile): void { this.nameControl.setValue(profile.name); profileFormat.forEach((question, index) => { + const answer = profile.questions.find( + answers => answers.question === question.question + ); if (question.type === FormControlType.SELECT_MULTIPLE) { question.options?.forEach((item, idx) => { - if ((profile.questions[index].answer as number[])?.includes(idx)) { + if ((answer?.answer as number[])?.includes(idx)) { this.getFormGroup(index).controls[idx].setValue(true); } else { this.getFormGroup(index).controls[idx].setValue(false); } }); } else { - this.getControl(index).setValue(profile.questions[index].answer); + this.getControl(index).setValue(answer?.answer || ''); } }); this.nameControl.markAsTouched(); diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html index 1384226fc..31049cd93 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.html @@ -22,16 +22,21 @@ class="profile-item-info" role="button" tabindex="0" + #tooltip="matTooltip" matTooltip="{{ profile.status === ProfileStatus.EXPIRED - ? 'Expired. Please, create a new Risk profile.' + ? EXPIRED_TOOLTIP : profile.status }}" (click)="profileClicked.emit(profile)" - (keydown.enter)="profileClicked.emit(profile)"> + (keydown.enter)="enterProfileItem(profile)"> + [attr.aria-label]=" + profile.status === ProfileStatus.EXPIRED + ? EXPIRED_TOOLTIP + : profile.status + "> { let component: ProfileItemComponent; @@ -25,11 +29,16 @@ describe('ProfileItemComponent', () => { let compiled: HTMLElement; const testRunServiceMock = jasmine.createSpyObj(['getRiskClass']); - + const mockLiveAnnouncer = jasmine.createSpyObj('mockLiveAnnouncer', [ + 'announce', + ]); beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ProfileItemComponent], - providers: [{ provide: TestRunService, useValue: testRunServiceMock }], + providers: [ + { provide: TestRunService, useValue: testRunServiceMock }, + { provide: LiveAnnouncer, useValue: mockLiveAnnouncer }, + ], }).compileComponents(); fixture = TestBed.createComponent(ProfileItemComponent); @@ -88,4 +97,22 @@ describe('ProfileItemComponent', () => { expect(profileClickedSpy).toHaveBeenCalledWith(PROFILE_MOCK); }); + + describe('with Expired profile', () => { + beforeEach(() => { + component.enterProfileItem(EXPIRED_PROFILE_MOCK); + }); + + it('should change tooltip on enterProfileItem', () => { + expect(component.tooltip.message).toEqual( + 'This risk profile is outdated. Please create a new risk profile.' + ); + }); + + it('should announce', () => { + expect(mockLiveAnnouncer.announce).toHaveBeenCalledWith( + 'This risk profile is outdated. Please create a new risk profile.' + ); + }); + }); }); diff --git a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts index 45ea3b2ca..514cbd46e 100644 --- a/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts +++ b/modules/ui/src/app/pages/risk-assessment/profile-item/profile-item.component.ts @@ -17,8 +17,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, + HostListener, Input, Output, + ViewChild, } from '@angular/core'; import { Profile, @@ -29,26 +31,55 @@ import { MatIcon } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { CommonModule } from '@angular/common'; import { TestRunService } from '../../../services/test-run.service'; -import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; @Component({ selector: 'app-profile-item', standalone: true, imports: [MatIcon, MatButtonModule, CommonModule, MatTooltipModule], + providers: [MatTooltip], templateUrl: './profile-item.component.html', styleUrl: './profile-item.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProfileItemComponent { public readonly ProfileStatus = ProfileStatus; + public readonly EXPIRED_TOOLTIP = + 'Expired. Please, create a new Risk profile.'; @Input() profile!: Profile; @Output() deleteButtonClicked = new EventEmitter(); @Output() profileClicked = new EventEmitter(); @Output() copyProfileClicked = new EventEmitter(); - constructor(private readonly testRunService: TestRunService) {} + @ViewChild('tooltip') tooltip!: MatTooltip; + + @HostListener('focusout', ['$event']) + outEvent(): void { + if (this.profile.status === ProfileStatus.EXPIRED) { + this.tooltip.message = this.EXPIRED_TOOLTIP; + } + } + + constructor( + private readonly testRunService: TestRunService, + private liveAnnouncer: LiveAnnouncer + ) {} public getRiskClass(riskResult: string): RiskResultClassName { return this.testRunService.getRiskClass(riskResult); } + + public async enterProfileItem(profile: Profile) { + if (profile.status === ProfileStatus.EXPIRED) { + this.tooltip.message = + 'This risk profile is outdated. Please create a new risk profile.'; + this.tooltip.show(); + await this.liveAnnouncer.announce( + 'This risk profile is outdated. Please create a new risk profile.' + ); + } else { + this.profileClicked.emit(profile); + } + } } diff --git a/modules/ui/src/app/services/test-run-mqtt.service.spec.ts b/modules/ui/src/app/services/test-run-mqtt.service.spec.ts index f8fd07142..19bda437a 100644 --- a/modules/ui/src/app/services/test-run-mqtt.service.spec.ts +++ b/modules/ui/src/app/services/test-run-mqtt.service.spec.ts @@ -8,6 +8,7 @@ import { of } from 'rxjs'; import { MOCK_ADAPTERS } from '../mocks/settings.mock'; import { Topic } from '../model/topic'; import { MOCK_INTERNET } from '../mocks/topic.mock'; +import { MOCK_PROGRESS_DATA_IN_PROGRESS } from '../mocks/testrun.mock'; describe('TestRunMqttService', () => { let service: TestRunMqttService; @@ -69,6 +70,28 @@ describe('TestRunMqttService', () => { }); }); + describe('getStatus', () => { + beforeEach(() => { + mockService.observe.and.returnValue( + of(getResponse(MOCK_PROGRESS_DATA_IN_PROGRESS)) + ); + }); + + it('should subscribe the topic', done => { + service.getStatus().subscribe(() => { + expect(mockService.observe).toHaveBeenCalledWith(Topic.Status); + done(); + }); + }); + + it('should return object of type', done => { + service.getStatus().subscribe(res => { + expect(res).toEqual(MOCK_PROGRESS_DATA_IN_PROGRESS); + done(); + }); + }); + }); + function getResponse(response: Type): IMqttMessage { const enc = new TextEncoder(); const message = enc.encode(JSON.stringify(response)); diff --git a/modules/ui/src/app/services/test-run-mqtt.service.ts b/modules/ui/src/app/services/test-run-mqtt.service.ts index 3483f77b6..d5e805da6 100644 --- a/modules/ui/src/app/services/test-run-mqtt.service.ts +++ b/modules/ui/src/app/services/test-run-mqtt.service.ts @@ -3,6 +3,7 @@ import { IMqttMessage, MqttService } from 'ngx-mqtt'; import { catchError, Observable, of } from 'rxjs'; import { map } from 'rxjs/operators'; import { Adapters } from '../model/setting'; +import { TestrunStatus } from '../model/testrun-status'; import { InternetConnection, Topic } from '../model/topic'; @Injectable({ @@ -19,6 +20,10 @@ export class TestRunMqttService { return this.topic(Topic.InternetConnection); } + getStatus(): Observable { + return this.topic(Topic.Status); + } + private topic(topicName: string): Observable { return this.mqttService.observe(topicName).pipe( map( diff --git a/modules/ui/src/app/store/effects.spec.ts b/modules/ui/src/app/store/effects.spec.ts index 6f6fae967..9d7984dd7 100644 --- a/modules/ui/src/app/store/effects.spec.ts +++ b/modules/ui/src/app/store/effects.spec.ts @@ -42,6 +42,7 @@ import { } from '../mocks/testrun.mock'; import { fetchSystemStatus, + fetchSystemStatusSuccess, setReports, setStatus, setTestrunStatus, @@ -52,6 +53,7 @@ import { throwError } from 'rxjs/internal/observable/throwError'; import { HttpErrorResponse } from '@angular/common/http'; import { IDLE_STATUS } from '../model/testrun-status'; import { HISTORY } from '../mocks/reports.mock'; +import { TestRunMqttService } from '../services/test-run-mqtt.service'; describe('Effects', () => { let actions$ = new Observable(); @@ -64,6 +66,8 @@ describe('Effects', () => { 'dismissWithTimout', 'openSnackBar', ]); + const mockMqttService: jasmine.SpyObj = + jasmine.createSpyObj('mockMqttService', ['getStatus']); beforeEach(() => { testRunServiceMock = jasmine.createSpyObj('testRunServiceMock', [ @@ -85,11 +89,16 @@ describe('Effects', () => { testRunServiceMock.fetchProfiles.and.returnValue(of([])); testRunServiceMock.getHistory.and.returnValue(of([])); + mockMqttService.getStatus.and.returnValue( + of(MOCK_PROGRESS_DATA_IN_PROGRESS) + ); + TestBed.configureTestingModule({ providers: [ AppEffects, { provide: TestRunService, useValue: testRunServiceMock }, { provide: NotificationService, useValue: notificationServiceMock }, + { provide: TestRunMqttService, useValue: mockMqttService }, provideMockActions(() => actions$), provideMockStore({}), ], @@ -399,14 +408,15 @@ describe('Effects', () => { ); }); - it('should call fetchSystemStatus for status "in progress"', fakeAsync(() => { + it('should call fetchSystemStatus for status "in progress"', () => { effects.onFetchSystemStatusSuccess$.subscribe(() => { - tick(5000); - - expect(dispatchSpy).toHaveBeenCalledWith(fetchSystemStatus()); - discardPeriodicTasks(); + expect(dispatchSpy).toHaveBeenCalledWith( + fetchSystemStatusSuccess({ + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + }) + ); }); - })); + }); it('should dispatch status and systemStatus', done => { effects.onFetchSystemStatusSuccess$.subscribe(() => { @@ -451,14 +461,15 @@ describe('Effects', () => { ); }); - it('should call fetchSystemStatus for status "waiting for device"', fakeAsync(() => { + it('should call fetchSystemStatus for status "waiting for device"', () => { effects.onFetchSystemStatusSuccess$.subscribe(() => { - tick(5000); - - expect(dispatchSpy).toHaveBeenCalledWith(fetchSystemStatus()); - discardPeriodicTasks(); + expect(dispatchSpy).toHaveBeenCalledWith( + fetchSystemStatusSuccess({ + systemStatus: MOCK_PROGRESS_DATA_IN_PROGRESS, + }) + ); }); - })); + }); it('should open snackbar when waiting for device is too long', fakeAsync(() => { effects.onFetchSystemStatusSuccess$.subscribe(() => { diff --git a/modules/ui/src/app/store/effects.ts b/modules/ui/src/app/store/effects.ts index b94eb55b1..c5528c695 100644 --- a/modules/ui/src/app/store/effects.ts +++ b/modules/ui/src/app/store/effects.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Injectable, NgZone } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; @@ -25,12 +25,12 @@ import { TestRunService } from '../services/test-run.service'; import { filter, combineLatest, - interval, Subject, timer, take, catchError, EMPTY, + Subscription, } from 'rxjs'; import { selectIsOpenWaitSnackBar, @@ -46,6 +46,7 @@ import { } from '../model/testrun-status'; import { fetchSystemStatus, + fetchSystemStatusSuccess, setReports, setStatus, setTestrunStatus, @@ -54,13 +55,13 @@ import { import { takeUntil } from 'rxjs/internal/operators/takeUntil'; import { NotificationService } from '../services/notification.service'; import { Profile } from '../model/profile'; +import { TestRunMqttService } from '../services/test-run-mqtt.service'; const WAIT_TO_OPEN_SNACKBAR_MS = 60 * 1000; @Injectable() export class AppEffects { - private startInterval = false; - private destroyInterval$: Subject = new Subject(); + private statusSubscription: Subscription | undefined; private destroyWaitDeviceInterval$: Subject = new Subject(); checkInterfacesInConfig$ = createEffect(() => @@ -206,8 +207,7 @@ export class AppEffects { return this.actions$.pipe( ofType(AppActions.stopInterval), tap(() => { - this.startInterval = false; - this.destroyInterval$.next(true); + this.statusSubscription?.unsubscribe(); }) ); }, @@ -219,10 +219,7 @@ export class AppEffects { return this.actions$.pipe( ofType(AppActions.fetchSystemStatusSuccess), tap(({ systemStatus }) => { - if ( - this.testrunService.testrunInProgress(systemStatus.status) && - !this.startInterval - ) { + if (this.testrunService.testrunInProgress(systemStatus.status)) { this.pullingSystemStatusData(); } else if ( !this.testrunService.testrunInProgress(systemStatus.status) @@ -251,12 +248,10 @@ export class AppEffects { tap(([{ systemStatus }, , status]) => { // for app - requires only status if (systemStatus.status !== status?.status) { - this.ngZone.run(() => { - this.store.dispatch(setStatus({ status: systemStatus.status })); - this.store.dispatch( - setTestrunStatus({ systemStatus: systemStatus }) - ); - }); + this.store.dispatch(setStatus({ status: systemStatus.status })); + this.store.dispatch( + setTestrunStatus({ systemStatus: systemStatus }) + ); } else if ( systemStatus.finished !== status?.finished || (systemStatus.tests as TestsData)?.results?.length !== @@ -264,11 +259,9 @@ export class AppEffects { (systemStatus.tests as IResult[])?.length !== (status?.tests as IResult[])?.length ) { - this.ngZone.run(() => { - this.store.dispatch( - setTestrunStatus({ systemStatus: systemStatus }) - ); - }); + this.store.dispatch( + setTestrunStatus({ systemStatus: systemStatus }) + ); } }) ); @@ -353,22 +346,23 @@ export class AppEffects { } private pullingSystemStatusData(): void { - this.ngZone.runOutsideAngular(() => { - this.startInterval = true; - interval(5000) - .pipe( - takeUntil(this.destroyInterval$), - tap(() => this.store.dispatch(fetchSystemStatus())) - ) - .subscribe(); - }); + if ( + this.statusSubscription === undefined || + this.statusSubscription?.closed + ) { + this.statusSubscription = this.testrunMqttService + .getStatus() + .subscribe(systemStatus => { + this.store.dispatch(fetchSystemStatusSuccess({ systemStatus })); + }); + } } constructor( private actions$: Actions, private testrunService: TestRunService, + private testrunMqttService: TestRunMqttService, private store: Store, - private ngZone: NgZone, private notificationService: NotificationService ) {} } diff --git a/testing/unit/conn/captures/monitor.pcap b/testing/unit/conn/captures/monitor.pcap new file mode 100644 index 000000000..0dfb85ff4 Binary files /dev/null and b/testing/unit/conn/captures/monitor.pcap differ diff --git a/testing/unit/conn/captures/startup.pcap b/testing/unit/conn/captures/startup.pcap new file mode 100644 index 000000000..dadd2edbc Binary files /dev/null and b/testing/unit/conn/captures/startup.pcap differ diff --git a/testing/unit/conn/conn_module_test.py b/testing/unit/conn/conn_module_test.py index d31a8051f..906abb754 100644 --- a/testing/unit/conn/conn_module_test.py +++ b/testing/unit/conn/conn_module_test.py @@ -13,13 +13,17 @@ # limitations under the License. """Module run all the Connection module related unit tests""" from port_stats_util import PortStatsUtil +from connection_module import ConnectionModule import os import unittest from common import logger MODULE = 'conn' -# Define the file paths -TEST_FILES_DIR = 'testing/unit/' + MODULE +# Define the directories +TEST_FILES_DIR = '/testing/unit/' + MODULE +OUTPUT_DIR = os.path.join(TEST_FILES_DIR, 'output/') +CAPTURES_DIR = os.path.join(TEST_FILES_DIR, 'captures/') + ETHTOOL_RESULTS_COMPLIANT_FILE = os.path.join(TEST_FILES_DIR, 'ethtool', 'ethtool_results_compliant.txt') ETHTOOL_RESULTS_NONCOMPLIANT_FILE = os.path.join( @@ -34,8 +38,12 @@ ETHTOOL_PORT_STATS_POST_NONCOMPLIANT_FILE = os.path.join( TEST_FILES_DIR, 'ethtool', 'ethtool_port_stats_post_monitor_noncompliant.txt') -LOGGER = None +# Define the capture files to be used for the test +STARTUP_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'startup.pcap') +MONITOR_CAPTURE_FILE = os.path.join(CAPTURES_DIR, 'monitor.pcap') + +LOGGER = None class ConnectionModuleTest(unittest.TestCase): """Contains and runs all the unit tests concerning Connection @@ -46,6 +54,9 @@ def setUpClass(cls): global LOGGER LOGGER = logger.get_logger('unit_test_' + MODULE) + # Set the MAC address for device in capture files + os.environ['DEVICE_MAC'] = '98:f0:7b:d1:87:06' + # Test the port link status def connection_port_link_compliant_test(self): LOGGER.info('connection_port_link_compliant_test') @@ -117,6 +128,17 @@ def connection_port_speed_autonegotiation_fail_test(self): LOGGER.info(result) self.assertEqual(result[0], False) + # Test proper filtering for ICMP protocol in DHCP packets + def connection_switch_dhcp_snooping_icmp_test(self): + LOGGER.info('connection_switch_dhcp_snooping_icmp_test') + conn_module = ConnectionModule(module=MODULE, + log_dir=OUTPUT_DIR, + results_dir=OUTPUT_DIR, + startup_capture_file=STARTUP_CAPTURE_FILE, + monitor_capture_file=MONITOR_CAPTURE_FILE) + result = conn_module._connection_switch_dhcp_snooping() # pylint: disable=W0212 + LOGGER.info(result) + self.assertEqual(result[0], True) if __name__ == '__main__': suite = unittest.TestSuite() @@ -136,5 +158,9 @@ def connection_port_speed_autonegotiation_fail_test(self): suite.addTest( ConnectionModuleTest('connection_port_speed_autonegotiation_fail_test')) + # DHCP Snooping related tests + suite.addTest( + ConnectionModuleTest('connection_switch_dhcp_snooping_icmp_test')) + runner = unittest.TextTestRunner() runner.run(suite) diff --git a/testing/unit/run.sh b/testing/unit/run.sh index 16aa9e0a0..795a1645b 100644 --- a/testing/unit/run.sh +++ b/testing/unit/run.sh @@ -27,13 +27,11 @@ run_test() { # Define the location in the container to # load the unit test files local UNIT_TEST_DIR_DST="/testing/unit/$MODULE_NAME" - local UNIT_TEST_FILE_DST="$UNIT_TEST_DIR_DST/module_test.py" - - # Define the python path inside the container - local PYTHONPATH="/testrun/python/src" + local UNIT_TEST_FILE_DST="/testrun/python/src/module_test.py" # Build the docker run command - local DOCKER_CMD="sudo docker run --rm -it --name ${MODULE_NAME}-unit-test -e PYTHONPATH=$PYTHONPATH" + local DOCKER_CMD="sudo docker run --rm -it --name ${MODULE_NAME}-unit-test" + # Add volume mounts for the main test file DOCKER_CMD="$DOCKER_CMD -v $UNIT_TEST_FILE_SRC:$UNIT_TEST_FILE_DST" @@ -44,14 +42,14 @@ run_test() { done # Add the container image and entry point - DOCKER_CMD="$DOCKER_CMD --entrypoint python3 test-run/${MODULE_NAME}-test $UNIT_TEST_FILE_DST" - + DOCKER_CMD="$DOCKER_CMD test-run/${MODULE_NAME}-test $UNIT_TEST_FILE_DST" + # Execute the docker command eval $DOCKER_CMD } # Run all test module tests from within their containers -run_test "conn" "ethtool" "output" +run_test "conn" "captures" "ethtool" "output" run_test "dns" "captures" "reports" "output" run_test "ntp" "captures" "reports" "output" run_test "protocol" "captures" "output" diff --git a/testing/unit/tls/certs/_.google.com.crt b/testing/unit/tls/certs/_.google.com.crt index 004c784d2..442bf6ec3 100644 --- a/testing/unit/tls/certs/_.google.com.crt +++ b/testing/unit/tls/certs/_.google.com.crt @@ -1,80 +1,77 @@ -----BEGIN CERTIFICATE----- -MIIOfTCCDWWgAwIBAgIRAJ/CcPio+CfgCR8NxdR88h4wDQYJKoZIhvcNAQELBQAw -RjELMAkGA1UEBhMCVVMxIjAgBgNVBAoTGUdvb2dsZSBUcnVzdCBTZXJ2aWNlcyBM -TEMxEzARBgNVBAMTCkdUUyBDQSAxQzMwHhcNMjQwNTA2MTM0MjA5WhcNMjQwNzI5 -MTM0MjA4WjAXMRUwEwYDVQQDDAwqLmdvb2dsZS5jb20wWTATBgcqhkjOPQIBBggq -hkjOPQMBBwNCAATgJirFNxNZgRzkS+uXAw1Z0lHqpQkPUJHRZg9LoEMfkj6fiR8V -OMJKVzDqu1I9IaKaqLv+Dcl7K9ehTZx+3PUeo4IMXjCCDFowDgYDVR0PAQH/BAQD -AgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE -FEQ+mbMDHKra3kablwkwj2mqjiPJMB8GA1UdIwQYMBaAFIp0f6+Fze6VzT2c0OJG -FPNxNR0nMGoGCCsGAQUFBwEBBF4wXDAnBggrBgEFBQcwAYYbaHR0cDovL29jc3Au -cGtpLmdvb2cvZ3RzMWMzMDEGCCsGAQUFBzAChiVodHRwOi8vcGtpLmdvb2cvcmVw -by9jZXJ0cy9ndHMxYzMuZGVyMIIKDgYDVR0RBIIKBTCCCgGCDCouZ29vZ2xlLmNv -bYIWKi5hcHBlbmdpbmUuZ29vZ2xlLmNvbYIJKi5iZG4uZGV2ghUqLm9yaWdpbi10 -ZXN0LmJkbi5kZXaCEiouY2xvdWQuZ29vZ2xlLmNvbYIYKi5jcm93ZHNvdXJjZS5n -b29nbGUuY29tghgqLmRhdGFjb21wdXRlLmdvb2dsZS5jb22CCyouZ29vZ2xlLmNh -ggsqLmdvb2dsZS5jbIIOKi5nb29nbGUuY28uaW6CDiouZ29vZ2xlLmNvLmpwgg4q -Lmdvb2dsZS5jby51a4IPKi5nb29nbGUuY29tLmFygg8qLmdvb2dsZS5jb20uYXWC -DyouZ29vZ2xlLmNvbS5icoIPKi5nb29nbGUuY29tLmNvgg8qLmdvb2dsZS5jb20u -bXiCDyouZ29vZ2xlLmNvbS50coIPKi5nb29nbGUuY29tLnZuggsqLmdvb2dsZS5k -ZYILKi5nb29nbGUuZXOCCyouZ29vZ2xlLmZyggsqLmdvb2dsZS5odYILKi5nb29n -bGUuaXSCCyouZ29vZ2xlLm5sggsqLmdvb2dsZS5wbIILKi5nb29nbGUucHSCDyou -Z29vZ2xlYXBpcy5jboIRKi5nb29nbGV2aWRlby5jb22CDCouZ3N0YXRpYy5jboIQ -Ki5nc3RhdGljLWNuLmNvbYIPZ29vZ2xlY25hcHBzLmNughEqLmdvb2dsZWNuYXBw -cy5jboIRZ29vZ2xlYXBwcy1jbi5jb22CEyouZ29vZ2xlYXBwcy1jbi5jb22CDGdr -ZWNuYXBwcy5jboIOKi5na2VjbmFwcHMuY26CEmdvb2dsZWRvd25sb2Fkcy5jboIU -Ki5nb29nbGVkb3dubG9hZHMuY26CEHJlY2FwdGNoYS5uZXQuY26CEioucmVjYXB0 -Y2hhLm5ldC5jboIQcmVjYXB0Y2hhLWNuLm5ldIISKi5yZWNhcHRjaGEtY24ubmV0 -ggt3aWRldmluZS5jboINKi53aWRldmluZS5jboIRYW1wcHJvamVjdC5vcmcuY26C -EyouYW1wcHJvamVjdC5vcmcuY26CEWFtcHByb2plY3QubmV0LmNughMqLmFtcHBy -b2plY3QubmV0LmNughdnb29nbGUtYW5hbHl0aWNzLWNuLmNvbYIZKi5nb29nbGUt -YW5hbHl0aWNzLWNuLmNvbYIXZ29vZ2xlYWRzZXJ2aWNlcy1jbi5jb22CGSouZ29v -Z2xlYWRzZXJ2aWNlcy1jbi5jb22CEWdvb2dsZXZhZHMtY24uY29tghMqLmdvb2ds -ZXZhZHMtY24uY29tghFnb29nbGVhcGlzLWNuLmNvbYITKi5nb29nbGVhcGlzLWNu -LmNvbYIVZ29vZ2xlb3B0aW1pemUtY24uY29tghcqLmdvb2dsZW9wdGltaXplLWNu -LmNvbYISZG91YmxlY2xpY2stY24ubmV0ghQqLmRvdWJsZWNsaWNrLWNuLm5ldIIY -Ki5mbHMuZG91YmxlY2xpY2stY24ubmV0ghYqLmcuZG91YmxlY2xpY2stY24ubmV0 -gg5kb3VibGVjbGljay5jboIQKi5kb3VibGVjbGljay5jboIUKi5mbHMuZG91Ymxl -Y2xpY2suY26CEiouZy5kb3VibGVjbGljay5jboIRZGFydHNlYXJjaC1jbi5uZXSC -EyouZGFydHNlYXJjaC1jbi5uZXSCHWdvb2dsZXRyYXZlbGFkc2VydmljZXMtY24u -Y29tgh8qLmdvb2dsZXRyYXZlbGFkc2VydmljZXMtY24uY29tghhnb29nbGV0YWdz -ZXJ2aWNlcy1jbi5jb22CGiouZ29vZ2xldGFnc2VydmljZXMtY24uY29tghdnb29n -bGV0YWdtYW5hZ2VyLWNuLmNvbYIZKi5nb29nbGV0YWdtYW5hZ2VyLWNuLmNvbYIY -Z29vZ2xlc3luZGljYXRpb24tY24uY29tghoqLmdvb2dsZXN5bmRpY2F0aW9uLWNu -LmNvbYIkKi5zYWZlZnJhbWUuZ29vZ2xlc3luZGljYXRpb24tY24uY29tghZhcHAt -bWVhc3VyZW1lbnQtY24uY29tghgqLmFwcC1tZWFzdXJlbWVudC1jbi5jb22CC2d2 -dDEtY24uY29tgg0qLmd2dDEtY24uY29tggtndnQyLWNuLmNvbYINKi5ndnQyLWNu -LmNvbYILMm1kbi1jbi5uZXSCDSouMm1kbi1jbi5uZXSCFGdvb2dsZWZsaWdodHMt -Y24ubmV0ghYqLmdvb2dsZWZsaWdodHMtY24ubmV0ggxhZG1vYi1jbi5jb22CDiou -YWRtb2ItY24uY29tghRnb29nbGVzYW5kYm94LWNuLmNvbYIWKi5nb29nbGVzYW5k -Ym94LWNuLmNvbYIeKi5zYWZlbnVwLmdvb2dsZXNhbmRib3gtY24uY29tgg0qLmdz -dGF0aWMuY29tghQqLm1ldHJpYy5nc3RhdGljLmNvbYIKKi5ndnQxLmNvbYIRKi5n -Y3BjZG4uZ3Z0MS5jb22CCiouZ3Z0Mi5jb22CDiouZ2NwLmd2dDIuY29tghAqLnVy -bC5nb29nbGUuY29tghYqLnlvdXR1YmUtbm9jb29raWUuY29tggsqLnl0aW1nLmNv -bYILYW5kcm9pZC5jb22CDSouYW5kcm9pZC5jb22CEyouZmxhc2guYW5kcm9pZC5j -b22CBGcuY26CBiouZy5jboIEZy5jb4IGKi5nLmNvggZnb28uZ2yCCnd3dy5nb28u -Z2yCFGdvb2dsZS1hbmFseXRpY3MuY29tghYqLmdvb2dsZS1hbmFseXRpY3MuY29t -ggpnb29nbGUuY29tghJnb29nbGVjb21tZXJjZS5jb22CFCouZ29vZ2xlY29tbWVy -Y2UuY29tgghnZ3BodC5jboIKKi5nZ3BodC5jboIKdXJjaGluLmNvbYIMKi51cmNo -aW4uY29tggh5b3V0dS5iZYILeW91dHViZS5jb22CDSoueW91dHViZS5jb22CFHlv -dXR1YmVlZHVjYXRpb24uY29tghYqLnlvdXR1YmVlZHVjYXRpb24uY29tgg95b3V0 -dWJla2lkcy5jb22CESoueW91dHViZWtpZHMuY29tggV5dC5iZYIHKi55dC5iZYIa -YW5kcm9pZC5jbGllbnRzLmdvb2dsZS5jb22CG2RldmVsb3Blci5hbmRyb2lkLmdv -b2dsZS5jboIcZGV2ZWxvcGVycy5hbmRyb2lkLmdvb2dsZS5jboIYc291cmNlLmFu -ZHJvaWQuZ29vZ2xlLmNughpkZXZlbG9wZXIuY2hyb21lLmdvb2dsZS5jboIYd2Vi -LmRldmVsb3BlcnMuZ29vZ2xlLmNugh1jb2RlbGFicy5kZXZlbG9wZXJzLmdvb2ds -ZS5jbjAhBgNVHSAEGjAYMAgGBmeBDAECATAMBgorBgEEAdZ5AgUDMDwGA1UdHwQ1 -MDMwMaAvoC2GK2h0dHA6Ly9jcmxzLnBraS5nb29nL2d0czFjMy9RT3ZKME4xc1Qy -QS5jcmwwggEEBgorBgEEAdZ5AgQCBIH1BIHyAPAAdwB2/4g/Crb7lVHCYcz1h7o0 -tKTNuyncaEIKn+ZnTFo6dAAAAY9OWu9TAAAEAwBIMEYCIQDgV2MAHik+52n1pXYg -+S1EJKqWrHTZqPIDS8T8xpG4awIhAP1xUt+oS5JVgGvepIzowOnWqnXGMjIPz8un -NOy72DVJAHUASLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMAAAGPTlrv -SwAABAMARjBEAiA9rUZy4H3k9tlGwyoh58vqNFxdVuu/TZIwVhrii485TAIgPuxq -CgYM1zCnyUuqzLeU3bEdplYB+pR8tjB/eYohZAswDQYJKoZIhvcNAQELBQADggEB -AMIo22fklccARNPymh0c984wFX6j18QuroTEFJoVg7yAiXsFiHOvCWhkf5Yyt/r7 -h9c9yvauIESITbErpCYLbejuGYL+wgQD9DpU75oEy/ViRBM/bjmV3sDbMHRqZUG7 -jHyWkl3DRmFJngR6i7ROByGbrry4xwQM3hofsF6igdwLJvLfcYigrwk3yFfDUTfj -C0xb0Okp2s4zukUfOQSAy7uWul+mkPEoMXwB6fJYvo3uUgXvhM5fbhXhgJIKWKxD -qiDjg7jlCoOrtBlINJY+PqYO/+L2Pyvqy3m3rM6omwwTT3vnFIgL2qdt+cpTNO9I -9EXEIr7rhXDXY3AUpG0xOs0= +MIIN4zCCDMugAwIBAgIRAMX7uoXXmJy3CoNgnp7ELOMwDQYJKoZIhvcNAQELBQAw +OzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFUdvb2dsZSBUcnVzdCBTZXJ2aWNlczEM +MAoGA1UEAxMDV1IyMB4XDTI0MDcwMTA2MzU0M1oXDTI0MDkyMzA2MzU0MlowFzEV +MBMGA1UEAwwMKi5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE +1mu/GyluisEPwCtCgrJeDhhGpQ9pvMzgMsbtBi3cFm0+bGTyNyoTRDSTdUsLRJNJ +BRF2O/M8i6CCi6PrYenOhKOCC88wggvLMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUE +DDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTQ+KGTW7bq4kmI +AhTEY+bMVA8+mDAfBgNVHSMEGDAWgBTeGx7teRXUPjckwyG77DQ5bUKyMDBYBggr +BgEFBQcBAQRMMEowIQYIKwYBBQUHMAGGFWh0dHA6Ly9vLnBraS5nb29nL3dyMjAl +BggrBgEFBQcwAoYZaHR0cDovL2kucGtpLmdvb2cvd3IyLmNydDCCCaUGA1UdEQSC +CZwwggmYggwqLmdvb2dsZS5jb22CFiouYXBwZW5naW5lLmdvb2dsZS5jb22CCSou +YmRuLmRldoIVKi5vcmlnaW4tdGVzdC5iZG4uZGV2ghIqLmNsb3VkLmdvb2dsZS5j +b22CGCouY3Jvd2Rzb3VyY2UuZ29vZ2xlLmNvbYIYKi5kYXRhY29tcHV0ZS5nb29n +bGUuY29tggsqLmdvb2dsZS5jYYILKi5nb29nbGUuY2yCDiouZ29vZ2xlLmNvLmlu +gg4qLmdvb2dsZS5jby5qcIIOKi5nb29nbGUuY28udWuCDyouZ29vZ2xlLmNvbS5h +coIPKi5nb29nbGUuY29tLmF1gg8qLmdvb2dsZS5jb20uYnKCDyouZ29vZ2xlLmNv +bS5jb4IPKi5nb29nbGUuY29tLm14gg8qLmdvb2dsZS5jb20udHKCDyouZ29vZ2xl +LmNvbS52boILKi5nb29nbGUuZGWCCyouZ29vZ2xlLmVzggsqLmdvb2dsZS5mcoIL +Ki5nb29nbGUuaHWCCyouZ29vZ2xlLml0ggsqLmdvb2dsZS5ubIILKi5nb29nbGUu +cGyCCyouZ29vZ2xlLnB0gg8qLmdvb2dsZWFwaXMuY26CESouZ29vZ2xldmlkZW8u +Y29tggwqLmdzdGF0aWMuY26CECouZ3N0YXRpYy1jbi5jb22CD2dvb2dsZWNuYXBw +cy5jboIRKi5nb29nbGVjbmFwcHMuY26CEWdvb2dsZWFwcHMtY24uY29tghMqLmdv +b2dsZWFwcHMtY24uY29tggxna2VjbmFwcHMuY26CDiouZ2tlY25hcHBzLmNughJn +b29nbGVkb3dubG9hZHMuY26CFCouZ29vZ2xlZG93bmxvYWRzLmNughByZWNhcHRj +aGEubmV0LmNughIqLnJlY2FwdGNoYS5uZXQuY26CEHJlY2FwdGNoYS1jbi5uZXSC +EioucmVjYXB0Y2hhLWNuLm5ldIILd2lkZXZpbmUuY26CDSoud2lkZXZpbmUuY26C +EWFtcHByb2plY3Qub3JnLmNughMqLmFtcHByb2plY3Qub3JnLmNughFhbXBwcm9q +ZWN0Lm5ldC5jboITKi5hbXBwcm9qZWN0Lm5ldC5jboIXZ29vZ2xlLWFuYWx5dGlj +cy1jbi5jb22CGSouZ29vZ2xlLWFuYWx5dGljcy1jbi5jb22CF2dvb2dsZWFkc2Vy +dmljZXMtY24uY29tghkqLmdvb2dsZWFkc2VydmljZXMtY24uY29tghFnb29nbGV2 +YWRzLWNuLmNvbYITKi5nb29nbGV2YWRzLWNuLmNvbYIRZ29vZ2xlYXBpcy1jbi5j +b22CEyouZ29vZ2xlYXBpcy1jbi5jb22CFWdvb2dsZW9wdGltaXplLWNuLmNvbYIX +Ki5nb29nbGVvcHRpbWl6ZS1jbi5jb22CEmRvdWJsZWNsaWNrLWNuLm5ldIIUKi5k +b3VibGVjbGljay1jbi5uZXSCGCouZmxzLmRvdWJsZWNsaWNrLWNuLm5ldIIWKi5n +LmRvdWJsZWNsaWNrLWNuLm5ldIIOZG91YmxlY2xpY2suY26CECouZG91YmxlY2xp +Y2suY26CFCouZmxzLmRvdWJsZWNsaWNrLmNughIqLmcuZG91YmxlY2xpY2suY26C +EWRhcnRzZWFyY2gtY24ubmV0ghMqLmRhcnRzZWFyY2gtY24ubmV0gh1nb29nbGV0 +cmF2ZWxhZHNlcnZpY2VzLWNuLmNvbYIfKi5nb29nbGV0cmF2ZWxhZHNlcnZpY2Vz +LWNuLmNvbYIYZ29vZ2xldGFnc2VydmljZXMtY24uY29tghoqLmdvb2dsZXRhZ3Nl +cnZpY2VzLWNuLmNvbYIXZ29vZ2xldGFnbWFuYWdlci1jbi5jb22CGSouZ29vZ2xl +dGFnbWFuYWdlci1jbi5jb22CGGdvb2dsZXN5bmRpY2F0aW9uLWNuLmNvbYIaKi5n +b29nbGVzeW5kaWNhdGlvbi1jbi5jb22CJCouc2FmZWZyYW1lLmdvb2dsZXN5bmRp +Y2F0aW9uLWNuLmNvbYIWYXBwLW1lYXN1cmVtZW50LWNuLmNvbYIYKi5hcHAtbWVh +c3VyZW1lbnQtY24uY29tggtndnQxLWNuLmNvbYINKi5ndnQxLWNuLmNvbYILZ3Z0 +Mi1jbi5jb22CDSouZ3Z0Mi1jbi5jb22CCzJtZG4tY24ubmV0gg0qLjJtZG4tY24u +bmV0ghRnb29nbGVmbGlnaHRzLWNuLm5ldIIWKi5nb29nbGVmbGlnaHRzLWNuLm5l +dIIMYWRtb2ItY24uY29tgg4qLmFkbW9iLWNuLmNvbYIUZ29vZ2xlc2FuZGJveC1j +bi5jb22CFiouZ29vZ2xlc2FuZGJveC1jbi5jb22CHiouc2FmZW51cC5nb29nbGVz +YW5kYm94LWNuLmNvbYINKi5nc3RhdGljLmNvbYIUKi5tZXRyaWMuZ3N0YXRpYy5j +b22CCiouZ3Z0MS5jb22CESouZ2NwY2RuLmd2dDEuY29tggoqLmd2dDIuY29tgg4q +LmdjcC5ndnQyLmNvbYIQKi51cmwuZ29vZ2xlLmNvbYIWKi55b3V0dWJlLW5vY29v +a2llLmNvbYILKi55dGltZy5jb22CC2FuZHJvaWQuY29tgg0qLmFuZHJvaWQuY29t +ghMqLmZsYXNoLmFuZHJvaWQuY29tggRnLmNuggYqLmcuY26CBGcuY2+CBiouZy5j +b4IGZ29vLmdsggp3d3cuZ29vLmdsghRnb29nbGUtYW5hbHl0aWNzLmNvbYIWKi5n +b29nbGUtYW5hbHl0aWNzLmNvbYIKZ29vZ2xlLmNvbYISZ29vZ2xlY29tbWVyY2Uu +Y29tghQqLmdvb2dsZWNvbW1lcmNlLmNvbYIIZ2dwaHQuY26CCiouZ2dwaHQuY26C +CnVyY2hpbi5jb22CDCoudXJjaGluLmNvbYIIeW91dHUuYmWCC3lvdXR1YmUuY29t +gg0qLnlvdXR1YmUuY29tghR5b3V0dWJlZWR1Y2F0aW9uLmNvbYIWKi55b3V0dWJl +ZWR1Y2F0aW9uLmNvbYIPeW91dHViZWtpZHMuY29tghEqLnlvdXR1YmVraWRzLmNv +bYIFeXQuYmWCByoueXQuYmWCGmFuZHJvaWQuY2xpZW50cy5nb29nbGUuY29tghMq +LmFuZHJvaWQuZ29vZ2xlLmNughIqLmNocm9tZS5nb29nbGUuY26CFiouZGV2ZWxv +cGVycy5nb29nbGUuY24wEwYDVR0gBAwwCjAIBgZngQwBAgEwNgYDVR0fBC8wLTAr +oCmgJ4YlaHR0cDovL2MucGtpLmdvb2cvd3IyL0dTeVQxTjRQQnJnLmNybDCCAQQG +CisGAQQB1nkCBAIEgfUEgfIA8AB1ANq2v2s/tbYin5vCu1xr6HCRcWy7UYSFNL2k +PTBI1/urAAABkG04oVMAAAQDAEYwRAIgAaPd62uAnl5mGI2sgPrRH/vRicAYxwaQ +hj9qnla8Z2ACIBwm4r6O2jnYSzMfFYoxaWa7Zn+7bgbUb1uUrzEe4xl7AHcA7s3Q +ZNXbGs7FXLedtM0TojKHRny87N7DUUhZRnEftZsAAAGQbTihHQAABAMASDBGAiEA +7+ZDT9MHQdLS4XEbXqbGAkbp7GQIYbSdoZg/DALMIm8CIQD3cwXxry/8R2I9BQXA +7SM0047FardcOzEV40z+kMDJ4jANBgkqhkiG9w0BAQsFAAOCAQEAd5GYYcv4ZG6P +OiQgV8RVO0TFSBfYUwNQDyeFQFyaagPvP3LbsJC5m9X22jdf64HpIcyKr7Aw4JIY +RzhqvlIZ2t8Hf1McRavJzCl4hER1dLNlzhCL1z+Wq/xiRz5VqVOXv1okG5YgGiEW +UQCBXPFM8nTAror5sMk1e/2IQOI4/4A2MhWZBrpVwYyaKEJUcdKLq9EbDRHVfsNo +0BQCnt/thD6IzL3FYWsEF0636Trxd9chE2r3JF30ORJm8q90Xz2aWwrFGlB0i8tW +uYv+WTi3qW9o2lLaqfsSTjoOmtGxANknI/0LARuBST3xSGk5GD+r/r8J/6LcCe8x +qvx81n6dcQ== -----END CERTIFICATE-----