diff --git a/docs/network/README.md b/docs/network/README.md new file mode 100644 index 000000000..2d66d3e6a --- /dev/null +++ b/docs/network/README.md @@ -0,0 +1,41 @@ +# Network Overview + +## Table of Contents +1) Network Overview (this page) +2) [Addresses](addresses.md) +3) [Add a new network service](add_new_service.md) + +Test Run provides several built-in network services that can be utilized for testing purposes. These services are already available and can be used without any additional configuration. + +The following network services are provided: + +### Internet Connectivity (Gateway Service) + +The gateway service provides internet connectivity to the test network. It allows devices in the network to access external resources and communicate with the internet. + +### DHCPv4 Service + +The DHCPv4 service provides Dynamic Host Configuration Protocol (DHCP) functionality for IPv4 addressing. It includes the following components: + +- Primary DHCP Server: A primary DHCP server is available to assign IPv4 addresses to DHCP clients in the network. +- Secondary DHCP Server (Failover Configuration): A secondary DHCP server operates in failover configuration with the primary server to provide high availability and redundancy. + +#### Configuration + +The configuration of the DHCPv4 service can be modified using the provided GRPC (gRPC Remote Procedure Call) service. + +### IPv6 SLAAC Addressing + +The primary DHCP server also provides IPv6 Stateless Address Autoconfiguration (SLAAC) addressing for devices in the network. IPv6 addresses are automatically assigned to devices using SLAAC where test devices support it. + +### NTP Service + +The Network Time Protocol (NTP) service provides time synchronization for devices in the network. It ensures that all devices have accurate and synchronized time information. + +### DNS Service + +The DNS (Domain Name System) service resolves domain names to their corresponding IP addresses. It allows devices in the network to access external resources using domain names. + +### 802.1x Authentication (Radius Module) + +The radius module provides 802.1x authentication for devices in the network. It ensures secure and authenticated access to the network. The issuing CA (Certificate Authority) certificate can be specified by the user if required. \ No newline at end of file diff --git a/docs/network/add_new_service.md b/docs/network/add_new_service.md new file mode 100644 index 000000000..1ad07b60d --- /dev/null +++ b/docs/network/add_new_service.md @@ -0,0 +1,94 @@ +# Adding a New Network Service + +The Test Run framework allows users to add their own network services with ease. A template network service can be used to get started quickly, this can be found at [modules/network/template](../../modules/network/template). Otherwise, see below for details of the requirements for new network services. + +To add a new network service to Test Run, follow the procedure below: + +1. Create a folder under `modules/network/` with the name of the network service in lowercase, using only alphanumeric characters and hyphens (`-`). +2. Inside the created folder, include the following files and folders: + - `{module}.Dockerfile`: Dockerfile for building the network service image. Replace `{module}` with the name of the module. + - `conf/`: Folder containing the module configuration files. + - `bin/`: Folder containing the startup script for the network service. + - Any additional application code can be placed in its own folder. + +### Example `module_config.json` + +```json +{ + "config": { + "meta": { + "name": "{module}", + "display_name": "Network Service Name", + "description": "Description of the network service" + }, + "network": { + "interface": "veth0", + "enable_wan": false, + "ip_index": 2 + }, + "grpc": { + "port": 5001 + }, + "docker": { + "depends_on": "base", + "mounts": [ + { + "source": "runtime/network", + "target": "/runtime/network" + } + ] + } + } +} +``` + +### Example of {module}.Dockerfile + +```Dockerfile +# Image name: test-run/{module} +FROM test-run/base:latest + +ARG MODULE_NAME={module} +ARG MODULE_DIR=modules/network/$MODULE_NAME + +# Install network service dependencies +# ... + +# 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 + +# Do not specify a CMD or Entrypoint as Test Run will automatically start your service as required +``` + +### Example of start_network_service script + +```bash +#!/bin/bash + +CONFIG_FILE=/etc/network_service/config.conf +# ... + +echo "Starting Network Service..." + +# Perform any required setup steps +# ... + +# Start the network service +# ... + +# Monitor for changes in the config file +# ... + +# Restart the network service when the config changes +# ... +``` + + + + diff --git a/docs/network/addresses.md b/docs/network/addresses.md new file mode 100644 index 000000000..ecaacfd36 --- /dev/null +++ b/docs/network/addresses.md @@ -0,0 +1,18 @@ +# Network Addresses + +Each network service is configured with an IPv4 and IPv6 address. For IPv4 addressing, the last number in the IPv4 address is fixed (ensuring the IP is unique). See below for a table of network addresses: + +| Name | Mac address | IPv4 address | IPv6 address | +|---------------------|----------------------|--------------|------------------------------| +| Internet gateway | 9a:02:57:1e:8f:01 | 10.10.10.1 | fd10:77be:4186::1 | +| DHCP primary | 9a:02:57:1e:8f:02 | 10.10.10.2 | fd10:77be:4186::2 | +| DHCP secondary | 9a:02:57:1e:8f:03 | 10.10.10.3 | fd10:77be:4186::3 | +| DNS server | 9a:02:57:1e:8f:04 | 10.10.10.4 | fd10:77be:4186::4 | +| NTP server | 9a:02:57:1e:8f:05 | 10.10.10.5 | fd10:77be:4186::5 | +| Radius authenticator| 9a:02:57:1e:8f:07 | 10.10.10.7 | fd10:77be:4186::7 | +| Active test module | 9a:02:57:1e:8f:09 | 10.10.10.9 | fd10:77be:4186::9 | + + +The default network range is 10.10.10.0/24 and devices will be assigned addresses in that range via DHCP. The range may change when requested by a test module. In which case, network services will be restarted and accessible on the new range, with the same final host ID. The default IPv6 network is fd10:77be:4186::/64 and addresses will be assigned to devices on the network using IPv6 SLAAC. + +When creating a new network module, please ensure that the ip_index value in the module_config.json is unique otherwise unexpected behaviour will occur. \ No newline at end of file diff --git a/framework/python/src/net_orc/network_validator.py b/framework/python/src/net_orc/network_validator.py index a4c51eb2d..f82787af5 100644 --- a/framework/python/src/net_orc/network_validator.py +++ b/framework/python/src/net_orc/network_validator.py @@ -193,7 +193,7 @@ def _get_os_user(self): LOGGER.error('An OS error occurred while retrieving the login name.') except Exception as error: # Catch any other unexpected exceptions - LOGGER.error('An exception occurred:', error) + LOGGER.error('An exception occurred:', error) return user def _get_user(self): @@ -203,15 +203,15 @@ def _get_user(self): except (KeyError, ImportError, ModuleNotFoundError, OSError) as e: # Handle specific exceptions individually if isinstance(e, KeyError): - LOGGER.error("USER environment variable not set or unavailable.") + LOGGER.error('USER environment variable not set or unavailable.') elif isinstance(e, ImportError): - LOGGER.error("Unable to import the getpass module.") + LOGGER.error('Unable to import the getpass module.') elif isinstance(e, ModuleNotFoundError): - LOGGER.error("The getpass module was not found.") + LOGGER.error('The getpass module was not found.') elif isinstance(e, OSError): - LOGGER.error("An OS error occurred while retrieving the username.") + LOGGER.error('An OS error occurred while retrieving the username.') else: - LOGGER.error("An exception occurred:", e) + LOGGER.error('An exception occurred:', e) return user def _get_device_status(self, module): diff --git a/framework/python/src/test_orc/test_orchestrator.py b/framework/python/src/test_orc/test_orchestrator.py index 4bc9fc003..fef4e5bb5 100644 --- a/framework/python/src/test_orc/test_orchestrator.py +++ b/framework/python/src/test_orc/test_orchestrator.py @@ -13,16 +13,14 @@ # limitations under the License. """Provides high level management of the test orchestrator.""" -import getpass import os import json import time import shutil import docker from docker.types import Mount -from common import logger +from common import logger, util from test_orc.module import TestModule -from common import util LOG_NAME = "test_orc" LOGGER = logger.get_logger("test_orc") @@ -61,7 +59,7 @@ def start(self): # Setup the output directory self._host_user = util.get_host_user() os.makedirs(RUNTIME_DIR, exist_ok=True) - util.run_command(f'chown -R {self._host_user} {RUNTIME_DIR}') + util.run_command(f"chown -R {self._host_user} {RUNTIME_DIR}") self._load_test_modules() self.build_test_modules() @@ -102,7 +100,7 @@ def _generate_results(self, device): results[module.name] = module_results except (FileNotFoundError, PermissionError, json.JSONDecodeError) as results_error: - LOGGER.error("Error occured whilst obbtaining results for module " + module.name) + LOGGER.error(f"Error occured whilst obbtaining results for module {module.name}") LOGGER.debug(results_error) out_file = os.path.join( @@ -110,7 +108,7 @@ def _generate_results(self, device): "runtime/test/" + device.mac_addr.replace(":", "") + "/results.json") with open(out_file, "w", encoding="utf-8") as f: json.dump(results, f, indent=2) - util.run_command(f'chown -R {self._host_user} {out_file}') + util.run_command(f"chown -R {self._host_user} {out_file}") return results def test_in_progress(self): @@ -140,18 +138,19 @@ def _run_test_module(self, module, device): container_runtime_dir = os.path.join( self._root_path, "runtime/test/" + device.mac_addr.replace(":", "") + "/" + module.name) - network_runtime_dir = os.path.join(self._root_path, "runtime/network") os.makedirs(container_runtime_dir) + network_runtime_dir = os.path.join(self._root_path, "runtime/network") + device_startup_capture = os.path.join( self._root_path, "runtime/test/" + device.mac_addr.replace(":", "") + "/startup.pcap") - util.run_command(f'chown -R {self._host_user} {device_startup_capture}') + util.run_command(f"chown -R {self._host_user} {device_startup_capture}") device_monitor_capture = os.path.join( self._root_path, "runtime/test/" + device.mac_addr.replace(":", "") + "/monitor.pcap") - util.run_command(f'chown -R {self._host_user} {device_monitor_capture}') + util.run_command(f"chown -R {self._host_user} {device_monitor_capture}") client = docker.from_env() diff --git a/local/system.json.example b/local/system.json.example index ecf480104..e99e013f3 100644 --- a/local/system.json.example +++ b/local/system.json.example @@ -1,10 +1,10 @@ -{ - "network": { - "device_intf": "enx123456789123", - "internet_intf": "enx123456789124" - }, - "log_level": "INFO", - "startup_timeout": 60, - "monitor_period": 300, - "runtime": 1200 +{ + "network": { + "device_intf": "enx123456789123", + "internet_intf": "enx123456789124" + }, + "log_level": "INFO", + "startup_timeout": 60, + "monitor_period": 300, + "runtime": 1200 } \ No newline at end of file diff --git a/modules/network/base/base.Dockerfile b/modules/network/base/base.Dockerfile index f8fa43c57..ac964a99d 100644 --- a/modules/network/base/base.Dockerfile +++ b/modules/network/base/base.Dockerfile @@ -17,10 +17,14 @@ FROM ubuntu:jammy ARG MODULE_NAME=base ARG MODULE_DIR=modules/network/$MODULE_NAME +ARG COMMON_DIR=framework/python/src/common # Install common software RUN apt-get update && apt-get install -y net-tools iputils-ping tcpdump iproute2 jq python3 python3-pip dos2unix +# Install common python modules +COPY $COMMON_DIR/ /testrun/python/src/common + # Setup the base python requirements COPY $MODULE_DIR/python /testrun/python diff --git a/modules/network/base/bin/setup_python_path b/modules/network/base/bin/setup_python_path new file mode 100644 index 000000000..3e30e965d --- /dev/null +++ b/modules/network/base/bin/setup_python_path @@ -0,0 +1,25 @@ +#!/bin/bash + +ROOT_DIRECTORY="/testrun/python/src" + +# Function to recursively add subdirectories to PYTHONPATH +add_subdirectories_to_pythonpath() { + local directory=$1 + local subdirectories=( "$directory"/* ) + local subdirectory + + for subdirectory in "${subdirectories[@]}"; do + if [[ -d "$subdirectory" && ! "$subdirectory" = *'__pycache__' ]]; then + export PYTHONPATH="$PYTHONPATH:$subdirectory" + add_subdirectories_to_pythonpath "$subdirectory" + fi + done +} + +# Set PYTHONPATH initially to an empty string +export PYTHONPATH="$ROOT_DIRECTORY" + +# Add all subdirectories to PYTHONPATH +add_subdirectories_to_pythonpath "$ROOT_DIRECTORY" + +echo "$PYTHONPATH" \ No newline at end of file diff --git a/modules/network/base/bin/start_grpc b/modules/network/base/bin/start_grpc index 56f915db7..840bea65f 100644 --- a/modules/network/base/bin/start_grpc +++ b/modules/network/base/bin/start_grpc @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -GRPC_DIR="/testrun/python/src/grpc" +GRPC_DIR="/testrun/python/src/grpc_server" GRPC_PROTO_DIR="proto" GRPC_PROTO_FILE="grpc.proto" @@ -22,10 +22,10 @@ GRPC_PROTO_FILE="grpc.proto" pushd $GRPC_DIR >/dev/null 2>&1 #Build the grpc proto file every time before starting server -python3 -m grpc_tools.protoc --proto_path=. ./$GRPC_PROTO_DIR/$GRPC_PROTO_FILE --python_out=. --grpc_python_out=. +python3 -u -m grpc_tools.protoc --proto_path=. ./$GRPC_PROTO_DIR/$GRPC_PROTO_FILE --python_out=. --grpc_python_out=. popd >/dev/null 2>&1 #Start the grpc server -python3 -u $GRPC_DIR/start_server.py $@ +python3 -u $GRPC_DIR/start_server.py $@ & diff --git a/modules/network/base/bin/start_module b/modules/network/base/bin/start_module index e00747b43..8e8cb5e4b 100644 --- a/modules/network/base/bin/start_module +++ b/modules/network/base/bin/start_module @@ -29,7 +29,7 @@ useradd $HOST_USER sysctl net.ipv6.conf.all.disable_ipv6=0 sysctl -p -#Read in the config file +# Read in the config file CONF_FILE="/testrun/conf/module_config.json" CONF=`cat $CONF_FILE` @@ -60,10 +60,16 @@ else INTF=$DEFINED_IFACE fi -echo "Starting module $MODULE_NAME on local interface $INTF..." +# Setup the PYTHONPATH so all imports work as expected +echo "Setting up PYTHONPATH..." +export PYTHONPATH=$($BIN_DIR/setup_python_path) +echo "PYTHONPATH: $PYTHONPATH" +echo "Configuring binary files..." $BIN_DIR/setup_binaries $BIN_DIR +echo "Starting module $MODULE_NAME on local interface $INTF..." + # Wait for interface to become ready $BIN_DIR/wait_for_interface $INTF @@ -80,14 +86,14 @@ then if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] then echo "gRPC port resolved from config: $GRPC_PORT" - $BIN_DIR/start_grpc "-p $GRPC_PORT" & + $BIN_DIR/start_grpc "-p $GRPC_PORT" else - $BIN_DIR/start_grpc & + $BIN_DIR/start_grpc fi fi -#Small pause to let all core services stabalize +# Small pause to let all core services stabalize sleep 3 -#Start the networking service +# Start the networking service $BIN_DIR/start_network_service $MODULE_NAME $INTF \ No newline at end of file diff --git a/modules/network/base/python/requirements.txt b/modules/network/base/python/requirements.txt index 9c4e2b056..9d9473d74 100644 --- a/modules/network/base/python/requirements.txt +++ b/modules/network/base/python/requirements.txt @@ -1,2 +1,3 @@ grpcio -grpcio-tools \ No newline at end of file +grpcio-tools +netifaces \ No newline at end of file diff --git a/modules/network/base/python/src/grpc/start_server.py b/modules/network/base/python/src/grpc_server/start_server.py similarity index 100% rename from modules/network/base/python/src/grpc/start_server.py rename to modules/network/base/python/src/grpc_server/start_server.py diff --git a/modules/network/base/python/src/logger.py b/modules/network/base/python/src/logger.py index 8893b1e8d..998a4aaae 100644 --- a/modules/network/base/python/src/logger.py +++ b/modules/network/base/python/src/logger.py @@ -35,7 +35,7 @@ log_level = logging.getLevelName(log_level_str) except OSError: # TODO: Print out warning that log level is incorrect or missing - LOG_LEVEL = _DEFAULT_LEVEL + log_level = _DEFAULT_LEVEL log_format = logging.Formatter(fmt=_LOG_FORMAT, datefmt=_DATE_FORMAT) diff --git a/modules/network/dhcp-1/bin/radvd-service b/modules/network/dhcp-1/bin/radvd-service new file mode 100644 index 000000000..1cfe499cb --- /dev/null +++ b/modules/network/dhcp-1/bin/radvd-service @@ -0,0 +1,55 @@ +#!/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. + +RA_PID_FILE=/var/run/radvd/radvd.pid +RA_LOG_FILE=/runtime/network/dhcp1-radvd.log + +stop_radvd(){ + # Directly kill by PID file reference + if [ -f "$RA_PID_FILE" ]; then + kill -9 $(cat $RA_PID_FILE) || true + rm -f $RA_PID_FILE + fi +} + +start_radvd(){ + /usr/sbin/radvd -m logfile -l $RA_LOG_FILE -p $RA_PID_FILE +} + +case "$1" in + start) + start_radvd + ;; + stop) + stop_radvd + ;; + restart) + stop_radvd + sleep 1 + start_radvd + ;; + status) + if [ -f "$RA_PID_FILE" ]; then + echo "radvd service is running." + else + echo "radvd 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 fbeede871..413c48ceb 100644 --- a/modules/network/dhcp-1/bin/start_network_service +++ b/modules/network/dhcp-1/bin/start_network_service @@ -20,7 +20,7 @@ DHCP_LOG_FILE=/runtime/network/dhcp1-dhcpd.log RA_PID_FILE=/var/run/radvd/radvd.pid RA_LOG_FILE=/runtime/network/dhcp1-radvd.log -echo "Starrting Network Service..." +echo "Starting Network Service..." #Enable IPv6 Forwarding sysctl net.ipv6.conf.all.forwarding=1 @@ -29,63 +29,22 @@ sysctl -p # Create leases file if needed touch /var/lib/dhcp/dhcpd.leases -#Create directory for radvd +# Create directory for radvd mkdir /var/run/radvd -#Create and set permissions on the log files +# Create and set permissions on the log files touch $DHCP_LOG_FILE touch $RA_LOG_FILE chown $HOST_USER $DHCP_LOG_FILE chown $HOST_USER $RA_LOG_FILE - -#Move the config files to the correct location +# Move the config files to the correct location +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 -# Restart dhcp server when config changes -while true; do - - new_checksum=$(md5sum $CONFIG_FILE) - - if [ "$checksum" == "$new_checksum" ]; then - sleep 2 - continue - fi - - echo Config changed. Restarting dhcp server at $(date).. - - if [ -f $DHCP_PID_FILE ]; then - kill -9 $(cat $DHCP_PID_FILE) || true - rm -f $DHCP_PID_FILE - fi - - if [ -f $RA_PID_FILE ]; then - kill -9 $(cat $RA_PID_FILE) || true - rm -f $RA_PID_FILE - fi - - checksum=$new_checksum - - echo Starting isc-dhcp-server at $(date) - - radvd -m logfile -l $RA_LOG_FILE -p $RA_PID_FILE - dhcpd -d &> $DHCP_LOG_FILE & - - while [ ! -f $DHCP_PID_FILE ]; do - echo Waiting for $DHCP_PID_FILE... - sleep 2 - done - - echo $DHCP_PID_FILE now available - - while [ ! -f $RA_PID_FILE ]; do - echo Waiting for $RA_PID_FILE... - sleep 2 - done - - echo $RA_PID_FILE now available - - echo Server now stable +# Move the radvd-sevice file to the correct location +cp /testrun/bin/radvd-service /usr/local/bin/ -done \ No newline at end of file +# Start the DHCP Server +python3 -u /testrun/python/src/grpc_server/dhcp_server.py \ No newline at end of file diff --git a/modules/network/dhcp-1/conf/dhcpd.conf b/modules/network/dhcp-1/conf/dhcpd.conf index 9f4fe1c28..ee171279c 100644 --- a/modules/network/dhcp-1/conf/dhcpd.conf +++ b/modules/network/dhcp-1/conf/dhcpd.conf @@ -1,26 +1,28 @@ -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; - pool { - failover peer "failover-peer"; - range 10.10.10.10 10.10.10.20; - } -} +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; + } +} \ 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 new file mode 100644 index 000000000..44db95cd9 --- /dev/null +++ b/modules/network/dhcp-1/conf/isc-dhcp-server @@ -0,0 +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" diff --git a/modules/network/dhcp-1/conf/radvd.conf b/modules/network/dhcp-1/conf/radvd.conf index f6d6f30d9..89995785f 100644 --- a/modules/network/dhcp-1/conf/radvd.conf +++ b/modules/network/dhcp-1/conf/radvd.conf @@ -8,5 +8,6 @@ interface veth0 AdvOnLink on; AdvAutonomous on; AdvRouterAddr on; + AdvSourceLLAddress off; }; }; \ No newline at end of file diff --git a/modules/network/dhcp-1/dhcp-1.Dockerfile b/modules/network/dhcp-1/dhcp-1.Dockerfile index a4eb8d90a..6b941d878 100644 --- a/modules/network/dhcp-1/dhcp-1.Dockerfile +++ b/modules/network/dhcp-1/dhcp-1.Dockerfile @@ -18,8 +18,14 @@ FROM test-run/base:latest ARG MODULE_NAME=dhcp-1 ARG MODULE_DIR=modules/network/$MODULE_NAME +# Install all necessary packages +RUN apt-get install -y wget + +#Update the oui.txt file from ieee +RUN wget http://standards-oui.ieee.org/oui.txt -P /usr/local/etc/ + # Install dhcp server -RUN apt-get install -y isc-dhcp-server radvd +RUN apt-get install -y isc-dhcp-server radvd systemd # Copy over all configuration files COPY $MODULE_DIR/conf /testrun/conf @@ -28,4 +34,4 @@ COPY $MODULE_DIR/conf /testrun/conf COPY $MODULE_DIR/bin /testrun/bin # Copy over all python files -COPY $MODULE_DIR/python /testrun/python +COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/network/dhcp-1/python/src/grpc/dhcp_config.py b/modules/network/dhcp-1/python/src/grpc/dhcp_config.py deleted file mode 100644 index 99d6bdebd..000000000 --- a/modules/network/dhcp-1/python/src/grpc/dhcp_config.py +++ /dev/null @@ -1,303 +0,0 @@ -# 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 -DHCP server's configuration""" -import re - -CONFIG_FILE = '/etc/dhcp/dhcpd.conf' -CONFIG_FILE_TEST = 'network/modules/dhcp-1/conf/dhcpd.conf' - -DEFAULT_LEASE_TIME_KEY = 'default-lease-time' - - -class DHCPConfig: - """Represents the DHCP Servers configuration and gives access to modify it""" - - def __init__(self): - self._default_lease_time = 300 - self.subnets = [] - self._peer = None - - def write_config(self): - conf = str(self) - print('Writing config: \n' + conf) - with open(CONFIG_FILE, 'w', encoding='UTF-8') as conf_file: - conf_file.write(conf) - - def resolve_config(self): - with open(CONFIG_FILE, 'r', encoding='UTF-8') as f: - conf = f.read() - self.resolve_subnets(conf) - self._peer = DHCPFailoverPeer(conf) - - def resolve_subnets(self, conf): - self.subnets = [] - regex = r'(subnet.*)' - subnets = re.findall(regex, conf, re.MULTILINE | re.DOTALL) - for subnet in subnets: - dhcp_subnet = DHCPSubnet(subnet) - self.subnets.append(dhcp_subnet) - - def set_range(self, start, end, subnet=0, pool=0): - print('Setting Range for pool ') - print(self.subnets[subnet].pools[pool]) - self.subnets[subnet].pools[pool].range_start = start - self.subnets[subnet].pools[pool].range_end = end - - # def resolve_settings(self, conf): - # lines = conf.split('\n') - # for line in lines: - # if DEFAULT_LEASE_TIME_KEY in line: - # self._default_lease_time = line.strip().split( - # DEFAULT_LEASE_TIME_KEY)[1].strip().split(';')[0] - - # self.peer = peer - - def __str__(self): - - config = """\r{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) - - config += '\n\n' + str(self.peer) - for subnet in self._subnets: - config += '\n\n' + str(subnet) - return str(config) - - -FAILOVER_PEER_KEY = 'failover peer' -PRIMARY_KEY = 'primary' -ADDRESS_KEY = 'address' -PORT_KEY = 'port' -PEER_ADDRESS_KEY = 'peer address' -PEER_PORT_KEY = 'peer port' -MAX_RESPONSE_DELAY_KEY = 'max-response-delay' -MAX_UNACKED_UPDATES_KEY = 'max-unacked-updates' -MCLT_KEY = 'mclt' -SPLIT_KEY = 'split' -LOAD_BALANCE_MAX_SECONDS_KEY = 'load balance max seconds' - - -class DHCPFailoverPeer: - """Contains all information to define the DHCP failover peer""" - - def __init__(self, config): - self.name = None - self.primary = False - self.address = None - self.port = None - self.peer_address = None - self.peer_port = None - self.max_response_delay = None - self.max_unacked_updates = None - self.mclt = None - self.split = None - self.load_balance_max_seconds = None - self.peer = None - - self.resolve_peer(config) - - def __str__(self): - config = '{FAILOVER_PEER_KEY} \"{FAILOVER_PEER}\" {{\n' - config += '\tprimary;' if self.primary else 'secondary;' - config += """\n\t{ADDRESS_KEY} {ADDRESS}; - {PORT_KEY} {PORT}; - {PEER_ADDRESS_KEY} {PEER_ADDRESS}; - {PEER_PORT_KEY} {PEER_PORT}; - {MAX_RESPONSE_DELAY_KEY} {MAX_RESPONSE_DELAY}; - {MAX_UNACKED_UPDATES_KEY} {MAX_UNACKED_UPDATES}; - {MCLT_KEY} {MCLT}; - {SPLIT_KEY} {SPLIT}; - {LOAD_BALANCE_MAX_SECONDS_KEY} {LOAD_BALANCE_MAX_SECONDS}; - \r}}""" - - return config.format( - length='multi-line', - FAILOVER_PEER_KEY=FAILOVER_PEER_KEY, - FAILOVER_PEER=self.name, - ADDRESS_KEY=ADDRESS_KEY, - ADDRESS=self.address, - PORT_KEY=PORT_KEY, - PORT=self.port, - PEER_ADDRESS_KEY=PEER_ADDRESS_KEY, - PEER_ADDRESS=self.peer_address, - PEER_PORT_KEY=PEER_PORT_KEY, - PEER_PORT=self.peer_port, - MAX_RESPONSE_DELAY_KEY=MAX_RESPONSE_DELAY_KEY, - MAX_RESPONSE_DELAY=self.max_response_delay, - MAX_UNACKED_UPDATES_KEY=MAX_UNACKED_UPDATES_KEY, - MAX_UNACKED_UPDATES=self.max_unacked_updates, - MCLT_KEY=MCLT_KEY, - MCLT=self.mclt, - SPLIT_KEY=SPLIT_KEY, - SPLIT=self.split, - LOAD_BALANCE_MAX_SECONDS_KEY=LOAD_BALANCE_MAX_SECONDS_KEY, - LOAD_BALANCE_MAX_SECONDS=self.load_balance_max_seconds) - - def resolve_peer(self, conf): - peer = '' - lines = conf.split('\n') - for line in lines: - if line.startswith(FAILOVER_PEER_KEY) or len(peer) > 0: - if len(peer) <= 0: - self.name = line.strip().split(FAILOVER_PEER_KEY)[1].strip().split( - '{')[0].split('\"')[1] - peer += line + '\n' - if PRIMARY_KEY in line: - self.primary = True - elif ADDRESS_KEY in line and PEER_ADDRESS_KEY not in line: - self.address = line.strip().split(ADDRESS_KEY)[1].strip().split( - ';')[0] - elif PORT_KEY in line and PEER_PORT_KEY not in line: - self.port = line.strip().split(PORT_KEY)[1].strip().split(';')[0] - elif PEER_ADDRESS_KEY in line: - self.peer_address = line.strip().split( - PEER_ADDRESS_KEY)[1].strip().split(';')[0] - elif PEER_PORT_KEY in line: - self.peer_port = line.strip().split(PEER_PORT_KEY)[1].strip().split( - ';')[0] - elif MAX_RESPONSE_DELAY_KEY in line: - self.max_response_delay = line.strip().split( - MAX_RESPONSE_DELAY_KEY)[1].strip().split(';')[0] - elif MAX_UNACKED_UPDATES_KEY in line: - self.max_unacked_updates = line.strip().split( - MAX_UNACKED_UPDATES_KEY)[1].strip().split(';')[0] - elif MCLT_KEY in line: - self.mclt = line.strip().split(MCLT_KEY)[1].strip().split(';')[0] - elif SPLIT_KEY in line: - self.split = line.strip().split(SPLIT_KEY)[1].strip().split(';')[0] - elif LOAD_BALANCE_MAX_SECONDS_KEY in line: - self.load_balance_max_seconds = line.strip().split( - LOAD_BALANCE_MAX_SECONDS_KEY)[1].strip().split(';')[0] - if line.endswith('}') and len(peer) > 0: - break - self.peer = peer - - -NTP_OPTION_KEY = 'option ntp-servers' -SUBNET_MASK_OPTION_KEY = 'option subnet-mask' -BROADCAST_OPTION_KEY = 'option broadcast-address' -ROUTER_OPTION_KEY = 'option routers' -DNS_OPTION_KEY = 'option domain-name-servers' - - -class DHCPSubnet: - """Represents the DHCP Servers subnet configuration""" - - def __init__(self, subnet): - self._ntp_servers = None - self._subnet_mask = None - self._broadcast = None - self._routers = None - self._dns_servers = None - self.pools = [] - - self.resolve_subnet(subnet) - self.resolve_pools(subnet) - - def __str__(self): - config = """subnet 10.10.10.0 netmask {SUBNET_MASK_OPTION} {{ - \r\t{NTP_OPTION_KEY} {NTP_OPTION}; - \r\t{SUBNET_MASK_OPTION_KEY} {SUBNET_MASK_OPTION}; - \r\t{BROADCAST_OPTION_KEY} {BROADCAST_OPTION}; - \r\t{ROUTER_OPTION_KEY} {ROUTER_OPTION}; - \r\t{DNS_OPTION_KEY} {DNS_OPTION};""" - - config = config.format(length='multi-line', - NTP_OPTION_KEY=NTP_OPTION_KEY, - NTP_OPTION=self._ntp_servers, - SUBNET_MASK_OPTION_KEY=SUBNET_MASK_OPTION_KEY, - SUBNET_MASK_OPTION=self._subnet_mask, - BROADCAST_OPTION_KEY=BROADCAST_OPTION_KEY, - BROADCAST_OPTION=self._broadcast, - ROUTER_OPTION_KEY=ROUTER_OPTION_KEY, - ROUTER_OPTION=self._routers, - DNS_OPTION_KEY=DNS_OPTION_KEY, - DNS_OPTION=self._dns_servers) - for pool in self.pools: - config += '\n\t' + str(pool) - - config += '\n\r}' - return config - - def resolve_subnet(self, subnet): - subnet_parts = subnet.split('\n') - for part in subnet_parts: - if NTP_OPTION_KEY in part: - self._ntp_servers = part.strip().split(NTP_OPTION_KEY)[1].strip().split( - ';')[0] - elif SUBNET_MASK_OPTION_KEY in part: - self._subnet_mask = part.strip().split( - SUBNET_MASK_OPTION_KEY)[1].strip().split(';')[0] - elif BROADCAST_OPTION_KEY in part: - self._broadcast = part.strip().split( - BROADCAST_OPTION_KEY)[1].strip().split(';')[0] - elif ROUTER_OPTION_KEY in part: - self._routers = part.strip().split(ROUTER_OPTION_KEY)[1].strip().split( - ';')[0] - elif DNS_OPTION_KEY in part: - self._dns_servers = part.strip().split(DNS_OPTION_KEY)[1].strip().split( - ';')[0] - - def resolve_pools(self, subnet): - regex = r'(pool.*)\}' - pools = re.findall(regex, subnet, re.MULTILINE | re.DOTALL) - for pool in pools: - dhcp_pool = DHCPPool(pool) - self.pools.append(dhcp_pool) - - -FAILOVER_KEY = 'failover peer' -RANGE_KEY = 'range' - - -class DHCPPool: - """Represents a DHCP Servers subnet pool configuration""" - - def __init__(self, pool): - self.failover_peer = None - self.range_start = None - self.range_end = None - self.resolve_pool(pool) - - def __str__(self): - - config = """pool {{ - \r\t\t{FAILOVER_KEY} "{FAILOVER}"; - \r\t\t{RANGE_KEY} {RANGE_START} {RANGE_END}; - \r\t}}""" - - return config.format( - length='multi-line', - FAILOVER_KEY=FAILOVER_KEY, - FAILOVER=self.failover_peer, - RANGE_KEY=RANGE_KEY, - RANGE_START=self.range_start, - RANGE_END=self.range_end, - ) - - def resolve_pool(self, pool): - pool_parts = pool.split('\n') - # pool_parts = pool.split("\n") - for part in pool_parts: - if FAILOVER_KEY in part: - self.failover_peer = part.strip().split(FAILOVER_KEY)[1].strip().split( - ';')[0].replace('\"', '') - if RANGE_KEY in part: - pool_range = part.strip().split(RANGE_KEY)[1].strip().split(';')[0] - self.range_start = pool_range.split(' ')[0].strip() - self.range_end = pool_range.split(' ')[1].strip() diff --git a/modules/network/dhcp-1/python/src/grpc/network_service.py b/modules/network/dhcp-1/python/src/grpc/network_service.py deleted file mode 100644 index 64aab8a07..000000000 --- a/modules/network/dhcp-1/python/src/grpc/network_service.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""gRPC Network Service for the DHCP Server network module""" -import proto.grpc_pb2_grpc as pb2_grpc -import proto.grpc_pb2 as pb2 - -from dhcp_config import DHCPConfig - - -class NetworkService(pb2_grpc.NetworkModule): - """gRPC endpoints for the DHCP Server""" - - def __init__(self): - self._dhcp_config = DHCPConfig() - - def GetDHCPRange(self, request, context): # pylint: disable=W0613 - """ - Resolve the current DHCP configuration and return - the first range from the first subnet in the file - """ - self._dhcp_config.resolve_config() - pool = self._dhcp_config.subnets[0].pools[0] - return pb2.DHCPRange(code=200, start=pool.range_start, end=pool.range_end) - - def SetDHCPRange(self, request, context): # pylint: disable=W0613 - """ - Change DHCP configuration and set the - the first range from the first subnet in the configuration - """ - - print('Setting DHCPRange') - print('Start: ' + request.start) - print('End: ' + request.end) - self._dhcp_config.resolve_config() - self._dhcp_config.set_range(request.start, request.end, 0, 0) - self._dhcp_config.write_config() - return pb2.Response(code=200, message='DHCP Range Set') - - def GetStatus(self, request, context): # pylint: disable=W0613 - """ - Return the current status of the network module - """ - # ToDo: Figure out how to resolve the current DHCP status - dhcp_status = True - message = str({'dhcpStatus': dhcp_status}) - return pb2.Response(code=200, message=message) diff --git a/modules/network/dhcp-1/python/src/grpc/proto/grpc.proto b/modules/network/dhcp-1/python/src/grpc/proto/grpc.proto deleted file mode 100644 index 8e2732620..000000000 --- a/modules/network/dhcp-1/python/src/grpc/proto/grpc.proto +++ /dev/null @@ -1,36 +0,0 @@ -syntax = "proto3"; - -service NetworkModule { - - rpc GetDHCPRange(GetDHCPRangeRequest) returns (DHCPRange) {}; - - rpc SetDHCPRange(DHCPRange) returns (Response) {}; - - rpc GetStatus(GetStatusRequest) returns (Response) {}; - - rpc GetIPAddress(GetIPAddressRequest) returns (Response) {}; - - rpc SetLeaseAddress(SetLeaseAddressRequest) returns (Response) {}; - -} - -message Response { - int32 code = 1; - string message = 2; -} - -message DHCPRange { - int32 code = 1; - string start = 2; - string end = 3; -} - -message GetDHCPRangeRequest {} - -message GetIPAddressRequest {} - -message GetStatusRequest {} - -message SetLeaseAddressRequest { - string ipAddress = 1; -} \ No newline at end of file diff --git a/modules/network/dhcp-1/python/src/grpc/__init__.py b/modules/network/dhcp-1/python/src/grpc_server/__init__.py similarity index 100% rename from modules/network/dhcp-1/python/src/grpc/__init__.py rename to modules/network/dhcp-1/python/src/grpc_server/__init__.py 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 new file mode 100644 index 000000000..6f003014c --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_config.py @@ -0,0 +1,504 @@ +# 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 +DHCP server's configuration""" +import re +from common import logger + +LOG_NAME = 'dhcp_config' +LOGGER = None +CONFIG_FILE = '/etc/dhcp/dhcpd.conf' +DEFAULT_LEASE_TIME_KEY = 'default-lease-time' + + +class DHCPConfig: + """Represents the DHCP Servers configuration and gives access to modify it""" + + def __init__(self): + self._default_lease_time = 300 + self._subnets = [] + self._peer = None + self._reserved_hosts = [] + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') + + def add_reserved_host(self, hostname, hw_addr, ip_addr): + host = DHCPReservedHost(hostname=hostname, + hw_addr=hw_addr, + fixed_addr=ip_addr) + self._reserved_hosts.append(host) + + def delete_reserved_host(self, hw_addr): + for host in self._reserved_hosts: + if hw_addr == host.hw_addr: + self._reserved_hosts.remove(host) + + def disable_failover(self): + self._peer.disable() + for subnet in self._subnets: + subnet.disable_peer() + + def enable_failover(self): + self._peer.enable() + for subnet in self._subnets: + subnet.enable_peer() + + def get_reserved_host(self, hw_addr): + for host in self._reserved_hosts: + if hw_addr == host.hw_addr: + return host + + def write_config(self, config=None): + if config is None: + conf = str(self) + with open(CONFIG_FILE, 'w', encoding='UTF-8') as conf_file: + conf_file.write(conf) + else: + with open(CONFIG_FILE, 'w', encoding='UTF-8') as conf_file: + conf_file.write(config) + + def _get_config(self, config_file=CONFIG_FILE): + content = None + with open(config_file, 'r', encoding='UTF-8') as f: + content = f.read() + return content + + def make(self, conf): + try: + self._subnets = self.resolve_subnets(conf) + self._peer = DHCPFailoverPeer(conf) + self._reserved_hosts = self.resolve_reserved_hosts(conf) + except Exception as e: # pylint: disable=W0718 + print('Failed to make DHCPConfig: ' + str(e)) + + def resolve_config(self, config_file=CONFIG_FILE): + try: + conf = self._get_config(config_file) + self._subnets = self.resolve_subnets(conf) + self._peer = DHCPFailoverPeer(conf) + self._reserved_hosts = self.resolve_reserved_hosts(conf) + except Exception as e: # pylint: disable=W0718 + print('Failed to resolve config: ' + str(e)) + + def resolve_subnets(self, conf): + subnets = [] + regex = r'(subnet.*)' + subnets_conf = re.findall(regex, conf, re.MULTILINE | re.DOTALL) + for subnet in subnets_conf: + dhcp_subnet = DHCPSubnet(subnet) + subnets.append(dhcp_subnet) + return subnets + + def resolve_reserved_hosts(self, conf): + hosts = [] + host_start = 0 + while True: + host_start = conf.find('host', host_start) + if host_start < 0: + break + else: + host_end = conf.find('}', host_start) + host = DHCPReservedHost(config=conf[host_start:host_end + 1]) + hosts.append(host) + host_start = host_end + 1 + return hosts + + def set_range(self, start, end, subnet=0, pool=0): + # Calculate the subnet from the range + octets = start.split('.') + octets[-1] = '0' + dhcp_subnet = '.'.join(octets) + + #Update the subnet and range + self._subnets[subnet].set_subnet(dhcp_subnet) + self._subnets[subnet].pools[pool].set_range(start, end) + + def __str__(self): + + # Encode the top level config options + 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) + + # Encode the failover peer + config += '\n\n' + str(self._peer) + + # Encode the subnets + for subnet in self._subnets: + config += '\n\n' + str(subnet) + + # Encode the reserved hosts + for host in self._reserved_hosts: + config += '\n' + str(host) + + return str(config) + + +FAILOVER_PEER_KEY = 'failover peer' +PRIMARY_KEY = 'primary' +ADDRESS_KEY = 'address' +PORT_KEY = 'port' +PEER_ADDRESS_KEY = 'peer address' +PEER_PORT_KEY = 'peer port' +MAX_RESPONSE_DELAY_KEY = 'max-response-delay' +MAX_UNACKED_UPDATES_KEY = 'max-unacked-updates' +MCLT_KEY = 'mclt' +SPLIT_KEY = 'split' +LOAD_BALANCE_MAX_SECONDS_KEY = 'load balance max seconds' + + +class DHCPFailoverPeer: + """Contains all information to define the DHCP failover peer""" + + def __init__(self, config): + self.name = None + self.primary = False + self.address = None + self.port = None + self.peer_address = None + self.peer_port = None + self.max_response_delay = None + self.max_unacked_updates = None + self.mclt = None + self.split = None + self.load_balance_max_seconds = None + self.peer = None + self.enabled = True + + self.resolve_peer(config) + + def __str__(self): + config = '{FAILOVER_PEER_KEY} \"{FAILOVER_PEER}\" {{\n' + config += '\tprimary;' if self.primary else 'secondary;' + config += '\n\t{ADDRESS_KEY} {ADDRESS};' if self.address is not None else '' + config += '\n\t{PORT_KEY} {PORT};' if self.port is not None else '' + config += ('\n\t{PEER_ADDRESS_KEY} {PEER_ADDRESS};' + if self.peer_address is not None else '') + config += ('\n\t{PEER_PORT_KEY} {PEER_PORT};' + if self.peer_port is not None else '') + config += ('\n\t{MAX_RESPONSE_DELAY_KEY} {MAX_RESPONSE_DELAY};' + if self.max_response_delay is not None else '') + config += ('\n\t{MAX_UNACKED_UPDATES_KEY} {MAX_UNACKED_UPDATES};' + if self.max_unacked_updates is not None else '') + config += '\n\t{MCLT_KEY} {MCLT};' if self.mclt is not None else '' + config += '\n\t{SPLIT_KEY} {SPLIT};' if self.split is not None else '' + config += ('\n\t{LOAD_BALANCE_MAX_SECONDS_KEY} {LOAD_BALANCE_MAX_SECONDS};' + if self.load_balance_max_seconds is not None else '') + config += '\n\r}}' + + config = config.format( + length='multi-line', + FAILOVER_PEER_KEY=FAILOVER_PEER_KEY, + FAILOVER_PEER=self.name, + ADDRESS_KEY=ADDRESS_KEY, + ADDRESS=self.address, + PORT_KEY=PORT_KEY, + PORT=self.port, + PEER_ADDRESS_KEY=PEER_ADDRESS_KEY, + PEER_ADDRESS=self.peer_address, + PEER_PORT_KEY=PEER_PORT_KEY, + PEER_PORT=self.peer_port, + MAX_RESPONSE_DELAY_KEY=MAX_RESPONSE_DELAY_KEY, + MAX_RESPONSE_DELAY=self.max_response_delay, + MAX_UNACKED_UPDATES_KEY=MAX_UNACKED_UPDATES_KEY, + MAX_UNACKED_UPDATES=self.max_unacked_updates, + MCLT_KEY=MCLT_KEY, + MCLT=self.mclt, + SPLIT_KEY=SPLIT_KEY, + SPLIT=self.split, + LOAD_BALANCE_MAX_SECONDS_KEY=LOAD_BALANCE_MAX_SECONDS_KEY, + LOAD_BALANCE_MAX_SECONDS=self.load_balance_max_seconds) + + if not self.enabled: + lines = config.strip().split('\n') + for i in range(len(lines) - 1): + lines[i] = '#' + lines[i] + lines[-1] = '#' + lines[-1].strip() # Handle the last line separately + config = '\n'.join(lines) + + return config + + def disable(self): + self.enabled = False + + def enable(self): + self.enabled = True + + def resolve_peer(self, conf): + peer = '' + lines = conf.split('\n') + for line in lines: + if line.startswith(FAILOVER_PEER_KEY) or len(peer) > 0: + if len(peer) <= 0: + self.name = line.strip().split(FAILOVER_PEER_KEY)[1].strip().split( + '{')[0].split('\"')[1] + peer += line + '\n' + if PRIMARY_KEY in line: + self.primary = True + elif ADDRESS_KEY in line and PEER_ADDRESS_KEY not in line: + self.address = line.strip().split(ADDRESS_KEY)[1].strip().split( + ';')[0] + elif PORT_KEY in line and PEER_PORT_KEY not in line: + self.port = line.strip().split(PORT_KEY)[1].strip().split(';')[0] + elif PEER_ADDRESS_KEY in line: + self.peer_address = line.strip().split( + PEER_ADDRESS_KEY)[1].strip().split(';')[0] + elif PEER_PORT_KEY in line: + self.peer_port = line.strip().split(PEER_PORT_KEY)[1].strip().split( + ';')[0] + elif MAX_RESPONSE_DELAY_KEY in line: + self.max_response_delay = line.strip().split( + MAX_RESPONSE_DELAY_KEY)[1].strip().split(';')[0] + elif MAX_UNACKED_UPDATES_KEY in line: + self.max_unacked_updates = line.strip().split( + MAX_UNACKED_UPDATES_KEY)[1].strip().split(';')[0] + elif MCLT_KEY in line: + self.mclt = line.strip().split(MCLT_KEY)[1].strip().split(';')[0] + elif SPLIT_KEY in line: + self.split = line.strip().split(SPLIT_KEY)[1].strip().split(';')[0] + elif LOAD_BALANCE_MAX_SECONDS_KEY in line: + self.load_balance_max_seconds = line.strip().split( + LOAD_BALANCE_MAX_SECONDS_KEY)[1].strip().split(';')[0] + if line.endswith('}') and len(peer) > 0: + break + self.peer = peer + + +SUBNET_KEY = 'subnet' +NTP_OPTION_KEY = 'option ntp-servers' +SUBNET_MASK_OPTION_KEY = 'option subnet-mask' +BROADCAST_OPTION_KEY = 'option broadcast-address' +ROUTER_OPTION_KEY = 'option routers' +DNS_OPTION_KEY = 'option domain-name-servers' +INTERFACE_KEY = 'interface' +AUTHORITATIVE_KEY = 'authoritative' + + +class DHCPSubnet: + """Represents the DHCP Servers subnet configuration""" + + def __init__(self, subnet): + self._authoritative = False + self._subnet = None + self._ntp_servers = None + self._subnet_mask = None + self._broadcast = None + self._routers = None + self._dns_servers = None + self._interface = None + self.pools = [] + + self.resolve_subnet(subnet) + self.resolve_pools(subnet) + + def __str__(self): + config = 'subnet {SUBNET_OPTION} netmask {SUBNET_MASK_OPTION} {{' + config += ('\n\t{NTP_OPTION_KEY} {NTP_OPTION};' + if self._ntp_servers is not None else '') + config += ('\n\t{SUBNET_MASK_OPTION_KEY} {SUBNET_MASK_OPTION};' + if self._subnet_mask is not None else '') + config += ('\n\t{BROADCAST_OPTION_KEY} {BROADCAST_OPTION};' + if self._broadcast is not None else '') + config += ('\n\t{ROUTER_OPTION_KEY} {ROUTER_OPTION};' + if self._routers is not None else '') + config += ('\n\t{DNS_OPTION_KEY} {DNS_OPTION};' + if self._dns_servers is not None else '') + config += ('\n\t{INTERFACE_KEY} {INTERFACE_OPTION};' + if self._interface is not None else '') + config += '\n\t{AUTHORITATIVE_KEY};' if self._authoritative else '' + + config = config.format(length='multi-line', + SUBNET_OPTION=self._subnet, + NTP_OPTION_KEY=NTP_OPTION_KEY, + NTP_OPTION=self._ntp_servers, + SUBNET_MASK_OPTION_KEY=SUBNET_MASK_OPTION_KEY, + SUBNET_MASK_OPTION=self._subnet_mask, + BROADCAST_OPTION_KEY=BROADCAST_OPTION_KEY, + BROADCAST_OPTION=self._broadcast, + ROUTER_OPTION_KEY=ROUTER_OPTION_KEY, + ROUTER_OPTION=self._routers, + DNS_OPTION_KEY=DNS_OPTION_KEY, + DNS_OPTION=self._dns_servers, + INTERFACE_KEY=INTERFACE_KEY, + INTERFACE_OPTION=self._interface, + AUTHORITATIVE_KEY=AUTHORITATIVE_KEY) + + # if not self._authoritative: + # config = config.replace(AUTHORITATIVE_KEY, '#' + AUTHORITATIVE_KEY) + + for pool in self.pools: + config += '\n\t' + str(pool) + + config += '\n}' + return config + + def disable_peer(self): + for pool in self.pools: + pool.disable_peer() + + def enable_peer(self): + for pool in self.pools: + pool.enable_peer() + + def set_subnet(self, subnet, netmask=None): + if netmask is None: + netmask = '255.255.255.0' + self._subnet = subnet + self._subnet_mask = netmask + + # Calculate the broadcast from the subnet + octets = subnet.split('.') + octets[-1] = '255' + dhcp_broadcast = '.'.join(octets) + + self._broadcast = dhcp_broadcast + + def resolve_subnet(self, subnet): + subnet_parts = subnet.split('\n') + for part in subnet_parts: + if part.strip().startswith(SUBNET_KEY): + self._subnet = part.strip().split()[1] + elif NTP_OPTION_KEY in part: + self._ntp_servers = part.strip().split(NTP_OPTION_KEY)[1].strip().split( + ';')[0] + elif SUBNET_MASK_OPTION_KEY in part: + self._subnet_mask = part.strip().split( + SUBNET_MASK_OPTION_KEY)[1].strip().split(';')[0] + elif BROADCAST_OPTION_KEY in part: + self._broadcast = part.strip().split( + BROADCAST_OPTION_KEY)[1].strip().split(';')[0] + elif ROUTER_OPTION_KEY in part: + self._routers = part.strip().split(ROUTER_OPTION_KEY)[1].strip().split( + ';')[0] + elif DNS_OPTION_KEY in part: + self._dns_servers = part.strip().split(DNS_OPTION_KEY)[1].strip().split( + ';')[0] + elif INTERFACE_KEY in part: + self._interface = part.strip().split(INTERFACE_KEY)[1].strip().split( + ';')[0] + elif AUTHORITATIVE_KEY in part: + self._authoritative = True + + def resolve_pools(self, subnet): + regex = r'(pool.*)\}' + pools = re.findall(regex, subnet, re.MULTILINE | re.DOTALL) + for pool in pools: + dhcp_pool = DHCPPool(pool) + self.pools.append(dhcp_pool) + + +FAILOVER_KEY = 'failover peer' +RANGE_KEY = 'range' + + +class DHCPPool: + """Represents a DHCP Servers subnet pool configuration""" + + def __init__(self, pool): + self.failover_peer = None + self.range_start = None + self.range_end = None + self.resolve_pool(pool) + self._peer_enabled = True + + def __str__(self): + config = 'pool {{' + config += ('\n\t\t{FAILOVER_KEY} "{FAILOVER}";' + if self.failover_peer is not None else '') + config += ('\n\t\t{RANGE_KEY} {RANGE_START} {RANGE_END};' + if self.range_start is not None and self.range_end is not None + else '') + config += '\n\t}}' + + config = config.format( + length='multi-line', + FAILOVER_KEY=FAILOVER_KEY, + FAILOVER=self.failover_peer, + RANGE_KEY=RANGE_KEY, + RANGE_START=self.range_start, + RANGE_END=self.range_end, + ) + + if not self._peer_enabled: + config = config.replace(FAILOVER_KEY, '#' + FAILOVER_KEY) + + return config + + def disable_peer(self): + self._peer_enabled = False + + def enable_peer(self): + self._peer_enabled = True + + def set_range(self, start, end): + self.range_start = start + self.range_end = end + + def resolve_pool(self, pool): + pool_parts = pool.split('\n') + for part in pool_parts: + if FAILOVER_KEY in part: + self.failover_peer = part.strip().split(FAILOVER_KEY)[1].strip().split( + ';')[0].replace('\"', '') + if RANGE_KEY in part: + pool_range = part.strip().split(RANGE_KEY)[1].strip().split(';')[0] + self.range_start = pool_range.split(' ')[0].strip() + self.range_end = pool_range.split(' ')[1].strip() + + +HOST_KEY = 'host' +HARDWARE_KEY = 'hardware ethernet' +FIXED_ADDRESS_KEY = 'fixed-address' + + +class DHCPReservedHost: + """Represents a DHCP Servers subnet pool configuration""" + + def __init__(self, hostname=None, hw_addr=None, fixed_addr=None, config=None): + if config is None: + self.host = hostname + self.hw_addr = hw_addr + self.fixed_addr = fixed_addr + else: + self.resolve_host(config) + + def __str__(self): + + config = """{HOST_KEY} {HOSTNAME} {{ + \r\t{HARDWARE_KEY} {HW_ADDR}; + \r\t{FIXED_ADDRESS_KEY} {RESERVED_IP}; + \r}}""" + + config = config.format( + length='multi-line', + HOST_KEY=HOST_KEY, + HOSTNAME=self.host, + HARDWARE_KEY=HARDWARE_KEY, + HW_ADDR=self.hw_addr, + FIXED_ADDRESS_KEY=FIXED_ADDRESS_KEY, + RESERVED_IP=self.fixed_addr, + ) + return config + + def resolve_host(self, reserved_host): + host_parts = reserved_host.split('\n') + for part in host_parts: + if HOST_KEY in part: + self.host = part.strip().split(HOST_KEY)[1].strip().split('{')[0] + elif HARDWARE_KEY in part: + self.hw_addr = part.strip().split(HARDWARE_KEY)[1].strip().split(';')[0] + elif FIXED_ADDRESS_KEY in part: + self.fixed_addr = part.strip().split( + FIXED_ADDRESS_KEY)[1].strip().split(';')[0] 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 new file mode 100644 index 000000000..a34ff4e31 --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py @@ -0,0 +1,105 @@ +# 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. +"""Unit Testing for the DHCP Server config""" +import unittest +from dhcp_config import DHCPConfig +import os + +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( + os.path.dirname(os.path.dirname(os.path.abspath(current_dir)))) + conf_file = os.path.join(module_dir, CONFIG_FILE) + return conf_file + + +def get_config(): + dhcp_config = DHCPConfig() + dhcp_config.resolve_config(get_config_file_path()) + return dhcp_config + + +class DHCPConfigTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # Resolve the config + global DHCP_CONFIG + DHCP_CONFIG = get_config() + + def test_resolve_config(self): + print('Test Resolve Config:\n' + str(DHCP_CONFIG)) + + # Resolve the raw config file + with open(get_config_file_path(), 'r', encoding='UTF-8') as f: + lines = f.readlines() + + # Get the resolved config as a + conf_parts = str(DHCP_CONFIG).split('\n') + + # dhcpd conf is not picky about spacing so we just + # need to check contents of each line for matching + # to make sure evertying matches + for i in range(len(lines)): + self.assertEqual(lines[i].strip(), conf_parts[i].strip()) + + def test_disable_failover(self): + DHCP_CONFIG.disable_failover() + print('Test Disable Config:\n' + str(DHCP_CONFIG)) + config_lines = str(DHCP_CONFIG._peer).split('\n') + for line in config_lines: + self.assertTrue(line.startswith('#')) + + def test_enable_failover(self): + DHCP_CONFIG.enable_failover() + print('Test Enable Config:\n' + str(DHCP_CONFIG)) + config_lines = str(DHCP_CONFIG._peer).split('\n') + for line in config_lines: + self.assertFalse(line.startswith('#')) + + def test_add_reserved_host(self): + DHCP_CONFIG.add_reserved_host('test', '00:11:22:33:44:55', '192.168.10.5') + host = DHCP_CONFIG.get_reserved_host('00:11:22:33:44:55') + self.assertIsNotNone(host) + print('AddHostConfig:\n' + str(DHCP_CONFIG)) + + def test_delete_reserved_host(self): + DHCP_CONFIG.delete_reserved_host('00:11:22:33:44:55') + host = DHCP_CONFIG.get_reserved_host('00:11:22:33:44:55') + self.assertIsNone(host) + print('DeleteHostConfig:\n' + str(DHCP_CONFIG)) + + def test_resolve_config_with_hosts(self): + DHCP_CONFIG.add_reserved_host('test', '00:11:22:33:44:55', '192.168.10.5') + config_with_hosts = DHCPConfig() + config_with_hosts.make(str(DHCP_CONFIG)) + host = config_with_hosts.get_reserved_host('00:11:22:33:44:55') + self.assertIsNotNone(host) + print('ResolveConfigWithHosts:\n' + str(config_with_hosts)) + + +if __name__ == '__main__': + suite = unittest.TestSuite() + suite.addTest(DHCPConfigTest('test_resolve_config')) + suite.addTest(DHCPConfigTest('test_disable_failover')) + suite.addTest(DHCPConfigTest('test_enable_failover')) + suite.addTest(DHCPConfigTest('test_add_reserved_host')) + suite.addTest(DHCPConfigTest('test_delete_reserved_host')) + suite.addTest(DHCPConfigTest('test_resolve_config_with_hosts')) + + 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 new file mode 100644 index 000000000..0d2f43e3b --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_lease.py @@ -0,0 +1,75 @@ +# 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 methods to create and monitor DHCP +leases on the server""" +from datetime import datetime +import time + +time_format = '%Y-%m-%d %H:%M:%S' + + +class DHCPLease(object): + """Represents a DHCP Server lease""" + hw_addr = None + ip = None + hostname = None + expires = None + + def __init__(self, lease): + self._make_lease(lease) + + def _make_lease(self, lease): + if lease is not None: + sections_raw = lease.split(' ') + sections = [] + for section in sections_raw: + if section.strip(): + sections.append(section) + self.hw_addr = sections[0] + self.ip = sections[1] + self.hostname = sections[2] + self.expires = sections[3] + '' '' + sections[4] + self.manufacturer = ' '.join(sections[5:]) + + def get_millis(self, timestamp): + dt_obj = datetime.strptime(timestamp, time_format) + millis = dt_obj.timestamp() * 1000 + return millis + + def get_expires_millis(self): + return self.get_millis(self.expires) + + def is_expired(self): + expires_millis = self.get_expires_millis() + cur_time = int(round(time.time()) * 1000) + return cur_time >= expires_millis + + def __str__(self): + lease = {} + if self.hw_addr is not None: + lease['hw_addr'] = self.hw_addr + + if self.ip is not None: + lease['ip'] = self.ip + + if self.hostname is not None: + lease['hostname'] = self.hostname + + if self.expires is not None: + lease['expires'] = self.expires + + if self.manufacturer is not None: + lease['manufacturer'] = self.manufacturer + + return str(lease) 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 new file mode 100644 index 000000000..698277a02 --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_leases.py @@ -0,0 +1,107 @@ +# 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. +"""Used to resolve the DHCP servers lease information""" +import os +from dhcp_lease import DHCPLease +import logger +from common import util + +LOG_NAME = 'dhcp_lease' +LOGGER = None + +DHCP_LEASE_FILES = [ + '/var/lib/dhcp/dhcpd.leases', '/var/lib/dhcp/dhcpd.leases~', + '/var/lib/dhcp/dhcpd6.leases', '/var/lib/dhcp/dhcpd6.leases~' +] +DHCP_CONFIG_FILE = '/etc/dhcp/dhcpd.conf' + + +class DHCPLeases: + """Leases for the DHCP server""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') + + def delete_all_hosts(self): + LOGGER.info('Deleting hosts') + for lease in DHCP_LEASE_FILES: + LOGGER.info('Checking file: ' + lease) + if os.path.exists(lease): + LOGGER.info('File Exists: ' + lease) + try: + # Delete existing lease file + os.remove(lease) + except OSError as e: + LOGGER.info(f'Error occurred while deleting the file: {e}') + # Create an empty lease file + with open(lease, 'w', encoding='UTF-8'): + pass + + def get_lease(self, hw_addr): + for lease in self.get_leases(): + if lease.hw_addr == hw_addr: + return lease + + 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:] + lines = lease_list.split('\n') + for line in lines: + try: + lease = DHCPLease(line) + leases.append(lease) + except Exception as e: # pylint: disable=W0718 + # Let non lease lines file without extra checks + LOGGER.error('Making Lease Error: ' + str(e)) + LOGGER.error('Not a valid lease line: ' + line) + return leases + + def delete_lease(self, ip_addr): + LOGGER.info('Deleting lease') + for lease in DHCP_LEASE_FILES: + LOGGER.info('Checking file: ' + lease) + if os.path.exists(lease): + LOGGER.info('File Exists: ' + lease) + try: + # Delete existing lease file + with (open(lease, 'r', encoding='UTF-8')) as f: + contents = f.read() + + while ip_addr in contents: + ix_ip = contents.find(ip_addr) + lease_start = contents.rindex('lease', 0, ix_ip) + lease_end = contents.find('}', lease_start) + LOGGER.info('Lease Location: ' + str(lease_start) + ':' + + str(lease_end)) + contents = contents[0:lease_start] + contents[lease_end + 1:] + + except OSError as e: + LOGGER.info(f'Error occurred while deleting the lease: {e}') + + def _get_lease_list(self): + LOGGER.info('Running lease list command') + try: + result = util.run_command('dhcp-lease-list') + return result[0] + except Exception as e: # pylint: disable=W0718 + LOGGER.error('Error lease list: ' + str(e)) + + def _write_config(self, config): + with open(DHCP_CONFIG_FILE, 'w', encoding='UTF-8') as f: + f.write(config) 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 new file mode 100644 index 000000000..5e88d59fe --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/dhcp_server.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. +"""Contains all the necessary classes to maintain the +DHCP server""" +import sys +import time +from common import logger +from common import util +from dhcp_config import DHCPConfig +from radvd_server import RADVDServer + +CONFIG_FILE = '/etc/dhcp/dhcpd.conf' +LOG_NAME = 'dhcp_server' +LOGGER = None + + +class DHCPServer: + """Represents the DHCP Server""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') + self.dhcp_config = DHCPConfig() + self.radvd = RADVDServer() + self.dhcp_config.resolve_config() + + def restart(self): + LOGGER.info('Restarting DHCP Server') + isc_started = util.run_command('service isc-dhcp-server restart', False) + radvd_started = self.radvd.restart() + started = isc_started and radvd_started + LOGGER.info('DHCP Restarted: ' + str(started)) + return started + + def start(self): + LOGGER.info('Starting DHCP Server') + isc_started = util.run_command('service isc-dhcp-server start', False) + radvd_started = self.radvd.start() + started = isc_started and radvd_started + LOGGER.info('DHCP Started: ' + str(started)) + return started + + def stop(self): + LOGGER.info('Stopping DHCP Server') + isc_stopped = util.run_command('service isc-dhcp-server stop', False) + radvd_stopped = self.radvd.stop() + stopped = isc_stopped and radvd_stopped + LOGGER.info('DHCP 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.' + radvd_running = self.radvd.is_running() + running = isc_running and radvd_running + LOGGER.info('DHCP Status: ' + str(running)) + return running + + def boot(self): + LOGGER.info('Booting DHCP Server') + isc_booted = False + radvd_booted = False + if self.is_running(): + LOGGER.info('Stopping isc-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') + 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: + break + LOGGER.info('RADVD started: ' + str(radvd_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') + sys.exit(1) + + config = str(dhcp_server.dhcp_config) + while True: + dhcp_server.dhcp_config.resolve_config() + new_config = str(dhcp_server.dhcp_config) + if config != new_config: + LOGGER.info('DHCP Config Changed') + config = new_config + dhcp_server.restart() + dhcp_server.radvd.restart() + time.sleep(1) + +if __name__ == '__main__': + run() 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 new file mode 100644 index 000000000..043ca49b3 --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/network_service.py @@ -0,0 +1,194 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""gRPC Network Service for the DHCP Server network module""" +import proto.grpc_pb2_grpc as pb2_grpc +import proto.grpc_pb2 as pb2 + +from dhcp_server import DHCPServer +from dhcp_config import DHCPConfig +from dhcp_leases import DHCPLeases + +import traceback +from common import logger + +LOG_NAME = 'network_service' +LOGGER = None + +class NetworkService(pb2_grpc.NetworkModule): + """gRPC endpoints for the DHCP Server""" + + def __init__(self): + self._dhcp_server = DHCPServer() + self._dhcp_config = None + self.dhcp_leases = DHCPLeases() + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') + + def _get_dhcp_config(self): + if self._dhcp_config is None: + self._dhcp_config = DHCPConfig() + self._dhcp_config.resolve_config() + return self._dhcp_config + + def RestartDHCPServer(self, request, context): # pylint: disable=W0613 + LOGGER.info('Restarting DHCP server') + try: + started = self._dhcp_server.restart() + LOGGER.info('DHCP server restarted: ' + (str(started))) + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to restart DHCP server: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def StartDHCPServer(self, request, context): # pylint: disable=W0613 + LOGGER.info('Starting DHCP server') + try: + started = self._dhcp_server.start() + LOGGER.info('DHCP server started: ' + (str(started))) + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to start DHCP server: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def StopDHCPServer(self, request, context): # pylint: disable=W0613 + LOGGER.info('Stopping DHCP server') + try: + stopped = self._dhcp_server.stop() + LOGGER.info('DHCP server stopped: ' + (str(stopped))) + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to stop DHCP server: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def AddReservedLease(self, request, context): # pylint: disable=W0613 + LOGGER.info('Add reserved lease called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.add_reserved_host(request.hostname, request.hw_addr, + request.ip_addr) + dhcp_config.write_config() + LOGGER.info('Reserved lease added') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to add reserved lease: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def DeleteReservedLease(self, request, context): # pylint: disable=W0613 + LOGGER.info('Delete reserved lease called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.delete_reserved_host(request.hw_addr) + dhcp_config.write_config() + LOGGER.info('Reserved lease deleted') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to delete reserved lease: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def DisableFailover(self, request, contest): # pylint: disable=W0613 + LOGGER.info('Disable failover called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.disable_failover() + dhcp_config.write_config() + LOGGER.info('Failover disabled') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to disable failover: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def EnableFailover(self, request, contest): # pylint: disable=W0613 + LOGGER.info('Enable failover called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.enable_failover() + dhcp_config.write_config() + LOGGER.info('Failover enabled') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to enable failover: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def GetDHCPRange(self, request, context): # pylint: disable=W0613 + """ + Resolve the current DHCP configuration and return + the first range from the first subnet in the file + """ + LOGGER.info('Get DHCP range called') + try: + pool = self._get_dhcp_config()._subnets[0].pools[0] + return pb2.DHCPRange(code=200, start=pool.range_start, end=pool.range_end) + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to get DHCP range: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def GetLease(self, request, context): # pylint: disable=W0613 + """ + Resolve the current DHCP leased address for the + provided MAC address + """ + LOGGER.info('Get lease called') + try: + lease = self.dhcp_leases.get_lease(request.hw_addr) + if lease is not None: + return pb2.Response(code=200, message=str(lease)) + else: + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to get lease: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def SetDHCPRange(self, request, context): # pylint: disable=W0613 + """ + Change DHCP configuration and set the + the first range from the first subnet in the configuration + """ + LOGGER.info('Set DHCP range called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.set_range(request.start, request.end, 0, 0) + dhcp_config.write_config() + LOGGER.info('DHCP range set') + return pb2.Response(code=200, message='DHCP Range Set') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to set DHCP range: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def GetStatus(self, request, context): # pylint: disable=W0613 + """ + Return the current status of the network module + """ + dhcp_status = self._dhcp_server.is_running() + message = str({'dhcpStatus': dhcp_status}) + return pb2.Response(code=200, message=message) diff --git a/modules/network/dhcp-1/python/src/grpc_server/proto/grpc.proto b/modules/network/dhcp-1/python/src/grpc_server/proto/grpc.proto new file mode 100644 index 000000000..e6abda674 --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/proto/grpc.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +service NetworkModule { + + rpc RestartDHCPServer(RestartDHCPServerRequest) returns (Response) {}; + + rpc StartDHCPServer(StartDHCPServerRequest) returns (Response) {}; + + rpc StopDHCPServer(StopDHCPServerRequest) returns (Response) {}; + + rpc AddReservedLease(AddReservedLeaseRequest) returns (Response) {}; + + rpc DeleteReservedLease(DeleteReservedLeaseRequest) returns (Response) {}; + + rpc DisableFailover(DisableFailoverRequest) returns (Response) {}; + + rpc EnableFailover(EnableFailoverRequest) returns (Response) {}; + + rpc GetDHCPRange(GetDHCPRangeRequest) returns (DHCPRange) {}; + + rpc GetLease(GetLeaseRequest) returns (Response) {}; + + rpc GetStatus(GetStatusRequest) returns (Response) {}; + + rpc SetDHCPRange(SetDHCPRangeRequest) returns (Response) {}; +} + +message AddReservedLeaseRequest { + string hostname = 1; + string hw_addr = 2; + string ip_addr = 3; +} + +message DeleteReservedLeaseRequest { + string hw_addr = 1; +} + +message RestartDHCPServerRequest {} + +message StartDHCPServerRequest {} + +message StopDHCPServerRequest {} + +message DisableFailoverRequest {} + +message EnableFailoverRequest {} + +message GetDHCPRangeRequest {} + +message GetLeaseRequest { + string hw_addr = 1; +} + +message GetStatusRequest {} + +message SetDHCPRangeRequest { + int32 code = 1; + string start = 2; + string end = 3; +} + +message Response { + int32 code = 1; + string message = 2; +} + +message DHCPRange { + int32 code = 1; + string start = 2; + string end = 3; +} \ No newline at end of file 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 new file mode 100644 index 000000000..8bb1d0539 --- /dev/null +++ b/modules/network/dhcp-1/python/src/grpc_server/radvd_server.py @@ -0,0 +1,53 @@ +# 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 +DHCP server""" +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""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-1') + + def restart(self): + LOGGER.info('Restarting RADVD Server') + response = util.run_command('radvd-service restart', False) + LOGGER.info('RADVD Restarted: ' + str(response)) + return response + + def start(self): + LOGGER.info('Starting RADVD Server') + response = util.run_command('radvd-service start', False) + LOGGER.info('RADVD Started: ' + str(response)) + return response + + def stop(self): + LOGGER.info('Stopping RADVD Server') + response = util.run_command('radvd-service stop', False) + LOGGER.info('RADVD Stopped: ' + str(response)) + return response + + def is_running(self): + 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.' diff --git a/modules/network/dhcp-2/bin/radvd-service b/modules/network/dhcp-2/bin/radvd-service new file mode 100644 index 000000000..912c64ee3 --- /dev/null +++ b/modules/network/dhcp-2/bin/radvd-service @@ -0,0 +1,55 @@ +#!/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. + +RA_PID_FILE=/var/run/radvd/radvd.pid +RA_LOG_FILE=/runtime/network/dhcp2-radvd.log + +stop_radvd(){ + # Directly kill by PID file reference + if [ -f "$RA_PID_FILE" ]; then + kill -9 $(cat $RA_PID_FILE) || true + rm -f $RA_PID_FILE + fi +} + +start_radvd(){ + /usr/sbin/radvd -m logfile -l $RA_LOG_FILE -p $RA_PID_FILE +} + +case "$1" in + start) + start_radvd + ;; + stop) + stop_radvd + ;; + restart) + stop_radvd + sleep 1 + start_radvd + ;; + status) + if [ -f "$RA_PID_FILE" ]; then + echo "radvd service is running." + else + echo "radvd 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 550854d49..ed7d3125e 100644 --- a/modules/network/dhcp-2/bin/start_network_service +++ b/modules/network/dhcp-2/bin/start_network_service @@ -20,7 +20,7 @@ DHCP_LOG_FILE=/runtime/network/dhcp2-dhcpd.log RA_PID_FILE=/var/run/radvd/radvd.pid RA_LOG_FILE=/runtime/network/dhcp2-radvd.log -echo "Starrting Network Service..." +echo "Starting Network Service..." #Enable IPv6 Forwarding sysctl net.ipv6.conf.all.forwarding=1 @@ -29,63 +29,23 @@ sysctl -p # Create leases file if needed touch /var/lib/dhcp/dhcpd.leases -#Create directory for radvd +# Create directory for radvd mkdir /var/run/radvd -#Create and set permissions on the log files +# Create and set permissions on the log files touch $DHCP_LOG_FILE touch $RA_LOG_FILE chown $HOST_USER $DHCP_LOG_FILE chown $HOST_USER $RA_LOG_FILE -#Move the config files to the correct location +# Move the config files to the correct location +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 -# Restart dhcp server when config changes -while true; do +# Move the radvd-sevice file to the correct location +cp /testrun/bin/radvd-service /usr/local/bin/ - new_checksum=$(md5sum $CONFIG_FILE) - - if [ "$checksum" == "$new_checksum" ]; then - sleep 2 - continue - fi - - echo Config changed. Restarting dhcp server at $(date).. - - if [ -f $DHCP_PID_FILE ]; then - kill -9 $(cat $DHCP_PID_FILE) || true - rm -f $DHCP_PID_FILE - fi - - if [ -f $RA_PID_FILE ]; then - kill -9 $(cat $RA_PID_FILE) || true - rm -f $RA_PID_FILE - fi - - checksum=$new_checksum - - echo Starting isc-dhcp-server at $(date) - - radvd -m logfile -l $RA_LOG_FILE -p $RA_PID_FILE - dhcpd -d &> $DHCP_LOG_FILE & - - while [ ! -f $DHCP_PID_FILE ]; do - echo Waiting for $DHCP_PID_FILE... - sleep 2 - done - - echo $DHCP_PID_FILE now available - - while [ ! -f $RA_PID_FILE ]; do - echo Waiting for $RA_PID_FILE... - sleep 2 - done - - echo $RA_PID_FILE now available - - echo Server now stable - -done \ No newline at end of file +# Start the DHCP Server +python3 -u /testrun/python/src/grpc_server/dhcp_server.py \ No newline at end of file diff --git a/modules/network/dhcp-2/conf/dhcpd.conf b/modules/network/dhcp-2/conf/dhcpd.conf index e73a81441..dcc47a4fe 100644 --- a/modules/network/dhcp-2/conf/dhcpd.conf +++ b/modules/network/dhcp-2/conf/dhcpd.conf @@ -1,24 +1,25 @@ 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; + 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; - pool { - failover peer "failover-peer"; - range 10.10.10.10 10.10.10.20; - } + 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/conf/isc-dhcp-server b/modules/network/dhcp-2/conf/isc-dhcp-server new file mode 100644 index 000000000..44db95cd9 --- /dev/null +++ b/modules/network/dhcp-2/conf/isc-dhcp-server @@ -0,0 +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" diff --git a/modules/network/dhcp-2/dhcp-2.Dockerfile b/modules/network/dhcp-2/dhcp-2.Dockerfile index df77cb811..153aa50e7 100644 --- a/modules/network/dhcp-2/dhcp-2.Dockerfile +++ b/modules/network/dhcp-2/dhcp-2.Dockerfile @@ -18,8 +18,14 @@ FROM test-run/base:latest ARG MODULE_NAME=dhcp-2 ARG MODULE_DIR=modules/network/$MODULE_NAME +# Install all necessary packages +RUN apt-get install -y wget + +#Update the oui.txt file from ieee +RUN wget http://standards-oui.ieee.org/oui.txt -P /usr/local/etc/ + # Install dhcp server -RUN apt-get install -y isc-dhcp-server radvd +RUN apt-get install -y isc-dhcp-server radvd systemd # Copy over all configuration files COPY $MODULE_DIR/conf /testrun/conf @@ -28,5 +34,4 @@ COPY $MODULE_DIR/conf /testrun/conf COPY $MODULE_DIR/bin /testrun/bin # Copy over all python files -COPY $MODULE_DIR/python /testrun/python - +COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/network/dhcp-2/python/src/grpc/dhcp_config.py b/modules/network/dhcp-2/python/src/grpc/dhcp_config.py deleted file mode 100644 index f6e79a2ec..000000000 --- a/modules/network/dhcp-2/python/src/grpc/dhcp_config.py +++ /dev/null @@ -1,303 +0,0 @@ -# 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 -DHCP server's configuration""" -import re - -CONFIG_FILE = '/etc/dhcp/dhcpd.conf' -CONFIG_FILE_TEST = 'network/modules/dhcp-2/conf/dhcpd.conf' - -DEFAULT_LEASE_TIME_KEY = 'default-lease-time' - - -class DHCPConfig: - """Represents the DHCP Servers configuration and gives access to modify it""" - - def __init__(self): - self._default_lease_time = 300 - self.subnets = [] - self._peer = None - - def write_config(self): - conf = str(self) - print('Writing config: \n' + conf) - with open(CONFIG_FILE, 'w', encoding='UTF-8') as conf_file: - conf_file.write(conf) - - def resolve_config(self): - with open(CONFIG_FILE, 'r', encoding='UTF-8') as f: - conf = f.read() - self.resolve_subnets(conf) - self._peer = DHCPFailoverPeer(conf) - - def resolve_subnets(self, conf): - self.subnets = [] - regex = r'(subnet.*)' - subnets = re.findall(regex, conf, re.MULTILINE | re.DOTALL) - for subnet in subnets: - dhcp_subnet = DHCPSubnet(subnet) - self.subnets.append(dhcp_subnet) - - def set_range(self, start, end, subnet=0, pool=0): - print('Setting Range for pool ') - print(self.subnets[subnet].pools[pool]) - self.subnets[subnet].pools[pool].range_start = start - self.subnets[subnet].pools[pool].range_end = end - - # def resolve_settings(self, conf): - # lines = conf.split('\n') - # for line in lines: - # if DEFAULT_LEASE_TIME_KEY in line: - # self._default_lease_time = line.strip().split( - # DEFAULT_LEASE_TIME_KEY)[1].strip().split(';')[0] - - # self.peer = peer - - def __str__(self): - - config = """\r{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) - - config += '\n\n' + str(self.peer) - for subnet in self._subnets: - config += '\n\n' + str(subnet) - return str(config) - - -FAILOVER_PEER_KEY = 'failover peer' -PRIMARY_KEY = 'primary' -ADDRESS_KEY = 'address' -PORT_KEY = 'port' -PEER_ADDRESS_KEY = 'peer address' -PEER_PORT_KEY = 'peer port' -MAX_RESPONSE_DELAY_KEY = 'max-response-delay' -MAX_UNACKED_UPDATES_KEY = 'max-unacked-updates' -MCLT_KEY = 'mclt' -SPLIT_KEY = 'split' -LOAD_BALANCE_MAX_SECONDS_KEY = 'load balance max seconds' - - -class DHCPFailoverPeer: - """Contains all information to define the DHCP failover peer""" - - def __init__(self, config): - self.name = None - self.primary = False - self.address = None - self.port = None - self.peer_address = None - self.peer_port = None - self.max_response_delay = None - self.max_unacked_updates = None - self.mclt = None - self.split = None - self.load_balance_max_seconds = None - self.peer = None - - self.resolve_peer(config) - - def __str__(self): - config = '{FAILOVER_PEER_KEY} \"{FAILOVER_PEER}\" {{\n' - config += '\tprimary;' if self.primary else 'secondary;' - config += """\n\t{ADDRESS_KEY} {ADDRESS}; - {PORT_KEY} {PORT}; - {PEER_ADDRESS_KEY} {PEER_ADDRESS}; - {PEER_PORT_KEY} {PEER_PORT}; - {MAX_RESPONSE_DELAY_KEY} {MAX_RESPONSE_DELAY}; - {MAX_UNACKED_UPDATES_KEY} {MAX_UNACKED_UPDATES}; - {MCLT_KEY} {MCLT}; - {SPLIT_KEY} {SPLIT}; - {LOAD_BALANCE_MAX_SECONDS_KEY} {LOAD_BALANCE_MAX_SECONDS}; - \r}}""" - - return config.format( - length='multi-line', - FAILOVER_PEER_KEY=FAILOVER_PEER_KEY, - FAILOVER_PEER=self.name, - ADDRESS_KEY=ADDRESS_KEY, - ADDRESS=self.address, - PORT_KEY=PORT_KEY, - PORT=self.port, - PEER_ADDRESS_KEY=PEER_ADDRESS_KEY, - PEER_ADDRESS=self.peer_address, - PEER_PORT_KEY=PEER_PORT_KEY, - PEER_PORT=self.peer_port, - MAX_RESPONSE_DELAY_KEY=MAX_RESPONSE_DELAY_KEY, - MAX_RESPONSE_DELAY=self.max_response_delay, - MAX_UNACKED_UPDATES_KEY=MAX_UNACKED_UPDATES_KEY, - MAX_UNACKED_UPDATES=self.max_unacked_updates, - MCLT_KEY=MCLT_KEY, - MCLT=self.mclt, - SPLIT_KEY=SPLIT_KEY, - SPLIT=self.split, - LOAD_BALANCE_MAX_SECONDS_KEY=LOAD_BALANCE_MAX_SECONDS_KEY, - LOAD_BALANCE_MAX_SECONDS=self.load_balance_max_seconds) - - def resolve_peer(self, conf): - peer = '' - lines = conf.split('\n') - for line in lines: - if line.startswith(FAILOVER_PEER_KEY) or len(peer) > 0: - if len(peer) <= 0: - self.name = line.strip().split(FAILOVER_PEER_KEY)[1].strip().split( - '{')[0].split('\"')[1] - peer += line + '\n' - if PRIMARY_KEY in line: - self.primary = True - elif ADDRESS_KEY in line and PEER_ADDRESS_KEY not in line: - self.address = line.strip().split(ADDRESS_KEY)[1].strip().split( - ';')[0] - elif PORT_KEY in line and PEER_PORT_KEY not in line: - self.port = line.strip().split(PORT_KEY)[1].strip().split(';')[0] - elif PEER_ADDRESS_KEY in line: - self.peer_address = line.strip().split( - PEER_ADDRESS_KEY)[1].strip().split(';')[0] - elif PEER_PORT_KEY in line: - self.peer_port = line.strip().split(PEER_PORT_KEY)[1].strip().split( - ';')[0] - elif MAX_RESPONSE_DELAY_KEY in line: - self.max_response_delay = line.strip().split( - MAX_RESPONSE_DELAY_KEY)[1].strip().split(';')[0] - elif MAX_UNACKED_UPDATES_KEY in line: - self.max_unacked_updates = line.strip().split( - MAX_UNACKED_UPDATES_KEY)[1].strip().split(';')[0] - elif MCLT_KEY in line: - self.mclt = line.strip().split(MCLT_KEY)[1].strip().split(';')[0] - elif SPLIT_KEY in line: - self.split = line.strip().split(SPLIT_KEY)[1].strip().split(';')[0] - elif LOAD_BALANCE_MAX_SECONDS_KEY in line: - self.load_balance_max_seconds = line.strip().split( - LOAD_BALANCE_MAX_SECONDS_KEY)[1].strip().split(';')[0] - if line.endswith('}') and len(peer) > 0: - break - self.peer = peer - - -NTP_OPTION_KEY = 'option ntp-servers' -SUBNET_MASK_OPTION_KEY = 'option subnet-mask' -BROADCAST_OPTION_KEY = 'option broadcast-address' -ROUTER_OPTION_KEY = 'option routers' -DNS_OPTION_KEY = 'option domain-name-servers' - - -class DHCPSubnet: - """Represents the DHCP Servers subnet configuration""" - - def __init__(self, subnet): - self._ntp_servers = None - self._subnet_mask = None - self._broadcast = None - self._routers = None - self._dns_servers = None - self.pools = [] - - self.resolve_subnet(subnet) - self.resolve_pools(subnet) - - def __str__(self): - config = """subnet 10.10.10.0 netmask {SUBNET_MASK_OPTION} {{ - \r\t{NTP_OPTION_KEY} {NTP_OPTION}; - \r\t{SUBNET_MASK_OPTION_KEY} {SUBNET_MASK_OPTION}; - \r\t{BROADCAST_OPTION_KEY} {BROADCAST_OPTION}; - \r\t{ROUTER_OPTION_KEY} {ROUTER_OPTION}; - \r\t{DNS_OPTION_KEY} {DNS_OPTION};""" - - config = config.format(length='multi-line', - NTP_OPTION_KEY=NTP_OPTION_KEY, - NTP_OPTION=self._ntp_servers, - SUBNET_MASK_OPTION_KEY=SUBNET_MASK_OPTION_KEY, - SUBNET_MASK_OPTION=self._subnet_mask, - BROADCAST_OPTION_KEY=BROADCAST_OPTION_KEY, - BROADCAST_OPTION=self._broadcast, - ROUTER_OPTION_KEY=ROUTER_OPTION_KEY, - ROUTER_OPTION=self._routers, - DNS_OPTION_KEY=DNS_OPTION_KEY, - DNS_OPTION=self._dns_servers) - for pool in self.pools: - config += '\n\t' + str(pool) - - config += '\n\r}' - return config - - def resolve_subnet(self, subnet): - subnet_parts = subnet.split('\n') - for part in subnet_parts: - if NTP_OPTION_KEY in part: - self._ntp_servers = part.strip().split(NTP_OPTION_KEY)[1].strip().split( - ';')[0] - elif SUBNET_MASK_OPTION_KEY in part: - self._subnet_mask = part.strip().split( - SUBNET_MASK_OPTION_KEY)[1].strip().split(';')[0] - elif BROADCAST_OPTION_KEY in part: - self._broadcast = part.strip().split( - BROADCAST_OPTION_KEY)[1].strip().split(';')[0] - elif ROUTER_OPTION_KEY in part: - self._routers = part.strip().split(ROUTER_OPTION_KEY)[1].strip().split( - ';')[0] - elif DNS_OPTION_KEY in part: - self._dns_servers = part.strip().split(DNS_OPTION_KEY)[1].strip().split( - ';')[0] - - def resolve_pools(self, subnet): - regex = r'(pool.*)\}' - pools = re.findall(regex, subnet, re.MULTILINE | re.DOTALL) - for pool in pools: - dhcp_pool = DHCPPool(pool) - self.pools.append(dhcp_pool) - - -FAILOVER_KEY = 'failover peer' -RANGE_KEY = 'range' - - -class DHCPPool: - """Represents a DHCP Servers subnet pool configuration""" - - def __init__(self, pool): - self.failover_peer = None - self.range_start = None - self.range_end = None - self.resolve_pool(pool) - - def __str__(self): - - config = """pool {{ - \r\t\t{FAILOVER_KEY} "{FAILOVER}"; - \r\t\t{RANGE_KEY} {RANGE_START} {RANGE_END}; - \r\t}}""" - - return config.format( - length='multi-line', - FAILOVER_KEY=FAILOVER_KEY, - FAILOVER=self.failover_peer, - RANGE_KEY=RANGE_KEY, - RANGE_START=self.range_start, - RANGE_END=self.range_end, - ) - - def resolve_pool(self, pool): - pool_parts = pool.split('\n') - # pool_parts = pool.split("\n") - for part in pool_parts: - if FAILOVER_KEY in part: - self.failover_peer = part.strip().split(FAILOVER_KEY)[1].strip().split( - ';')[0].replace('\"', '') - if RANGE_KEY in part: - pool_range = part.strip().split(RANGE_KEY)[1].strip().split(';')[0] - self.range_start = pool_range.split(' ')[0].strip() - self.range_end = pool_range.split(' ')[1].strip() diff --git a/modules/network/dhcp-2/python/src/grpc/network_service.py b/modules/network/dhcp-2/python/src/grpc/network_service.py deleted file mode 100644 index 64aab8a07..000000000 --- a/modules/network/dhcp-2/python/src/grpc/network_service.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2023 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""gRPC Network Service for the DHCP Server network module""" -import proto.grpc_pb2_grpc as pb2_grpc -import proto.grpc_pb2 as pb2 - -from dhcp_config import DHCPConfig - - -class NetworkService(pb2_grpc.NetworkModule): - """gRPC endpoints for the DHCP Server""" - - def __init__(self): - self._dhcp_config = DHCPConfig() - - def GetDHCPRange(self, request, context): # pylint: disable=W0613 - """ - Resolve the current DHCP configuration and return - the first range from the first subnet in the file - """ - self._dhcp_config.resolve_config() - pool = self._dhcp_config.subnets[0].pools[0] - return pb2.DHCPRange(code=200, start=pool.range_start, end=pool.range_end) - - def SetDHCPRange(self, request, context): # pylint: disable=W0613 - """ - Change DHCP configuration and set the - the first range from the first subnet in the configuration - """ - - print('Setting DHCPRange') - print('Start: ' + request.start) - print('End: ' + request.end) - self._dhcp_config.resolve_config() - self._dhcp_config.set_range(request.start, request.end, 0, 0) - self._dhcp_config.write_config() - return pb2.Response(code=200, message='DHCP Range Set') - - def GetStatus(self, request, context): # pylint: disable=W0613 - """ - Return the current status of the network module - """ - # ToDo: Figure out how to resolve the current DHCP status - dhcp_status = True - message = str({'dhcpStatus': dhcp_status}) - return pb2.Response(code=200, message=message) diff --git a/modules/network/dhcp-2/python/src/grpc/proto/grpc.proto b/modules/network/dhcp-2/python/src/grpc/proto/grpc.proto deleted file mode 100644 index 8e2732620..000000000 --- a/modules/network/dhcp-2/python/src/grpc/proto/grpc.proto +++ /dev/null @@ -1,36 +0,0 @@ -syntax = "proto3"; - -service NetworkModule { - - rpc GetDHCPRange(GetDHCPRangeRequest) returns (DHCPRange) {}; - - rpc SetDHCPRange(DHCPRange) returns (Response) {}; - - rpc GetStatus(GetStatusRequest) returns (Response) {}; - - rpc GetIPAddress(GetIPAddressRequest) returns (Response) {}; - - rpc SetLeaseAddress(SetLeaseAddressRequest) returns (Response) {}; - -} - -message Response { - int32 code = 1; - string message = 2; -} - -message DHCPRange { - int32 code = 1; - string start = 2; - string end = 3; -} - -message GetDHCPRangeRequest {} - -message GetIPAddressRequest {} - -message GetStatusRequest {} - -message SetLeaseAddressRequest { - string ipAddress = 1; -} \ No newline at end of file diff --git a/modules/network/dhcp-2/python/src/grpc/__init__.py b/modules/network/dhcp-2/python/src/grpc_server/__init__.py similarity index 100% rename from modules/network/dhcp-2/python/src/grpc/__init__.py rename to modules/network/dhcp-2/python/src/grpc_server/__init__.py 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 new file mode 100644 index 000000000..5da5e4cf2 --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_config.py @@ -0,0 +1,506 @@ +# 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 +DHCP server's configuration""" +import re +from common import logger + +LOG_NAME = 'dhcp_config' +LOGGER = None + +CONFIG_FILE = '/etc/dhcp/dhcpd.conf' + +DEFAULT_LEASE_TIME_KEY = 'default-lease-time' + + +class DHCPConfig: + """Represents the DHCP Servers configuration and gives access to modify it""" + + def __init__(self): + self._default_lease_time = 300 + self._subnets = [] + self._peer = None + self._reserved_hosts = [] + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') + + def add_reserved_host(self, hostname, hw_addr, ip_addr): + host = DHCPReservedHost(hostname=hostname, + hw_addr=hw_addr, + fixed_addr=ip_addr) + self._reserved_hosts.append(host) + + def delete_reserved_host(self, hw_addr): + for host in self._reserved_hosts: + if hw_addr == host.hw_addr: + self._reserved_hosts.remove(host) + + def disable_failover(self): + self._peer.disable() + for subnet in self._subnets: + subnet.disable_peer() + + def enable_failover(self): + self._peer.enable() + for subnet in self._subnets: + subnet.enable_peer() + + def get_reserved_host(self, hw_addr): + for host in self._reserved_hosts: + if hw_addr == host.hw_addr: + return host + + def write_config(self, config=None): + if config is None: + conf = str(self) + with open(CONFIG_FILE, 'w', encoding='UTF-8') as conf_file: + conf_file.write(conf) + else: + with open(CONFIG_FILE, 'w', encoding='UTF-8') as conf_file: + conf_file.write(config) + + def _get_config(self, config_file=CONFIG_FILE): + content = None + with open(config_file, 'r', encoding='UTF-8') as f: + content = f.read() + return content + + def make(self, conf): + try: + self._subnets = self.resolve_subnets(conf) + self._peer = DHCPFailoverPeer(conf) + self._reserved_hosts = self.resolve_reserved_hosts(conf) + except Exception as e: # pylint: disable=W0718 + print('Failed to make DHCPConfig: ' + str(e)) + + def resolve_config(self, config_file=CONFIG_FILE): + try: + conf = self._get_config(config_file) + self._subnets = self.resolve_subnets(conf) + self._peer = DHCPFailoverPeer(conf) + self._reserved_hosts = self.resolve_reserved_hosts(conf) + except Exception as e: # pylint: disable=W0718 + print('Failed to resolve config: ' + str(e)) + + def resolve_subnets(self, conf): + subnets = [] + regex = r'(subnet.*)' + subnets_conf = re.findall(regex, conf, re.MULTILINE | re.DOTALL) + for subnet in subnets_conf: + dhcp_subnet = DHCPSubnet(subnet) + subnets.append(dhcp_subnet) + return subnets + + def resolve_reserved_hosts(self, conf): + hosts = [] + host_start = 0 + while True: + host_start = conf.find('host', host_start) + if host_start < 0: + break + else: + host_end = conf.find('}', host_start) + host = DHCPReservedHost(config=conf[host_start:host_end + 1]) + hosts.append(host) + host_start = host_end + 1 + return hosts + + def set_range(self, start, end, subnet=0, pool=0): + # Calculate the subnet from the range + octets = start.split('.') + octets[-1] = '0' + dhcp_subnet = '.'.join(octets) + + #Update the subnet and range + self._subnets[subnet].set_subnet(dhcp_subnet) + self._subnets[subnet].pools[pool].set_range(start, end) + + def __str__(self): + + # Encode the top level config options + 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) + + # Encode the failover peer + config += '\n\n' + str(self._peer) + + # Encode the subnets + for subnet in self._subnets: + config += '\n\n' + str(subnet) + + # Encode the reserved hosts + for host in self._reserved_hosts: + config += '\n' + str(host) + + return str(config) + + +FAILOVER_PEER_KEY = 'failover peer' +PRIMARY_KEY = 'primary' +ADDRESS_KEY = 'address' +PORT_KEY = 'port' +PEER_ADDRESS_KEY = 'peer address' +PEER_PORT_KEY = 'peer port' +MAX_RESPONSE_DELAY_KEY = 'max-response-delay' +MAX_UNACKED_UPDATES_KEY = 'max-unacked-updates' +MCLT_KEY = 'mclt' +SPLIT_KEY = 'split' +LOAD_BALANCE_MAX_SECONDS_KEY = 'load balance max seconds' + + +class DHCPFailoverPeer: + """Contains all information to define the DHCP failover peer""" + + def __init__(self, config): + self.name = None + self.primary = False + self.address = None + self.port = None + self.peer_address = None + self.peer_port = None + self.max_response_delay = None + self.max_unacked_updates = None + self.mclt = None + self.split = None + self.load_balance_max_seconds = None + self.peer = None + self.enabled = True + + self.resolve_peer(config) + + def __str__(self): + config = '{FAILOVER_PEER_KEY} \"{FAILOVER_PEER}\" {{\n' + config += '\tprimary;' if self.primary else 'secondary;' + config += '\n\t{ADDRESS_KEY} {ADDRESS};' if self.address is not None else '' + config += '\n\t{PORT_KEY} {PORT};' if self.port is not None else '' + config += ('\n\t{PEER_ADDRESS_KEY} {PEER_ADDRESS};' + if self.peer_address is not None else '') + config += ('\n\t{PEER_PORT_KEY} {PEER_PORT};' + if self.peer_port is not None else '') + config += ('\n\t{MAX_RESPONSE_DELAY_KEY} {MAX_RESPONSE_DELAY};' + if self.max_response_delay is not None else '') + config += ('\n\t{MAX_UNACKED_UPDATES_KEY} {MAX_UNACKED_UPDATES};' + if self.max_unacked_updates is not None else '') + config += '\n\t{MCLT_KEY} {MCLT};' if self.mclt is not None else '' + config += '\n\t{SPLIT_KEY} {SPLIT};' if self.split is not None else '' + config += ('\n\t{LOAD_BALANCE_MAX_SECONDS_KEY} {LOAD_BALANCE_MAX_SECONDS};' + if self.load_balance_max_seconds is not None else '') + config += '\n\r}}' + + config = config.format( + length='multi-line', + FAILOVER_PEER_KEY=FAILOVER_PEER_KEY, + FAILOVER_PEER=self.name, + ADDRESS_KEY=ADDRESS_KEY, + ADDRESS=self.address, + PORT_KEY=PORT_KEY, + PORT=self.port, + PEER_ADDRESS_KEY=PEER_ADDRESS_KEY, + PEER_ADDRESS=self.peer_address, + PEER_PORT_KEY=PEER_PORT_KEY, + PEER_PORT=self.peer_port, + MAX_RESPONSE_DELAY_KEY=MAX_RESPONSE_DELAY_KEY, + MAX_RESPONSE_DELAY=self.max_response_delay, + MAX_UNACKED_UPDATES_KEY=MAX_UNACKED_UPDATES_KEY, + MAX_UNACKED_UPDATES=self.max_unacked_updates, + MCLT_KEY=MCLT_KEY, + MCLT=self.mclt, + SPLIT_KEY=SPLIT_KEY, + SPLIT=self.split, + LOAD_BALANCE_MAX_SECONDS_KEY=LOAD_BALANCE_MAX_SECONDS_KEY, + LOAD_BALANCE_MAX_SECONDS=self.load_balance_max_seconds) + + if not self.enabled: + lines = config.strip().split('\n') + for i in range(len(lines) - 1): + lines[i] = '#' + lines[i] + lines[-1] = '#' + lines[-1].strip() # Handle the last line separately + config = '\n'.join(lines) + + return config + + def disable(self): + self.enabled = False + + def enable(self): + self.enabled = True + + def resolve_peer(self, conf): + peer = '' + lines = conf.split('\n') + for line in lines: + if line.startswith(FAILOVER_PEER_KEY) or len(peer) > 0: + if len(peer) <= 0: + self.name = line.strip().split(FAILOVER_PEER_KEY)[1].strip().split( + '{')[0].split('\"')[1] + peer += line + '\n' + if PRIMARY_KEY in line: + self.primary = True + elif ADDRESS_KEY in line and PEER_ADDRESS_KEY not in line: + self.address = line.strip().split(ADDRESS_KEY)[1].strip().split( + ';')[0] + elif PORT_KEY in line and PEER_PORT_KEY not in line: + self.port = line.strip().split(PORT_KEY)[1].strip().split(';')[0] + elif PEER_ADDRESS_KEY in line: + self.peer_address = line.strip().split( + PEER_ADDRESS_KEY)[1].strip().split(';')[0] + elif PEER_PORT_KEY in line: + self.peer_port = line.strip().split(PEER_PORT_KEY)[1].strip().split( + ';')[0] + elif MAX_RESPONSE_DELAY_KEY in line: + self.max_response_delay = line.strip().split( + MAX_RESPONSE_DELAY_KEY)[1].strip().split(';')[0] + elif MAX_UNACKED_UPDATES_KEY in line: + self.max_unacked_updates = line.strip().split( + MAX_UNACKED_UPDATES_KEY)[1].strip().split(';')[0] + elif MCLT_KEY in line: + self.mclt = line.strip().split(MCLT_KEY)[1].strip().split(';')[0] + elif SPLIT_KEY in line: + self.split = line.strip().split(SPLIT_KEY)[1].strip().split(';')[0] + elif LOAD_BALANCE_MAX_SECONDS_KEY in line: + self.load_balance_max_seconds = line.strip().split( + LOAD_BALANCE_MAX_SECONDS_KEY)[1].strip().split(';')[0] + if line.endswith('}') and len(peer) > 0: + break + self.peer = peer + + +SUBNET_KEY = 'subnet' +NTP_OPTION_KEY = 'option ntp-servers' +SUBNET_MASK_OPTION_KEY = 'option subnet-mask' +BROADCAST_OPTION_KEY = 'option broadcast-address' +ROUTER_OPTION_KEY = 'option routers' +DNS_OPTION_KEY = 'option domain-name-servers' +INTERFACE_KEY = 'interface' +AUTHORITATIVE_KEY = 'authoritative' + + +class DHCPSubnet: + """Represents the DHCP Servers subnet configuration""" + + def __init__(self, subnet): + self._authoritative = False + self._subnet = None + self._ntp_servers = None + self._subnet_mask = None + self._broadcast = None + self._routers = None + self._dns_servers = None + self._interface = None + self.pools = [] + + self.resolve_subnet(subnet) + self.resolve_pools(subnet) + + def __str__(self): + config = 'subnet {SUBNET_OPTION} netmask {SUBNET_MASK_OPTION} {{' + config += ('\n\t{NTP_OPTION_KEY} {NTP_OPTION};' + if self._ntp_servers is not None else '') + config += ('\n\t{SUBNET_MASK_OPTION_KEY} {SUBNET_MASK_OPTION};' + if self._subnet_mask is not None else '') + config += ('\n\t{BROADCAST_OPTION_KEY} {BROADCAST_OPTION};' + if self._broadcast is not None else '') + config += ('\n\t{ROUTER_OPTION_KEY} {ROUTER_OPTION};' + if self._routers is not None else '') + config += ('\n\t{DNS_OPTION_KEY} {DNS_OPTION};' + if self._dns_servers is not None else '') + config += ('\n\t{INTERFACE_KEY} {INTERFACE_OPTION};' + if self._interface is not None else '') + config += '\n\t{AUTHORITATIVE_KEY};' if self._authoritative else '' + + config = config.format(length='multi-line', + SUBNET_OPTION=self._subnet, + NTP_OPTION_KEY=NTP_OPTION_KEY, + NTP_OPTION=self._ntp_servers, + SUBNET_MASK_OPTION_KEY=SUBNET_MASK_OPTION_KEY, + SUBNET_MASK_OPTION=self._subnet_mask, + BROADCAST_OPTION_KEY=BROADCAST_OPTION_KEY, + BROADCAST_OPTION=self._broadcast, + ROUTER_OPTION_KEY=ROUTER_OPTION_KEY, + ROUTER_OPTION=self._routers, + DNS_OPTION_KEY=DNS_OPTION_KEY, + DNS_OPTION=self._dns_servers, + INTERFACE_KEY=INTERFACE_KEY, + INTERFACE_OPTION=self._interface, + AUTHORITATIVE_KEY=AUTHORITATIVE_KEY) + + # if not self._authoritative: + # config = config.replace(AUTHORITATIVE_KEY, '#' + AUTHORITATIVE_KEY) + + for pool in self.pools: + config += '\n\t' + str(pool) + + config += '\n}' + return config + + def disable_peer(self): + for pool in self.pools: + pool.disable_peer() + + def enable_peer(self): + for pool in self.pools: + pool.enable_peer() + + def set_subnet(self, subnet, netmask=None): + if netmask is None: + netmask = '255.255.255.0' + self._subnet = subnet + self._subnet_mask = netmask + + # Calculate the broadcast from the subnet + octets = subnet.split('.') + octets[-1] = '255' + dhcp_broadcast = '.'.join(octets) + + self._broadcast = dhcp_broadcast + + def resolve_subnet(self, subnet): + subnet_parts = subnet.split('\n') + for part in subnet_parts: + if part.strip().startswith(SUBNET_KEY): + self._subnet = part.strip().split()[1] + elif NTP_OPTION_KEY in part: + self._ntp_servers = part.strip().split(NTP_OPTION_KEY)[1].strip().split( + ';')[0] + elif SUBNET_MASK_OPTION_KEY in part: + self._subnet_mask = part.strip().split( + SUBNET_MASK_OPTION_KEY)[1].strip().split(';')[0] + elif BROADCAST_OPTION_KEY in part: + self._broadcast = part.strip().split( + BROADCAST_OPTION_KEY)[1].strip().split(';')[0] + elif ROUTER_OPTION_KEY in part: + self._routers = part.strip().split(ROUTER_OPTION_KEY)[1].strip().split( + ';')[0] + elif DNS_OPTION_KEY in part: + self._dns_servers = part.strip().split(DNS_OPTION_KEY)[1].strip().split( + ';')[0] + elif INTERFACE_KEY in part: + self._interface = part.strip().split(INTERFACE_KEY)[1].strip().split( + ';')[0] + elif AUTHORITATIVE_KEY in part: + self._authoritative = True + + def resolve_pools(self, subnet): + regex = r'(pool.*)\}' + pools = re.findall(regex, subnet, re.MULTILINE | re.DOTALL) + for pool in pools: + dhcp_pool = DHCPPool(pool) + self.pools.append(dhcp_pool) + + +FAILOVER_KEY = 'failover peer' +RANGE_KEY = 'range' + + +class DHCPPool: + """Represents a DHCP Servers subnet pool configuration""" + + def __init__(self, pool): + self.failover_peer = None + self.range_start = None + self.range_end = None + self.resolve_pool(pool) + self._peer_enabled = True + + def __str__(self): + config = 'pool {{' + config += ('\n\t\t{FAILOVER_KEY} "{FAILOVER}";' + if self.failover_peer is not None else '') + config += ('\n\t\t{RANGE_KEY} {RANGE_START} {RANGE_END};' + if self.range_start is not None and self.range_end is not None + else '') + config += '\n\t}}' + + config = config.format( + length='multi-line', + FAILOVER_KEY=FAILOVER_KEY, + FAILOVER=self.failover_peer, + RANGE_KEY=RANGE_KEY, + RANGE_START=self.range_start, + RANGE_END=self.range_end, + ) + + if not self._peer_enabled: + config = config.replace(FAILOVER_KEY, '#' + FAILOVER_KEY) + + return config + + def disable_peer(self): + self._peer_enabled = False + + def enable_peer(self): + self._peer_enabled = True + + def set_range(self, start, end): + self.range_start = start + self.range_end = end + + def resolve_pool(self, pool): + pool_parts = pool.split('\n') + for part in pool_parts: + if FAILOVER_KEY in part: + self.failover_peer = part.strip().split(FAILOVER_KEY)[1].strip().split( + ';')[0].replace('\"', '') + if RANGE_KEY in part: + pool_range = part.strip().split(RANGE_KEY)[1].strip().split(';')[0] + self.range_start = pool_range.split(' ')[0].strip() + self.range_end = pool_range.split(' ')[1].strip() + + +HOST_KEY = 'host' +HARDWARE_KEY = 'hardware ethernet' +FIXED_ADDRESS_KEY = 'fixed-address' + + +class DHCPReservedHost: + """Represents a DHCP Servers subnet pool configuration""" + + def __init__(self, hostname=None, hw_addr=None, fixed_addr=None, config=None): + if config is None: + self.host = hostname + self.hw_addr = hw_addr + self.fixed_addr = fixed_addr + else: + self.resolve_host(config) + + def __str__(self): + + config = """{HOST_KEY} {HOSTNAME} {{ + \r\t{HARDWARE_KEY} {HW_ADDR}; + \r\t{FIXED_ADDRESS_KEY} {RESERVED_IP}; + \r}}""" + + config = config.format( + length='multi-line', + HOST_KEY=HOST_KEY, + HOSTNAME=self.host, + HARDWARE_KEY=HARDWARE_KEY, + HW_ADDR=self.hw_addr, + FIXED_ADDRESS_KEY=FIXED_ADDRESS_KEY, + RESERVED_IP=self.fixed_addr, + ) + return config + + def resolve_host(self, reserved_host): + host_parts = reserved_host.split('\n') + for part in host_parts: + if HOST_KEY in part: + self.host = part.strip().split(HOST_KEY)[1].strip().split('{')[0] + elif HARDWARE_KEY in part: + self.hw_addr = part.strip().split(HARDWARE_KEY)[1].strip().split(';')[0] + elif FIXED_ADDRESS_KEY in part: + self.fixed_addr = part.strip().split( + FIXED_ADDRESS_KEY)[1].strip().split(';')[0] 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 new file mode 100644 index 000000000..b07f57b27 --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.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. +"""Unit Testing for the DHCP Server config""" +import unittest +from dhcp_config import DHCPConfig +import os + +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( + os.path.dirname(os.path.dirname(os.path.abspath(current_dir)))) + conf_file = os.path.join(module_dir, CONFIG_FILE) + return conf_file + +def get_config(): + dhcp_config = DHCPConfig() + dhcp_config.resolve_config(get_config_file_path()) + return dhcp_config + +class DHCPConfigTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + # Resolve the config + global DHCP_CONFIG + DHCP_CONFIG = get_config() + + def test_resolve_config(self): + print('Test Resolve Config:\n' + str(DHCP_CONFIG)) + + # Resolve the raw config file + with open(get_config_file_path(), 'r', encoding='UTF-8') as f: + lines = f.readlines() + + # Get the resolved config as a + conf_parts = str(DHCP_CONFIG).split('\n') + + # dhcpd conf is not picky about spacing so we just + # need to check contents of each line for matching + # to make sure evertying matches + for i in range(len(lines)): + self.assertEqual(lines[i].strip(), conf_parts[i].strip()) + + def test_disable_failover(self): + DHCP_CONFIG.disable_failover() + print('Test Disable Config:\n' + str(DHCP_CONFIG)) + config_lines = str(DHCP_CONFIG._peer).split('\n') + for line in config_lines: + self.assertTrue(line.startswith('#')) + + def test_enable_failover(self): + DHCP_CONFIG.enable_failover() + print('Test Enable Config:\n' + str(DHCP_CONFIG)) + config_lines = str(DHCP_CONFIG._peer).split('\n') + for line in config_lines: + self.assertFalse(line.startswith('#')) + + def test_add_reserved_host(self): + DHCP_CONFIG.add_reserved_host('test', '00:11:22:33:44:55', '192.168.10.5') + host = DHCP_CONFIG.get_reserved_host('00:11:22:33:44:55') + self.assertIsNotNone(host) + print('AddHostConfig:\n' + str(DHCP_CONFIG)) + + def test_delete_reserved_host(self): + DHCP_CONFIG.delete_reserved_host('00:11:22:33:44:55') + host = DHCP_CONFIG.get_reserved_host('00:11:22:33:44:55') + self.assertIsNone(host) + print('DeleteHostConfig:\n' + str(DHCP_CONFIG)) + + def test_resolve_config_with_hosts(self): + DHCP_CONFIG.add_reserved_host('test', '00:11:22:33:44:55', '192.168.10.5') + config_with_hosts = DHCPConfig() + config_with_hosts.make(str(DHCP_CONFIG)) + host = config_with_hosts.get_reserved_host('00:11:22:33:44:55') + self.assertIsNotNone(host) + print('ResolveConfigWithHosts:\n' + str(config_with_hosts)) + +if __name__ == '__main__': + suite = unittest.TestSuite() + suite.addTest(DHCPConfigTest('test_resolve_config')) + suite.addTest(DHCPConfigTest('test_disable_failover')) + suite.addTest(DHCPConfigTest('test_enable_failover')) + suite.addTest(DHCPConfigTest('test_add_reserved_host')) + suite.addTest(DHCPConfigTest('test_delete_reserved_host')) + suite.addTest(DHCPConfigTest('test_resolve_config_with_hosts')) + + 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 new file mode 100644 index 000000000..0d2f43e3b --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_lease.py @@ -0,0 +1,75 @@ +# 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 methods to create and monitor DHCP +leases on the server""" +from datetime import datetime +import time + +time_format = '%Y-%m-%d %H:%M:%S' + + +class DHCPLease(object): + """Represents a DHCP Server lease""" + hw_addr = None + ip = None + hostname = None + expires = None + + def __init__(self, lease): + self._make_lease(lease) + + def _make_lease(self, lease): + if lease is not None: + sections_raw = lease.split(' ') + sections = [] + for section in sections_raw: + if section.strip(): + sections.append(section) + self.hw_addr = sections[0] + self.ip = sections[1] + self.hostname = sections[2] + self.expires = sections[3] + '' '' + sections[4] + self.manufacturer = ' '.join(sections[5:]) + + def get_millis(self, timestamp): + dt_obj = datetime.strptime(timestamp, time_format) + millis = dt_obj.timestamp() * 1000 + return millis + + def get_expires_millis(self): + return self.get_millis(self.expires) + + def is_expired(self): + expires_millis = self.get_expires_millis() + cur_time = int(round(time.time()) * 1000) + return cur_time >= expires_millis + + def __str__(self): + lease = {} + if self.hw_addr is not None: + lease['hw_addr'] = self.hw_addr + + if self.ip is not None: + lease['ip'] = self.ip + + if self.hostname is not None: + lease['hostname'] = self.hostname + + if self.expires is not None: + lease['expires'] = self.expires + + if self.manufacturer is not None: + lease['manufacturer'] = self.manufacturer + + return str(lease) diff --git a/modules/network/dhcp-2/python/src/grpc_server/dhcp_leases.py b/modules/network/dhcp-2/python/src/grpc_server/dhcp_leases.py new file mode 100644 index 000000000..08e6feabe --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_leases.py @@ -0,0 +1,107 @@ +# 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. +"""Used to resolve the DHCP servers lease information""" +import os +from dhcp_lease import DHCPLease +import logger +from common import util + +LOG_NAME = 'dhcp_lease' +LOGGER = None + +DHCP_LEASE_FILES = [ + '/var/lib/dhcp/dhcpd.leases', '/var/lib/dhcp/dhcpd.leases~', + '/var/lib/dhcp/dhcpd6.leases', '/var/lib/dhcp/dhcpd6.leases~' +] +DHCP_CONFIG_FILE = '/etc/dhcp/dhcpd.conf' + + +class DHCPLeases: + """Leases for the DHCP server""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') + + def delete_all_hosts(self): + LOGGER.info('Deleting hosts') + for lease in DHCP_LEASE_FILES: + LOGGER.info('Checking file: ' + lease) + if os.path.exists(lease): + LOGGER.info('File Exists: ' + lease) + try: + # Delete existing lease file + os.remove(lease) + except OSError as e: + LOGGER.info(f'Error occurred while deleting the file: {e}') + # Create an empty lease file + with open(lease, 'w', encoding='UTF-8'): + pass + + def get_lease(self, hw_addr): + for lease in self.get_leases(): + if lease.hw_addr == hw_addr: + return lease + + 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:] + lines = lease_list.split('\n') + for line in lines: + try: + lease = DHCPLease(line) + leases.append(lease) + except Exception as e: # pylint: disable=W0718 + # Let non lease lines file without extra checks + LOGGER.error('Making Lease Error: ' + str(e)) + LOGGER.error('Not a valid lease line: ' + line) + return leases + + def delete_lease(self, ip_addr): + LOGGER.info('Deleting lease') + for lease in DHCP_LEASE_FILES: + LOGGER.info('Checking file: ' + lease) + if os.path.exists(lease): + LOGGER.info('File Exists: ' + lease) + try: + # Delete existing lease file + with (open(lease, 'r', encoding='UTF-8')) as f: + contents = f.read() + + while ip_addr in contents: + ix_ip = contents.find(ip_addr) + lease_start = contents.rindex('lease', 0, ix_ip) + lease_end = contents.find('}', lease_start) + LOGGER.info('Lease Location: ' + str(lease_start) + ':' + + str(lease_end)) + contents = contents[0:lease_start] + contents[lease_end + 1:] + + except OSError as e: + LOGGER.info(f'Error occurred while deleting the lease: {e}') + + def _get_lease_list(self): + LOGGER.info('Running lease list command') + try: + result = util.run_command('dhcp-lease-list') + return result[0] + except Exception as e: # pylint: disable=W0718 + LOGGER.error('Error lease list: ' + str(e)) + + def _write_config(self, config): + with open(DHCP_CONFIG_FILE, 'w', encoding='UTF-8') as f: + f.write(config) 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 new file mode 100644 index 000000000..67a31c2cb --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/dhcp_server.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. +"""Contains all the necessary classes to maintain the +DHCP server""" +import sys +import time +from common import logger +from common import util +from dhcp_config import DHCPConfig +from radvd_server import RADVDServer + +CONFIG_FILE = '/etc/dhcp/dhcpd.conf' +LOG_NAME = 'dhcp_server' +LOGGER = None + + +class DHCPServer: + """Represents the DHCP Server""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') + self.dhcp_config = DHCPConfig() + self.radvd = RADVDServer() + self.dhcp_config.resolve_config() + + def restart(self): + LOGGER.info('Restarting DHCP Server') + isc_started = util.run_command('service isc-dhcp-server restart', False) + radvd_started = self.radvd.restart() + started = isc_started and radvd_started + LOGGER.info('DHCP Restarted: ' + str(started)) + return started + + def start(self): + LOGGER.info('Starting DHCP Server') + isc_started = util.run_command('service isc-dhcp-server start', False) + radvd_started = self.radvd.start() + started = isc_started and radvd_started + LOGGER.info('DHCP Started: ' + str(started)) + return started + + def stop(self): + LOGGER.info('Stopping DHCP Server') + isc_stopped = util.run_command('service isc-dhcp-server stop', False) + radvd_stopped = self.radvd.stop() + stopped = isc_stopped and radvd_stopped + LOGGER.info('DHCP 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.' + radvd_running = self.radvd.is_running() + running = isc_running and radvd_running + LOGGER.info('DHCP Status: ' + str(running)) + return running + + def boot(self): + LOGGER.info('Booting DHCP Server') + isc_booted = False + radvd_booted = False + if self.is_running(): + LOGGER.info('Stopping isc-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') + 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: + break + LOGGER.info('RADVD started: ' + str(radvd_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') + sys.exit(1) + + config = str(dhcp_server.dhcp_config) + while True: + dhcp_server.dhcp_config.resolve_config() + new_config = str(dhcp_server.dhcp_config) + if config != new_config: + LOGGER.info('DHCP Config Changed') + config = new_config + dhcp_server.restart() + dhcp_server.radvd.restart() + time.sleep(1) + +if __name__ == '__main__': + run() diff --git a/modules/network/dhcp-2/python/src/grpc_server/network_service.py b/modules/network/dhcp-2/python/src/grpc_server/network_service.py new file mode 100644 index 000000000..f9deba965 --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/network_service.py @@ -0,0 +1,195 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""gRPC Network Service for the DHCP Server network module""" +import proto.grpc_pb2_grpc as pb2_grpc +import proto.grpc_pb2 as pb2 + +from dhcp_server import DHCPServer +from dhcp_config import DHCPConfig +from dhcp_leases import DHCPLeases + +import traceback +from common import logger + +LOG_NAME = 'network_service' +LOGGER = None + + +class NetworkService(pb2_grpc.NetworkModule): + """gRPC endpoints for the DHCP Server""" + + def __init__(self): + self._dhcp_server = DHCPServer() + self._dhcp_config = None + self.dhcp_leases = DHCPLeases() + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') + + def _get_dhcp_config(self): + if self._dhcp_config is None: + self._dhcp_config = DHCPConfig() + self._dhcp_config.resolve_config() + return self._dhcp_config + + def RestartDHCPServer(self, request, context): # pylint: disable=W0613 + LOGGER.info('Restarting DHCP server') + try: + started = self._dhcp_server.restart() + LOGGER.info('DHCP server restarted: ' + (str(started))) + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to restart DHCP server: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def StartDHCPServer(self, request, context): # pylint: disable=W0613 + LOGGER.info('Starting DHCP server') + try: + started = self._dhcp_server.start() + LOGGER.info('DHCP server started: ' + (str(started))) + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to start DHCP server: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def StopDHCPServer(self, request, context): # pylint: disable=W0613 + LOGGER.info('Stopping DHCP server') + try: + stopped = self._dhcp_server.stop() + LOGGER.info('DHCP server stopped: ' + (str(stopped))) + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to stop DHCP server: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def AddReservedLease(self, request, context): # pylint: disable=W0613 + LOGGER.info('Add reserved lease called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.add_reserved_host(request.hostname, request.hw_addr, + request.ip_addr) + dhcp_config.write_config() + LOGGER.info('Reserved lease added') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to add reserved lease: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def DeleteReservedLease(self, request, context): # pylint: disable=W0613 + LOGGER.info('Delete reserved lease called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.delete_reserved_host(request.hw_addr) + dhcp_config.write_config() + LOGGER.info('Reserved lease deleted') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to delete reserved lease: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def DisableFailover(self, request, contest): # pylint: disable=W0613 + LOGGER.info('Disable failover called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.disable_failover() + dhcp_config.write_config() + LOGGER.info('Failover disabled') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to disable failover: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def EnableFailover(self, request, contest): # pylint: disable=W0613 + LOGGER.info('Enable failover called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.enable_failover() + dhcp_config.write_config() + LOGGER.info('Failover enabled') + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to enable failover: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def GetDHCPRange(self, request, context): # pylint: disable=W0613 + """ + Resolve the current DHCP configuration and return + the first range from the first subnet in the file + """ + LOGGER.info('Get DHCP range called') + try: + pool = self._get_dhcp_config()._subnets[0].pools[0] + return pb2.DHCPRange(code=200, start=pool.range_start, end=pool.range_end) + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to get DHCP range: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def GetLease(self, request, context): # pylint: disable=W0613 + """ + Resolve the current DHCP leased address for the + provided MAC address + """ + LOGGER.info('Get lease called') + try: + lease = self.dhcp_leases.get_lease(request.hw_addr) + if lease is not None: + return pb2.Response(code=200, message=str(lease)) + else: + return pb2.Response(code=200, message='{}') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to get lease: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def SetDHCPRange(self, request, context): # pylint: disable=W0613 + """ + Change DHCP configuration and set the + the first range from the first subnet in the configuration + """ + LOGGER.info('Set DHCP range called') + try: + dhcp_config = self._get_dhcp_config() + dhcp_config.set_range(request.start, request.end, 0, 0) + dhcp_config.write_config() + LOGGER.info('DHCP range set') + return pb2.Response(code=200, message='DHCP Range Set') + except Exception as e: # pylint: disable=W0718 + fail_message = 'Failed to set DHCP range: ' + str(e) + LOGGER.error(fail_message) + LOGGER.error(traceback.format_exc()) + return pb2.Response(code=500, message=fail_message) + + def GetStatus(self, request, context): # pylint: disable=W0613 + """ + Return the current status of the network module + """ + dhcp_status = self._dhcp_server.is_running() + message = str({'dhcpStatus': dhcp_status}) + return pb2.Response(code=200, message=message) diff --git a/modules/network/dhcp-2/python/src/grpc_server/proto/grpc.proto b/modules/network/dhcp-2/python/src/grpc_server/proto/grpc.proto new file mode 100644 index 000000000..e6abda674 --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/proto/grpc.proto @@ -0,0 +1,71 @@ +syntax = "proto3"; + +service NetworkModule { + + rpc RestartDHCPServer(RestartDHCPServerRequest) returns (Response) {}; + + rpc StartDHCPServer(StartDHCPServerRequest) returns (Response) {}; + + rpc StopDHCPServer(StopDHCPServerRequest) returns (Response) {}; + + rpc AddReservedLease(AddReservedLeaseRequest) returns (Response) {}; + + rpc DeleteReservedLease(DeleteReservedLeaseRequest) returns (Response) {}; + + rpc DisableFailover(DisableFailoverRequest) returns (Response) {}; + + rpc EnableFailover(EnableFailoverRequest) returns (Response) {}; + + rpc GetDHCPRange(GetDHCPRangeRequest) returns (DHCPRange) {}; + + rpc GetLease(GetLeaseRequest) returns (Response) {}; + + rpc GetStatus(GetStatusRequest) returns (Response) {}; + + rpc SetDHCPRange(SetDHCPRangeRequest) returns (Response) {}; +} + +message AddReservedLeaseRequest { + string hostname = 1; + string hw_addr = 2; + string ip_addr = 3; +} + +message DeleteReservedLeaseRequest { + string hw_addr = 1; +} + +message RestartDHCPServerRequest {} + +message StartDHCPServerRequest {} + +message StopDHCPServerRequest {} + +message DisableFailoverRequest {} + +message EnableFailoverRequest {} + +message GetDHCPRangeRequest {} + +message GetLeaseRequest { + string hw_addr = 1; +} + +message GetStatusRequest {} + +message SetDHCPRangeRequest { + int32 code = 1; + string start = 2; + string end = 3; +} + +message Response { + int32 code = 1; + string message = 2; +} + +message DHCPRange { + int32 code = 1; + string start = 2; + string end = 3; +} \ No newline at end of file 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 new file mode 100644 index 000000000..bc5d8b55f --- /dev/null +++ b/modules/network/dhcp-2/python/src/grpc_server/radvd_server.py @@ -0,0 +1,53 @@ +# 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 +DHCP server""" +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""" + + def __init__(self): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, 'dhcp-2') + + def restart(self): + LOGGER.info('Restarting RADVD Server') + response = util.run_command('radvd-service restart', False) + LOGGER.info('RADVD Restarted: ' + str(response)) + return response + + def start(self): + LOGGER.info('Starting RADVD Server') + response = util.run_command('radvd-service start', False) + LOGGER.info('RADVD Started: ' + str(response)) + return response + + def stop(self): + LOGGER.info('Stopping RADVD Server') + response = util.run_command('radvd-service stop', False) + LOGGER.info('RADVD Stopped: ' + str(response)) + return response + + def is_running(self): + 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.' diff --git a/modules/test/base/base.Dockerfile b/modules/test/base/base.Dockerfile index 9c7f2bac2..10344cbc7 100644 --- a/modules/test/base/base.Dockerfile +++ b/modules/test/base/base.Dockerfile @@ -36,5 +36,13 @@ RUN dos2unix /testrun/bin/* # Make sure all the bin files are executable RUN chmod u+x /testrun/bin/* +# Copy over all network module gRPC proto files +ARG NET_MODULE_DIR=modules/network +ARG NET_MODULE_PROTO_DIR=python/src/grpc_server/proto/grpc.proto +ARG CONTAINER_PROTO_DIR=testrun/python/src/grpc_server/proto + +COPY $NET_MODULE_DIR/dhcp-1/$NET_MODULE_PROTO_DIR $CONTAINER_PROTO_DIR/dhcp1/ +COPY $NET_MODULE_DIR/dhcp-2/$NET_MODULE_PROTO_DIR $CONTAINER_PROTO_DIR/dhcp2/ + # Start the test module ENTRYPOINT [ "/testrun/bin/start_module" ] \ No newline at end of file diff --git a/modules/test/base/bin/setup_grpc_clients b/modules/test/base/bin/setup_grpc_clients new file mode 100644 index 000000000..30efe5002 --- /dev/null +++ b/modules/test/base/bin/setup_grpc_clients @@ -0,0 +1,34 @@ +#!/bin/bash -e + +GRPC_DIR="/testrun/python/src/grpc_server" +GRPC_PROTO_DIR="proto" +GRPC_PROTO_FILE="grpc.proto" + +# Build the grpc proto file +build_grpc_client(){ + MODULE=$1 + echo "Building gRPC proto: $MODULE" + python3 -m grpc_tools.protoc --proto_path=. ./$GRPC_PROTO_DIR/$MODULE/$GRPC_PROTO_FILE --python_out=. --grpc_python_out=. +} + +# Build the grpc proto files for every module that has a proto defined +build_grpc_clients(){ + + for dir in "$GRPC_DIR/$GRPC_PROTO_DIR"/*/;do + if [ -f $dir/$GRPC_PROTO_FILE ];then + # Extract the last folder name + last_folder="${dir%%/}" + last_folder="${last_folder##*/}" + build_grpc_client "$last_folder" + fi + done +} + +# Move into the grpc directory. +# This is necessary to build the proto files +# with the correct import paths +pushd $GRPC_DIR >/dev/null 2>&1 + +build_grpc_clients + +popd >/dev/null 2>&1 \ No newline at end of file diff --git a/modules/test/base/bin/setup_python_path b/modules/test/base/bin/setup_python_path new file mode 100644 index 000000000..8201bbb36 --- /dev/null +++ b/modules/test/base/bin/setup_python_path @@ -0,0 +1,25 @@ +#!/bin/bash + +ROOT_DIRECTORY="/testrun/python/src" + +# Function to recursively add subdirectories to PYTHONPATH +add_subdirectories_to_pythonpath() { + local directory=$1 + local subdirectories=( "$directory"/* ) + local subdirectory + + for subdirectory in "${subdirectories[@]}"; do + if [ -d "$subdirectory" ]; then + export PYTHONPATH="$PYTHONPATH:$subdirectory" + add_subdirectories_to_pythonpath "$subdirectory" + fi + done +} + +# Set PYTHONPATH initially to an empty string +export PYTHONPATH="" + +# Add all subdirectories to PYTHONPATH +add_subdirectories_to_pythonpath "$ROOT_DIRECTORY" + +echo "$PYTHONPATH" \ No newline at end of file diff --git a/modules/test/base/bin/start_module b/modules/test/base/bin/start_module index 5f6e1ee35..82c9d26bf 100644 --- a/modules/test/base/bin/start_module +++ b/modules/test/base/bin/start_module @@ -57,10 +57,21 @@ then exit 1 fi -echo "Starting module $MODULE_NAME..." +# Setup the PYTHONPATH so all imports work as expected +echo "Setting up PYTHONPATH..." +export PYTHONPATH=$($BIN_DIR/setup_python_path) +echo "PYTHONPATH: $PYTHONPATH" + +# Build all gRPC files from the proto for use in +# gRPC clients for communications to network modules +echo "Building gRPC files from available proto files..." +$BIN_DIR/setup_grpc_clients +echo "Configuring binary files..." $BIN_DIR/setup_binaries $BIN_DIR +echo "Starting module $MODULE_NAME..." + # Only start network services if the test container needs # a network connection to run its tests if [ $NETWORK_REQUIRED == "true" ];then @@ -78,9 +89,9 @@ then if [[ ! -z $GRPC_PORT && ! $GRPC_PORT == "null" ]] then echo "gRPC port resolved from config: $GRPC_PORT" - $BIN_DIR/start_grpc "-p $GRPC_PORT" & + $BIN_DIR/start_grpc "-p $GRPC_PORT" else - $BIN_DIR/start_grpc & + $BIN_DIR/start_grpc fi fi diff --git a/modules/test/base/python/src/grpc/proto/dhcp1/client.py b/modules/test/base/python/src/grpc/proto/dhcp1/client.py new file mode 100644 index 000000000..921929edb --- /dev/null +++ b/modules/test/base/python/src/grpc/proto/dhcp1/client.py @@ -0,0 +1,98 @@ +import grpc +import grpc_pb2_grpc as pb2_grpc +import grpc_pb2 as pb2 + +DEFAULT_PORT = '5001' +DEFAULT_HOST = '10.10.10.2' # Default DHCP1 server + + +class Client(): + + 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 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 5342e36f8..2a892b810 100644 --- a/modules/test/base/python/src/test_module.py +++ b/modules/test/base/python/src/test_module.py @@ -89,14 +89,13 @@ def run_tests(self): else: result = getattr(self, test_method_name)() else: - LOGGER.info('Test ' + test['name'] + ' not resolved. Skipping') + LOGGER.info(f'Test {test["name"]} not resolved. Skipping') result = None else: - LOGGER.info('Test ' + test['name'] + ' disabled. Skipping') + LOGGER.info(f'Test {test["name"]} disabled. Skipping') if result is not None: - success = None - if isinstance(result,bool): - test['result'] = 'compliant' if result else 'non-compliant' + if isinstance(result, bool): + test['result'] = 'compliant' if result else 'non-compliant' else: test['result'] = 'compliant' if result[0] else 'non-compliant' test['result_details'] = result[1] diff --git a/modules/test/conn/conf/module_config.json b/modules/test/conn/conf/module_config.json index 0f599c5d3..496b6aada 100644 --- a/modules/test/conn/conf/module_config.json +++ b/modules/test/conn/conf/module_config.json @@ -12,6 +12,11 @@ "timeout": 30 }, "tests": [ + { + "name": "connection.dhcp_address", + "description": "The device under test has received an IP address from the DHCP server and responds to an ICMP echo (ping) request", + "expected_behavior": "The device is not setup with a static IP address. The device accepts an IP address from a DHCP server (RFC 2131) and responds succesfully to an ICMP echo (ping) request." + }, { "name": "connection.mac_address", "description": "Check and note device physical address.", @@ -31,6 +36,16 @@ "name": "connection.target_ping", "description": "The device under test responds to an ICMP echo (ping) request.", "expected_behavior": "The device under test responds to an ICMP echo (ping) request." + }, + { + "name": "connection.ipv6_slaac", + "description": "The device forms a valid IPv6 address as a combination of the IPv6 router prefix and the device interface identifier", + "expected_behavior": "The device under test complies with RFC4862 and forms a valid IPv6 SLAAC address" + }, + { + "name": "connection.ipv6_ping", + "description": "The device responds to an IPv6 ping (ICMPv6 Echo) request to the SLAAC address", + "expected_behavior": "The device responds to the ping as per RFC4443" } ] } diff --git a/modules/test/conn/conn.Dockerfile b/modules/test/conn/conn.Dockerfile index 1714f49f2..5d8148335 100644 --- a/modules/test/conn/conn.Dockerfile +++ b/modules/test/conn/conn.Dockerfile @@ -17,6 +17,8 @@ FROM test-run/base-test:latest ARG MODULE_NAME=conn ARG MODULE_DIR=modules/test/$MODULE_NAME +ARG GRPC_PROTO_DIR=/testrun/python/src/grpc/proto/dhcp +ARG GRPC_PROTO_FILE="grpc.proto" # Install all necessary packages RUN apt-get install -y wget @@ -37,4 +39,4 @@ COPY $MODULE_DIR/conf /testrun/conf COPY $MODULE_DIR/bin /testrun/bin # Copy over all python files -COPY $MODULE_DIR/python /testrun/python +COPY $MODULE_DIR/python /testrun/python \ No newline at end of file diff --git a/modules/test/conn/python/src/connection_module.py b/modules/test/conn/python/src/connection_module.py index 196c335d8..0b11fde24 100644 --- a/modules/test/conn/python/src/connection_module.py +++ b/modules/test/conn/python/src/connection_module.py @@ -11,19 +11,21 @@ # 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. - """Connection test module""" import util import sys -from scapy.all import * +import json +from scapy.all import rdpcap, DHCP, Ether from test_module import TestModule +from dhcp1.client import Client as DHCPClient1 -LOG_NAME = "test_connection" +LOG_NAME = 'test_connection' LOGGER = None -OUI_FILE="/usr/local/etc/oui.txt" +OUI_FILE = '/usr/local/etc/oui.txt' 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" class ConnectionModule(TestModule): @@ -33,34 +35,84 @@ def __init__(self, module): super().__init__(module_name=module, log_name=LOG_NAME) global LOGGER LOGGER = self._get_logger() + self.dhcp1_client = DHCPClient1() + + # ToDo: Move this into some level of testing, leave for + # reference until tests are implemented with these calls + # response = self.dhcp1_client.add_reserved_lease( + # 'test','00:11:22:33:44:55','10.10.10.21') + # print("AddLeaseResp: " + str(response)) + + # response = self.dhcp1_client.delete_reserved_lease('00:11:22:33:44:55') + # print("DelLeaseResp: " + str(response)) + + # response = self.dhcp1_client.disable_failover() + # print("FailoverDisabled: " + str(response)) + + # response = self.dhcp1_client.enable_failover() + # print("FailoverEnabled: " + str(response)) + + # response = self.dhcp1_client.get_dhcp_range() + # print("DHCP Range: " + str(response)) + + # response = self.dhcp1_client.get_lease(self._device_mac) + # print("Lease: " + str(response)) + + # response = self.dhcp1_client.get_status() + # print("Status: " + str(response)) + + # response = self.dhcp1_client.set_dhcp_range('10.10.10.20','10.10.10.30') + # print("Set Range: " + str(response)) + + 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 + if 'ip' in lease: + ip_addr = lease['ip'] + LOGGER.info('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)) + if ping_success: + return True, 'Device responded to leased ip address' + else: + return False, 'Device did not respond to leased ip address' + else: + 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") + LOGGER.info('Running connection.mac_address') if self._device_mac is not None: - LOGGER.info("MAC address found: " + self._device_mac) - return True, "MAC address found: " + self._device_mac + LOGGER.info('MAC address found: ' + self._device_mac) + return True, 'MAC address found: ' + self._device_mac else: - LOGGER.info("No MAC address found: " + self._device_mac) - return False, "No MAC address found." + LOGGER.info('No MAC address found: ' + self._device_mac) + return False, 'No MAC address found.' def _connection_mac_oui(self): - LOGGER.info("Running connection.mac_oui") + LOGGER.info('Running connection.mac_oui') manufacturer = self._get_oui_manufacturer(self._device_mac) if manufacturer is not None: - LOGGER.info("OUI Manufacturer found: " + manufacturer) - return True, "OUI Manufacturer found: " + manufacturer + LOGGER.info('OUI Manufacturer found: ' + manufacturer) + return True, 'OUI Manufacturer found: ' + manufacturer else: - LOGGER.info("No OUI Manufacturer found for: " + self._device_mac) - return False, "No OUI Manufacturer found for: " + self._device_mac + LOGGER.info('No OUI Manufacturer found for: ' + self._device_mac) + return False, 'No OUI Manufacturer found for: ' + self._device_mac def _connection_single_ip(self): - LOGGER.info("Running connection.single_ip") + LOGGER.info('Running connection.single_ip') result = None if self._device_mac is None: - LOGGER.info("No MAC address found: ") - return result, "No MAC address found." - + LOGGER.info('No MAC address found: ') + return result, 'No MAC address found.' + # Read all the pcap files containing DHCP packet information packets = rdpcap(DHCP_SERVER_CAPTURE_FILE) packets.append(rdpcap(STARTUP_CAPTURE_FILE)) @@ -68,50 +120,84 @@ def _connection_single_ip(self): # Extract MAC addresses from DHCP packets mac_addresses = set() - LOGGER.info("Inspecting: " + str(len(packets)) + " packets") + LOGGER.info('Inspecting: ' + str(len(packets)) + ' packets') for packet in packets: # Option[1] = message-type, option 3 = DHCPREQUEST - if DHCP in packet and packet[DHCP].options[0][1] == 3: - mac_address = packet[Ether].src - mac_addresses.add(mac_address.upper()) + if DHCP in packet and packet[DHCP].options[0][1] == 3: + mac_address = packet[Ether].src + mac_addresses.add(mac_address.upper()) # Check if the device mac address is in the list of DHCPREQUESTs result = self._device_mac.upper() in mac_addresses - LOGGER.info("DHCPREQUEST detected from device: " + str(result)) + LOGGER.info('DHCPREQUEST detected from device: ' + str(result)) # Check the unique MAC addresses to see if they match the device for mac_address in mac_addresses: - LOGGER.info("DHCPREQUEST from MAC address: " + mac_address) - result &= self._device_mac.upper() == mac_address + LOGGER.info('DHCPREQUEST from MAC address: ' + mac_address) + result &= self._device_mac.upper() == mac_address return result - def _connection_target_ping(self): - LOGGER.info("Running connection.target_ping") + LOGGER.info('Running connection.target_ping') # If the ipv4 address wasn't resolved yet, try again if self._device_ipv4_addr is None: - self._device_ipv4_addr = self._get_device_ipv4(self) + self._device_ipv4_addr = self._get_device_ipv4(self) if self._device_ipv4_addr is None: - LOGGER.error("No device IP could be resolved") + LOGGER.error('No device IP could be resolved') sys.exit(1) else: return self._ping(self._device_ipv4_addr) - def _get_oui_manufacturer(self,mac_address): + def _get_oui_manufacturer(self, mac_address): # Do some quick fixes on the format of the mac_address # to match the oui file pattern - mac_address = mac_address.replace(":","-").upper() - with open(OUI_FILE, "r") as file: - for line in file: - if mac_address.startswith(line[:8]): - start = line.index("(hex)") + len("(hex)") - return line[start:].strip() # Extract the company name + mac_address = mac_address.replace(':', '-').upper() + with open(OUI_FILE, 'r', encoding='UTF-8') as file: + for line in file: + if mac_address.startswith(line[:8]): + start = line.index('(hex)') + len('(hex)') + return line[start:].strip() # Extract the company name return None + def _connection_ipv6_slaac(self): + LOGGER.info("Running connection.ipv6_slaac") + packet_capture = rdpcap(MONITOR_CAPTURE_FILE) + + sends_ipv6 = False + + for packet in packet_capture: + if IPv6 in packet and packet.src == self._device_mac: + sends_ipv6 = True + 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}") + return True + + if sends_ipv6: + LOGGER.info("Device does not support IPv6 SLAAC") + else: + LOGGER.info("Device does not support IPv6") + return False + + def _connection_ipv6_ping(self): + LOGGER.info("Running connection.ipv6_ping") + + if self._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}") + return True + else: + LOGGER.info("Device does not respond to IPv6 ping") + return False + def _ping(self, host): - cmd = 'ping -c 1 ' + str(host) + cmd = "ping -c 1 " + str(host) success = util.run_command(cmd, output=False) return success - \ No newline at end of file diff --git a/modules/test/nmap/nmap.Dockerfile b/modules/test/nmap/nmap.Dockerfile index 1789da382..ea90ee06f 100644 --- a/modules/test/nmap/nmap.Dockerfile +++ b/modules/test/nmap/nmap.Dockerfile @@ -18,10 +18,10 @@ FROM test-run/base-test:latest ARG MODULE_NAME=nmap ARG MODULE_DIR=modules/test/$MODULE_NAME -#Load the requirements file +# Load the requirements file COPY $MODULE_DIR/python/requirements.txt /testrun/python -#Install all python requirements for the module +# Install all python requirements for the module RUN pip3 install -r /testrun/python/requirements.txt # Copy over all configuration files diff --git a/modules/test/ntp/bin/start_test_module b/modules/test/ntp/bin/start_test_module new file mode 100644 index 000000000..a09349cf9 --- /dev/null +++ b/modules/test/ntp/bin/start_test_module @@ -0,0 +1,42 @@ +#!/bin/bash + +# An example startup script that does the bare minimum to start +# a test module via a pyhon script. Each test module should include a +# start_test_module file that overwrites this one to boot all of its +# specific requirements to run. + +# 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 scrip 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/ntp/conf/module_config.json b/modules/test/ntp/conf/module_config.json new file mode 100644 index 000000000..288474868 --- /dev/null +++ b/modules/test/ntp/conf/module_config.json @@ -0,0 +1,27 @@ +{ + "config": { + "meta": { + "name": "ntp", + "display_name": "NTP", + "description": "NTP test" + }, + "network": false, + "docker": { + "depends_on": "base", + "enable_container": true, + "timeout": 30 + }, + "tests":[ + { + "name": "ntp.network.ntp_support", + "description": "Does the device request network time sync as client as per RFC 5905 - Network Time Protocol Version 4: Protocol and Algorithms Specification", + "expected_behavior": "The device sends an NTPv4 request to the configured NTP server." + }, + { + "name": "ntp.network.ntp_dhcp", + "description": "Accept NTP address over DHCP", + "expected_behavior": "Device can accept NTP server address, provided by the DHCP server (DHCP OFFER PACKET)" + } + ] + } +} diff --git a/modules/test/ntp/ntp.Dockerfile b/modules/test/ntp/ntp.Dockerfile new file mode 100644 index 000000000..33b06287e --- /dev/null +++ b/modules/test/ntp/ntp.Dockerfile @@ -0,0 +1,20 @@ +# Image name: test-run/ntp-test +FROM test-run/base-test:latest + +ARG MODULE_NAME=ntp +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/ntp/python/requirements.txt b/modules/test/ntp/python/requirements.txt new file mode 100644 index 000000000..93b351f44 --- /dev/null +++ b/modules/test/ntp/python/requirements.txt @@ -0,0 +1 @@ +scapy \ No newline at end of file diff --git a/modules/test/ntp/python/src/ntp_module.py b/modules/test/ntp/python/src/ntp_module.py new file mode 100644 index 000000000..4053ce98a --- /dev/null +++ b/modules/test/ntp/python/src/ntp_module.py @@ -0,0 +1,79 @@ +# 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. + +"""NTP test module""" +from test_module import TestModule +from scapy.all import rdpcap, NTP, IP + +LOG_NAME = 'test_ntp' +NTP_SERVER_CAPTURE_FILE = '/runtime/network/ntp.pcap' +STARTUP_CAPTURE_FILE = '/runtime/device/startup.pcap' +MONITOR_CAPTURE_FILE = '/runtime/device/monitor.pcap' +LOGGER = None + +class NTPModule(TestModule): + """NTP Test module""" + + def __init__(self, module): + super().__init__(module_name=module, log_name=LOG_NAME) + # TODO: This should be fetched dynamically + self._ntp_server = '10.10.10.5' + + global LOGGER + LOGGER = self._get_logger() + + def _ntp_network_ntp_support(self): + LOGGER.info('Running ntp.network.ntp_support') + + packet_capture = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + + device_sends_ntp4 = False + device_sends_ntp3 = False + + for packet in packet_capture: + + if NTP in packet and packet.src == self._device_mac: + if packet[NTP].version == 4: + device_sends_ntp4 = True + LOGGER.info(f'Device sent NTPv4 request to {packet[IP].dst}') + elif packet[NTP].version == 3: + device_sends_ntp3 = True + LOGGER.info(f'Device sent NTPv3 request to {packet[IP].dst}') + + if not (device_sends_ntp3 or device_sends_ntp4): + LOGGER.info('Device has not sent any NTP requests') + + return device_sends_ntp4 and not device_sends_ntp3 + + def _ntp_network_ntp_dhcp(self): + LOGGER.info('Running ntp.network.ntp_dhcp') + + packet_capture = rdpcap(STARTUP_CAPTURE_FILE) + rdpcap(MONITOR_CAPTURE_FILE) + + device_sends_ntp = False + + for packet in packet_capture: + + if NTP in packet and packet.src == self._device_mac: + device_sends_ntp = True + if packet[IP].dst == self._ntp_server: + LOGGER.info('Device sent NTP request to DHCP provided NTP server') + return True + + if not device_sends_ntp: + LOGGER.info('Device has not sent any NTP requests') + else: + LOGGER.info('Device has not sent NTP requests to DHCP provided NTP server') + + return False diff --git a/modules/test/ntp/python/src/run.py b/modules/test/ntp/python/src/run.py new file mode 100644 index 000000000..685bb4083 --- /dev/null +++ b/modules/test/ntp/python/src/run.py @@ -0,0 +1,75 @@ +# 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 NTP test module""" +import argparse +import signal +import sys +import logger + +from ntp_module import NTPModule + +LOG_NAME = "ntp_runner" +LOGGER = logger.get_logger(LOG_NAME) + + +class NTPModuleRunner: + """Run the NTP module tests.""" + 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) + self.add_logger(module) + + LOGGER.info("Starting NTP test module") + + self._test_module = NTPModule(module) + self._test_module.run_tests() + + LOGGER.info("NTP test module finished") + + def add_logger(self, module): + global LOGGER + LOGGER = logger.get_logger(LOG_NAME, module) + + 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="NTP 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 + NTPModuleRunner(args.module.strip()) + + +if __name__ == "__main__": + run() diff --git a/resources/devices/template/device_config.json b/resources/devices/template/device_config.json index 3bb804b22..1e92de25d 100644 --- a/resources/devices/template/device_config.json +++ b/resources/devices/template/device_config.json @@ -14,6 +14,35 @@ } } }, + "connection": { + "enabled": true, + "tests": { + "connection.mac_address": { + "enabled": true + }, + "connection.mac_oui": { + "enabled": true + }, + "connection.target_ping": { + "enabled": true + } + , + "connection.single_ip": { + "enabled": true + } + } + }, + "ntp": { + "enabled": true, + "tests": { + "ntp.network.ntp_support": { + "enabled": true + }, + "ntp.network.ntp_dhcp": { + "enabled": true + } + } + }, "baseline": { "enabled": false, "tests": { diff --git a/testing/test_baseline b/testing/test_baseline index ac47a5cfa..f12d124de 100755 --- a/testing/test_baseline +++ b/testing/test_baseline @@ -82,4 +82,4 @@ more $TESTRUN_OUT pytest testing/ -exit $? +exit $? \ No newline at end of file diff --git a/testing/test_pylint b/testing/test_pylint index 5cd1dff73..2ba696af5 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=1100 +ERROR_LIMIT=100 sudo cmd/install diff --git a/testing/unit_test/run_tests.sh b/testing/unit_test/run_tests.sh new file mode 100644 index 000000000..5b1ed6257 --- /dev/null +++ b/testing/unit_test/run_tests.sh @@ -0,0 +1,18 @@ +#!/bin/bash -e + +# This script should be run from within the unit_test directory. If +# it is run outside this directory, paths will not be resolved correctly. + +# Move into the root directory of test-run +pushd ../../ >/dev/null 2>&1 + +echo "Root Dir: $PWD" + +# Setup the python path +export PYTHONPATH="$PWD/framework/python/src" + +# Run the DHCP Unit tests +python3 -u $PWD/modules/network/dhcp-1/python/src/grpc_server/dhcp_config_test.py +python3 -u $PWD/modules/network/dhcp-2/python/src/grpc_server/dhcp_config_test.py + +popd >/dev/null 2>&1 \ No newline at end of file