From 2ee820339779de50eb69e97c1e11f8261eaa3359 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 13 Nov 2023 17:39:27 +0000 Subject: [PATCH 01/54] #2041: NTP server initial commits --- .../simulator/network/protocols/ntp.py | 34 +++++++++++ .../simulator/system/services/ntp/__init__.py | 0 .../system/services/ntp/ntp_server.py | 56 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/primaite/simulator/network/protocols/ntp.py create mode 100644 src/primaite/simulator/system/services/ntp/__init__.py create mode 100644 src/primaite/simulator/system/services/ntp/ntp_server.py diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py new file mode 100644 index 00000000..f14dab73 --- /dev/null +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Optional + +from pydantic import BaseModel + +from primaite.simulator.network.protocols.packet import DataPacket + + +class NTPRequest(BaseModel): + """Represents a NTP Request packet.""" + + pass + + +class NTPReply(BaseModel): + """Represents a NTP Reply packet.""" + + pass + + +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_request: NTPRequest + "NTP Request packet sent by NTP Client." + ntp_reply: Optional[NTPReply] = None + "NTP Reply packet generated by NTP Server." 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_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py new file mode 100644 index 00000000..914dd1c3 --- /dev/null +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -0,0 +1,56 @@ +from ipaddress import IPv4Address +from typing import Any, Dict, Optional + +from primaite import getLogger +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.UDP + 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: Any, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """Receives a request from NTPClient""" + pass + + def send(self): + """Sends time data to NTPClient""" + pass From 764d9561bd3ef45520b9bd6ec50614d244e00e78 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 13 Nov 2023 17:40:25 +0000 Subject: [PATCH 02/54] #2042: NTP client initial commit --- .../system/services/ntp/ntp_client.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/primaite/simulator/system/services/ntp/ntp_client.py 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..edae9af6 --- /dev/null +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -0,0 +1,50 @@ +from ipaddress import IPv4Address +from typing import Dict, Optional +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 + + +from primaite import getLogger + +_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." + + def __init__(self, **kwargs): + kwargs["name"] = "NTPClient" + kwargs["port"] = Port.NTP + kwargs["protocol"] = IPProtocol.UDP + 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): + """Receives time data from server""" + pass From f32048712880d3951404656c10bdb1c86fd55a47 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 14 Nov 2023 15:13:05 +0000 Subject: [PATCH 03/54] #2041: Implement NTP protocol for server --- .../simulator/network/protocols/ntp.py | 18 +++++++--- .../system/services/ntp/ntp_server.py | 36 ++++++++++++++++--- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py index f14dab73..89a26961 100644 --- a/src/primaite/simulator/network/protocols/ntp.py +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -2,22 +2,22 @@ from __future__ import annotations from ipaddress import IPv4Address from typing import Optional - from pydantic import BaseModel - from primaite.simulator.network.protocols.packet import DataPacket +from datetime import datetime class NTPRequest(BaseModel): """Represents a NTP Request packet.""" - pass + ntp_client: IPv4Address = None class NTPReply(BaseModel): """Represents a NTP Reply packet.""" - pass + ntp_datetime: datetime + "NTP datetime object set by NTP Server." class NTPPacket(DataPacket): @@ -31,4 +31,12 @@ class NTPPacket(DataPacket): ntp_request: NTPRequest "NTP Request packet sent by NTP Client." ntp_reply: Optional[NTPReply] = None - "NTP Reply packet generated by NTP Server." + + def generate_reply(self, 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(time) + return self diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index 914dd1c3..50a582a4 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -2,12 +2,15 @@ from ipaddress import IPv4Address from typing import Any, 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 +from datetime import datetime _LOGGER = getLogger(__name__) + class NTPServer(Service): """Represents a NTP server as a service""" @@ -48,9 +51,32 @@ class NTPServer(Service): session_id: Optional[str] = None, **kwargs, ) -> bool: - """Receives a request from NTPClient""" - pass + """Receives a request from NTPClient. - def send(self): - """Sends time data to NTPClient""" - pass + 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) and + payload.ntp_request.ntp_client): + _LOGGER.debug(f"{payload} is not a NTPPacket") + return False + payload: NTPPacket = payload + if payload.ntp_request.ntp_client: + self.sys_log.info( + f"{self.name}: Received request for {payload.ntp_request.ntp_client} " + f"from session {session_id}" + ) + # generate a reply with the current time + time = datetime.now() + payload = payload.generate_reply(time) + self.sys_log.info( + f"{self.name}: Responding to NTP request for {payload.ntp_request.ntp_client} " + f"with current time: {time}" + ) + # send reply + self.send(payload, session_id) + return True From 195e8a4e84507eb30125b57160fb7b7038648260 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 14 Nov 2023 15:13:28 +0000 Subject: [PATCH 04/54] #2042: Implement NTP protocol for client --- .../system/services/ntp/ntp_client.py | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index edae9af6..d1fe7bbf 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -1,5 +1,6 @@ from ipaddress import IPv4Address from typing import Dict, Optional +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 @@ -45,6 +46,48 @@ class NTPClient(Service): """ pass - def receive(self): - """Receives time data from server""" - pass + def send( + self, + payload: NTPPacket, + session_id: Optional[str] = None, + dest_ip_address: Optional[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. + """ + self.sys_log.info(f"{self.name}: Sending NTP request {payload.ntp_request.ntp_client}") + + 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, + ): + """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) and + payload.ntp_request.ntp_client): + _LOGGER.debug(f"{payload} is not a NTPPacket") + return False + + # XXX: compare received datetime with current time. Log error if differ by more than x ms? + if payload.ntp_reply.ntp_datetime: + self.sys_log.info( + f"{self.name}: Received time update from NTP server{payload.ntp_reply.ntp_datetime}") + return True From d31fce202cbada0504b37fa30581578e80da6125 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 15 Nov 2023 10:57:56 +0000 Subject: [PATCH 05/54] #2041: pre-commit changes. --- .../simulator/network/protocols/ntp.py | 6 ++++-- .../system/services/ntp/ntp_server.py | 19 ++++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py index 89a26961..286c5664 100644 --- a/src/primaite/simulator/network/protocols/ntp.py +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -1,10 +1,12 @@ from __future__ import annotations +from datetime import datetime from ipaddress import IPv4Address from typing import Optional + from pydantic import BaseModel + from primaite.simulator.network.protocols.packet import DataPacket -from datetime import datetime class NTPRequest(BaseModel): @@ -33,7 +35,7 @@ class NTPPacket(DataPacket): ntp_reply: Optional[NTPReply] = None def generate_reply(self, time: datetime) -> NTPPacket: - """ Generate a NTPPacket containing the time in a NTPReply object + """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. diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index 50a582a4..d4be6924 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -1,4 +1,4 @@ -from ipaddress import IPv4Address +from datetime import datetime from typing import Any, Dict, Optional from primaite import getLogger @@ -6,13 +6,12 @@ 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 -from datetime import datetime _LOGGER = getLogger(__name__) class NTPServer(Service): - """Represents a NTP server as a service""" + """Represents a NTP server as a service.""" def __init__(self, **kwargs): kwargs["name"] = "NTPServer" @@ -46,10 +45,10 @@ class NTPServer(Service): pass def receive( - self, - payload: Any, - session_id: Optional[str] = None, - **kwargs, + self, + payload: Any, + session_id: Optional[str] = None, + **kwargs, ) -> bool: """Receives a request from NTPClient. @@ -60,15 +59,13 @@ class NTPServer(Service): :return: True if valid NTP request else False. """ - if not (isinstance(payload, NTPPacket) and - payload.ntp_request.ntp_client): + if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): _LOGGER.debug(f"{payload} is not a NTPPacket") return False payload: NTPPacket = payload if payload.ntp_request.ntp_client: self.sys_log.info( - f"{self.name}: Received request for {payload.ntp_request.ntp_client} " - f"from session {session_id}" + f"{self.name}: Received request for {payload.ntp_request.ntp_client} " f"from session {session_id}" ) # generate a reply with the current time time = datetime.now() From 9deb130d10bc699931bcdae141075d5d68cf2d00 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 15 Nov 2023 10:58:24 +0000 Subject: [PATCH 06/54] #2042: pre-commit changes --- .../system/services/ntp/ntp_client.py | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index d1fe7bbf..0e3646ae 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -1,18 +1,17 @@ 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 - -from primaite import getLogger - _LOGGER = getLogger(__name__) class NTPClient(Service): - """Represents a NTP client as a service""" + """Represents a NTP client as a service.""" ntp_server: Optional[IPv4Address] = None "The NTP server the client sends requests to." @@ -47,12 +46,12 @@ class NTPClient(Service): pass def send( - self, - payload: NTPPacket, - session_id: Optional[str] = None, - dest_ip_address: Optional[IPv4Address] = None, - dest_port: [Port] = Port.NTP, - **kwargs, + self, + payload: NTPPacket, + session_id: Optional[str] = None, + dest_ip_address: IPv4Address = ntp_server, + dest_port: [Port] = Port.NTP, + **kwargs, ) -> bool: """Requests NTP data from NTP server. @@ -74,20 +73,18 @@ class NTPClient(Service): payload: NTPPacket, session_id: Optional[str] = None, **kwargs, - ): - """Receives time data from server + ) -> 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) and - payload.ntp_request.ntp_client): + if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): _LOGGER.debug(f"{payload} is not a NTPPacket") return False # XXX: compare received datetime with current time. Log error if differ by more than x ms? 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.sys_log.info(f"{self.name}: Received time update from NTP server{payload.ntp_reply.ntp_datetime}") return True From 0c544a9a263d7fb05d1ab16bc1647946043ea2f3 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 15 Nov 2023 15:58:10 +0000 Subject: [PATCH 07/54] #2042: Add support for apply_timestep() --- .../simulator/network/protocols/ntp.py | 2 +- .../system/services/ntp/ntp_client.py | 36 ++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py index 286c5664..e201a770 100644 --- a/src/primaite/simulator/network/protocols/ntp.py +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -12,7 +12,7 @@ from primaite.simulator.network.protocols.packet import DataPacket class NTPRequest(BaseModel): """Represents a NTP Request packet.""" - ntp_client: IPv4Address = None + ntp_client: Optional[IPv4Address] = None class NTPReply(BaseModel): diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 0e3646ae..e305970a 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -2,10 +2,10 @@ 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.protocols.ntp import NTPPacket, NTPRequest 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 +from primaite.simulator.system.services.service import Service, ServiceOperatingState _LOGGER = getLogger(__name__) @@ -13,6 +13,7 @@ _LOGGER = getLogger(__name__) class NTPClient(Service): """Represents a NTP client as a service.""" + ip_addr: Optional[IPv4Address] = None ntp_server: Optional[IPv4Address] = None "The NTP server the client sends requests to." @@ -30,7 +31,8 @@ class NTPClient(Service): 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. + :return: A dictionary containing key-value pairs representing the current state + of the software. :rtype: Dict """ state = super().describe_state() @@ -65,7 +67,11 @@ class NTPClient(Service): self.sys_log.info(f"{self.name}: Sending NTP request {payload.ntp_request.ntp_client}") return super().send( - payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + payload=payload, + dest_ip_address=dest_ip_address, + dest_port=dest_port, + session_id=session_id, + **kwargs, ) def receive( @@ -86,5 +92,25 @@ class NTPClient(Service): # XXX: compare received datetime with current time. Log error if differ by more than x ms? 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.sys_log.info( + f"{self.name}: Received time \ + update from NTP server{payload.ntp_reply.ntp_datetime}" + ) return True + + def apply_timestep(self, timestep: int) -> None: + """ + For each timestep request the time tfrom 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 + """ + super().apply_timestep(timestep) + if self.operating_state == ServiceOperatingState.RUNNING: + # request time from server + ntp_request = NTPPacket(NTPRequest()) + self.send(ntp_request) From 7ee2c4220a6eb80e0ed00e9a1000ab32df19aa60 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Thu, 16 Nov 2023 15:04:01 +0000 Subject: [PATCH 08/54] #2042: ntp_client test --- .../system/services/ntp/ntp_client.py | 6 ++++- .../system/test_ntp_client_server.py | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 tests/integration_tests/system/test_ntp_client_server.py diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index e305970a..123de7cc 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -100,7 +100,7 @@ class NTPClient(Service): def apply_timestep(self, timestep: int) -> None: """ - For each timestep request the time tfrom the NTP server. + 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 @@ -114,3 +114,7 @@ class NTPClient(Service): # request time from server ntp_request = NTPPacket(NTPRequest()) self.send(ntp_request) + return True + else: + self.sys_log.debug(f"{self.name} ntp client not running") + return False 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..5d301d2b --- /dev/null +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -0,0 +1,25 @@ +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 + +# Define one node to be an NTP server and another node to be a NTP Client. + + +def test_ntp_client_server(network): + server: Server = network.get_node_by_hostname("ntp_server") + client: Computer = network.get_node_by_hostname("ntp_client") + + ntp_server: NTPServer = server.software_manager.software["NTP_Server"] + ntp_client: NTPClient = client.software_manager.software["NTP_Client"] + + assert ntp_server.operating_state == ServiceOperatingState.RUNNING + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + + ntp_client.send(payload=NTPPacket()) + assert ntp_server.receive() is True + assert ntp_client.receive() is True + + assert ntp_client.apply_timestep(1) is True From 622c6931d8df352f7fd96b0a1ea58ec3323db98e Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 17 Nov 2023 12:07:46 +0000 Subject: [PATCH 09/54] #2041: Create test network + extra test --- .../system/test_ntp_client_server.py | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 5d301d2b..61c8740b 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -1,3 +1,8 @@ +from ipaddress import IPv4Address + +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 @@ -5,10 +10,41 @@ 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 + + +def create_ntp_network() -> Network: + """ + +------------+ +------------+ + | ntp | | ntp | + | client_1 +------------+ server_1 | + | | | | + +------------+ +------------+ + + """ + + network = Network() + ntp_server = Server( + hostname="ntp_server", ip_address="192.168.1.2", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + ) + ntp_server.power_on() + + ntp_client = Computer( + hostname="ntp_client", ip_address="192.168.1.3", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + ) + ntp_client.power_on() + network.connect(endpoint_b=ntp_server.ethernet_port[1], endpoint_a=ntp_client.ethernet_port[1]) + + +# @pytest.fixture() +# def create_network(): +# return create_ntp_network() + # Define one node to be an NTP server and another node to be a NTP Client. -def test_ntp_client_server(network): +def test_ntp_client_server(): + network = create_ntp_network() server: Server = network.get_node_by_hostname("ntp_server") client: Computer = network.get_node_by_hostname("ntp_client") @@ -23,3 +59,24 @@ def test_ntp_client_server(network): assert ntp_client.receive() is True assert ntp_client.apply_timestep(1) is True + + +# Test ntp client behaviour when ntp server is unavailable. +def test_ntp_server_failure(): + network = create_ntp_network() + server: Server = network.get_node_by_hostname("ntp_server") + client: Computer = network.get_node_by_hostname("ntp_client") + + ntp_server: NTPServer = server.software_manager.software["NTP_Server"] + ntp_client: NTPClient = client.software_manager.software["NTP_Client"] + + assert ntp_client.operating_state == ServiceOperatingState.RUNNING + + # Turn off ntp server. + ntp_server.stop() + assert ntp_server.operating_state == ServiceOperatingState.STOPPED + assert ntp_client.receive() is False + + # Restart ntp server. + ntp_server.start() + assert ntp_server.operating_state == ServiceOperatingState.RUNNING From 6081f02caa68982a0c19dbee235ff91bfcea5ff6 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 17 Nov 2023 12:41:53 +0000 Subject: [PATCH 10/54] #2041: Add missing return statement --- tests/integration_tests/system/test_ntp_client_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 61c8740b..0e3567ae 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -35,6 +35,8 @@ def create_ntp_network() -> Network: ntp_client.power_on() network.connect(endpoint_b=ntp_server.ethernet_port[1], endpoint_a=ntp_client.ethernet_port[1]) + return network + # @pytest.fixture() # def create_network(): From dbecc681dc47f916f5c181fa72d1b95e5852b2cc Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 17 Nov 2023 12:59:06 +0000 Subject: [PATCH 11/54] #2041: Install NTPServer and NTPClient. --- tests/integration_tests/system/test_ntp_client_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 0e3567ae..dc487164 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -28,11 +28,13 @@ def create_ntp_network() -> Network: hostname="ntp_server", ip_address="192.168.1.2", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) ntp_server.power_on() + ntp_server.software_manager.install(NTPServer) ntp_client = Computer( hostname="ntp_client", ip_address="192.168.1.3", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) ntp_client.power_on() + ntp_client.software_manager.install(NTPClient) network.connect(endpoint_b=ntp_server.ethernet_port[1], endpoint_a=ntp_client.ethernet_port[1]) return network From d28bd0d1c36217489c75d0663d3c3c0091776bcd Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 17 Nov 2023 14:19:36 +0000 Subject: [PATCH 12/54] #2041: Fix names --- tests/integration_tests/system/test_ntp_client_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index dc487164..e859faf4 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -52,8 +52,8 @@ def test_ntp_client_server(): server: Server = network.get_node_by_hostname("ntp_server") client: Computer = network.get_node_by_hostname("ntp_client") - ntp_server: NTPServer = server.software_manager.software["NTP_Server"] - ntp_client: NTPClient = client.software_manager.software["NTP_Client"] + 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 From 95ad55a78369f92776e46a95cf5fce421d27f194 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 20 Nov 2023 18:04:49 +0000 Subject: [PATCH 13/54] #2041: change deprecated logger levels. --- src/primaite/simulator/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 9ead877e..5ec816bb 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -113,7 +113,7 @@ class RequestManager(BaseModel): """ if name in self.request_types: msg = f"Overwriting request type {name}." - _LOGGER.warn(msg) + _LOGGER.warning(msg) self.request_types[name] = request_type @@ -248,6 +248,6 @@ class SimComponent(BaseModel): def parent(self, new_parent: Union["SimComponent", None]) -> None: if self._parent and new_parent: msg = f"Overwriting parent of {self.uuid}. Old parent: {self._parent.uuid}, New parent: {new_parent.uuid}" - _LOGGER.warn(msg) + _LOGGER.warning(msg) raise RuntimeWarning(msg) self._parent = new_parent From b0b37f9da5ce255acfa246c6609adcea725763be Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 20 Nov 2023 18:06:50 +0000 Subject: [PATCH 14/54] #2042: ntp_client test fixes. --- src/primaite/simulator/system/services/ntp/ntp_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 123de7cc..3e73eee7 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -89,8 +89,7 @@ class NTPClient(Service): if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): _LOGGER.debug(f"{payload} is not a NTPPacket") return False - - # XXX: compare received datetime with current time. Log error if differ by more than x ms? + print(f">>>>>>>>>>>>>>>>>> payload.ntp_reply.ntp_datetime {payload.ntp_reply.ntp_datetime}") if payload.ntp_reply.ntp_datetime: self.sys_log.info( f"{self.name}: Received time \ @@ -112,8 +111,9 @@ class NTPClient(Service): super().apply_timestep(timestep) if self.operating_state == ServiceOperatingState.RUNNING: # request time from server - ntp_request = NTPPacket(NTPRequest()) - self.send(ntp_request) + ntp_request = NTPRequest(ntp_client=self.ip_addr) + ntp_server_packet = NTPPacket(ntp_request=ntp_request) + self.send(payload=ntp_server_packet) return True else: self.sys_log.debug(f"{self.name} ntp client not running") From f7215847d414f947c921602044b8bc0e25b03a06 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Mon, 20 Nov 2023 18:08:55 +0000 Subject: [PATCH 15/54] #2041: ntp_server test fixes. --- .../simulator/network/protocols/ntp.py | 4 ++-- .../system/services/ntp/ntp_server.py | 8 ++++--- .../system/test_ntp_client_server.py | 21 ++++++++++++------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py index e201a770..df5ce0c1 100644 --- a/src/primaite/simulator/network/protocols/ntp.py +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -34,11 +34,11 @@ class NTPPacket(DataPacket): "NTP Request packet sent by NTP Client." ntp_reply: Optional[NTPReply] = None - def generate_reply(self, time: datetime) -> NTPPacket: + 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(time) + self.ntp_reply = NTPReply(ntp_datetime=ntp_server_time) return self diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index d4be6924..337869a4 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -50,7 +50,8 @@ class NTPServer(Service): session_id: Optional[str] = None, **kwargs, ) -> bool: - """Receives a request from NTPClient. + """ + Receives a request from NTPClient. Check that request has a valid IP address. @@ -75,5 +76,6 @@ class NTPServer(Service): f"with current time: {time}" ) # send reply - self.send(payload, session_id) - return True + if self.send(payload, session_id): + return True + return False diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index e859faf4..545683f3 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -5,7 +5,7 @@ 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.network.protocols.ntp import NTPPacket, NTPRequest 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 @@ -58,9 +58,11 @@ def test_ntp_client_server(): assert ntp_server.operating_state == ServiceOperatingState.RUNNING assert ntp_client.operating_state == ServiceOperatingState.RUNNING - ntp_client.send(payload=NTPPacket()) - assert ntp_server.receive() is True - assert ntp_client.receive() is True + ntp_request = NTPRequest(ntp_client="192.168.1.3") + ntp_packet = NTPPacket(ntp_request=ntp_request) + ntp_client.send(payload=ntp_packet) + assert ntp_server.receive(payload=ntp_packet) is True + assert ntp_client.receive(payload=ntp_packet) is True assert ntp_client.apply_timestep(1) is True @@ -71,15 +73,20 @@ def test_ntp_server_failure(): server: Server = network.get_node_by_hostname("ntp_server") client: Computer = network.get_node_by_hostname("ntp_client") - ntp_server: NTPServer = server.software_manager.software["NTP_Server"] - ntp_client: NTPClient = client.software_manager.software["NTP_Client"] + ntp_server: NTPServer = server.software_manager.software["NTPServer"] + ntp_client: NTPClient = client.software_manager.software["NTPClient"] assert ntp_client.operating_state == ServiceOperatingState.RUNNING # Turn off ntp server. ntp_server.stop() assert ntp_server.operating_state == ServiceOperatingState.STOPPED - assert ntp_client.receive() is False + # And request a time update. + ntp_request = NTPRequest(ntp_client="192.168.1.3") + ntp_packet = NTPPacket(ntp_request=ntp_request) + ntp_client.send(payload=ntp_packet) + assert ntp_server.receive(payload=ntp_packet) is False + assert ntp_client.receive(payload=ntp_packet) is False # Restart ntp server. ntp_server.start() From 813a1f356e9fac0afcd627145150bdb43484deac Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 21 Nov 2023 11:15:07 +0000 Subject: [PATCH 16/54] #2042: Remove debug statement --- src/primaite/simulator/system/services/ntp/ntp_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 3e73eee7..99bc7584 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -89,7 +89,6 @@ class NTPClient(Service): if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): _LOGGER.debug(f"{payload} is not a NTPPacket") return False - print(f">>>>>>>>>>>>>>>>>> payload.ntp_reply.ntp_datetime {payload.ntp_reply.ntp_datetime}") if payload.ntp_reply.ntp_datetime: self.sys_log.info( f"{self.name}: Received time \ From 4f0f758ce9b19387cf79ca989fd023792b1a8b35 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 21 Nov 2023 11:16:34 +0000 Subject: [PATCH 17/54] #2041: Correct return value from receive() --- src/primaite/simulator/system/services/ntp/ntp_server.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index 337869a4..238f4f84 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -76,6 +76,5 @@ class NTPServer(Service): f"with current time: {time}" ) # send reply - if self.send(payload, session_id): - return True - return False + self.send(payload, session_id) + return True From 60d94bf4b56f45c2e60a57ce9b2dce663d37feda Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 21 Nov 2023 11:17:38 +0000 Subject: [PATCH 18/54] #2041: Remove test --- .../system/test_ntp_client_server.py | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 545683f3..8059defa 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -40,10 +40,6 @@ def create_ntp_network() -> Network: return network -# @pytest.fixture() -# def create_network(): -# return create_ntp_network() - # Define one node to be an NTP server and another node to be a NTP Client. @@ -67,27 +63,28 @@ def test_ntp_client_server(): assert ntp_client.apply_timestep(1) is True +# TODO: Disabled until a service such as NTP can introspect to see if it's running. # Test ntp client behaviour when ntp server is unavailable. -def test_ntp_server_failure(): - network = create_ntp_network() - server: Server = network.get_node_by_hostname("ntp_server") - client: Computer = network.get_node_by_hostname("ntp_client") +# def test_ntp_server_failure(): +# network = create_ntp_network() +# server: Server = network.get_node_by_hostname("ntp_server") +# client: Computer = network.get_node_by_hostname("ntp_client") - ntp_server: NTPServer = server.software_manager.software["NTPServer"] - ntp_client: NTPClient = client.software_manager.software["NTPClient"] +# 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 - # Turn off ntp server. - ntp_server.stop() - assert ntp_server.operating_state == ServiceOperatingState.STOPPED - # And request a time update. - ntp_request = NTPRequest(ntp_client="192.168.1.3") - ntp_packet = NTPPacket(ntp_request=ntp_request) - ntp_client.send(payload=ntp_packet) - assert ntp_server.receive(payload=ntp_packet) is False - assert ntp_client.receive(payload=ntp_packet) is False +# # Turn off ntp server. +# ntp_server.stop() +# assert ntp_server.operating_state == ServiceOperatingState.STOPPED +# # And request a time update. +# ntp_request = NTPRequest(ntp_client="192.168.1.3") +# ntp_packet = NTPPacket(ntp_request=ntp_request) +# ntp_client.send(payload=ntp_packet) +# assert ntp_server.receive(payload=ntp_packet) is False +# assert ntp_client.receive(payload=ntp_packet) is False - # Restart ntp server. - ntp_server.start() - assert ntp_server.operating_state == ServiceOperatingState.RUNNING +# # Restart ntp server. +# ntp_server.start() +# assert ntp_server.operating_state == ServiceOperatingState.RUNNING From 243f2dd938e335c048d16f63a292e24876e7a141 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 21 Nov 2023 12:11:30 +0000 Subject: [PATCH 19/54] #2041: Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3af5c14c..a6dd0f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ SessionManager. - DNS Services: `DNSClient` and `DNSServer` - FTP Services: `FTPClient` and `FTPServer` - HTTP Services: `WebBrowser` to simulate a web client and `WebServer` +- NTP Services: `NTPClient` and `NTPServer` ### Removed - Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` From eb2e37429a1ccfb74f998e3a6f907c80e2a4de13 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 21 Nov 2023 17:24:24 +0000 Subject: [PATCH 20/54] #2042: Add time attribute --- .../system/services/ntp/ntp_client.py | 3 ++ .../system/test_ntp_client_server.py | 43 ++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 99bc7584..81ec031c 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -1,3 +1,4 @@ +from datetime import datetime from ipaddress import IPv4Address from typing import Dict, Optional @@ -16,6 +17,7 @@ class NTPClient(Service): ip_addr: Optional[IPv4Address] = None ntp_server: Optional[IPv4Address] = None "The NTP server the client sends requests to." + time: Optional[datetime] = None def __init__(self, **kwargs): kwargs["name"] = "NTPClient" @@ -94,6 +96,7 @@ class NTPClient(Service): f"{self.name}: Received time \ update from NTP server{payload.ntp_reply.ntp_datetime}" ) + self.time = payload.ntp_reply.ntp_datetime return True def apply_timestep(self, timestep: int) -> None: diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 8059defa..48db0cbb 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -53,38 +53,39 @@ def test_ntp_client_server(): assert ntp_server.operating_state == ServiceOperatingState.RUNNING assert ntp_client.operating_state == ServiceOperatingState.RUNNING + assert ntp_client.time is None ntp_request = NTPRequest(ntp_client="192.168.1.3") ntp_packet = NTPPacket(ntp_request=ntp_request) ntp_client.send(payload=ntp_packet) assert ntp_server.receive(payload=ntp_packet) is True assert ntp_client.receive(payload=ntp_packet) is True - + assert ntp_client.time is not None assert ntp_client.apply_timestep(1) is True -# TODO: Disabled until a service such as NTP can introspect to see if it's running. # Test ntp client behaviour when ntp server is unavailable. -# def test_ntp_server_failure(): -# network = create_ntp_network() -# server: Server = network.get_node_by_hostname("ntp_server") -# client: Computer = network.get_node_by_hostname("ntp_client") +@pytest.mark.skip(reason="NTP needs to know if underly node is RUNNING") +def test_ntp_server_failure(): + network = create_ntp_network() + server: Server = network.get_node_by_hostname("ntp_server") + client: Computer = network.get_node_by_hostname("ntp_client") -# ntp_server: NTPServer = server.software_manager.software["NTPServer"] -# ntp_client: NTPClient = client.software_manager.software["NTPClient"] + 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 -# # Turn off ntp server. -# ntp_server.stop() -# assert ntp_server.operating_state == ServiceOperatingState.STOPPED -# # And request a time update. -# ntp_request = NTPRequest(ntp_client="192.168.1.3") -# ntp_packet = NTPPacket(ntp_request=ntp_request) -# ntp_client.send(payload=ntp_packet) -# assert ntp_server.receive(payload=ntp_packet) is False -# assert ntp_client.receive(payload=ntp_packet) is False + # Turn off ntp server. + ntp_server.stop() + assert ntp_server.operating_state == ServiceOperatingState.STOPPED + # And request a time update. + ntp_request = NTPRequest(ntp_client="192.168.1.3") + ntp_packet = NTPPacket(ntp_request=ntp_request) + ntp_client.send(payload=ntp_packet) + assert ntp_server.receive(payload=ntp_packet) is False + assert ntp_client.receive(payload=ntp_packet) is False -# # Restart ntp server. -# ntp_server.start() -# assert ntp_server.operating_state == ServiceOperatingState.RUNNING + # Restart ntp server. + ntp_server.start() + assert ntp_server.operating_state == ServiceOperatingState.RUNNING From 984d165364b7399277ccc9b3991190344683ac8a Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 21 Nov 2023 17:24:50 +0000 Subject: [PATCH 21/54] #2041: Fix long line --- src/primaite/simulator/system/services/ntp/ntp_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index 238f4f84..13bc04ee 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -66,7 +66,8 @@ class NTPServer(Service): payload: NTPPacket = payload if payload.ntp_request.ntp_client: self.sys_log.info( - f"{self.name}: Received request for {payload.ntp_request.ntp_client} " f"from session {session_id}" + f"{self.name}: Received request for {payload.ntp_request.ntp_client} \ + from session {session_id}" ) # generate a reply with the current time time = datetime.now() From dd7c2b05f830e132a40265299dca93fe28699e6f Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 22 Nov 2023 08:54:39 +0000 Subject: [PATCH 22/54] #2041: Add RST doc --- .../system/ntp_client_server.rst | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/source/simulation_components/system/ntp_client_server.rst 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. From 3f76e095214a4fbf44e96cb5c05913250cb2c25a Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 22 Nov 2023 14:13:50 +0000 Subject: [PATCH 23/54] #2042: remove apply_timestep() return value --- .../simulator/system/services/ntp/ntp_client.py | 9 +++------ .../system/test_ntp_client_server.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 81ec031c..51df5010 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -66,6 +66,7 @@ class NTPClient(Service): :return: True if successful, False otherwise. """ + self.ip_addr = payload.ntp_request.ntp_client self.sys_log.info(f"{self.name}: Sending NTP request {payload.ntp_request.ntp_client}") return super().send( @@ -92,10 +93,7 @@ class NTPClient(Service): _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.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 @@ -110,13 +108,12 @@ class NTPClient(Service): :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: IP address: {self.ip_addr}") super().apply_timestep(timestep) if self.operating_state == ServiceOperatingState.RUNNING: # request time from server ntp_request = NTPRequest(ntp_client=self.ip_addr) ntp_server_packet = NTPPacket(ntp_request=ntp_request) self.send(payload=ntp_server_packet) - return True else: self.sys_log.debug(f"{self.name} ntp client not running") - return False diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 48db0cbb..95394e84 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -1,4 +1,5 @@ from ipaddress import IPv4Address +from time import sleep import pytest @@ -61,11 +62,17 @@ def test_ntp_client_server(): assert ntp_server.receive(payload=ntp_packet) is True assert ntp_client.receive(payload=ntp_packet) is True assert ntp_client.time is not None - assert ntp_client.apply_timestep(1) is True + first_time = ntp_client.time + sleep(0.1) + ntp_client.apply_timestep(1) # Check time advances + ntp_server.receive(payload=ntp_packet) + ntp_client.receive(payload=ntp_packet) + second_time = ntp_client.time + assert first_time != second_time # Test ntp client behaviour when ntp server is unavailable. -@pytest.mark.skip(reason="NTP needs to know if underly node is RUNNING") +@pytest.mark.skip(reason="NTP needs to know if underlying node is RUNNING") def test_ntp_server_failure(): network = create_ntp_network() server: Server = network.get_node_by_hostname("ntp_server") From 006a37d2686a8f44c20a1291d50c70581c95b3f7 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 22 Nov 2023 14:40:44 +0000 Subject: [PATCH 24/54] #2042: extract code into request_time() method. --- .../simulator/system/services/ntp/ntp_client.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 51df5010..38ef820b 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -93,10 +93,19 @@ class NTPClient(Service): _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.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_request = NTPRequest(ntp_client=self.ip_addr) + ntp_server_packet = NTPPacket(ntp_request=ntp_request) + self.send(payload=ntp_server_packet) + def apply_timestep(self, timestep: int) -> None: """ For each timestep request the time from the NTP server. @@ -112,8 +121,6 @@ class NTPClient(Service): super().apply_timestep(timestep) if self.operating_state == ServiceOperatingState.RUNNING: # request time from server - ntp_request = NTPRequest(ntp_client=self.ip_addr) - ntp_server_packet = NTPPacket(ntp_request=ntp_request) - self.send(payload=ntp_server_packet) + self.request_time() else: self.sys_log.debug(f"{self.name} ntp client not running") From 8584fa8f5163863795ee714a4180424b83b2cd11 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Thu, 23 Nov 2023 10:04:52 +0000 Subject: [PATCH 25/54] # 2041: Minor test changes --- src/primaite/simulator/system/services/ntp/ntp_client.py | 4 ++-- src/primaite/simulator/system/services/ntp/ntp_server.py | 4 ++-- tests/integration_tests/system/test_ntp_client_server.py | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 38ef820b..2b2c725a 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -100,9 +100,9 @@ class NTPClient(Service): self.time = payload.ntp_reply.ntp_datetime return True - def request_time(self) -> None: + def request_time(self, ip_address: IPv4Address = ip_addr) -> None: """Send request to ntp_server.""" - ntp_request = NTPRequest(ntp_client=self.ip_addr) + ntp_request = NTPRequest(ntp_client=ip_address) ntp_server_packet = NTPPacket(ntp_request=ntp_request) self.send(payload=ntp_server_packet) diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index 13bc04ee..6d76c1ed 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Any, Dict, Optional +from typing import Dict, Optional from primaite import getLogger from primaite.simulator.network.protocols.ntp import NTPPacket @@ -46,7 +46,7 @@ class NTPServer(Service): def receive( self, - payload: Any, + payload: NTPPacket, session_id: Optional[str] = None, **kwargs, ) -> bool: diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 95394e84..54e54a5b 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -58,7 +58,9 @@ def test_ntp_client_server(): ntp_request = NTPRequest(ntp_client="192.168.1.3") ntp_packet = NTPPacket(ntp_request=ntp_request) - ntp_client.send(payload=ntp_packet) + # ntp_client.send(payload=ntp_packet) + ntp_client.request_time("192.168.1.3") + assert ntp_server.receive(payload=ntp_packet) is True assert ntp_client.receive(payload=ntp_packet) is True assert ntp_client.time is not None From 87dde6ee0bfcad3e3993b1e968de8b63790a2a49 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Thu, 23 Nov 2023 15:55:58 +0000 Subject: [PATCH 26/54] #2042: Test tidying changes. --- .../system/services/ntp/ntp_client.py | 16 ++++++++++++++-- .../system/test_ntp_client_server.py | 19 ++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index 2b2c725a..c75e639d 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -15,6 +15,7 @@ class NTPClient(Service): """Represents a NTP client as a service.""" ip_addr: Optional[IPv4Address] = None + "The IP address of the NTP client" ntp_server: Optional[IPv4Address] = None "The NTP server the client sends requests to." time: Optional[datetime] = None @@ -26,6 +27,17 @@ class NTPClient(Service): super().__init__(**kwargs) self.start() + def configure(self, ntp_server_ip_address: IPv4Address, ntp_client_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.ip_addr = ntp_client_ip_address + self.ntp_server = ntp_server_ip_address + self.sys_log.info(f"{self.name}: ip_addr: {self.ip_addr}, ntp_server: {self.ntp_server}") + def describe_state(self) -> Dict: """ Describes the current state of the software. @@ -100,9 +112,9 @@ class NTPClient(Service): self.time = payload.ntp_reply.ntp_datetime return True - def request_time(self, ip_address: IPv4Address = ip_addr) -> None: + def request_time(self) -> None: """Send request to ntp_server.""" - ntp_request = NTPRequest(ntp_client=ip_address) + ntp_request = NTPRequest(ntp_client=self.ip_addr) ntp_server_packet = NTPPacket(ntp_request=ntp_request) self.send(payload=ntp_server_packet) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index 54e54a5b..dec6c0f7 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -36,6 +36,7 @@ def create_ntp_network() -> Network: ) ntp_client.power_on() ntp_client.software_manager.install(NTPClient) + network.connect(endpoint_b=ntp_server.ethernet_port[1], endpoint_a=ntp_client.ethernet_port[1]) return network @@ -54,21 +55,25 @@ def test_ntp_client_server(): assert ntp_server.operating_state == ServiceOperatingState.RUNNING assert ntp_client.operating_state == ServiceOperatingState.RUNNING + ntp_client.configure( + ntp_server_ip_address=IPv4Address("192.168.1.2"), ntp_client_ip_address=IPv4Address("192.168.1.3") + ) + assert ntp_client.time is None - ntp_request = NTPRequest(ntp_client="192.168.1.3") - ntp_packet = NTPPacket(ntp_request=ntp_request) + # ntp_request = NTPRequest(ntp_client="192.168.1.3") + # ntp_packet = NTPPacket(ntp_request=ntp_request) # ntp_client.send(payload=ntp_packet) - ntp_client.request_time("192.168.1.3") + ntp_client.request_time() - assert ntp_server.receive(payload=ntp_packet) is True - assert ntp_client.receive(payload=ntp_packet) is True + # assert ntp_server.receive(payload=ntp_packet) is True + # assert ntp_client.receive(payload=ntp_packet) is True assert ntp_client.time is not None first_time = ntp_client.time sleep(0.1) ntp_client.apply_timestep(1) # Check time advances - ntp_server.receive(payload=ntp_packet) - ntp_client.receive(payload=ntp_packet) + # ntp_server.receive(payload=ntp_packet) + # ntp_client.receive(payload=ntp_packet) second_time = ntp_client.time assert first_time != second_time From 30b0f12a8dac8c02cbf8b6154fc43b24078edf28 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 4 Dec 2023 11:34:17 +0000 Subject: [PATCH 27/54] Allow cancelling jobs --- .azure/azure-ci-build-pipeline.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.azure/azure-ci-build-pipeline.yaml b/.azure/azure-ci-build-pipeline.yaml index 26559889..fe1cc58e 100644 --- a/.azure/azure-ci-build-pipeline.yaml +++ b/.azure/azure-ci-build-pipeline.yaml @@ -44,10 +44,11 @@ stages: jobs: - ${{ each item in parameters.matrix }}: - job: ${{ item.job_name }} + timeoutInMinutes: 90 + cancelTimeoutInMinutes: 1 pool: vmImage: ${{ item.img }} - - condition: or( eq(variables['Build.Reason'], 'PullRequest'), ${{ item.every_time }} ) + condition: and(succeeded(), or( eq(variables['Build.Reason'], 'PullRequest'), ${{ item.every_time }} )) steps: - task: UsePythonVersion@0 From 12ede2329b48e3225acef5fb86030a54eacfa91d Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 6 Dec 2023 16:41:10 +0000 Subject: [PATCH 28/54] 2041: Add network config and pytest fixture --- .../system/test_ntp_client_server.py | 45 +++++++------------ 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index dec6c0f7..ed5e6962 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -1,5 +1,6 @@ from ipaddress import IPv4Address from time import sleep +from typing import Tuple import pytest @@ -14,7 +15,8 @@ from primaite.simulator.system.services.service import ServiceOperatingState # Create simple network for testing -def create_ntp_network() -> Network: +@pytest.fixture(scope="function") +def create_ntp_network(client_server) -> Tuple[NTPClient, Computer, NTPServer, Server]: """ +------------+ +------------+ | ntp | | ntp | @@ -23,32 +25,26 @@ def create_ntp_network() -> Network: +------------+ +------------+ """ + client, server = client_server - network = Network() - ntp_server = Server( - hostname="ntp_server", ip_address="192.168.1.2", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" - ) - ntp_server.power_on() - ntp_server.software_manager.install(NTPServer) + server.power_on() + server.software_manager.install(NTPServer) + ntp_server: NTPServer = server.software_manager.software.get("NTPServer") + ntp_server.start() - ntp_client = Computer( - hostname="ntp_client", ip_address="192.168.1.3", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" - ) - ntp_client.power_on() - ntp_client.software_manager.install(NTPClient) + client.power_on() + client.software_manager.install(NTPClient) + ntp_client: NTPClient = client.software_manager.software.get("NTPClient") + ntp_client.start() - network.connect(endpoint_b=ntp_server.ethernet_port[1], endpoint_a=ntp_client.ethernet_port[1]) - - return network + return ntp_client, client, ntp_server, server # Define one node to be an NTP server and another node to be a NTP Client. -def test_ntp_client_server(): - network = create_ntp_network() - server: Server = network.get_node_by_hostname("ntp_server") - client: Computer = network.get_node_by_hostname("ntp_client") +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"] @@ -56,24 +52,15 @@ def test_ntp_client_server(): assert ntp_server.operating_state == ServiceOperatingState.RUNNING assert ntp_client.operating_state == ServiceOperatingState.RUNNING ntp_client.configure( - ntp_server_ip_address=IPv4Address("192.168.1.2"), ntp_client_ip_address=IPv4Address("192.168.1.3") + ntp_server_ip_address=IPv4Address("192.168.0.2"), ntp_client_ip_address=IPv4Address("192.168.0.1") ) assert ntp_client.time is None - - # ntp_request = NTPRequest(ntp_client="192.168.1.3") - # ntp_packet = NTPPacket(ntp_request=ntp_request) - # ntp_client.send(payload=ntp_packet) ntp_client.request_time() - - # assert ntp_server.receive(payload=ntp_packet) is True - # assert ntp_client.receive(payload=ntp_packet) is True assert ntp_client.time is not None first_time = ntp_client.time sleep(0.1) ntp_client.apply_timestep(1) # Check time advances - # ntp_server.receive(payload=ntp_packet) - # ntp_client.receive(payload=ntp_packet) second_time = ntp_client.time assert first_time != second_time From 50a6e17fabfd6c20eb0f5ab63b01e7f3377144b5 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 6 Dec 2023 16:42:28 +0000 Subject: [PATCH 29/54] 2041: Make NTP work with TCP transport layer --- src/primaite/game/agent/actions.py | 2 +- src/primaite/game/game.py | 4 ++++ .../simulator/system/services/ntp/ntp_client.py | 10 +++++----- .../simulator/system/services/ntp/ntp_server.py | 3 ++- 4 files changed, 12 insertions(+), 7 deletions(-) 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 a36cbea9..edfe058d 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -25,6 +25,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.red_services.data_manipulation_bot import DataManipulationBot from primaite.simulator.system.services.web_server.web_server import WebServer @@ -257,6 +259,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/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index c75e639d..f9cf29d4 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -23,7 +23,7 @@ class NTPClient(Service): def __init__(self, **kwargs): kwargs["name"] = "NTPClient" kwargs["port"] = Port.NTP - kwargs["protocol"] = IPProtocol.UDP + kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) self.start() @@ -65,7 +65,7 @@ class NTPClient(Service): self, payload: NTPPacket, session_id: Optional[str] = None, - dest_ip_address: IPv4Address = ntp_server, + dest_ip_address: IPv4Address = None, dest_port: [Port] = Port.NTP, **kwargs, ) -> bool: @@ -79,8 +79,6 @@ class NTPClient(Service): :return: True if successful, False otherwise. """ self.ip_addr = payload.ntp_request.ntp_client - self.sys_log.info(f"{self.name}: Sending NTP request {payload.ntp_request.ntp_client}") - return super().send( payload=payload, dest_ip_address=dest_ip_address, @@ -101,6 +99,8 @@ class NTPClient(Service): :param session_id: The Session ID the payload is to originate from. Optional. :return: True if successful, False otherwise. """ + self.sys_log.info(f"{self.name}: Receiving NTP request from {payload.ntp_request.ntp_client}") + if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): _LOGGER.debug(f"{payload} is not a NTPPacket") return False @@ -116,7 +116,7 @@ class NTPClient(Service): """Send request to ntp_server.""" ntp_request = NTPRequest(ntp_client=self.ip_addr) ntp_server_packet = NTPPacket(ntp_request=ntp_request) - self.send(payload=ntp_server_packet) + self.send(payload=ntp_server_packet, dest_ip_address=self.ntp_server) def apply_timestep(self, timestep: int) -> None: """ diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index 6d76c1ed..400c397f 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -16,7 +16,7 @@ class NTPServer(Service): def __init__(self, **kwargs): kwargs["name"] = "NTPServer" kwargs["port"] = Port.NTP - kwargs["protocol"] = IPProtocol.UDP + kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) self.start() @@ -60,6 +60,7 @@ class NTPServer(Service): :return: True if valid NTP request else False. """ + self.sys_log.info(f"{self.name} received request from {payload.ntp_request.ntp_client}") if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): _LOGGER.debug(f"{payload} is not a NTPPacket") return False From 385a4997ce289afdc27e8ae318d7db9fa0e851c3 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 7 Dec 2023 10:35:50 +0000 Subject: [PATCH 30/54] New parameter for publishing code coverage --- .azure/azure-ci-build-pipeline.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.azure/azure-ci-build-pipeline.yaml b/.azure/azure-ci-build-pipeline.yaml index fe1cc58e..f962a628 100644 --- a/.azure/azure-ci-build-pipeline.yaml +++ b/.azure/azure-ci-build-pipeline.yaml @@ -18,26 +18,32 @@ parameters: py: '3.8' img: 'ubuntu-latest' every_time: false + publish_coverage: false - job_name: 'UbuntuPython310' py: '3.10' img: 'ubuntu-latest' every_time: true + publish_coverage: true - job_name: 'WindowsPython38' py: '3.8' img: 'windows-latest' every_time: false + publish_coverage: false - job_name: 'WindowsPython310' py: '3.10' img: 'windows-latest' every_time: false + publish_coverage: false - job_name: 'MacOSPython38' py: '3.8' img: 'macOS-latest' every_time: false + publish_coverage: false - job_name: 'MacOSPython310' py: '3.10' img: 'macOS-latest' every_time: false + publish_coverage: false stages: - stage: Test @@ -110,12 +116,12 @@ stages: - publish: $(System.DefaultWorkingDirectory)/htmlcov/ # publish the html report - so we can debug the coverage if needed - condition: ${{ item.every_time }} # should only be run once + condition: ${{ item.publish_coverage }} # should only be run once artifact: coverage_report - task: PublishCodeCoverageResults@2 # publish the code coverage so it can be viewed in the run coverage page - condition: ${{ item.every_time }} # should only be run once + condition: ${{ item.publish_coverage }} # should only be run once inputs: codeCoverageTool: Cobertura summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml' From 44ada941e62cb1be63a832d6a9578349e6a1bec7 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Thu, 7 Dec 2023 14:22:27 +0000 Subject: [PATCH 31/54] 2041: Reinstate test for ntp_server failure --- .../system/test_ntp_client_server.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index ed5e6962..97b2fe30 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -66,27 +66,27 @@ def test_ntp_client_server(create_ntp_network): # Test ntp client behaviour when ntp server is unavailable. -@pytest.mark.skip(reason="NTP needs to know if underlying node is RUNNING") -def test_ntp_server_failure(): - network = create_ntp_network() - server: Server = network.get_node_by_hostname("ntp_server") - client: Computer = network.get_node_by_hostname("ntp_client") +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"), ntp_client_ip_address=IPv4Address("192.168.0.1") + ) # Turn off ntp server. ntp_server.stop() assert ntp_server.operating_state == ServiceOperatingState.STOPPED # And request a time update. - ntp_request = NTPRequest(ntp_client="192.168.1.3") - ntp_packet = NTPPacket(ntp_request=ntp_request) - ntp_client.send(payload=ntp_packet) - assert ntp_server.receive(payload=ntp_packet) is False - assert ntp_client.receive(payload=ntp_packet) is False + 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 From 094e89fff15a073207a43a8422a7fef23669544c Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Fri, 8 Dec 2023 14:54:29 +0000 Subject: [PATCH 32/54] #2059: Renamed Red service to red application and moved the datamanipulation bot to the red application folder --- src/primaite/game/agent/data_manipulation_bot.py | 2 +- src/primaite/game/game.py | 3 ++- src/primaite/simulator/network/networks.py | 2 +- .../red_services => applications/red_applications}/__init__.py | 0 .../red_applications}/data_manipulation_bot.py | 0 .../test_uc2_data_manipulation_scenario.py | 2 +- .../_red_applications}/__init__.py | 0 .../_red_applications}/test_data_manipulation_bot.py | 2 +- 8 files changed, 6 insertions(+), 5 deletions(-) rename src/primaite/simulator/system/{services/red_services => applications/red_applications}/__init__.py (100%) rename src/primaite/simulator/system/{services/red_services => applications/red_applications}/data_manipulation_bot.py (100%) rename tests/unit_tests/_primaite/_simulator/_system/{_services/_red_services => _applications/_red_applications}/__init__.py (100%) rename tests/unit_tests/_primaite/_simulator/_system/{_services/_red_services => _applications/_red_applications}/test_data_manipulation_bot.py (96%) diff --git a/src/primaite/game/agent/data_manipulation_bot.py b/src/primaite/game/agent/data_manipulation_bot.py index 8237ce06..791c362d 100644 --- a/src/primaite/game/agent/data_manipulation_bot.py +++ b/src/primaite/game/agent/data_manipulation_bot.py @@ -4,7 +4,7 @@ from typing import Dict, List, Tuple from gymnasium.core import ObsType from primaite.game.agent.interface import AbstractScriptedAgent -from primaite.simulator.system.services.red_services.data_manipulation_bot import DataManipulationBot +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot class DataManipulationAgent(AbstractScriptedAgent): diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 8c32f41d..b6b815f1 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -20,13 +20,13 @@ from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot from primaite.simulator.system.applications.web_browser import WebBrowser from primaite.simulator.system.services.database.database_service import DatabaseService 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.red_services.data_manipulation_bot import DataManipulationBot from primaite.simulator.system.services.web_server.web_server import WebServer _LOGGER = getLogger(__name__) @@ -314,6 +314,7 @@ class PrimaiteGame: opt = application_cfg["options"] new_application.configure( server_ip_address=IPv4Address(opt.get("server_ip")), + server_password=opt.get("server_password"), payload=opt.get("payload"), port_scan_p_of_success=float(opt.get("port_scan_p_of_success", "0.1")), data_manipulation_p_of_success=float(opt.get("data_manipulation_p_of_success", "0.1")), diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index 4cd9c8d3..61ec7baf 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -9,10 +9,10 @@ from primaite.simulator.network.hardware.nodes.switch import Switch from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.ftp.ftp_server import FTPServer -from primaite.simulator.system.services.red_services.data_manipulation_bot import DataManipulationBot from primaite.simulator.system.services.web_server.web_server import WebServer diff --git a/src/primaite/simulator/system/services/red_services/__init__.py b/src/primaite/simulator/system/applications/red_applications/__init__.py similarity index 100% rename from src/primaite/simulator/system/services/red_services/__init__.py rename to src/primaite/simulator/system/applications/red_applications/__init__.py diff --git a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py b/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py similarity index 100% rename from src/primaite/simulator/system/services/red_services/data_manipulation_bot.py rename to src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py diff --git a/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py index 0dc2c031..5206561b 100644 --- a/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py +++ b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py @@ -1,8 +1,8 @@ from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.system.applications.database_client import DatabaseClient +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import DataManipulationBot from primaite.simulator.system.services.database.database_service import DatabaseService -from primaite.simulator.system.services.red_services.data_manipulation_bot import DataManipulationBot def test_data_manipulation(uc2_network): diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/__init__.py similarity index 100% rename from tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/__init__.py rename to tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/__init__.py diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/test_data_manipulation_bot.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py similarity index 96% rename from tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/test_data_manipulation_bot.py rename to tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py index 2c4826bf..b0ff0467 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/test_data_manipulation_bot.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py @@ -5,7 +5,7 @@ from primaite.simulator.network.networks import arcd_uc2_network from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState -from primaite.simulator.system.services.red_services.data_manipulation_bot import ( +from primaite.simulator.system.applications.red_applications.data_manipulation_bot import ( DataManipulationAttackStage, DataManipulationBot, ) From cd5ed48b007c0b4e8304dd75f861698b488337bb Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Fri, 8 Dec 2023 17:07:57 +0000 Subject: [PATCH 33/54] #2059: implementing the service connections limit --- src/primaite/simulator/network/networks.py | 4 +- .../system/applications/database_client.py | 109 ++++++++++------ .../red_applications/data_manipulation_bot.py | 4 +- .../services/database/database_service.py | 61 ++++++--- .../system/services/ftp/ftp_server.py | 13 +- .../simulator/system/services/service.py | 72 ++++++++++- src/primaite/simulator/system/software.py | 2 +- .../system/test_database_on_node.py | 121 +++++++++++++----- .../test_data_manipulation_bot.py | 2 +- .../_applications/test_database_client.py | 19 +-- 10 files changed, 280 insertions(+), 127 deletions(-) diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index 61ec7baf..630846b3 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -252,9 +252,9 @@ def arcd_uc2_network() -> Network: database_service: DatabaseService = database_server.software_manager.software.get("DatabaseService") # noqa database_service.start() database_service.configure_backup(backup_server=IPv4Address("192.168.1.16")) - database_service._process_sql(ddl, None) # noqa + database_service._process_sql(ddl, None, None) # noqa for insert_statement in user_insert_statements: - database_service._process_sql(insert_statement, None) # noqa + database_service._process_sql(insert_statement, None, None) # noqa # Web Server web_server = Server( diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py index f57246fc..9d7bfcaa 100644 --- a/src/primaite/simulator/system/applications/database_client.py +++ b/src/primaite/simulator/system/applications/database_client.py @@ -23,7 +23,7 @@ class DatabaseClient(Application): server_ip_address: Optional[IPv4Address] = None server_password: Optional[str] = None - connected: bool = False + connections: Dict[str, Dict] = {} _query_success_tracker: Dict[str, bool] = {} def __init__(self, **kwargs): @@ -66,18 +66,24 @@ class DatabaseClient(Application): self.server_password = server_password self.sys_log.info(f"{self.name}: Configured the {self.name} with {server_ip_address=}, {server_password=}.") - def connect(self) -> bool: + def connect(self, connection_id: Optional[str] = None) -> bool: """Connect to a Database Service.""" if not self._can_perform_action(): return False - if not self.connected: - return self._connect(self.server_ip_address, self.server_password) - # already connected - return True + if not connection_id: + connection_id = str(uuid4()) + + return self._connect( + server_ip_address=self.server_ip_address, password=self.server_password, connection_id=connection_id + ) def _connect( - self, server_ip_address: IPv4Address, password: Optional[str] = None, is_reattempt: bool = False + self, + server_ip_address: IPv4Address, + connection_id: Optional[str] = None, + password: Optional[str] = None, + is_reattempt: bool = False, ) -> bool: """ Connects the DatabaseClient to the DatabaseServer. @@ -92,33 +98,58 @@ class DatabaseClient(Application): :type: is_reattempt: Optional[bool] """ if is_reattempt: - if self.connected: - self.sys_log.info(f"{self.name}: DatabaseClient connection to {server_ip_address} authorised") + if self.connections.get(connection_id): + self.sys_log.info( + f"{self.name} {connection_id=}: DatabaseClient connection to {server_ip_address} authorised" + ) self.server_ip_address = server_ip_address - return self.connected + return True else: - self.sys_log.info(f"{self.name}: DatabaseClient connection to {server_ip_address} declined") + self.sys_log.info( + f"{self.name} {connection_id=}: DatabaseClient connection to {server_ip_address} declined" + ) return False - payload = {"type": "connect_request", "password": password} + payload = { + "type": "connect_request", + "password": password, + "connection_id": connection_id, + } software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( payload=payload, dest_ip_address=server_ip_address, dest_port=self.port ) - return self._connect(server_ip_address, password, True) + return self._connect( + server_ip_address=server_ip_address, password=password, connection_id=connection_id, is_reattempt=True + ) - def disconnect(self): + def disconnect(self, connection_id: Optional[str] = None) -> bool: """Disconnect from the Database Service.""" - if self.connected and self.operating_state is ApplicationOperatingState.RUNNING: - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( - payload={"type": "disconnect"}, dest_ip_address=self.server_ip_address, dest_port=self.port - ) + if not self._can_perform_action(): + self.sys_log.error(f"Unable to disconnect - {self.name} is {self.operating_state.name}") + return False - self.sys_log.info(f"{self.name}: DatabaseClient disconnected from {self.server_ip_address}") - self.server_ip_address = None - self.connected = False + # if there are no connections - nothing to disconnect + if not len(self.connections): + self.sys_log.error(f"Unable to disconnect - {self.name} has no active connections.") + return False - def _query(self, sql: str, query_id: str, is_reattempt: bool = False) -> bool: + # if no connection provided, disconnect the first connection + if not connection_id: + connection_id = list(self.connections.keys())[0] + + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "disconnect", "connection_id": connection_id}, + dest_ip_address=self.server_ip_address, + dest_port=self.port, + ) + self.connections.pop(connection_id) + + self.sys_log.info( + f"{self.name}: DatabaseClient disconnected connection {connection_id} from {self.server_ip_address}" + ) + + def _query(self, sql: str, query_id: str, connection_id: str, is_reattempt: bool = False) -> bool: """ Send a query to the connected database server. @@ -141,11 +172,11 @@ class DatabaseClient(Application): else: software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( - payload={"type": "sql", "sql": sql, "uuid": query_id}, + payload={"type": "sql", "sql": sql, "uuid": query_id, "connection_id": connection_id}, dest_ip_address=self.server_ip_address, dest_port=self.port, ) - return self._query(sql=sql, query_id=query_id, is_reattempt=True) + return self._query(sql=sql, query_id=query_id, connection_id=connection_id, is_reattempt=True) def run(self) -> None: """Run the DatabaseClient.""" @@ -153,7 +184,7 @@ class DatabaseClient(Application): if self.operating_state == ApplicationOperatingState.RUNNING: self.connect() - def query(self, sql: str, is_reattempt: bool = False) -> bool: + def query(self, sql: str, connection_id: Optional[str] = None) -> bool: """ Send a query to the Database Service. @@ -164,20 +195,17 @@ class DatabaseClient(Application): if not self._can_perform_action(): return False - if self.connected: - query_id = str(uuid4()) + if connection_id is None: + connection_id = str(uuid4()) + + if not self.connections.get(connection_id): + if not self.connect(connection_id=connection_id): + return False # Initialise the tracker of this ID to False - self._query_success_tracker[query_id] = False - return self._query(sql=sql, query_id=query_id) - else: - if is_reattempt: - return False - - if not self.connect(): - return False - - self.query(sql=sql, is_reattempt=True) + uuid = str(uuid4()) + self._query_success_tracker[uuid] = False + return self._query(sql=sql, query_id=uuid, connection_id=connection_id) def receive(self, payload: Any, session_id: str, **kwargs) -> bool: """ @@ -192,13 +220,12 @@ class DatabaseClient(Application): if isinstance(payload, dict) and payload.get("type"): if payload["type"] == "connect_response": - self.connected = payload["response"] == True + if payload["response"] is True: + self.connections[payload.get("connection_id")] = payload elif payload["type"] == "sql": query_id = payload.get("uuid") status_code = payload.get("status_code") self._query_success_tracker[query_id] = status_code == 200 if self._query_success_tracker[query_id]: _LOGGER.debug(f"Received payload {payload}") - else: - self.connected = False return True diff --git a/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py b/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py index 44a56cf1..87959e9b 100644 --- a/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py +++ b/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py @@ -149,9 +149,9 @@ class DataManipulationBot(DatabaseClient): if simulate_trial(p_of_success): self.sys_log.info(f"{self.name}: Performing data manipulation") # perform the attack - if not self.connected: + if not len(self.connections): self.connect() - if self.connected: + if len(self.connections): self.query(self.payload) self.sys_log.info(f"{self.name} payload delivered: {self.payload}") attack_successful = True diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 61cf1560..70a4e6cc 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -1,4 +1,3 @@ -from datetime import datetime from ipaddress import IPv4Address from typing import Any, Dict, List, Literal, Optional, Union @@ -22,7 +21,6 @@ class DatabaseService(Service): """ password: Optional[str] = None - connections: Dict[str, datetime] = {} backup_server: IPv4Address = None """IP address of the backup server.""" @@ -140,7 +138,7 @@ class DatabaseService(Service): self.folder = self.file_system.get_folder_by_id(self._db_file.folder_id) def _process_connect( - self, session_id: str, password: Optional[str] = None + self, connection_id: str, password: Optional[str] = None ) -> Dict[str, Union[int, Dict[str, bool]]]: status_code = 500 # Default internal server error if self.operating_state == ServiceOperatingState.RUNNING: @@ -148,16 +146,27 @@ class DatabaseService(Service): if self.health_state_actual == SoftwareHealthState.GOOD: if self.password == password: status_code = 200 # ok - self.connections[session_id] = datetime.now() - self.sys_log.info(f"{self.name}: Connect request for {session_id=} authorised") + # try to create connection + if not self.add_connection(connection_id=connection_id): + status_code = 500 + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} declined") + else: + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") else: status_code = 401 # Unauthorised - self.sys_log.info(f"{self.name}: Connect request for {session_id=} declined") + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} declined") else: status_code = 404 # service not found - return {"status_code": status_code, "type": "connect_response", "response": status_code == 200} + return { + "status_code": status_code, + "type": "connect_response", + "response": status_code == 200, + "connection_id": connection_id, + } - def _process_sql(self, query: Literal["SELECT", "DELETE"], query_id: str) -> Dict[str, Union[int, List[Any]]]: + def _process_sql( + self, query: Literal["SELECT", "DELETE"], query_id: str, connection_id: Optional[str] = None + ) -> Dict[str, Union[int, List[Any]]]: """ Executes the given SQL query and returns the result. @@ -169,15 +178,28 @@ class DatabaseService(Service): :return: Dictionary containing status code and data fetched. """ self.sys_log.info(f"{self.name}: Running {query}") + if query == "SELECT": if self.health_state_actual == SoftwareHealthState.GOOD: - return {"status_code": 200, "type": "sql", "data": True, "uuid": query_id} + return { + "status_code": 200, + "type": "sql", + "data": True, + "uuid": query_id, + "connection_id": connection_id, + } else: return {"status_code": 404, "data": False} elif query == "DELETE": if self.health_state_actual == SoftwareHealthState.GOOD: self.health_state_actual = SoftwareHealthState.COMPROMISED - return {"status_code": 200, "type": "sql", "data": False, "uuid": query_id} + return { + "status_code": 200, + "type": "sql", + "data": False, + "uuid": query_id, + "connection_id": connection_id, + } else: return {"status_code": 404, "data": False} else: @@ -207,15 +229,24 @@ class DatabaseService(Service): return False result = {"status_code": 500, "data": []} + + # if server service is down, return error + if not self._can_perform_action(): + return False + if isinstance(payload, dict) and payload.get("type"): if payload["type"] == "connect_request": - result = self._process_connect(session_id=session_id, password=payload.get("password")) + result = self._process_connect( + connection_id=payload.get("connection_id"), password=payload.get("password") + ) elif payload["type"] == "disconnect": - if session_id in self.connections: - self.connections.pop(session_id) + if payload["connection_id"] in self.connections: + self.remove_connection(connection_id=payload["connection_id"]) elif payload["type"] == "sql": - if session_id in self.connections: - result = self._process_sql(query=payload["sql"], query_id=payload["uuid"]) + if payload.get("connection_id") in self.connections: + result = self._process_sql( + query=payload["sql"], query_id=payload["uuid"], connection_id=payload["connection_id"] + ) else: result = {"status_code": 401, "type": "sql"} self.send(payload=result, session_id=session_id) diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 0278b616..6e6c1a48 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -1,5 +1,4 @@ -from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from typing import Any, Optional from primaite import getLogger from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode @@ -21,9 +20,6 @@ class FTPServer(FTPServiceABC): server_password: Optional[str] = None """Password needed to connect to FTP server. Default is None.""" - connections: Dict[str, IPv4Address] = {} - """Current active connections to the FTP server.""" - def __init__(self, **kwargs): kwargs["name"] = "FTPServer" kwargs["port"] = Port.FTP @@ -62,9 +58,6 @@ class FTPServer(FTPServiceABC): self.sys_log.info(f"{self.name}: Received FTP {payload.ftp_command.name} {payload.ftp_command_args}") - if session_id: - session_details = self._get_session_details(session_id) - if payload.ftp_command is not None: self.sys_log.info(f"Received FTP {payload.ftp_command.name} command.") @@ -73,7 +66,7 @@ class FTPServer(FTPServiceABC): # check that the port is valid if isinstance(payload.ftp_command_args, Port) and payload.ftp_command_args.value in range(0, 65535): # return successful connection - self.connections[session_id] = session_details.with_ip_address + self.add_connection(connection_id=session_id, session_id=session_id) payload.status_code = FTPStatusCode.OK return payload @@ -81,7 +74,7 @@ class FTPServer(FTPServiceABC): return payload if payload.ftp_command == FTPCommand.QUIT: - self.connections.pop(session_id) + self.remove_connection(connection_id=session_id) payload.status_code = FTPStatusCode.OK return payload diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index e60b7700..52187e51 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,3 +1,5 @@ +import copy +from datetime import datetime from enum import Enum from typing import Any, Dict, Optional @@ -40,6 +42,15 @@ class Service(IOSoftware): restart_countdown: Optional[int] = None "If currently restarting, how many timesteps remain until the restart is finished." + _connections: Dict[str, Dict] = {} + "Active connections to the Service." + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.health_state_visible = SoftwareHealthState.UNUSED + self.health_state_actual = SoftwareHealthState.UNUSED + def _can_perform_action(self) -> bool: """ Checks if the service can perform actions. @@ -74,12 +85,6 @@ class Service(IOSoftware): """ return super().receive(payload=payload, session_id=session_id, **kwargs) - def __init__(self, **kwargs): - super().__init__(**kwargs) - - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def set_original_state(self): """Sets the original state.""" super().set_original_state() @@ -98,6 +103,11 @@ class Service(IOSoftware): rm.add_request("enable", RequestType(func=lambda request, context: self.enable())) return rm + @property + def connections(self) -> Dict[str, Dict]: + """Return the public version of connections.""" + return copy.copy(self._connections) + def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -113,6 +123,56 @@ class Service(IOSoftware): state["health_state_visible"] = self.health_state_visible.value return state + def add_connection(self, connection_id: str, session_id: Optional[str] = None) -> bool: + """ + Create a new connection to this service. + + Returns true if connection successfully created + + :param: connection_id: UUID of the connection to create + :type: string + """ + # if over or at capacity, set to overwhelmed + if len(self._connections) >= self.max_sessions: + self.health_state_actual = SoftwareHealthState.OVERWHELMED + self.sys_log.error(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.") + return False + else: + # if service was previously overwhelmed, set to good because there is enough space for connections + if self.health_state_actual == SoftwareHealthState.OVERWHELMED: + self.health_state_actual = SoftwareHealthState.GOOD + + # check that connection already doesn't exist + if not self._connections.get(connection_id): + session_details = None + if session_id: + session_details = self._get_session_details(session_id) + self._connections[connection_id] = { + "ip_address": session_details.with_ip_address if session_details else None, + "time": datetime.now(), + } + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + return True + # connection with given id already exists + self.sys_log.error( + f"{self.name}: Connect request for {connection_id=} declined. Connection already exists." + ) + return False + + def remove_connection(self, connection_id: str) -> bool: + """ + Remove a connection from this service. + + Returns true if connection successfully removed + + :param: connection_id: UUID of the connection to create + :type: string + """ + if self._connections.get(connection_id): + self._connections.pop(connection_id) + self.sys_log.info(f"{self.name}: Connection {connection_id=} closed.") + return True + def stop(self) -> None: """Stop the service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 87802a7b..8746bdf3 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -198,7 +198,7 @@ class IOSoftware(Software): installing_count: int = 0 "The number of times the software has been installed. Default is 0." - max_sessions: int = 1 + max_sessions: int = 100 "The maximum number of sessions that the software can handle simultaneously. Default is 0." tcp: bool = True "Indicates if the software uses TCP protocol for communication. Default is True." diff --git a/tests/integration_tests/system/test_database_on_node.py b/tests/integration_tests/system/test_database_on_node.py index 98c8c87b..daa125ca 100644 --- a/tests/integration_tests/system/test_database_on_node.py +++ b/tests/integration_tests/system/test_database_on_node.py @@ -1,6 +1,9 @@ from ipaddress import IPv4Address +from typing import Tuple -from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +import pytest + +from primaite.simulator.network.hardware.base import Link, NIC, Node, NodeOperatingState from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.services.database.database_service import DatabaseService @@ -8,57 +11,109 @@ from primaite.simulator.system.services.ftp.ftp_server import FTPServer from primaite.simulator.system.services.service import ServiceOperatingState -def test_database_client_server_connection(uc2_network): - web_server: Server = uc2_network.get_node_by_hostname("web_server") - db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") +@pytest.fixture(scope="function") +def peer_to_peer() -> Tuple[Node, Node]: + node_a = Node(hostname="node_a", operating_state=NodeOperatingState.ON) + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", operating_state=NodeOperatingState.ON) + node_a.connect_nic(nic_a) + node_a.software_manager.get_open_ports() - db_server: Server = uc2_network.get_node_by_hostname("database_server") - db_service: DatabaseService = db_server.software_manager.software.get("DatabaseService") + node_b = Node(hostname="node_b", operating_state=NodeOperatingState.ON) + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0") + node_b.connect_nic(nic_b) + Link(endpoint_a=nic_a, endpoint_b=nic_b) + + assert node_a.ping("192.168.0.11") + + node_a.software_manager.install(DatabaseClient) + node_a.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.11")) + node_a.software_manager.software["DatabaseClient"].run() + + node_b.software_manager.install(DatabaseService) + database_service: DatabaseService = node_b.software_manager.software["DatabaseService"] # noqa + database_service.start() + return node_a, node_b + + +@pytest.fixture(scope="function") +def peer_to_peer_secure_db() -> Tuple[Node, Node]: + node_a = Node(hostname="node_a", operating_state=NodeOperatingState.ON) + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", operating_state=NodeOperatingState.ON) + node_a.connect_nic(nic_a) + node_a.software_manager.get_open_ports() + + node_b = Node(hostname="node_b", operating_state=NodeOperatingState.ON) + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0") + node_b.connect_nic(nic_b) + + Link(endpoint_a=nic_a, endpoint_b=nic_b) + + assert node_a.ping("192.168.0.11") + + node_a.software_manager.install(DatabaseClient) + node_a.software_manager.software["DatabaseClient"].configure(server_ip_address=IPv4Address("192.168.0.11")) + node_a.software_manager.software["DatabaseClient"].run() + + node_b.software_manager.install(DatabaseService) + database_service: DatabaseService = node_b.software_manager.software["DatabaseService"] # noqa + database_service.password = "12345" + database_service.start() + return node_a, node_b + + +def test_database_client_server_connection(peer_to_peer): + node_a, node_b = peer_to_peer + + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] + + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] + + db_client.connect() + assert len(db_client.connections) == 1 assert len(db_service.connections) == 1 db_client.disconnect() + assert len(db_client.connections) == 0 assert len(db_service.connections) == 0 -def test_database_client_server_correct_password(uc2_network): - web_server: Server = uc2_network.get_node_by_hostname("web_server") - db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") +def test_database_client_server_correct_password(peer_to_peer_secure_db): + node_a, node_b = peer_to_peer_secure_db - db_server: Server = uc2_network.get_node_by_hostname("database_server") - db_service: DatabaseService = db_server.software_manager.software.get("DatabaseService") + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] - db_client.disconnect() - - db_client.configure(server_ip_address=IPv4Address("192.168.1.14"), server_password="12345") - db_service.password = "12345" - - assert db_client.connect() + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] + db_client.configure(server_ip_address=IPv4Address("192.168.0.11"), server_password="12345") + db_client.connect() + assert len(db_client.connections) == 1 assert len(db_service.connections) == 1 -def test_database_client_server_incorrect_password(uc2_network): - web_server: Server = uc2_network.get_node_by_hostname("web_server") - db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") +def test_database_client_server_incorrect_password(peer_to_peer_secure_db): + node_a, node_b = peer_to_peer_secure_db - db_server: Server = uc2_network.get_node_by_hostname("database_server") - db_service: DatabaseService = db_server.software_manager.software.get("DatabaseService") + db_client: DatabaseClient = node_a.software_manager.software["DatabaseClient"] - db_client.disconnect() - db_client.configure(server_ip_address=IPv4Address("192.168.1.14"), server_password="54321") - db_service.password = "12345" + db_service: DatabaseService = node_b.software_manager.software["DatabaseService"] - assert not db_client.connect() + # should fail + db_client.connect() + assert len(db_client.connections) == 0 + assert len(db_service.connections) == 0 + + db_client.configure(server_ip_address=IPv4Address("192.168.0.11"), server_password="wrongpass") + db_client.connect() + assert len(db_client.connections) == 0 assert len(db_service.connections) == 0 def test_database_client_query(uc2_network): """Tests DB query across the network returns HTTP status 200 and date.""" web_server: Server = uc2_network.get_node_by_hostname("web_server") - db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") - - assert db_client.connected + db_client: DatabaseClient = web_server.software_manager.software["DatabaseClient"] + db_client.connect() assert db_client.query("SELECT") @@ -66,13 +121,13 @@ def test_database_client_query(uc2_network): def test_create_database_backup(uc2_network): """Run the backup_database method and check if the FTP server has the relevant file.""" db_server: Server = uc2_network.get_node_by_hostname("database_server") - db_service: DatabaseService = db_server.software_manager.software.get("DatabaseService") + db_service: DatabaseService = db_server.software_manager.software["DatabaseService"] # back up should be created assert db_service.backup_database() is True backup_server: Server = uc2_network.get_node_by_hostname("backup_server") - ftp_server: FTPServer = backup_server.software_manager.software.get("FTPServer") + ftp_server: FTPServer = backup_server.software_manager.software["FTPServer"] # backup file should exist in the backup server assert ftp_server.file_system.get_file(folder_name=db_service.uuid, file_name="database.db") is not None @@ -81,7 +136,7 @@ def test_create_database_backup(uc2_network): def test_restore_backup(uc2_network): """Run the restore_backup method and check if the backup is properly restored.""" db_server: Server = uc2_network.get_node_by_hostname("database_server") - db_service: DatabaseService = db_server.software_manager.software.get("DatabaseService") + db_service: DatabaseService = db_server.software_manager.software["DatabaseService"] # create a back up assert db_service.backup_database() is True @@ -107,7 +162,7 @@ def test_database_client_cannot_query_offline_database_server(uc2_network): web_server: Server = uc2_network.get_node_by_hostname("web_server") db_client: DatabaseClient = web_server.software_manager.software.get("DatabaseClient") - assert db_client.connected + assert len(db_client.connections) assert db_client.query("SELECT") is True diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py index b0ff0467..2ca67119 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_data_manipulation_bot.py @@ -70,4 +70,4 @@ def test_dm_bot_perform_data_manipulation_success(dm_bot): dm_bot._perform_data_manipulation(p_of_success=1.0) assert dm_bot.attack_stage in (DataManipulationAttackStage.SUCCEEDED, DataManipulationAttackStage.FAILED) - assert dm_bot.connected + assert len(dm_bot.connections) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py index 59d44561..15d28d4b 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py @@ -1,5 +1,6 @@ from ipaddress import IPv4Address from typing import Tuple, Union +from uuid import uuid4 import pytest @@ -65,15 +66,14 @@ def test_disconnect(database_client_on_computer): """Database client should set connected to False and remove the database server ip address.""" database_client, computer = database_client_on_computer - database_client.connected = True + database_client.connections[uuid4()] = {} assert database_client.operating_state is ApplicationOperatingState.RUNNING assert database_client.server_ip_address is not None database_client.disconnect() - assert database_client.connected is False - assert database_client.server_ip_address is None + assert len(database_client.connections) == 0 def test_query_when_client_is_closed(database_client_on_computer): @@ -86,19 +86,6 @@ def test_query_when_client_is_closed(database_client_on_computer): assert database_client.query(sql="test") is False -def test_query_failed_reattempt(database_client_on_computer): - """Database client query should return False if the reattempt fails.""" - database_client, computer = database_client_on_computer - - def return_false(): - return False - - database_client.connect = return_false - - database_client.connected = False - assert database_client.query(sql="test", is_reattempt=True) is False - - def test_query_fail_to_connect(database_client_on_computer): """Database client query should return False if the connect attempt fails.""" database_client, computer = database_client_on_computer From 4f79d2ad36abd5e25aca33e09577dd3669aa098b Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 12 Dec 2023 17:01:03 +0000 Subject: [PATCH 34/54] #2059: moved connection handling from Service to IOSoftware + changes that now utilise connections from IOSoftware + dos bot attacking now works + tests --- .../system/applications/database_client.py | 10 +- .../red_applications/data_manipulation_bot.py | 5 +- .../applications/red_applications/dos_bot.py | 184 ++++++++++++++++++ .../services/database/database_service.py | 7 +- .../system/services/ftp/ftp_client.py | 24 +-- .../system/services/ftp/ftp_server.py | 2 +- .../simulator/system/services/service.py | 60 ------ src/primaite/simulator/system/software.py | 63 ++++++ .../test_dos_bot_and_server.py | 107 ++++++++++ .../_red_applications/test_dos_bot.py | 90 +++++++++ .../_applications/test_database_client.py | 13 +- .../_system/_services/test_services.py | 33 ++++ 12 files changed, 510 insertions(+), 88 deletions(-) create mode 100644 src/primaite/simulator/system/applications/red_applications/dos_bot.py create mode 100644 tests/integration_tests/system/red_applications/test_dos_bot_and_server.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_dos_bot.py diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py index 9d7bfcaa..fbeefe6a 100644 --- a/src/primaite/simulator/system/applications/database_client.py +++ b/src/primaite/simulator/system/applications/database_client.py @@ -5,7 +5,7 @@ from uuid import uuid4 from primaite import getLogger from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.applications.application import Application, ApplicationOperatingState +from primaite.simulator.system.applications.application import Application from primaite.simulator.system.core.software_manager import SoftwareManager _LOGGER = getLogger(__name__) @@ -23,7 +23,6 @@ class DatabaseClient(Application): server_ip_address: Optional[IPv4Address] = None server_password: Optional[str] = None - connections: Dict[str, Dict] = {} _query_success_tracker: Dict[str, bool] = {} def __init__(self, **kwargs): @@ -143,7 +142,7 @@ class DatabaseClient(Application): dest_ip_address=self.server_ip_address, dest_port=self.port, ) - self.connections.pop(connection_id) + self.remove_connection(connection_id=connection_id) self.sys_log.info( f"{self.name}: DatabaseClient disconnected connection {connection_id} from {self.server_ip_address}" @@ -181,8 +180,6 @@ class DatabaseClient(Application): def run(self) -> None: """Run the DatabaseClient.""" super().run() - if self.operating_state == ApplicationOperatingState.RUNNING: - self.connect() def query(self, sql: str, connection_id: Optional[str] = None) -> bool: """ @@ -221,7 +218,8 @@ class DatabaseClient(Application): if isinstance(payload, dict) and payload.get("type"): if payload["type"] == "connect_response": if payload["response"] is True: - self.connections[payload.get("connection_id")] = payload + # add connection + self.add_connection(connection_id=payload.get("connection_id"), session_id=session_id) elif payload["type"] == "sql": query_id = payload.get("uuid") status_code = payload.get("status_code") diff --git a/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py b/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py index 87959e9b..a1429e51 100644 --- a/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py +++ b/src/primaite/simulator/system/applications/red_applications/data_manipulation_bot.py @@ -5,7 +5,6 @@ from typing import Optional from primaite import getLogger from primaite.game.science import simulate_trial from primaite.simulator.core import RequestManager, RequestType -from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.database_client import DatabaseClient _LOGGER = getLogger(__name__) @@ -177,9 +176,9 @@ class DataManipulationBot(DatabaseClient): This is the core loop where the bot sequentially goes through the stages of the attack. """ - if self.operating_state != ApplicationOperatingState.RUNNING: + if not self._can_perform_action(): return - if self.server_ip_address and self.payload and self.operating_state: + if self.server_ip_address and self.payload: self.sys_log.info(f"{self.name}: Running") self._logon() self._perform_port_scan(p_of_success=self.port_scan_p_of_success) diff --git a/src/primaite/simulator/system/applications/red_applications/dos_bot.py b/src/primaite/simulator/system/applications/red_applications/dos_bot.py new file mode 100644 index 00000000..e6c643ee --- /dev/null +++ b/src/primaite/simulator/system/applications/red_applications/dos_bot.py @@ -0,0 +1,184 @@ +from enum import IntEnum +from ipaddress import IPv4Address +from typing import Optional + +from primaite import getLogger +from primaite.game.science import simulate_trial +from primaite.simulator.core import RequestManager, RequestType +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.applications.database_client import DatabaseClient + +_LOGGER = getLogger(__name__) + + +class DoSAttackStage(IntEnum): + """Enum representing the different stages of a Denial of Service attack.""" + + NOT_STARTED = 0 + "Attack not yet started." + + PORT_SCAN = 1 + "Attack is in discovery stage - checking if provided ip and port are open." + + ATTACKING = 2 + "Denial of Service attack is in progress." + + COMPLETED = 3 + "Attack is completed." + + +class DoSBot(DatabaseClient, Application): + """A bot that simulates a Denial of Service attack.""" + + target_ip_address: Optional[IPv4Address] = None + """IP address of the target service.""" + + target_port: Optional[Port] = None + """Port of the target service.""" + + payload: Optional[str] = None + """Payload to deliver to the target service as part of the denial of service attack.""" + + repeat: bool = False + """If true, the Denial of Service bot will keep performing the attack.""" + + attack_stage: DoSAttackStage = DoSAttackStage.NOT_STARTED + """Current stage of the DoS kill chain.""" + + port_scan_p_of_success: float = 0.1 + """Probability of port scanning being sucessful.""" + + dos_intensity: float = 0.25 + """How much of the max sessions will be used by the DoS when attacking.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.name = "DoSBot" + self.max_sessions = 1000 # override normal max sessions + + def set_original_state(self): + """Set the original state of the Denial of Service Bot.""" + _LOGGER.debug(f"Setting {self.name} original state on node {self.software_manager.node.hostname}") + super().set_original_state() + vals_to_include = { + "target_ip_address", + "target_port", + "payload", + "repeat", + "attack_stage", + "max_sessions", + "port_scan_p_of_success", + "dos_intensity", + } + self._original_state.update(self.model_dump(include=vals_to_include)) + + def reset_component_for_episode(self, episode: int): + """Reset the original state of the SimComponent.""" + _LOGGER.debug(f"Resetting {self.name} state on node {self.software_manager.node.hostname}") + super().reset_component_for_episode(episode) + + def _init_request_manager(self) -> RequestManager: + rm = super()._init_request_manager() + + rm.add_request(name="execute", request_type=RequestType(func=lambda request, context: self.run())) + + return rm + + def configure( + self, + target_ip_address: IPv4Address, + target_port: Optional[Port] = Port.POSTGRES_SERVER, + payload: Optional[str] = None, + repeat: bool = False, + max_sessions: int = 1000, + ): + """ + Configure the Denial of Service bot. + + :param: target_ip_address: The IP address of the Node containing the target service. + :param: target_port: The port of the target service. Optional - Default is `Port.HTTP` + :param: payload: The payload the DoS Bot will throw at the target service. Optional - Default is `None` + :param: repeat: If True, the bot will maintain the attack. Optional - Default is `True` + :param: max_sessions: The maximum number of sessions the DoS bot will attack with. Optional - Default is 1000 + """ + self.target_ip_address = target_ip_address + self.target_port = target_port + self.payload = payload + self.repeat = repeat + self.max_sessions = max_sessions + self.sys_log.info( + f"{self.name}: Configured the {self.name} with {target_ip_address=}, {target_port=}, {payload=}, {repeat=}." + ) + + def run(self): + """Run the Denial of Service Bot.""" + super().run() + self._application_loop() + + def _application_loop(self): + """ + The main application loop for the Denial of Service bot. + + The loop goes through the stages of a DoS attack. + """ + if not self._can_perform_action(): + return + + # DoS bot cannot do anything without a target + if not self.target_ip_address or not self.target_port: + self.sys_log.error( + f"{self.name} is not properly configured. {self.target_ip_address=}, {self.target_port=}" + ) + return + + self.clear_connections() + self._perform_port_scan(p_of_success=self.port_scan_p_of_success) + self._perform_dos() + + if self.repeat and self.attack_stage is DoSAttackStage.ATTACKING: + self.attack_stage = DoSAttackStage.NOT_STARTED + else: + self.attack_stage = DoSAttackStage.COMPLETED + + def _perform_port_scan(self, p_of_success: Optional[float] = 0.1): + """ + Perform a simulated port scan to check for open SQL ports. + + Advances the attack stage to `PORT_SCAN` if successful. + + :param p_of_success: Probability of successful port scan, by default 0.1. + """ + if self.attack_stage == DoSAttackStage.NOT_STARTED: + # perform a port scan to identify that the SQL port is open on the server + if simulate_trial(p_of_success): + self.sys_log.info(f"{self.name}: Performing port scan") + # perform the port scan + port_is_open = True # Temporary; later we can implement NMAP port scan. + if port_is_open: + self.sys_log.info(f"{self.name}: ") + self.attack_stage = DoSAttackStage.PORT_SCAN + + def _perform_dos(self): + """ + Perform the Denial of Service attack. + + DoSBot does this by clogging up the available connections to a service. + """ + if not self.attack_stage == DoSAttackStage.PORT_SCAN: + return + self.attack_stage = DoSAttackStage.ATTACKING + self.server_ip_address = self.target_ip_address + self.port = self.target_port + + dos_sessions = int(float(self.max_sessions) * self.dos_intensity) + for i in range(dos_sessions): + self.connect() + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a timestep to the bot, iterate through the application loop. + + :param timestep: The timestep value to update the bot's state. + """ + self._application_loop() diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 70a4e6cc..7d313068 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -45,7 +45,7 @@ class DatabaseService(Service): super().set_original_state() vals_to_include = { "password", - "connections", + "_connections", "backup_server", "latest_backup_directory", "latest_backup_file_name", @@ -55,7 +55,7 @@ class DatabaseService(Service): def reset_component_for_episode(self, episode: int): """Reset the original state of the SimComponent.""" _LOGGER.debug("Resetting DatabaseService original state on node {self.software_manager.node.hostname}") - self.connections.clear() + self.clear_connections() super().reset_component_for_episode(episode) def configure_backup(self, backup_server: IPv4Address): @@ -225,9 +225,6 @@ class DatabaseService(Service): :param session_id: The session identifier. :return: True if the Status Code is 200, otherwise False. """ - if not super().receive(payload=payload, session_id=session_id, **kwargs): - return False - result = {"status_code": 500, "data": []} # if server service is down, return error diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py index 52655fa4..7faa5d32 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_client.py +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -20,9 +20,6 @@ class FTPClient(FTPServiceABC): RFC 959: https://datatracker.ietf.org/doc/html/rfc959 """ - connected: bool = False - """Keeps track of whether or not the FTP client is connected to an FTP server.""" - def __init__(self, **kwargs): kwargs["name"] = "FTPClient" kwargs["port"] = Port.FTP @@ -129,10 +126,7 @@ class FTPClient(FTPServiceABC): software_manager.send_payload_to_session_manager( payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port ) - if payload.status_code == FTPStatusCode.OK: - self.connected = False - return True - return False + return payload.status_code == FTPStatusCode.OK def send_file( self, @@ -179,9 +173,9 @@ class FTPClient(FTPServiceABC): return False # check if FTP is currently connected to IP - self.connected = self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) + self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) - if not self.connected: + if not len(self.connections): return False else: self.sys_log.info(f"Sending file {src_folder_name}/{src_file_name} to {str(dest_ip_address)}") @@ -230,9 +224,9 @@ class FTPClient(FTPServiceABC): :type: dest_port: Optional[Port] """ # check if FTP is currently connected to IP - self.connected = self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) + self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) - if not self.connected: + if not len(self.connections): return False else: # send retrieve request @@ -286,6 +280,14 @@ class FTPClient(FTPServiceABC): self.sys_log.error(f"FTP Server could not be found - Error Code: {FTPStatusCode.NOT_FOUND.value}") return False + # if PORT succeeded, add the connection as an active connection list + if payload.ftp_command is FTPCommand.PORT and payload.status_code is FTPStatusCode.OK: + self.add_connection(connection_id=session_id, session_id=session_id) + + # if QUIT succeeded, remove the session from active connection list + if payload.ftp_command is FTPCommand.QUIT and payload.status_code is FTPStatusCode.OK: + self.remove_connection(connection_id=session_id) + self.sys_log.info(f"{self.name}: Received FTP Response {payload.ftp_command.name} {payload.status_code.value}") self._process_ftp_command(payload=payload, session_id=session_id) diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 6e6c1a48..585690b6 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -37,7 +37,7 @@ class FTPServer(FTPServiceABC): def reset_component_for_episode(self, episode: int): """Reset the original state of the SimComponent.""" _LOGGER.debug(f"Resetting FTPServer state on node {self.software_manager.node.hostname}") - self.connections.clear() + self.clear_connections() super().reset_component_for_episode(episode) def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 52187e51..3155a4bd 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,5 +1,3 @@ -import copy -from datetime import datetime from enum import Enum from typing import Any, Dict, Optional @@ -42,9 +40,6 @@ class Service(IOSoftware): restart_countdown: Optional[int] = None "If currently restarting, how many timesteps remain until the restart is finished." - _connections: Dict[str, Dict] = {} - "Active connections to the Service." - def __init__(self, **kwargs): super().__init__(**kwargs) @@ -103,11 +98,6 @@ class Service(IOSoftware): rm.add_request("enable", RequestType(func=lambda request, context: self.enable())) return rm - @property - def connections(self) -> Dict[str, Dict]: - """Return the public version of connections.""" - return copy.copy(self._connections) - def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -123,56 +113,6 @@ class Service(IOSoftware): state["health_state_visible"] = self.health_state_visible.value return state - def add_connection(self, connection_id: str, session_id: Optional[str] = None) -> bool: - """ - Create a new connection to this service. - - Returns true if connection successfully created - - :param: connection_id: UUID of the connection to create - :type: string - """ - # if over or at capacity, set to overwhelmed - if len(self._connections) >= self.max_sessions: - self.health_state_actual = SoftwareHealthState.OVERWHELMED - self.sys_log.error(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.") - return False - else: - # if service was previously overwhelmed, set to good because there is enough space for connections - if self.health_state_actual == SoftwareHealthState.OVERWHELMED: - self.health_state_actual = SoftwareHealthState.GOOD - - # check that connection already doesn't exist - if not self._connections.get(connection_id): - session_details = None - if session_id: - session_details = self._get_session_details(session_id) - self._connections[connection_id] = { - "ip_address": session_details.with_ip_address if session_details else None, - "time": datetime.now(), - } - self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") - return True - # connection with given id already exists - self.sys_log.error( - f"{self.name}: Connect request for {connection_id=} declined. Connection already exists." - ) - return False - - def remove_connection(self, connection_id: str) -> bool: - """ - Remove a connection from this service. - - Returns true if connection successfully removed - - :param: connection_id: UUID of the connection to create - :type: string - """ - if self._connections.get(connection_id): - self._connections.pop(connection_id) - self.sys_log.info(f"{self.name}: Connection {connection_id=} closed.") - return True - def stop(self) -> None: """Stop the service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 8746bdf3..b393ffd8 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -1,4 +1,6 @@ +import copy from abc import abstractmethod +from datetime import datetime from enum import Enum from ipaddress import IPv4Address from typing import Any, Dict, Optional @@ -206,6 +208,8 @@ class IOSoftware(Software): "Indicates if the software uses UDP protocol for communication. Default is True." port: Port "The port to which the software is connected." + _connections: Dict[str, Dict] = {} + "Active connections." def set_original_state(self): """Sets the original state.""" @@ -250,6 +254,65 @@ class IOSoftware(Software): return False return True + @property + def connections(self) -> Dict[str, Dict]: + """Return the public version of connections.""" + return copy.copy(self._connections) + + def add_connection(self, connection_id: str, session_id: Optional[str] = None) -> bool: + """ + Create a new connection to this service. + + Returns true if connection successfully created + + :param: connection_id: UUID of the connection to create + :type: string + """ + # if over or at capacity, set to overwhelmed + if len(self._connections) >= self.max_sessions: + self.health_state_actual = SoftwareHealthState.OVERWHELMED + self.sys_log.error(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.") + return False + else: + # if service was previously overwhelmed, set to good because there is enough space for connections + if self.health_state_actual == SoftwareHealthState.OVERWHELMED: + self.health_state_actual = SoftwareHealthState.GOOD + + # check that connection already doesn't exist + if not self._connections.get(connection_id): + session_details = None + if session_id: + session_details = self._get_session_details(session_id) + self._connections[connection_id] = { + "ip_address": session_details.with_ip_address if session_details else None, + "time": datetime.now(), + } + self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + return True + # connection with given id already exists + self.sys_log.error( + f"{self.name}: Connect request for {connection_id=} declined. Connection already exists." + ) + return False + + def remove_connection(self, connection_id: str) -> bool: + """ + Remove a connection from this service. + + Returns true if connection successfully removed + + :param: connection_id: UUID of the connection to create + :type: string + """ + if self.connections.get(connection_id): + self._connections.pop(connection_id) + self.sys_log.info(f"{self.name}: Connection {connection_id=} closed.") + return True + + def clear_connections(self): + """Clears all the connections from the software.""" + self._connections = {} + def send( self, payload: Any, diff --git a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py new file mode 100644 index 00000000..2828cc25 --- /dev/null +++ b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py @@ -0,0 +1,107 @@ +from ipaddress import IPv4Address +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.server import Server +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.red_applications.dos_bot import DoSAttackStage, DoSBot +from primaite.simulator.system.services.database.database_service import DatabaseService +from primaite.simulator.system.software import SoftwareHealthState + + +@pytest.fixture(scope="function") +def dos_bot_and_db_server(client_server) -> Tuple[DoSBot, Computer, DatabaseService, Server]: + computer, server = client_server + + # Install DoSBot on computer + computer.software_manager.install(DoSBot) + + dos_bot: DoSBot = computer.software_manager.software.get("DoSBot") + dos_bot.configure( + target_ip_address=IPv4Address(server.nics.get(next(iter(server.nics))).ip_address), + target_port=Port.POSTGRES_SERVER, + ) + + # Install FTP Server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + return dos_bot, computer, db_server_service, server + + +def test_repeating_dos_attack(dos_bot_and_db_server): + dos_bot, computer, db_server_service, server = dos_bot_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + dos_bot.port_scan_p_of_success = 1 + dos_bot.repeat = True + dos_bot.run() + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + + assert dos_bot.attack_stage is DoSAttackStage.NOT_STARTED + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + db_server_service.clear_connections() + db_server_service.health_state_actual = SoftwareHealthState.GOOD + assert len(db_server_service.connections) == 0 + + computer.apply_timestep(timestep=1) + server.apply_timestep(timestep=1) + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + + assert dos_bot.attack_stage is DoSAttackStage.NOT_STARTED + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + +def test_non_repeating_dos_attack(dos_bot_and_db_server): + dos_bot, computer, db_server_service, server = dos_bot_and_db_server + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + dos_bot.port_scan_p_of_success = 1 + dos_bot.repeat = False + dos_bot.run() + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + + assert dos_bot.attack_stage is DoSAttackStage.COMPLETED + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + db_server_service.clear_connections() + db_server_service.health_state_actual = SoftwareHealthState.GOOD + assert len(db_server_service.connections) == 0 + + computer.apply_timestep(timestep=1) + server.apply_timestep(timestep=1) + + assert len(dos_bot.connections) == 0 + assert len(db_server_service.connections) == 0 + assert len(dos_bot.connections) == 0 + + assert dos_bot.attack_stage is DoSAttackStage.COMPLETED + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + +def test_dos_bot_database_service_connection(dos_bot_and_db_server): + dos_bot, computer, db_server_service, server = dos_bot_and_db_server + + dos_bot.operating_state = ApplicationOperatingState.RUNNING + dos_bot.attack_stage = DoSAttackStage.PORT_SCAN + dos_bot._perform_dos() + + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_dos_bot.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_dos_bot.py new file mode 100644 index 00000000..71489171 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/_red_applications/test_dos_bot.py @@ -0,0 +1,90 @@ +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.red_applications.dos_bot import DoSAttackStage, DoSBot + + +@pytest.fixture(scope="function") +def dos_bot() -> DoSBot: + computer = Computer( + hostname="compromised_pc", + ip_address="192.168.0.1", + subnet_mask="255.255.255.0", + operating_state=NodeOperatingState.ON, + ) + + computer.software_manager.install(DoSBot) + + dos_bot: DoSBot = computer.software_manager.software.get("DoSBot") + dos_bot.configure(target_ip_address=IPv4Address("192.168.0.1")) + dos_bot.set_original_state() + return dos_bot + + +def test_dos_bot_creation(dos_bot): + """Test that the DoS bot is installed on a node.""" + assert dos_bot is not None + + +def test_dos_bot_reset(dos_bot): + assert dos_bot.target_ip_address == IPv4Address("192.168.0.1") + assert dos_bot.target_port is Port.POSTGRES_SERVER + assert dos_bot.payload is None + assert dos_bot.repeat is False + + dos_bot.configure( + target_ip_address=IPv4Address("192.168.1.1"), target_port=Port.HTTP, payload="payload", repeat=True + ) + + # should reset the relevant items + dos_bot.reset_component_for_episode(episode=0) + assert dos_bot.target_ip_address == IPv4Address("192.168.0.1") + assert dos_bot.target_port is Port.POSTGRES_SERVER + assert dos_bot.payload is None + assert dos_bot.repeat is False + + dos_bot.configure( + target_ip_address=IPv4Address("192.168.1.1"), target_port=Port.HTTP, payload="payload", repeat=True + ) + dos_bot.set_original_state() + dos_bot.reset_component_for_episode(episode=1) + # should reset to the configured value + assert dos_bot.target_ip_address == IPv4Address("192.168.1.1") + assert dos_bot.target_port is Port.HTTP + assert dos_bot.payload == "payload" + assert dos_bot.repeat is True + + +def test_dos_bot_cannot_run_when_node_offline(dos_bot): + dos_bot_node: Computer = dos_bot.parent + assert dos_bot_node.operating_state is NodeOperatingState.ON + + dos_bot_node.power_off() + + for i in range(dos_bot_node.shut_down_duration + 1): + dos_bot_node.apply_timestep(timestep=i) + + assert dos_bot_node.operating_state is NodeOperatingState.OFF + + dos_bot._application_loop() + + # assert not run + assert dos_bot.attack_stage is DoSAttackStage.NOT_STARTED + + +def test_dos_bot_not_configured(dos_bot): + dos_bot.target_ip_address = None + + dos_bot.operating_state = ApplicationOperatingState.RUNNING + dos_bot._application_loop() + + +def test_dos_bot_perform_port_scan(dos_bot): + dos_bot._perform_port_scan(p_of_success=1) + + assert dos_bot.attack_stage is DoSAttackStage.PORT_SCAN diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py index 15d28d4b..204b356f 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_database_client.py @@ -63,10 +63,11 @@ def test_disconnect_when_client_is_closed(database_client_on_computer): def test_disconnect(database_client_on_computer): - """Database client should set connected to False and remove the database server ip address.""" + """Database client should remove the connection.""" database_client, computer = database_client_on_computer - database_client.connections[uuid4()] = {} + database_client._connections[str(uuid4())] = {"item": True} + assert len(database_client.connections) == 1 assert database_client.operating_state is ApplicationOperatingState.RUNNING assert database_client.server_ip_address is not None @@ -75,6 +76,14 @@ def test_disconnect(database_client_on_computer): assert len(database_client.connections) == 0 + uuid = str(uuid4()) + database_client._connections[uuid] = {"item": True} + assert len(database_client.connections) == 1 + + database_client.disconnect(connection_id=uuid) + + assert len(database_client.connections) == 0 + def test_query_when_client_is_closed(database_client_on_computer): """Database client should return False when it is not running.""" diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py index b32463a2..016cf011 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + from primaite.simulator.system.services.service import ServiceOperatingState from primaite.simulator.system.software import SoftwareHealthState @@ -66,3 +68,34 @@ def test_enable_disable(service): service.enable() assert service.operating_state == ServiceOperatingState.STOPPED + + +def test_overwhelm_service(service): + service.max_sessions = 2 + service.start() + + uuid = str(uuid4()) + assert service.add_connection(connection_id=uuid) # should be true + assert service.health_state_actual is SoftwareHealthState.GOOD + + assert not service.add_connection(connection_id=uuid) # fails because connection already exists + assert service.health_state_actual is SoftwareHealthState.GOOD + + assert service.add_connection(connection_id=str(uuid4())) # succeed + assert service.health_state_actual is SoftwareHealthState.GOOD + + assert not service.add_connection(connection_id=str(uuid4())) # fail because at capacity + assert service.health_state_actual is SoftwareHealthState.OVERWHELMED + + +def test_create_and_remove_connections(service): + service.start() + uuid = str(uuid4()) + + assert service.add_connection(connection_id=uuid) # should be true + assert len(service.connections) == 1 + assert service.health_state_actual is SoftwareHealthState.GOOD + + assert service.remove_connection(connection_id=uuid) # should be true + assert len(service.connections) == 0 + assert service.health_state_actual is SoftwareHealthState.GOOD From e620771c8d5524e481f2a1d4d291076fac0dd633 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Tue, 12 Dec 2023 17:08:11 +0000 Subject: [PATCH 35/54] 2041: Remove IP address from NTP client (review comment) --- .../system/services/ntp/ntp_client.py | 16 ++++-------- .../system/services/ntp/ntp_server.py | 25 ++++++------------- .../system/test_ntp_client_server.py | 8 ++---- 3 files changed, 15 insertions(+), 34 deletions(-) diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index f9cf29d4..e3cd21cf 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -14,8 +14,6 @@ _LOGGER = getLogger(__name__) class NTPClient(Service): """Represents a NTP client as a service.""" - ip_addr: Optional[IPv4Address] = None - "The IP address of the NTP client" ntp_server: Optional[IPv4Address] = None "The NTP server the client sends requests to." time: Optional[datetime] = None @@ -27,16 +25,15 @@ class NTPClient(Service): super().__init__(**kwargs) self.start() - def configure(self, ntp_server_ip_address: IPv4Address, ntp_client_ip_address: IPv4Address) -> None: + 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.ip_addr = ntp_client_ip_address self.ntp_server = ntp_server_ip_address - self.sys_log.info(f"{self.name}: ip_addr: {self.ip_addr}, ntp_server: {self.ntp_server}") + self.sys_log.info(f"{self.name}: ntp_server: {self.ntp_server}") def describe_state(self) -> Dict: """ @@ -78,7 +75,6 @@ class NTPClient(Service): :return: True if successful, False otherwise. """ - self.ip_addr = payload.ntp_request.ntp_client return super().send( payload=payload, dest_ip_address=dest_ip_address, @@ -99,9 +95,7 @@ class NTPClient(Service): :param session_id: The Session ID the payload is to originate from. Optional. :return: True if successful, False otherwise. """ - self.sys_log.info(f"{self.name}: Receiving NTP request from {payload.ntp_request.ntp_client}") - - if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): + if not isinstance(payload, NTPPacket): _LOGGER.debug(f"{payload} is not a NTPPacket") return False if payload.ntp_reply.ntp_datetime: @@ -114,7 +108,7 @@ class NTPClient(Service): def request_time(self) -> None: """Send request to ntp_server.""" - ntp_request = NTPRequest(ntp_client=self.ip_addr) + ntp_request = NTPRequest() ntp_server_packet = NTPPacket(ntp_request=ntp_request) self.send(payload=ntp_server_packet, dest_ip_address=self.ntp_server) @@ -129,7 +123,7 @@ class NTPClient(Service): :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: IP address: {self.ip_addr}") + self.sys_log.info(f"{self.name} apply_timestep") super().apply_timestep(timestep) if self.operating_state == ServiceOperatingState.RUNNING: # request time from server diff --git a/src/primaite/simulator/system/services/ntp/ntp_server.py b/src/primaite/simulator/system/services/ntp/ntp_server.py index 400c397f..0a66384a 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_server.py +++ b/src/primaite/simulator/system/services/ntp/ntp_server.py @@ -60,23 +60,14 @@ class NTPServer(Service): :return: True if valid NTP request else False. """ - self.sys_log.info(f"{self.name} received request from {payload.ntp_request.ntp_client}") - if not (isinstance(payload, NTPPacket) and payload.ntp_request.ntp_client): + if not (isinstance(payload, NTPPacket)): _LOGGER.debug(f"{payload} is not a NTPPacket") return False payload: NTPPacket = payload - if payload.ntp_request.ntp_client: - self.sys_log.info( - f"{self.name}: Received request for {payload.ntp_request.ntp_client} \ - from session {session_id}" - ) - # generate a reply with the current time - time = datetime.now() - payload = payload.generate_reply(time) - self.sys_log.info( - f"{self.name}: Responding to NTP request for {payload.ntp_request.ntp_client} " - f"with current time: {time}" - ) - # send reply - self.send(payload, session_id) - return True + + # 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 index 97b2fe30..c30fd5bc 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -51,9 +51,7 @@ def test_ntp_client_server(create_ntp_network): 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"), ntp_client_ip_address=IPv4Address("192.168.0.1") - ) + ntp_client.configure(ntp_server_ip_address=IPv4Address("192.168.0.2")) assert ntp_client.time is None ntp_client.request_time() @@ -74,9 +72,7 @@ def test_ntp_server_failure(create_ntp_network): 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"), ntp_client_ip_address=IPv4Address("192.168.0.1") - ) + ntp_client.configure(ntp_server_ip_address=IPv4Address("192.168.0.2")) # Turn off ntp server. ntp_server.stop() From f0be77c79b6f2118489b972fcd443d934b650129 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 12 Dec 2023 17:20:31 +0000 Subject: [PATCH 36/54] #2059: configure missing configurable items --- .../system/applications/red_applications/dos_bot.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/dos_bot.py b/src/primaite/simulator/system/applications/red_applications/dos_bot.py index e6c643ee..84e0abb2 100644 --- a/src/primaite/simulator/system/applications/red_applications/dos_bot.py +++ b/src/primaite/simulator/system/applications/red_applications/dos_bot.py @@ -49,7 +49,7 @@ class DoSBot(DatabaseClient, Application): port_scan_p_of_success: float = 0.1 """Probability of port scanning being sucessful.""" - dos_intensity: float = 0.25 + dos_intensity: float = 1 """How much of the max sessions will be used by the DoS when attacking.""" def __init__(self, **kwargs): @@ -91,6 +91,8 @@ class DoSBot(DatabaseClient, Application): target_port: Optional[Port] = Port.POSTGRES_SERVER, payload: Optional[str] = None, repeat: bool = False, + port_scan_p_of_success: float = 0.1, + dos_intensity: float = 1, max_sessions: int = 1000, ): """ @@ -100,15 +102,21 @@ class DoSBot(DatabaseClient, Application): :param: target_port: The port of the target service. Optional - Default is `Port.HTTP` :param: payload: The payload the DoS Bot will throw at the target service. Optional - Default is `None` :param: repeat: If True, the bot will maintain the attack. Optional - Default is `True` + :param: port_scan_p_of_success: The chance of the port scan being sucessful. Optional - Default is 0.1 (10%) + :param: dos_intensity: The intensity of the DoS attack. + Multiplied with the application's max session - Default is 1.0 :param: max_sessions: The maximum number of sessions the DoS bot will attack with. Optional - Default is 1000 """ self.target_ip_address = target_ip_address self.target_port = target_port self.payload = payload self.repeat = repeat + self.port_scan_p_of_success = port_scan_p_of_success + self.dos_intensity = dos_intensity self.max_sessions = max_sessions self.sys_log.info( - f"{self.name}: Configured the {self.name} with {target_ip_address=}, {target_port=}, {payload=}, {repeat=}." + f"{self.name}: Configured the {self.name} with {target_ip_address=}, {target_port=}, {payload=}, " + f"{repeat=}, {port_scan_p_of_success=}, {dos_intensity=}, {max_sessions=}." ) def run(self): From f7b5c8ae2fda6cd7311bb4052bdfd515f6402e4f Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Wed, 13 Dec 2023 10:34:52 +0000 Subject: [PATCH 37/54] 2041: Remove NTPRequest class (review comment) --- src/primaite/simulator/network/protocols/ntp.py | 9 --------- src/primaite/simulator/system/services/ntp/ntp_client.py | 6 +++--- tests/integration_tests/system/test_ntp_client_server.py | 2 +- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ntp.py b/src/primaite/simulator/network/protocols/ntp.py index df5ce0c1..55353265 100644 --- a/src/primaite/simulator/network/protocols/ntp.py +++ b/src/primaite/simulator/network/protocols/ntp.py @@ -1,7 +1,6 @@ from __future__ import annotations from datetime import datetime -from ipaddress import IPv4Address from typing import Optional from pydantic import BaseModel @@ -9,12 +8,6 @@ from pydantic import BaseModel from primaite.simulator.network.protocols.packet import DataPacket -class NTPRequest(BaseModel): - """Represents a NTP Request packet.""" - - ntp_client: Optional[IPv4Address] = None - - class NTPReply(BaseModel): """Represents a NTP Reply packet.""" @@ -30,8 +23,6 @@ class NTPPacket(DataPacket): :param ntp_reply: NTPReply packet from NTP Server. """ - ntp_request: NTPRequest - "NTP Request packet sent by NTP Client." ntp_reply: Optional[NTPReply] = None def generate_reply(self, ntp_server_time: datetime) -> NTPPacket: diff --git a/src/primaite/simulator/system/services/ntp/ntp_client.py b/src/primaite/simulator/system/services/ntp/ntp_client.py index e3cd21cf..e8c3d0cb 100644 --- a/src/primaite/simulator/system/services/ntp/ntp_client.py +++ b/src/primaite/simulator/system/services/ntp/ntp_client.py @@ -3,7 +3,7 @@ from ipaddress import IPv4Address from typing import Dict, Optional from primaite import getLogger -from primaite.simulator.network.protocols.ntp import NTPPacket, NTPRequest +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 @@ -108,8 +108,8 @@ class NTPClient(Service): def request_time(self) -> None: """Send request to ntp_server.""" - ntp_request = NTPRequest() - ntp_server_packet = NTPPacket(ntp_request=ntp_request) + ntp_server_packet = NTPPacket() + self.send(payload=ntp_server_packet, dest_ip_address=self.ntp_server) def apply_timestep(self, timestep: int) -> None: diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index c30fd5bc..d58e3372 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -7,7 +7,7 @@ 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, NTPRequest +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 From 592e1a3610c2849e8873a9e372a6774ef9b95df7 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 13 Dec 2023 11:56:25 +0000 Subject: [PATCH 38/54] #2059: apply suggestions from PR + adding another test that checks for dos affecting green agent --- .../applications/red_applications/dos_bot.py | 4 +- .../services/database/database_service.py | 4 + .../simulator/system/services/service.py | 2 +- .../test_dos_bot_and_server.py | 75 ++++++++++++++++++- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/primaite/simulator/system/applications/red_applications/dos_bot.py b/src/primaite/simulator/system/applications/red_applications/dos_bot.py index 84e0abb2..dfc48dd3 100644 --- a/src/primaite/simulator/system/applications/red_applications/dos_bot.py +++ b/src/primaite/simulator/system/applications/red_applications/dos_bot.py @@ -49,7 +49,7 @@ class DoSBot(DatabaseClient, Application): port_scan_p_of_success: float = 0.1 """Probability of port scanning being sucessful.""" - dos_intensity: float = 1 + dos_intensity: float = 1.0 """How much of the max sessions will be used by the DoS when attacking.""" def __init__(self, **kwargs): @@ -92,7 +92,7 @@ class DoSBot(DatabaseClient, Application): payload: Optional[str] = None, repeat: bool = False, port_scan_p_of_success: float = 0.1, - dos_intensity: float = 1, + dos_intensity: float = 1.0, max_sessions: int = 1000, ): """ diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 7d313068..6f333091 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -143,6 +143,10 @@ class DatabaseService(Service): status_code = 500 # Default internal server error if self.operating_state == ServiceOperatingState.RUNNING: status_code = 503 # service unavailable + if self.health_state_actual == SoftwareHealthState.OVERWHELMED: + self.sys_log.error( + f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity." + ) if self.health_state_actual == SoftwareHealthState.GOOD: if self.password == password: status_code = 200 # ok diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 3155a4bd..d45ef3a6 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -58,7 +58,7 @@ class Service(IOSoftware): if not super()._can_perform_action(): return False - if self.operating_state is not self.operating_state.RUNNING: + if self.operating_state is not ServiceOperatingState.RUNNING: # service is not running _LOGGER.error(f"Cannot perform action: {self.name} is {self.operating_state.name}") return False diff --git a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py index 2828cc25..85028d75 100644 --- a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py +++ b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py @@ -3,10 +3,13 @@ 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.router import ACLAction, Router from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.applications.red_applications.dos_bot import DoSAttackStage, DoSBot from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.software import SoftwareHealthState @@ -25,7 +28,7 @@ def dos_bot_and_db_server(client_server) -> Tuple[DoSBot, Computer, DatabaseServ target_port=Port.POSTGRES_SERVER, ) - # Install FTP Server service on server + # Install DB Server service on server server.software_manager.install(DatabaseService) db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") db_server_service.start() @@ -33,6 +36,43 @@ def dos_bot_and_db_server(client_server) -> Tuple[DoSBot, Computer, DatabaseServ return dos_bot, computer, db_server_service, server +@pytest.fixture(scope="function") +def dos_bot_db_server_green_client(example_network) -> Network: + network: Network = example_network + + router_1: Router = example_network.get_node_by_hostname("router_1") + router_1.acl.add_rule( + action=ACLAction.PERMIT, src_port=Port.POSTGRES_SERVER, dst_port=Port.POSTGRES_SERVER, position=0 + ) + + client_1: Computer = network.get_node_by_hostname("client_1") + client_2: Computer = network.get_node_by_hostname("client_2") + server: Server = network.get_node_by_hostname("server_1") + + # install DoS bot on client 1 + client_1.software_manager.install(DoSBot) + + dos_bot: DoSBot = client_1.software_manager.software.get("DoSBot") + dos_bot.configure( + target_ip_address=IPv4Address(server.nics.get(next(iter(server.nics))).ip_address), + target_port=Port.POSTGRES_SERVER, + ) + + # install db server service on server + server.software_manager.install(DatabaseService) + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + db_server_service.start() + + # Install DB client (green) on client 2 + client_2.software_manager.install(DatabaseClient) + + database_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + database_client.configure(server_ip_address=IPv4Address("192.168.0.1")) + database_client.run() + + return network + + def test_repeating_dos_attack(dos_bot_and_db_server): dos_bot, computer, db_server_service, server = dos_bot_and_db_server @@ -105,3 +145,36 @@ def test_dos_bot_database_service_connection(dos_bot_and_db_server): assert len(dos_bot.connections) == db_server_service.max_sessions assert len(db_server_service.connections) == db_server_service.max_sessions assert len(dos_bot.connections) == db_server_service.max_sessions + + +def test_dos_blocks_green_agent_connection(dos_bot_db_server_green_client): + network: Network = dos_bot_db_server_green_client + + client_1: Computer = network.get_node_by_hostname("client_1") + dos_bot: DoSBot = client_1.software_manager.software.get("DoSBot") + + client_2: Computer = network.get_node_by_hostname("client_2") + green_db_client: DatabaseClient = client_2.software_manager.software.get("DatabaseClient") + + server: Server = network.get_node_by_hostname("server_1") + db_server_service: DatabaseService = server.software_manager.software.get("DatabaseService") + + assert db_server_service.health_state_actual is SoftwareHealthState.GOOD + + dos_bot.port_scan_p_of_success = 1 + dos_bot.repeat = False + dos_bot.run() + + # DoS bot fills up connection of db server service + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(db_server_service.connections) == db_server_service.max_sessions + assert len(dos_bot.connections) == db_server_service.max_sessions + assert len(green_db_client.connections) == 0 + + assert dos_bot.attack_stage is DoSAttackStage.COMPLETED + # db server service is overwhelmed + assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED + + # green agent tries to connect but fails because service is overwhelmed + assert green_db_client.connect() is False + assert len(green_db_client.connections) == 0 From 1ec7df11701949cfe408d0b766b45227d36fa199 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 14 Dec 2023 11:19:32 +0000 Subject: [PATCH 39/54] Change describe_state to use names instead of uuids --- src/primaite/simulator/domain/controller.py | 2 +- src/primaite/simulator/network/container.py | 18 ++++++++++++++++-- .../simulator/network/hardware/base.py | 19 ++++++++++++------- .../network/hardware/nodes/switch.py | 1 + .../simulator/system/processes/process.py | 2 +- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index e9f3b26d..bc428743 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -102,7 +102,7 @@ class DomainController(SimComponent): :rtype: Dict """ state = super().describe_state() - state.update({"accounts": {uuid: acct.describe_state() for uuid, acct in self.accounts.items()}}) + state.update({"accounts": {acct.username: acct.describe_state() for acct in self.accounts.values()}}) return state def _register_account(self, account: Account) -> None: diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index e1780448..8d8709d3 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -199,10 +199,24 @@ class Network(SimComponent): state = super().describe_state() state.update( { - "nodes": {uuid: node.describe_state() for uuid, node in self.nodes.items()}, - "links": {uuid: link.describe_state() for uuid, link in self.links.items()}, + "nodes": {node.hostname: node.describe_state() for node in self.nodes.values()}, + "links": {}, } ) + # Update the links one-by-one. The key is a 4-tuple of `hostname_a, port_a, hostname_b, port_b` + for uuid, link in self.links.items(): + node_a = link.endpoint_a._connected_node + node_b = link.endpoint_b._connected_node + hostname_a = node_a.hostname if node_a else None + hostname_b = node_b.hostname if node_b else None + port_a = link.endpoint_a._port_num_on_node + port_b = link.endpoint_b._port_num_on_node + state["links"][uuid] = link.describe_state() + state["links"][uuid]["hostname_a"] = hostname_a + state["links"][uuid]["hostname_b"] = hostname_b + state["links"][uuid]["port_a"] = port_a + state["links"][uuid]["port_b"] = port_b + return state def add_node(self, node: Node) -> None: diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index a310a3f5..ad3d73aa 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -91,6 +91,8 @@ class NIC(SimComponent): "Indicates if the NIC supports Wake-on-LAN functionality." _connected_node: Optional[Node] = None "The Node to which the NIC is connected." + _port_num_on_node: Optional[int] = None + "Which port number is assigned on this NIC" _connected_link: Optional[Link] = None "The Link to which the NIC is connected." enabled: bool = False @@ -148,7 +150,7 @@ class NIC(SimComponent): state = super().describe_state() state.update( { - "ip_adress": str(self.ip_address), + "ip_address": str(self.ip_address), "subnet_mask": str(self.subnet_mask), "mac_address": self.mac_address, "speed": self.speed, @@ -311,6 +313,8 @@ class SwitchPort(SimComponent): "The Maximum Transmission Unit (MTU) of the SwitchPort in Bytes. Default is 1500 B" _connected_node: Optional[Node] = None "The Node to which the SwitchPort is connected." + _port_num_on_node: Optional[int] = None + "The port num on the connected node." _connected_link: Optional[Link] = None "The Link to which the SwitchPort is connected." enabled: bool = False @@ -497,8 +501,8 @@ class Link(SimComponent): state = super().describe_state() state.update( { - "endpoint_a": self.endpoint_a.uuid, - "endpoint_b": self.endpoint_b.uuid, + "endpoint_a": self.endpoint_a.uuid, # TODO: consider if using UUID is the best way to do this + "endpoint_b": self.endpoint_b.uuid, # TODO: consider if using UUID is the best way to do this "bandwidth": self.bandwidth, "current_load": self.current_load, } @@ -1094,12 +1098,12 @@ class Node(SimComponent): { "hostname": self.hostname, "operating_state": self.operating_state.value, - "NICs": {uuid: nic.describe_state() for uuid, nic in self.nics.items()}, + "NICs": {eth_num: nic.describe_state() for eth_num, nic in self.ethernet_port.items()}, # "switch_ports": {uuid, sp for uuid, sp in self.switch_ports.items()}, "file_system": self.file_system.describe_state(), - "applications": {uuid: app.describe_state() for uuid, app in self.applications.items()}, - "services": {uuid: svc.describe_state() for uuid, svc in self.services.items()}, - "process": {uuid: proc.describe_state() for uuid, proc in self.processes.items()}, + "applications": {app.name: app.describe_state() for app in self.applications.values()}, + "services": {svc.name: svc.describe_state() for svc in self.services.values()}, + "process": {proc.name: proc.describe_state() for proc in self.processes.values()}, "revealed_to_red": self.revealed_to_red, } ) @@ -1316,6 +1320,7 @@ class Node(SimComponent): self.nics[nic.uuid] = nic self.ethernet_port[len(self.nics)] = nic nic._connected_node = self + nic._port_num_on_node = len(self.nics) nic.parent = self self.sys_log.info(f"Connected NIC {nic}") if self.operating_state == NodeOperatingState.ON: diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py index 92999b88..fffae6e2 100644 --- a/src/primaite/simulator/network/hardware/nodes/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -30,6 +30,7 @@ class Switch(Node): self.switch_ports = {i: SwitchPort() for i in range(1, self.num_ports + 1)} for port_num, port in self.switch_ports.items(): port._connected_node = self + port._port_num_on_node = port_num port.parent = self port.port_num = port_num diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py index ad9af335..b753e3ad 100644 --- a/src/primaite/simulator/system/processes/process.py +++ b/src/primaite/simulator/system/processes/process.py @@ -41,5 +41,5 @@ class Process(Software): :rtype: Dict """ state = super().describe_state() - state.update({"operating_state": self.operating_state.name}) + state.update({"operating_state": self.operating_state.value}) return state From 6a80f4cc77d287892780b4d074c432989a8a0970 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 14 Dec 2023 14:04:43 +0000 Subject: [PATCH 40/54] Make game layer work with new state api --- .gitignore | 1 + .../config/_package_data/example_config.yaml | 28 ++++---- src/primaite/game/agent/observations.py | 46 ++++++------ src/primaite/game/agent/rewards.py | 71 ++++++------------- .../assets/configs/bad_primaite_session.yaml | 28 ++++---- .../configs/eval_only_primaite_session.yaml | 35 ++++----- tests/assets/configs/multi_agent_session.yaml | 63 ++++++++-------- .../assets/configs/test_primaite_session.yaml | 37 ++++------ .../configs/train_only_primaite_session.yaml | 30 ++++---- .../game_layer/test_observations.py | 2 +- 10 files changed, 145 insertions(+), 196 deletions(-) diff --git a/.gitignore b/.gitignore index 892751d9..8be60770 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,4 @@ benchmark/output # src/primaite/notebooks/scratch.ipynb src/primaite/notebooks/scratch.py sandbox.py +sandbox.ipynb diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index 24f9945d..db0bca74 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -105,25 +105,25 @@ agents: num_files_per_folder: 1 num_nics_per_node: 2 nodes: - - node_ref: domain_controller + - node_hostname: domain_controller services: - - service_ref: domain_controller_dns_server - - node_ref: web_server + - service_name: DNSServer + - node_hostname: web_server services: - - service_ref: web_server_database_client - - node_ref: database_server + - service_name: web_server_database_client + - node_hostname: database_server services: - - service_ref: database_service + - service_name: database_service folders: - folder_name: database files: - file_name: database.db - - node_ref: backup_server + - node_hostname: backup_server # services: # - service_ref: backup_service - - node_ref: security_suite - - node_ref: client_1 - - node_ref: client_2 + - node_hostname: security_suite + - node_hostname: client_1 + - node_hostname: client_2 links: - link_ref: router_1___switch_1 - link_ref: router_1___switch_2 @@ -138,7 +138,7 @@ agents: acl: options: max_acl_rules: 10 - router_node_ref: router_1 + router_hostname: router_1 ip_address_order: - node_ref: domain_controller nic_num: 1 @@ -509,7 +509,7 @@ agents: - type: DATABASE_FILE_INTEGRITY weight: 0.5 options: - node_ref: database_server + node_hostname: database_server folder_name: database file_name: database.db @@ -517,8 +517,8 @@ agents: - type: WEB_SERVER_404_PENALTY weight: 0.5 options: - node_ref: web_server - service_ref: web_server_web_service + node_hostname: web_server + service_name: web_server_web_service agent_settings: diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index 767514b4..ac091b77 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -40,8 +40,7 @@ class AbstractObservation(ABC): def from_config(cls, config: Dict, game: "PrimaiteGame"): """Create this observation space component form a serialised format. - The `game` parameter is for a the PrimaiteGame object that spawns this component. During deserialisation, - a subclass of this class may need to translate from a 'reference' to a UUID. + The `game` parameter is for a the PrimaiteGame object that spawns this component. """ pass @@ -53,12 +52,12 @@ class FileObservation(AbstractObservation): """ Initialise file observation. - :param where: Store information about where in the simulation state dictionary to find the relevatn information. + :param where: Store information about where in the simulation state dictionary to find the relevant information. Optional. If None, this corresponds that the file does not exist and the observation will be populated with zeroes. A typical location for a file looks like this: - ['network','nodes',,'file_system', 'folders',,'files',] + ['network','nodes',,'file_system', 'folders',,'files',] :type where: Optional[List[str]] """ super().__init__() @@ -120,7 +119,7 @@ class ServiceObservation(AbstractObservation): zeroes. A typical location for a service looks like this: - `['network','nodes',,'services', ]` + `['network','nodes',,'services', ]` :type where: Optional[List[str]] """ super().__init__() @@ -162,7 +161,7 @@ class ServiceObservation(AbstractObservation): :return: Constructed service observation :rtype: ServiceObservation """ - return cls(where=parent_where + ["services", game.ref_map_services[config["service_ref"]]]) + return cls(where=parent_where + ["services", config["service_name"]]) class LinkObservation(AbstractObservation): @@ -179,7 +178,7 @@ class LinkObservation(AbstractObservation): zeroes. A typical location for a service looks like this: - `['network','nodes',,'servics', ]` + `['network','nodes',,'servics', ]` :type where: Optional[List[str]] """ super().__init__() @@ -242,7 +241,7 @@ class FolderObservation(AbstractObservation): :param where: Where in the simulation state dictionary to find the relevant information for this folder. A typical location for a file looks like this: - ['network','nodes',,'file_system', 'folders',] + ['network','nodes',,'file_system', 'folders',] :type where: Optional[List[str]] :param max_files: As size of the space must remain static, define max files that can be in this folder , defaults to 5 @@ -321,7 +320,7 @@ class FolderObservation(AbstractObservation): :type game: PrimaiteGame :param parent_where: Where in the simulation state dictionary to find the information about this folder's parent node. A typical location for a node ``where`` can be: - ['network','nodes',,'file_system'] + ['network','nodes',,'file_system'] :type parent_where: Optional[List[str]] :param num_files_per_folder: How many spaces for files are in this folder observation (to preserve static observation size) , defaults to 2 @@ -347,7 +346,7 @@ class NicObservation(AbstractObservation): :param where: Where in the simulation state dictionary to find the relevant information for this NIC. A typical example may look like this: - ['network','nodes',,'NICs',] + ['network','nodes',,'NICs',] If None, this denotes that the NIC does not exist and the observation will be populated with zeroes. :type where: Optional[Tuple[str]], optional """ @@ -384,12 +383,12 @@ class NicObservation(AbstractObservation): :param game: Reference to the PrimaiteGame object that spawned this observation. :type game: PrimaiteGame :param parent_where: Where in the simulation state dictionary to find the information about this NIC's parent - node. A typical location for a node ``where`` can be: ['network','nodes',] + node. A typical location for a node ``where`` can be: ['network','nodes',] :type parent_where: Optional[List[str]] :return: Constructed NIC observation :rtype: NicObservation """ - return cls(where=parent_where + ["NICs", config["nic_uuid"]]) + return cls(where=parent_where + ["NICs", config["nic_num"]]) class NodeObservation(AbstractObservation): @@ -412,9 +411,9 @@ class NodeObservation(AbstractObservation): :param where: Where in the simulation state dictionary for find relevant information for this observation. A typical location for a node looks like this: - ['network','nodes',]. If empty list, a default null observation will be output, defaults to [] + ['network','nodes',]. If empty list, a default null observation will be output, defaults to [] :type where: List[str], optional - :param services: Mapping between position in observation space and service UUID, defaults to {} + :param services: Mapping between position in observation space and service name, defaults to {} :type services: Dict[int,str], optional :param max_services: Max number of services that can be presented in observation space for this node , defaults to 2 @@ -423,7 +422,7 @@ class NodeObservation(AbstractObservation): :type folders: Dict[int,str], optional :param max_folders: Max number of folders in this node's obs space, defaults to 2 :type max_folders: int, optional - :param nics: Mapping between position in observation space and NIC UUID, defaults to {} + :param nics: Mapping between position in observation space and NIC idx, defaults to {} :type nics: Dict[int,str], optional :param max_nics: Max number of NICS in this node's obs space, defaults to 5 :type max_nics: int, optional @@ -541,11 +540,11 @@ class NodeObservation(AbstractObservation): :return: Constructed node observation :rtype: NodeObservation """ - node_uuid = game.ref_map_nodes[config["node_ref"]] + node_hostname = config["node_hostname"] if parent_where is None: - where = ["network", "nodes", node_uuid] + where = ["network", "nodes", node_hostname] else: - where = parent_where + ["nodes", node_uuid] + where = parent_where + ["nodes", node_hostname] svc_configs = config.get("services", {}) services = [ServiceObservation.from_config(config=c, game=game, parent_where=where) for c in svc_configs] @@ -556,8 +555,8 @@ class NodeObservation(AbstractObservation): ) for c in folder_configs ] - nic_uuids = game.simulation.network.nodes[node_uuid].nics.keys() - nic_configs = [{"nic_uuid": n for n in nic_uuids}] if nic_uuids else [] + # create some configs for the NIC observation in the format {"nic_num":1}, {"nic_num":2}, {"nic_num":3}, etc. + nic_configs = [{"nic_num": i for i in range(num_nics_per_node)}] nics = [NicObservation.from_config(config=c, game=game, parent_where=where) for c in nic_configs] logon_status = config.get("logon_status", False) return cls( @@ -598,7 +597,7 @@ class AclObservation(AbstractObservation): :type protocols: list[str] :param where: Where in the simulation state dictionary to find the relevant information for this ACL. A typical example may look like this: - ['network','nodes',,'acl','acl'] + ['network','nodes',,'acl','acl'] :type where: Optional[Tuple[str]], optional :param num_rules: , defaults to 10 :type num_rules: int, optional @@ -711,12 +710,12 @@ class AclObservation(AbstractObservation): nic_obj = node_obj.ethernet_port[nic_num] node_ip_to_idx[nic_obj.ip_address] = ip_idx + 2 - router_uuid = game.ref_map_nodes[config["router_node_ref"]] + router_hostname = config["router_hostname"] return cls( node_ip_to_id=node_ip_to_idx, ports=game.options.ports, protocols=game.options.protocols, - where=["network", "nodes", router_uuid, "acl", "acl"], + where=["network", "nodes", router_hostname, "acl", "acl"], num_rules=max_acl_rules, ) @@ -846,6 +845,7 @@ class UC2BlueObservation(AbstractObservation): :rtype: UC2BlueObservation """ node_configs = config["nodes"] + num_services_per_node = config["num_services_per_node"] num_folders_per_node = config["num_folders_per_node"] num_files_per_folder = config["num_files_per_folder"] diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 9b3dfb80..e2c7d6fc 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -82,11 +82,11 @@ class DummyReward(AbstractReward): class DatabaseFileIntegrity(AbstractReward): """Reward function component which rewards the agent for maintaining the integrity of a database file.""" - def __init__(self, node_uuid: str, folder_name: str, file_name: str) -> None: + def __init__(self, node_hostname: str, folder_name: str, file_name: str) -> None: """Initialise the reward component. - :param node_uuid: UUID of the node which contains the database file. - :type node_uuid: str + :param node_hostname: Hostname of the node which contains the database file. + :type node_hostname: str :param folder_name: folder which contains the database file. :type folder_name: str :param file_name: name of the database file. @@ -95,7 +95,7 @@ class DatabaseFileIntegrity(AbstractReward): self.location_in_state = [ "network", "nodes", - node_uuid, + node_hostname, "file_system", "folders", folder_name, @@ -129,49 +129,29 @@ class DatabaseFileIntegrity(AbstractReward): :return: The reward component. :rtype: DatabaseFileIntegrity """ - node_ref = config.get("node_ref") + node_hostname = config.get("node_hostname") folder_name = config.get("folder_name") file_name = config.get("file_name") - if not node_ref: - _LOGGER.error( - f"{cls.__name__} could not be initialised from config because node_ref parameter was not specified" - ) - return DummyReward() # TODO: better error handling - if not folder_name: - _LOGGER.error( - f"{cls.__name__} could not be initialised from config because folder_name parameter was not specified" - ) - return DummyReward() # TODO: better error handling - if not file_name: - _LOGGER.error( - f"{cls.__name__} could not be initialised from config because file_name parameter was not specified" - ) - return DummyReward() # TODO: better error handling - node_uuid = game.ref_map_nodes[node_ref] - if not node_uuid: - _LOGGER.error( - ( - f"{cls.__name__} could not be initialised from config because the referenced node could not be " - f"found in the simulation" - ) - ) - return DummyReward() # TODO: better error handling + if not node_hostname and folder_name and file_name: + msg = f"{cls.__name__} could not be initialised with parameters {config}" + _LOGGER.error(msg) + raise ValueError(msg) - return cls(node_uuid=node_uuid, folder_name=folder_name, file_name=file_name) + return cls(node_hostname=node_hostname, folder_name=folder_name, file_name=file_name) class WebServer404Penalty(AbstractReward): """Reward function component which penalises the agent when the web server returns a 404 error.""" - def __init__(self, node_uuid: str, service_uuid: str) -> None: + def __init__(self, node_hostname: str, service_name: str) -> None: """Initialise the reward component. - :param node_uuid: UUID of the node which contains the web server service. - :type node_uuid: str - :param service_uuid: UUID of the web server service. - :type service_uuid: str + :param node_hostname: Hostname of the node which contains the web server service. + :type node_hostname: str + :param service_node: Name of the web server service. + :type service_node: str """ - self.location_in_state = ["network", "nodes", node_uuid, "services", service_uuid] + self.location_in_state = ["network", "nodes", node_hostname, "services", service_name] def calculate(self, state: Dict) -> float: """Calculate the reward for the current state. @@ -203,26 +183,17 @@ class WebServer404Penalty(AbstractReward): :return: The reward component. :rtype: WebServer404Penalty """ - node_ref = config.get("node_ref") - service_ref = config.get("service_ref") - if not (node_ref and service_ref): + node_hostname = config.get("node_hostname") + service_name = config.get("service_name") + if not (node_hostname and service_name): msg = ( f"{cls.__name__} could not be initialised from config because node_ref and service_ref were not " "found in reward config." ) _LOGGER.warning(msg) - return DummyReward() # TODO: should we error out with incorrect inputs? Probably! - node_uuid = game.ref_map_nodes[node_ref] - service_uuid = game.ref_map_services[service_ref] - if not (node_uuid and service_uuid): - msg = ( - f"{cls.__name__} could not be initialised because node {node_ref} and service {service_ref} were not" - " found in the simulator." - ) - _LOGGER.warning(msg) - return DummyReward() # TODO: consider erroring here as well + raise ValueError(msg) - return cls(node_uuid=node_uuid, service_uuid=service_uuid) + return cls(node_hostname=node_hostname, service_name=service_name) class RewardFunction: diff --git a/tests/assets/configs/bad_primaite_session.yaml b/tests/assets/configs/bad_primaite_session.yaml index 9070f246..478cbfae 100644 --- a/tests/assets/configs/bad_primaite_session.yaml +++ b/tests/assets/configs/bad_primaite_session.yaml @@ -93,25 +93,25 @@ agents: num_files_per_folder: 1 num_nics_per_node: 2 nodes: - - node_ref: domain_controller + - node_hostname: domain_controller services: - - service_ref: domain_controller_dns_server - - node_ref: web_server + - service_name: domain_controller_dns_server + - node_hostname: web_server services: - - service_ref: web_server_database_client - - node_ref: database_server + - service_name: web_server_database_client + - node_hostname: database_server services: - - service_ref: database_service + - service_name: database_service folders: - folder_name: database files: - file_name: database.db - - node_ref: backup_server + - node_hostname: backup_server # services: # - service_ref: backup_service - - node_ref: security_suite - - node_ref: client_1 - - node_ref: client_2 + - node_hostname: security_suite + - node_hostname: client_1 + - node_hostname: client_2 links: - link_ref: router_1___switch_1 - link_ref: router_1___switch_2 @@ -126,7 +126,7 @@ agents: acl: options: max_acl_rules: 10 - router_node_ref: router_1 + router_hostname: router_1 ip_address_order: - node_ref: domain_controller nic_num: 1 @@ -497,7 +497,7 @@ agents: - type: DATABASE_FILE_INTEGRITY weight: 0.5 options: - node_ref: database_server + node_hostname: database_server folder_name: database file_name: database.db @@ -505,8 +505,8 @@ agents: - type: WEB_SERVER_404_PENALTY weight: 0.5 options: - node_ref: web_server - service_ref: web_server_web_service + node_hostname: web_server + service_name: web_server_web_service agent_settings: diff --git a/tests/assets/configs/eval_only_primaite_session.yaml b/tests/assets/configs/eval_only_primaite_session.yaml index e67f6606..ec6bfb63 100644 --- a/tests/assets/configs/eval_only_primaite_session.yaml +++ b/tests/assets/configs/eval_only_primaite_session.yaml @@ -31,13 +31,6 @@ agents: action_space: action_list: - type: DONOTHING - # - # - type: NODE_LOGON - # - type: NODE_LOGOFF - # - type: NODE_APPLICATION_EXECUTE - # options: - # execution_definition: - # target_address: arcd.com options: nodes: @@ -104,25 +97,25 @@ agents: num_files_per_folder: 1 num_nics_per_node: 2 nodes: - - node_ref: domain_controller + - node_hostname: domain_controller services: - - service_ref: domain_controller_dns_server - - node_ref: web_server + - service_name: domain_controller_dns_server + - node_hostname: web_server services: - - service_ref: web_server_database_client - - node_ref: database_server + - service_name: web_server_database_client + - node_hostname: database_server services: - - service_ref: database_service + - service_name: database_service folders: - folder_name: database files: - file_name: database.db - - node_ref: backup_server + - node_hostname: backup_server # services: # - service_ref: backup_service - - node_ref: security_suite - - node_ref: client_1 - - node_ref: client_2 + - node_hostname: security_suite + - node_hostname: client_1 + - node_hostname: client_2 links: - link_ref: router_1___switch_1 - link_ref: router_1___switch_2 @@ -137,7 +130,7 @@ agents: acl: options: max_acl_rules: 10 - router_node_ref: router_1 + router_hostname: router_1 ip_address_order: - node_ref: domain_controller nic_num: 1 @@ -508,7 +501,7 @@ agents: - type: DATABASE_FILE_INTEGRITY weight: 0.5 options: - node_ref: database_server + node_hostname: database_server folder_name: database file_name: database.db @@ -516,8 +509,8 @@ agents: - type: WEB_SERVER_404_PENALTY weight: 0.5 options: - node_ref: web_server - service_ref: web_server_web_service + node_hostname: web_server + service_name: web_server_web_service agent_settings: diff --git a/tests/assets/configs/multi_agent_session.yaml b/tests/assets/configs/multi_agent_session.yaml index 220ca21e..3671b809 100644 --- a/tests/assets/configs/multi_agent_session.yaml +++ b/tests/assets/configs/multi_agent_session.yaml @@ -37,13 +37,6 @@ agents: action_space: action_list: - type: DONOTHING - # - # - type: NODE_LOGON - # - type: NODE_LOGOFF - # - type: NODE_APPLICATION_EXECUTE - # options: - # execution_definition: - # target_address: arcd.com options: nodes: @@ -111,25 +104,25 @@ agents: num_files_per_folder: 1 num_nics_per_node: 2 nodes: - - node_ref: domain_controller + - node_hostname: domain_controller services: - - service_ref: domain_controller_dns_server - - node_ref: web_server + - service_name: domain_controller_dns_server + - node_hostname: web_server services: - - service_ref: web_server_database_client - - node_ref: database_server + - service_name: web_server_database_client + - node_hostname: database_server services: - - service_ref: database_service + - service_name: database_service folders: - folder_name: database files: - file_name: database.db - - node_ref: backup_server + - node_hostname: backup_server # services: # - service_ref: backup_service - - node_ref: security_suite - - node_ref: client_1 - - node_ref: client_2 + - node_hostname: security_suite + - node_hostname: client_1 + - node_hostname: client_2 links: - link_ref: router_1___switch_1 - link_ref: router_1___switch_2 @@ -144,7 +137,7 @@ agents: acl: options: max_acl_rules: 10 - router_node_ref: router_1 + router_hostname: router_1 ip_address_order: - node_ref: domain_controller nic_num: 1 @@ -515,7 +508,7 @@ agents: - type: DATABASE_FILE_INTEGRITY weight: 0.5 options: - node_ref: database_server + node_hostname: database_server folder_name: database file_name: database.db @@ -523,8 +516,8 @@ agents: - type: WEB_SERVER_404_PENALTY weight: 0.5 options: - node_ref: web_server - service_ref: web_server_web_service + node_hostname: web_server + service_name: web_server_web_service agent_settings: @@ -542,25 +535,25 @@ agents: num_files_per_folder: 1 num_nics_per_node: 2 nodes: - - node_ref: domain_controller + - node_hostname: domain_controller services: - - service_ref: domain_controller_dns_server - - node_ref: web_server + - service_name: domain_controller_dns_server + - node_hostname: web_server services: - - service_ref: web_server_database_client - - node_ref: database_server + - service_name: web_server_database_client + - node_hostname: database_server services: - - service_ref: database_service + - service_name: database_service folders: - folder_name: database files: - file_name: database.db - - node_ref: backup_server + - node_hostname: backup_server # services: # - service_ref: backup_service - - node_ref: security_suite - - node_ref: client_1 - - node_ref: client_2 + - node_hostname: security_suite + - node_hostname: client_1 + - node_hostname: client_2 links: - link_ref: router_1___switch_1 - link_ref: router_1___switch_2 @@ -575,7 +568,7 @@ agents: acl: options: max_acl_rules: 10 - router_node_ref: router_1 + router_hostname: router_1 ip_address_order: - node_ref: domain_controller nic_num: 1 @@ -946,7 +939,7 @@ agents: - type: DATABASE_FILE_INTEGRITY weight: 0.5 options: - node_ref: database_server + node_hostname: database_server folder_name: database file_name: database.db @@ -954,8 +947,8 @@ agents: - type: WEB_SERVER_404_PENALTY weight: 0.5 options: - node_ref: web_server - service_ref: web_server_web_service + node_hostname: web_server + service_name: web_server_web_service agent_settings: diff --git a/tests/assets/configs/test_primaite_session.yaml b/tests/assets/configs/test_primaite_session.yaml index d7e94cb6..cc198a64 100644 --- a/tests/assets/configs/test_primaite_session.yaml +++ b/tests/assets/configs/test_primaite_session.yaml @@ -35,13 +35,6 @@ agents: action_space: action_list: - type: DONOTHING - # - # - type: NODE_LOGON - # - type: NODE_LOGOFF - # - type: NODE_APPLICATION_EXECUTE - # options: - # execution_definition: - # target_address: arcd.com options: nodes: @@ -109,25 +102,25 @@ agents: num_files_per_folder: 1 num_nics_per_node: 2 nodes: - - node_ref: domain_controller + - node_hostname: domain_controller services: - - service_ref: domain_controller_dns_server - - node_ref: web_server + - service_name: domain_controller_dns_server + - node_hostname: web_server services: - - service_ref: web_server_database_client - - node_ref: database_server + - service_name: web_server_database_client + - node_hostname: database_server services: - - service_ref: database_service + - service_name: database_service folders: - folder_name: database files: - file_name: database.db - - node_ref: backup_server + - node_hostname: backup_server # services: - # - service_ref: backup_service - - node_ref: security_suite - - node_ref: client_1 - - node_ref: client_2 + # - service_name: backup_service + - node_hostname: security_suite + - node_hostname: client_1 + - node_hostname: client_2 links: - link_ref: router_1___switch_1 - link_ref: router_1___switch_2 @@ -142,7 +135,7 @@ agents: acl: options: max_acl_rules: 10 - router_node_ref: router_1 + router_hostname: router_1 ip_address_order: - node_ref: domain_controller nic_num: 1 @@ -513,7 +506,7 @@ agents: - type: DATABASE_FILE_INTEGRITY weight: 0.5 options: - node_ref: database_server + node_hostname: database_server folder_name: database file_name: database.db @@ -521,8 +514,8 @@ agents: - type: WEB_SERVER_404_PENALTY weight: 0.5 options: - node_ref: web_server - service_ref: web_server_web_service + node_hostname: web_server + service_name: web_server_web_service agent_settings: diff --git a/tests/assets/configs/train_only_primaite_session.yaml b/tests/assets/configs/train_only_primaite_session.yaml index b89349c0..ebef7f6a 100644 --- a/tests/assets/configs/train_only_primaite_session.yaml +++ b/tests/assets/configs/train_only_primaite_session.yaml @@ -105,25 +105,23 @@ agents: num_files_per_folder: 1 num_nics_per_node: 2 nodes: - - node_ref: domain_controller + - node_hostname: domain_controller services: - - service_ref: domain_controller_dns_server - - node_ref: web_server + - service_name: domain_controller_dns_server + - node_hostname: web_server services: - - service_ref: web_server_database_client - - node_ref: database_server + - service_name: web_server_database_client + - node_hostname: database_server services: - - service_ref: database_service + - service_name: database_service folders: - folder_name: database files: - file_name: database.db - - node_ref: backup_server - # services: - # - service_ref: backup_service - - node_ref: security_suite - - node_ref: client_1 - - node_ref: client_2 + - node_hostname: backup_server + - node_hostname: security_suite + - node_hostname: client_1 + - node_hostname: client_2 links: - link_ref: router_1___switch_1 - link_ref: router_1___switch_2 @@ -138,7 +136,7 @@ agents: acl: options: max_acl_rules: 10 - router_node_ref: router_1 + router_hostname: router_1 ip_address_order: - node_ref: domain_controller nic_num: 1 @@ -509,7 +507,7 @@ agents: - type: DATABASE_FILE_INTEGRITY weight: 0.5 options: - node_ref: database_server + node_hostname: database_server folder_name: database file_name: database.db @@ -517,8 +515,8 @@ agents: - type: WEB_SERVER_404_PENALTY weight: 0.5 options: - node_ref: web_server - service_ref: web_server_web_service + node_hostname: web_server + service_name: web_service agent_settings: diff --git a/tests/integration_tests/game_layer/test_observations.py b/tests/integration_tests/game_layer/test_observations.py index 97154f62..07f3d25c 100644 --- a/tests/integration_tests/game_layer/test_observations.py +++ b/tests/integration_tests/game_layer/test_observations.py @@ -14,7 +14,7 @@ def test_file_observation(): state = sim.describe_state() dog_file_obs = FileObservation( - where=["network", "nodes", pc.uuid, "file_system", "folders", "root", "files", "dog.png"] + where=["network", "nodes", pc.hostname, "file_system", "folders", "root", "files", "dog.png"] ) assert dog_file_obs.observe(state) == {"health_status": 1} assert dog_file_obs.space == spaces.Dict({"health_status": spaces.Discrete(6)}) From 0cfd525ab8c2a1a5c4c4ef1c05ec14119f1d6a39 Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 15 Dec 2023 10:14:35 +0000 Subject: [PATCH 41/54] 2041: change comparison operator in test --- tests/integration_tests/system/test_ntp_client_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index d58e3372..f626322f 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -60,7 +60,7 @@ def test_ntp_client_server(create_ntp_network): sleep(0.1) ntp_client.apply_timestep(1) # Check time advances second_time = ntp_client.time - assert first_time != second_time + assert first_time < second_time # Test ntp client behaviour when ntp server is unavailable. From 2d892d4a5acd88e2031ddffa3d336e461169e02c Mon Sep 17 00:00:00 2001 From: Nick Todd Date: Fri, 15 Dec 2023 10:52:46 +0000 Subject: [PATCH 42/54] 2041: Tidy up test comments --- tests/integration_tests/system/test_ntp_client_server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration_tests/system/test_ntp_client_server.py b/tests/integration_tests/system/test_ntp_client_server.py index f626322f..b7839479 100644 --- a/tests/integration_tests/system/test_ntp_client_server.py +++ b/tests/integration_tests/system/test_ntp_client_server.py @@ -13,6 +13,7 @@ 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") @@ -40,9 +41,6 @@ def create_ntp_network(client_server) -> Tuple[NTPClient, Computer, NTPServer, S return ntp_client, client, ntp_server, server -# Define one node to be an NTP server and another node to be a NTP Client. - - def test_ntp_client_server(create_ntp_network): ntp_client, client, ntp_server, server = create_ntp_network From 7a1abb1ef835ab4956b3d6af28711068f130c1a6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 15 Dec 2023 13:09:50 +0000 Subject: [PATCH 43/54] Minor fixes based on code review --- src/primaite/game/agent/observations.py | 2 +- src/primaite/game/agent/rewards.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index ac091b77..928aebfd 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -346,7 +346,7 @@ class NicObservation(AbstractObservation): :param where: Where in the simulation state dictionary to find the relevant information for this NIC. A typical example may look like this: - ['network','nodes',,'NICs',] + ['network','nodes',,'NICs',] If None, this denotes that the NIC does not exist and the observation will be populated with zeroes. :type where: Optional[Tuple[str]], optional """ diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index e2c7d6fc..da51d94f 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -132,7 +132,7 @@ class DatabaseFileIntegrity(AbstractReward): node_hostname = config.get("node_hostname") folder_name = config.get("folder_name") file_name = config.get("file_name") - if not node_hostname and folder_name and file_name: + if not (node_hostname and folder_name and file_name): msg = f"{cls.__name__} could not be initialised with parameters {config}" _LOGGER.error(msg) raise ValueError(msg) @@ -148,8 +148,8 @@ class WebServer404Penalty(AbstractReward): :param node_hostname: Hostname of the node which contains the web server service. :type node_hostname: str - :param service_node: Name of the web server service. - :type service_node: str + :param service_name: Name of the web server service. + :type service_name: str """ self.location_in_state = ["network", "nodes", node_hostname, "services", service_name] From ade5f133d0491398e090cebe8213778183fb3e5b Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 22 Dec 2023 10:31:11 +0000 Subject: [PATCH 44/54] #2139 - Implemented routing --- CHANGELOG.md | 4 + src/primaite/simulator/network/creation.py | 148 ++++++++++++++++++ .../simulator/network/hardware/base.py | 26 +-- .../network/hardware/nodes/router.py | 148 ++++++++++++++---- .../network/hardware/nodes/switch.py | 4 +- .../integration_tests/network/test_routing.py | 93 +++++++++++ 6 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 src/primaite/simulator/network/creation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 541a39d5..96634b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ SessionManager. - 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` +### Changed +- Integrated the RouteTable into the Routers frame processing. +- Frames are now dropped when their TTL reaches 0 + ### Removed - Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` - Removed legacy training modules diff --git a/src/primaite/simulator/network/creation.py b/src/primaite/simulator/network/creation.py new file mode 100644 index 00000000..48313a1f --- /dev/null +++ b/src/primaite/simulator/network/creation.py @@ -0,0 +1,148 @@ +from ipaddress import IPv4Address +from typing import Optional + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +def num_of_switches_required(num_nodes: int, max_switch_ports: int = 24) -> int: + """ + Calculate the minimum number of network switches required to connect a given number of nodes. + + Each switch is assumed to have one port reserved for connecting to a router, reducing the effective + number of ports available for PCs. The function calculates the total number of switches needed + to accommodate all nodes under this constraint. + + :param num_nodes: The total number of nodes that need to be connected in the network. + :param max_switch_ports: The maximum number of ports available on each switch. Defaults to 24. + + :return: The minimum number of switches required to connect all PCs. + + Example: + >>> num_of_switches_required(5) + 1 + >>> num_of_switches_required(24,24) + 2 + >>> num_of_switches_required(48,24) + 3 + >>> num_of_switches_required(25,10) + 3 + """ + # Reduce the effective number of switch ports by 1 to leave space for the router + effective_switch_ports = max_switch_ports - 1 + + # Calculate the number of fully utilised switches and any additional switch for remaining PCs + full_switches = num_nodes // effective_switch_ports + extra_pcs = num_nodes % effective_switch_ports + + # Return the total number of switches required + return full_switches + (1 if extra_pcs > 0 else 0) + + +def create_office_lan( + lan_name: str, + subnet_base: int, + pcs_ip_block_start: int, + num_pcs: int, + network: Optional[Network] = None, + include_router: bool = True, +) -> Network: + """ + Creates a 2-Tier or 3-Tier office local area network (LAN). + + The LAN is configured with a specified number of personal computers (PCs), optionally including a router, + and multiple edge switches to connect them. A core switch is added only if more than one edge switch is required. + The network topology involves edge switches connected either directly to the router in a 2-Tier setup or + to a core switch in a 3-Tier setup. If a router is included, it is connected to the core switch (if present) + and configured with basic access control list (ACL) rules. PCs are distributed across the edge switches. + + + :param str lan_name: The name to be assigned to the LAN. + :param int subnet_base: The subnet base number to be used in the IP addresses. + :param int pcs_ip_block_start: The starting block for assigning IP addresses to PCs. + :param int num_pcs: The number of PCs to be added to the LAN. + :param Optional[Network] network: The network to which the LAN components will be added. If None, a new network is + created. + :param bool include_router: Flag to determine if a router should be included in the LAN. Defaults to True. + :return: The network object with the LAN components added. + :raises ValueError: If pcs_ip_block_start is less than or equal to the number of required switches. + """ + # Initialise the network if not provided + if not network: + network = Network() + + # Calculate the required number of switches + num_of_switches = num_of_switches_required(num_nodes=num_pcs) + effective_switch_ports = 23 # One port less for router connection + if pcs_ip_block_start <= num_of_switches: + raise ValueError(f"pcs_ip_block_start must be greater than the number of required switches {num_of_switches}") + + # Create a core switch if more than one edge switch is needed + if num_of_switches > 1: + core_switch = Switch(hostname=f"switch_core_{lan_name}", start_up_duration=0) + core_switch.power_on() + network.add_node(core_switch) + core_switch_port = 1 + + # Initialise the default gateway to None + default_gateway = None + + # Optionally include a router in the LAN + if include_router: + default_gateway = IPv4Address(f"192.168.{subnet_base}.1") + router = Router(hostname=f"router_{lan_name}", start_up_duration=0) + router.power_on() + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + network.add_node(router) + router.configure_port(port=1, ip_address=default_gateway, subnet_mask="255.255.255.0") + router.enable_port(1) + + # Initialise the first edge switch and connect to the router or core switch + switch_port = 0 + switch_n = 1 + switch = Switch(hostname=f"switch_edge_{switch_n}_{lan_name}", start_up_duration=0) + switch.power_on() + network.add_node(switch) + if num_of_switches > 1: + network.connect(core_switch.switch_ports[core_switch_port], switch.switch_ports[24]) + else: + network.connect(router.ethernet_ports[1], switch.switch_ports[24]) + + # Add PCs to the LAN and connect them to switches + for i in range(1, num_pcs + 1): + # Add a new edge switch if the current one is full + if switch_port == effective_switch_ports: + switch_n += 1 + switch_port = 0 + switch = Switch(hostname=f"switch_edge_{switch_n}_{lan_name}", start_up_duration=0) + switch.power_on() + network.add_node(switch) + # Connect the new switch to the router or core switch + if num_of_switches > 1: + core_switch_port += 1 + network.connect(core_switch.switch_ports[core_switch_port], switch.switch_ports[24]) + else: + network.connect(router.ethernet_ports[1], switch.switch_ports[24]) + + # Create and add a PC to the network + pc = Computer( + hostname=f"pc_{i}_{lan_name}", + ip_address=f"192.168.{subnet_base}.{i+pcs_ip_block_start-1}", + subnet_mask="255.255.255.0", + default_gateway=default_gateway, + start_up_duration=0, + ) + pc.power_on() + network.add_node(pc) + + # Connect the PC to the switch + switch_port += 1 + network.connect(switch.switch_ports[switch_port], pc.ethernet_port[1]) + switch.switch_ports[switch_port].enable() + + return network diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index ad3d73aa..c27378a8 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -4,7 +4,7 @@ import re import secrets from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, Dict, Literal, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, Union from prettytable import MARKDOWN, PrettyTable @@ -282,6 +282,9 @@ class NIC(SimComponent): """ if self.enabled: frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info("Frame discarded as TTL limit reached") + return False frame.set_received_timestamp() self.pcap.capture(frame) # If this destination or is broadcast @@ -436,6 +439,9 @@ class SwitchPort(SimComponent): """ if self.enabled: frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info("Frame discarded as TTL limit reached") + return False self.pcap.capture(frame) connected_node: Node = self._connected_node connected_node.forward_frame(frame=frame, incoming_port=self) @@ -671,7 +677,9 @@ class ARPCache: """Clear the entire ARP cache, removing all stored entries.""" self.arp.clear() - def send_arp_request(self, target_ip_address: Union[IPv4Address, str]): + def send_arp_request( + self, target_ip_address: Union[IPv4Address, str], ignore_networks: Optional[List[IPv4Address]] = None + ): """ Perform a standard ARP request for a given target IP address. @@ -681,7 +689,12 @@ class ARPCache: :param target_ip_address: The target IP address to send an ARP request for. """ for nic in self.nics.values(): - if nic.enabled: + use_nic = True + if ignore_networks: + for ipv4 in ignore_networks: + if ipv4 in nic.ip_network: + use_nic = False + if nic.enabled and use_nic: self.sys_log.info(f"Sending ARP request from NIC {nic} for ip {target_ip_address}") tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) @@ -806,7 +819,6 @@ class ICMP: self.arp.send_arp_request(frame.ip.src_ip_address) self.process_icmp(frame=frame, from_nic=from_nic, is_reattempt=True) return - tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer ip_packet = IPPacket( @@ -821,9 +833,7 @@ class ICMP: sequence=frame.icmp.sequence + 1, ) payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size - frame = Frame( - ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet, payload=payload - ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, icmp=icmp_reply_packet, payload=payload) self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip_address}") src_nic.send_frame(frame) @@ -1447,7 +1457,6 @@ class Node(SimComponent): service.parent = self service.install() # Perform any additional setup, such as creating files for this service on the node. self.sys_log.info(f"Installed service {service.name}") - _LOGGER.info(f"Added service {service.uuid} to node {self.uuid}") self._service_request_manager.add_request(service.uuid, RequestType(func=service._request_manager)) def uninstall_service(self, service: Service) -> None: @@ -1480,7 +1489,6 @@ class Node(SimComponent): self.applications[application.uuid] = application application.parent = self self.sys_log.info(f"Installed application {application.name}") - _LOGGER.info(f"Added application {application.uuid} to node {self.uuid}") self._application_request_manager.add_request(application.uuid, RequestType(func=application._request_manager)) def uninstall_application(self, application: Application) -> None: diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 0234934d..1e3d8022 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -324,11 +324,10 @@ class RouteEntry(SimComponent): """ Represents a single entry in a routing table. - Attributes: - address (IPv4Address): The destination IP address or network address. - subnet_mask (IPv4Address): The subnet mask for the network. - next_hop_ip_address (IPv4Address): The next hop IP address to which packets should be forwarded. - metric (int): The cost metric for this route. Default is 0.0. + :ivar address: The destination IP address or network address. + :ivar subnet_mask: The subnet mask for the network. + :ivar next_hop_ip_address: The next hop IP address to which packets should be forwarded. + :ivar metric: The cost metric for this route. Default is 0.0. Example: >>> entry = RouteEntry( @@ -348,12 +347,6 @@ class RouteEntry(SimComponent): metric: float = 0.0 "The cost metric for this route. Default is 0.0." - def __init__(self, **kwargs): - for key in {"address", "subnet_mask", "next_hop_ip_address"}: - if not isinstance(kwargs[key], IPv4Address): - kwargs[key] = IPv4Address(kwargs[key]) - super().__init__(**kwargs) - def set_original_state(self): """Sets the original state.""" vals_to_include = {"address", "subnet_mask", "next_hop_ip_address", "metric"} @@ -388,6 +381,7 @@ class RouteTable(SimComponent): """ routes: List[RouteEntry] = [] + default_route: Optional[RouteEntry] = None sys_log: SysLog def set_original_state(self): @@ -433,12 +427,35 @@ class RouteTable(SimComponent): ) self.routes.append(route) + def set_default_route_next_hop_ip_address(self, ip_address: IPv4Address): + """ + Sets the next-hop IP address for the default route in a routing table. + + This method checks if a default route (0.0.0.0/0) exists in the routing table. If it does not exist, + the method creates a new default route with the specified next-hop IP address. If a default route already + exists, it updates the next-hop IP address of the existing default route. After setting the next-hop + IP address, the method logs this action. + + :param ip_address: The next-hop IP address to be set for the default route. + """ + if not self.default_route: + self.default_route = RouteEntry( + ip_address=IPv4Address("0.0.0.0"), + subnet_mask=IPv4Address("0.0.0.0"), + next_hop_ip_address=ip_address, + ) + else: + self.default_route.next_hop_ip_address = ip_address + self.sys_log.info(f"Default configured to use {ip_address} as the next-hop") + def find_best_route(self, destination_ip: Union[str, IPv4Address]) -> Optional[RouteEntry]: """ Find the best route for a given destination IP. This method uses the Longest Prefix Match algorithm and considers metrics to find the best route. + If no dedicated route exists but a default route does, then the default route is returned as a last resort. + :param destination_ip: The destination IP to find the route for. :return: The best matching RouteEntry, or None if no route matches. """ @@ -458,6 +475,9 @@ class RouteTable(SimComponent): longest_prefix = prefix_len lowest_metric = route.metric + if not best_route and self.default_route: + best_route = self.default_route + return best_route def show(self, markdown: bool = False): @@ -489,12 +509,26 @@ class RouterARPCache(ARPCache): super().__init__(sys_log) self.router: Router = router - def process_arp_packet(self, from_nic: NIC, frame: Frame): + def process_arp_packet( + self, from_nic: NIC, frame: Frame, route_table: RouteTable, is_reattempt: bool = False + ) -> None: """ - Overridden method to process a received ARP packet in a router-specific way. + Processes a received ARP (Address Resolution Protocol) packet in a router-specific way. + + This method is responsible for handling both ARP requests and responses. It processes ARP packets received on a + Network Interface Card (NIC) and performs actions based on whether the packet is a request or a reply. This + includes updating the ARP cache, forwarding ARP replies, sending ARP requests for unknown destinations, and + handling packet TTL (Time To Live). + + The method first checks if the ARP packet is a request or a reply. For ARP replies, it updates the ARP cache + and forwards the reply if necessary. For ARP requests, it checks if the target IP matches one of the router's + NICs and sends an ARP reply if so. If the destination is not directly connected, it consults the routing table + to find the best route and reattempts ARP request processing if needed. :param from_nic: The NIC that received the ARP packet. - :param frame: The original ARP frame. + :param frame: The frame containing the ARP packet. + :param route_table: The routing table of the router. + :param is_reattempt: Flag to indicate if this is a reattempt of processing the ARP packet, defaults to False. """ arp_packet = frame.arp @@ -522,7 +556,11 @@ class RouterARPCache(ARPCache): ) arp_packet.sender_mac_addr = nic.mac_address frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + return nic.send_frame(frame) + return # ARP Request self.sys_log.info( @@ -533,16 +571,32 @@ class RouterARPCache(ARPCache): self.add_arp_cache_entry( ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic ) - arp_packet = arp_packet.generate_reply(from_nic.mac_address) - self.send_arp_reply(arp_packet, from_nic) # If the target IP matches one of the router's NICs for nic in self.nics.values(): - if nic.enabled and nic.ip_address == arp_packet.target_ip_address: + if arp_packet.target_ip_address in nic.ip_network: + # if nic.enabled and nic.ip_address == arp_packet.target_ip_address: arp_reply = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_reply, from_nic) return + # Check Route Table + route = route_table.find_best_route(arp_packet.target_ip_address) + if route: + nic = self.get_arp_cache_nic(route.next_hop_ip_address) + + if not nic: + if not is_reattempt: + self.send_arp_request(route.next_hop_ip_address, ignore_networks=[frame.ip.src_ip_address]) + return self.process_arp_packet(from_nic, frame, route_table, is_reattempt=True) + else: + pass + # TODO: destination unavailable/No ARP netry found + else: + arp_reply = arp_packet.generate_reply(from_nic.mac_address) + self.send_arp_reply(arp_reply, from_nic) + return + class RouterICMP(ICMP): """ @@ -613,7 +667,7 @@ class RouterICMP(ICMP): return # Route the frame - self.router.route_frame(frame, from_nic) + self.router.process_frame(frame, from_nic) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: for nic in self.router.nics.values(): @@ -633,7 +687,7 @@ class RouterICMP(ICMP): return # Route the frame - self.router.route_frame(frame, from_nic) + self.router.process_frame(frame, from_nic) class Router(Node): @@ -720,9 +774,9 @@ class Router(Node): state["acl"] = (self.acl.describe_state(),) return state - def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: + def process_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: """ - Route a given frame from a source NIC to its destination. + Process a Frame. :param frame: The frame to be routed. :param from_nic: The source network interface. @@ -737,8 +791,10 @@ class Router(Node): return if not nic: - self.arp.send_arp_request(frame.ip.dst_ip_address) - return self.route_frame(frame=frame, from_nic=from_nic, re_attempt=True) + self.arp.send_arp_request( + frame.ip.dst_ip_address, ignore_networks=[frame.ip.src_ip_address, from_nic.ip_address] + ) + return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) if not nic.enabled: # TODO: Add sys_log here @@ -747,15 +803,45 @@ class Router(Node): if frame.ip.dst_ip_address in nic.ip_network: from_port = self._get_port_of_nic(from_nic) to_port = self._get_port_of_nic(nic) - self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") + self.sys_log.info(f"Forwarding frame to internally from port {from_port} to port {to_port}") frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + return frame.ethernet.src_mac_addr = nic.mac_address frame.ethernet.dst_mac_addr = target_mac nic.send_frame(frame) return else: - pass - # TODO: Deal with routing from route tables + self._route_frame(frame, from_nic) + + def _route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: + route = self.route_table.find_best_route(frame.ip.dst_ip_address) + if route: + nic = self.arp.get_arp_cache_nic(route.next_hop_ip_address) + target_mac = self.arp.get_arp_cache_mac_address(route.next_hop_ip_address) + if re_attempt and not nic: + self.sys_log.info(f"Destination {frame.ip.dst_ip_address} is unreachable") + return + + if not nic: + self.arp.send_arp_request(frame.ip.dst_ip_address, ignore_networks=[frame.ip.src_ip_address]) + return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) + + if not nic.enabled: + # TODO: Add sys_log here + return + + from_port = self._get_port_of_nic(from_nic) + to_port = self._get_port_of_nic(nic) + self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + return + frame.ethernet.src_mac_addr = nic.mac_address + frame.ethernet.dst_mac_addr = target_mac + nic.send_frame(frame) def receive_frame(self, frame: Frame, from_nic: NIC): """ @@ -764,7 +850,7 @@ class Router(Node): :param frame: The incoming frame. :param from_nic: The network interface where the frame is coming from. """ - route_frame = False + process_frame = False protocol = frame.ip.protocol src_ip_address = frame.ip.src_ip_address dst_ip_address = frame.ip.dst_ip_address @@ -796,12 +882,12 @@ class Router(Node): self.icmp.process_icmp(frame=frame, from_nic=from_nic) else: if src_port == Port.ARP: - self.arp.process_arp_packet(from_nic=from_nic, frame=frame) + self.arp.process_arp_packet(from_nic=from_nic, frame=frame, route_table=self.route_table) else: # All other traffic - route_frame = True - if route_frame: - self.route_frame(frame, from_nic) + process_frame = True + if process_frame: + self.process_frame(frame, from_nic) def configure_port(self, port: int, ip_address: Union[IPv4Address, str], subnet_mask: Union[IPv4Address, str]): """ diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py index fffae6e2..ead857f2 100644 --- a/src/primaite/simulator/network/hardware/nodes/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -90,12 +90,12 @@ class Switch(Node): self._add_mac_table_entry(src_mac, incoming_port) outgoing_port = self.mac_address_table.get(dst_mac) - if outgoing_port or dst_mac != "ff:ff:ff:ff:ff:ff": + if outgoing_port and dst_mac != "ff:ff:ff:ff:ff:ff": outgoing_port.send_frame(frame) else: # If the destination MAC is not in the table, flood to all ports except incoming for port in self.switch_ports.values(): - if port != incoming_port: + if port.enabled and port != incoming_port: port.send_frame(frame) def disconnect_link_from_port(self, link: Link, port_number: int): diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index 6053c457..3f636eae 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -1,8 +1,11 @@ +from ipaddress import IPv4Address from typing import Tuple import pytest +from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.base import Link, NIC, Node, NodeOperatingState +from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.router import ACLAction, Router from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -34,6 +37,69 @@ def pc_a_pc_b_router_1() -> Tuple[Node, Node, Router]: return pc_a, pc_b, router_1 +@pytest.fixture(scope="function") +def multi_hop_network() -> Network: + network = Network() + + # Configure PC A + pc_a = Computer( + hostname="pc_a", + ip_address="192.168.0.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + pc_a.power_on() + network.add_node(pc_a) + + # Configure Router 1 + router_1 = Router(hostname="router_1", start_up_duration=0) + router_1.power_on() + network.add_node(router_1) + + # Configure the connection between PC A and Router 1 port 2 + router_1.configure_port(2, "192.168.0.1", "255.255.255.0") + network.connect(pc_a.ethernet_port[1], router_1.ethernet_ports[2]) + router_1.enable_port(2) + + # Configure Router 1 ACLs + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure PC B + pc_b = Computer( + hostname="pc_b", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1", + start_up_duration=0, + ) + pc_b.power_on() + network.add_node(pc_b) + + # Configure Router 2 + router_2 = Router(hostname="router_2", start_up_duration=0) + router_2.power_on() + network.add_node(router_2) + + # Configure the connection between PC B and Router 2 port 2 + router_2.configure_port(2, "192.168.2.1", "255.255.255.0") + network.connect(pc_b.ethernet_port[1], router_2.ethernet_ports[2]) + router_2.enable_port(2) + + # Configure Router 2 ACLs + router_2.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_2.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure the connection between Router 1 port 1 and Router 2 port 1 + router_2.configure_port(1, "192.168.1.2", "255.255.255.252") + router_1.configure_port(1, "192.168.1.1", "255.255.255.252") + network.connect(router_1.ethernet_ports[1], router_2.ethernet_ports[1]) + router_1.enable_port(1) + router_2.enable_port(1) + return network + + def test_ping_default_gateway(pc_a_pc_b_router_1): pc_a, pc_b, router_1 = pc_a_pc_b_router_1 @@ -50,3 +116,30 @@ def test_host_on_other_subnet(pc_a_pc_b_router_1): pc_a, pc_b, router_1 = pc_a_pc_b_router_1 assert pc_a.ping("192.168.1.10") + + +def test_no_route_no_ping(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + assert not pc_a.ping(pc_b.ethernet_port[1].ip_address) + + +def test_with_routes_can_ping(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + router_1: Router = multi_hop_network.get_node_by_hostname("router_1") # noqa + router_2: Router = multi_hop_network.get_node_by_hostname("router_2") # noqa + + # Configure Route from Router 1 to PC B subnet + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + assert pc_a.ping(pc_b.ethernet_port[1].ip_address) From 3adf1f9f6aa89927367d298a8f78e1f2a26db443 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 3 Jan 2024 14:49:40 +0000 Subject: [PATCH 45/54] bump version --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 6da222f2..0fd919fd 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b3dev +3.0.0b4dev From bc367222a843e72e97f35332a7c68dff1721d94f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 4 Jan 2024 12:55:46 +0000 Subject: [PATCH 46/54] Change software describe state keys --- src/primaite/game/agent/observations.py | 5 ++++- src/primaite/simulator/system/software.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index 928aebfd..46c9d75c 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -139,7 +139,10 @@ class ServiceObservation(AbstractObservation): service_state = access_from_nested_dict(state, self.where) if service_state is NOT_PRESENT_IN_STATE: return self.default_observation - return {"operating_status": service_state["operating_state"], "health_status": service_state["health_state"]} + return { + "operating_status": service_state["operating_state"], + "health_status": service_state["health_state_visible"], + } @property def space(self) -> spaces.Space: diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index b393ffd8..c3db48fc 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -137,8 +137,8 @@ class Software(SimComponent): state = super().describe_state() state.update( { - "health_state": self.health_state_actual.value, - "health_state_red_view": self.health_state_visible.value, + "health_state_actual": self.health_state_actual.value, + "health_state_visible": self.health_state_visible.value, "criticality": self.criticality.value, "patching_count": self.patching_count, "scanning_count": self.scanning_count, From ddf7fbf88bce2f95448053eb2152cb5c3d6875c1 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 5 Jan 2024 15:27:10 +0000 Subject: [PATCH 47/54] #2139 - Included a test that tests services over multi-hop routing. Added some PR suggestions around logging. --- .../network/hardware/nodes/router.py | 8 ++-- .../network/hardware/nodes/switch.py | 2 +- .../integration_tests/network/test_routing.py | 40 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 1e3d8022..172cc711 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -590,8 +590,8 @@ class RouterARPCache(ARPCache): self.send_arp_request(route.next_hop_ip_address, ignore_networks=[frame.ip.src_ip_address]) return self.process_arp_packet(from_nic, frame, route_table, is_reattempt=True) else: - pass - # TODO: destination unavailable/No ARP netry found + self.sys_log.info("Ignoring ARP request as destination unavailable/No ARP entry found") + return else: arp_reply = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_reply, from_nic) @@ -797,7 +797,7 @@ class Router(Node): return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) if not nic.enabled: - # TODO: Add sys_log here + self.sys_log.info(f"Frame dropped as NIC {nic} is not enabled") return if frame.ip.dst_ip_address in nic.ip_network: @@ -829,7 +829,7 @@ class Router(Node): return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) if not nic.enabled: - # TODO: Add sys_log here + self.sys_log.info(f"Frame dropped as NIC {nic} is not enabled") return from_port = self._get_port_of_nic(from_nic) diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py index ead857f2..b394bae0 100644 --- a/src/primaite/simulator/network/hardware/nodes/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -90,7 +90,7 @@ class Switch(Node): self._add_mac_table_entry(src_mac, incoming_port) outgoing_port = self.mac_address_table.get(dst_mac) - if outgoing_port and dst_mac != "ff:ff:ff:ff:ff:ff": + if outgoing_port and dst_mac.lower() != "ff:ff:ff:ff:ff:ff": outgoing_port.send_frame(frame) else: # If the destination MAC is not in the table, flood to all ports except incoming diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index 3f636eae..042debca 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -9,6 +9,8 @@ from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.router import ACLAction, Router from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer @pytest.fixture(scope="function") @@ -143,3 +145,41 @@ def test_with_routes_can_ping(multi_hop_network): ) assert pc_a.ping(pc_b.ethernet_port[1].ip_address) + + +def test_routing_services(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + pc_a.software_manager.install(NTPClient) + ntp_client = pc_a.software_manager.software["NTPClient"] + ntp_client.start() + + pc_b.software_manager.install(NTPServer) + pc_b.software_manager.software["NTPServer"].start() + + ntp_client.configure(ntp_server_ip_address=pc_b.ethernet_port[1].ip_address) + + router_1: Router = multi_hop_network.get_node_by_hostname("router_1") # noqa + router_2: Router = multi_hop_network.get_node_by_hostname("router_2") # noqa + + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=21) + router_2.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=21) + + assert ntp_client.time is None + ntp_client.request_time() + assert ntp_client.time is None + + # Configure Route from Router 1 to PC B subnet + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + ntp_client.request_time() + assert ntp_client.time is not None From d2d628b67653fff4339d90ec72f88ef3cf693e6e Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 5 Jan 2024 22:11:37 +0000 Subject: [PATCH 48/54] #2139 - Fixed unicast and broadcast functionality properly --- CHANGELOG.md | 8 + .../simulator/network/hardware/base.py | 27 ++- .../network/hardware/nodes/router.py | 45 ++++- .../simulator/system/core/session_manager.py | 83 +++++--- .../simulator/system/core/software_manager.py | 22 ++- src/primaite/simulator/system/software.py | 21 +- .../network/test_broadcast.py | 180 ++++++++++++++++++ 7 files changed, 341 insertions(+), 45 deletions(-) create mode 100644 tests/integration_tests/network/test_broadcast.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 96634b28..60961802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,10 +38,18 @@ SessionManager. - 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` +- **RouterNIC Class**: Introduced a new class `RouterNIC`, extending the standard `NIC` functionality. This class is specifically designed for router operations, optimizing the processing and routing of network traffic. + - **Custom Layer-3 Processing**: The `RouterNIC` class includes custom handling for network frames, bypassing standard Node NIC's Layer 3 broadcast/unicast checks. This allows for more efficient routing behavior in network scenarios where router-specific frame processing is required. + - **Enhanced Frame Reception**: The `receive_frame` method in `RouterNIC` is tailored to handle frames based on Layer 2 (Ethernet) checks, focusing on MAC address-based routing and broadcast frame acceptance. + ### Changed - Integrated the RouteTable into the Routers frame processing. - Frames are now dropped when their TTL reaches 0 +- **NIC Functionality Update**: Updated the Network Interface Card (`NIC`) functionality to support Layer 3 (L3) broadcasts. + - **Layer 3 Broadcast Handling**: Enhanced the existing `NIC` classes to correctly process and handle Layer 3 broadcasts. This update allows devices using standard NICs to effectively participate in network activities that involve L3 broadcasting. + - **Improved Frame Reception Logic**: The `receive_frame` method of the `NIC` class has been updated to include additional checks and handling for L3 broadcasts, ensuring proper frame processing in a wider range of network scenarios. + ### Removed - Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index c27378a8..7e6e0a3b 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -274,11 +274,20 @@ class NIC(SimComponent): def receive_frame(self, frame: Frame) -> bool: """ - Receive a network frame from the connected link if the NIC is enabled. + Receive a network frame from the connected link, processing it if the NIC is enabled. - The Frame is passed to the Node. + This method decrements the Time To Live (TTL) of the frame, captures it using PCAP (Packet Capture), and checks + if the frame is either a broadcast or destined for this NIC. If the frame is acceptable, it is passed to the + connected node. The method also handles the discarding of frames with TTL expired and logs this event. - :param frame: The network frame being received. + The frame's reception is based on various conditions: + - If the NIC is disabled, the frame is not processed. + - If the TTL of the frame reaches zero after decrement, it is discarded and logged. + - If the frame is a broadcast or its destination MAC/IP address matches this NIC's, it is accepted. + - All other frames are dropped and logged or printed to the console. + + :param frame: The network frame being received. This should be an instance of the Frame class. + :return: Returns True if the frame is processed and passed to the node, False otherwise. """ if self.enabled: frame.decrement_ttl() @@ -288,7 +297,17 @@ class NIC(SimComponent): frame.set_received_timestamp() self.pcap.capture(frame) # If this destination or is broadcast - if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + accept_frame = False + + # Check if it's a broadcast: + if frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + if frame.ip.dst_ip_address in {self.ip_address, self.ip_network.broadcast_address}: + accept_frame = True + else: + if frame.ethernet.dst_mac_addr == self.mac_address: + accept_frame = True + + if accept_frame: self._connected_node.receive_frame(frame=frame, from_nic=self) return True return False diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 172cc711..473712ea 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -690,6 +690,47 @@ class RouterICMP(ICMP): self.router.process_frame(frame, from_nic) +class RouterNIC(NIC): + """ + A Router-specific Network Interface Card (NIC) that extends the standard NIC functionality. + + This class overrides the standard Node NIC's Layer 3 (L3) broadcast/unicast checks. It is designed + to handle network frames in a manner specific to routers, allowing them to efficiently process + and route network traffic. + """ + + def receive_frame(self, frame: Frame) -> bool: + """ + Receive and process a network frame from the connected link, provided the NIC is enabled. + + This method is tailored for router behavior. It decrements the frame's Time To Live (TTL), checks for TTL + expiration, and captures the frame using PCAP (Packet Capture). The frame is accepted if it is destined for + this NIC's MAC address or is a broadcast frame. + + Key Differences from Standard NIC: + - Does not perform Layer 3 (IP-based) broadcast checks. + - Only checks for Layer 2 (Ethernet) destination MAC address and broadcast frames. + + :param frame: The network frame being received. This should be an instance of the Frame class. + :return: Returns True if the frame is processed and passed to the connected node, False otherwise. + """ + if self.enabled: + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info("Frame discarded as TTL limit reached") + return False + frame.set_received_timestamp() + self.pcap.capture(frame) + # If this destination or is broadcast + if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + self._connected_node.receive_frame(frame=frame, from_nic=self) + return True + return False + + def __str__(self) -> str: + return f"{self.mac_address}/{self.ip_address}" + + class Router(Node): """ A class to represent a network router node. @@ -700,7 +741,7 @@ class Router(Node): """ num_ports: int - ethernet_ports: Dict[int, NIC] = {} + ethernet_ports: Dict[int, RouterNIC] = {} acl: AccessControlList route_table: RouteTable arp: RouterARPCache @@ -719,7 +760,7 @@ class Router(Node): kwargs["icmp"] = RouterICMP(sys_log=kwargs.get("sys_log"), arp_cache=kwargs.get("arp"), router=self) super().__init__(hostname=hostname, num_ports=num_ports, **kwargs) for i in range(1, self.num_ports + 1): - nic = NIC(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0") + nic = RouterNIC(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0") self.connect_nic(nic) self.ethernet_ports[i] = nic diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index 8658f155..a95846a3 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ipaddress import IPv4Address +from ipaddress import IPv4Address, IPv4Network from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING, Union from prettytable import MARKDOWN, PrettyTable @@ -141,41 +141,76 @@ class SessionManager: def receive_payload_from_software_manager( self, payload: Any, - dst_ip_address: Optional[IPv4Address] = None, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, dst_port: Optional[Port] = None, session_id: Optional[str] = None, is_reattempt: bool = False, ) -> Union[Any, None]: """ - Receive a payload from the SoftwareManager. + Receive a payload from the SoftwareManager and send it to the appropriate NIC for transmission. - If no session_id, a Session is established. Once established, the payload is sent to ``send_payload_to_nic``. + This method supports both unicast and Layer 3 broadcast transmissions. If `dst_ip_address` is an + IPv4Network, a broadcast is initiated. For unicast, the destination MAC address is resolved via ARP. + A new session is established if `session_id` is not provided, and an existing session is used otherwise. :param payload: The payload to be sent. - :param session_id: The Session ID the payload is to originate from. Optional. If None, one will be created. + :param dst_ip_address: The destination IP address or network for broadcast. Optional. + :param dst_port: The destination port for the TCP packet. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :param is_reattempt: Flag to indicate if this is a reattempt after an ARP request. Default is False. + :return: The outcome of sending the frame, or None if sending was unsuccessful. """ + is_broadcast = False + outbound_nic = None + dst_mac_address = None + + # Use session details if session_id is provided if session_id: session = self.sessions_by_uuid[session_id] - dst_ip_address = self.sessions_by_uuid[session_id].with_ip_address - dst_port = self.sessions_by_uuid[session_id].dst_port + dst_ip_address = session.with_ip_address + dst_port = session.dst_port - dst_mac_address = self.arp_cache.get_arp_cache_mac_address(dst_ip_address) + # Determine if the payload is for broadcast or unicast - if dst_mac_address: - outbound_nic = self.arp_cache.get_arp_cache_nic(dst_ip_address) + # Handle broadcast transmission + if isinstance(dst_ip_address, IPv4Network): + is_broadcast = True + dst_ip_address = dst_ip_address.broadcast_address + if dst_ip_address: + # Find a suitable NIC for the broadcast + for nic in self.arp_cache.nics.values(): + if dst_ip_address in nic.ip_network and nic.enabled: + dst_mac_address = "ff:ff:ff:ff:ff:ff" + outbound_nic = nic else: - if not is_reattempt: - self.arp_cache.send_arp_request(dst_ip_address) - return self.receive_payload_from_software_manager( - payload=payload, - dst_ip_address=dst_ip_address, - dst_port=dst_port, - session_id=session_id, - is_reattempt=True, - ) - else: - return + # Resolve MAC address for unicast transmission + dst_mac_address = self.arp_cache.get_arp_cache_mac_address(dst_ip_address) + # Resolve outbound NIC for unicast transmission + if dst_mac_address: + outbound_nic = self.arp_cache.get_arp_cache_nic(dst_ip_address) + + # If MAC address not found, initiate ARP request + else: + if not is_reattempt: + self.arp_cache.send_arp_request(dst_ip_address) + # Reattempt payload transmission after ARP request + return self.receive_payload_from_software_manager( + payload=payload, + dst_ip_address=dst_ip_address, + dst_port=dst_port, + session_id=session_id, + is_reattempt=True, + ) + else: + # Return None if reattempt fails + return + + # Check if outbound NIC and destination MAC address are resolved + if not outbound_nic or not dst_mac_address: + return False + + # Construct the frame for transmission frame = Frame( ethernet=EthernetHeader(src_mac_addr=outbound_nic.mac_address, dst_mac_addr=dst_mac_address), ip=IPPacket( @@ -189,15 +224,17 @@ class SessionManager: payload=payload, ) - if not session_id: + # Manage session for unicast transmission + if not (is_broadcast and session_id): session_key = self._get_session_key(frame, inbound_frame=False) session = self.sessions_by_key.get(session_key) if not session: - # Create new session + # Create a new session if it doesn't exist session = Session.from_session_key(session_key) self.sessions_by_key[session_key] = session self.sessions_by_uuid[session.uuid] = session + # Send the frame through the NIC return outbound_nic.send_frame(frame) def receive_frame(self, frame: Frame): diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index 21a121c1..95948a1e 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -1,4 +1,4 @@ -from ipaddress import IPv4Address +from ipaddress import IPv4Address, IPv4Network from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union from prettytable import MARKDOWN, PrettyTable @@ -130,20 +130,28 @@ class SoftwareManager: def send_payload_to_session_manager( self, payload: Any, - dest_ip_address: Optional[IPv4Address] = None, + dest_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, dest_port: Optional[Port] = None, session_id: Optional[str] = None, ) -> bool: """ - Send a payload to the SessionManager. + Sends a payload to the SessionManager for network transmission. + + This method is responsible for initiating the process of sending network payloads. It supports both + unicast and Layer 3 broadcast transmissions. For broadcasts, the destination IP should be specified + as an IPv4Network. :param payload: The payload to be sent. - :param dest_ip_address: The ip address of the payload destination. - :param dest_port: The port of the payload destination. - :param session_id: The Session ID the payload is to originate from. Optional. + :param dest_ip_address: The IP address or network (for broadcasts) of the payload destination. + :param dest_port: The destination port for the payload. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :return: True if the payload was successfully sent, False otherwise. """ return self.session_manager.receive_payload_from_software_manager( - payload=payload, dst_ip_address=dest_ip_address, dst_port=dest_port, session_id=session_id + payload=payload, + dst_ip_address=dest_ip_address, + dst_port=dest_port, + session_id=session_id, ) def receive_payload_from_session_manager(self, payload: Any, port: Port, protocol: IPProtocol, session_id: str): diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index b393ffd8..d8aed2fb 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -2,8 +2,8 @@ import copy from abc import abstractmethod from datetime import datetime from enum import Enum -from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, Optional, Union from primaite.simulator.core import _LOGGER, RequestManager, RequestType, SimComponent from primaite.simulator.file_system.file_system import FileSystem, Folder @@ -317,19 +317,22 @@ class IOSoftware(Software): self, payload: Any, session_id: Optional[str] = None, - dest_ip_address: Optional[IPv4Address] = None, + dest_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, dest_port: Optional[Port] = None, **kwargs, ) -> bool: """ - Sends a payload to the SessionManager. + Sends a payload to the SessionManager for network transmission. + + This method is responsible for initiating the process of sending network payloads. It supports both + unicast and Layer 3 broadcast transmissions. For broadcasts, the destination IP should be specified + as an IPv4Network. It delegates the actual sending process to the SoftwareManager. :param payload: The payload to be sent. - :param dest_ip_address: The ip address of the payload destination. - :param dest_port: The port of the payload destination. - :param session_id: The Session ID the payload is to originate from. Optional. - - :return: True if successful, False otherwise. + :param dest_ip_address: The IP address or network (for broadcasts) of the payload destination. + :param dest_port: The destination port for the payload. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :return: True if the payload was successfully sent, False otherwise. """ if not self._can_perform_action(): return False diff --git a/tests/integration_tests/network/test_broadcast.py b/tests/integration_tests/network/test_broadcast.py new file mode 100644 index 00000000..b9ecb28b --- /dev/null +++ b/tests/integration_tests/network/test_broadcast.py @@ -0,0 +1,180 @@ +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, List, 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.hardware.nodes.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.services.service import Service + + +class BroadcastService(Service): + """A service for sending broadcast and unicast messages over a network.""" + + def __init__(self, **kwargs): + # Set default service properties for broadcasting + kwargs["name"] = "BroadcastService" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + # Implement state description for the service + pass + + def unicast(self, ip_address: IPv4Address): + # Send a unicast payload to a specific IP address + super().send( + payload="unicast", + dest_ip_address=ip_address, + dest_port=Port.HTTP, + ) + + def broadcast(self, ip_network: IPv4Network): + # Send a broadcast payload to an entire IP network + super().send( + payload="broadcast", + dest_ip_address=ip_network, + dest_port=Port.HTTP, + ) + + +class BroadcastClient(Application): + """A client application to receive broadcast and unicast messages.""" + + payloads_received: List = [] + + def __init__(self, **kwargs): + # Set default client properties + kwargs["name"] = "BroadcastClient" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + # Implement state description for the application + pass + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + # Append received payloads to the list and print a message + self.payloads_received.append(payload) + print(f"Payload: {payload} received on node {self.sys_log.hostname}") + + +@pytest.fixture(scope="function") +def broadcast_network() -> Network: + network = Network() + + client_1 = Computer( + hostname="client_1", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client_1.power_on() + client_1.software_manager.install(BroadcastClient) + application_1 = client_1.software_manager.software["BroadcastClient"] + application_1.run() + + client_2 = Computer( + hostname="client_2", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client_2.power_on() + client_2.software_manager.install(BroadcastClient) + application_2 = client_2.software_manager.software["BroadcastClient"] + application_2.run() + + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.1", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server_1.power_on() + + server_1.software_manager.install(BroadcastService) + service: BroadcastService = server_1.software_manager.software["BroadcastService"] + service.start() + + switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) + switch_1.power_on() + + network.connect(endpoint_a=client_1.ethernet_port[1], endpoint_b=switch_1.switch_ports[1]) + network.connect(endpoint_a=client_2.ethernet_port[1], endpoint_b=switch_1.switch_ports[2]) + network.connect(endpoint_a=server_1.ethernet_port[1], endpoint_b=switch_1.switch_ports[3]) + + return network + + +@pytest.fixture(scope="function") +def broadcast_service_and_clients(broadcast_network) -> Tuple[BroadcastService, BroadcastClient, BroadcastClient]: + client_1: BroadcastClient = broadcast_network.get_node_by_hostname("client_1").software_manager.software[ + "BroadcastClient" + ] + client_2: BroadcastClient = broadcast_network.get_node_by_hostname("client_2").software_manager.software[ + "BroadcastClient" + ] + service: BroadcastService = broadcast_network.get_node_by_hostname("server_1").software_manager.software[ + "BroadcastService" + ] + + return service, client_1, client_2 + + +def test_broadcast_correct_subnet(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.broadcast(IPv4Network("192.168.1.0/24")) + + assert client_1.payloads_received == ["broadcast"] + assert client_2.payloads_received == ["broadcast"] + + +def test_broadcast_incorrect_subnet(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.broadcast(IPv4Network("192.168.2.0/24")) + + assert not client_1.payloads_received + assert not client_2.payloads_received + + +def test_unicast_correct_address(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.unicast(IPv4Address("192.168.1.2")) + + assert client_1.payloads_received == ["unicast"] + assert not client_2.payloads_received + + +def test_unicast_incorrect_address(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.unicast(IPv4Address("192.168.2.2")) + + assert not client_1.payloads_received + assert not client_2.payloads_received From 59d1a6668e21972b4d162e420794e9c595e05699 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 5 Jan 2024 22:19:54 +0000 Subject: [PATCH 49/54] #2139 - Updated the CHANGELOG.md with broadcast entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60961802..e82a1038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ SessionManager. - **RouterNIC Class**: Introduced a new class `RouterNIC`, extending the standard `NIC` functionality. This class is specifically designed for router operations, optimizing the processing and routing of network traffic. - **Custom Layer-3 Processing**: The `RouterNIC` class includes custom handling for network frames, bypassing standard Node NIC's Layer 3 broadcast/unicast checks. This allows for more efficient routing behavior in network scenarios where router-specific frame processing is required. - **Enhanced Frame Reception**: The `receive_frame` method in `RouterNIC` is tailored to handle frames based on Layer 2 (Ethernet) checks, focusing on MAC address-based routing and broadcast frame acceptance. - +- **Subnet-Wide Broadcasting for Services and Applications**: Implemented the ability for services and applications to conduct broadcasts across an entire IPv4 subnet within the network simulation framework. ### Changed - Integrated the RouteTable into the Routers frame processing. From 8b43f6abe36952bec7c678e4accf3e48f1defa26 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 8 Jan 2024 10:30:38 +0000 Subject: [PATCH 50/54] #2139 - updated docstring in send_arp_request function in base.py --- src/primaite/simulator/network/hardware/base.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 7e6e0a3b..54fd1238 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -703,9 +703,15 @@ class ARPCache: Perform a standard ARP request for a given target IP address. Broadcasts the request through all enabled NICs to determine the MAC address corresponding to the target IP - address. + address. This method can be configured to ignore specific networks when sending out ARP requests, + which is useful in environments where certain addresses should not be queried. :param target_ip_address: The target IP address to send an ARP request for. + :param ignore_networks: An optional list of IPv4 addresses representing networks to be excluded from the ARP + request broadcast. Each address in this list indicates a network which will not be queried during the ARP + request process. This is particularly useful in complex network environments where traffic should be + minimized or controlled to specific subnets. It is mainly used by the router to prevent ARP requests being + sent back to their source. """ for nic in self.nics.values(): use_nic = True From 5d89820a159a17b2bdc614f5898c5728a32174ec Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 12:38:01 +0000 Subject: [PATCH 51/54] Apply PR review suggestions --- src/primaite/game/agent/interface.py | 3 ++- src/primaite/session/environment.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/game/agent/interface.py b/src/primaite/game/agent/interface.py index 8657fc45..01df33de 100644 --- a/src/primaite/game/agent/interface.py +++ b/src/primaite/game/agent/interface.py @@ -45,6 +45,7 @@ class AgentSettings(BaseModel): start_settings: Optional[AgentStartSettings] = None "Configuration for when an agent begins performing it's actions" flatten_obs: bool = True + "Whether to flatten the observation space before passing it to the agent. True by default." @classmethod def from_config(cls, config: Optional[Dict]) -> "AgentSettings": @@ -176,7 +177,7 @@ class ProxyAgent(AbstractAgent): reward_function=reward_function, ) self.most_recent_action: ActType - self.flatten_obs: bool = agent_settings.flatten_obs + self.flatten_obs: bool = agent_settings.flatten_obs if agent_settings else False def get_action(self, obs: ObsType, reward: float = 0.0) -> Tuple[str, Dict]: """ diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index 36ab3f58..6701f183 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -23,7 +23,6 @@ class PrimaiteGymEnv(gymnasium.Env): super().__init__() self.game: "PrimaiteGame" = game self.agent: ProxyAgent = self.game.rl_agents[0] - self.flatten_obs: bool = False def step(self, action: ActType) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict[str, Any]]: """Perform a step in the environment.""" From 6fc4e156603b7cb81389765eac7ce4d8dabdcf1d Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 9 Jan 2024 15:18:31 +0000 Subject: [PATCH 52/54] #2151: remove changing of health_state_actual in actions and tests --- .../simulator/system/services/service.py | 14 +-- src/primaite/simulator/system/software.py | 6 +- tests/conftest.py | 2 +- .../_system/_services/test_services.py | 100 +++++++++++++++++- .../_system/_services/test_web_server.py | 7 +- 5 files changed, 110 insertions(+), 19 deletions(-) diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index d45ef3a6..1de52e92 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -43,9 +43,6 @@ class Service(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def _can_perform_action(self) -> bool: """ Checks if the service can perform actions. @@ -118,7 +115,6 @@ class Service(IOSoftware): if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Stopping service {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.UNUSED def start(self, **kwargs) -> None: """Start the service.""" @@ -129,42 +125,39 @@ class Service(IOSoftware): if self.operating_state == ServiceOperatingState.STOPPED: self.sys_log.info(f"Starting service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.health_state_actual = SoftwareHealthState.GOOD def pause(self) -> None: """Pause the service.""" if self.operating_state == ServiceOperatingState.RUNNING: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.PAUSED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def resume(self) -> None: """Resume paused service.""" if self.operating_state == ServiceOperatingState.PAUSED: self.sys_log.info(f"Resuming service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD def restart(self) -> None: """Restart running service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING - self.health_state_actual = SoftwareHealthState.OVERWHELMED self.restart_countdown = self.restart_duration def disable(self) -> None: """Disable the service.""" self.sys_log.info(f"Disabling Application {self.name}") self.operating_state = ServiceOperatingState.DISABLED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def enable(self) -> None: """Enable the disabled service.""" if self.operating_state == ServiceOperatingState.DISABLED: self.sys_log.info(f"Enabling Application {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def apply_timestep(self, timestep: int) -> None: """ @@ -181,5 +174,4 @@ class Service(IOSoftware): if self.restart_countdown <= 0: _LOGGER.debug(f"Restarting finished for service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD self.restart_countdown -= 1 diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 38e1f30b..f41a5a86 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -71,9 +71,9 @@ class Software(SimComponent): name: str "The name of the software." - health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_actual: SoftwareHealthState = SoftwareHealthState.UNUSED "The actual health state of the software." - health_state_visible: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_visible: SoftwareHealthState = SoftwareHealthState.UNUSED "The health state of the software visible to the red agent." criticality: SoftwareCriticality = SoftwareCriticality.LOWEST "The criticality level of the software." @@ -282,7 +282,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state is NodeOperatingState.OFF: + if self.software_manager and self.software_manager.node.operating_state == NodeOperatingState.OFF: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 1ab07dd8..37289674 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -167,7 +167,7 @@ def example_network() -> Network: -------------- -------------- | client_1 |----- ----| server_1 | -------------- | -------------- -------------- -------------- | -------------- - ------| switch_1 |------| router_1 |------| switch_2 |------ + ------| switch_2 |------| router_1 |------| switch_1 |------ -------------- | -------------- -------------- -------------- | -------------- | client_2 |---- ----| server_2 | -------------- -------------- diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py index 016cf011..2c0671d5 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py @@ -19,55 +19,149 @@ def test_scan(service): def test_start_service(service): assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD def test_stop_service(service): service.start() assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD service.stop() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.GOOD def test_pause_and_resume_service(service): assert service.operating_state == ServiceOperatingState.STOPPED service.resume() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD service.pause() assert service.operating_state == ServiceOperatingState.PAUSED + assert service.health_state_actual == SoftwareHealthState.GOOD service.resume() assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD def test_restart(service): assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.restart() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD service.restart() assert service.operating_state == ServiceOperatingState.RESTARTING + assert service.health_state_actual == SoftwareHealthState.GOOD timestep = 0 while service.operating_state == ServiceOperatingState.RESTARTING: service.apply_timestep(timestep) + assert service.health_state_actual == SoftwareHealthState.GOOD timestep += 1 assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD + + +def test_restart_compromised(service): + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + service.restart() + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + # compromise the service + service.health_state_actual = SoftwareHealthState.COMPROMISED + + service.restart() + assert service.operating_state == ServiceOperatingState.RESTARTING + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + """ + Service should be compromised even after reset. + + Only way to remove compromised status is via patching. + """ + + timestep = 0 + while service.operating_state == ServiceOperatingState.RESTARTING: + service.apply_timestep(timestep) + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + timestep += 1 + + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + +def test_compromised_service_remains_compromised(service): + """ + Tests that a compromised service stays compromised. + + The only way that the service can be uncompromised is by running patch. + """ + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.health_state_actual = SoftwareHealthState.COMPROMISED + + service.stop() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.start() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.disable() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.enable() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.pause() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.resume() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + +def test_service_patching(service): + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.health_state_actual = SoftwareHealthState.COMPROMISED + + service.patch() + assert service.health_state_actual == SoftwareHealthState.PATCHING + + for i in range(service.patching_duration + 1): + service.apply_timestep(i) + + assert service.health_state_actual == SoftwareHealthState.GOOD def test_enable_disable(service): service.disable() assert service.operating_state == ServiceOperatingState.DISABLED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.enable() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED def test_overwhelm_service(service): @@ -76,13 +170,13 @@ def test_overwhelm_service(service): uuid = str(uuid4()) assert service.add_connection(connection_id=uuid) # should be true - assert service.health_state_actual is SoftwareHealthState.GOOD + assert service.health_state_actual == SoftwareHealthState.GOOD assert not service.add_connection(connection_id=uuid) # fails because connection already exists - assert service.health_state_actual is SoftwareHealthState.GOOD + assert service.health_state_actual == SoftwareHealthState.GOOD assert service.add_connection(connection_id=str(uuid4())) # succeed - assert service.health_state_actual is SoftwareHealthState.GOOD + assert service.health_state_actual == SoftwareHealthState.GOOD assert not service.add_connection(connection_id=str(uuid4())) # fail because at capacity assert service.health_state_actual is SoftwareHealthState.OVERWHELMED diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py index bbccda27..64277356 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py @@ -1,5 +1,6 @@ import pytest +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.protocols.http import ( HttpRequestMethod, @@ -15,7 +16,11 @@ from primaite.simulator.system.services.web_server.web_server import WebServer @pytest.fixture(scope="function") def web_server() -> Server: node = Server( - hostname="web_server", ip_address="192.168.1.10", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + hostname="web_server", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + operating_state=NodeOperatingState.ON, ) node.software_manager.install(software_class=WebServer) node.software_manager.software.get("WebServer").start() From a4d372d3ebe556fca63ebf6beb7f4d6f55c7fe60 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 9 Jan 2024 16:29:40 +0000 Subject: [PATCH 53/54] #2151: utilise set_health_state method instead of directly changing software states --- .../system/applications/application.py | 5 +--- .../services/database/database_service.py | 2 +- .../simulator/system/services/service.py | 2 +- src/primaite/simulator/system/software.py | 4 +-- .../test_dos_bot_and_server.py | 4 +-- .../_network/_hardware/test_node_actions.py | 6 ++-- .../_system/_services/test_services.py | 15 ++++------ .../_simulator/_system/test_software.py | 29 +++++++++++++++++++ 8 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/test_software.py diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 898e5917..09828b89 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Dict, Set from primaite import getLogger -from primaite.simulator.system.software import IOSoftware, SoftwareHealthState +from primaite.simulator.system.software import IOSoftware _LOGGER = getLogger(__name__) @@ -38,9 +38,6 @@ class Application(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def set_original_state(self): """Sets the original state.""" super().set_original_state() diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 6f333091..1df1db9e 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -196,7 +196,7 @@ class DatabaseService(Service): return {"status_code": 404, "data": False} elif query == "DELETE": if self.health_state_actual == SoftwareHealthState.GOOD: - self.health_state_actual = SoftwareHealthState.COMPROMISED + self.set_health_state(SoftwareHealthState.COMPROMISED) return { "status_code": 200, "type": "sql", diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 1de52e92..43c85471 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -127,7 +127,7 @@ class Service(IOSoftware): self.operating_state = ServiceOperatingState.RUNNING # set software health state to GOOD if initially set to UNUSED if self.health_state_actual == SoftwareHealthState.UNUSED: - self.health_state_actual = SoftwareHealthState.GOOD + self.set_health_state(SoftwareHealthState.GOOD) def pause(self) -> None: """Pause the service.""" diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index f41a5a86..4072fab1 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -303,13 +303,13 @@ class IOSoftware(Software): """ # if over or at capacity, set to overwhelmed if len(self._connections) >= self.max_sessions: - self.health_state_actual = SoftwareHealthState.OVERWHELMED + self.set_health_state(SoftwareHealthState.OVERWHELMED) self.sys_log.error(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.") return False else: # if service was previously overwhelmed, set to good because there is enough space for connections if self.health_state_actual == SoftwareHealthState.OVERWHELMED: - self.health_state_actual = SoftwareHealthState.GOOD + self.set_health_state(SoftwareHealthState.GOOD) # check that connection already doesn't exist if not self._connections.get(connection_id): diff --git a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py index 85028d75..fb768127 100644 --- a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py +++ b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py @@ -90,7 +90,7 @@ def test_repeating_dos_attack(dos_bot_and_db_server): assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED db_server_service.clear_connections() - db_server_service.health_state_actual = SoftwareHealthState.GOOD + db_server_service.set_health_state(SoftwareHealthState.GOOD) assert len(db_server_service.connections) == 0 computer.apply_timestep(timestep=1) @@ -121,7 +121,7 @@ def test_non_repeating_dos_attack(dos_bot_and_db_server): assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED db_server_service.clear_connections() - db_server_service.health_state_actual = SoftwareHealthState.GOOD + db_server_service.set_health_state(SoftwareHealthState.GOOD) assert len(db_server_service.connections) == 0 computer.apply_timestep(timestep=1) diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py index 5fe5df16..b6f7a86d 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py @@ -53,12 +53,12 @@ def test_node_os_scan(node, service, application): # TODO implement processes # add services to node - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) node.install_service(service=service) assert service.health_state_visible == SoftwareHealthState.UNUSED # add application to node - application.health_state_actual = SoftwareHealthState.COMPROMISED + application.set_health_state(SoftwareHealthState.COMPROMISED) node.install_application(application=application) assert application.health_state_visible == SoftwareHealthState.UNUSED @@ -101,7 +101,7 @@ def test_node_red_scan(node, service, application): assert service.revealed_to_red is False # add application to node - application.health_state_actual = SoftwareHealthState.COMPROMISED + application.set_health_state(SoftwareHealthState.COMPROMISED) node.install_application(application=application) assert application.revealed_to_red is False diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py index 2c0671d5..ac36c660 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py @@ -57,12 +57,15 @@ def test_restart(service): assert service.operating_state == ServiceOperatingState.STOPPED assert service.health_state_actual == SoftwareHealthState.UNUSED service.restart() + # Service is STOPPED. Restart will only work if the service was PAUSED or RUNNING assert service.operating_state == ServiceOperatingState.STOPPED assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() + assert service.operating_state == ServiceOperatingState.RUNNING assert service.health_state_actual == SoftwareHealthState.GOOD service.restart() + # Service is RUNNING. Restart should work assert service.operating_state == ServiceOperatingState.RESTARTING assert service.health_state_actual == SoftwareHealthState.GOOD @@ -77,17 +80,11 @@ def test_restart(service): def test_restart_compromised(service): - assert service.operating_state == ServiceOperatingState.STOPPED - assert service.health_state_actual == SoftwareHealthState.UNUSED - service.restart() - assert service.operating_state == ServiceOperatingState.STOPPED - assert service.health_state_actual == SoftwareHealthState.UNUSED - service.start() assert service.health_state_actual == SoftwareHealthState.GOOD # compromise the service - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) service.restart() assert service.operating_state == ServiceOperatingState.RESTARTING @@ -118,7 +115,7 @@ def test_compromised_service_remains_compromised(service): service.start() assert service.health_state_actual == SoftwareHealthState.GOOD - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) service.stop() assert service.health_state_actual == SoftwareHealthState.COMPROMISED @@ -143,7 +140,7 @@ def test_service_patching(service): service.start() assert service.health_state_actual == SoftwareHealthState.GOOD - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) service.patch() assert service.health_state_actual == SoftwareHealthState.PATCHING diff --git a/tests/unit_tests/_primaite/_simulator/_system/test_software.py b/tests/unit_tests/_primaite/_simulator/_system/test_software.py new file mode 100644 index 00000000..e77cd895 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/test_software.py @@ -0,0 +1,29 @@ +from typing import Dict + +import pytest + +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.software import Software, SoftwareHealthState + + +class TestSoftware(Software): + def describe_state(self) -> Dict: + pass + + +@pytest.fixture(scope="function") +def software(file_system): + return TestSoftware( + name="TestSoftware", port=Port.ARP, file_system=file_system, sys_log=SysLog(hostname="test_service") + ) + + +def test_software_creation(software): + assert software is not None + + +def test_software_set_health_state(software): + assert software.health_state_actual == SoftwareHealthState.UNUSED + software.set_health_state(SoftwareHealthState.GOOD) + assert software.health_state_actual == SoftwareHealthState.GOOD From c985b8793dfe4b1a1a9f2c309bd3ce28bce40aec Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 10 Jan 2024 11:58:36 +0000 Subject: [PATCH 54/54] #2151 and #2166: added tests for application being unused + even more tests --- .../system/applications/application.py | 5 +- .../system/services/ftp/ftp_service.py | 6 ++- .../simulator/system/services/service.py | 2 + src/primaite/simulator/system/software.py | 2 +- tests/conftest.py | 5 +- .../system/test_application_on_node.py | 23 +++++---- .../system/test_service_on_node.py | 7 +-- .../_applications/test_application_actions.py | 0 .../_applications/test_applications.py | 50 +++++++++++++++++++ 9 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 09828b89..322ac808 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Dict, Set from primaite import getLogger -from primaite.simulator.system.software import IOSoftware +from primaite.simulator.system.software import IOSoftware, SoftwareHealthState _LOGGER = getLogger(__name__) @@ -92,6 +92,9 @@ class Application(IOSoftware): if self.operating_state == ApplicationOperatingState.CLOSED: self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.set_health_state(SoftwareHealthState.GOOD) def _application_loop(self): """The main application loop.""" diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index f2c01544..276a9d5f 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -1,7 +1,7 @@ import shutil from abc import ABC from ipaddress import IPv4Address -from typing import Optional +from typing import Dict, Optional from primaite.simulator.file_system.file_system import File from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode @@ -16,6 +16,10 @@ class FTPServiceABC(Service, ABC): Contains shared methods between both classes. """ + def describe_state(self) -> Dict: + """Returns a Dict of the FTPService state.""" + return super().describe_state() + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: """ Process the command in the FTP Packet. diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 43c85471..162678a0 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from enum import Enum from typing import Any, Dict, Optional @@ -95,6 +96,7 @@ class Service(IOSoftware): rm.add_request("enable", RequestType(func=lambda request, context: self.enable())) return rm + @abstractmethod def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 4072fab1..8656154c 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -282,7 +282,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state == NodeOperatingState.OFF: + if self.software_manager and self.software_manager.node.operating_state != NodeOperatingState.ON: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 37289674..c37226a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,9 @@ from primaite.simulator.network.hardware.base import Link, Node class TestService(Service): """Test Service class""" + def describe_state(self) -> Dict: + return super().describe_state() + def __init__(self, **kwargs): kwargs["name"] = "TestService" kwargs["port"] = Port.HTTP @@ -60,7 +63,7 @@ class TestApplication(Application): super().__init__(**kwargs) def describe_state(self) -> Dict: - pass + return super().describe_state() @pytest.fixture(scope="function") diff --git a/tests/integration_tests/system/test_application_on_node.py b/tests/integration_tests/system/test_application_on_node.py index 46be5e55..60497f22 100644 --- a/tests/integration_tests/system/test_application_on_node.py +++ b/tests/integration_tests/system/test_application_on_node.py @@ -24,8 +24,8 @@ def populated_node(application_class) -> Tuple[Application, Computer]: return app, computer -def test_service_on_offline_node(application_class): - """Test to check that the service cannot be interacted with when node it is on is off.""" +def test_application_on_offline_node(application_class): + """Test to check that the application cannot be interacted with when node it is on is off.""" computer: Computer = Computer( hostname="test_computer", ip_address="192.168.1.2", @@ -49,8 +49,8 @@ def test_service_on_offline_node(application_class): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_off_service(populated_node): - """Check that the service is turned off when the server is turned off""" +def test_server_turns_off_application(populated_node): + """Check that the application is turned off when the server is turned off""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -65,8 +65,8 @@ def test_server_turns_off_service(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_service_cannot_be_turned_on_when_server_is_off(populated_node): - """Check that the service cannot be started when the server is off.""" +def test_application_cannot_be_turned_on_when_computer_is_off(populated_node): + """Check that the application cannot be started when the computer is off.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -86,8 +86,8 @@ def test_service_cannot_be_turned_on_when_server_is_off(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_on_service(populated_node): - """Check that turning on the server turns on service.""" +def test_computer_runs_applications(populated_node): + """Check that turning on the computer will turn on applications.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -109,13 +109,14 @@ def test_server_turns_on_service(populated_node): assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING - computer.start_up_duration = 0 - computer.shut_down_duration = 0 - computer.power_off() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.OFF assert app.operating_state is ApplicationOperatingState.CLOSED computer.power_on() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING diff --git a/tests/integration_tests/system/test_service_on_node.py b/tests/integration_tests/system/test_service_on_node.py index aab1e4da..9b0084bd 100644 --- a/tests/integration_tests/system/test_service_on_node.py +++ b/tests/integration_tests/system/test_service_on_node.py @@ -117,13 +117,14 @@ def test_server_turns_on_service(populated_node): assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING - server.start_up_duration = 0 - server.shut_down_duration = 0 - server.power_off() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.OFF assert service.operating_state is ServiceOperatingState.STOPPED server.power_on() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py new file mode 100644 index 00000000..6247a100 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py @@ -0,0 +1,50 @@ +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.software import SoftwareHealthState + + +def test_scan(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_visible == SoftwareHealthState.UNUSED + + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_visible == SoftwareHealthState.UNUSED + + application.scan() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_visible == SoftwareHealthState.GOOD + + +def test_run_application(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.UNUSED + + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_actual == SoftwareHealthState.GOOD + + +def test_close_application(application): + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_actual == SoftwareHealthState.GOOD + + application.close() + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.GOOD + + +def test_application_describe_states(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.UNUSED + + assert SoftwareHealthState.UNUSED.value == application.describe_state().get("health_state_actual") + + application.run() + assert SoftwareHealthState.GOOD.value == application.describe_state().get("health_state_actual") + + application.set_health_state(SoftwareHealthState.COMPROMISED) + assert SoftwareHealthState.COMPROMISED.value == application.describe_state().get("health_state_actual") + + application.patch() + assert SoftwareHealthState.PATCHING.value == application.describe_state().get("health_state_actual")