Merged PR 233: NTP server and client
## Summary The NTP Server and Client are created as two separate classes NTPServer and NTPClient which define the methods used to send and receive time data. A set of classes used in the sending and receiving of requests and data is in ntp.py. A single file, test_ntp_client_server.py is used to run the unit tests. The changes have no impact on other parts of the codebase. ## Test process Created simple two node network representing NTP server and client. Created NTP server and client objects and client requests and receives date/time from server. ## Checklist - [Y] PR is linked to a **work item** - [Y] **acceptance criteria** of linked ticket are met - [Y] performed **self-review** of the code - [Y] written **tests** for any new functionality added with this PR - [Y] updated the **documentation** if this PR changes or adds functionality - [N] written/updated **design docs** if this PR implements new functionality - [Y] updated the **change log** - [Y] ran **pre-commit** checks for code style - [N] attended to any **TO-DOs** left in the code Related work items: #2041, #2042
This commit is contained in:
@@ -37,6 +37,7 @@ SessionManager.
|
|||||||
- FTP Services: `FTPClient` and `FTPServer`
|
- FTP Services: `FTPClient` and `FTPServer`
|
||||||
- HTTP Services: `WebBrowser` to simulate a web client and `WebServer`
|
- 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
|
- 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
|
||||||
- Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol`
|
- Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -601,7 +601,7 @@ class ActionManager:
|
|||||||
max_nics_per_node: int = 8, # allows calculating shape
|
max_nics_per_node: int = 8, # allows calculating shape
|
||||||
max_acl_rules: int = 10, # allows calculating shape
|
max_acl_rules: int = 10, # allows calculating shape
|
||||||
protocols: List[str] = ["TCP", "UDP", "ICMP"], # allow mapping index to protocol
|
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.
|
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
|
act_map: Optional[Dict[int, Dict]] = None, # allows restricting set of possible actions
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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.dns.dns_server import DNSServer
|
||||||
from primaite.simulator.system.services.ftp.ftp_client import FTPClient
|
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.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
|
from primaite.simulator.system.services.web_server.web_server import WebServer
|
||||||
|
|
||||||
_LOGGER = getLogger(__name__)
|
_LOGGER = getLogger(__name__)
|
||||||
@@ -266,6 +268,8 @@ class PrimaiteGame:
|
|||||||
"WebServer": WebServer,
|
"WebServer": WebServer,
|
||||||
"FTPClient": FTPClient,
|
"FTPClient": FTPClient,
|
||||||
"FTPServer": FTPServer,
|
"FTPServer": FTPServer,
|
||||||
|
"NTPClient": NTPClient,
|
||||||
|
"NTPServer": NTPServer,
|
||||||
}
|
}
|
||||||
if service_type in service_types_mapping:
|
if service_type in service_types_mapping:
|
||||||
_LOGGER.debug(f"installing {service_type} on node {new_node.hostname}")
|
_LOGGER.debug(f"installing {service_type} on node {new_node.hostname}")
|
||||||
|
|||||||
35
src/primaite/simulator/network/protocols/ntp.py
Normal file
35
src/primaite/simulator/network/protocols/ntp.py
Normal file
@@ -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
|
||||||
132
src/primaite/simulator/system/services/ntp/ntp_client.py
Normal file
132
src/primaite/simulator/system/services/ntp/ntp_client.py
Normal file
@@ -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")
|
||||||
73
src/primaite/simulator/system/services/ntp/ntp_server.py
Normal file
73
src/primaite/simulator/system/services/ntp/ntp_server.py
Normal file
@@ -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
|
||||||
86
tests/integration_tests/system/test_ntp_client_server.py
Normal file
86
tests/integration_tests/system/test_ntp_client_server.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user