diff --git a/modules/test/protocol/bin/start_test_module b/modules/test/protocol/bin/start_test_module new file mode 100644 index 000000000..a0754836c --- /dev/null +++ b/modules/test/protocol/bin/start_test_module @@ -0,0 +1,53 @@ +#!/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. + +# Setup and start the connection test module + +# Define where the python source files are located +PYTHON_SRC_DIR=/testrun/python/src + +# Fetch module name +MODULE_NAME=$1 + +# Default interface should be veth0 for all containers +DEFAULT_IFACE=veth0 + +# Allow a user to define an interface by passing it into this script +DEFINED_IFACE=$2 + +# 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 + +# Create and set permissions on the log files +LOG_FILE=/runtime/output/$MODULE_NAME.log +RESULT_FILE=/runtime/output/$MODULE_NAME-result.json +touch $LOG_FILE +touch $RESULT_FILE +chown $HOST_USER $LOG_FILE +chown $HOST_USER $RESULT_FILE + +# Run the python script that will execute the tests for this module +# -u flag allows python print statements +# to be logged by docker by running unbuffered +python3 -u $PYTHON_SRC_DIR/run.py "-m $MODULE_NAME" + +echo Module has finished \ No newline at end of file diff --git a/modules/test/protocol/conf/module_config.json b/modules/test/protocol/conf/module_config.json new file mode 100644 index 000000000..365cd3c37 --- /dev/null +++ b/modules/test/protocol/conf/module_config.json @@ -0,0 +1,55 @@ +{ + "config": { + "meta": { + "name": "protocol", + "display_name": "Protocol", + "description": "Protocol tests" + }, + "network": true, + "docker": { + "depends_on": "base", + "enable_container": true, + "timeout": 300 + }, + "tests":[ + { + "name": "protocol.valid_bacnet", + "description": "Can valid BACnet traffic be seen", + "expected_behavior": "BACnet traffic can be seen on the network and packets are valid and not malformed", + "required_result": "Required" + }, + { + "name": "protocol.valid_modbus", + "description": "Can valid Modbus traffic be seen", + "expected_behavior": "Any Modbus functionality works as expected and valid modbus traffic can be observed", + "required_result": "Required", + "config":{ + "port": 502, + "device_id": 1, + "registers":{ + "holding":{ + "enabled": true, + "address_start": 0, + "count": 5 + }, + "input":{ + "enabled": true, + "address_start": 0, + "count": 5 + }, + "coil":{ + "enabled": true, + "address_start": 0, + "count": 1 + }, + "discrete":{ + "enabled": true, + "address_start": 0, + "count": 1 + } + } + } + } + ] + } +} \ No newline at end of file diff --git a/modules/test/protocol/protocol.Dockerfile b/modules/test/protocol/protocol.Dockerfile new file mode 100644 index 000000000..abfbc16b0 --- /dev/null +++ b/modules/test/protocol/protocol.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/protocol-test +FROM test-run/base-test:latest + +ARG MODULE_NAME=protocol +ARG MODULE_DIR=modules/test/$MODULE_NAME + +#Load the requirements file +COPY $MODULE_DIR/python/requirements.txt /testrun/python + +#Install all python requirements for the module +RUN pip3 install -r /testrun/python/requirements.txt + +# 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 \ No newline at end of file diff --git a/modules/test/protocol/python/requirements.txt b/modules/test/protocol/python/requirements.txt new file mode 100644 index 000000000..57917735d --- /dev/null +++ b/modules/test/protocol/python/requirements.txt @@ -0,0 +1,7 @@ +# Required for BACnet protocol tests +netifaces +BAC0 +pytz + +# Required for Modbus protocol tests +pymodbus \ No newline at end of file diff --git a/modules/test/protocol/python/src/protocol_bacnet.py b/modules/test/protocol/python/src/protocol_bacnet.py new file mode 100644 index 000000000..557dcdfb9 --- /dev/null +++ b/modules/test/protocol/python/src/protocol_bacnet.py @@ -0,0 +1,71 @@ +# 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. +"""Module run all the BACnet related methods for testing""" + +import BAC0 +import logging + +LOGGER = None +BAC0_LOG = '/root/.BAC0/BAC0.log' + +class BACnet(): + """BACnet Test module""" + + def __init__(self, log): + # Set the log + global LOGGER + LOGGER = log + + # Setup the BAC0 Log + BAC0.log_level(log_file=logging.DEBUG, stdout=logging.INFO, stderr=logging.CRITICAL) + + self.devices = [] + + def discover(self, local_ip=None): + LOGGER.info("Performing BACnet discovery...") + bacnet = BAC0.lite(local_ip) + LOGGER.info("Local BACnet object: " + str(bacnet)) + try: + bacnet.discover(global_broadcast=True) + except Exception as e: + LOGGER.error(e) + LOGGER.info("BACnet discovery complete") + with open(BAC0_LOG,'r',encoding='utf-8') as f: + bac0_log = f.read() + LOGGER.info("BAC0 Log:\n" + bac0_log) + self.devices = bacnet.devices + + # Check if the device being tested is in the discovered devices list + def validate_device(self, local_ip, device_ip): + result = None + LOGGER.info("Validating BACnet device: " + device_ip) + self.discover(local_ip + '/24') + LOGGER.info("BACnet Devices Found: " + str(len(self.devices))) + if len(self.devices) > 0: + # Load a fail result initially and pass only + # if we can validate it's the right device responding + result = False, ( + f'Could not confirm discovered BACnet device is the ' + + 'same as device being tested') + for device in self.devices: + name, vendor, address, device_id = device + LOGGER.info("Checking Device: " + str(device)) + if device_ip in address: + result = True, 'Device IP matches discovered device' + break + else: + result = None, 'BACnet discovery could not resolve any devices' + if result is not None: + LOGGER.info(result[1]) + return result diff --git a/modules/test/protocol/python/src/protocol_modbus.py b/modules/test/protocol/python/src/protocol_modbus.py new file mode 100644 index 000000000..6204a0e41 --- /dev/null +++ b/modules/test/protocol/python/src/protocol_modbus.py @@ -0,0 +1,272 @@ +# 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. +"""Module run all the Modbus related methods for testing""" + +from pymodbus.client import ModbusTcpClient as ModbusClient +from pymodbus.exceptions import ModbusIOException + +DEFAULT_MODBUS_PORT = 502 +DEFAULT_DEVICE_ID = 1 +DEFAULT_REG_START = 0 +DEFAULT_REG_COUNT = 1 +LOGGER = None + + +class Modbus(): + """Modbus Test module""" + + def __init__(self, log, device_ip, config): + # Setup the log + global LOGGER + LOGGER = log + + # Setup modbus addressing + self._port = config['port'] if 'port' in config else DEFAULT_MODBUS_PORT + self._device_id = config[ + 'device_id'] if 'device_id' in config else DEFAULT_DEVICE_ID + + # Setup default register states + self._holding_reg_enabled = True + self._input_reg_enabled = True + self._coil_enabled = True + self._discrete_input_enabled = True + self._holding_reg_start = DEFAULT_REG_START + self._holding_reg_count = DEFAULT_REG_COUNT + self._input_reg_start = DEFAULT_REG_START + self._input_reg_count = DEFAULT_REG_COUNT + self._coil_reg_start = DEFAULT_REG_START + self._coil_reg_count = DEFAULT_REG_COUNT + self._discrete_input_reg_start = DEFAULT_REG_START + self._discrete_input_reg_count = DEFAULT_REG_COUNT + + LOGGER.info('Config: ' + str(config)) + # Extract all register information + if 'registers' in config: + + # Extract holding register information + if 'holding' in config['registers']: + if ('enabled' in config['registers']['holding'] + and config['registers']['holding']['enabled']) or ( + 'enabled' not in config['registers']['holding']): + self._holding_reg_start = config['registers']['holding'].get( + 'address_start', DEFAULT_REG_START) + self._holding_reg_count = config['registers']['holding'].get( + 'count', DEFAULT_REG_COUNT) + else: + self._holding_reg_enabled = False + + # Extract input register information + if 'input' in config['registers']: + if ('enabled' in config['registers']['input'] + and config['registers']['input']['enabled']) or ( + 'enabled' not in config['registers']['input']): + self._input_reg_start = config['registers']['input'].get( + 'address_start', DEFAULT_REG_START) + self._input_reg_count = config['registers']['input'].get( + 'count', DEFAULT_REG_COUNT) + else: + self._input_reg_enabled = False + + # Extract coil register information + if 'coil' in config['registers']: + if ('enabled' in config['registers']['coil'] + and config['registers']['coil']['enabled']) or ( + 'enabled' not in config['registers']['coil']): + self._coil_reg_start = config['registers']['coil'].get( + 'address_start', DEFAULT_REG_START) + self._coil_reg_count = config['registers']['coil'].get( + 'count', DEFAULT_REG_COUNT) + else: + self._coil_enabled = False + + # Extract discrete register information + if 'discrete' in config['registers']: + if ('enabled' in config['registers']['discrete'] + and config['registers']['discrete']['enabled']) or ( + 'enabled' not in config['registers']['discrete']): + self._discrete_input_reg_start = config['registers']['discrete'].get( + 'address_start', DEFAULT_REG_START) + self._discrete_input_reg_count = config['registers']['discrete'].get( + 'count', DEFAULT_REG_COUNT) + else: + self._discrete_input_enabled = False + + # Initialize the modbus client + self.client = ModbusClient(device_ip, self._port) + + # Connections created from this method are simple socket connections + # and aren't indicative of valid modbus + def connect(self): + connection = None + try: + LOGGER.info(f'Attempting modbus connection to: {str()}') + connection = self.client.connect() + if connection: + LOGGER.info('Connected to Modbus device') + else: + LOGGER.info('Failed to connect to Modbus device') + except ModbusIOException as e: + LOGGER.error('Modbus Connection Failed:', e) + return connection + + # Read a range of holding registers + def read_holding_registers(self, + address=DEFAULT_REG_START, + count=DEFAULT_REG_COUNT, + device_id=DEFAULT_DEVICE_ID): + registers = None + LOGGER.info(f'Reading holding registers: {address}:{count}') + try: + response = self.client.read_holding_registers(address, + count, + slave=device_id) + if response.isError(): + LOGGER.error(f'Failed to read holding registers: {address}:{count}') + LOGGER.error('Read Response: ' + str(response)) + else: + registers = response.registers + LOGGER.info(f'Holding registers read: {str(registers)}') + except ModbusIOException as e: + LOGGER.error('Error reading holding registers:' + e) + return registers + + # Read a range of input registers + def read_input_registers(self, + address=DEFAULT_REG_START, + count=DEFAULT_REG_COUNT, + device_id=DEFAULT_DEVICE_ID): + registers = None + LOGGER.info(f'Reading input registers: {address}:{count}') + try: + response = self.client.read_input_registers(address, + count, + slave=device_id) + if response.isError(): + LOGGER.error(f'Failed to read input registers: {address}:{count}') + LOGGER.error('Read Response: ' + str(response)) + else: + registers = response.registers + LOGGER.info(f'Input registers read: {str(registers)}') + except ModbusIOException as e: + LOGGER.error('Error reading input registers:' + e) + return registers + + # Read a range of input registers + def read_coils(self, + address=DEFAULT_REG_START, + count=DEFAULT_REG_COUNT, + device_id=DEFAULT_DEVICE_ID): + coils = None + LOGGER.info(f'Reading coil registers: {address}:{count}') + try: + response = self.client.read_coils(address, count, slave=device_id) + if response.isError(): + LOGGER.error(f'Failed to read coil registers: {address}:{count}') + LOGGER.error('Read Response: ' + str(response)) + else: + coils = response.bits + LOGGER.info(f'Coil registers read: {str(coils)}') + except ModbusIOException as e: + LOGGER.error('Error reading coil registers:' + e) + return coils + + # Read a range of input registers + def read_discrete_inputs(self, + address=DEFAULT_REG_START, + count=DEFAULT_REG_COUNT, + device_id=DEFAULT_DEVICE_ID): + inputs = None + LOGGER.info(f'Reading discrete inputs: {address}:{count}') + try: + response = self.client.read_discrete_inputs(address, + count, + slave=device_id) + if response.isError(): + LOGGER.error(f'Failed to read discrete inputs: {address}:{count}') + LOGGER.error('Read Response: ' + str(response)) + else: + inputs = response.bits + LOGGER.info(f'Discrete inputs read: {str(inputs)}') + except ModbusIOException as e: + LOGGER.error('Error reading discrete inputs:' + e) + return inputs + + # Check if we can make a modbus connection and read various registers + # We don't care what the values in the registers are, just that + # we can read them since we will not have an expectation + # of the contents of the values + def validate_device(self): + result = None + compliant = None + details = '' + LOGGER.info('Validating Modbus device') + connection = self.connect() + if connection: + details = f'Established connection to modbus port: {self._port}' + + # Validate if the device supports holding registers and can be read + holding_reg = self.read_holding_registers(self._holding_reg_start, + self._holding_reg_count, + self._device_id) + if holding_reg: + details += ('\nHolding registers succesfully read: ' + + f'{self._holding_reg_start}:{self._holding_reg_count}') + else: + details += ('\nHolding registers could not be read: ' + + f'{self._holding_reg_start}:{self._holding_reg_count}') + + # Validate if the device supports input registers and can be read + input_reg = self.read_input_registers(self._input_reg_start, + self._input_reg_count, + self._device_id) + if input_reg: + details += ('\nInput registers succesfully read: ' + + f'{self._input_reg_start}:{self._input_reg_count}') + else: + details += ('\nInput registers could not be read: ' + + f'{self._input_reg_start}:{self._input_reg_count}') + + # Validate if the device supports coils and can be read + coils = self.read_coils(self._coil_reg_start, self._coil_reg_count, + self._device_id) + if coils: + details += ('\nCoil registers succesfully read: ' + + f'{self._coil_reg_start}:{self._coil_reg_count}') + else: + details += ('\nCoil registers could not be read: ' + + f'{self._coil_reg_start}:{self._coil_reg_count}') + + # Validate if the device supports discrete inputs and can be read + discrete_inputs = self.read_discrete_inputs( + self._discrete_input_reg_start, self._discrete_input_reg_count, + self._device_id) + if discrete_inputs: + details += ( + '\nDiscrete inputs succesfully read: ' + + f'{self._discrete_input_reg_start}:{self._discrete_input_reg_count}' + ) + else: + details += ( + '\nDiscrete inputs could not be read: ' + + f'{self._discrete_input_reg_start}:{self._discrete_input_reg_count}' + ) + + # Since we can't know what data types the device supports + # we'll pass if any of the supported data types are succesfully read + compliant = holding_reg or input_reg or coils or discrete_inputs + else: + compliant = False + details = 'Failed to establish Modbus connection to device' + result = compliant, details + return result diff --git a/modules/test/protocol/python/src/protocol_module.py b/modules/test/protocol/python/src/protocol_module.py new file mode 100644 index 000000000..b3233b6ae --- /dev/null +++ b/modules/test/protocol/python/src/protocol_module.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. +"""Protocol test module""" +from test_module import TestModule +import netifaces +from protocol_bacnet import BACnet +from protocol_modbus import Modbus + +LOG_NAME = 'test_protocol' +LOGGER = None + + +class ProtocolModule(TestModule): + """Protocol Test module""" + + def __init__(self, module): + super().__init__(module_name=module, log_name=LOG_NAME) + global LOGGER + LOGGER = self._get_logger() + self._bacnet = BACnet(LOGGER) + + def _protocol_valid_bacnet(self): + LOGGER.info('Running protocol.valid_bacnet') + result = None + interface_name = 'veth0' + + # Resolve the appropriate IP for BACnet comms + local_address = self.get_local_ip(interface_name) + if local_address: + result = self._bacnet.validate_device(local_address, + self._device_ipv4_addr) + else: + result = None, 'Could not resolve test container IP for BACnet discovery' + return result + + def _protocol_valid_modbus(self, config): + LOGGER.info('Running protocol.valid_modbus') + # Extract basic device connection information + modbus = Modbus(log=LOGGER, device_ip=self._device_ipv4_addr, config=config) + return modbus.validate_device() + + def get_local_ip(self, interface_name): + try: + addresses = netifaces.ifaddresses(interface_name) + local_address = addresses[netifaces.AF_INET][0]['addr'] + if local_address: + LOGGER.info(f'IP address of {interface_name}: {local_address}') + else: + LOGGER.info(f'Unable to retrieve IP address for {interface_name}') + return local_address + except (KeyError, IndexError): + return None diff --git a/modules/test/protocol/python/src/run.py b/modules/test/protocol/python/src/run.py new file mode 100644 index 000000000..d47c81cb6 --- /dev/null +++ b/modules/test/protocol/python/src/run.py @@ -0,0 +1,68 @@ +# 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. + +"""Run Baseline module""" +import argparse +import signal +import sys +import logger +from protocol_module import ProtocolModule + +LOGGER = logger.get_logger('test_module') +RUNTIME = 1500 + + +class ProtocolModuleRunner: + """An example runner class for test modules.""" + + def __init__(self, module): + + signal.signal(signal.SIGINT, self._handler) + signal.signal(signal.SIGTERM, self._handler) + signal.signal(signal.SIGABRT, self._handler) + signal.signal(signal.SIGQUIT, self._handler) + + LOGGER.info('Starting Protocol Module') + + self._test_module = ProtocolModule(module) + self._test_module.run_tests() + + def _handler(self, signum): + LOGGER.debug('SigtermEnum: ' + str(signal.SIGTERM)) + LOGGER.debug('Exit signal received: ' + str(signum)) + if signum in (2, signal.SIGTERM): + LOGGER.info('Exit signal received. Stopping test module...') + LOGGER.info('Test module stopped') + sys.exit(1) + + +def run(): + parser = argparse.ArgumentParser( + description='Protocol Module Help', + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + + parser.add_argument( + '-m', + '--module', + help='Define the module name to be used to create the log file') + + args = parser.parse_args() + + # For some reason passing in the args from bash adds an extra + # space before the argument so we'll just strip out extra space + ProtocolModuleRunner(args.module.strip()) + + +if __name__ == '__main__': + run()