diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index fbdbe442c..c981dbd56 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -7,7 +7,7 @@ on: - cron: '0 13 * * *' jobs: - testrun: + testrun_baseline: name: Baseline runs-on: ubuntu-20.04 timeout-minutes: 20 @@ -17,11 +17,21 @@ jobs: - name: Run tests shell: bash {0} run: testing/test_baseline - + + testrun_tests: + name: Tests + runs-on: ubuntu-20.04 + timeout-minutes: 40 + steps: + - name: Checkout source + uses: actions/checkout@v2.3.4 + - name: Run tests + shell: bash {0} + run: testing/test_tests pylint: name: Pylint - runs-on: ubuntu-20.04 - timeout-minutes: 20 + runs-on: ubuntu-22.04 + timeout-minutes: 5 steps: - name: Checkout source uses: actions/checkout@v2.3.4 diff --git a/modules/network/dhcp-1/bin/isc-dhcp-service b/modules/network/dhcp-1/bin/isc-dhcp-service new file mode 100644 index 000000000..de029515b --- /dev/null +++ b/modules/network/dhcp-1/bin/isc-dhcp-service @@ -0,0 +1,56 @@ +#!/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. + +CONFIG_FILE=/etc/dhcp/dhcpd.conf +DHCP_PID_FILE=/var/run/dhcpd.pid +DHCP_LOG_FILE=/runtime/network/dhcp1-dhcpd.log + +stop_dhcp(){ + # Directly kill by PID file reference + if [ -f "$DHCP_PID_FILE" ]; then + kill -9 $(cat $DHCP_PID_FILE) || true + rm -f $DHCP_PID_FILE + fi +} + +start_dhcp(){ + /usr/sbin/dhcpd -d &> $DHCP_LOG_FILE & +} + +case "$1" in + start) + start_dhcp + ;; + stop) + stop_dhcp + ;; + restart) + stop_dhcp + sleep 1 + start_dhcp + ;; + status) + if [ -f "$DHCP_PID_FILE" ]; then + echo "isc-dhcp service is running." + else + echo "isc-dhcp service is not running." + fi + ;; + *) + echo "Usage: $0 {start|stop|status|restart}" + exit 1 + ;; +esac \ No newline at end of file diff --git a/modules/network/dhcp-1/bin/start_network_service b/modules/network/dhcp-1/bin/start_network_service index 413c48ceb..945313dd3 100644 --- a/modules/network/dhcp-1/bin/start_network_service +++ b/modules/network/dhcp-1/bin/start_network_service @@ -43,7 +43,8 @@ cp /testrun/conf/isc-dhcp-server /etc/default/ cp /testrun/conf/dhcpd.conf /etc/dhcp/dhcpd.conf cp /testrun/conf/radvd.conf /etc/radvd.conf -# Move the radvd-sevice file to the correct location +# Move the service files to the correct location +cp /testrun/bin/isc-dhcp-service /usr/local/bin/ cp /testrun/bin/radvd-service /usr/local/bin/ # Start the DHCP Server diff --git a/modules/network/dhcp-1/conf/dhcpd.conf b/modules/network/dhcp-1/conf/dhcpd.conf index ee171279c..39f67c3b8 100644 --- a/modules/network/dhcp-1/conf/dhcpd.conf +++ b/modules/network/dhcp-1/conf/dhcpd.conf @@ -1,28 +1,29 @@ -default-lease-time 300; - -failover peer "failover-peer" { - primary; - address 10.10.10.2; - port 847; - peer address 10.10.10.3; - peer port 647; - max-response-delay 60; - max-unacked-updates 10; - mclt 3600; - split 128; - load balance max seconds 3; -} - -subnet 10.10.10.0 netmask 255.255.255.0 { - option ntp-servers 10.10.10.5; - option subnet-mask 255.255.255.0; - option broadcast-address 10.10.10.255; - option routers 10.10.10.1; - option domain-name-servers 10.10.10.4; - interface veth0; - authoritative; - pool { - failover peer "failover-peer"; - range 10.10.10.10 10.10.10.20; - } +default-lease-time 30; +max-lease-time 30; + +failover peer "failover-peer" { + primary; + address 10.10.10.2; + port 847; + peer address 10.10.10.3; + peer port 647; + max-response-delay 60; + max-unacked-updates 10; + mclt 30; + split 128; + load balance max seconds 3; +} + +subnet 10.10.10.0 netmask 255.255.255.0 { + option ntp-servers 10.10.10.5; + option subnet-mask 255.255.255.0; + option broadcast-address 10.10.10.255; + option routers 10.10.10.1; + option domain-name-servers 10.10.10.4; + interface veth0; + authoritative; + pool { + failover peer "failover-peer"; + range 10.10.10.10 10.10.10.20; + } } \ No newline at end of file diff --git a/modules/network/dhcp-1/conf/isc-dhcp-server b/modules/network/dhcp-1/conf/isc-dhcp-server index 44db95cd9..4a4aa09f9 100644 --- a/modules/network/dhcp-1/conf/isc-dhcp-server +++ b/modules/network/dhcp-1/conf/isc-dhcp-server @@ -1,4 +1,4 @@ -# On what interfaces should the DHCP server (dhcpd) serve DHCP requests? -# Separate multiple interfaces with spaces, e.g. "eth0 eth1". -INTERFACESv4="veth0" -#INTERFACESv6="veth0" +# On what interfaces should the DHCP server (dhcpd) serve DHCP requests? +# Separate multiple interfaces with spaces, e.g. "eth0 eth1". +INTERFACESv4="veth0" +#INTERFACESv6="veth0" diff --git a/modules/network/dhcp-1/conf/module_config.json b/modules/network/dhcp-1/conf/module_config.json index 4a41eee3f..cf1f59a1e 100644 --- a/modules/network/dhcp-1/conf/module_config.json +++ b/modules/network/dhcp-1/conf/module_config.json @@ -1,26 +1,26 @@ -{ - "config": { - "meta": { - "name": "dhcp-1", - "display_name": "DHCP Primary", - "description": "Primary DHCP server with IPv6 SLAAC" - }, - "network": { - "interface": "veth0", - "enable_wan": false, - "ip_index": 2 - }, - "grpc":{ - "port": 5001 - }, - "docker": { - "depends_on": "base", - "mounts": [ - { - "source": "runtime/network", - "target": "/runtime/network" - } - ] - } - } +{ + "config": { + "meta": { + "name": "dhcp-1", + "display_name": "DHCP Primary", + "description": "Primary DHCP server with IPv6 SLAAC" + }, + "network": { + "interface": "veth0", + "enable_wan": false, + "ip_index": 2 + }, + "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/dhcp-1/conf/radvd.conf b/modules/network/dhcp-1/conf/radvd.conf index 89995785f..0cc500fd5 100644 --- a/modules/network/dhcp-1/conf/radvd.conf +++ b/modules/network/dhcp-1/conf/radvd.conf @@ -7,7 +7,6 @@ interface veth0 prefix fd10:77be:4186::/64 { AdvOnLink on; AdvAutonomous on; - AdvRouterAddr on; - AdvSourceLLAddress off; + AdvRouterAddr on; }; -}; \ No newline at end of file +}; diff --git a/modules/network/dhcp-1/python/src/grpc_server/dhcp_config.py b/modules/network/dhcp-1/python/src/grpc_server/dhcp_config.py index 6f003014c..877d49610 100644 --- a/modules/network/dhcp-1/python/src/grpc_server/dhcp_config.py +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_config.py @@ -20,13 +20,15 @@ LOGGER = None CONFIG_FILE = '/etc/dhcp/dhcpd.conf' DEFAULT_LEASE_TIME_KEY = 'default-lease-time' +MAX_LEASE_TIME_KEY = 'max-lease-time' class DHCPConfig: """Represents the DHCP Servers configuration and gives access to modify it""" def __init__(self): - self._default_lease_time = 300 + self._default_lease_time = 30 + self._max_lease_time = 30 self._subnets = [] self._peer = None self._reserved_hosts = [] @@ -120,17 +122,50 @@ def set_range(self, start, end, subnet=0, pool=0): octets[-1] = '0' dhcp_subnet = '.'.join(octets) - #Update the subnet and range - self._subnets[subnet].set_subnet(dhcp_subnet) + # Calcualte the netmask from the range + prefix = self.calculate_prefix_length(start, end) + netmask = self.calculate_netmask(prefix) + + #Update the subnet, range and netmask + self._subnets[subnet].set_subnet(dhcp_subnet, netmask) self._subnets[subnet].pools[pool].set_range(start, end) + def calculate_prefix_length(self, start_ip, end_ip): + start_octets = start_ip.split('.') + end_octets = end_ip.split('.') + + start_int = int( + ''.join(format(int(octet), '08b') for octet in start_octets), 2) + end_int = int(''.join(format(int(octet), '08b') for octet in end_octets), 2) + + xor_result = start_int ^ end_int + prefix_length = 32 - xor_result.bit_length() + + return prefix_length + + def calculate_netmask(self, prefix_length): + num_network_bits = prefix_length + num_host_bits = 32 - num_network_bits + + netmask_int = (2**num_network_bits - 1) << num_host_bits + netmask_octets = [(netmask_int >> (i * 8)) & 0xff for i in range(3, -1, -1)] + + return '.'.join(str(octet) for octet in netmask_octets) + def __str__(self): + config = ('{DEFAULT_LEASE_TIME_KEY} {DEFAULT_LEASE_TIME};' + if self._default_lease_time is not None else '') + config += ('\n\r{MAX_LEASE_TIME_KEY} {MAX_LEASE_TIME};' + if self._max_lease_time is not None else '') + # Encode the top level config options - config = """{DEFAULT_LEASE_TIME_KEY} {DEFAULT_LEASE_TIME};""" + #config = """{DEFAULT_LEASE_TIME_KEY} {DEFAULT_LEASE_TIME};""" config = config.format(length='multi-line', DEFAULT_LEASE_TIME_KEY=DEFAULT_LEASE_TIME_KEY, - DEFAULT_LEASE_TIME=self._default_lease_time) + DEFAULT_LEASE_TIME=self._default_lease_time, + MAX_LEASE_TIME_KEY=MAX_LEASE_TIME_KEY, + MAX_LEASE_TIME=self._max_lease_time) # Encode the failover peer config += '\n\n' + str(self._peer) @@ -358,12 +393,24 @@ def set_subnet(self, subnet, netmask=None): self._subnet = subnet self._subnet_mask = netmask - # Calculate the broadcast from the subnet - octets = subnet.split('.') - octets[-1] = '255' - dhcp_broadcast = '.'.join(octets) + # Calculate the broadcast from the subnet and netmask + broadcast = self.calculate_broadcast_address(subnet, netmask) + self._broadcast = broadcast + + def calculate_broadcast_address(self, subnet_address, netmask): + subnet_octets = subnet_address.split('.') + netmask_octets = netmask.split('.') + + subnet_int = int( + ''.join(format(int(octet), '08b') for octet in subnet_octets), 2) + netmask_int = int( + ''.join(format(int(octet), '08b') for octet in netmask_octets), 2) + + broadcast_int = subnet_int | (~netmask_int & 0xffffffff) + broadcast_octets = [(broadcast_int >> (i * 8)) & 0xff + for i in range(3, -1, -1)] - self._broadcast = dhcp_broadcast + return '.'.join(str(octet) for octet in broadcast_octets) def resolve_subnet(self, subnet): subnet_parts = subnet.split('\n') diff --git a/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py b/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py index a34ff4e31..4bc1bd52d 100644 --- a/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py @@ -19,6 +19,7 @@ CONFIG_FILE = 'conf/dhcpd.conf' DHCP_CONFIG = None + def get_config_file_path(): current_dir = os.path.dirname(os.path.abspath(__file__)) module_dir = os.path.dirname( @@ -91,6 +92,15 @@ def test_resolve_config_with_hosts(self): self.assertIsNotNone(host) print('ResolveConfigWithHosts:\n' + str(config_with_hosts)) + def test_set_subnet_range(self): + range_start = '10.0.0.100' + range_end = '10.0.0.200' + DHCP_CONFIG.set_range(range_start, range_end) + subnets = DHCP_CONFIG.resolve_subnets(str(DHCP_CONFIG)) + pool = subnets[0].pools[0] + self.assertTrue(pool.range_start == range_start + and pool.range_end == range_end) + print('SetSubnetRange:\n' + str(DHCP_CONFIG)) if __name__ == '__main__': suite = unittest.TestSuite() @@ -100,6 +110,7 @@ def test_resolve_config_with_hosts(self): suite.addTest(DHCPConfigTest('test_add_reserved_host')) suite.addTest(DHCPConfigTest('test_delete_reserved_host')) suite.addTest(DHCPConfigTest('test_resolve_config_with_hosts')) + suite.addTest(DHCPConfigTest('test_set_subnet_range')) runner = unittest.TextTestRunner() runner.run(suite) diff --git a/modules/network/dhcp-1/python/src/grpc_server/dhcp_lease.py b/modules/network/dhcp-1/python/src/grpc_server/dhcp_lease.py index 0d2f43e3b..dd7ba9516 100644 --- a/modules/network/dhcp-1/python/src/grpc_server/dhcp_lease.py +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_lease.py @@ -39,7 +39,7 @@ def _make_lease(self, lease): self.hw_addr = sections[0] self.ip = sections[1] self.hostname = sections[2] - self.expires = sections[3] + '' '' + sections[4] + self.expires = sections[3] + ' ' + sections[4] self.manufacturer = ' '.join(sections[5:]) def get_millis(self, timestamp): diff --git a/modules/network/dhcp-1/python/src/grpc_server/dhcp_leases.py b/modules/network/dhcp-1/python/src/grpc_server/dhcp_leases.py index 698277a02..aa2945759 100644 --- a/modules/network/dhcp-1/python/src/grpc_server/dhcp_leases.py +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_leases.py @@ -58,9 +58,9 @@ def get_leases(self): leases = [] lease_list_raw = self._get_lease_list() LOGGER.info('Raw Leases:\n' + str(lease_list_raw) + '\n') - lease_list_start = lease_list_raw.find('=========',0) - lease_list_start = lease_list_raw.find('\n',lease_list_start) - lease_list = lease_list_raw[lease_list_start+1:] + lease_list_start = lease_list_raw.find('=========', 0) + lease_list_start = lease_list_raw.find('\n', lease_list_start) + lease_list = lease_list_raw[lease_list_start + 1:] lines = lease_list.split('\n') for line in lines: try: diff --git a/modules/network/dhcp-1/python/src/grpc_server/dhcp_server.py b/modules/network/dhcp-1/python/src/grpc_server/dhcp_server.py index 5e88d59fe..7c2ca3d83 100644 --- a/modules/network/dhcp-1/python/src/grpc_server/dhcp_server.py +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_server.py @@ -16,15 +16,13 @@ import sys import time from common import logger -from common import util from dhcp_config import DHCPConfig from radvd_server import RADVDServer +from isc_dhcp_server import ISCDHCPServer -CONFIG_FILE = '/etc/dhcp/dhcpd.conf' LOG_NAME = 'dhcp_server' LOGGER = None - class DHCPServer: """Represents the DHCP Server""" @@ -33,86 +31,65 @@ def __init__(self): LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') self.dhcp_config = DHCPConfig() self.radvd = RADVDServer() + self.isc_dhcp = ISCDHCPServer() self.dhcp_config.resolve_config() def restart(self): - LOGGER.info('Restarting DHCP Server') - isc_started = util.run_command('service isc-dhcp-server restart', False) + LOGGER.info('Restarting DHCP server') + isc_started = self.isc_dhcp.restart() radvd_started = self.radvd.restart() started = isc_started and radvd_started - LOGGER.info('DHCP Restarted: ' + str(started)) + LOGGER.info('DHCP server restarted: ' + str(started)) return started def start(self): - LOGGER.info('Starting DHCP Server') - isc_started = util.run_command('service isc-dhcp-server start', False) + LOGGER.info('Starting DHCP server') + isc_started = self.isc_dhcp.start() radvd_started = self.radvd.start() started = isc_started and radvd_started - LOGGER.info('DHCP Started: ' + str(started)) + LOGGER.info('DHCP server started: ' + str(started)) return started def stop(self): - LOGGER.info('Stopping DHCP Server') - isc_stopped = util.run_command('service isc-dhcp-server stop', False) + LOGGER.info('Stopping DHCP server') + isc_stopped = self.isc_dhcp.stop() radvd_stopped = self.radvd.stop() stopped = isc_stopped and radvd_stopped - LOGGER.info('DHCP Stopped: ' + str(stopped)) + LOGGER.info('DHCP server stopped: ' + str(stopped)) return stopped def is_running(self): - LOGGER.info('Checking DHCP Status') - response = util.run_command('service isc-dhcp-server status') - isc_running = response[ - 0] == 'Status of ISC DHCPv4 server: dhcpd is running.' + LOGGER.info('Checking DHCP server status') + isc_running = self.isc_dhcp.is_running() radvd_running = self.radvd.is_running() running = isc_running and radvd_running - LOGGER.info('DHCP Status: ' + str(running)) + LOGGER.info('DHCP server status: ' + str(running)) return running def boot(self): - LOGGER.info('Booting DHCP Server') - isc_booted = False - radvd_booted = False + LOGGER.info('Booting DHCP server') + booted = False if self.is_running(): - LOGGER.info('Stopping isc-dhcp-server') + LOGGER.info('Stopping DHCP server') stopped = self.stop() - LOGGER.info('isc-dhcp-server stopped: ' + str(stopped)) - - if self.radvd.is_running(): - LOGGER.info('Stopping RADVD') - stopped = self.radvd.stop() - LOGGER.info('radvd stopped: ' + str(stopped)) - - LOGGER.info('Starting isc-dhcp-server') + LOGGER.info('DHCP server stopped: ' + str(stopped)) if self.start(): - isc_booted = False - # Scan for 5 seconds if not yet ready - for _ in range(5): - time.sleep(1) - isc_booted = self.is_running() - if isc_booted: - break - LOGGER.info('isc-dhcp-server started: ' + str(isc_booted)) - - LOGGER.info('Starting RADVD') - if self.radvd.start(): - radvd_booted = False # Scan for 5 seconds if not yet ready for _ in range(5): time.sleep(1) - radvd_booted = self.radvd.is_running() - if radvd_booted: + booted = self.is_running() + if booted: break - LOGGER.info('RADVD started: ' + str(radvd_booted)) + LOGGER.info('DHCP server booted: ' + str(booted)) + return booted - return isc_booted and radvd_booted def run(): dhcp_server = DHCPServer() booted = dhcp_server.boot() if not booted: - LOGGER.error('DHCP Server Failed to boot. Exiting') + LOGGER.error('DHCP server failed to boot. Exiting') sys.exit(1) config = str(dhcp_server.dhcp_config) @@ -120,7 +97,7 @@ def run(): dhcp_server.dhcp_config.resolve_config() new_config = str(dhcp_server.dhcp_config) if config != new_config: - LOGGER.info('DHCP Config Changed') + LOGGER.info('DHCP server config changed') config = new_config dhcp_server.restart() dhcp_server.radvd.restart() diff --git a/modules/network/dhcp-1/python/src/grpc_server/isc_dhcp_server.py b/modules/network/dhcp-1/python/src/grpc_server/isc_dhcp_server.py new file mode 100644 index 000000000..1a0e34186 --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/isc_dhcp_server.py @@ -0,0 +1,52 @@ +# 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. +"""Contains all the necessary classes to maintain the +isc-dhcp server booted from the isc-dhcp service file""" +from common import logger +from common import util + +LOG_NAME = 'isc-dhcp' +LOGGER = None + +class ISCDHCPServer: + """Represents the isc-dhcp server""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') + + def restart(self): + LOGGER.info('Restarting isc-dhcp server') + response = util.run_command('isc-dhcp-service restart', False) + LOGGER.info('isc-dhcp server restarted: ' + str(response)) + return response + + def start(self): + LOGGER.info('Starting isc-dhcp server') + response = util.run_command('isc-dhcp-service start', False) + LOGGER.info('isc-dhcp server started: ' + str(response)) + return response + + def stop(self): + LOGGER.info('Stopping isc-dhcp server') + response = util.run_command('isc-dhcp-service stop', False) + LOGGER.info('isc-dhcp server stopped: ' + str(response)) + return response + + def is_running(self): + LOGGER.info('Checking isc-dhcp server') + response = util.run_command('isc-dhcp-service status') + running = response[0] == 'isc-dhcp service is running.' + LOGGER.info('isc-dhcp server status: ' + str(running)) + return running diff --git a/modules/network/dhcp-1/python/src/grpc_server/network_service.py b/modules/network/dhcp-1/python/src/grpc_server/network_service.py index 043ca49b3..92726025d 100644 --- a/modules/network/dhcp-1/python/src/grpc_server/network_service.py +++ b/modules/network/dhcp-1/python/src/grpc_server/network_service.py @@ -25,6 +25,7 @@ LOG_NAME = 'network_service' LOGGER = None + class NetworkService(pb2_grpc.NetworkModule): """gRPC endpoints for the DHCP Server""" diff --git a/modules/network/dhcp-1/python/src/grpc_server/radvd_server.py b/modules/network/dhcp-1/python/src/grpc_server/radvd_server.py index 8bb1d0539..38eec4985 100644 --- a/modules/network/dhcp-1/python/src/grpc_server/radvd_server.py +++ b/modules/network/dhcp-1/python/src/grpc_server/radvd_server.py @@ -12,15 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. """Contains all the necessary classes to maintain the -DHCP server""" +RADVD server booted from the radvd-service file""" from common import logger from common import util -CONFIG_FILE = '/etc/dhcp/dhcpd.conf' LOG_NAME = 'radvd' LOGGER = None - class RADVDServer: """Represents the RADVD Server""" @@ -29,25 +27,26 @@ def __init__(self): LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') def restart(self): - LOGGER.info('Restarting RADVD Server') + LOGGER.info('Restarting RADVD server') response = util.run_command('radvd-service restart', False) - LOGGER.info('RADVD Restarted: ' + str(response)) + LOGGER.info('RADVD restarted: ' + str(response)) return response def start(self): - LOGGER.info('Starting RADVD Server') + LOGGER.info('Starting RADVD server') response = util.run_command('radvd-service start', False) - LOGGER.info('RADVD Started: ' + str(response)) + LOGGER.info('RADVD started: ' + str(response)) return response def stop(self): - LOGGER.info('Stopping RADVD Server') + LOGGER.info('Stopping RADVD server') response = util.run_command('radvd-service stop', False) - LOGGER.info('RADVD Stopped: ' + str(response)) + LOGGER.info('RADVD stopped: ' + str(response)) return response def is_running(self): - LOGGER.info('Checking RADVD Status') + LOGGER.info('Checking RADVD status') response = util.run_command('radvd-service status') - LOGGER.info('RADVD Status: ' + str(response)) - return response[0] == 'radvd service is running.' + running = response[0] == 'radvd service is running.' + LOGGER.info('RADVD status: ' + str(running)) + return running diff --git a/modules/network/dhcp-2/bin/isc-dhcp-service b/modules/network/dhcp-2/bin/isc-dhcp-service new file mode 100644 index 000000000..ee6df0341 --- /dev/null +++ b/modules/network/dhcp-2/bin/isc-dhcp-service @@ -0,0 +1,56 @@ +#!/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. + +CONFIG_FILE=/etc/dhcp/dhcpd.conf +DHCP_PID_FILE=/var/run/dhcpd.pid +DHCP_LOG_FILE=/runtime/network/dhcp2-dhcpd.log + +stop_dhcp(){ + # Directly kill by PID file reference + if [ -f "$DHCP_PID_FILE" ]; then + kill -9 $(cat $DHCP_PID_FILE) || true + rm -f $DHCP_PID_FILE + fi +} + +start_dhcp(){ + /usr/sbin/dhcpd -d &> $DHCP_LOG_FILE & +} + +case "$1" in + start) + start_dhcp + ;; + stop) + stop_dhcp + ;; + restart) + stop_dhcp + sleep 1 + start_dhcp + ;; + status) + if [ -f "$DHCP_PID_FILE" ]; then + echo "isc-dhcp service is running." + else + echo "isc-dhcp service is not running." + fi + ;; + *) + echo "Usage: $0 {start|stop|status|restart}" + exit 1 + ;; +esac \ No newline at end of file diff --git a/modules/network/dhcp-2/bin/start_network_service b/modules/network/dhcp-2/bin/start_network_service index ed7d3125e..9f702f015 100644 --- a/modules/network/dhcp-2/bin/start_network_service +++ b/modules/network/dhcp-2/bin/start_network_service @@ -44,7 +44,8 @@ cp /testrun/conf/isc-dhcp-server /etc/default/ cp /testrun/conf/dhcpd.conf /etc/dhcp/dhcpd.conf cp /testrun/conf/radvd.conf /etc/radvd.conf -# Move the radvd-sevice file to the correct location +# Move the service files to the correct location +cp /testrun/bin/isc-dhcp-service /usr/local/bin/ cp /testrun/bin/radvd-service /usr/local/bin/ # Start the DHCP Server diff --git a/modules/network/dhcp-2/conf/dhcpd.conf b/modules/network/dhcp-2/conf/dhcpd.conf index dcc47a4fe..5a6c82410 100644 --- a/modules/network/dhcp-2/conf/dhcpd.conf +++ b/modules/network/dhcp-2/conf/dhcpd.conf @@ -1,25 +1,26 @@ -default-lease-time 300; - -failover peer "failover-peer" { - secondary; - address 10.10.10.3; - port 647; - peer address 10.10.10.2; - peer port 847; - max-response-delay 60; - max-unacked-updates 10; - load balance max seconds 3; -} - -subnet 10.10.10.0 netmask 255.255.255.0 { - option ntp-servers 10.10.10.5; - option subnet-mask 255.255.255.0; - option broadcast-address 10.10.10.255; - option routers 10.10.10.1; - option domain-name-servers 10.10.10.4; - interface veth0; - pool { - failover peer "failover-peer"; - range 10.10.10.10 10.10.10.20; - } -} +default-lease-time 30; +max-lease-time 30; + +failover peer "failover-peer" { + secondary; + address 10.10.10.3; + port 647; + peer address 10.10.10.2; + peer port 847; + max-response-delay 60; + max-unacked-updates 10; + load balance max seconds 3; +} + +subnet 10.10.10.0 netmask 255.255.255.0 { + option ntp-servers 10.10.10.5; + option subnet-mask 255.255.255.0; + option broadcast-address 10.10.10.255; + option routers 10.10.10.1; + option domain-name-servers 10.10.10.4; + interface veth0; + pool { + failover peer "failover-peer"; + range 10.10.10.10 10.10.10.20; + } +} diff --git a/modules/network/dhcp-2/python/src/grpc_server/dhcp_config.py b/modules/network/dhcp-2/python/src/grpc_server/dhcp_config.py index 5da5e4cf2..5357ba7ed 100644 --- a/modules/network/dhcp-2/python/src/grpc_server/dhcp_config.py +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_config.py @@ -22,13 +22,15 @@ CONFIG_FILE = '/etc/dhcp/dhcpd.conf' DEFAULT_LEASE_TIME_KEY = 'default-lease-time' +MAX_LEASE_TIME_KEY = 'max-lease-time' class DHCPConfig: """Represents the DHCP Servers configuration and gives access to modify it""" def __init__(self): - self._default_lease_time = 300 + self._default_lease_time = 30 + self._max_lease_time = 30 self._subnets = [] self._peer = None self._reserved_hosts = [] @@ -122,17 +124,50 @@ def set_range(self, start, end, subnet=0, pool=0): octets[-1] = '0' dhcp_subnet = '.'.join(octets) - #Update the subnet and range - self._subnets[subnet].set_subnet(dhcp_subnet) + # Calcualte the netmask from the range + prefix = self.calculate_prefix_length(start, end) + netmask = self.calculate_netmask(prefix) + + #Update the subnet, range and netmask + self._subnets[subnet].set_subnet(dhcp_subnet, netmask) self._subnets[subnet].pools[pool].set_range(start, end) + def calculate_prefix_length(self, start_ip, end_ip): + start_octets = start_ip.split('.') + end_octets = end_ip.split('.') + + start_int = int( + ''.join(format(int(octet), '08b') for octet in start_octets), 2) + end_int = int(''.join(format(int(octet), '08b') for octet in end_octets), 2) + + xor_result = start_int ^ end_int + prefix_length = 32 - xor_result.bit_length() + + return prefix_length + + def calculate_netmask(self, prefix_length): + num_network_bits = prefix_length + num_host_bits = 32 - num_network_bits + + netmask_int = (2**num_network_bits - 1) << num_host_bits + netmask_octets = [(netmask_int >> (i * 8)) & 0xff for i in range(3, -1, -1)] + + return '.'.join(str(octet) for octet in netmask_octets) + def __str__(self): + config = ('{DEFAULT_LEASE_TIME_KEY} {DEFAULT_LEASE_TIME};' + if self._default_lease_time is not None else '') + config += ('\n\r{MAX_LEASE_TIME_KEY} {MAX_LEASE_TIME};' + if self._max_lease_time is not None else '') + # Encode the top level config options - config = """{DEFAULT_LEASE_TIME_KEY} {DEFAULT_LEASE_TIME};""" + #config = """{DEFAULT_LEASE_TIME_KEY} {DEFAULT_LEASE_TIME};""" config = config.format(length='multi-line', DEFAULT_LEASE_TIME_KEY=DEFAULT_LEASE_TIME_KEY, - DEFAULT_LEASE_TIME=self._default_lease_time) + DEFAULT_LEASE_TIME=self._default_lease_time, + MAX_LEASE_TIME_KEY=MAX_LEASE_TIME_KEY, + MAX_LEASE_TIME=self._max_lease_time) # Encode the failover peer config += '\n\n' + str(self._peer) @@ -360,12 +395,24 @@ def set_subnet(self, subnet, netmask=None): self._subnet = subnet self._subnet_mask = netmask - # Calculate the broadcast from the subnet - octets = subnet.split('.') - octets[-1] = '255' - dhcp_broadcast = '.'.join(octets) + # Calculate the broadcast from the subnet and netmask + broadcast = self.calculate_broadcast_address(subnet, netmask) + self._broadcast = broadcast + + def calculate_broadcast_address(self, subnet_address, netmask): + subnet_octets = subnet_address.split('.') + netmask_octets = netmask.split('.') + + subnet_int = int( + ''.join(format(int(octet), '08b') for octet in subnet_octets), 2) + netmask_int = int( + ''.join(format(int(octet), '08b') for octet in netmask_octets), 2) + + broadcast_int = subnet_int | (~netmask_int & 0xffffffff) + broadcast_octets = [(broadcast_int >> (i * 8)) & 0xff + for i in range(3, -1, -1)] - self._broadcast = dhcp_broadcast + return '.'.join(str(octet) for octet in broadcast_octets) def resolve_subnet(self, subnet): subnet_parts = subnet.split('\n') diff --git a/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.py b/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.py index b07f57b27..0a156db68 100644 --- a/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.py +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.py @@ -89,6 +89,16 @@ def test_resolve_config_with_hosts(self): self.assertIsNotNone(host) print('ResolveConfigWithHosts:\n' + str(config_with_hosts)) + def test_set_subnet_range(self): + range_start = '10.0.0.100' + range_end = '10.0.0.200' + DHCP_CONFIG.set_range(range_start, range_end) + subnets = DHCP_CONFIG.resolve_subnets(str(DHCP_CONFIG)) + pool = subnets[0].pools[0] + self.assertTrue(pool.range_start == range_start + and pool.range_end == range_end) + print('SetSubnetRange:\n' + str(DHCP_CONFIG)) + if __name__ == '__main__': suite = unittest.TestSuite() suite.addTest(DHCPConfigTest('test_resolve_config')) @@ -97,6 +107,6 @@ def test_resolve_config_with_hosts(self): suite.addTest(DHCPConfigTest('test_add_reserved_host')) suite.addTest(DHCPConfigTest('test_delete_reserved_host')) suite.addTest(DHCPConfigTest('test_resolve_config_with_hosts')) - + suite.addTest(DHCPConfigTest('test_set_subnet_range')) runner = unittest.TextTestRunner() runner.run(suite) diff --git a/modules/network/dhcp-2/python/src/grpc_server/dhcp_lease.py b/modules/network/dhcp-2/python/src/grpc_server/dhcp_lease.py index 0d2f43e3b..dd7ba9516 100644 --- a/modules/network/dhcp-2/python/src/grpc_server/dhcp_lease.py +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_lease.py @@ -39,7 +39,7 @@ def _make_lease(self, lease): self.hw_addr = sections[0] self.ip = sections[1] self.hostname = sections[2] - self.expires = sections[3] + '' '' + sections[4] + self.expires = sections[3] + ' ' + sections[4] self.manufacturer = ' '.join(sections[5:]) def get_millis(self, timestamp): diff --git a/modules/network/dhcp-2/python/src/grpc_server/dhcp_server.py b/modules/network/dhcp-2/python/src/grpc_server/dhcp_server.py index 67a31c2cb..7aec674f2 100644 --- a/modules/network/dhcp-2/python/src/grpc_server/dhcp_server.py +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_server.py @@ -16,15 +16,13 @@ import sys import time from common import logger -from common import util from dhcp_config import DHCPConfig from radvd_server import RADVDServer +from isc_dhcp_server import ISCDHCPServer -CONFIG_FILE = '/etc/dhcp/dhcpd.conf' LOG_NAME = 'dhcp_server' LOGGER = None - class DHCPServer: """Represents the DHCP Server""" @@ -33,86 +31,64 @@ def __init__(self): LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') self.dhcp_config = DHCPConfig() self.radvd = RADVDServer() + self.isc_dhcp = ISCDHCPServer() self.dhcp_config.resolve_config() def restart(self): - LOGGER.info('Restarting DHCP Server') - isc_started = util.run_command('service isc-dhcp-server restart', False) + LOGGER.info('Restarting DHCP server') + isc_started = self.isc_dhcp.restart() radvd_started = self.radvd.restart() started = isc_started and radvd_started - LOGGER.info('DHCP Restarted: ' + str(started)) + LOGGER.info('DHCP server restarted: ' + str(started)) return started def start(self): - LOGGER.info('Starting DHCP Server') - isc_started = util.run_command('service isc-dhcp-server start', False) + LOGGER.info('Starting DHCP server') + isc_started = self.isc_dhcp.start() radvd_started = self.radvd.start() started = isc_started and radvd_started - LOGGER.info('DHCP Started: ' + str(started)) + LOGGER.info('DHCP server started: ' + str(started)) return started def stop(self): - LOGGER.info('Stopping DHCP Server') - isc_stopped = util.run_command('service isc-dhcp-server stop', False) + LOGGER.info('Stopping DHCP server') + isc_stopped = self.isc_dhcp.stop() radvd_stopped = self.radvd.stop() stopped = isc_stopped and radvd_stopped - LOGGER.info('DHCP Stopped: ' + str(stopped)) + LOGGER.info('DHCP server stopped: ' + str(stopped)) return stopped def is_running(self): - LOGGER.info('Checking DHCP Status') - response = util.run_command('service isc-dhcp-server status') - isc_running = response[ - 0] == 'Status of ISC DHCPv4 server: dhcpd is running.' + LOGGER.info('Checking DHCP server status') + isc_running = self.isc_dhcp.is_running() radvd_running = self.radvd.is_running() running = isc_running and radvd_running - LOGGER.info('DHCP Status: ' + str(running)) + LOGGER.info('DHCP server status: ' + str(running)) return running def boot(self): - LOGGER.info('Booting DHCP Server') - isc_booted = False - radvd_booted = False + LOGGER.info('Booting DHCP server') + booted = False if self.is_running(): - LOGGER.info('Stopping isc-dhcp-server') + LOGGER.info('Stopping DHCP server') stopped = self.stop() - LOGGER.info('isc-dhcp-server stopped: ' + str(stopped)) - - if self.radvd.is_running(): - LOGGER.info('Stopping RADVD') - stopped = self.radvd.stop() - LOGGER.info('radvd stopped: ' + str(stopped)) - - LOGGER.info('Starting isc-dhcp-server') + LOGGER.info('DHCP server stopped: ' + str(stopped)) if self.start(): - isc_booted = False # Scan for 5 seconds if not yet ready for _ in range(5): time.sleep(1) - isc_booted = self.is_running() - if isc_booted: + booted = self.is_running() + if booted: break - LOGGER.info('isc-dhcp-server started: ' + str(isc_booted)) - - LOGGER.info('Starting RADVD') - if self.radvd.start(): - radvd_booted = False - # Scan for 5 seconds if not yet ready - for _ in range(5): - time.sleep(1) - radvd_booted = self.radvd.is_running() - if radvd_booted: - break - LOGGER.info('RADVD started: ' + str(radvd_booted)) - - return isc_booted and radvd_booted + LOGGER.info('DHCP server booted: ' + str(booted)) + return booted def run(): dhcp_server = DHCPServer() booted = dhcp_server.boot() if not booted: - LOGGER.error('DHCP Server Failed to boot. Exiting') + LOGGER.error('DHCP server failed to boot. Exiting') sys.exit(1) config = str(dhcp_server.dhcp_config) @@ -120,7 +96,7 @@ def run(): dhcp_server.dhcp_config.resolve_config() new_config = str(dhcp_server.dhcp_config) if config != new_config: - LOGGER.info('DHCP Config Changed') + LOGGER.info('DHCP server config changed') config = new_config dhcp_server.restart() dhcp_server.radvd.restart() diff --git a/modules/network/dhcp-2/python/src/grpc_server/isc_dhcp_server.py b/modules/network/dhcp-2/python/src/grpc_server/isc_dhcp_server.py new file mode 100644 index 000000000..429c06da0 --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/isc_dhcp_server.py @@ -0,0 +1,52 @@ +# 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. +"""Contains all the necessary classes to maintain the +isc-dhcp server booted from the isc-dhcp service file""" +from common import logger +from common import util + +LOG_NAME = 'isc-dhcp' +LOGGER = None + +class ISCDHCPServer: + """Represents the isc-dhcp server""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') + + def restart(self): + LOGGER.info('Restarting isc-dhcp server') + response = util.run_command('isc-dhcp-service restart', False) + LOGGER.info('isc-dhcp server restarted: ' + str(response)) + return response + + def start(self): + LOGGER.info('Starting isc-dhcp server') + response = util.run_command('isc-dhcp-service start', False) + LOGGER.info('isc-dhcp server started: ' + str(response)) + return response + + def stop(self): + LOGGER.info('Stopping isc-dhcp server') + response = util.run_command('isc-dhcp-service stop', False) + LOGGER.info('isc-dhcp server stopped: ' + str(response)) + return response + + def is_running(self): + LOGGER.info('Checking isc-dhcp server') + response = util.run_command('isc-dhcp-service status') + running = response[0] == 'isc-dhcp service is running.' + LOGGER.info('isc-dhcp server status: ' + str(running)) + return running diff --git a/modules/network/dhcp-2/python/src/grpc_server/radvd_server.py b/modules/network/dhcp-2/python/src/grpc_server/radvd_server.py index bc5d8b55f..910354e31 100644 --- a/modules/network/dhcp-2/python/src/grpc_server/radvd_server.py +++ b/modules/network/dhcp-2/python/src/grpc_server/radvd_server.py @@ -12,15 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. """Contains all the necessary classes to maintain the -DHCP server""" +RADVD server booted from the radvd-service file""" from common import logger from common import util -CONFIG_FILE = '/etc/dhcp/dhcpd.conf' LOG_NAME = 'radvd' LOGGER = None - class RADVDServer: """Represents the RADVD Server""" @@ -29,25 +27,26 @@ def __init__(self): LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') def restart(self): - LOGGER.info('Restarting RADVD Server') + LOGGER.info('Restarting RADVD server') response = util.run_command('radvd-service restart', False) - LOGGER.info('RADVD Restarted: ' + str(response)) + LOGGER.info('RADVD restarted: ' + str(response)) return response def start(self): - LOGGER.info('Starting RADVD Server') + LOGGER.info('Starting RADVD server') response = util.run_command('radvd-service start', False) - LOGGER.info('RADVD Started: ' + str(response)) + LOGGER.info('RADVD started: ' + str(response)) return response def stop(self): - LOGGER.info('Stopping RADVD Server') + LOGGER.info('Stopping RADVD server') response = util.run_command('radvd-service stop', False) - LOGGER.info('RADVD Stopped: ' + str(response)) + LOGGER.info('RADVD stopped: ' + str(response)) return response def is_running(self): - LOGGER.info('Checking RADVD Status') + LOGGER.info('Checking RADVD status') response = util.run_command('radvd-service status') - LOGGER.info('RADVD Status: ' + str(response)) - return response[0] == 'radvd service is running.' + running = response[0] == 'radvd service is running.' + LOGGER.info('RADVD status: ' + str(running)) + return running diff --git a/modules/test/base/python/src/grpc/proto/dhcp1/client.py b/modules/test/base/python/src/grpc/proto/dhcp1/client.py index 921929edb..03dd873bd 100644 --- a/modules/test/base/python/src/grpc/proto/dhcp1/client.py +++ b/modules/test/base/python/src/grpc/proto/dhcp1/client.py @@ -1,3 +1,18 @@ +# 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 primary DHCP Server""" + import grpc import grpc_pb2_grpc as pb2_grpc import grpc_pb2 as pb2 @@ -7,7 +22,7 @@ class Client(): - + """gRPC Client for the primary DHCP server""" def __init__(self, port=DEFAULT_PORT, host=DEFAULT_HOST): self._port = port self._host = host @@ -86,6 +101,24 @@ def get_status(self): return response + def stop_dhcp_server(self): + # Create a request message + request = pb2.StopDHCPServerRequest() + + # Make the RPC call + response = self._stub.StopDHCPServer(request) + + return response + + def start_dhcp_server(self): + # Create a request message + request = pb2.StartDHCPServerRequest() + + # Make the RPC call + response = self._stub.StartDHCPServer(request) + + return response + def set_dhcp_range(self,start,end): # Create a request message request = pb2.SetDHCPRangeRequest() diff --git a/modules/test/base/python/src/grpc/proto/dhcp2/client.py b/modules/test/base/python/src/grpc/proto/dhcp2/client.py new file mode 100644 index 000000000..e0d953ee5 --- /dev/null +++ b/modules/test/base/python/src/grpc/proto/dhcp2/client.py @@ -0,0 +1,130 @@ +# 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 grpc_pb2_grpc as pb2_grpc +import grpc_pb2 as pb2 + +DEFAULT_PORT = '5001' +DEFAULT_HOST = '10.10.10.3' # 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.NetworkModuleStub(self._channel) + + def add_reserved_lease(self, hostname, hw_addr, ip_addr): + # Create a request message + request = pb2.AddReservedLeaseRequest() + request.hostname = hostname + request.hw_addr = hw_addr + request.ip_addr = ip_addr + + # Make the RPC call + response = self._stub.AddReservedLease(request) + + return response + + def delete_reserved_lease(self, hw_addr): + # Create a request message + request = pb2.DeleteReservedLeaseRequest() + request.hw_addr = hw_addr + + # Make the RPC call + response = self._stub.DeleteReservedLease(request) + + return response + + def disable_failover(self): + # Create a request message + request = pb2.DisableFailoverRequest() + + # Make the RPC call + response = self._stub.DisableFailover(request) + + return response + + def enable_failover(self): + # Create a request message + request = pb2.EnableFailoverRequest() + + # Make the RPC call + response = self._stub.EnableFailover(request) + + return response + + def get_dhcp_range(self): + # Create a request message + request = pb2.GetDHCPRangeRequest() + + # Make the RPC call + response = self._stub.GetDHCPRange(request) + + return response + + def get_lease(self,hw_addr): + # Create a request message + request = pb2.GetLeaseRequest() + request.hw_addr=hw_addr + + # Make the RPC call + response = self._stub.GetLease(request) + + return response + + def get_status(self): + # Create a request message + request = pb2.GetStatusRequest() + + # Make the RPC call + response = self._stub.GetStatus(request) + + return response + + def stop_dhcp_server(self): + # Create a request message + request = pb2.StopDHCPServerRequest() + + # Make the RPC call + response = self._stub.StopDHCPServer(request) + + return response + + def start_dhcp_server(self): + # Create a request message + request = pb2.StartDHCPServerRequest() + + # Make the RPC call + response = self._stub.StartDHCPServer(request) + + return response + + def set_dhcp_range(self,start,end): + # Create a request message + request = pb2.SetDHCPRangeRequest() + request.start=start + request.end=end + + # Make the RPC call + response = self._stub.SetDHCPRange(request) + + return response diff --git a/modules/test/base/python/src/test_module.py b/modules/test/base/python/src/test_module.py index 2a892b810..b0898aa20 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -58,10 +58,11 @@ def _get_device_tests(self, device_test_module): for test in module_tests: # Resolve device specific configurations for the test if it exists # and update module test config with device config options - if test['name'] in device_test_module['tests']: - dev_test_config = device_test_module['tests'][test['name']] - if 'config' in test: - test['config'].update(dev_test_config) + if 'tests' in device_test_module: + if test['name'] in device_test_module['tests']: + dev_test_config = device_test_module['tests'][test['name']] + if 'config' in test: + test['config'].update(dev_test_config) return module_tests def _get_device_test_module(self): diff --git a/modules/test/conn/conf/module_config.json b/modules/test/conn/conf/module_config.json index 496b6aada..b82879544 100644 --- a/modules/test/conn/conf/module_config.json +++ b/modules/test/conn/conf/module_config.json @@ -9,7 +9,7 @@ "docker": { "depends_on": "base", "enable_container": true, - "timeout": 30 + "timeout": 600 }, "tests": [ { @@ -27,6 +27,25 @@ "description": "The device under test hs a MAC address prefix that is registered against a known manufacturer.", "expected_behavior": "The MAC address prefix is registered in the IEEE Organizationally Unique Identifier database." }, + { + "name": "connection.private_address", + "description": "The device under test accepts an IP address that is compliant with RFC 1918 Address Allocation for Private Internets.", + "expected_behavior": "The device under test accepts IP addresses within all ranges specified in RFC 1918 and communicates using these addresses. The Internet Assigned Numbers Authority (IANA) has reserved the following three blocks of the IP address space for private internets. 10.0.0.0 - 10.255.255.255.255 (10/8 prefix). 172.16.0.0 - 172.31.255.255 (172.16/12 prefix). 192.168.0.0 - 192.168.255.255 (192.168/16 prefix)", + "config": [ + { + "start": "10.0.0.100", + "end": "10.0.0.200" + }, + { + "start":"172.16.0.0", + "end":"172.16.255.255" + }, + { + "start":"192.168.0.0", + "end":"192.168.255.255" + } + ] + }, { "name": "connection.single_ip", "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/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index 0b11fde24..da8754608 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -14,10 +14,12 @@ """Connection test module""" import util import sys -import json -from scapy.all import rdpcap, DHCP, Ether +import time +from datetime import datetime +from scapy.all import rdpcap, DHCP, Ether, IPv6, ICMPv6ND_NS from test_module import TestModule from dhcp1.client import Client as DHCPClient1 +from dhcp2.client import Client as DHCPClient2 LOG_NAME = 'test_connection' LOGGER = None @@ -25,7 +27,7 @@ DHCP_SERVER_CAPTURE_FILE = '/runtime/network/dhcp-1.pcap' STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' -SLAAC_PREFIX = "fd10:77be:4186" +SLAAC_PREFIX = 'fd10:77be:4186' class ConnectionModule(TestModule): @@ -36,6 +38,7 @@ def __init__(self, module): global LOGGER LOGGER = self._get_logger() self.dhcp1_client = DHCPClient1() + self.dhcp2_client = DHCPClient2() # ToDo: Move this into some level of testing, leave for # reference until tests are implemented with these calls @@ -64,12 +67,79 @@ def __init__(self, module): # response = self.dhcp1_client.set_dhcp_range('10.10.10.20','10.10.10.30') # print("Set Range: " + str(response)) + def _connection_private_address(self, config): + # Shutdown the secondary DHCP Server + LOGGER.info('Running connection.private_address') + response = self.dhcp1_client.get_dhcp_range() + cur_range = {} + if response.code == 200: + cur_range['start'] = response.start + cur_range['end'] = response.end + LOGGER.info('Current DHCP subnet range: ' + str(cur_range)) + else: + LOGGER.error('Failed to resolve current subnet range required ' + 'for restoring network') + return None, ('Failed to resolve current subnet range required ' + 'for restoring network') + + results = [] + dhcp_setup = self.setup_single_dhcp_server() + if dhcp_setup[0]: + LOGGER.info(dhcp_setup[1]) + lease = self._get_cur_lease() + if lease is not None: + if self._is_lease_active(lease): + results = self.test_subnets(config) + else: + return None, 'Failed to confirm a valid active lease for the device' + else: + LOGGER.error(dhcp_setup[1]) + return None, 'Failed to setup DHCP server for test' + + # Process and return final results + final_result = None + final_result_details = '' + for result in results: + if final_result is None: + final_result = result['result'] + else: + final_result &= result['result'] + final_result_details += result['details'] + '\n' + + try: + # Restore failover configuration of DHCP servers + self.restore_failover_dhcp_server(cur_range) + + # Wait for the current lease to expire + self._wait_for_lease_expire(self._get_cur_lease()) + + # Wait for a new lease to be provided before exiting test + # to prevent other test modules from failing + for _ in range(5): + LOGGER.info('Checking for new lease') + lease = self._get_cur_lease() + if lease is not None: + LOGGER.info('New Lease found: ' + str(lease)) + LOGGER.info('Validating subnet for new lease...') + in_range = self.is_ip_in_range(lease['ip'], cur_range['start'], + cur_range['end']) + LOGGER.info('Lease within subnet: ' + str(in_range)) + break + else: + LOGGER.info('New lease not found. Waiting to check again') + time.sleep(5) + + except Exception as e: # pylint: disable=W0718 + LOGGER.error('Failed to restore DHCP server configuration: ' + str(e)) + + return final_result, final_result_details + def _connection_dhcp_address(self): LOGGER.info('Running connection.dhcp_address') response = self.dhcp1_client.get_lease(self._device_mac) LOGGER.info('DHCP Lease resolved:\n' + str(response)) if response.code == 200: - lease = eval(response.message) # pylint: disable=E0203 + lease = eval(response.message) # pylint: disable=W0123 if 'ip' in lease: ip_addr = lease['ip'] LOGGER.info('IP Resolved: ' + ip_addr) @@ -84,8 +154,6 @@ def _connection_dhcp_address(self): LOGGER.info('No DHCP lease found for: ' + self._device_mac) return False, 'No DHCP lease found for: ' + self._device_mac - self._ipv6_addr = None - def _connection_mac_address(self): LOGGER.info('Running connection.mac_address') if self._device_mac is not None: @@ -162,7 +230,7 @@ def _get_oui_manufacturer(self, mac_address): return None def _connection_ipv6_slaac(self): - LOGGER.info("Running connection.ipv6_slaac") + LOGGER.info('Running connection.ipv6_slaac') packet_capture = rdpcap(MONITOR_CAPTURE_FILE) sends_ipv6 = False @@ -173,31 +241,191 @@ def _connection_ipv6_slaac(self): if ICMPv6ND_NS in packet: ipv6_addr = str(packet[ICMPv6ND_NS].tgt) if ipv6_addr.startswith(SLAAC_PREFIX): - self._ipv6_addr = ipv6_addr - LOGGER.info(f"Device has formed SLAAC address {ipv6_addr}") + self._device_ipv6_addr = ipv6_addr + LOGGER.info(f'Device has formed SLAAC address {ipv6_addr}') return True if sends_ipv6: - LOGGER.info("Device does not support IPv6 SLAAC") + LOGGER.info('Device does not support IPv6 SLAAC') else: - LOGGER.info("Device does not support IPv6") + LOGGER.info('Device does not support IPv6') return False def _connection_ipv6_ping(self): - LOGGER.info("Running connection.ipv6_ping") + LOGGER.info('Running connection.ipv6_ping') - if self._ipv6_addr is None: - LOGGER.info("No IPv6 SLAAC address found. Cannot ping") + if self._device_ipv6_addr is None: + LOGGER.info('No IPv6 SLAAC address found. Cannot ping') return - if self._ping(self._ipv6_addr): - LOGGER.info(f"Device responds to IPv6 ping on {self._ipv6_addr}") + if self._ping(self._device_ipv6_addr): + LOGGER.info(f'Device responds to IPv6 ping on {self._device_ipv6_addr}') return True else: - LOGGER.info("Device does not respond to IPv6 ping") + LOGGER.info('Device does not respond to IPv6 ping') return False def _ping(self, host): cmd = "ping -c 1 " + str(host) success = util.run_command(cmd, output=False) return success + + def restore_failover_dhcp_server(self, subnet): + # Configure the subnet range + if self._change_subnet(subnet): + if self.enable_failover(): + response = self.dhcp2_client.start_dhcp_server() + if response.code == 200: + LOGGER.info('DHCP server configuration restored') + return True + else: + LOGGER.error('Failed to start secondary DHCP server') + return False + else: + LOGGER.error('Failed to enabled failover in primary DHCP server') + return False + else: + LOGGER.error('Failed to restore original subnet') + return False + + def setup_single_dhcp_server(self): + # Shutdown the secondary DHCP Server + LOGGER.info('Stopping secondary DHCP server') + response = self.dhcp2_client.stop_dhcp_server() + if response.code == 200: + LOGGER.info('Secondary DHCP server stop command success') + time.sleep(3) # Give some time for the server to stop + LOGGER.info('Checking secondary DHCP server status') + response = self.dhcp2_client.get_status() + if response.code == 200: + LOGGER.info('Secondary DHCP server stopped') + return True, 'Single DHCP server configured' + else: + return False, 'DHCP server still running' + else: + return False, 'DHCP server stop command failed' + + # Move primary DHCP server from failover into a single DHCP server config + LOGGER.info('Configuring primary DHCP server') + response = self.dhcp1_client.disable_failover() + if response.code == 200: + LOGGER.info('Primary DHCP server failover disabled') + else: + return False, 'Failed to disable primary DHCP server failover' + + def enable_failover(self): + # Move primary DHCP server to primary failover + LOGGER.info('Configuring primary failover DHCP server') + response = self.dhcp1_client.enable_failover() + if response.code == 200: + LOGGER.info('Primary DHCP server enabled') + return True + else: + LOGGER.error('Failed to disable primary DHCP server failover') + return False + + def is_ip_in_range(self, ip, start_ip, end_ip): + ip_int = int(''.join(format(int(octet), '08b') for octet in ip.split('.')), + 2) + start_int = int( + ''.join(format(int(octet), '08b') for octet in start_ip.split('.')), 2) + end_int = int( + ''.join(format(int(octet), '08b') for octet in end_ip.split('.')), 2) + + return start_int <= ip_int <= end_int + + def _test_subnet(self, subnet, lease): + if self._change_subnet(subnet): + expiration = datetime.strptime(lease['expires'], '%Y-%m-%d %H:%M:%S') + time_to_expire = expiration - datetime.now() + LOGGER.info('Time until lease expiration: ' + str(time_to_expire)) + LOGGER.info('Waiting for current lease to expire: ' + str(expiration)) + if time_to_expire.total_seconds() > 0: + time.sleep(time_to_expire.total_seconds() + + 5) # Wait until the expiration time and padd 5 seconds + LOGGER.info('Current lease expired. Checking for new lease') + for _ in range(5): + LOGGER.info('Checking for new lease') + lease = self._get_cur_lease() + if lease is not None: + LOGGER.info('New Lease found: ' + str(lease)) + LOGGER.info('Validating subnet for new lease...') + in_range = self.is_ip_in_range(lease['ip'], subnet['start'], + subnet['end']) + LOGGER.info('Lease within subnet: ' + str(in_range)) + return in_range + else: + LOGGER.info('New lease not found. Waiting to check again') + time.sleep(5) + + def _wait_for_lease_expire(self, lease): + expiration = datetime.strptime(lease['expires'], '%Y-%m-%d %H:%M:%S') + time_to_expire = expiration - datetime.now() + LOGGER.info('Time until lease expiration: ' + str(time_to_expire)) + LOGGER.info('Waiting for current lease to expire: ' + str(expiration)) + if time_to_expire.total_seconds() > 0: + time.sleep(time_to_expire.total_seconds() + + 5) # Wait until the expiration time and padd 5 seconds + LOGGER.info('Current lease expired.') + + def _change_subnet(self, subnet): + LOGGER.info('Changing subnet to: ' + str(subnet)) + response = self.dhcp1_client.set_dhcp_range(subnet['start'], subnet['end']) + if response.code == 200: + LOGGER.info('Subnet change request accepted. Confirming change...') + response = self.dhcp1_client.get_dhcp_range() + if response.code == 200: + if response.start == subnet['start'] and response.end == subnet['end']: + LOGGER.info('Subnet change confirmed') + return True + LOGGER.error('Failed to confirm subnet change') + else: + LOGGER.error('Subnet change request failed.') + return False + + def _get_cur_lease(self): + LOGGER.info('Checking current device lease') + response = self.dhcp1_client.get_lease(self._device_mac) + if response.code == 200: + lease = eval(response.message) # pylint: disable=W0123 + if lease: # Check if non-empty lease + return lease + else: + return None + + def _is_lease_active(self, lease): + if 'ip' in lease: + ip_addr = lease['ip'] + LOGGER.info('Lease IP Resolved: ' + ip_addr) + LOGGER.info('Attempting to ping device...') + ping_success = self._ping(self._device_ipv4_addr) + LOGGER.info('Ping Success: ' + str(ping_success)) + LOGGER.info('Current lease confirmed active in device') + return ping_success + + def test_subnets(self, subnets): + results = [] + for subnet in subnets: + result = {} + try: + lease = self._get_cur_lease() + if lease is not None: + result = self._test_subnet(subnet, lease) + if result: + result = { + 'result': + True, + 'details': + 'Subnet ' + subnet['start'] + '-' + subnet['end'] + ' passed' + } + else: + result = { + 'result': + False, + 'details': + 'Subnet ' + subnet['start'] + '-' + subnet['end'] + ' failed' + } + except Exception as e: # pylint: disable=W0718 + result = {'result': False, 'details': 'Subnet test failed: ' + str(e)} + results.append(result) + return results diff --git a/testing/docker/ci_test_device1/Dockerfile b/testing/docker/ci_test_device1/Dockerfile new file mode 100644 index 000000000..0bb697509 --- /dev/null +++ b/testing/docker/ci_test_device1/Dockerfile @@ -0,0 +1,11 @@ + +FROM ubuntu:jammy + +#Update and get all additional requirements not contained in the base image +RUN apt-get update && apt-get -y upgrade + +RUN apt-get update && apt-get install -y isc-dhcp-client ntpdate coreutils moreutils inetutils-ping curl jq dnsutils openssl netcat-openbsd + +COPY entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/testing/docker/ci_test_device1/entrypoint.sh b/testing/docker/ci_test_device1/entrypoint.sh new file mode 100755 index 000000000..8113704be --- /dev/null +++ b/testing/docker/ci_test_device1/entrypoint.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +ip a + +declare -A options +for option in $*; do + if [[ $option == *"="* ]]; then + k=$(echo $option | cut -d'=' -f1) + v=$(echo $option | cut -d'=' -f2) + options[$k]=$v + else + options[$option]=$option + fi +done + +OUT=/out/testrun_ci.json + +NTP_SERVER=10.10.10.5 +DNS_SERVER=10.10.10.4 + +function wout(){ + temp=${1//./\".\"} + key=${temp:1}\" + echo $key + value=$2 + jq "$key+=\"$value\"" $OUT | sponge $OUT +} + + +dig @8.8.8.8 +short www.google.com + +# DHCP +ip addr flush dev eth0 +PID_FILE=/var/run/dhclient.pid +if [ -f $PID_FILE ]; then + kill -9 $(cat $PID_FILE) || true + rm -f $PID_FILE +fi +dhclient -v eth0 + + +if [ -n "${options[oddservices]}" ]; then + echo Running services on non standard ports and open default ports + + echo Starting FTP 21514 and open default 20,21 + nc -nvlt -p 20 & + nc -nvlt -p 21 & + (while true; do echo -e "220 ProFTPD 1.3.5e Server (Debian) $(hostname)" | nc -l -w 1 21514; done) & + + echo Starting SMTP 1256 and open default 25, 465, 587 + nc -nvlt -p 25 & + nc -nvlt -p 465 & + nc -nvlt -p 587 & + (while true; do echo -e "220 $(hostname) ESMTP Postfix (Ubuntu)" | nc -l -w 1 1256; done) & + + echo Starting IMAP 5361 and open default ports 143, 993 + nc -nvlt -p 143 & + nc -nvlt -p 993 & + (while true; do echo -e "* OK [CAPABILITY IMAP4rev1 LITERAL+ SASL-IR LOGIN-REFERRALS ID ENABLE IDLE STARTTLS AUTH=PLAIN] Dovecot (Ubuntu) ready.\r\n" \ + | nc -l -w 1 5361; done) & + + echo Starting POP3 23451 and open default 110, 995 + nc -nvlt -p 110 & + nc -nvlt -p 995 & + (while true; do echo -ne "+OK POP3 Server ready\r\n" | nc -l -w 1 23451; done) & + + echo starting TFTP UDP 69 + (while true; do echo -ne "\0\x05\0\0\x07\0" | nc -u -l -w 1 69; done) & + +fi + +if [ -n "${options[snmp]}" ]; then + echo starting mock none snmpv3 on port UDP 161 + (while true; do echo -ne " \x02\x01\ " | nc -u -l -w 1 161; done) & +fi + +if [ -n "${options[snmpv3]}" ]; then + echo starting mock SNMPv3 UDP 161 + (while true; do echo -ne " \x02\x01\x030 \x02\x02Ji\x02 \x04\x01 \x02\x01\x03\x04" | nc -u -l -w 1 161; done) & +fi + +if [ -n "${options[ssh]}" ]; then + echo Starting SSH server + /usr/local/sbin/sshd +elif [ -n "${options[sshv1]}" ]; then + echo Starting SSHv1 server + echo 'Protocol 1' >> /usr/local/etc/sshd_config + /usr/local/sbin/sshd +fi + +tail -f /dev/null \ No newline at end of file diff --git a/testing/example/mac b/testing/example/mac new file mode 100644 index 000000000..e69de29bb diff --git a/testing/example/mac1/results.json b/testing/example/mac1/results.json new file mode 100644 index 000000000..e1b837225 --- /dev/null +++ b/testing/example/mac1/results.json @@ -0,0 +1,252 @@ +{ + "device": { + "mac_addr": "7e:41:12:d2:35:6a" + }, + "dns": { + "results": [ + { + "name": "dns.network.from_device", + "description": "Verify the device sends DNS requests", + "expected_behavior": "The device sends DNS requests.", + "start": "2023-07-03T13:35:48.990574", + "result": "compliant", + "end": "2023-07-03T13:35:49.035528", + "duration": "0:00:00.044954" + }, + { + "name": "dns.network.from_dhcp", + "description": "Verify the device allows for a DNS server to be entered automatically", + "expected_behavior": "The device sends DNS requests to the DNS server provided by the DHCP server", + "start": "2023-07-03T13:35:49.035701", + "result": "non-compliant", + "end": "2023-07-03T13:35:49.041532", + "duration": "0:00:00.005831" + }, + { + "name": "dns.mdns", + "description": "If the device has MDNS (or any kind of IP multicast), can it be disabled", + "start": "2023-07-03T13:35:49.041679", + "result": "non-compliant", + "end": "2023-07-03T13:35:49.057430", + "duration": "0:00:00.015751" + } + ] + }, + "nmap": { + "results": [ + { + "name": "security.nmap.ports", + "description": "Run an nmap scan of open ports", + "expected_behavior": "Report all open ports", + "config": { + "security.services.ftp": { + "tcp_ports": { + "20": { + "allowed": false, + "description": "File Transfer Protocol (FTP) Server Data Transfer", + "result": "compliant" + }, + "21": { + "allowed": false, + "description": "File Transfer Protocol (FTP) Server Data Transfer", + "result": "compliant" + } + }, + "description": "Check FTP port 20/21 is disabled and FTP is not running on any port", + "expected_behavior": "There is no FTP service running on any port" + }, + "security.services.ssh": { + "tcp_ports": { + "22": { + "allowed": true, + "description": "Secure Shell (SSH) server", + "version": "2.0", + "result": "compliant" + } + }, + "description": "Check TELNET port 23 is disabled and TELNET is not running on any port", + "expected_behavior": "There is no FTP service running on any port" + }, + "security.services.telnet": { + "tcp_ports": { + "23": { + "allowed": false, + "description": "Telnet Server", + "result": "compliant" + } + }, + "description": "Check TELNET port 23 is disabled and TELNET is not running on any port", + "expected_behavior": "There is no FTP service running on any port" + }, + "security.services.smtp": { + "tcp_ports": { + "25": { + "allowed": false, + "description": "Simple Mail Transfer Protocol (SMTP) Server", + "result": "compliant" + }, + "465": { + "allowed": false, + "description": "Simple Mail Transfer Protocol over SSL (SMTPS) Server", + "result": "compliant" + }, + "587": { + "allowed": false, + "description": "Simple Mail Transfer Protocol via TLS (SMTPS) Server", + "result": "compliant" + } + }, + "description": "Check SMTP port 25 is disabled and ports 465 or 587 with SSL encryption are (not?) enabled and SMTP is not running on any port.", + "expected_behavior": "There is no smtp service running on any port" + }, + "security.services.http": { + "tcp_ports": { + "80": { + "service_scan": { + "script": "http-methods" + }, + "allowed": false, + "description": "Administrative Insecure Web-Server", + "result": "compliant" + } + }, + "description": "Check that there is no HTTP server running on any port", + "expected_behavior": "Device is unreachable on port 80 (or any other port) and only responds to HTTPS requests on port 443 (or any other port if HTTP is used at all)" + }, + "security.services.pop": { + "tcp_ports": { + "110": { + "allowed": false, + "description": "Post Office Protocol v3 (POP3) Server", + "result": "compliant" + } + }, + "description": "Check POP port 110 is disalbed and POP is not running on any port", + "expected_behavior": "There is no pop service running on any port" + }, + "security.services.imap": { + "tcp_ports": { + "143": { + "allowed": false, + "description": "Internet Message Access Protocol (IMAP) Server", + "result": "compliant" + } + }, + "description": "Check IMAP port 143 is disabled and IMAP is not running on any port", + "expected_behavior": "There is no imap service running on any port" + }, + "security.services.snmpv3": { + "tcp_ports": { + "161": { + "allowed": false, + "description": "Simple Network Management Protocol (SNMP)", + "result": "compliant" + }, + "162": { + "allowed": false, + "description": "Simple Network Management Protocol (SNMP) Trap", + "result": "compliant" + } + }, + "udp_ports": { + "161": { + "allowed": false, + "description": "Simple Network Management Protocol (SNMP)" + }, + "162": { + "allowed": false, + "description": "Simple Network Management Protocol (SNMP) Trap" + } + }, + "description": "Check SNMP port 161/162 is disabled. If SNMP is an essential service, check it supports version 3", + "expected_behavior": "Device is unreachable on port 161 (or any other port) and device is unreachable on port 162 (or any other port) unless SNMP is essential in which case it is SNMPv3 is used." + }, + "security.services.https": { + "tcp_ports": { + "80": { + "allowed": false, + "description": "Administrative Secure Web-Server", + "result": "compliant" + } + }, + "description": "Check that if there is a web server running it is running on a secure port.", + "expected_behavior": "Device only responds to HTTPS requests on port 443 (or any other port if HTTP is used at all)" + }, + "security.services.vnc": { + "tcp_ports": { + "5800": { + "allowed": false, + "description": "Virtual Network Computing (VNC) Remote Frame Buffer Protocol Over HTTP", + "result": "compliant" + }, + "5500": { + "allowed": false, + "description": "Virtual Network Computing (VNC) Remote Frame Buffer Protocol", + "result": "compliant" + } + }, + "description": "Check VNC is disabled on any port", + "expected_behavior": "Device cannot be accessed /connected to via VNc on any port" + }, + "security.services.tftp": { + "udp_ports": { + "69": { + "allowed": false, + "description": "Trivial File Transfer Protocol (TFTP) Server", + "result": "compliant" + } + }, + "description": "Check TFTP port 69 is disabled (UDP)", + "expected_behavior": "There is no tftp service running on any port" + }, + "security.services.ntp": { + "udp_ports": { + "123": { + "allowed": false, + "description": "Network Time Protocol (NTP) Server", + "result": "compliant" + } + }, + "description": "Check NTP port 123 is disabled and the device is not operating as an NTP server", + "expected_behavior": "The device dos not respond to NTP requests when it's IP is set as the NTP server on another device" + } + }, + "start": "2023-07-03T13:36:26.923704", + "result": "compliant", + "end": "2023-07-03T13:36:52.965535", + "duration": "0:00:26.041831" + } + ] + }, + "baseline": { + "results": [ + { + "name": "baseline.pass", + "description": "Simulate a compliant test", + "expected_behavior": "A compliant test result is generated", + "start": "2023-07-03T13:37:29.100681", + "result": "compliant", + "end": "2023-07-03T13:37:29.100869", + "duration": "0:00:00.000188" + }, + { + "name": "baseline.fail", + "description": "Simulate a non-compliant test", + "expected_behavior": "A non-compliant test result is generated", + "start": "2023-07-03T13:37:29.100961", + "result": "non-compliant", + "end": "2023-07-03T13:37:29.101089", + "duration": "0:00:00.000128" + }, + { + "name": "baseline.skip", + "description": "Simulate a skipped test", + "expected_behavior": "A skipped test result is generated", + "start": "2023-07-03T13:37:29.101164", + "result": "skipped", + "end": "2023-07-03T13:37:29.101283", + "duration": "0:00:00.000119" + } + ] + } + } \ No newline at end of file diff --git a/testing/test_baseline b/testing/test_baseline index f12d124de..2b95ded23 100755 --- a/testing/test_baseline +++ b/testing/test_baseline @@ -20,7 +20,7 @@ ifconfig # Setup requirements sudo apt-get update -sudo apt-get install openvswitch-common openvswitch-switch tcpdump jq moreutils coreutils +sudo apt-get install openvswitch-common openvswitch-switch tcpdump jq moreutils coreutils isc-dhcp-client pip3 install pytest @@ -80,6 +80,6 @@ echo "Done baseline test" more $TESTRUN_OUT -pytest testing/ +pytest testing/test_baseline.py exit $? \ No newline at end of file diff --git a/testing/test_baseline.py b/testing/test_baseline.py index 246857581..520f909f7 100644 --- a/testing/test_baseline.py +++ b/testing/test_baseline.py @@ -12,6 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +""" Test assertions for CI network baseline test """ +# Temporarily disabled because using Pytest fixtures +# TODO refactor fixtures to not trigger error +# pylint: disable=redefined-outer-name + import json import pytest import re @@ -24,14 +29,13 @@ @pytest.fixture def container_data(): - dir = os.path.dirname(os.path.abspath(__file__)) with open(CI_BASELINE_OUT, encoding='utf-8') as f: return json.load(f) @pytest.fixture def validator_results(): - dir = os.path.dirname(os.path.abspath(__file__)) - with open(os.path.join(dir, + basedir = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(basedir, '../', 'runtime/validation/faux-dev/result.json'), encoding='utf-8') as f: @@ -63,6 +67,5 @@ def test_dns_server_resolves(container_data): @pytest.mark.skip(reason='requires internet') def test_validator_results_compliant(validator_results): - results = [True if x['result'] == 'compliant' else False - for x in validator_results['results']] + results = [x['result'] == 'compliant' for x in validator_results['results']] assert all(results) diff --git a/testing/test_pylint b/testing/test_pylint index 2ba696af5..3f4d8a3ed 100755 --- a/testing/test_pylint +++ b/testing/test_pylint @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -ERROR_LIMIT=100 +ERROR_LIMIT=175 sudo cmd/install @@ -38,4 +38,4 @@ if (( $new_errors > $ERROR_LIMIT)); then exit 1 fi -exit 0 +exit 0 \ No newline at end of file diff --git a/testing/test_tests b/testing/test_tests new file mode 100755 index 000000000..6ba9fef94 --- /dev/null +++ b/testing/test_tests @@ -0,0 +1,120 @@ +#!/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. + +set -o xtrace +ip a +TEST_DIR=/tmp/results +MATRIX=testing/test_tests.json + +mkdir -p $TEST_DIR + +# Setup requirements +sudo apt-get update +sudo apt-get install openvswitch-common openvswitch-switch tcpdump jq moreutils coreutils isc-dhcp-client + +pip3 install pytest + +# Start OVS +# Setup device network +sudo ip link add dev endev0a type veth peer name endev0b +sudo ip link set dev endev0a up +sudo ip link set dev endev0b up +sudo docker network create -d macvlan -o parent=endev0b endev1 + +sudo /usr/share/openvswitch/scripts/ovs-ctl start + +# Build Test Container +sudo docker build ./testing/docker/ci_test_device1 -t ci_test_device1 -f ./testing/docker/ci_test_device1/Dockerfile + +cat <local/system.json +{ + "network": { + "device_intf": "endev0a", + "internet_intf": "eth0" + }, + "log_level": "DEBUG", + "monitor_period": 30 +} +EOF + +sudo cmd/install + +TESTERS=$(jq -r 'keys[]' $MATRIX) +for tester in $TESTERS; do + testrun_log=$TEST_DIR/${tester}_testrun.log + device_log=$TEST_DIR/${tester}_device.log + + image=$(jq -r .$tester.image $MATRIX) + ethmac=$(jq -r .$tester.ethmac $MATRIX) + args=$(jq -r .$tester.args $MATRIX) + + touch $testrun_log + sudo timeout 900 cmd/start --single-intf > $testrun_log 2>&1 & + TPID=$! + + # Time to wait for testrun to be ready + WAITING=600 + for i in `seq 1 $WAITING`; do + tail -1 $testrun_log + if [[ -n $(fgrep "Waiting for devices on the network" $testrun_log) ]]; then + break + fi + + if [[ ! -d /proc/$TPID ]]; then + cat $testrun_log + echo "error encountered starting test run" + exit 1 + fi + + sleep 1 + done + + if [[ $i -eq $WAITING ]]; then + cat $testrun_log + echo "failed after waiting $WAITING seconds for test-run start" + exit 1 + fi + + # Load Test Container + sudo docker run -d \ + --network=endev1 \ + --mac-address=$ethmac \ + --cap-add=NET_ADMIN \ + -v /tmp:/out \ + --privileged \ + --name=$tester \ + ci_test_device1 $args + + wait $TPID + # Following line indicates that tests are completed but wait till it exits + # Completed running test modules on device with mac addr 7e:41:12:d2:35:6a + #Change this line! - LOGGER.info(f"""Completed running test modules on device + # with mac addr {device.mac_addr}""") + + ls runtime + more runtime/network/*.log + sudo docker kill $tester + sudo docker logs $tester | cat + + cp runtime/test/${ethmac//:/}/results.json $TEST_DIR/$tester.json + more $TEST_DIR/$tester.json + more $testrun_log + +done + +pytest -s testing/test_tests.py + +exit $? diff --git a/testing/test_tests.json b/testing/test_tests.json new file mode 100644 index 000000000..076e9149e --- /dev/null +++ b/testing/test_tests.json @@ -0,0 +1,19 @@ +{ + "tester1": { + "image": "test-run/ci_test1", + "args": "oddservices", + "ethmac": "02:42:aa:00:00:01", + "expected_results": { + "security.nmap.ports": "non-compliant" + } + }, + "tester2": { + "image": "test-run/ci_test1", + "args": "", + "ethmac": "02:42:aa:00:00:02", + "expected_results": { + "security.nmap.ports": "compliant" + } + } + +} \ No newline at end of file diff --git a/testing/test_tests.py b/testing/test_tests.py new file mode 100644 index 000000000..7c60484f0 --- /dev/null +++ b/testing/test_tests.py @@ -0,0 +1,102 @@ +# 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. + +""" Test assertions for CI testing of tests """ +# Temporarily disabled because using Pytest fixtures +# TODO refactor fixtures to not trigger error +# pylint: disable=redefined-outer-name + +import json +import pytest +import os +import glob +import itertools + +from pathlib import Path +from dataclasses import dataclass + +TEST_MATRIX = 'test_tests.json' +RESULTS_PATH = '/tmp/results/*.json' + +@dataclass(frozen=True) +class TestResult: + name: str + result: str + __test__ = False + + +def collect_expected_results(expected_results): + """ Yields results from expected_results property of the test matrix""" + for name, result in expected_results.items(): + yield TestResult(name, result) + + +def collect_actual_results(results_dict): + """ Yields results from an already loaded testrun results file """ + # "module"."results".[list]."result" + for maybe_module, child in results_dict.items(): + if 'results' in child and maybe_module != 'baseline': + for test in child['results']: + yield TestResult(test['name'], test['result']) + + +@pytest.fixture +def test_matrix(): + basedir = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(basedir, TEST_MATRIX), encoding='utf-8') as f: + return json.load(f) + + +@pytest.fixture +def results(): + results = {} + for file in [Path(x) for x in glob.glob(RESULTS_PATH)]: + with open(file, encoding='utf-8') as f: + results[file.stem] = json.load(f) + return results + + +def test_tests(results, test_matrix): + """ Check if each testers expect results were obtained """ + for tester, props in test_matrix.items(): + expected = set(collect_expected_results(props['expected_results'])) + actual = set(collect_actual_results(results[tester])) + + assert expected.issubset(actual), f'{tester} expected results not obtained' + +def test_list_tests(capsys, results, test_matrix): + all_tests = set(itertools.chain.from_iterable( + [collect_actual_results(results[x]) for x in results.keys()])) + + ci_pass = set([test + for testers in test_matrix.values() + for test, result in testers['expected_results'].items() + if result == 'compliant']) + + ci_fail = set([test + for testers in test_matrix.values() + for test, result in testers['expected_results'].items() + if result == 'non-compliant']) + + with capsys.disabled(): + print('============') + print('============') + print('tests seen:') + print('\n'.join([x.name for x in all_tests])) + print('\ntesting for pass:') + print('\n'.join(ci_pass)) + print('\ntesting for pass:') + print('\n'.join(ci_pass)) + + assert True