diff --git a/CHANGELOG.md b/CHANGELOG.md index ccaa411a..541a39d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ SessionManager. - FTP Services: `FTPClient` and `FTPServer` - HTTP Services: `WebBrowser` to simulate a web client and `WebServer` - Fixed an issue where the services were still able to run even though the node the service is installed on is turned off +- NTP Services: `NTPClient` and `NTPServer` ### Removed - Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` diff --git a/docs/source/simulation_components/system/ntp_client_server.rst b/docs/source/simulation_components/system/ntp_client_server.rst new file mode 100644 index 00000000..671126fb --- /dev/null +++ b/docs/source/simulation_components/system/ntp_client_server.rst @@ -0,0 +1,54 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +NTP Client Server +================= + +NTP Server +---------- +The ``NTPServer`` provides a NTP Server simulation by extending the base Service class. + +NTP Client +---------- +The ``NTPClient`` provides a NTP Client simulation by extending the base Service class. + +Key capabilities +^^^^^^^^^^^^^^^^ + +- Simulates NTP requests and NTPPacket transfer across a network +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +^^^^^ +- Install on a Node via the ``SoftwareManager`` to start the database service. +- Service runs on TCP port 123 by default. + +Implementation +^^^^^^^^^^^^^^ + +- NTP request and responses use a ``NTPPacket`` object +- Extends Service class for integration with ``SoftwareManager``. + +NTP Client +---------- + +The NTPClient provides a client interface for connecting to the ``NTPServer``. + +Key features +^^^^^^^^^^^^ + +- Connects to the ``NTPServer`` via the ``SoftwareManager``. + +Usage +^^^^^ + +- Install on a Node via the ``SoftwareManager`` to start the database service. +- Service runs on TCP port 123 by default. + +Implementation +^^^^^^^^^^^^^^ + +- Leverages ``SoftwareManager`` for sending payloads over the network. +- Provides easy interface for Nodes to find IP addresses via domain names. +- Extends base Service class. diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 8eed3ba4..893b12b4 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -601,7 +601,7 @@ class ActionManager: max_nics_per_node: int = 8, # allows calculating shape max_acl_rules: int = 10, # allows calculating shape protocols: List[str] = ["TCP", "UDP", "ICMP"], # allow mapping index to protocol - ports: List[str] = ["HTTP", "DNS", "ARP", "FTP"], # allow mapping index to port + ports: List[str] = ["HTTP", "DNS", "ARP", "FTP", "NTP"], # allow mapping index to port ip_address_list: Optional[List[str]] = None, # to allow us to map an index to an ip address. act_map: Optional[Dict[int, Dict]] = None, # allows restricting set of possible actions ) -> None: diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index b6b815f1..f8be6727 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -27,6 +27,8 @@ from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.ftp.ftp_client import FTPClient from primaite.simulator.system.services.ftp.ftp_server import FTPServer +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer from primaite.simulator.system.services.web_server.web_server import WebServer _LOGGER = getLogger(__name__) @@ -266,6 +268,8 @@ class PrimaiteGame: "WebServer": WebServer, "FTPClient": FTPClient, "FTPServer": FTPServer, + "NTPClient": NTPClient, + "NTPServer": NTPServer, } if service_type in service_types_mapping: _LOGGER.debug(f"installing {service_type} on node {new_node.hostname}") diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py new file mode 100644 index 00000000..55353265 --- /dev/null +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + +from primaite.simulator.network.protocols.packet import DataPacket + + +class NTPReply(BaseModel): + """Represents a NTP Reply packet.""" + + ntp_datetime: datetime + "NTP datetime object set by NTP Server." + + +class NTPPacket(DataPacket): + """ + Represents the NTP layer of a network frame. + + :param ntp_request: NTPRequest packet from NTP client. + :param ntp_reply: NTPReply packet from NTP Server. + """ + + ntp_reply: Optional[NTPReply] = None + + def generate_reply(self, ntp_server_time: datetime) -> NTPPacket: + """Generate a NTPPacket containing the time in a NTPReply object. + + :param time: datetime object representing the time from the NTP server. + :return: A new NTPPacket object. + """ + self.ntp_reply = NTPReply(ntp_datetime=ntp_server_time) + return self diff --git a/src/primaite/simulator/system/services/ntp/__init__.py b/src/primaite/simulator/system/services/ntp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py new file mode 100644 index 00000000..e8c3d0cb --- /dev/null +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -0,0 +1,132 @@ +from datetime import datetime +from ipaddress import IPv4Address +from typing import Dict, Optional + +from primaite import getLogger +from primaite.simulator.network.protocols.ntp import NTPPacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service, ServiceOperatingState + +_LOGGER = getLogger(__name__) + + +class NTPClient(Service): + """Represents a NTP client as a service.""" + + ntp_server: Optional[IPv4Address] = None + "The NTP server the client sends requests to." + time: Optional[datetime] = None + + def __init__(self, **kwargs): + kwargs["name"] = "NTPClient" + kwargs["port"] = Port.NTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + self.start() + + def configure(self, ntp_server_ip_address: IPv4Address) -> None: + """ + Set the IP address for the NTP server. + + :param ntp_server_ip_address: IPv4 address of NTP server. + :param ntp_client_ip_Address: IPv4 address of NTP client. + """ + self.ntp_server = ntp_server_ip_address + self.sys_log.info(f"{self.name}: ntp_server: {self.ntp_server}") + + def describe_state(self) -> Dict: + """ + Describes the current state of the software. + + The specifics of the software's state, including its health, criticality, + and any other pertinent information, should be implemented in subclasses. + + :return: A dictionary containing key-value pairs representing the current state + of the software. + :rtype: Dict + """ + state = super().describe_state() + return state + + def reset_component_for_episode(self, episode: int): + """ + Resets the Service component for a new episode. + + This method ensures the Service is ready for a new episode, including resetting any + stateful properties or statistics, and clearing any message queues. + """ + pass + + def send( + self, + payload: NTPPacket, + session_id: Optional[str] = None, + dest_ip_address: IPv4Address = None, + dest_port: [Port] = Port.NTP, + **kwargs, + ) -> bool: + """Requests NTP data from NTP server. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. + + :return: True if successful, False otherwise. + """ + return super().send( + payload=payload, + dest_ip_address=dest_ip_address, + dest_port=dest_port, + session_id=session_id, + **kwargs, + ) + + def receive( + self, + payload: NTPPacket, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """Receives time data from server. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. + :return: True if successful, False otherwise. + """ + if not isinstance(payload, NTPPacket): + _LOGGER.debug(f"{payload} is not a NTPPacket") + return False + if payload.ntp_reply.ntp_datetime: + self.sys_log.info( + f"{self.name}: \ + Received time update from NTP server{payload.ntp_reply.ntp_datetime}" + ) + self.time = payload.ntp_reply.ntp_datetime + return True + + def request_time(self) -> None: + """Send request to ntp_server.""" + ntp_server_packet = NTPPacket() + + self.send(payload=ntp_server_packet, dest_ip_address=self.ntp_server) + + def apply_timestep(self, timestep: int) -> None: + """ + For each timestep request the time from the NTP server. + + In this instance, if any multi-timestep processes are currently + occurring (such as restarting or installation), then they are brought one step closer to + being finished. + + :param timestep: The current timestep number. (Amount of time since simulation episode began) + :type timestep: int + """ + self.sys_log.info(f"{self.name} apply_timestep") + super().apply_timestep(timestep) + if self.operating_state == ServiceOperatingState.RUNNING: + # request time from server + self.request_time() + else: + self.sys_log.debug(f"{self.name} ntp client not running") diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py new file mode 100644 index 00000000..0a66384a --- /dev/null +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -0,0 +1,73 @@ +from datetime import datetime +from typing import Dict, Optional + +from primaite import getLogger +from primaite.simulator.network.protocols.ntp import NTPPacket +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service + +_LOGGER = getLogger(__name__) + + +class NTPServer(Service): + """Represents a NTP server as a service.""" + + def __init__(self, **kwargs): + kwargs["name"] = "NTPServer" + kwargs["port"] = Port.NTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + self.start() + + def describe_state(self) -> Dict: + """ + Describes the current state of the software. + + The specifics of the software's state, including its health, criticality, + and any other pertinent information, should be implemented in subclasses. + + :return: A dictionary containing key-value pairs representing the current + state of the software. + :rtype: Dict + """ + state = super().describe_state() + return state + + def reset_component_for_episode(self, episode: int): + """ + Resets the Service component for a new episode. + + This method ensures the Service is ready for a new episode, including + resetting any stateful properties or statistics, and clearing any message + queues. + """ + pass + + def receive( + self, + payload: NTPPacket, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Receives a request from NTPClient. + + Check that request has a valid IP address. + + :param payload: The payload to send. + :param session_id: Id of the session (Optional). + + :return: True if valid NTP request else False. + """ + if not (isinstance(payload, NTPPacket)): + _LOGGER.debug(f"{payload} is not a NTPPacket") + return False + payload: NTPPacket = payload + + # generate a reply with the current time + time = datetime.now() + payload = payload.generate_reply(time) + # send reply + self.send(payload, session_id) + return True diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py new file mode 100644 index 00000000..b7839479 --- /dev/null +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -0,0 +1,86 @@ +from ipaddress import IPv4Address +from time import sleep +from typing import Tuple + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.server import Server +from primaite.simulator.network.protocols.ntp import NTPPacket +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer +from primaite.simulator.system.services.service import ServiceOperatingState + +# Create simple network for testing +# Define one node to be an NTP server and another node to be a NTP Client. + + +@pytest.fixture(scope="function") +def create_ntp_network(client_server) -> Tuple[NTPClient, Computer, NTPServer, Server]: + """ + +------------+ +------------+ + | ntp | | ntp | + | client_1 +------------+ server_1 | + | | | | + +------------+ +------------+ + + """ + client, server = client_server + + server.power_on() + server.software_manager.install(NTPServer) + ntp_server: NTPServer = server.software_manager.software.get("NTPServer") + ntp_server.start() + + client.power_on() + client.software_manager.install(NTPClient) + ntp_client: NTPClient = client.software_manager.software.get("NTPClient") + ntp_client.start() + + return ntp_client, client, ntp_server, server + + +def test_ntp_client_server(create_ntp_network): + ntp_client, client, ntp_server, server = create_ntp_network + + ntp_server: NTPServer = server.software_manager.software["NTPServer"] + ntp_client: NTPClient = client.software_manager.software["NTPClient"] + + assert ntp_server.operating_state == ServiceOperatingState.RUNNING + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + ntp_client.configure(ntp_server_ip_address=IPv4Address("192.168.0.2")) + + assert ntp_client.time is None + ntp_client.request_time() + assert ntp_client.time is not None + first_time = ntp_client.time + sleep(0.1) + ntp_client.apply_timestep(1) # Check time advances + second_time = ntp_client.time + assert first_time < second_time + + +# Test ntp client behaviour when ntp server is unavailable. +def test_ntp_server_failure(create_ntp_network): + ntp_client, client, ntp_server, server = create_ntp_network + + ntp_server: NTPServer = server.software_manager.software["NTPServer"] + ntp_client: NTPClient = client.software_manager.software["NTPClient"] + + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + ntp_client.configure(ntp_server_ip_address=IPv4Address("192.168.0.2")) + + # Turn off ntp server. + ntp_server.stop() + assert ntp_server.operating_state == ServiceOperatingState.STOPPED + # And request a time update. + ntp_client.request_time() + assert ntp_client.time is None + + # Restart ntp server. + ntp_server.start() + assert ntp_server.operating_state == ServiceOperatingState.RUNNING + ntp_client.request_time() + assert ntp_client.time is not None