From 5ebbfab0ff738ccf35ede644d598f1551ed418ec Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 1 Aug 2023 10:02:13 +0100 Subject: [PATCH 01/63] Create some files for domain sim --- src/primaite/simulator/domain_controller/__init__.py | 0 src/primaite/simulator/domain_controller/account.py | 8 ++++++++ 2 files changed, 8 insertions(+) create mode 100644 src/primaite/simulator/domain_controller/__init__.py create mode 100644 src/primaite/simulator/domain_controller/account.py diff --git a/src/primaite/simulator/domain_controller/__init__.py b/src/primaite/simulator/domain_controller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/domain_controller/account.py b/src/primaite/simulator/domain_controller/account.py new file mode 100644 index 00000000..1f3ac900 --- /dev/null +++ b/src/primaite/simulator/domain_controller/account.py @@ -0,0 +1,8 @@ +"""User account simulation.""" +from primaite.simulator.core import SimComponent + + +class Account(SimComponent): + """User accounts.""" + + uid: int From ea8c65a17e972af1ac0c6a1334c7d391a39f9065 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 1 Aug 2023 16:18:49 +0100 Subject: [PATCH 02/63] #1714: set up files --- .azure/azure-ci-build-pipeline.yaml | 2 +- src/primaite/simulator/core.py | 2 +- .../simulator/file_system}/__init__.py | 0 .../simulator/file_system/file_system.py | 77 +++++++++++++++++++ .../simulator/file_system/file_system_file.py | 34 ++++++++ .../file_system/file_system_file_type.py | 7 ++ .../file_system/file_system_folder.py | 41 ++++++++++ .../file_system/file_system_item_abc.py | 60 +++++++++++++++ tests/conftest.py | 4 +- .../simulator => _primaite}/__init__.py | 0 .../_primaite/_simulator/__init__.py | 0 .../_simulator/_file_system/__init__.py | 0 .../_file_system/test_file_system.py | 0 .../_file_system/test_file_system_file.py | 0 .../_file_system/test_file_system_folder.py | 0 .../_simulator}/test_core.py | 0 16 files changed, 223 insertions(+), 4 deletions(-) rename {tests/unit_tests/primaite => src/primaite/simulator/file_system}/__init__.py (100%) create mode 100644 src/primaite/simulator/file_system/file_system.py create mode 100644 src/primaite/simulator/file_system/file_system_file.py create mode 100644 src/primaite/simulator/file_system/file_system_file_type.py create mode 100644 src/primaite/simulator/file_system/file_system_folder.py create mode 100644 src/primaite/simulator/file_system/file_system_item_abc.py rename tests/unit_tests/{primaite/simulator => _primaite}/__init__.py (100%) create mode 100644 tests/unit_tests/_primaite/_simulator/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_file_system/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py create mode 100644 tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py create mode 100644 tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py rename tests/unit_tests/{primaite/simulator => _primaite/_simulator}/test_core.py (100%) diff --git a/.azure/azure-ci-build-pipeline.yaml b/.azure/azure-ci-build-pipeline.yaml index 0bb03594..9070270a 100644 --- a/.azure/azure-ci-build-pipeline.yaml +++ b/.azure/azure-ci-build-pipeline.yaml @@ -86,5 +86,5 @@ stages: displayName: 'Perform PrimAITE Setup' - script: | - pytest -n 4 + pytest -n auto displayName: 'Run tests' diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 5b9bea1f..fce192c7 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -6,7 +6,7 @@ from pydantic import BaseModel class SimComponent(BaseModel): - """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" @abstractmethod def describe_state(self) -> Dict: diff --git a/tests/unit_tests/primaite/__init__.py b/src/primaite/simulator/file_system/__init__.py similarity index 100% rename from tests/unit_tests/primaite/__init__.py rename to src/primaite/simulator/file_system/__init__.py diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py new file mode 100644 index 00000000..f467595d --- /dev/null +++ b/src/primaite/simulator/file_system/file_system.py @@ -0,0 +1,77 @@ +from typing import Dict, List, Union + +from primaite.simulator.core import SimComponent +from primaite.simulator.file_system.file_system_file import FileSystemFile +from primaite.simulator.file_system.file_system_folder import FileSystemFolder + + +class FileSystem(SimComponent): + """Class that contains all the simulation File System.""" + + files: List[FileSystemFile] + """List containing all the files in the file system.""" + + folders: List[FileSystemFolder] + """List containing all the folders in the file system.""" + + def describe_state(self) -> Dict: + """ + Get the current state of the FileSystem as a dict. + + :return: A dict containing the current state of the FileSystemFile. + """ + pass + + def create_file(self): + """Creates a FileSystemFile and adds it to the list of files.""" + pass + + def create_folder(self): + """Creates a FileSystemFolder and adds it to the list of folders.""" + pass + + def delete_file(self, file_item: str): + """ + Deletes a file and removes it from the files list. + + :param file_item: The UUID of the file item to delete + :type file_item: str + """ + self.files = list(filter(lambda x: (x.get_item_uuid() != file_item), self.files)) + + def delete_folder(self, file_item: str): + """ + Deletes a folder, removes it frdom the folders list and removes any child folders and files. + + :param file_item: The UUID of the file item to delete + :type file_item: str + """ + self.files = list(filter(lambda x: (x.get_item_parent() != file_item), self.files)) + self.folders = list(filter(lambda x: (x.get_item_uuid() != file_item), self.folders)) + + def move_file_item(self, file_item: str, target_directory: str): + """ + Check to see if the file_item and target_directory exists then moves the item by changing its parent item uuid. + + :param file_item: The UUID of the file item to move + :type file_item: str + + :param target_directory: The UUID of the directory the item should be moved into + :type target_directory: str + """ + item = self._file_item_exists(file_item) + if item and any(f for f in self.folders if f.get_item_uuid() == target_directory): + item.move(target_directory) + + def _file_item_exists(self, file_item_uuid: str) -> Union[FileSystemFile, FileSystemFolder, None]: + """Returns true if the file or folder UUID exists.""" + item = next((x for x in self.files if x.get_item_uuid() == file_item_uuid), None) + if item: + return item + + next((x for x in self.folders if x.get_item_uuid() == file_item_uuid), None) + + if item: + return item + + raise Exception(f"No file or folder found with id: {file_item_uuid}") diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py new file mode 100644 index 00000000..ee4fe1e5 --- /dev/null +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -0,0 +1,34 @@ +from typing import Dict + +from primaite.simulator.file_system.file_system_file_type import FileSystemFileType +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC + + +class FileSystemFile(FileSystemItemABC): + """Class that represents a file in the simulation.""" + + _file_type: FileSystemFileType + """The type of the FileSystemFile""" + + def get_file_type(self) -> FileSystemFileType: + """Returns the FileSystemFileType of the file.""" + return self._file_type + + def move(self, target_directory: str): + """ + Changes the parent_item of the FileSystemFile. + + Essentially simulates the file system item being moved from folder to folder + + :param target_directory: The UUID of the directory the file system item should be moved to + :type target_directory: str + """ + super().move(target_directory) + + def describe_state(self) -> Dict: + """ + Get the current state of the FileSystemFile as a dict. + + :return: A dict containing the current state of the FileSystemFile. + """ + pass diff --git a/src/primaite/simulator/file_system/file_system_file_type.py b/src/primaite/simulator/file_system/file_system_file_type.py new file mode 100644 index 00000000..134b38f4 --- /dev/null +++ b/src/primaite/simulator/file_system/file_system_file_type.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class FileSystemFileType(str, Enum): + """Enum used to determine the FileSystemFile type.""" + + TBD = "TBD" diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py new file mode 100644 index 00000000..41b9e1dd --- /dev/null +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -0,0 +1,41 @@ +from typing import Dict + +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC + + +class FileSystemFolder(FileSystemItemABC): + """Simulation FileSystemFolder.""" + + _is_quarantined: bool + """Flag that marks the folder as quarantined if true.""" + + def quarantine(self): + """Quarantines the File System Folder.""" + self._is_quarantined = True + + def end_quarantine(self): + """Ends the quarantine of the File System Folder.""" + self._is_quarantined = False + + def quarantine_status(self) -> bool: + """Returns true if the folder is being quarantined.""" + return self._is_quarantined + + def move(self, target_directory: str): + """ + Changes the parent_item of the file system item. + + Essentially simulates the file system item being moved from folder to folder + + :param target_directory: The UUID of the directory the file system item should be moved to + :type target_directory: str + """ + super().move(target_directory) + + def describe_state(self) -> Dict: + """ + Get the current state of the FileSystemFolder as a dict. + + :return: A dict containing the current state of the FileSystemFile. + """ + pass diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py new file mode 100644 index 00000000..11a3f858 --- /dev/null +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -0,0 +1,60 @@ +from abc import ABC, abstractmethod +from uuid import uuid4 + +from primaite.simulator.core import SimComponent + + +class FileSystemItemABC(SimComponent, ABC): + """Abstract Base class for any file system items e.g. files and folders.""" + + _uuid: str + """Unique identifier for the FileSystemItem""" + + _parent_item: str + """UUID of the parent FileSystemItem""" + + _item_size: float + """Disk size of the FileSystemItem""" + + def __init__(self, parent_item: str, item_size: float): + """ + Abstract base class used by FileSystem items. + + :param parent_item: The UUID of the FileSystemItem parent + :type parent_item: str + + :param item_size: The size of the FileSystemItem + :type item_size: float + """ + super().__init__() + + # generate random uuid for file system item + self._uuid = str(uuid4()) + + self._parent_item = parent_item + + self._item_size = item_size + + def get_item_uuid(self) -> str: + """Returns the file system item UUID.""" + return self._uuid + + def get_item_parent(self) -> str: + """Returns the UUID of the item's parent.""" + return self._parent_item + + def get_item_size(self) -> float: + """Returns the item size.""" + return self._item_size + + @abstractmethod + def move(self, target_directory: str): + """ + Changes the parent_item of the file system item. + + Essentially simulates the file system item being moved from folder to folder + + :param target_directory: The UUID of the directory the file system item should be moved to + :type target_directory: str + """ + self._parent_item = target_directory diff --git a/tests/conftest.py b/tests/conftest.py index f40b0b94..8102050e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,7 +96,7 @@ def temp_primaite_session(request): """ training_config_path = request.param[0] lay_down_config_path = request.param[1] - with patch("primaite.agents.agent_abc.get_session_path", get_temp_session_path) as mck: + with patch("_primaite.agents.agent_abc.get_session_path", get_temp_session_path) as mck: mck.session_timestamp = datetime.now() return TempPrimaiteSession(training_config_path, lay_down_config_path) @@ -112,7 +112,7 @@ def temp_session_path() -> Path: session_timestamp = datetime.now() date_dir = session_timestamp.strftime("%Y-%m-%d") session_path = session_timestamp.strftime("%Y-%m-%d_%H-%M-%S") - session_path = Path(tempfile.gettempdir()) / "primaite" / date_dir / session_path + session_path = Path(tempfile.gettempdir()) / "_primaite" / date_dir / session_path session_path.mkdir(exist_ok=True, parents=True) return session_path diff --git a/tests/unit_tests/primaite/simulator/__init__.py b/tests/unit_tests/_primaite/__init__.py similarity index 100% rename from tests/unit_tests/primaite/simulator/__init__.py rename to tests/unit_tests/_primaite/__init__.py diff --git a/tests/unit_tests/_primaite/_simulator/__init__.py b/tests/unit_tests/_primaite/_simulator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/__init__.py b/tests/unit_tests/_primaite/_simulator/_file_system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/primaite/simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py similarity index 100% rename from tests/unit_tests/primaite/simulator/test_core.py rename to tests/unit_tests/_primaite/_simulator/test_core.py From 091b4a801dc5a7f558fdea273fcbed579f950021 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 2 Aug 2023 13:43:31 +0100 Subject: [PATCH 03/63] Make some progress on accounts --- src/primaite/simulator/domain/__init__.py | 3 + src/primaite/simulator/domain/account.py | 92 +++++++++++++++++++ src/primaite/simulator/domain/controller.py | 13 +++ .../simulator/domain_controller/__init__.py | 0 .../simulator/domain_controller/account.py | 8 -- 5 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 src/primaite/simulator/domain/__init__.py create mode 100644 src/primaite/simulator/domain/account.py create mode 100644 src/primaite/simulator/domain/controller.py delete mode 100644 src/primaite/simulator/domain_controller/__init__.py delete mode 100644 src/primaite/simulator/domain_controller/account.py diff --git a/src/primaite/simulator/domain/__init__.py b/src/primaite/simulator/domain/__init__.py new file mode 100644 index 00000000..6f59cf49 --- /dev/null +++ b/src/primaite/simulator/domain/__init__.py @@ -0,0 +1,3 @@ +from primaite.simulator.domain.account import Account + +__all__ = ["Account"] diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py new file mode 100644 index 00000000..374675a0 --- /dev/null +++ b/src/primaite/simulator/domain/account.py @@ -0,0 +1,92 @@ +"""User account simulation.""" +from enum import Enum +from typing import Dict, List, Set, TypeAlias + +from primaite import getLogger +from primaite.simulator.core import SimComponent + +_LOGGER = getLogger(__name__) + + +__temp_node = TypeAlias() # placeholder while nodes don't exist + + +class AccountType(Enum): + """Whether the account is intended for a user to log in or for a service to use.""" + + service = 1 + "Service accounts are used to grant permissions to software on nodes to perform actions" + user = 2 + "User accounts are used to allow agents to log in and perform actions" + + +class AccountGroup(Enum): + """Permissions are set at group-level and accounts can belong to these groups.""" + + local_user = 1 + "For performing basic actions on a node" + domain_user = 2 + "For performing basic actions to the domain" + local_admin = 3 + "For full access to actions on a node" + domain_admin = 4 + "For full access" + + +class AccountStatus(Enum): + """Whether the account is active.""" + + enabled = 1 + disabled = 2 + + +class Account(SimComponent): + """User accounts.""" + + num_logons: int = 0 + "The number of times this account was logged into since last reset." + num_logoffs: int = 0 + "The number of times this account was logged out of since last reset." + num_group_changes: int = 0 + "The number of times this account was moved in or out of an AccountGroup." + username: str + "Account username." + password: str + "Account password." + account_type: AccountType + "Account Type, currently this can be service account (used by apps) or user account." + domain_groups: Set[AccountGroup] = [] + "Domain-wide groups that this account belongs to." + local_groups: Dict[__temp_node, List[AccountGroup]] + "For each node, whether this account has local/admin privileges on that node." + status: AccountStatus = AccountStatus.disabled + + def add_to_domain_group(self, group: AccountGroup) -> None: + """ + Add this account to a domain group. + + If the account is already a member of this group, nothing happens. + + :param group: The group to which to add this account. + :type group: AccountGroup + """ + self.domain_groups.add(group) + + def remove_from_domain_group(self, group: AccountGroup) -> None: + """ + Remove this account from a domain group. + + If the account is already not a member of that group, nothing happens. + + :param group: The group from which this account should be removed. + :type group: AccountGroup + """ + self.domain_groups.discard(group) + + def enable_account(self): + """Set the status to enabled.""" + self.status = AccountStatus.enabled + + def disable_account(self): + """Set the status to disabled.""" + self.status = AccountStatus.disabled diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py new file mode 100644 index 00000000..5a14e80e --- /dev/null +++ b/src/primaite/simulator/domain/controller.py @@ -0,0 +1,13 @@ +from typing import Set, TypeAlias + +from primaite.simulator.core import SimComponent +from primaite.simulator.domain import Account + +__temp_node = TypeAlias() # placeholder while nodes don't exist + + +class DomainController(SimComponent): + """Main object for controlling the domain.""" + + nodes: Set(__temp_node) = set() + accounts: Set(Account) = set() diff --git a/src/primaite/simulator/domain_controller/__init__.py b/src/primaite/simulator/domain_controller/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/primaite/simulator/domain_controller/account.py b/src/primaite/simulator/domain_controller/account.py deleted file mode 100644 index 1f3ac900..00000000 --- a/src/primaite/simulator/domain_controller/account.py +++ /dev/null @@ -1,8 +0,0 @@ -"""User account simulation.""" -from primaite.simulator.core import SimComponent - - -class Account(SimComponent): - """User accounts.""" - - uid: int From 897dbdf10c83e2bebd33952c38bbe267703a4f4f Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 2 Aug 2023 21:54:21 +0100 Subject: [PATCH 04/63] #1706 - Got the core Node class build and working with ARP and the ability to ping another node. Added some basic tests in. Next job is to create the Node subclasses. Then move ARP and ICMP into a service that is used by all nodes. --- docs/source/simulation.rst | 2 +- .../{physical_layer.rst => base_hardware.rst} | 4 +- .../network/transport_to_data_link_layer.rst | 4 +- src/primaite/simulator/core.py | 9 + .../simulator/network/hardware/__init__.py | 0 .../simulator/network/hardware/base.py | 665 ++++++++++++++++++ .../simulator/network/nodes/__init__.py | 0 .../simulator/network/protocols/__init__.py | 0 .../simulator/network/protocols/arp.py | 69 ++ .../network/transmission/data_link_layer.py | 25 +- .../network/transmission/network_layer.py | 13 +- .../network/transmission/physical_layer.py | 277 -------- .../network/transmission/transport_layer.py | 6 +- src/primaite/simulator/network/utils.py | 27 + .../network/test_frame_transmission.py | 25 + .../network/test_link_connection.py | 21 + .../network/test_nic_link_connection.py | 2 +- .../_simulator/_network/_hardware/__init__.py | 0 .../test_nic.py} | 2 +- .../_network/_hardware/test_node.py | 10 + .../_transmission/test_data_link_layer.py | 4 +- .../_transmission/test_network_layer.py | 16 +- .../_primaite/_simulator/test_core.py | 7 +- 23 files changed, 879 insertions(+), 309 deletions(-) rename docs/source/simulation_components/network/{physical_layer.rst => base_hardware.rst} (98%) create mode 100644 src/primaite/simulator/network/hardware/__init__.py create mode 100644 src/primaite/simulator/network/hardware/base.py create mode 100644 src/primaite/simulator/network/nodes/__init__.py create mode 100644 src/primaite/simulator/network/protocols/__init__.py create mode 100644 src/primaite/simulator/network/protocols/arp.py delete mode 100644 src/primaite/simulator/network/transmission/physical_layer.py create mode 100644 src/primaite/simulator/network/utils.py create mode 100644 tests/integration_tests/network/test_frame_transmission.py create mode 100644 tests/integration_tests/network/test_link_connection.py create mode 100644 tests/unit_tests/_primaite/_simulator/_network/_hardware/__init__.py rename tests/unit_tests/_primaite/_simulator/_network/{_transmission/test_physical_layer.py => _hardware/test_nic.py} (95%) create mode 100644 tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node.py diff --git a/docs/source/simulation.rst b/docs/source/simulation.rst index 81476998..b9f921c2 100644 --- a/docs/source/simulation.rst +++ b/docs/source/simulation.rst @@ -16,5 +16,5 @@ Contents :maxdepth: 8 simulation_structure - simulation_components/network/physical_layer + simulation_components/network/base_hardware simulation_components/network/transport_to_data_link_layer diff --git a/docs/source/simulation_components/network/physical_layer.rst b/docs/source/simulation_components/network/base_hardware.rst similarity index 98% rename from docs/source/simulation_components/network/physical_layer.rst rename to docs/source/simulation_components/network/base_hardware.rst index 1e87b72e..c3891a6e 100644 --- a/docs/source/simulation_components/network/physical_layer.rst +++ b/docs/source/simulation_components/network/base_hardware.rst @@ -2,8 +2,8 @@ © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -Physical Layer -============== +Base Hardware +============= The physical layer components are models of a ``NIC`` (Network Interface Card) and a ``Link``. These components allow modelling of layer 1 (physical layer) in the OSI model. diff --git a/docs/source/simulation_components/network/transport_to_data_link_layer.rst b/docs/source/simulation_components/network/transport_to_data_link_layer.rst index 8273339c..9332b57c 100644 --- a/docs/source/simulation_components/network/transport_to_data_link_layer.rst +++ b/docs/source/simulation_components/network/transport_to_data_link_layer.rst @@ -34,7 +34,7 @@ specify the priority of IP packets for Quality of Service handling. **ICMPType:** Enumeration of common ICMP (Internet Control Message Protocol) types. It defines various types of ICMP messages used for network troubleshooting and error reporting. -**ICMPHeader:** Models an ICMP header and includes ICMP type, code, identifier, and sequence number. It is used to +**ICMPPacket:** Models an ICMP header and includes ICMP type, code, identifier, and sequence number. It is used to create ICMP packets for network control and error reporting. **IPPacket:** Represents the IP layer of a network frame. It includes source and destination IP addresses, protocol @@ -59,7 +59,7 @@ Data Link Layer (Layer 2) This header is used to identify the physical hardware addresses of devices on a local network. **Frame:** Represents a complete network frame with all layers. It includes an ``EthernetHeader``, an ``IPPacket``, an -optional ``TCPHeader``, ``UDPHeader``, or ``ICMPHeader``, a ``PrimaiteHeader`` and an optional payload. This class +optional ``TCPHeader``, ``UDPHeader``, or ``ICMPPacket``, a ``PrimaiteHeader`` and an optional payload. This class combines all the headers and data to create a complete network frame that can be sent over the network and used in the PrimAITE simulation. diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index c3130116..d684a74b 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -1,6 +1,7 @@ """Core of the PrimAITE Simulator.""" from abc import abstractmethod from typing import Callable, Dict, List +from uuid import uuid4 from pydantic import BaseModel @@ -8,6 +9,14 @@ from pydantic import BaseModel class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + uuid: str + "The component UUID." + + def __init__(self, **kwargs): + if not kwargs.get("uuid"): + kwargs["uuid"] = str(uuid4()) + super().__init__(**kwargs) + @abstractmethod def describe_state(self) -> Dict: """ diff --git a/src/primaite/simulator/network/hardware/__init__.py b/src/primaite/simulator/network/hardware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py new file mode 100644 index 00000000..054eb1c6 --- /dev/null +++ b/src/primaite/simulator/network/hardware/base.py @@ -0,0 +1,665 @@ +from __future__ import annotations + +import re +import secrets +from enum import Enum +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, List, Optional, Union + +from primaite import getLogger +from primaite.exceptions import NetworkError +from primaite.simulator.core import SimComponent +from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket +from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame +from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader + +_LOGGER = getLogger(__name__) + + +def generate_mac_address(oui: Optional[str] = None) -> str: + """ + Generate a random MAC Address. + + :Example: + + >>> generate_mac_address() + 'ef:7e:97:c8:a8:ce' + + >>> generate_mac_address(oui='aa:bb:cc') + 'aa:bb:cc:42:ba:41' + + :param oui: The Organizationally Unique Identifier (OUI) portion of the MAC address. It should be a string with + the first 3 bytes (24 bits) in the format "XX:XX:XX". + :raises ValueError: If the 'oui' is not in the correct format (hexadecimal and 6 characters). + """ + random_bytes = [secrets.randbits(8) for _ in range(6)] + + if oui: + oui_pattern = re.compile(r"^([0-9A-Fa-f]{2}[:-]){2}[0-9A-Fa-f]{2}$") + if not oui_pattern.match(oui): + msg = f"Invalid oui. The oui should be in the format xx:xx:xx, where x is a hexadecimal digit, got '{oui}'" + raise ValueError(msg) + oui_bytes = [int(chunk, 16) for chunk in oui.split(":")] + mac = oui_bytes + random_bytes[len(oui_bytes) :] + else: + mac = random_bytes + + return ":".join(f"{b:02x}" for b in mac) + + +class NIC(SimComponent): + """ + Models a Network Interface Card (NIC) in a computer or network device. + + :param ip_address: The IPv4 address assigned to the NIC. + :param subnet_mask: The subnet mask assigned to the NIC. + :param gateway: The default gateway IP address for forwarding network traffic to other networks. + :param mac_address: The MAC address of the NIC. Defaults to a randomly set MAC address. + :param speed: The speed of the NIC in Mbps (default is 100 Mbps). + :param mtu: The Maximum Transmission Unit (MTU) of the NIC in Bytes, representing the largest data packet size it + can handle without fragmentation (default is 1500 B). + :param wake_on_lan: Indicates if the NIC supports Wake-on-LAN functionality. + :param dns_servers: List of IP addresses of DNS servers used for name resolution. + """ + + ip_address: IPv4Address + "The IP address assigned to the NIC for communication on an IP-based network." + subnet_mask: str + "The subnet mask assigned to the NIC." + gateway: IPv4Address + "The default gateway IP address for forwarding network traffic to other networks. Randomly generated upon creation." + mac_address: str + "The MAC address of the NIC. Defaults to a randomly set MAC address." + speed: int = 100 + "The speed of the NIC in Mbps. Default is 100 Mbps." + mtu: int = 1500 + "The Maximum Transmission Unit (MTU) of the NIC in Bytes. Default is 1500 B" + wake_on_lan: bool = False + "Indicates if the NIC supports Wake-on-LAN functionality." + dns_servers: List[IPv4Address] = [] + "List of IP addresses of DNS servers used for name resolution." + connected_node: Optional[Node] = None + "The Node to which the NIC is connected." + connected_link: Optional[Link] = None + "The Link to which the NIC is connected." + enabled: bool = False + "Indicates whether the NIC is enabled." + + def __init__(self, **kwargs): + """ + NIC constructor. + + Performs some type conversion the calls ``super().__init__()``. Then performs some checking on the ip_address + and gateway just to check that it's all been configured correctly. + + :raises ValueError: When the ip_address and gateway are the same. And when the ip_address/subnet mask are a + network address. + """ + if not isinstance(kwargs["ip_address"], IPv4Address): + kwargs["ip_address"] = IPv4Address(kwargs["ip_address"]) + if not isinstance(kwargs["gateway"], IPv4Address): + kwargs["gateway"] = IPv4Address(kwargs["gateway"]) + if "mac_address" not in kwargs: + kwargs["mac_address"] = generate_mac_address() + super().__init__(**kwargs) + + if self.ip_address == self.gateway: + msg = f"NIC ip address {self.ip_address} cannot be the same as the gateway {self.gateway}" + _LOGGER.error(msg) + raise ValueError(msg) + if self.ip_network.network_address == self.ip_address: + msg = ( + f"Failed to set IP address {self.ip_address} and subnet mask {self.subnet_mask} as it is a " + f"network address {self.ip_network.network_address}" + ) + _LOGGER.error(msg) + raise ValueError(msg) + + @property + def ip_network(self) -> IPv4Network: + """ + Return the IPv4Network of the NIC. + + :return: The IPv4Network from the ip_address/subnet mask. + """ + return IPv4Network(f"{self.ip_address}/{self.subnet_mask}", strict=False) + + def enable(self): + """Attempt to enable the NIC.""" + if not self.enabled: + if self.connected_node: + if self.connected_node.hardware_state == HardwareState.ON: + self.enabled = True + _LOGGER.info(f"NIC {self} enabled") + if self.connected_link: + self.connected_link.endpoint_up() + else: + _LOGGER.info(f"NIC {self} cannot be enabled as the endpoint is not turned on") + else: + msg = f"NIC {self} cannot be enabled as it is not connected to a Node" + _LOGGER.error(msg) + raise NetworkError(msg) + + def disable(self): + """Disable the NIC.""" + if self.enabled: + self.enabled = False + _LOGGER.info(f"NIC {self} disabled") + if self.connected_link: + self.connected_link.endpoint_down() + + def connect_link(self, link: Link): + """ + Connect the NIC to a link. + + :param link: The link to which the NIC is connected. + :type link: :class:`~primaite.simulator.network.transmission.physical_layer.Link` + :raise NetworkError: When an attempt to connect a Link is made while the NIC has a connected Link. + """ + if not self.connected_link: + if self.connected_link != link: + _LOGGER.info(f"NIC {self} connected to Link") + # TODO: Inform the Node that a link has been connected + self.connected_link = link + else: + _LOGGER.warning(f"Cannot connect link to NIC ({self.mac_address}) as it is already connected") + else: + msg = f"Cannot connect link to NIC ({self.mac_address}) as it already has a connection" + _LOGGER.error(msg) + raise NetworkError(msg) + + def disconnect_link(self): + """Disconnect the NIC from the connected Link.""" + if self.connected_link.endpoint_a == self: + self.connected_link.endpoint_a = None + if self.connected_link.endpoint_b == self: + self.connected_link.endpoint_b = None + self.connected_link = None + + def add_dns_server(self, ip_address: IPv4Address): + """ + Add a DNS server IP address. + + :param ip_address: The IP address of the DNS server to be added. + :type ip_address: ipaddress.IPv4Address + """ + pass + + def remove_dns_server(self, ip_address: IPv4Address): + """ + Remove a DNS server IP Address. + + :param ip_address: The IP address of the DNS server to be removed. + :type ip_address: ipaddress.IPv4Address + """ + pass + + def send_frame(self, frame: Frame) -> bool: + """ + Send a network frame from the NIC to the connected link. + + :param frame: The network frame to be sent. + :type frame: :class:`~primaite.simulator.network.osi_layers.Frame` + """ + if self.enabled: + self.connected_link.transmit_frame(sender_nic=self, frame=frame) + return True + else: + # Cannot send Frame as the NIC is not enabled + return False + + def receive_frame(self, frame: Frame) -> bool: + """ + Receive a network frame from the connected link if the NIC is enabled. + + The Frame is passed to the Node. + + :param frame: The network frame being received. + :type frame: :class:`~primaite.simulator.network.osi_layers.Frame` + """ + if self.enabled: + self.connected_node.receive_frame(frame=frame, from_nic=self) + return True + else: + return False + + def describe_state(self) -> Dict: + """ + Get the current state of the NIC as a dict. + + :return: A dict containing the current state of the NIC. + """ + pass + + def apply_action(self, action: str): + """ + Apply an action to the NIC. + + :param action: The action to be applied. + :type action: str + """ + pass + + def __str__(self) -> str: + return f"{self.mac_address}/{self.ip_address}" + + +class Link(SimComponent): + """ + Represents a network link between two network interface cards (NICs). + + :param endpoint_a: The first NIC connected to the Link. + :type endpoint_a: NIC + :param endpoint_b: The second NIC connected to the Link. + :type endpoint_b: NIC + :param bandwidth: The bandwidth of the Link in Mbps (default is 100 Mbps). + :type bandwidth: int + """ + + endpoint_a: NIC + "The first NIC connected to the Link." + endpoint_b: NIC + "The second NIC connected to the Link." + bandwidth: int = 100 + "The bandwidth of the Link in Mbps (default is 100 Mbps)." + current_load: float = 0.0 + "The current load on the link in Mbps." + + def __init__(self, **kwargs): + """ + Ensure that endpoint_a and endpoint_b are not the same NIC. + + Connect the link to the NICs after creation. + + :raises ValueError: If endpoint_a and endpoint_b are the same NIC. + """ + if kwargs["endpoint_a"] == kwargs["endpoint_b"]: + msg = "endpoint_a and endpoint_b cannot be the same NIC" + _LOGGER.error(msg) + raise ValueError(msg) + super().__init__(**kwargs) + self.endpoint_a.connect_link(self) + self.endpoint_b.connect_link(self) + if self.up: + _LOGGER.info(f"Link up between {self.endpoint_a} and {self.endpoint_b}") + + def endpoint_up(self): + """Let the Link know and endpoint has been brought up.""" + if self.up: + _LOGGER.info(f"Link up between {self.endpoint_a} and {self.endpoint_b}") + + def endpoint_down(self): + """Let the Link know and endpoint has been brought down.""" + if not self.up: + self.current_load = 0.0 + _LOGGER.info(f"Link down between {self.endpoint_a} and {self.endpoint_b}") + + @property + def up(self) -> bool: + """ + Informs whether the link is up. + + This is based upon both NIC endpoints being enabled. + """ + return self.endpoint_a.enabled and self.endpoint_b.enabled + + def _can_transmit(self, frame: Frame) -> bool: + if self.up: + frame_size_Mbits = frame.size_Mbits # noqa - Leaving it as Mbits as this is how they're expressed + return self.current_load + frame_size_Mbits <= self.bandwidth + return False + + def transmit_frame(self, sender_nic: NIC, frame: Frame) -> bool: + """ + Send a network frame from one NIC to another connected NIC. + + :param sender_nic: The NIC sending the frame. + :param frame: The network frame to be sent. + :return: True if the Frame can be sent, otherwise False. + """ + receiver_nic = self.endpoint_a + if receiver_nic == sender_nic: + receiver_nic = self.endpoint_b + frame_size = frame.size + sent = receiver_nic.receive_frame(frame) + if sent: + # Frame transmitted successfully + # Load the frame size on the link + self.current_load += frame_size + return True + # Received NIC disabled, reply + + return False + + def reset_component_for_episode(self): + """ + Link reset function. + + Reset: + - returns the link current_load to 0. + """ + self.current_load = 0 + + def describe_state(self) -> Dict: + """ + Get the current state of the Libk as a dict. + + :return: A dict containing the current state of the Link. + """ + pass + + def apply_action(self, action: str): + """ + Apply an action to the Link. + + :param action: The action to be applied. + :type action: str + """ + pass + + +class HardwareState(Enum): + """Node hardware state enumeration.""" + + ON = 1 + OFF = 2 + RESETTING = 3 + SHUTTING_DOWN = 4 + BOOTING = 5 + + +class Node(SimComponent): + """ + A basic Node class. + + :param hostname: The node hostname on the network. + :param hardware_state: The hardware state of the node. + """ + + hostname: str + "The node hostname on the network." + hardware_state: HardwareState = HardwareState.OFF + "The hardware state of the node." + nics: Dict[str, NIC] = {} + "The NICs on the node." + + accounts: Dict = {} + "All accounts on the node." + applications: Dict = {} + "All applications on the node." + services: Dict = {} + "All services on the node." + processes: Dict = {} + "All processes on the node." + file_system: Any = None + "The nodes file system." + arp_cache: Dict[IPv4Address, ARPEntry] = {} + "The ARP cache." + + revealed_to_red: bool = False + "Informs whether the node has been revealed to a red agent." + + def turn_on(self): + """Turn on the Node.""" + if self.hardware_state == HardwareState.OFF: + self.hardware_state = HardwareState.ON + _LOGGER.info(f"Node {self.hostname} turned on") + for nic in self.nics.values(): + nic.enable() + + def turn_off(self): + """Turn off the Node.""" + if self.hardware_state == HardwareState.ON: + for nic in self.nics.values(): + nic.disable() + self.hardware_state = HardwareState.OFF + _LOGGER.info(f"Node {self.hostname} turned off") + + def connect_nic(self, nic: NIC): + """ + Connect a NIC. + + :param nic: The NIC to connect. + :raise NetworkError: If the NIC is already connected. + """ + if nic.uuid not in self.nics: + self.nics[nic.uuid] = nic + nic.connected_node = self + _LOGGER.debug(f"Node {self.hostname} connected NIC {nic}") + if self.hardware_state == HardwareState.ON: + nic.enable() + else: + msg = f"Cannot connect NIC {nic} to Node {self.hostname} as it is already connected" + _LOGGER.error(msg) + raise NetworkError(msg) + + def disconnect_nic(self, nic: Union[NIC, str]): + """ + Disconnect a NIC. + + :param nic: The NIC to Disconnect. + :raise NetworkError: If the NIC is not connected. + """ + if isinstance(nic, str): + nic = self.nics.get(nic) + if nic or nic.uuid in self.nics: + self.nics.pop(nic.uuid) + nic.disable() + _LOGGER.debug(f"Node {self.hostname} disconnected NIC {nic}") + else: + msg = f"Cannot disconnect NIC {nic} from Node {self.hostname} as it is not connected" + _LOGGER.error(msg) + raise NetworkError(msg) + + def _add_arp_cache_entry(self, ip_address: IPv4Address, mac_address: str, nic: NIC): + """ + Add an ARP entry to the cache. + + :param ip_address: The IP address to be added to the cache. + :param mac_address: The MAC address associated with the IP address. + :param nic: The NIC through which the NIC with the IP address is reachable. + """ + _LOGGER.info(f"Node {self.hostname} Adding ARP cache entry for {mac_address}/{ip_address} via NIC {nic}") + arp_entry = ARPEntry(mac_address=mac_address, nic_uuid=nic.uuid) + self.arp_cache[ip_address] = arp_entry + + def _remove_arp_cache_entry(self, ip_address: IPv4Address): + """ + Remove an ARP entry from the cache. + + :param ip_address: The IP address to be removed from the cache. + """ + if ip_address in self.arp_cache: + del self.arp_cache[ip_address] + + def _get_arp_cache_mac_address(self, ip_address: IPv4Address) -> Optional[str]: + """ + Get the MAC address associated with an IP address. + + :param ip_address: The IP address to look up in the cache. + :return: The MAC address associated with the IP address, or None if not found. + """ + arp_entry = self.arp_cache.get(ip_address) + if arp_entry: + return arp_entry.mac_address + + def _get_arp_cache_nic(self, ip_address: IPv4Address) -> Optional[NIC]: + """ + Get the NIC associated with an IP address. + + :param ip_address: The IP address to look up in the cache. + :return: The NIC associated with the IP address, or None if not found. + """ + arp_entry = self.arp_cache.get(ip_address) + if arp_entry: + return self.nics[arp_entry.nic_uuid] + + def _clear_arp_cache(self): + """Clear the entire ARP cache.""" + self.arp_cache.clear() + + def _send_arp_request(self, target_ip_address: Union[IPv4Address, str]): + """Perform a standard ARP request for a given target IP address.""" + for nic in self.nics.values(): + if nic.enabled: + _LOGGER.info(f"Node {self.hostname} sending ARP request from NIC {nic} for ip {target_ip_address}") + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + # Network Layer + ip_packet = IPPacket( + src_ip=nic.ip_address, + dst_ip=target_ip_address, + ) + # Data Link Layer + ethernet_header = EthernetHeader(src_mac_addr=nic.mac_address, dst_mac_addr="ff:ff:ff:ff:ff:ff") + arp_packet = ARPPacket( + sender_ip=nic.ip_address, sender_mac_addr=nic.mac_address, target_ip=target_ip_address + ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) + nic.send_frame(frame) + + def process_arp_packet(self, from_nic: NIC, arp_packet: ARPPacket): + """ + Process an ARP packet. + + # TODO: This will become a service that sits on the Node. + + :param from_nic: The NIC the arp packet was received at. + :param arp_packet:The ARP packet to process. + """ + if arp_packet.request: + _LOGGER.info( + f"Node {self.hostname} received ARP request from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip}" + ) + self._add_arp_cache_entry( + ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic + ) + arp_packet = arp_packet.generate_reply(from_nic.mac_address) + _LOGGER.info( + f"Node {self.hostname} sending ARP reply from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " + f"to {arp_packet.target_ip}/{arp_packet.target_mac_addr} " + ) + + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + # Network Layer + ip_packet = IPPacket( + src_ip=arp_packet.sender_ip, + dst_ip=arp_packet.target_ip, + ) + # Data Link Layer + ethernet_header = EthernetHeader( + src_mac_addr=arp_packet.sender_mac_addr, dst_mac_addr=arp_packet.target_mac_addr + ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) + self.send_frame(frame) + else: + _LOGGER.info( + f"Node {self.hostname} received ARP response for {arp_packet.sender_ip} " + f"from {arp_packet.sender_mac_addr} via NIC {from_nic}" + ) + self._add_arp_cache_entry( + ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic + ) + + def process_icmp(self, frame: Frame): + """ + Process an ICMP packet. + + # TODO: This will become a service that sits on the Node. + + :param frame: The Frame containing the icmp packet to process. + """ + if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: + _LOGGER.info(f"Node {self.hostname} received echo request from {frame.ip.src_ip}") + target_mac_address = self._get_arp_cache_mac_address(frame.ip.src_ip) + src_nic = self._get_arp_cache_nic(frame.ip.src_ip) + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + # Network Layer + ip_packet = IPPacket(src_ip=src_nic.ip_address, dst_ip=frame.ip.src_ip, protocol=IPProtocol.ICMP) + # Data Link Layer + ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) + icmp_reply_packet = ICMPPacket( + icmp_type=ICMPType.ECHO_REPLY, + icmp_code=0, + identifier=frame.icmp.identifier, + sequence=frame.icmp.sequence + 1, + ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) + src_nic.send_frame(frame) + elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: + _LOGGER.info(f"Node {self.hostname} received echo reply from {frame.ip.src_ip}") + if frame.icmp.sequence <= 6: # 3 pings + self._ping(frame.ip.src_ip, sequence=frame.icmp.sequence, identifier=frame.icmp.identifier) + + def _ping(self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None): + nic = self._get_arp_cache_nic(target_ip_address) + if nic: + sequence += 1 + target_mac_address = self._get_arp_cache_mac_address(target_ip_address) + src_nic = self._get_arp_cache_nic(target_ip_address) + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + # Network Layer + ip_packet = IPPacket( + src_ip=nic.ip_address, + dst_ip=target_ip_address, + protocol=IPProtocol.ICMP, + ) + # Data Link Layer + ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) + icmp_packet = ICMPPacket(identifier=identifier, sequence=sequence) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_packet) + nic.send_frame(frame) + else: + _LOGGER.info(f"Node {self.hostname} no entry in ARP cache for {target_ip_address}") + self._send_arp_request(target_ip_address) + self._ping(target_ip_address=target_ip_address) + + def ping(self, target_ip_address: Union[IPv4Address, str]) -> bool: + """ + Ping an IP address. + + Performs a standard ICMP echo request/response four times. + + :param target_ip_address: The target IP address to ping. + :return: True if successful, otherwise False. + """ + if not isinstance(target_ip_address, IPv4Address): + target_ip_address = IPv4Address(target_ip_address) + if self.hardware_state == HardwareState.ON: + _LOGGER.info(f"Node {self.hostname} attempting to ping {target_ip_address}") + self._ping(target_ip_address) + return True + return False + + def send_frame(self, frame: Frame): + """ + Send a Frame from the Node to the connected NIC. + + :param frame: The Frame to be sent. + """ + nic: NIC = self._get_arp_cache_nic(frame.ip.dst_ip) + nic.send_frame(frame) + + def receive_frame(self, frame: Frame, from_nic: NIC): + """ + Receive a Frame from the connected NIC. + + The Frame is passed to up to the SessionManager. + + :param frame: The Frame being received. + """ + if frame.ip.protocol == IPProtocol.TCP: + if frame.tcp.src_port == Port.ARP: + self.process_arp_packet(from_nic=from_nic, arp_packet=frame.arp) + elif frame.ip.protocol == IPProtocol.UDP: + pass + elif frame.ip.protocol == IPProtocol.ICMP: + self.process_icmp(frame=frame) + + def describe_state(self) -> Dict: + """Describe the state of a Node.""" + pass diff --git a/src/primaite/simulator/network/nodes/__init__.py b/src/primaite/simulator/network/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/protocols/__init__.py b/src/primaite/simulator/network/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/network/protocols/arp.py b/src/primaite/simulator/network/protocols/arp.py new file mode 100644 index 00000000..bae14d28 --- /dev/null +++ b/src/primaite/simulator/network/protocols/arp.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Optional + +from pydantic import BaseModel + + +class ARPEntry(BaseModel): + """ + Represents an entry in the ARP cache. + + :param mac_address: The MAC address associated with the IP address. + :param nic: The NIC through which the NIC with the IP address is reachable. + """ + + mac_address: str + nic_uuid: str + + +class ARPPacket(BaseModel): + """ + Represents the ARP layer of a network frame. + + :param request: ARP operation. True if a request, False if a reply. + :param sender_mac_addr: Sender MAC address. + :param sender_ip: Sender IP address. + :param target_mac_addr: Target MAC address. + :param target_ip: Target IP address. + + :Example: + + >>> arp_request = ARPPacket( + ... sender_mac_addr="aa:bb:cc:dd:ee:ff", + ... sender_ip=IPv4Address("192.168.0.1"), + ... target_ip=IPv4Address("192.168.0.2") + ... ) + >>> arp_response = ARPPacket( + ... sender_mac_addr="aa:bb:cc:dd:ee:ff", + ... sender_ip=IPv4Address("192.168.0.1"), + ... target_ip=IPv4Address("192.168.0.2") + ... ) + """ + + request: bool = True + "ARP operation. True if a request, False if a reply." + sender_mac_addr: str + "Sender MAC address." + sender_ip: IPv4Address + "Sender IP address." + target_mac_addr: Optional[str] = None + "Target MAC address." + target_ip: IPv4Address + "Target IP address." + + def generate_reply(self, mac_address: str) -> ARPPacket: + """ + Generate a new ARPPacket to be sent as a response with a given mac address. + + :param mac_address: The mac_address that was being sought after from the original target IP address. + :return: A new instance of ARPPacket. + """ + return ARPPacket( + request=False, + sender_ip=self.target_ip, + sender_mac_addr=mac_address, + target_ip=self.sender_ip, + target_mac_addr=self.sender_mac_addr, + ) diff --git a/src/primaite/simulator/network/transmission/data_link_layer.py b/src/primaite/simulator/network/transmission/data_link_layer.py index b9d969bd..bc7e2453 100644 --- a/src/primaite/simulator/network/transmission/data_link_layer.py +++ b/src/primaite/simulator/network/transmission/data_link_layer.py @@ -3,9 +3,11 @@ from typing import Any, Optional from pydantic import BaseModel from primaite import getLogger -from primaite.simulator.network.transmission.network_layer import ICMPHeader, IPPacket, IPProtocol +from primaite.simulator.network.protocols.arp import ARPPacket +from primaite.simulator.network.transmission.network_layer import ICMPPacket, IPPacket, IPProtocol from primaite.simulator.network.transmission.primaite_layer import PrimaiteHeader from primaite.simulator.network.transmission.transport_layer import TCPHeader, UDPHeader +from primaite.simulator.network.utils import convert_bytes_to_megabits _LOGGER = getLogger(__name__) @@ -74,9 +76,11 @@ class Frame(BaseModel): _LOGGER.error(msg) raise ValueError(msg) if kwargs["ip"].protocol == IPProtocol.ICMP and not kwargs.get("icmp"): - msg = "Cannot build a Frame using the ICMP IP Protocol without a ICMPHeader" + msg = "Cannot build a Frame using the ICMP IP Protocol without a ICMPPacket" _LOGGER.error(msg) raise ValueError(msg) + kwargs["primaite"] = PrimaiteHeader() + super().__init__(**kwargs) ethernet: EthernetHeader @@ -87,14 +91,21 @@ class Frame(BaseModel): "TCP header." udp: Optional[UDPHeader] = None "UDP header." - icmp: Optional[ICMPHeader] = None + icmp: Optional[ICMPPacket] = None "ICMP header." - primaite: PrimaiteHeader = PrimaiteHeader() + arp: Optional[ARPPacket] = None + "ARP packet." + primaite: PrimaiteHeader "PrimAITE header." payload: Optional[Any] = None "Raw data payload." @property - def size(self) -> int: - """The size in Bytes.""" - return len(self.model_dump_json().encode("utf-8")) + def size(self) -> float: # noqa - Keep it as MBits as this is how they're expressed + """The size of the Frame in Bytes.""" + return float(len(self.model_dump_json().encode("utf-8"))) + + @property + def size_Mbits(self) -> float: # noqa - Keep it as MBits as this is how they're expressed + """The daa transfer size of the Frame in MBits.""" + return convert_bytes_to_megabits(self.size) diff --git a/src/primaite/simulator/network/transmission/network_layer.py b/src/primaite/simulator/network/transmission/network_layer.py index 69b682cc..afd1ecef 100644 --- a/src/primaite/simulator/network/transmission/network_layer.py +++ b/src/primaite/simulator/network/transmission/network_layer.py @@ -120,18 +120,23 @@ def get_icmp_type_code_description(icmp_type: ICMPType, icmp_code: int) -> Union return icmp_code_descriptions[icmp_type].get(icmp_code) -class ICMPHeader(BaseModel): - """Models an ICMP Header.""" +class ICMPPacket(BaseModel): + """Models an ICMP Packet.""" icmp_type: ICMPType = ICMPType.ECHO_REQUEST "ICMP Type." icmp_code: int = 0 "ICMP Code." - identifier: str = secrets.randbits(16) + identifier: int "ICMP identifier (16 bits randomly generated)." - sequence: int = 1 + sequence: int = 0 "ICMP message sequence number." + def __init__(self, **kwargs): + if not kwargs.get("identifier"): + kwargs["identifier"] = secrets.randbits(16) + super().__init__(**kwargs) + @field_validator("icmp_code") # noqa @classmethod def _icmp_type_must_have_icmp_code(cls, v: int, info: FieldValidationInfo) -> int: diff --git a/src/primaite/simulator/network/transmission/physical_layer.py b/src/primaite/simulator/network/transmission/physical_layer.py deleted file mode 100644 index ee2297b6..00000000 --- a/src/primaite/simulator/network/transmission/physical_layer.py +++ /dev/null @@ -1,277 +0,0 @@ -from __future__ import annotations - -import re -import secrets -from ipaddress import IPv4Address, IPv4Network -from typing import Dict, List, Optional - -from primaite import getLogger -from primaite.exceptions import NetworkError -from primaite.simulator.core import SimComponent -from primaite.simulator.network.transmission.data_link_layer import Frame - -_LOGGER = getLogger(__name__) - - -def generate_mac_address(oui: Optional[str] = None) -> str: - """ - Generate a random MAC Address. - - :Example: - - >>> generate_mac_address() - 'ef:7e:97:c8:a8:ce' - - >>> generate_mac_address(oui='aa:bb:cc') - 'aa:bb:cc:42:ba:41' - - :param oui: The Organizationally Unique Identifier (OUI) portion of the MAC address. It should be a string with - the first 3 bytes (24 bits) in the format "XX:XX:XX". - :raises ValueError: If the 'oui' is not in the correct format (hexadecimal and 6 characters). - """ - random_bytes = [secrets.randbits(8) for _ in range(6)] - - if oui: - oui_pattern = re.compile(r"^([0-9A-Fa-f]{2}[:-]){2}[0-9A-Fa-f]{2}$") - if not oui_pattern.match(oui): - msg = f"Invalid oui. The oui should be in the format xx:xx:xx, where x is a hexadecimal digit, got '{oui}'" - raise ValueError(msg) - oui_bytes = [int(chunk, 16) for chunk in oui.split(":")] - mac = oui_bytes + random_bytes[len(oui_bytes) :] - else: - mac = random_bytes - - return ":".join(f"{b:02x}" for b in mac) - - -class NIC(SimComponent): - """ - Models a Network Interface Card (NIC) in a computer or network device. - - :param ip_address: The IPv4 address assigned to the NIC. - :param subnet_mask: The subnet mask assigned to the NIC. - :param gateway: The default gateway IP address for forwarding network traffic to other networks. - :param mac_address: The MAC address of the NIC. Defaults to a randomly set MAC address. - :param speed: The speed of the NIC in Mbps (default is 100 Mbps). - :param mtu: The Maximum Transmission Unit (MTU) of the NIC in Bytes, representing the largest data packet size it - can handle without fragmentation (default is 1500 B). - :param wake_on_lan: Indicates if the NIC supports Wake-on-LAN functionality. - :param dns_servers: List of IP addresses of DNS servers used for name resolution. - """ - - ip_address: IPv4Address - "The IP address assigned to the NIC for communication on an IP-based network." - subnet_mask: str - "The subnet mask assigned to the NIC." - gateway: IPv4Address - "The default gateway IP address for forwarding network traffic to other networks. Randomly generated upon creation." - mac_address: str = generate_mac_address() - "The MAC address of the NIC. Defaults to a randomly set MAC address." - speed: int = 100 - "The speed of the NIC in Mbps. Default is 100 Mbps." - mtu: int = 1500 - "The Maximum Transmission Unit (MTU) of the NIC in Bytes. Default is 1500 B" - wake_on_lan: bool = False - "Indicates if the NIC supports Wake-on-LAN functionality." - dns_servers: List[IPv4Address] = [] - "List of IP addresses of DNS servers used for name resolution." - connected_link: Optional[Link] = None - "The Link to which the NIC is connected." - enabled: bool = False - "Indicates whether the NIC is enabled." - - def __init__(self, **kwargs): - """ - NIC constructor. - - Performs some type conversion the calls ``super().__init__()``. Then performs some checking on the ip_address - and gateway just to check that it's all been configured correctly. - - :raises ValueError: When the ip_address and gateway are the same. And when the ip_address/subnet mask are a - network address. - """ - if not isinstance(kwargs["ip_address"], IPv4Address): - kwargs["ip_address"] = IPv4Address(kwargs["ip_address"]) - if not isinstance(kwargs["gateway"], IPv4Address): - kwargs["gateway"] = IPv4Address(kwargs["gateway"]) - super().__init__(**kwargs) - - if self.ip_address == self.gateway: - msg = f"NIC ip address {self.ip_address} cannot be the same as the gateway {self.gateway}" - _LOGGER.error(msg) - raise ValueError(msg) - if self.ip_network.network_address == self.ip_address: - msg = ( - f"Failed to set IP address {self.ip_address} and subnet mask {self.subnet_mask} as it is a " - f"network address {self.ip_network.network_address}" - ) - _LOGGER.error(msg) - raise ValueError(msg) - - @property - def ip_network(self) -> IPv4Network: - """ - Return the IPv4Network of the NIC. - - :return: The IPv4Network from the ip_address/subnet mask. - """ - return IPv4Network(f"{self.ip_address}/{self.subnet_mask}", strict=False) - - def connect_link(self, link: Link): - """ - Connect the NIC to a link. - - :param link: The link to which the NIC is connected. - :type link: :class:`~primaite.simulator.network.transmission.physical_layer.Link` - :raise NetworkError: When an attempt to connect a Link is made while the NIC has a connected Link. - """ - if not self.connected_link: - if self.connected_link != link: - # TODO: Inform the Node that a link has been connected - self.connected_link = link - else: - _LOGGER.warning(f"Cannot connect link to NIC ({self.mac_address}) as it is already connected") - else: - msg = f"Cannot connect link to NIC ({self.mac_address}) as it already has a connection" - _LOGGER.error(msg) - raise NetworkError(msg) - - def disconnect_link(self): - """Disconnect the NIC from the connected Link.""" - if self.connected_link.endpoint_a == self: - self.connected_link.endpoint_a = None - if self.connected_link.endpoint_b == self: - self.connected_link.endpoint_b = None - self.connected_link = None - - def add_dns_server(self, ip_address: IPv4Address): - """ - Add a DNS server IP address. - - :param ip_address: The IP address of the DNS server to be added. - :type ip_address: ipaddress.IPv4Address - """ - pass - - def remove_dns_server(self, ip_address: IPv4Address): - """ - Remove a DNS server IP Address. - - :param ip_address: The IP address of the DNS server to be removed. - :type ip_address: ipaddress.IPv4Address - """ - pass - - def send_frame(self, frame: Frame): - """ - Send a network frame from the NIC to the connected link. - - :param frame: The network frame to be sent. - :type frame: :class:`~primaite.simulator.network.osi_layers.Frame` - """ - pass - - def receive_frame(self, frame: Frame): - """ - Receive a network frame from the connected link. - - The Frame is passed to the Node. - - :param frame: The network frame being received. - :type frame: :class:`~primaite.simulator.network.osi_layers.Frame` - """ - pass - - def describe_state(self) -> Dict: - """ - Get the current state of the NIC as a dict. - - :return: A dict containing the current state of the NIC. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the NIC. - - :param action: The action to be applied. - :type action: str - """ - pass - - -class Link(SimComponent): - """ - Represents a network link between two network interface cards (NICs). - - :param endpoint_a: The first NIC connected to the Link. - :type endpoint_a: NIC - :param endpoint_b: The second NIC connected to the Link. - :type endpoint_b: NIC - :param bandwidth: The bandwidth of the Link in Mbps (default is 100 Mbps). - :type bandwidth: int - """ - - endpoint_a: NIC - "The first NIC connected to the Link." - endpoint_b: NIC - "The second NIC connected to the Link." - bandwidth: int = 100 - "The bandwidth of the Link in Mbps (default is 100 Mbps)." - current_load: int = 0 - "The current load on the link in Mbps." - - def __init__(self, **kwargs): - """ - Ensure that endpoint_a and endpoint_b are not the same NIC. - - Connect the link to the NICs after creation. - - :raises ValueError: If endpoint_a and endpoint_b are the same NIC. - """ - if kwargs["endpoint_a"] == kwargs["endpoint_b"]: - msg = "endpoint_a and endpoint_b cannot be the same NIC" - _LOGGER.error(msg) - raise ValueError(msg) - super().__init__(**kwargs) - self.endpoint_a.connect_link(self) - self.endpoint_b.connect_link(self) - - def send_frame(self, sender_nic: NIC, frame: Frame): - """ - Send a network frame from one NIC to another connected NIC. - - :param sender_nic: The NIC sending the frame. - :type sender_nic: NIC - :param frame: The network frame to be sent. - :type frame: Frame - """ - pass - - def receive_frame(self, sender_nic: NIC, frame: Frame): - """ - Receive a network frame from a connected NIC. - - :param sender_nic: The NIC sending the frame. - :type sender_nic: NIC - :param frame: The network frame being received. - :type frame: Frame - """ - pass - - def describe_state(self) -> Dict: - """ - Get the current state of the Libk as a dict. - - :return: A dict containing the current state of the Link. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the Link. - - :param action: The action to be applied. - :type action: str - """ - pass diff --git a/src/primaite/simulator/network/transmission/transport_layer.py b/src/primaite/simulator/network/transmission/transport_layer.py index c8e6b89d..b95b4a74 100644 --- a/src/primaite/simulator/network/transmission/transport_layer.py +++ b/src/primaite/simulator/network/transmission/transport_layer.py @@ -33,6 +33,8 @@ class Port(Enum): "Simple Network Management Protocol (SNMP) - Used for network device management." SNMP_TRAP = 162 "SNMP Trap - Used for sending SNMP notifications (traps) to a network management system." + ARP = 219 + "Address resolution Protocol - Used to connect a MAC address to an IP address." LDAP = 389 "Lightweight Directory Access Protocol (LDAP) - Used for accessing and modifying directory information." HTTPS = 443 @@ -114,6 +116,6 @@ class TCPHeader(BaseModel): ... ) """ - src_port: int - dst_port: int + src_port: Port + dst_port: Port flags: List[TCPFlags] = [TCPFlags.SYN] diff --git a/src/primaite/simulator/network/utils.py b/src/primaite/simulator/network/utils.py new file mode 100644 index 00000000..496f5e13 --- /dev/null +++ b/src/primaite/simulator/network/utils.py @@ -0,0 +1,27 @@ +from typing import Union + + +def convert_bytes_to_megabits(B: Union[int, float]) -> float: # noqa - Keep it as B as this is how Bytes are expressed + """ + Convert Bytes (file size) to Megabits (data transfer). + + :param B: The file size in Bytes. + :return: File bits to transfer in Megabits. + """ + if isinstance(B, int): + B = float(B) + bits = B * 8.0 + return bits / 1024.0**2.0 + + +def convert_megabits_to_bytes(Mbits: Union[int, float]) -> float: # noqa - The same for Mbits + """ + Convert Megabits (data transfer) to Bytes (file size). + + :param Mbits bits to transfer in Megabits. + :return: The file size in Bytes. + """ + if isinstance(Mbits, int): + Mbits = float(Mbits) + bits = Mbits * 1024.0**2.0 + return bits / 8 diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py new file mode 100644 index 00000000..32abd0ef --- /dev/null +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -0,0 +1,25 @@ +from primaite.simulator.network.hardware.base import Link, NIC, Node + + +def test_node_to_node_ping(): + node_a = Node(hostname="node_a") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_a.connect_nic(nic_a) + node_a.turn_on() + + node_b = Node(hostname="node_b") + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_b.connect_nic(nic_b) + node_b.turn_on() + + Link(endpoint_a=nic_a, endpoint_b=nic_b) + + assert node_a.ping("192.168.0.11") + + node_a.turn_off() + + assert not node_a.ping("192.168.0.11") + + node_a.turn_on() + + assert node_a.ping("192.168.0.11") diff --git a/tests/integration_tests/network/test_link_connection.py b/tests/integration_tests/network/test_link_connection.py new file mode 100644 index 00000000..50abed77 --- /dev/null +++ b/tests/integration_tests/network/test_link_connection.py @@ -0,0 +1,21 @@ +from primaite.simulator.network.hardware.base import Link, NIC, Node + + +def test_link_up(): + """Tests Nodes, NICs, and Links can all be connected and be in an enabled/up state.""" + node_a = Node(hostname="node_a") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_a.connect_nic(nic_a) + node_a.turn_on() + assert nic_a.enabled + + node_b = Node(hostname="node_b") + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_b.connect_nic(nic_b) + node_b.turn_on() + + assert nic_b.enabled + + link = Link(endpoint_a=nic_a, endpoint_b=nic_b) + + assert link.up diff --git a/tests/integration_tests/network/test_nic_link_connection.py b/tests/integration_tests/network/test_nic_link_connection.py index 6bca3c0a..52a0c735 100644 --- a/tests/integration_tests/network/test_nic_link_connection.py +++ b/tests/integration_tests/network/test_nic_link_connection.py @@ -1,6 +1,6 @@ import pytest -from primaite.simulator.network.transmission.physical_layer import Link, NIC +from primaite.simulator.network.hardware.base import Link, NIC def test_link_fails_with_same_nic(): diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/__init__.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_physical_layer.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py similarity index 95% rename from tests/unit_tests/_primaite/_simulator/_network/_transmission/test_physical_layer.py rename to tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py index 5a33e723..dc508508 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_physical_layer.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py @@ -3,7 +3,7 @@ from ipaddress import IPv4Address import pytest -from primaite.simulator.network.transmission.physical_layer import generate_mac_address, NIC +from primaite.simulator.network.hardware.base import generate_mac_address, NIC def test_mac_address_generation(): diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node.py new file mode 100644 index 00000000..0e5fb4c7 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node.py @@ -0,0 +1,10 @@ +import re +from ipaddress import IPv4Address + +import pytest + +from primaite.simulator.network.hardware.base import Node + + +def test_node_creation(): + node = Node(hostname="host_1") diff --git a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py index 83b215ca..8a78d1bc 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py @@ -1,7 +1,7 @@ import pytest from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame -from primaite.simulator.network.transmission.network_layer import ICMPHeader, IPPacket, IPProtocol, Precedence +from primaite.simulator.network.transmission.network_layer import ICMPPacket, IPPacket, IPProtocol, Precedence from primaite.simulator.network.transmission.primaite_layer import AgentSource, DataStatus from primaite.simulator.network.transmission.transport_layer import Port, TCPFlags, TCPHeader, UDPHeader @@ -76,7 +76,7 @@ def test_icmp_frame_creation(): frame = Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20", protocol=IPProtocol.ICMP), - icmp=ICMPHeader(), + icmp=ICMPPacket(), ) assert frame diff --git a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_network_layer.py b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_network_layer.py index 584ff25d..a7189452 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_network_layer.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_network_layer.py @@ -1,24 +1,24 @@ import pytest -from primaite.simulator.network.transmission.network_layer import ICMPHeader, ICMPType +from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType def test_icmp_minimal_header_creation(): - """Checks the minimal ICMPHeader (ping 1 request) creation using default values.""" - ping = ICMPHeader() + """Checks the minimal ICMPPacket (ping 1 request) creation using default values.""" + ping = ICMPPacket() assert ping.icmp_type == ICMPType.ECHO_REQUEST assert ping.icmp_code == 0 assert ping.identifier - assert ping.sequence == 1 + assert ping.sequence == 0 def test_valid_icmp_type_code_pairing(): - """Tests ICMPHeader creation with valid type and code pairing.""" - assert ICMPHeader(icmp_type=ICMPType.DESTINATION_UNREACHABLE, icmp_code=6) + """Tests ICMPPacket creation with valid type and code pairing.""" + assert ICMPPacket(icmp_type=ICMPType.DESTINATION_UNREACHABLE, icmp_code=6) def test_invalid_icmp_type_code_pairing(): - """Tests ICMPHeader creation fails with invalid type and code pairing.""" + """Tests ICMPPacket creation fails with invalid type and code pairing.""" with pytest.raises(ValueError): - assert ICMPHeader(icmp_type=ICMPType.DESTINATION_UNREACHABLE, icmp_code=16) + assert ICMPPacket(icmp_type=ICMPType.DESTINATION_UNREACHABLE, icmp_code=16) diff --git a/tests/unit_tests/_primaite/_simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py index de0732f9..9f4b5fd9 100644 --- a/tests/unit_tests/_primaite/_simulator/test_core.py +++ b/tests/unit_tests/_primaite/_simulator/test_core.py @@ -1,4 +1,5 @@ from typing import Callable, Dict, List, Literal, Tuple +from uuid import uuid4 import pytest from pydantic import ValidationError @@ -35,15 +36,17 @@ class TestIsolatedSimComponent: """Validate that our added functionality does not interfere with pydantic.""" class TestComponent(SimComponent): + uuid: str name: str size: Tuple[float, float] def describe_state(self) -> Dict: return {} - comp = TestComponent(name="computer", size=(5, 10)) + uuid = str(uuid4()) + comp = TestComponent(uuid=uuid, name="computer", size=(5, 10)) dump = comp.model_dump() - assert dump == {"name": "computer", "size": (5, 10)} + assert dump == {"uuid": uuid, "name": "computer", "size": (5, 10)} def test_apply_action(self): """Validate that we can override apply_action behaviour and it updates the state of the component.""" From 209f934abd8bd860131130ea36ce24d4b560dd74 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 2 Aug 2023 22:01:15 +0100 Subject: [PATCH 05/63] #1706 - Added some extra logging --- src/primaite/simulator/network/hardware/base.py | 4 +++- .../simulator/network/transmission/data_link_layer.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 054eb1c6..c1bed5b0 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -321,12 +321,13 @@ class Link(SimComponent): receiver_nic = self.endpoint_a if receiver_nic == sender_nic: receiver_nic = self.endpoint_b - frame_size = frame.size + frame_size = frame.size_Mbits sent = receiver_nic.receive_frame(frame) if sent: # Frame transmitted successfully # Load the frame size on the link self.current_load += frame_size + _LOGGER.info(f"Link added {frame_size} Mbits, current load {self.current_load} Mbits") return True # Received NIC disabled, reply @@ -633,6 +634,7 @@ class Node(SimComponent): _LOGGER.info(f"Node {self.hostname} attempting to ping {target_ip_address}") self._ping(target_ip_address) return True + _LOGGER.info(f"Node {self.hostname} ping failed as the node is turned off") return False def send_frame(self, frame: Frame): diff --git a/src/primaite/simulator/network/transmission/data_link_layer.py b/src/primaite/simulator/network/transmission/data_link_layer.py index bc7e2453..97a1a423 100644 --- a/src/primaite/simulator/network/transmission/data_link_layer.py +++ b/src/primaite/simulator/network/transmission/data_link_layer.py @@ -107,5 +107,5 @@ class Frame(BaseModel): @property def size_Mbits(self) -> float: # noqa - Keep it as MBits as this is how they're expressed - """The daa transfer size of the Frame in MBits.""" + """The daa transfer size of the Frame in Mbits.""" return convert_bytes_to_megabits(self.size) From a0356a7fbc3e71046dd5094ecaf7200c88adfcab Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Thu, 3 Aug 2023 12:14:11 +0100 Subject: [PATCH 06/63] #1714: updated file system classes --- src/primaite/simulator/core.py | 9 ++ .../simulator/file_system/file_system.py | 131 ++++++++++++------ .../simulator/file_system/file_system_file.py | 41 ++++-- .../file_system/file_system_folder.py | 55 ++++++-- .../file_system/file_system_item_abc.py | 60 -------- .../_file_system/test_file_system.py | 80 +++++++++++ .../_file_system/test_file_system_file.py | 14 ++ .../_file_system/test_file_system_folder.py | 41 ++++++ 8 files changed, 302 insertions(+), 129 deletions(-) delete mode 100644 src/primaite/simulator/file_system/file_system_item_abc.py diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index a58e0c11..84b03498 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -1,6 +1,7 @@ """Core of the PrimAITE Simulator.""" from abc import abstractmethod from typing import Callable, Dict, List +from uuid import uuid4 from pydantic import BaseModel @@ -8,6 +9,14 @@ from pydantic import BaseModel class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + uuid: str + "The component UUID." + + def __init__(self, **kwargs): + if not kwargs.get("uuid"): + kwargs["uuid"] = str(uuid4()) + super().__init__(**kwargs) + @abstractmethod def describe_state(self) -> Dict: """ diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index f467595d..6af6db3e 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -1,17 +1,17 @@ -from typing import Dict, List, Union +from typing import Dict, List, Optional + +from pydantic import PrivateAttr from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file import FileSystemFile +from primaite.simulator.file_system.file_system_file_type import FileSystemFileType from primaite.simulator.file_system.file_system_folder import FileSystemFolder class FileSystem(SimComponent): """Class that contains all the simulation File System.""" - files: List[FileSystemFile] - """List containing all the files in the file system.""" - - folders: List[FileSystemFolder] + _folders: List[FileSystemFolder] = PrivateAttr([]) """List containing all the folders in the file system.""" def describe_state(self) -> Dict: @@ -22,56 +22,107 @@ class FileSystem(SimComponent): """ pass - def create_file(self): - """Creates a FileSystemFile and adds it to the list of files.""" - pass + def get_folders(self) -> List[FileSystemFolder]: + """Returns the list of folders.""" + return self._folders - def create_folder(self): + def create_file(self, file_size: float, folder_uuid: Optional[str] = None) -> FileSystemFile: + """ + Creates a FileSystemFile and adds it to the list of files. + + :param: folder_uuid: The uuid of the folder to add the file to + :type: folder_uuid: str + """ + file = None + # if no folder uuid provided, create a folder and add file to it + if folder_uuid is None: + folder = FileSystemFolder() + + file = FileSystemFile(item_parent=folder.uuid, file_size=file_size, file_type=FileSystemFileType.TBD) + folder.add_file(file) + self._folders.append(folder) + else: + # otherwise check for existence and add file + folder = self.get_folder_by_id(folder_uuid) + if folder is not None: + file = FileSystemFile(file_size=file_size, file_type=FileSystemFileType.TBD) + folder.add_file(file=file) + return file + + def create_folder(self) -> FileSystemFolder: """Creates a FileSystemFolder and adds it to the list of folders.""" - pass + folder = FileSystemFolder(item_parent=None) + self._folders.append(folder) + return folder - def delete_file(self, file_item: str): + def delete_file(self, file_id: str): """ Deletes a file and removes it from the files list. - :param file_item: The UUID of the file item to delete - :type file_item: str + :param file_id: The UUID of the file item to delete + :type file_id: str """ - self.files = list(filter(lambda x: (x.get_item_uuid() != file_item), self.files)) + # iterate through folders to delete the item with the matching uuid + for folder in self._folders: + folder.remove_file(file_id) - def delete_folder(self, file_item: str): + def delete_folder(self, folder_id: str): """ - Deletes a folder, removes it frdom the folders list and removes any child folders and files. + Deletes a folder, removes it from the folders list and removes any child folders and files. - :param file_item: The UUID of the file item to delete - :type file_item: str + :param folder_id: The UUID of the file item to delete + :type folder_id: str """ - self.files = list(filter(lambda x: (x.get_item_parent() != file_item), self.files)) - self.folders = list(filter(lambda x: (x.get_item_uuid() != file_item), self.folders)) + self._folders = list(filter(lambda f: (f.uuid != folder_id), self._folders)) - def move_file_item(self, file_item: str, target_directory: str): - """ - Check to see if the file_item and target_directory exists then moves the item by changing its parent item uuid. + def move_file(self, src_folder_id: str, target_folder_id: str, file_id: str): + """Moves a file from one folder to another.""" + # check that both folders and the file exists + src = self.get_folder_by_id(src_folder_id) + target = self.get_folder_by_id(target_folder_id) - :param file_item: The UUID of the file item to move - :type file_item: str + if src is None: + raise Exception(f"src folder with UUID {src_folder_id} could not be found") - :param target_directory: The UUID of the directory the item should be moved into - :type target_directory: str - """ - item = self._file_item_exists(file_item) - if item and any(f for f in self.folders if f.get_item_uuid() == target_directory): - item.move(target_directory) + if target is None: + raise Exception(f"src folder with UUID {target_folder_id} could not be found") - def _file_item_exists(self, file_item_uuid: str) -> Union[FileSystemFile, FileSystemFolder, None]: - """Returns true if the file or folder UUID exists.""" - item = next((x for x in self.files if x.get_item_uuid() == file_item_uuid), None) - if item: - return item + file = src.get_file(file_id=file_id) + if file is None: + raise Exception(f"file with UUID {file_id} could not be found") - next((x for x in self.folders if x.get_item_uuid() == file_item_uuid), None) + # remove file from src + src.remove_file(file_id) - if item: - return item + # add file to target + target.add_file(file) - raise Exception(f"No file or folder found with id: {file_item_uuid}") + def copy_file(self, src_folder_id: str, target_folder_id: str, file_id: str): + """Copies a file from one folder to another.""" + # check that both folders and the file exists + src = self.get_folder_by_id(src_folder_id) + target = self.get_folder_by_id(target_folder_id) + + if src is None: + raise Exception(f"src folder with UUID {src_folder_id} could not be found") + + if target is None: + raise Exception(f"src folder with UUID {target_folder_id} could not be found") + + file = src.get_file(file_id=file_id) + if file is None: + raise Exception(f"file with UUID {file_id} could not be found") + + # add file to target + target.add_file(file) + + def get_file_by_id(self, file_id: str) -> FileSystemFile: + """Checks if the file exists in any file system folders.""" + for folder in self._folders: + file = folder.get_file(file_id=file_id) + if file is not None: + return file + + def get_folder_by_id(self, folder_id: str) -> FileSystemFolder: + """Checks if the folder exists.""" + return next((f for f in self._folders if f.uuid == folder_id), None) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index ee4fe1e5..bebaa223 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -1,30 +1,43 @@ from typing import Dict +from pydantic import PrivateAttr + +from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file_type import FileSystemFileType -from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC -class FileSystemFile(FileSystemItemABC): +class FileSystemFile(SimComponent): """Class that represents a file in the simulation.""" - _file_type: FileSystemFileType + _file_type: FileSystemFileType = PrivateAttr() """The type of the FileSystemFile""" + _file_size: float = PrivateAttr() + """Disk size of the FileSystemItem""" + + def __init__(self, file_type: FileSystemFileType, file_size: float, **kwargs): + """ + Initialise FileSystemFile class. + + :param item_parent: The UUID of the FileSystemItem parent + :type item_parent: str + + :param file_size: The size of the FileSystemItem + :type file_size: float + """ + super().__init__(**kwargs) + + self._file_type = file_type + self._file_size = file_size + + def get_file_size(self) -> float: + """Returns the size of the file system item.""" + return self._file_size + def get_file_type(self) -> FileSystemFileType: """Returns the FileSystemFileType of the file.""" return self._file_type - def move(self, target_directory: str): - """ - Changes the parent_item of the FileSystemFile. - - Essentially simulates the file system item being moved from folder to folder - - :param target_directory: The UUID of the directory the file system item should be moved to - :type target_directory: str - """ - super().move(target_directory) - def describe_state(self) -> Dict: """ Get the current state of the FileSystemFile as a dict. diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 41b9e1dd..248a4f98 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -1,14 +1,50 @@ -from typing import Dict +from typing import Dict, List -from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC +from pydantic import PrivateAttr + +from primaite.simulator.core import SimComponent +from primaite.simulator.file_system.file_system_file import FileSystemFile -class FileSystemFolder(FileSystemItemABC): +class FileSystemFolder(SimComponent): """Simulation FileSystemFolder.""" - _is_quarantined: bool + _files: List[FileSystemFile] = PrivateAttr([]) + """List of files stored in the folder.""" + + _folder_size: float = PrivateAttr(0) + """The current size of the folder""" + + _is_quarantined: bool = PrivateAttr(False) """Flag that marks the folder as quarantined if true.""" + def get_files(self) -> List[FileSystemFile]: + """Returns the list of files the folder contains.""" + return self._files + + def get_file(self, file_id: str) -> FileSystemFile: + """Return a FileSystemFile with the matching id.""" + return next((f for f in self._files if f.uuid == file_id), None) + + def add_file(self, file: FileSystemFile): + """Adds a file to the folder list.""" + self._folder_size += file.get_file_size() + + # add to list + self._files.append(file) + + def remove_file(self, file_id: str): + """Removes a file from the folder list.""" + file = next((f for f in self._files if f.uuid == file_id), None) + self._files.remove(file) + + # remove folder size from folder + self._folder_size -= file.get_file_size() + + def get_folder_size(self) -> float: + """Returns a sum of all file sizes in the files list.""" + return sum([file.get_file_size() for file in self._files]) + def quarantine(self): """Quarantines the File System Folder.""" self._is_quarantined = True @@ -21,17 +57,6 @@ class FileSystemFolder(FileSystemItemABC): """Returns true if the folder is being quarantined.""" return self._is_quarantined - def move(self, target_directory: str): - """ - Changes the parent_item of the file system item. - - Essentially simulates the file system item being moved from folder to folder - - :param target_directory: The UUID of the directory the file system item should be moved to - :type target_directory: str - """ - super().move(target_directory) - def describe_state(self) -> Dict: """ Get the current state of the FileSystemFolder as a dict. diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py deleted file mode 100644 index 11a3f858..00000000 --- a/src/primaite/simulator/file_system/file_system_item_abc.py +++ /dev/null @@ -1,60 +0,0 @@ -from abc import ABC, abstractmethod -from uuid import uuid4 - -from primaite.simulator.core import SimComponent - - -class FileSystemItemABC(SimComponent, ABC): - """Abstract Base class for any file system items e.g. files and folders.""" - - _uuid: str - """Unique identifier for the FileSystemItem""" - - _parent_item: str - """UUID of the parent FileSystemItem""" - - _item_size: float - """Disk size of the FileSystemItem""" - - def __init__(self, parent_item: str, item_size: float): - """ - Abstract base class used by FileSystem items. - - :param parent_item: The UUID of the FileSystemItem parent - :type parent_item: str - - :param item_size: The size of the FileSystemItem - :type item_size: float - """ - super().__init__() - - # generate random uuid for file system item - self._uuid = str(uuid4()) - - self._parent_item = parent_item - - self._item_size = item_size - - def get_item_uuid(self) -> str: - """Returns the file system item UUID.""" - return self._uuid - - def get_item_parent(self) -> str: - """Returns the UUID of the item's parent.""" - return self._parent_item - - def get_item_size(self) -> float: - """Returns the item size.""" - return self._item_size - - @abstractmethod - def move(self, target_directory: str): - """ - Changes the parent_item of the file system item. - - Essentially simulates the file system item being moved from folder to folder - - :param target_directory: The UUID of the directory the file system item should be moved to - :type target_directory: str - """ - self._parent_item = target_directory diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py index e69de29b..7b26f707 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -0,0 +1,80 @@ +from primaite.simulator.file_system.file_system import FileSystem + + +def test_create_folder_and_file(): + """Test creating a folder and a file.""" + file_system = FileSystem() + folder = file_system.create_folder() + assert len(file_system.get_folders()) is 1 + + file_system.create_file(file_size=10, folder_uuid=folder.uuid) + assert len(file_system.get_folders()[0].get_files()) is 1 + + +def test_create_file(): + """Tests that creating a file without a folder creates a folder and sets that as the file's parent.""" + file_system = FileSystem() + + file = file_system.create_file(file_size=10) + assert len(file_system.get_folders()) is 1 + assert file_system.get_folders()[0].get_file(file.uuid) is file + + +def test_delete_file(): + """Tests that a file can be deleted.""" + file_system = FileSystem() + + file = file_system.create_file(file_size=10) + assert len(file_system.get_folders()) is 1 + assert file_system.get_folders()[0].get_file(file.uuid) is file + + file_system.delete_file(file.uuid) + assert len(file_system.get_folders()) is 1 + assert len(file_system.get_folders()[0].get_files()) is 0 + + +def test_delete_folder(): + file_system = FileSystem() + folder = file_system.create_folder() + assert len(file_system.get_folders()) is 1 + + file_system.delete_folder(folder.uuid) + assert len(file_system.get_folders()) is 0 + + +def test_move_file(): + """Tests the file move function.""" + file_system = FileSystem() + src_folder = file_system.create_folder() + assert len(file_system.get_folders()) is 1 + + target_folder = file_system.create_folder() + assert len(file_system.get_folders()) is 2 + + file = file_system.create_file(file_size=10, folder_uuid=src_folder.uuid) + assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 + assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 0 + + file_system.move_file(src_folder.uuid, target_folder.uuid, file.uuid) + + assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 0 + assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 1 + + +def test_copy_file(): + """Tests the file copy function.""" + file_system = FileSystem() + src_folder = file_system.create_folder() + assert len(file_system.get_folders()) is 1 + + target_folder = file_system.create_folder() + assert len(file_system.get_folders()) is 2 + + file = file_system.create_file(file_size=10, folder_uuid=src_folder.uuid) + assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 + assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 0 + + file_system.copy_file(src_folder.uuid, target_folder.uuid, file.uuid) + + assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 + assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 1 diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py index e69de29b..34c8dd94 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py @@ -0,0 +1,14 @@ +from primaite.simulator.file_system.file_system_file import FileSystemFile +from primaite.simulator.file_system.file_system_file_type import FileSystemFileType + + +def test_file_type(): + """Tests tha the FileSystemFile type is set correctly.""" + file = FileSystemFile(file_size=1.5, file_type=FileSystemFileType.TBD) + assert file.get_file_type() is FileSystemFileType.TBD + + +def test_get_file_size(): + """Tests that the file size is being returned properly.""" + file = FileSystemFile(file_size=1.5, file_type=FileSystemFileType.TBD) + assert file.get_file_size() is 1.5 diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py index e69de29b..b67ea385 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py @@ -0,0 +1,41 @@ +from primaite.simulator.file_system.file_system_file import FileSystemFile +from primaite.simulator.file_system.file_system_file_type import FileSystemFileType +from primaite.simulator.file_system.file_system_folder import FileSystemFolder + + +def test_adding_removing_file(): + folder = FileSystemFolder() + + file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) + + folder.add_file(file) + assert folder.get_folder_size() is 10 + assert len(folder.get_files()) is 1 + + folder.remove_file(file_id=file.uuid) + assert folder.get_folder_size() is 0 + assert len(folder.get_files()) is 0 + + +def test_get_file_by_id(): + folder = FileSystemFolder() + + file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) + + folder.add_file(file) + assert folder.get_folder_size() is 10 + assert len(folder.get_files()) is 1 + + assert folder.get_file(file_id=file.uuid) is file + + +def test_folder_quarantine_state(): + folder = FileSystemFolder() + + assert folder.quarantine_status() is False + + folder.quarantine() + assert folder.quarantine_status() is True + + folder.end_quarantine() + assert folder.quarantine_status() is False From b08683fcd322919ce59c9c646d98121265caaa88 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Thu, 3 Aug 2023 12:42:16 +0100 Subject: [PATCH 07/63] #1714: fix tests --- pyproject.toml | 2 +- tests/conftest.py | 2 +- tests/unit_tests/_primaite/_simulator/test_core.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4982dfd1..74de37df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "stable-baselines3==1.6.2", "tensorflow==2.12.0", "typer[all]==0.9.0", - "pydantic" + "pydantic==2.1.1" ] [tool.setuptools.dynamic] diff --git a/tests/conftest.py b/tests/conftest.py index 8102050e..f1c05187 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,7 +96,7 @@ def temp_primaite_session(request): """ training_config_path = request.param[0] lay_down_config_path = request.param[1] - with patch("_primaite.agents.agent_abc.get_session_path", get_temp_session_path) as mck: + with patch("primaite.agents.agent_abc.get_session_path", get_temp_session_path) as mck: mck.session_timestamp = datetime.now() return TempPrimaiteSession(training_config_path, lay_down_config_path) diff --git a/tests/unit_tests/_primaite/_simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py index de0732f9..00f29791 100644 --- a/tests/unit_tests/_primaite/_simulator/test_core.py +++ b/tests/unit_tests/_primaite/_simulator/test_core.py @@ -43,7 +43,7 @@ class TestIsolatedSimComponent: comp = TestComponent(name="computer", size=(5, 10)) dump = comp.model_dump() - assert dump == {"name": "computer", "size": (5, 10)} + assert dump["name"] is "computer" def test_apply_action(self): """Validate that we can override apply_action behaviour and it updates the state of the component.""" From 3a2840bed850de3fb2c57c55fc23bdee4fc55285 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 3 Aug 2023 13:09:04 +0100 Subject: [PATCH 08/63] Overhaul sim component for permission management. --- src/primaite/simulator/core.py | 131 +++++++++++++++++--- src/primaite/simulator/domain/__init__.py | 4 +- src/primaite/simulator/domain/account.py | 46 +++---- src/primaite/simulator/domain/controller.py | 51 +++++++- 4 files changed, 182 insertions(+), 50 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index c3130116..eaedc85a 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -1,13 +1,123 @@ """Core of the PrimAITE Simulator.""" -from abc import abstractmethod -from typing import Callable, Dict, List +from abc import ABC, abstractmethod +from typing import Callable, Dict, List, Optional -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict + +from primaite import getLogger +from primaite.simulator.domain import AccountGroup + +_LOGGER = getLogger(__name__) + + +class ActionPermissionValidator(ABC): + """ + Base class for action validators. + + The permissions manager is designed to be generic. So, although in the first instance the permissions + are evaluated purely on membership to AccountGroup, this class can support validating permissions based on any + arbitrary criteria. + """ + + @abstractmethod + def __call__(self, request: List[str], context: Dict) -> bool: + """TODO.""" + pass + + +class AllowAllValidator(ActionPermissionValidator): + """Always allows the action.""" + + def __call__(self, request: List[str], context: Dict) -> bool: + """Always allow the action.""" + return True + + +class GroupMembershipValidator(ActionPermissionValidator): + """Permit actions based on group membership.""" + + def __init__(self, allowed_groups: List[AccountGroup]) -> None: + """TODO.""" + self.allowed_groups = allowed_groups + + def __call__(self, request: List[str], context: Dict) -> bool: + """Permit the action if the request comes from an account which belongs to the right group.""" + # if context request source is part of any groups mentioned in self.allow_groups, return true, otherwise false + requestor_groups: List[str] = context["request_source"]["groups"] + for allowed_group in self.allowed_groups: + if allowed_group.name in requestor_groups: + return True + return False + + +class Action: + """ + This object stores data related to a single action. + + This includes the callable that can execute the action request, and the validator that will decide whether + the action can be performed or not. + """ + + def __init__(self, func: Callable[[List[str], Dict], None], validator: ActionPermissionValidator) -> None: + """ + Save the functions that are for this action. + + Here's a description for the intended use of both of these. + + ``func`` is a function that accepts a request and a context dict. Typically this would be a lambda function + that invokes a class method of your SimComponent. For example if the component is a node and the action is for + turning it off, then the SimComponent should have a turn_off(self) method that does not need to accept any args. + Then, this Action will be given something like ``func = lambda request, context: self.turn_off()``. + + :param func: Function that performs the request. + :type func: Callable[[List[str], Dict], None] + :param validator: Function that checks if the request is authenticated given the context. + :type validator: ActionPermissionValidator + """ + self.func: Callable[[List[str], Dict], None] = func + self.validator: ActionPermissionValidator = validator + + +class ActionManager: + """TODO.""" + + def __init__(self) -> None: + """TODO.""" + self.actions: Dict[str, Action] + + def process_request(self, request: List[str], context: Dict) -> None: + """Process action request.""" + action_key = request[0] + + if action_key not in self.actions: + msg = ( + f"Action request {request} could not be processed because {action_key} is not a valid action", + "within this ActionManager", + ) + _LOGGER.error(msg) + raise RuntimeError(msg) + + action = self.actions[action_key] + action_options = request[1:] + + if not action.validator(action_options, context): + _LOGGER.debug(f"Action request {request} was denied due to insufficient permissions") + return + + action.func(action_options, context) class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + model_config = ConfigDict(arbitrary_types_allowed=True) + uuid: str + "The component UUID." + + def __init__(self, **kwargs) -> None: + self.action_manager: Optional[ActionManager] = None + super().__init__(**kwargs) + @abstractmethod def describe_state(self) -> Dict: """ @@ -19,7 +129,7 @@ class SimComponent(BaseModel): """ return {} - def apply_action(self, action: List[str]) -> None: + def apply_action(self, action: List[str], context: Dict = {}) -> None: """ Apply an action to a simulation component. Action data is passed in as a 'namespaced' list of strings. @@ -34,16 +144,9 @@ class SimComponent(BaseModel): :param action: List describing the action to apply to this object. :type action: List[str] """ - possible_actions = self._possible_actions() - if action[0] in possible_actions: - # take the first element off the action list and pass the remaining arguments to the corresponding action - # function - possible_actions[action.pop(0)](action) - else: - raise ValueError(f"{self.__class__.__name__} received invalid action {action}") - - def _possible_actions(self) -> Dict[str, Callable[[List[str]], None]]: - return {} + if self.action_manager is None: + return + self.action_manager.process_request(action, context) def apply_timestep(self, timestep: int) -> None: """ diff --git a/src/primaite/simulator/domain/__init__.py b/src/primaite/simulator/domain/__init__.py index 6f59cf49..0e23133f 100644 --- a/src/primaite/simulator/domain/__init__.py +++ b/src/primaite/simulator/domain/__init__.py @@ -1,3 +1,3 @@ -from primaite.simulator.domain.account import Account +from primaite.simulator.domain.account import Account, AccountGroup, AccountType -__all__ = ["Account"] +__all__ = ["Account", "AccountGroup", "AccountType"] diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index 374675a0..c134e916 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -1,6 +1,6 @@ """User account simulation.""" from enum import Enum -from typing import Dict, List, Set, TypeAlias +from typing import Callable, Dict, List, TypeAlias from primaite import getLogger from primaite.simulator.core import SimComponent @@ -40,6 +40,14 @@ class AccountStatus(Enum): disabled = 2 +class PasswordPolicyLevel(Enum): + """Complexity requirements for account passwords.""" + + low = 1 + medium = 2 + high = 3 + + class Account(SimComponent): """User accounts.""" @@ -55,38 +63,18 @@ class Account(SimComponent): "Account password." account_type: AccountType "Account Type, currently this can be service account (used by apps) or user account." - domain_groups: Set[AccountGroup] = [] - "Domain-wide groups that this account belongs to." - local_groups: Dict[__temp_node, List[AccountGroup]] - "For each node, whether this account has local/admin privileges on that node." status: AccountStatus = AccountStatus.disabled - def add_to_domain_group(self, group: AccountGroup) -> None: - """ - Add this account to a domain group. - - If the account is already a member of this group, nothing happens. - - :param group: The group to which to add this account. - :type group: AccountGroup - """ - self.domain_groups.add(group) - - def remove_from_domain_group(self, group: AccountGroup) -> None: - """ - Remove this account from a domain group. - - If the account is already not a member of that group, nothing happens. - - :param group: The group from which this account should be removed. - :type group: AccountGroup - """ - self.domain_groups.discard(group) - - def enable_account(self): + def enable(self): """Set the status to enabled.""" self.status = AccountStatus.enabled - def disable_account(self): + def disable(self): """Set the status to disabled.""" self.status = AccountStatus.disabled + + def _possible_actions(self) -> Dict[str, Callable[[List[str]], None]]: + return { + "enable": self.enable, + "disable": self.disable, + } diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 5a14e80e..c9165bbf 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -1,13 +1,54 @@ -from typing import Set, TypeAlias +from typing import Dict, Final, List, TypeAlias from primaite.simulator.core import SimComponent -from primaite.simulator.domain import Account +from primaite.simulator.domain import Account, AccountGroup, AccountType -__temp_node = TypeAlias() # placeholder while nodes don't exist +# placeholder while these objects don't yet exist +__temp_node = TypeAlias() +__temp_application = TypeAlias() +__temp_folder = TypeAlias() +__temp_file = TypeAlias() class DomainController(SimComponent): """Main object for controlling the domain.""" - nodes: Set(__temp_node) = set() - accounts: Set(Account) = set() + # owned objects + accounts: List(Account) = [] + groups: Final[List[AccountGroup]] = list(AccountGroup) + + group_membership: Dict[AccountGroup, List[Account]] + + # references to non-owned objects + nodes: List(__temp_node) = [] + applications: List(__temp_application) = [] + folders: List(__temp_folder) = [] + files: List(__temp_file) = [] + + def register_account(self, account: Account) -> None: + """TODO.""" + ... + + def deregister_account(self, account: Account) -> None: + """TODO.""" + ... + + def create_account(self, username: str, password: str, account_type: AccountType) -> Account: + """TODO.""" + ... + + def rotate_all_credentials(self) -> None: + """TODO.""" + ... + + def rotate_account_credentials(self, account: Account) -> None: + """TODO.""" + ... + + def add_account_to_group(self, account: Account, group: AccountGroup) -> None: + """TODO.""" + ... + + def remove_account_from_group(self, account: Account, group: AccountGroup) -> None: + """TODO.""" + ... From 94617c57a4a70b96840004862d1cfc7467283cf2 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 3 Aug 2023 13:24:27 +0100 Subject: [PATCH 09/63] Make register and deregister acct private --- src/primaite/simulator/domain/controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index c9165bbf..bdb5fbb0 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -25,11 +25,11 @@ class DomainController(SimComponent): folders: List(__temp_folder) = [] files: List(__temp_file) = [] - def register_account(self, account: Account) -> None: + def _register_account(self, account: Account) -> None: """TODO.""" ... - def deregister_account(self, account: Account) -> None: + def _deregister_account(self, account: Account) -> None: """TODO.""" ... From cac4779244cb7ae1f42cb76bd1c276bb0f1adf7f Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 3 Aug 2023 14:37:55 +0100 Subject: [PATCH 10/63] #1706 - Started adding the core node software required by all nodes. Made some tweaks to the Frame to have send and receive timestamp. --- src/primaite/simulator/core.py | 3 +- .../simulator/network/hardware/base.py | 185 +++++++++++------- .../network/transmission/data_link_layer.py | 24 +++ src/primaite/simulator/system/__init__.py | 0 .../simulator/system/processes/__init__.py | 0 .../simulator/system/processes/pcap.py | 61 ++++++ .../simulator/system/processes/sys_log.py | 87 ++++++++ .../simulator/system/services/__init__.py | 0 .../simulator/system/services/icmp.py | 0 src/primaite/simulator/system/software.py | 94 +++++++++ .../network/test_frame_transmission.py | 27 ++- 11 files changed, 400 insertions(+), 81 deletions(-) create mode 100644 src/primaite/simulator/system/__init__.py create mode 100644 src/primaite/simulator/system/processes/__init__.py create mode 100644 src/primaite/simulator/system/processes/pcap.py create mode 100644 src/primaite/simulator/system/processes/sys_log.py create mode 100644 src/primaite/simulator/system/services/__init__.py create mode 100644 src/primaite/simulator/system/services/icmp.py create mode 100644 src/primaite/simulator/system/software.py diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index d684a74b..2b84a2a6 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -3,12 +3,13 @@ from abc import abstractmethod from typing import Callable, Dict, List from uuid import uuid4 -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + model_config = ConfigDict(arbitrary_types_allowed=True) uuid: str "The component UUID." diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index c1bed5b0..ce0e7f25 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 enum import Enum from ipaddress import IPv4Address, IPv4Network -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union from primaite import getLogger from primaite.exceptions import NetworkError @@ -13,6 +13,8 @@ from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader +from primaite.simulator.system.processes.pcap import PCAP +from primaite.simulator.system.processes.sys_log import SysLog _LOGGER = getLogger(__name__) @@ -85,6 +87,7 @@ class NIC(SimComponent): "The Link to which the NIC is connected." enabled: bool = False "Indicates whether the NIC is enabled." + pcap: Optional[PCAP] = None def __init__(self, **kwargs): """ @@ -129,9 +132,10 @@ class NIC(SimComponent): """Attempt to enable the NIC.""" if not self.enabled: if self.connected_node: - if self.connected_node.hardware_state == HardwareState.ON: + if self.connected_node.hardware_state == NodeOperatingState.ON: self.enabled = True _LOGGER.info(f"NIC {self} enabled") + self.pcap = PCAP(hostname=self.connected_node.hostname, ip_address=self.ip_address) if self.connected_link: self.connected_link.endpoint_up() else: @@ -203,6 +207,8 @@ class NIC(SimComponent): :type frame: :class:`~primaite.simulator.network.osi_layers.Frame` """ if self.enabled: + frame.set_sent_timestamp() + self.pcap.capture(frame) self.connected_link.transmit_frame(sender_nic=self, frame=frame) return True else: @@ -219,6 +225,9 @@ class NIC(SimComponent): :type frame: :class:`~primaite.simulator.network.osi_layers.Frame` """ if self.enabled: + frame.decrement_ttl() + frame.set_received_timestamp() + self.pcap.capture(frame) self.connected_node.receive_frame(frame=frame, from_nic=self) return True else: @@ -281,19 +290,18 @@ class Link(SimComponent): super().__init__(**kwargs) self.endpoint_a.connect_link(self) self.endpoint_b.connect_link(self) - if self.up: - _LOGGER.info(f"Link up between {self.endpoint_a} and {self.endpoint_b}") + self.endpoint_up() def endpoint_up(self): """Let the Link know and endpoint has been brought up.""" if self.up: - _LOGGER.info(f"Link up between {self.endpoint_a} and {self.endpoint_b}") + _LOGGER.info(f"Link {self} up") def endpoint_down(self): """Let the Link know and endpoint has been brought down.""" if not self.up: self.current_load = 0.0 - _LOGGER.info(f"Link down between {self.endpoint_a} and {self.endpoint_b}") + _LOGGER.info(f"Link {self} down") @property def up(self) -> bool: @@ -318,20 +326,24 @@ class Link(SimComponent): :param frame: The network frame to be sent. :return: True if the Frame can be sent, otherwise False. """ - receiver_nic = self.endpoint_a - if receiver_nic == sender_nic: - receiver_nic = self.endpoint_b - frame_size = frame.size_Mbits - sent = receiver_nic.receive_frame(frame) - if sent: - # Frame transmitted successfully - # Load the frame size on the link - self.current_load += frame_size - _LOGGER.info(f"Link added {frame_size} Mbits, current load {self.current_load} Mbits") - return True - # Received NIC disabled, reply + if self._can_transmit(frame): + receiver_nic = self.endpoint_a + if receiver_nic == sender_nic: + receiver_nic = self.endpoint_b + frame_size = frame.size_Mbits + sent = receiver_nic.receive_frame(frame) + if sent: + # Frame transmitted successfully + # Load the frame size on the link + self.current_load += frame_size + _LOGGER.info(f"Added {frame_size:.3f} Mbits to {self}, current load {self.current_load:.3f} Mbits") + return True + # Received NIC disabled, reply - return False + return False + else: + _LOGGER.info(f"Cannot transmit frame as {self} is at capacity") + return False def reset_component_for_episode(self): """ @@ -359,15 +371,21 @@ class Link(SimComponent): """ pass + def __str__(self) -> str: + return f"{self.endpoint_a}<-->{self.endpoint_b}" -class HardwareState(Enum): - """Node hardware state enumeration.""" +class NodeOperatingState(Enum): + """Enumeration of Node Operating States.""" + + OFF = 0 + "The node is powered off." ON = 1 - OFF = 2 - RESETTING = 3 - SHUTTING_DOWN = 4 - BOOTING = 5 + "The node is powered on." + SHUTTING_DOWN = 2 + "The node is in the process of shutting down." + BOOTING = 3 + "The node is in the process of booting up." class Node(SimComponent): @@ -380,7 +398,7 @@ class Node(SimComponent): hostname: str "The node hostname on the network." - hardware_state: HardwareState = HardwareState.OFF + operating_state: NodeOperatingState = NodeOperatingState.OFF "The hardware state of the node." nics: Dict[str, NIC] = {} "The NICs on the node." @@ -397,25 +415,30 @@ class Node(SimComponent): "The nodes file system." arp_cache: Dict[IPv4Address, ARPEntry] = {} "The ARP cache." + sys_log: Optional[SysLog] = None revealed_to_red: bool = False "Informs whether the node has been revealed to a red agent." + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.sys_log = SysLog(self.hostname) + def turn_on(self): """Turn on the Node.""" - if self.hardware_state == HardwareState.OFF: - self.hardware_state = HardwareState.ON - _LOGGER.info(f"Node {self.hostname} turned on") + if self.operating_state == NodeOperatingState.OFF: + self.operating_state = NodeOperatingState.ON + self.sys_log.info("Turned on") for nic in self.nics.values(): nic.enable() def turn_off(self): """Turn off the Node.""" - if self.hardware_state == HardwareState.ON: + if self.operating_state == NodeOperatingState.ON: for nic in self.nics.values(): nic.disable() - self.hardware_state = HardwareState.OFF - _LOGGER.info(f"Node {self.hostname} turned off") + self.operating_state = NodeOperatingState.OFF + self.sys_log.info("Turned off") def connect_nic(self, nic: NIC): """ @@ -427,11 +450,12 @@ class Node(SimComponent): if nic.uuid not in self.nics: self.nics[nic.uuid] = nic nic.connected_node = self - _LOGGER.debug(f"Node {self.hostname} connected NIC {nic}") - if self.hardware_state == HardwareState.ON: + self.sys_log.info(f"Connected NIC {nic}") + if self.operating_state == NodeOperatingState.ON: nic.enable() else: - msg = f"Cannot connect NIC {nic} to Node {self.hostname} as it is already connected" + msg = f"Cannot connect NIC {nic} as it is already connected" + self.sys_log.logger.error(msg) _LOGGER.error(msg) raise NetworkError(msg) @@ -447,9 +471,10 @@ class Node(SimComponent): if nic or nic.uuid in self.nics: self.nics.pop(nic.uuid) nic.disable() - _LOGGER.debug(f"Node {self.hostname} disconnected NIC {nic}") + self.sys_log.info(f"Disconnected NIC {nic}") else: - msg = f"Cannot disconnect NIC {nic} from Node {self.hostname} as it is not connected" + msg = f"Cannot disconnect NIC {nic} as it is not connected" + self.sys_log.logger.error(msg) _LOGGER.error(msg) raise NetworkError(msg) @@ -461,7 +486,7 @@ class Node(SimComponent): :param mac_address: The MAC address associated with the IP address. :param nic: The NIC through which the NIC with the IP address is reachable. """ - _LOGGER.info(f"Node {self.hostname} Adding ARP cache entry for {mac_address}/{ip_address} via NIC {nic}") + self.sys_log.info(f"Adding ARP cache entry for {mac_address}/{ip_address} via NIC {nic}") arp_entry = ARPEntry(mac_address=mac_address, nic_uuid=nic.uuid) self.arp_cache[ip_address] = arp_entry @@ -504,7 +529,7 @@ class Node(SimComponent): """Perform a standard ARP request for a given target IP address.""" for nic in self.nics.values(): if nic.enabled: - _LOGGER.info(f"Node {self.hostname} sending ARP request from NIC {nic} for ip {target_ip_address}") + 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) # Network Layer @@ -530,35 +555,38 @@ class Node(SimComponent): :param arp_packet:The ARP packet to process. """ if arp_packet.request: - _LOGGER.info( - f"Node {self.hostname} received ARP request from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip}" - ) - self._add_arp_cache_entry( - ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic - ) - arp_packet = arp_packet.generate_reply(from_nic.mac_address) - _LOGGER.info( - f"Node {self.hostname} sending ARP reply from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " - f"to {arp_packet.target_ip}/{arp_packet.target_mac_addr} " + self.sys_log.info( + f"Received ARP request for {arp_packet.target_ip} from " + f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " ) + if arp_packet.target_ip == from_nic.ip_address: + self._add_arp_cache_entry( + ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic + ) + arp_packet = arp_packet.generate_reply(from_nic.mac_address) + self.sys_log.info( + f"Sending ARP reply from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " + f"to {arp_packet.target_ip}/{arp_packet.target_mac_addr} " + ) - tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) - # Network Layer - ip_packet = IPPacket( - src_ip=arp_packet.sender_ip, - dst_ip=arp_packet.target_ip, - ) - # Data Link Layer - ethernet_header = EthernetHeader( - src_mac_addr=arp_packet.sender_mac_addr, dst_mac_addr=arp_packet.target_mac_addr - ) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) - self.send_frame(frame) + # Network Layer + ip_packet = IPPacket( + src_ip=arp_packet.sender_ip, + dst_ip=arp_packet.target_ip, + ) + # Data Link Layer + ethernet_header = EthernetHeader( + src_mac_addr=arp_packet.sender_mac_addr, dst_mac_addr=arp_packet.target_mac_addr + ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) + self.send_frame(frame) + else: + self.sys_log.info(f"Ignoring ARP request for {arp_packet.target_ip}") else: - _LOGGER.info( - f"Node {self.hostname} received ARP response for {arp_packet.sender_ip} " - f"from {arp_packet.sender_mac_addr} via NIC {from_nic}" + self.sys_log.info( + f"Received ARP response for {arp_packet.sender_ip} from {arp_packet.sender_mac_addr} via NIC {from_nic}" ) self._add_arp_cache_entry( ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic @@ -573,7 +601,7 @@ class Node(SimComponent): :param frame: The Frame containing the icmp packet to process. """ if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: - _LOGGER.info(f"Node {self.hostname} received echo request from {frame.ip.src_ip}") + self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") target_mac_address = self._get_arp_cache_mac_address(frame.ip.src_ip) src_nic = self._get_arp_cache_nic(frame.ip.src_ip) tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) @@ -589,13 +617,14 @@ class Node(SimComponent): sequence=frame.icmp.sequence + 1, ) frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) + self.sys_log.info(f"Sending echo reply to {frame.ip.src_ip}") src_nic.send_frame(frame) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: - _LOGGER.info(f"Node {self.hostname} received echo reply from {frame.ip.src_ip}") - if frame.icmp.sequence <= 6: # 3 pings - self._ping(frame.ip.src_ip, sequence=frame.icmp.sequence, identifier=frame.icmp.identifier) + self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") - def _ping(self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None): + def _ping( + self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None + ) -> Tuple[int, Union[int, None]]: nic = self._get_arp_cache_nic(target_ip_address) if nic: sequence += 1 @@ -613,13 +642,15 @@ class Node(SimComponent): ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) icmp_packet = ICMPPacket(identifier=identifier, sequence=sequence) frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_packet) + self.sys_log.info(f"Sending echo request to {target_ip_address}") nic.send_frame(frame) + return sequence, icmp_packet.identifier else: - _LOGGER.info(f"Node {self.hostname} no entry in ARP cache for {target_ip_address}") + self.sys_log.info(f"No entry in ARP cache for {target_ip_address}") self._send_arp_request(target_ip_address) - self._ping(target_ip_address=target_ip_address) + return 0, None - def ping(self, target_ip_address: Union[IPv4Address, str]) -> bool: + def ping(self, target_ip_address: Union[IPv4Address, str], pings: int = 4) -> bool: """ Ping an IP address. @@ -630,11 +661,13 @@ class Node(SimComponent): """ if not isinstance(target_ip_address, IPv4Address): target_ip_address = IPv4Address(target_ip_address) - if self.hardware_state == HardwareState.ON: - _LOGGER.info(f"Node {self.hostname} attempting to ping {target_ip_address}") - self._ping(target_ip_address) + if self.operating_state == NodeOperatingState.ON: + self.sys_log.info(f"Attempting to ping {target_ip_address}") + sequence, identifier = 0, None + while sequence < pings: + sequence, identifier = self._ping(target_ip_address, sequence, identifier) return True - _LOGGER.info(f"Node {self.hostname} ping failed as the node is turned off") + self.sys_log.info("Ping failed as the node is turned off") return False def send_frame(self, frame: Frame): diff --git a/src/primaite/simulator/network/transmission/data_link_layer.py b/src/primaite/simulator/network/transmission/data_link_layer.py index 97a1a423..1b7ccf7d 100644 --- a/src/primaite/simulator/network/transmission/data_link_layer.py +++ b/src/primaite/simulator/network/transmission/data_link_layer.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any, Optional from pydantic import BaseModel @@ -99,6 +100,29 @@ class Frame(BaseModel): "PrimAITE header." payload: Optional[Any] = None "Raw data payload." + sent_timestamp: Optional[datetime] = None + "The time the Frame was sent from the original source NIC." + received_timestamp: Optional[datetime] = None + "The time the Frame was received at the final destination NIC." + + def decrement_ttl(self): + """Decrement the IPPacket ttl by 1.""" + self.ip.ttl -= 1 + + @property + def can_transmit(self) -> bool: + """Informs whether the Frame can transmit based on the IPPacket tll being >= 1.""" + return self.ip.ttl >= 1 + + def set_sent_timestamp(self): + """Set the sent_timestamp.""" + if not self.sent_timestamp: + self.sent_timestamp = datetime.now() + + def set_received_timestamp(self): + """Set the received_timestamp.""" + if not self.received_timestamp: + self.received_timestamp = datetime.now() @property def size(self) -> float: # noqa - Keep it as MBits as this is how they're expressed diff --git a/src/primaite/simulator/system/__init__.py b/src/primaite/simulator/system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/processes/__init__.py b/src/primaite/simulator/system/processes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/processes/pcap.py b/src/primaite/simulator/system/processes/pcap.py new file mode 100644 index 00000000..c502adc8 --- /dev/null +++ b/src/primaite/simulator/system/processes/pcap.py @@ -0,0 +1,61 @@ +import logging +from pathlib import Path + + +class _JSONFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + """Filter logs that start and end with '{' and '}' (JSON-like messages).""" + return record.getMessage().startswith("{") and record.getMessage().endswith("}") + + +class PCAP: + """ + A logger class for logging Frames as json strings. + + This is essentially a PrimAITE simulated version of PCAP. + + The PCAPs are logged to: //__pcap.log + """ + + def __init__(self, hostname: str, ip_address: str): + """ + Initialize the PCAP instance. + + :param hostname: The hostname for which PCAP logs are being recorded. + :param ip_address: The IP address associated with the PCAP logs. + """ + self.hostname = hostname + self.ip_address = str(ip_address) + self._setup_logger() + + def _setup_logger(self): + """Set up the logger configuration.""" + log_path = self._get_log_path() + + file_handler = logging.FileHandler(filename=log_path) + file_handler.setLevel(60) # Custom log level > CRITICAL to prevent any unwanted standard DEBUG-CRITICAL logs + + log_format = "%(message)s" + file_handler.setFormatter(logging.Formatter(log_format)) + + logger_name = f"{self.hostname}_{self.ip_address}_pcap" + self.logger = logging.getLogger(logger_name) + self.logger.setLevel(60) # Custom log level > CRITICAL to prevent any unwanted standard DEBUG-CRITICAL logs + self.logger.addHandler(file_handler) + + self.logger.addFilter(_JSONFilter()) + + def _get_log_path(self) -> Path: + """Get the path for the log file.""" + root = Path(__file__).parent.parent.parent.parent.parent.parent / "simulation_output" / self.hostname + root.mkdir(exist_ok=True, parents=True) + return root / f"{self.hostname}_{self.ip_address}_pcap.log" + + def capture(self, frame): # noqa Please don't make me, I'll have a circular import and cant use if TYPE_CHECKING ;( + """ + Capture a Frame and log it. + + :param frame: The PCAP frame to capture. + """ + msg = frame.model_dump_json() + self.logger.log(level=60, msg=msg) # Log at custom log level > CRITICAL diff --git a/src/primaite/simulator/system/processes/sys_log.py b/src/primaite/simulator/system/processes/sys_log.py new file mode 100644 index 00000000..27b35505 --- /dev/null +++ b/src/primaite/simulator/system/processes/sys_log.py @@ -0,0 +1,87 @@ +import logging +from pathlib import Path + + +class _NotJSONFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + """Filter logs that do not start and end with '{' and '}'.""" + return not record.getMessage().startswith("{") and not record.getMessage().endswith("}") + + +class SysLog: + """ + A simple logger class for writing the sys logs of a Node. + + Logs are logged to: //_sys.log + """ + + def __init__(self, hostname: str): + """ + Initialize the SysLog instance. + + :param hostname: The hostname for which logs are being recorded. + """ + self.hostname = hostname + self._setup_logger() + + def _setup_logger(self): + """Set up the logger configuration.""" + log_path = self._get_log_path() + + file_handler = logging.FileHandler(filename=log_path) + file_handler.setLevel(logging.DEBUG) + + log_format = "%(asctime)s %(levelname)s: %(message)s" + file_handler.setFormatter(logging.Formatter(log_format)) + + self.logger = logging.getLogger(f"{self.hostname}_sys_log") + self.logger.setLevel(logging.DEBUG) + self.logger.addHandler(file_handler) + + self.logger.addFilter(_NotJSONFilter()) + + def _get_log_path(self) -> Path: + """Get the path for the log file.""" + root = Path(__file__).parent.parent.parent.parent.parent.parent / "simulation_output" / self.hostname + root.mkdir(exist_ok=True, parents=True) + return root / f"{self.hostname}_sys.log" + + def debug(self, msg: str): + """ + Log a debug message. + + :param msg: The message to log. + """ + self.logger.debug(msg) + + def info(self, msg: str): + """ + Log an info message. + + :param msg: The message to log. + """ + self.logger.info(msg) + + def warning(self, msg: str): + """ + Log a warning message. + + :param msg: The message to log. + """ + self.logger.warning(msg) + + def error(self, msg: str): + """ + Log an error message. + + :param msg: The message to log. + """ + self.logger.error(msg) + + def critical(self, msg: str): + """ + Log a critical message. + + :param msg: The message to log. + """ + self.logger.critical(msg) diff --git a/src/primaite/simulator/system/services/__init__.py b/src/primaite/simulator/system/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/icmp.py b/src/primaite/simulator/system/services/icmp.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py new file mode 100644 index 00000000..a5d0bd18 --- /dev/null +++ b/src/primaite/simulator/system/software.py @@ -0,0 +1,94 @@ +from enum import Enum + +from primaite.simulator.core import SimComponent + + +class SoftwareHealthState(Enum): + """Enumeration of the Software Health States.""" + + GOOD = 1 + "The software is in a good and healthy condition." + COMPROMISED = 2 + "The software's security has been compromised." + OVERWHELMED = 3 + "he software is overwhelmed and not functioning properly." + PATCHING = 4 + "The software is undergoing patching or updates." + + +class ApplicationOperatingState(Enum): + """Enumeration of Application Operating States.""" + + CLOSED = 0 + "The application is closed or not running." + RUNNING = 1 + "The application is running." + INSTALLING = 3 + "The application is being installed or updated." + + +class ServiceOperatingState(Enum): + """Enumeration of Service Operating States.""" + + STOPPED = 0 + "The service is not running." + RUNNING = 1 + "The service is currently running." + RESTARTING = 2 + "The service is in the process of restarting." + INSTALLING = 3 + "The service is being installed or updated." + PAUSED = 4 + "The service is temporarily paused." + DISABLED = 5 + "The service is disabled and cannot be started." + + +class ProcessOperatingState(Enum): + """Enumeration of Process Operating States.""" + + RUNNING = 1 + "The process is running." + PAUSED = 2 + "The process is temporarily paused." + + +class SoftwareCriticality(Enum): + """Enumeration of Software Criticality Levels.""" + + LOWEST = 1 + "The lowest level of criticality." + LOW = 2 + "A low level of criticality." + MEDIUM = 3 + "A medium level of criticality." + HIGH = 4 + "A high level of criticality." + HIGHEST = 5 + "The highest level of criticality." + + +class Software(SimComponent): + """ + Represents software information along with its health, criticality, and status. + + This class inherits from the Pydantic BaseModel and provides a structured way to store + information about software entities. + + Attributes: + name (str): The name of the software. + health_state_actual (SoftwareHealthState): The actual health state of the software. + health_state_visible (SoftwareHealthState): The health state of the software visible to users. + criticality (SoftwareCriticality): The criticality level of the software. + patching_count (int, optional): The count of patches applied to the software. Default is 0. + scanning_count (int, optional): The count of times the software has been scanned. Default is 0. + revealed_to_red (bool, optional): Indicates if the software has been revealed to red team. Default is False. + """ + + name: str + health_state_actual: SoftwareHealthState + health_state_visible: SoftwareHealthState + criticality: SoftwareCriticality + patching_count: int = 0 + scanning_count: int = 0 + revealed_to_red: bool = False diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py index 32abd0ef..82e97049 100644 --- a/tests/integration_tests/network/test_frame_transmission.py +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -16,10 +16,29 @@ def test_node_to_node_ping(): assert node_a.ping("192.168.0.11") - node_a.turn_off() - - assert not node_a.ping("192.168.0.11") +def test_multi_nic(): + node_a = Node(hostname="node_a") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_a.connect_nic(nic_a) node_a.turn_on() - assert node_a.ping("192.168.0.11") + node_b = Node(hostname="node_b") + nic_b1 = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_b2 = NIC(ip_address="10.0.0.12", subnet_mask="255.0.0.0", gateway="10.0.0.1") + node_b.connect_nic(nic_b1) + node_b.connect_nic(nic_b2) + node_b.turn_on() + + node_c = Node(hostname="node_c") + nic_c = NIC(ip_address="10.0.0.13", subnet_mask="255.0.0.0", gateway="10.0.0.1") + node_c.connect_nic(nic_c) + node_c.turn_on() + + link_a_b1 = Link(endpoint_a=nic_a, endpoint_b=nic_b1) + + link_b2_c = Link(endpoint_a=nic_b2, endpoint_b=nic_c) + + node_a.ping("192.168.0.11") + + node_c.ping("10.0.0.12") From 2a680c1e4817764f92887a7e15d0807b5c4f5d31 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 3 Aug 2023 16:26:33 +0100 Subject: [PATCH 11/63] Test my validators --- src/primaite/simulator/core.py | 32 +--- src/primaite/simulator/domain/__init__.py | 3 - src/primaite/simulator/domain/account.py | 18 +- src/primaite/simulator/domain/controller.py | 66 +++++-- .../component_creation/__init__.py | 0 .../test_permission_system.py | 171 ++++++++++++++++++ 6 files changed, 235 insertions(+), 55 deletions(-) create mode 100644 tests/integration_tests/component_creation/__init__.py create mode 100644 tests/integration_tests/component_creation/test_permission_system.py diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index eaedc85a..17e09f85 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -1,11 +1,11 @@ """Core of the PrimAITE Simulator.""" from abc import ABC, abstractmethod from typing import Callable, Dict, List, Optional +from uuid import uuid4 -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Extra from primaite import getLogger -from primaite.simulator.domain import AccountGroup _LOGGER = getLogger(__name__) @@ -33,23 +33,6 @@ class AllowAllValidator(ActionPermissionValidator): return True -class GroupMembershipValidator(ActionPermissionValidator): - """Permit actions based on group membership.""" - - def __init__(self, allowed_groups: List[AccountGroup]) -> None: - """TODO.""" - self.allowed_groups = allowed_groups - - def __call__(self, request: List[str], context: Dict) -> bool: - """Permit the action if the request comes from an account which belongs to the right group.""" - # if context request source is part of any groups mentioned in self.allow_groups, return true, otherwise false - requestor_groups: List[str] = context["request_source"]["groups"] - for allowed_group in self.allowed_groups: - if allowed_group.name in requestor_groups: - return True - return False - - class Action: """ This object stores data related to a single action. @@ -83,7 +66,7 @@ class ActionManager: def __init__(self) -> None: """TODO.""" - self.actions: Dict[str, Action] + self.actions: Dict[str, Action] = {} def process_request(self, request: List[str], context: Dict) -> None: """Process action request.""" @@ -106,17 +89,20 @@ class ActionManager: action.func(action_options, context) + def add_action(self, name: str, action: Action) -> None: + self.actions[name] = action + class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" - model_config = ConfigDict(arbitrary_types_allowed=True) - uuid: str + model_config = ConfigDict(arbitrary_types_allowed=True, extra=Extra.allow) + uuid: str = str(uuid4()) "The component UUID." def __init__(self, **kwargs) -> None: - self.action_manager: Optional[ActionManager] = None super().__init__(**kwargs) + self.action_manager: Optional[ActionManager] = None @abstractmethod def describe_state(self) -> Dict: diff --git a/src/primaite/simulator/domain/__init__.py b/src/primaite/simulator/domain/__init__.py index 0e23133f..e69de29b 100644 --- a/src/primaite/simulator/domain/__init__.py +++ b/src/primaite/simulator/domain/__init__.py @@ -1,3 +0,0 @@ -from primaite.simulator.domain.account import Account, AccountGroup, AccountType - -__all__ = ["Account", "AccountGroup", "AccountType"] diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index c134e916..0f59db2e 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -1,6 +1,6 @@ """User account simulation.""" from enum import Enum -from typing import Callable, Dict, List, TypeAlias +from typing import Any, Callable, Dict, List from primaite import getLogger from primaite.simulator.core import SimComponent @@ -8,9 +8,6 @@ from primaite.simulator.core import SimComponent _LOGGER = getLogger(__name__) -__temp_node = TypeAlias() # placeholder while nodes don't exist - - class AccountType(Enum): """Whether the account is intended for a user to log in or for a service to use.""" @@ -20,19 +17,6 @@ class AccountType(Enum): "User accounts are used to allow agents to log in and perform actions" -class AccountGroup(Enum): - """Permissions are set at group-level and accounts can belong to these groups.""" - - local_user = 1 - "For performing basic actions on a node" - domain_user = 2 - "For performing basic actions to the domain" - local_admin = 3 - "For full access to actions on a node" - domain_admin = 4 - "For full access" - - class AccountStatus(Enum): """Whether the account is active.""" diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index bdb5fbb0..7cb3f4a6 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -1,29 +1,71 @@ -from typing import Dict, Final, List, TypeAlias +from enum import Enum +from typing import Any, Dict, Final, List + +from primaite.simulator.core import ActionPermissionValidator, SimComponent +from primaite.simulator.domain.account import Account, AccountType -from primaite.simulator.core import SimComponent -from primaite.simulator.domain import Account, AccountGroup, AccountType # placeholder while these objects don't yet exist -__temp_node = TypeAlias() -__temp_application = TypeAlias() -__temp_folder = TypeAlias() -__temp_file = TypeAlias() +class temp_node: + pass + + +class temp_application: + pass + + +class temp_folder: + pass + + +class temp_file: + pass + + +class AccountGroup(Enum): + """Permissions are set at group-level and accounts can belong to these groups.""" + + local_user = 1 + "For performing basic actions on a node" + domain_user = 2 + "For performing basic actions to the domain" + local_admin = 3 + "For full access to actions on a node" + domain_admin = 4 + "For full access" + + +class GroupMembershipValidator(ActionPermissionValidator): + """Permit actions based on group membership.""" + + def __init__(self, allowed_groups: List[AccountGroup]) -> None: + """TODO.""" + self.allowed_groups = allowed_groups + + def __call__(self, request: List[str], context: Dict) -> bool: + """Permit the action if the request comes from an account which belongs to the right group.""" + # if context request source is part of any groups mentioned in self.allow_groups, return true, otherwise false + requestor_groups: List[str] = context["request_source"]["groups"] + for allowed_group in self.allowed_groups: + if allowed_group.name in requestor_groups: + return True + return False class DomainController(SimComponent): """Main object for controlling the domain.""" # owned objects - accounts: List(Account) = [] + accounts: List[Account] = [] groups: Final[List[AccountGroup]] = list(AccountGroup) group_membership: Dict[AccountGroup, List[Account]] # references to non-owned objects - nodes: List(__temp_node) = [] - applications: List(__temp_application) = [] - folders: List(__temp_folder) = [] - files: List(__temp_file) = [] + nodes: List[temp_node] = [] + applications: List[temp_application] = [] + folders: List[temp_folder] = [] + files: List[temp_file] = [] def _register_account(self, account: Account) -> None: """TODO.""" diff --git a/tests/integration_tests/component_creation/__init__.py b/tests/integration_tests/component_creation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/component_creation/test_permission_system.py b/tests/integration_tests/component_creation/test_permission_system.py new file mode 100644 index 00000000..acc35b72 --- /dev/null +++ b/tests/integration_tests/component_creation/test_permission_system.py @@ -0,0 +1,171 @@ +from enum import Enum +from typing import Dict, List, Literal + +import pytest + +from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent +from primaite.simulator.domain.controller import AccountGroup, GroupMembershipValidator + + +def test_group_action_validation() -> None: + """Check that actions are denied when an unauthorised request is made.""" + + class Folder(SimComponent): + name: str + + def describe_state(self) -> Dict: + return super().describe_state() + + class Node(SimComponent): + name: str + folders: List[Folder] = [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.action_manager = ActionManager() + + self.action_manager.add_action( + "create_folder", + Action( + func=lambda request, context: self.create_folder(request[0]), + validator=GroupMembershipValidator([AccountGroup.local_admin, AccountGroup.domain_admin]), + ), + ) + + def describe_state(self) -> Dict: + return super().describe_state() + + def create_folder(self, folder_name: str) -> None: + new_folder = Folder(uuid="0000-0000-0001", name=folder_name) + self.folders.append(new_folder) + + def remove_folder(self, folder: Folder) -> None: + self.folders = [x for x in self.folders if x is not folder] + + permitted_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_admin"]}} + + my_node = Node(uuid="0000-0000-1234", name="pc") + my_node.apply_action(["create_folder", "memes"], context=permitted_context) + assert len(my_node.folders) == 1 + assert my_node.folders[0].name == "memes" + + invalid_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_user", "domain_user"]}} + + my_node.apply_action(["create_folder", "memes2"], context=invalid_context) + assert len(my_node.folders) == 1 + assert my_node.folders[0].name == "memes" + + +def test_hierarchical_action_with_validation() -> None: + """Check that validation works with sub-objects""" + + class Application(SimComponent): + name: str + state: Literal["on", "off", "disabled"] = "off" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.action_manager = ActionManager() + + self.action_manager.add_action( + "turn_on", + Action( + func=lambda request, context: self.turn_on(), + validator=AllowAllValidator(), + ), + ) + self.action_manager.add_action( + "turn_off", + Action( + func=lambda request, context: self.turn_off(), + validator=AllowAllValidator(), + ), + ) + self.action_manager.add_action( + "disable", + Action( + func=lambda request, context: self.disable(), + validator=GroupMembershipValidator([AccountGroup.local_admin, AccountGroup.domain_admin]), + ), + ) + self.action_manager.add_action( + "enable", + Action( + func=lambda request, context: self.enable(), + validator=GroupMembershipValidator([AccountGroup.local_admin, AccountGroup.domain_admin]), + ), + ) + + def describe_state(self) -> Dict: + return super().describe_state() + + def disable(self) -> None: + self.status = "disabled" + + def enable(self) -> None: + if self.status == "disabled": + self.status = "off" + + def turn_on(self) -> None: + if self.status == "off": + self.status = "on" + + def turn_off(self) -> None: + if self.status == "on": + self.status = "off" + + class Node(SimComponent): + name: str + state: Literal["on", "off"] = "on" + apps: List[Application] = [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.action_manager = ActionManager() + + self.action_manager.add_action( + "apps", + Action( + func=lambda request, context: self.send_action_to_app(request.pop(0), request, context), + validator=AllowAllValidator(), + ), + ) + + def describe_state(self) -> Dict: + return super().describe_state() + + def install_app(self, app_name: str) -> None: + new_app = Application(name=app_name) + self.apps.append(new_app) + + def send_action_to_app(self, app_name: str, options: List[str], context: Dict): + for app in self.apps: + if app_name == app.name: + app.apply_action(options) + break + else: + msg = f"Node has no app with name {app_name}" + raise LookupError(msg) + + my_node = Node(name="pc") + my_node.install_app("Chrome") + my_node.install_app("Firefox") + + non_admin_context = { + "request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_user", "domain_user"]} + } + + admin_context = { + "request_source": { + "agent": "BLUE", + "account": "User1", + "groups": ["local_admin", "domain_admin", "local_user", "domain_user"], + } + } + + my_node.apply_action(["apps", "Chrome", "disable"], non_admin_context) + my_node.apply_action(["apps", "Firefox", "turn_on"], non_admin_context) + + assert my_node.apps[0].name == "Chrome" + assert my_node.apps[1].name == "Firefox" + assert my_node.apps[0].state == ... # TODO: finish From 04f1cb0dc6720c4ddf1dec60faaef8ca5595f154 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 3 Aug 2023 21:30:13 +0100 Subject: [PATCH 12/63] #1706 - Got the code services, application, and process base classes stubbed out. Need them now so that I can leverage them for core node services required. --- src/primaite/simulator/core.py | 2 +- .../simulator/network/hardware/base.py | 12 +- .../simulator/system/applications/__init__.py | 0 .../system/applications/application.py | 86 +++++++++++ src/primaite/simulator/system/arp_cache.py | 30 ++++ .../{processes/pcap.py => packet_capture.py} | 16 +- .../simulator/system/processes/process.py | 37 +++++ .../simulator/system/services/service.py | 87 +++++++++++ src/primaite/simulator/system/software.py | 142 ++++++++++++------ .../system/{processes => }/sys_log.py | 46 +++--- .../network/test_frame_transmission.py | 2 +- 11 files changed, 378 insertions(+), 82 deletions(-) create mode 100644 src/primaite/simulator/system/applications/__init__.py create mode 100644 src/primaite/simulator/system/applications/application.py create mode 100644 src/primaite/simulator/system/arp_cache.py rename src/primaite/simulator/system/{processes/pcap.py => packet_capture.py} (78%) create mode 100644 src/primaite/simulator/system/processes/process.py create mode 100644 src/primaite/simulator/system/services/service.py rename src/primaite/simulator/system/{processes => }/sys_log.py (51%) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 2b84a2a6..2125c693 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -64,7 +64,7 @@ class SimComponent(BaseModel): """ pass - def reset_component_for_episode(self): + def reset_component_for_episode(self, episode: int): """ Reset this component to its original state for a new episode. diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index ce0e7f25..138c444c 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -13,8 +13,8 @@ from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader -from primaite.simulator.system.processes.pcap import PCAP -from primaite.simulator.system.processes.sys_log import SysLog +from primaite.simulator.system.packet_capture import PacketCapture +from primaite.simulator.system.sys_log import SysLog _LOGGER = getLogger(__name__) @@ -87,7 +87,7 @@ class NIC(SimComponent): "The Link to which the NIC is connected." enabled: bool = False "Indicates whether the NIC is enabled." - pcap: Optional[PCAP] = None + pcap: Optional[PacketCapture] = None def __init__(self, **kwargs): """ @@ -132,10 +132,10 @@ class NIC(SimComponent): """Attempt to enable the NIC.""" if not self.enabled: if self.connected_node: - if self.connected_node.hardware_state == NodeOperatingState.ON: + if self.connected_node.operating_state == NodeOperatingState.ON: self.enabled = True _LOGGER.info(f"NIC {self} enabled") - self.pcap = PCAP(hostname=self.connected_node.hostname, ip_address=self.ip_address) + self.pcap = PacketCapture(hostname=self.connected_node.hostname, ip_address=self.ip_address) if self.connected_link: self.connected_link.endpoint_up() else: @@ -393,7 +393,7 @@ class Node(SimComponent): A basic Node class. :param hostname: The node hostname on the network. - :param hardware_state: The hardware state of the node. + :param operating_state: The node operating state. """ hostname: str diff --git a/src/primaite/simulator/system/applications/__init__.py b/src/primaite/simulator/system/applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py new file mode 100644 index 00000000..31a645b5 --- /dev/null +++ b/src/primaite/simulator/system/applications/application.py @@ -0,0 +1,86 @@ +from abc import abstractmethod +from enum import Enum +from typing import Any, List, Dict, Set + +from primaite.simulator.system.software import IOSoftware + + +class ApplicationOperatingState(Enum): + """Enumeration of Application Operating States.""" + + CLOSED = 0 + "The application is closed or not running." + RUNNING = 1 + "The application is running." + INSTALLING = 3 + "The application is being installed or updated." + + +class Application(IOSoftware): + """ + Represents an Application in the simulation environment. + + Applications are user-facing programs that may perform input/output operations. + """ + operating_state: ApplicationOperatingState + "The current operating state of the Application." + execution_control_status: str + "Control status of the application's execution. It could be 'manual' or 'automatic'." + num_executions: int = 0 + "The number of times the application has been executed. Default is 0." + groups: Set[str] = set() + "The set of groups to which the application belongs." + + @abstractmethod + 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 + """ + pass + + def apply_action(self, action: List[str]) -> None: + """ + Applies a list of actions to the Application. + + :param action: A list of actions to apply. + """ + pass + + def reset_component_for_episode(self, episode: int): + """ + Resets the Application component for a new episode. + + This method ensures the Application is ready for a new episode, including resetting any + stateful properties or statistics, and clearing any message queues. + """ + pass + + def send(self, payload: Any) -> bool: + """ + Sends a payload to the SessionManager + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param payload: The payload to send. + :return: True if successful, False otherwise. + """ + pass + + def receive(self, payload: Any) -> bool: + """ + Receives a payload from the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param payload: The payload to receive. + :return: True if successful, False otherwise. + """ + pass diff --git a/src/primaite/simulator/system/arp_cache.py b/src/primaite/simulator/system/arp_cache.py new file mode 100644 index 00000000..1fb830ab --- /dev/null +++ b/src/primaite/simulator/system/arp_cache.py @@ -0,0 +1,30 @@ +from ipaddress import IPv4Address + +from pydantic import BaseModel + + +class ARPCacheService(BaseModel): + def __init__(self, node): + super().__init__() + self.node = node + + def _add_arp_cache_entry(self, ip_address: IPv4Address, mac_address: str, nic: NIC): + ... + + def _remove_arp_cache_entry(self, ip_address: IPv4Address): + ... + + def _get_arp_cache_mac_address(self, ip_address: IPv4Address) -> Optional[str]: + ... + + def _get_arp_cache_nic(self, ip_address: IPv4Address) -> Optional[NIC]: + ... + + def _clear_arp_cache(self): + ... + + def _send_arp_request(self, target_ip_address: Union[IPv4Address, str]): + ... + + def process_arp_packet(self, from_nic: NIC, arp_packet: ARPPacket): + ... \ No newline at end of file diff --git a/src/primaite/simulator/system/processes/pcap.py b/src/primaite/simulator/system/packet_capture.py similarity index 78% rename from src/primaite/simulator/system/processes/pcap.py rename to src/primaite/simulator/system/packet_capture.py index c502adc8..c05b6db9 100644 --- a/src/primaite/simulator/system/processes/pcap.py +++ b/src/primaite/simulator/system/packet_capture.py @@ -8,24 +8,26 @@ class _JSONFilter(logging.Filter): return record.getMessage().startswith("{") and record.getMessage().endswith("}") -class PCAP: +class PacketCapture: """ - A logger class for logging Frames as json strings. + Represents a PacketCapture component on a Node in the simulation environment. - This is essentially a PrimAITE simulated version of PCAP. + PacketCapture is a service that logs Frames as json strings; It's Wireshark for PrimAITE. The PCAPs are logged to: //__pcap.log """ def __init__(self, hostname: str, ip_address: str): """ - Initialize the PCAP instance. + Initialize the PacketCapture process. :param hostname: The hostname for which PCAP logs are being recorded. :param ip_address: The IP address associated with the PCAP logs. """ - self.hostname = hostname - self.ip_address = str(ip_address) + self.hostname: str = hostname + "The hostname for which PCAP logs are being recorded." + self.ip_address: str = ip_address + "The IP address associated with the PCAP logs." self._setup_logger() def _setup_logger(self): @@ -51,7 +53,7 @@ class PCAP: root.mkdir(exist_ok=True, parents=True) return root / f"{self.hostname}_{self.ip_address}_pcap.log" - def capture(self, frame): # noqa Please don't make me, I'll have a circular import and cant use if TYPE_CHECKING ;( + def capture(self, frame): # noqa - I'll have a circular import and cant use if TYPE_CHECKING ;( """ Capture a Frame and log it. diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py new file mode 100644 index 00000000..68f3102f --- /dev/null +++ b/src/primaite/simulator/system/processes/process.py @@ -0,0 +1,37 @@ +from abc import abstractmethod +from enum import Enum +from typing import List, Dict, Any + +from primaite.simulator.system.software import Software + + +class ProcessOperatingState(Enum): + """Enumeration of Process Operating States.""" + + RUNNING = 1 + "The process is running." + PAUSED = 2 + "The process is temporarily paused." + + +class Process(Software): + """ + Represents a Process, a program in execution, in the simulation environment. + + Processes are executed by a Node and do not have the ability to performing input/output operations. + """ + operating_state: ProcessOperatingState + "The current operating state of the Process." + + @abstractmethod + 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 + """ + pass diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py new file mode 100644 index 00000000..a66249ad --- /dev/null +++ b/src/primaite/simulator/system/services/service.py @@ -0,0 +1,87 @@ +from abc import abstractmethod +from enum import Enum +from typing import Any, Dict, List + +from primaite.simulator.system.software import IOSoftware + + +class ServiceOperatingState(Enum): + """Enumeration of Service Operating States.""" + + STOPPED = 0 + "The service is not running." + RUNNING = 1 + "The service is currently running." + RESTARTING = 2 + "The service is in the process of restarting." + INSTALLING = 3 + "The service is being installed or updated." + PAUSED = 4 + "The service is temporarily paused." + DISABLED = 5 + "The service is disabled and cannot be started." + + +class Service(IOSoftware): + """ + Represents a Service in the simulation environment. + + Services are programs that run in the background and may perform input/output operations. + """ + operating_state: ServiceOperatingState + "The current operating state of the Service." + + @abstractmethod + 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 + """ + pass + + def apply_action(self, action: List[str]) -> None: + """ + Applies a list of actions to the Service. + + :param action: A list of actions to apply. + """ + pass + + def reset_component_for_episode(self, episode: int): + """ + Resets the Service component for a new episode. + + This method ensures the Service is ready for a new episode, including resetting any + stateful properties or statistics, and clearing any message queues. + """ + pass + + def send(self, payload: Any) -> bool: + """ + Sends a payload to the SessionManager + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param payload: The payload to send. + :return: True if successful, False otherwise. + """ + pass + + def receive(self, payload: Any) -> bool: + """ + Receives a payload from the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param payload: The payload to receive. + :return: True if successful, False otherwise. + """ + pass + diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index a5d0bd18..e5991429 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -1,6 +1,9 @@ +from abc import abstractmethod from enum import Enum +from typing import Any, Dict, List, Set from primaite.simulator.core import SimComponent +from primaite.simulator.network.transmission.transport_layer import Port class SoftwareHealthState(Enum): @@ -16,43 +19,6 @@ class SoftwareHealthState(Enum): "The software is undergoing patching or updates." -class ApplicationOperatingState(Enum): - """Enumeration of Application Operating States.""" - - CLOSED = 0 - "The application is closed or not running." - RUNNING = 1 - "The application is running." - INSTALLING = 3 - "The application is being installed or updated." - - -class ServiceOperatingState(Enum): - """Enumeration of Service Operating States.""" - - STOPPED = 0 - "The service is not running." - RUNNING = 1 - "The service is currently running." - RESTARTING = 2 - "The service is in the process of restarting." - INSTALLING = 3 - "The service is being installed or updated." - PAUSED = 4 - "The service is temporarily paused." - DISABLED = 5 - "The service is disabled and cannot be started." - - -class ProcessOperatingState(Enum): - """Enumeration of Process Operating States.""" - - RUNNING = 1 - "The process is running." - PAUSED = 2 - "The process is temporarily paused." - - class SoftwareCriticality(Enum): """Enumeration of Software Criticality Levels.""" @@ -70,25 +36,101 @@ class SoftwareCriticality(Enum): class Software(SimComponent): """ - Represents software information along with its health, criticality, and status. + A base class representing software in a simulator environment. - This class inherits from the Pydantic BaseModel and provides a structured way to store - information about software entities. - - Attributes: - name (str): The name of the software. - health_state_actual (SoftwareHealthState): The actual health state of the software. - health_state_visible (SoftwareHealthState): The health state of the software visible to users. - criticality (SoftwareCriticality): The criticality level of the software. - patching_count (int, optional): The count of patches applied to the software. Default is 0. - scanning_count (int, optional): The count of times the software has been scanned. Default is 0. - revealed_to_red (bool, optional): Indicates if the software has been revealed to red team. Default is False. + This class is intended to be subclassed by specific types of software entities. + It outlines the fundamental attributes and behaviors expected of any software in the simulation. """ - name: str + "The name of the software." health_state_actual: SoftwareHealthState + "The actual health state of the software." health_state_visible: SoftwareHealthState + "The health state of the software visible to the red agent." criticality: SoftwareCriticality + "The criticality level of the software." patching_count: int = 0 + "The count of patches applied to the software, defaults to 0." scanning_count: int = 0 + "The count of times the software has been scanned, defaults to 0." revealed_to_red: bool = False + "Indicates if the software has been revealed to red agent, defaults is False." + + @abstractmethod + 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 + """ + pass + + def apply_action(self, action: List[str]) -> None: + """ + Applies a list of actions to the software. + + The specifics of how these actions are applied should be implemented in subclasses. + + :param action: A list of actions to apply. + :type action: List[str] + """ + pass + + def reset_component_for_episode(self, episode: int): + """ + Resets the software component for a new episode. + + This method should ensure the software is ready for a new episode, including resetting any + stateful properties or statistics, and clearing any message queues. The specifics of what constitutes a + "reset" should be implemented in subclasses. + """ + pass + + +class IOSoftware(Software): + """ + Represents software in a simulator environment that is capable of input/output operations. + + This base class is meant to be sub-classed by Application and Service classes. It provides the blueprint for + Applications and Services that can receive payloads from a Node's SessionManager (corresponding to layer 5 in the + OSI Model), process them according to their internals, and send a response payload back to the SessionManager if + required. + """ + installing_count: int = 0 + "The number of times the software has been installed. Default is 0." + max_sessions: int = 1 + "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." + udp: bool = True + "Indicates if the software uses UDP protocol for communication. Default is True." + ports: Set[Port] + "The set of ports to which the software is connected." + + def send(self, payload: Any) -> bool: + """ + Sends a payload to the SessionManager + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param payload: The payload to send. + :return: True if successful, False otherwise. + """ + pass + + def receive(self, payload: Any) -> bool: + """ + Receives a payload from the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param payload: The payload to receive. + :return: True if successful, False otherwise. + """ + pass diff --git a/src/primaite/simulator/system/processes/sys_log.py b/src/primaite/simulator/system/sys_log.py similarity index 51% rename from src/primaite/simulator/system/processes/sys_log.py rename to src/primaite/simulator/system/sys_log.py index 27b35505..bb2fd7ec 100644 --- a/src/primaite/simulator/system/processes/sys_log.py +++ b/src/primaite/simulator/system/sys_log.py @@ -4,28 +4,36 @@ from pathlib import Path class _NotJSONFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: - """Filter logs that do not start and end with '{' and '}'.""" + """ + Determines if a log message does not start and end with '{' and '}' (i.e., it is not a JSON-like message). + + :param record: LogRecord object containing all the information pertinent to the event being logged. + :return: True if log message is not JSON-like, False otherwise. + """ return not record.getMessage().startswith("{") and not record.getMessage().endswith("}") class SysLog: """ - A simple logger class for writing the sys logs of a Node. + A SysLog class is a simple logger dedicated to managing and writing system logs for a Node. - Logs are logged to: //_sys.log + Each log message is written to a file located at: //_sys.log """ def __init__(self, hostname: str): """ - Initialize the SysLog instance. + Constructs a SysLog instance for a given hostname. - :param hostname: The hostname for which logs are being recorded. + :param hostname: The hostname associated with the system logs being recorded. """ self.hostname = hostname self._setup_logger() def _setup_logger(self): - """Set up the logger configuration.""" + """ + Configures the logger for this SysLog instance. The logger is set to the DEBUG level, + and is equipped with a handler that writes to a file and filters out JSON-like messages. + """ log_path = self._get_log_path() file_handler = logging.FileHandler(filename=log_path) @@ -41,47 +49,51 @@ class SysLog: self.logger.addFilter(_NotJSONFilter()) def _get_log_path(self) -> Path: - """Get the path for the log file.""" + """ + Constructs the path for the log file based on the hostname. + + :return: Path object representing the location of the log file. + """ root = Path(__file__).parent.parent.parent.parent.parent.parent / "simulation_output" / self.hostname root.mkdir(exist_ok=True, parents=True) return root / f"{self.hostname}_sys.log" def debug(self, msg: str): """ - Log a debug message. + Logs a message with the DEBUG level. - :param msg: The message to log. + :param msg: The message to be logged. """ self.logger.debug(msg) def info(self, msg: str): """ - Log an info message. + Logs a message with the INFO level. - :param msg: The message to log. + :param msg: The message to be logged. """ self.logger.info(msg) def warning(self, msg: str): """ - Log a warning message. + Logs a message with the WARNING level. - :param msg: The message to log. + :param msg: The message to be logged. """ self.logger.warning(msg) def error(self, msg: str): """ - Log an error message. + Logs a message with the ERROR level. - :param msg: The message to log. + :param msg: The message to be logged. """ self.logger.error(msg) def critical(self, msg: str): """ - Log a critical message. + Logs a message with the CRITICAL level. - :param msg: The message to log. + :param msg: The message to be logged. """ self.logger.critical(msg) diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py index 82e97049..9681e72d 100644 --- a/tests/integration_tests/network/test_frame_transmission.py +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -41,4 +41,4 @@ def test_multi_nic(): node_a.ping("192.168.0.11") - node_c.ping("10.0.0.12") + node_c.ping("10.0.0.12") \ No newline at end of file From 46c70ac084631f1321fd8b9b4b5478da0ef83448 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Thu, 3 Aug 2023 22:20:14 +0100 Subject: [PATCH 13/63] #1714: refactor private attributes and made them public + serialisation tests --- .../simulator/file_system/file_system.py | 18 +++++------ .../simulator/file_system/file_system_file.py | 31 ++++++++++++------- .../file_system/file_system_folder.py | 30 +++++++++--------- .../_file_system/test_file_system.py | 15 +++++++++ .../_file_system/test_file_system_file.py | 9 ++++++ .../_file_system/test_file_system_folder.py | 22 +++++++++++-- 6 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index 6af6db3e..e2f89809 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -1,7 +1,5 @@ from typing import Dict, List, Optional -from pydantic import PrivateAttr - from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file import FileSystemFile from primaite.simulator.file_system.file_system_file_type import FileSystemFileType @@ -11,7 +9,7 @@ from primaite.simulator.file_system.file_system_folder import FileSystemFolder class FileSystem(SimComponent): """Class that contains all the simulation File System.""" - _folders: List[FileSystemFolder] = PrivateAttr([]) + folders: List[FileSystemFolder] = [] """List containing all the folders in the file system.""" def describe_state(self) -> Dict: @@ -24,7 +22,7 @@ class FileSystem(SimComponent): def get_folders(self) -> List[FileSystemFolder]: """Returns the list of folders.""" - return self._folders + return self.folders def create_file(self, file_size: float, folder_uuid: Optional[str] = None) -> FileSystemFile: """ @@ -40,7 +38,7 @@ class FileSystem(SimComponent): file = FileSystemFile(item_parent=folder.uuid, file_size=file_size, file_type=FileSystemFileType.TBD) folder.add_file(file) - self._folders.append(folder) + self.folders.append(folder) else: # otherwise check for existence and add file folder = self.get_folder_by_id(folder_uuid) @@ -52,7 +50,7 @@ class FileSystem(SimComponent): def create_folder(self) -> FileSystemFolder: """Creates a FileSystemFolder and adds it to the list of folders.""" folder = FileSystemFolder(item_parent=None) - self._folders.append(folder) + self.folders.append(folder) return folder def delete_file(self, file_id: str): @@ -63,7 +61,7 @@ class FileSystem(SimComponent): :type file_id: str """ # iterate through folders to delete the item with the matching uuid - for folder in self._folders: + for folder in self.folders: folder.remove_file(file_id) def delete_folder(self, folder_id: str): @@ -73,7 +71,7 @@ class FileSystem(SimComponent): :param folder_id: The UUID of the file item to delete :type folder_id: str """ - self._folders = list(filter(lambda f: (f.uuid != folder_id), self._folders)) + self.folders = list(filter(lambda f: (f.uuid != folder_id), self.folders)) def move_file(self, src_folder_id: str, target_folder_id: str, file_id: str): """Moves a file from one folder to another.""" @@ -118,11 +116,11 @@ class FileSystem(SimComponent): def get_file_by_id(self, file_id: str) -> FileSystemFile: """Checks if the file exists in any file system folders.""" - for folder in self._folders: + for folder in self.folders: file = folder.get_file(file_id=file_id) if file is not None: return file def get_folder_by_id(self, folder_id: str) -> FileSystemFolder: """Checks if the folder exists.""" - return next((f for f in self._folders if f.uuid == folder_id), None) + return next((f for f in self.folders if f.uuid == folder_id), None) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index bebaa223..f10ae0ad 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -1,7 +1,6 @@ +from random import choice, random from typing import Dict -from pydantic import PrivateAttr - from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file_type import FileSystemFileType @@ -9,34 +8,42 @@ from primaite.simulator.file_system.file_system_file_type import FileSystemFileT class FileSystemFile(SimComponent): """Class that represents a file in the simulation.""" - _file_type: FileSystemFileType = PrivateAttr() + file_type: FileSystemFileType = None """The type of the FileSystemFile""" - _file_size: float = PrivateAttr() + file_size: float = 0 """Disk size of the FileSystemItem""" - def __init__(self, file_type: FileSystemFileType, file_size: float, **kwargs): + def __init__(self, **kwargs): """ Initialise FileSystemFile class. - :param item_parent: The UUID of the FileSystemItem parent - :type item_parent: str + :param file_type: The FileSystemFileType of the file + :type file_type: Optional[FileSystemFileType] :param file_size: The size of the FileSystemItem - :type file_size: float + :type file_size: Optional[float] """ super().__init__(**kwargs) - self._file_type = file_type - self._file_size = file_size + self.file_type = choice(list(FileSystemFileType)) + self.file_size = random() + + # set random file size if non provided + if kwargs.get("file_size") is not None: + self.file_size = kwargs.get("file_size") + + # set random file type if none provided + if kwargs.get("file_type") is None: + self.file_type = kwargs.get("file_type") def get_file_size(self) -> float: """Returns the size of the file system item.""" - return self._file_size + return self.file_size def get_file_type(self) -> FileSystemFileType: """Returns the FileSystemFileType of the file.""" - return self._file_type + return self.file_type def describe_state(self) -> Dict: """ diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 248a4f98..a2bcd226 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -1,7 +1,5 @@ from typing import Dict, List -from pydantic import PrivateAttr - from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file import FileSystemFile @@ -9,53 +7,53 @@ from primaite.simulator.file_system.file_system_file import FileSystemFile class FileSystemFolder(SimComponent): """Simulation FileSystemFolder.""" - _files: List[FileSystemFile] = PrivateAttr([]) + files: List[FileSystemFile] = [] """List of files stored in the folder.""" - _folder_size: float = PrivateAttr(0) + folder_size: float = 0 """The current size of the folder""" - _is_quarantined: bool = PrivateAttr(False) + is_quarantined: bool = False """Flag that marks the folder as quarantined if true.""" def get_files(self) -> List[FileSystemFile]: """Returns the list of files the folder contains.""" - return self._files + return self.files def get_file(self, file_id: str) -> FileSystemFile: """Return a FileSystemFile with the matching id.""" - return next((f for f in self._files if f.uuid == file_id), None) + return next((f for f in self.files if f.uuid == file_id), None) def add_file(self, file: FileSystemFile): """Adds a file to the folder list.""" - self._folder_size += file.get_file_size() + self.folder_size += file.get_file_size() # add to list - self._files.append(file) + self.files.append(file) def remove_file(self, file_id: str): """Removes a file from the folder list.""" - file = next((f for f in self._files if f.uuid == file_id), None) - self._files.remove(file) + file = next((f for f in self.files if f.uuid == file_id), None) + self.files.remove(file) # remove folder size from folder - self._folder_size -= file.get_file_size() + self.folder_size -= file.get_file_size() def get_folder_size(self) -> float: """Returns a sum of all file sizes in the files list.""" - return sum([file.get_file_size() for file in self._files]) + return sum([file.get_file_size() for file in self.files]) def quarantine(self): """Quarantines the File System Folder.""" - self._is_quarantined = True + self.is_quarantined = True def end_quarantine(self): """Ends the quarantine of the File System Folder.""" - self._is_quarantined = False + self.is_quarantined = False def quarantine_status(self) -> bool: """Returns true if the folder is being quarantined.""" - return self._is_quarantined + return self.is_quarantined def describe_state(self) -> Dict: """ diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py index 7b26f707..e19b2bf5 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -78,3 +78,18 @@ def test_copy_file(): assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 1 + + +def test_serialisation(): + """Test to check that the object serialisation works correctly.""" + file_system = FileSystem() + folder = file_system.create_folder() + assert len(file_system.get_folders()) is 1 + + file_system.create_file(file_size=10, folder_uuid=folder.uuid) + assert len(file_system.get_folders()[0].get_files()) is 1 + + serialised_file_sys = file_system.model_dump_json() + deserialised_file_sys = FileSystem().model_validate_json(serialised_file_sys) + + assert file_system == deserialised_file_sys diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py index 34c8dd94..31967f31 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py @@ -12,3 +12,12 @@ def test_get_file_size(): """Tests that the file size is being returned properly.""" file = FileSystemFile(file_size=1.5, file_type=FileSystemFileType.TBD) assert file.get_file_size() is 1.5 + + +def test_serialisation(): + """Test to check that the object serialisation works correctly.""" + file = FileSystemFile(file_size=1.5, file_type=FileSystemFileType.TBD) + serialised_file = file.model_dump_json() + deserialised_file = FileSystemFile().model_validate_json(serialised_file) + + assert file == deserialised_file diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py index b67ea385..c04465c3 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py @@ -4,6 +4,7 @@ from primaite.simulator.file_system.file_system_folder import FileSystemFolder def test_adding_removing_file(): + """Test the adding and removing of a file from a folder.""" folder = FileSystemFolder() file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) @@ -18,18 +19,22 @@ def test_adding_removing_file(): def test_get_file_by_id(): + """Test to make sure that the correct file is returned.""" folder = FileSystemFolder() file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) + file2 = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) folder.add_file(file) - assert folder.get_folder_size() is 10 - assert len(folder.get_files()) is 1 + folder.add_file(file2) + assert folder.get_folder_size() is 20 + assert len(folder.get_files()) is 2 assert folder.get_file(file_id=file.uuid) is file def test_folder_quarantine_state(): + """Tests the changing of folder quarantine status.""" folder = FileSystemFolder() assert folder.quarantine_status() is False @@ -39,3 +44,16 @@ def test_folder_quarantine_state(): folder.end_quarantine() assert folder.quarantine_status() is False + + +def test_serialisation(): + """Test to check that the object serialisation works correctly.""" + folder = FileSystemFolder() + file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) + folder.add_file(file) + + serialised_folder = folder.model_dump_json() + + deserialised_folder = FileSystemFolder().model_validate_json(serialised_folder) + + assert folder == deserialised_folder From 028211d2881290ca160a663f109f0a2b8d403534 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 7 Aug 2023 09:34:59 +0100 Subject: [PATCH 14/63] #1714: update to use objects instead of uuids + tests --- .../simulator/file_system/file_system.py | 158 +++++++++++++----- .../simulator/file_system/file_system_file.py | 37 ++-- .../file_system/file_system_file_type.py | 8 +- .../file_system/file_system_folder.py | 33 ++-- .../file_system/file_system_item_abc.py | 13 ++ .../_file_system/test_file_system.py | 36 ++-- .../_file_system/test_file_system_file.py | 10 +- .../_file_system/test_file_system_folder.py | 20 +-- 8 files changed, 211 insertions(+), 104 deletions(-) create mode 100644 src/primaite/simulator/file_system/file_system_item_abc.py diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index e2f89809..f8ae9d67 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -1,3 +1,4 @@ +from random import choice from typing import Dict, List, Optional from primaite.simulator.core import SimComponent @@ -24,95 +25,148 @@ class FileSystem(SimComponent): """Returns the list of folders.""" return self.folders - def create_file(self, file_size: float, folder_uuid: Optional[str] = None) -> FileSystemFile: + def create_file( + self, + file_name: str, + file_size: Optional[float] = None, + file_type: Optional[FileSystemFileType] = None, + folder: Optional[FileSystemFolder] = None, + folder_uuid: Optional[str] = None, + ) -> FileSystemFile: """ Creates a FileSystemFile and adds it to the list of files. + If no file_size or file_type are provided, one will be chosen randomly. + If no folder_uuid or folder is provided, a new folder will be created. + + :param: file_name: The file name + :type: file_name: str + + :param: file_size: The size the file takes on disk. + :type: file_size: Optional[float] + + :param: file_type: The type of the file + :type: Optional[FileSystemFileType] + :param: folder_uuid: The uuid of the folder to add the file to - :type: folder_uuid: str + :type: folder_uuid: Optional[str] """ file = None - # if no folder uuid provided, create a folder and add file to it - if folder_uuid is None: - folder = FileSystemFolder() + folder = None - file = FileSystemFile(item_parent=folder.uuid, file_size=file_size, file_type=FileSystemFileType.TBD) - folder.add_file(file) - self.folders.append(folder) - else: + if file_type is None: + file_type = self.get_random_file_type() + + # if no folder uuid provided, create a folder and add file to it + if folder_uuid is not None: # otherwise check for existence and add file folder = self.get_folder_by_id(folder_uuid) - if folder is not None: - file = FileSystemFile(file_size=file_size, file_type=FileSystemFileType.TBD) - folder.add_file(file=file) + + if folder is not None: + file = FileSystemFile(item_name=file_name, item_size=file_size, file_type=file_type) + folder.add_file(file=file) + else: + # check if a "root" folder exists + folder = self.get_folder_by_name("root") + if folder is None: + # create a root folder + folder = FileSystemFolder(item_name="root") + + # add file to root folder + file = FileSystemFile(item_name=file_name, item_size=file_size, file_type=file_type) + folder.add_file(file) + self.folders.append(folder) return file - def create_folder(self) -> FileSystemFolder: + def create_folder( + self, + folder_name: str, + ) -> FileSystemFolder: """Creates a FileSystemFolder and adds it to the list of folders.""" - folder = FileSystemFolder(item_parent=None) + folder = FileSystemFolder(item_name=folder_name) self.folders.append(folder) return folder - def delete_file(self, file_id: str): + def delete_file(self, file: Optional[FileSystemFile] = None): """ Deletes a file and removes it from the files list. + :param file: The file to delete + :type file: Optional[FileSystemFile] + :param file_id: The UUID of the file item to delete - :type file_id: str + :type file_id: Optional[str] """ # iterate through folders to delete the item with the matching uuid for folder in self.folders: - folder.remove_file(file_id) + folder.remove_file(file=file) - def delete_folder(self, folder_id: str): + def delete_folder(self, folder: FileSystemFolder): """ Deletes a folder, removes it from the folders list and removes any child folders and files. - :param folder_id: The UUID of the file item to delete - :type folder_id: str + :param folder: The folder to remove + :type folder: FileSystemFolder """ - self.folders = list(filter(lambda f: (f.uuid != folder_id), self.folders)) + self.folders.remove(folder) - def move_file(self, src_folder_id: str, target_folder_id: str, file_id: str): - """Moves a file from one folder to another.""" - # check that both folders and the file exists - src = self.get_folder_by_id(src_folder_id) - target = self.get_folder_by_id(target_folder_id) + def move_file(self, file: FileSystemFile, src_folder: FileSystemFolder, target_folder: FileSystemFolder): + """ + Moves a file from one folder to another. - if src is None: - raise Exception(f"src folder with UUID {src_folder_id} could not be found") + can provide - if target is None: - raise Exception(f"src folder with UUID {target_folder_id} could not be found") + :param: file: The file to move + :type: file: FileSystemFile + + :param: src_folder: The folder where the file is located + :type: FileSystemFolder + + :param: target_folder: The folder where the file should be moved to + :type: FileSystemFolder + """ + # check that the folders exist + if src_folder is None: + raise Exception("Source folder not provided") + + if target_folder is None: + raise Exception("Target folder not provided") - file = src.get_file(file_id=file_id) if file is None: - raise Exception(f"file with UUID {file_id} could not be found") + raise Exception("File to be moved is None") # remove file from src - src.remove_file(file_id) + src_folder.remove_file(file) # add file to target - target.add_file(file) + target_folder.add_file(file) - def copy_file(self, src_folder_id: str, target_folder_id: str, file_id: str): - """Copies a file from one folder to another.""" - # check that both folders and the file exists - src = self.get_folder_by_id(src_folder_id) - target = self.get_folder_by_id(target_folder_id) + def copy_file(self, file: FileSystemFile, src_folder: FileSystemFolder, target_folder: FileSystemFolder): + """ + Copies a file from one folder to another. - if src is None: - raise Exception(f"src folder with UUID {src_folder_id} could not be found") + can provide - if target is None: - raise Exception(f"src folder with UUID {target_folder_id} could not be found") + :param: file: The file to move + :type: file: FileSystemFile + + :param: src_folder: The folder where the file is located + :type: FileSystemFolder + + :param: target_folder: The folder where the file should be moved to + :type: FileSystemFolder + """ + if src_folder is None: + raise Exception("Source folder not provided") + + if target_folder is None: + raise Exception("Target folder not provided") - file = src.get_file(file_id=file_id) if file is None: - raise Exception(f"file with UUID {file_id} could not be found") + raise Exception("File to be moved is None") # add file to target - target.add_file(file) + target_folder.add_file(file) def get_file_by_id(self, file_id: str) -> FileSystemFile: """Checks if the file exists in any file system folders.""" @@ -121,6 +175,18 @@ class FileSystem(SimComponent): if file is not None: return file + def get_folder_by_name(self, folder_name: str) -> FileSystemFolder: + """ + Returns a the first folder with a matching name. + + :return: Returns the first FileSydtemFolder with a matching name + """ + return next((f for f in self.folders if f.get_folder_name() == folder_name), None) + def get_folder_by_id(self, folder_id: str) -> FileSystemFolder: """Checks if the folder exists.""" return next((f for f in self.folders if f.uuid == folder_id), None) + + def get_random_file_type(self) -> FileSystemFileType: + """Returns a random FileSystemFileTypeEnum.""" + return choice(list(FileSystemFileType)) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index f10ae0ad..441a27b0 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -1,45 +1,54 @@ -from random import choice, random +from random import choice, randint from typing import Dict -from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file_type import FileSystemFileType +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC -class FileSystemFile(SimComponent): +class FileSystemFile(FileSystemItemABC): """Class that represents a file in the simulation.""" file_type: FileSystemFileType = None """The type of the FileSystemFile""" - file_size: float = 0 - """Disk size of the FileSystemItem""" - def __init__(self, **kwargs): """ Initialise FileSystemFile class. + :param item_name: The name of the file. + :type item_name: str + :param file_type: The FileSystemFileType of the file :type file_type: Optional[FileSystemFileType] - :param file_size: The size of the FileSystemItem - :type file_size: Optional[float] + :param item_size: The size of the FileSystemItem + :type item_size: Optional[float] """ super().__init__(**kwargs) - self.file_type = choice(list(FileSystemFileType)) - self.file_size = random() + # set random file type if none provided + if kwargs.get("item_name") is None: + raise Exception("File name not provided.") # set random file size if non provided - if kwargs.get("file_size") is not None: - self.file_size = kwargs.get("file_size") + if kwargs.get("item_size") is None: + kwargs["item_size"] = float(randint(1, 1000)) # set random file type if none provided if kwargs.get("file_type") is None: - self.file_type = kwargs.get("file_type") + kwargs["file_type"] = choice(list(FileSystemFileType)) + + self.item_name = kwargs.get("item_name") + self.item_size = kwargs.get("item_size") + self.file_type = kwargs.get("file_type") + + def get_file_name(self) -> str: + """Returns the name of the file.""" + return self.item_name def get_file_size(self) -> float: """Returns the size of the file system item.""" - return self.file_size + return self.item_size def get_file_type(self) -> FileSystemFileType: """Returns the FileSystemFileType of the file.""" diff --git a/src/primaite/simulator/file_system/file_system_file_type.py b/src/primaite/simulator/file_system/file_system_file_type.py index 134b38f4..fd11f30f 100644 --- a/src/primaite/simulator/file_system/file_system_file_type.py +++ b/src/primaite/simulator/file_system/file_system_file_type.py @@ -4,4 +4,10 @@ from enum import Enum class FileSystemFileType(str, Enum): """Enum used to determine the FileSystemFile type.""" - TBD = "TBD" + CSV = ("CSV",) + DOC = ("DOC",) + EXE = ("EXE",) + PDF = ("PDF",) + TXT = ("TXT",) + XML = ("XML",) + ZIP = "ZIP" diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index a2bcd226..2d0b0eb0 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -1,21 +1,26 @@ -from typing import Dict, List +from typing import Dict, List, Optional -from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file import FileSystemFile +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC -class FileSystemFolder(SimComponent): +class FileSystemFolder(FileSystemItemABC): """Simulation FileSystemFolder.""" files: List[FileSystemFile] = [] """List of files stored in the folder.""" - folder_size: float = 0 - """The current size of the folder""" - is_quarantined: bool = False """Flag that marks the folder as quarantined if true.""" + def get_folder_name(self) -> str: + """Returns the item_name of the folder.""" + return self.item_name + + def get_folder_size(self) -> float: + """Returns the item_size of the folder.""" + return self.item_size + def get_files(self) -> List[FileSystemFile]: """Returns the list of files the folder contains.""" return self.files @@ -26,18 +31,24 @@ class FileSystemFolder(SimComponent): def add_file(self, file: FileSystemFile): """Adds a file to the folder list.""" - self.folder_size += file.get_file_size() + self.item_size += file.get_file_size() # add to list self.files.append(file) - def remove_file(self, file_id: str): - """Removes a file from the folder list.""" - file = next((f for f in self.files if f.uuid == file_id), None) + def remove_file(self, file: Optional[FileSystemFile]): + """ + Removes a file from the folder list. + + The method can take a FileSystemFile object or a file id. + + :param: file: The file to remove + :type: Optional[FileSystemFile] + """ self.files.remove(file) # remove folder size from folder - self.folder_size -= file.get_file_size() + self.item_size -= file.get_file_size() def get_folder_size(self) -> float: """Returns a sum of all file sizes in the files list.""" diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py new file mode 100644 index 00000000..4dca0f4e --- /dev/null +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -0,0 +1,13 @@ +from abc import ABC + +from primaite.simulator.core import SimComponent + + +class FileSystemItemABC(SimComponent, ABC): + """Abstract base class for FileSystemItems used in the file system simulation.""" + + item_size: float = 0 + """The size the item takes up on disk.""" + + item_name: str + """The name of the file system item.""" diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py index e19b2bf5..dae8b34e 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -4,18 +4,20 @@ from primaite.simulator.file_system.file_system import FileSystem def test_create_folder_and_file(): """Test creating a folder and a file.""" file_system = FileSystem() - folder = file_system.create_folder() + folder = file_system.create_folder(folder_name="test_folder") assert len(file_system.get_folders()) is 1 - file_system.create_file(file_size=10, folder_uuid=folder.uuid) + file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) assert len(file_system.get_folders()[0].get_files()) is 1 + assert file_system.get_folders()[0].get_files()[0].get_file_name() is "test_file" + assert file_system.get_folders()[0].get_files()[0].get_file_size() is 10 def test_create_file(): """Tests that creating a file without a folder creates a folder and sets that as the file's parent.""" file_system = FileSystem() - file = file_system.create_file(file_size=10) + file = file_system.create_file(file_name="test_file", file_size=10) assert len(file_system.get_folders()) is 1 assert file_system.get_folders()[0].get_file(file.uuid) is file @@ -24,38 +26,38 @@ def test_delete_file(): """Tests that a file can be deleted.""" file_system = FileSystem() - file = file_system.create_file(file_size=10) + file = file_system.create_file(file_name="test_file", file_size=10) assert len(file_system.get_folders()) is 1 assert file_system.get_folders()[0].get_file(file.uuid) is file - file_system.delete_file(file.uuid) + file_system.delete_file(file=file) assert len(file_system.get_folders()) is 1 assert len(file_system.get_folders()[0].get_files()) is 0 def test_delete_folder(): file_system = FileSystem() - folder = file_system.create_folder() + folder = file_system.create_folder(folder_name="test_folder") assert len(file_system.get_folders()) is 1 - file_system.delete_folder(folder.uuid) + file_system.delete_folder(folder) assert len(file_system.get_folders()) is 0 def test_move_file(): """Tests the file move function.""" file_system = FileSystem() - src_folder = file_system.create_folder() + src_folder = file_system.create_folder(folder_name="test_folder_1") assert len(file_system.get_folders()) is 1 - target_folder = file_system.create_folder() + target_folder = file_system.create_folder(folder_name="test_folder_2") assert len(file_system.get_folders()) is 2 - file = file_system.create_file(file_size=10, folder_uuid=src_folder.uuid) + file = file_system.create_file(file_name="test_file", file_size=10, folder_uuid=src_folder.uuid) assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 0 - file_system.move_file(src_folder.uuid, target_folder.uuid, file.uuid) + file_system.move_file(file=file, src_folder=src_folder, target_folder=target_folder) assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 0 assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 1 @@ -64,17 +66,17 @@ def test_move_file(): def test_copy_file(): """Tests the file copy function.""" file_system = FileSystem() - src_folder = file_system.create_folder() + src_folder = file_system.create_folder(folder_name="test_folder_1") assert len(file_system.get_folders()) is 1 - target_folder = file_system.create_folder() + target_folder = file_system.create_folder(folder_name="test_folder_2") assert len(file_system.get_folders()) is 2 - file = file_system.create_file(file_size=10, folder_uuid=src_folder.uuid) + file = file_system.create_file(file_name="test_file", file_size=10, folder_uuid=src_folder.uuid) assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 0 - file_system.copy_file(src_folder.uuid, target_folder.uuid, file.uuid) + file_system.copy_file(file=file, src_folder=src_folder, target_folder=target_folder) assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 1 @@ -83,10 +85,10 @@ def test_copy_file(): def test_serialisation(): """Test to check that the object serialisation works correctly.""" file_system = FileSystem() - folder = file_system.create_folder() + folder = file_system.create_folder(folder_name="test_folder") assert len(file_system.get_folders()) is 1 - file_system.create_file(file_size=10, folder_uuid=folder.uuid) + file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) assert len(file_system.get_folders()[0].get_files()) is 1 serialised_file_sys = file_system.model_dump_json() diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py index 31967f31..669be62d 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py @@ -4,20 +4,20 @@ from primaite.simulator.file_system.file_system_file_type import FileSystemFileT def test_file_type(): """Tests tha the FileSystemFile type is set correctly.""" - file = FileSystemFile(file_size=1.5, file_type=FileSystemFileType.TBD) - assert file.get_file_type() is FileSystemFileType.TBD + file = FileSystemFile(item_name="test", file_type=FileSystemFileType.DOC) + assert file.get_file_type() is FileSystemFileType.DOC def test_get_file_size(): """Tests that the file size is being returned properly.""" - file = FileSystemFile(file_size=1.5, file_type=FileSystemFileType.TBD) + file = FileSystemFile(item_name="test", item_size=1.5) assert file.get_file_size() is 1.5 def test_serialisation(): """Test to check that the object serialisation works correctly.""" - file = FileSystemFile(file_size=1.5, file_type=FileSystemFileType.TBD) + file = FileSystemFile(item_name="test", item_size=1.5, file_type=FileSystemFileType.DOC) serialised_file = file.model_dump_json() - deserialised_file = FileSystemFile().model_validate_json(serialised_file) + deserialised_file = FileSystemFile(item_name="test").model_validate_json(serialised_file) assert file == deserialised_file diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py index c04465c3..f9ba80f0 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py @@ -5,25 +5,25 @@ from primaite.simulator.file_system.file_system_folder import FileSystemFolder def test_adding_removing_file(): """Test the adding and removing of a file from a folder.""" - folder = FileSystemFolder() + folder = FileSystemFolder(item_name="test") - file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) + file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) assert folder.get_folder_size() is 10 assert len(folder.get_files()) is 1 - folder.remove_file(file_id=file.uuid) + folder.remove_file(file) assert folder.get_folder_size() is 0 assert len(folder.get_files()) is 0 def test_get_file_by_id(): """Test to make sure that the correct file is returned.""" - folder = FileSystemFolder() + folder = FileSystemFolder(item_name="test") - file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) - file2 = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) + file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) + file2 = FileSystemFile(item_name="test_file_2", item_size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) folder.add_file(file2) @@ -35,7 +35,7 @@ def test_get_file_by_id(): def test_folder_quarantine_state(): """Tests the changing of folder quarantine status.""" - folder = FileSystemFolder() + folder = FileSystemFolder(item_name="test") assert folder.quarantine_status() is False @@ -48,12 +48,12 @@ def test_folder_quarantine_state(): def test_serialisation(): """Test to check that the object serialisation works correctly.""" - folder = FileSystemFolder() - file = FileSystemFile(file_size=10, file_type=FileSystemFileType.TBD) + folder = FileSystemFolder(item_name="test") + file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) serialised_folder = folder.model_dump_json() - deserialised_folder = FileSystemFolder().model_validate_json(serialised_folder) + deserialised_folder = FileSystemFolder(item_name="test").model_validate_json(serialised_folder) assert folder == deserialised_folder From d57c2a936efd047a75cdae739894b8e4683eafb9 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 7 Aug 2023 10:10:05 +0100 Subject: [PATCH 15/63] #1714: remove duplicate method --- src/primaite/simulator/file_system/file_system_folder.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 2d0b0eb0..f5024966 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -50,10 +50,6 @@ class FileSystemFolder(FileSystemItemABC): # remove folder size from folder self.item_size -= file.get_file_size() - def get_folder_size(self) -> float: - """Returns a sum of all file sizes in the files list.""" - return sum([file.get_file_size() for file in self.files]) - def quarantine(self): """Quarantines the File System Folder.""" self.is_quarantined = True From f0d7e03fd7548305c70638007f5bfc73829a9154 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 7 Aug 2023 10:55:29 +0100 Subject: [PATCH 16/63] Add docs and tests --- docs/source/simulation_structure.rst | 52 +++++++++++++++++++ src/primaite/simulator/domain/account.py | 18 ++++--- src/primaite/simulator/domain/controller.py | 33 ++++++++++-- .../test_permission_system.py | 51 ++++++++++++------ .../_primaite/_simulator/_domain/__init__.py | 0 .../_simulator/_domain/test_account.py | 18 +++++++ .../_simulator/_domain/test_controller.py | 0 7 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_domain/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_domain/test_account.py create mode 100644 tests/unit_tests/_primaite/_simulator/_domain/test_controller.py diff --git a/docs/source/simulation_structure.rst b/docs/source/simulation_structure.rst index 65373a72..479b3e7b 100644 --- a/docs/source/simulation_structure.rst +++ b/docs/source/simulation_structure.rst @@ -11,3 +11,55 @@ top level, there is an object called the ``SimulationController`` _(doesn't exis and a software controller for managing software and users. Each node of the simulation 'tree' has responsibility for creating, deleting, and updating its direct descendants. + + + +Actions +======= +Agents can interact with the simulation by using actions. Actions are standardised with the +:py:class:`primaite.simulation.core.Action` class, which just holds a reference to two special functions. + +1. The action function itself, it must accept a `request` parameters which is a list of strings that describe what the + action should do. It must also accept a `context` dict which can house additional information surrounding the action. + For example, the context will typically include information about which entity intiated the action. +2. A validator function. This function should return a boolean value that decides if the request is permitted or not. + It uses the same paramters as the action function. + +Action Permissions +------------------ +When an agent tries to perform an action on a simulation component, that action will only be executed if the request is +validated. For example, some actions can require that an agent is logged into an admin account. Each action defines its +own permissions using an instance of :py:class:`primaite.simulation.core.ActionPermissionValidator`. The below code +snippet demonstrates usage of the ``ActionPermissionValidator``. + +.. code:: python + + from primaite.simulator.core import Action, ActionManager, SimComponent + from primaite.simulator.domain.controller import AccountGroup, GroupMembershipValidator + + class Smartphone(SimComponent): + name: str + apps = [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.action_manager = ActionManager() + + self.action_manager.add_action( + "reset_factory_settings", + Action( + func = lambda request, context: self.reset_factory_settings(), + validator = GroupMembershipValidator([AccountGroup.domain_admin]), + ), + ) + + def reset_factory_settings(self): + self.apps = [] + + phone = Smartphone(name="phone1") + + # try to wipe the phone as a domain user, this will have no effect + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["domain_user"]}) + + # try to wipe the phone as an admin user, this will wipe the phone + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["domain_admin"]}) diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index 0f59db2e..086022e6 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -1,6 +1,6 @@ """User account simulation.""" from enum import Enum -from typing import Any, Callable, Dict, List +from typing import Dict from primaite import getLogger from primaite.simulator.core import SimComponent @@ -49,6 +49,10 @@ class Account(SimComponent): "Account Type, currently this can be service account (used by apps) or user account." status: AccountStatus = AccountStatus.disabled + def describe_state(self) -> Dict: + """Describe state for agent observations.""" + return super().describe_state() + def enable(self): """Set the status to enabled.""" self.status = AccountStatus.enabled @@ -57,8 +61,10 @@ class Account(SimComponent): """Set the status to disabled.""" self.status = AccountStatus.disabled - def _possible_actions(self) -> Dict[str, Callable[[List[str]], None]]: - return { - "enable": self.enable, - "disable": self.disable, - } + def log_on(self) -> None: + """TODO.""" + self.num_logons += 1 + + def log_off(self) -> None: + """TODO.""" + self.num_logoffs += 1 diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 7cb3f4a6..e4a73b4e 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Dict, Final, List +from typing import Dict, Final, List, Literal, Tuple from primaite.simulator.core import ActionPermissionValidator, SimComponent from primaite.simulator.domain.account import Account, AccountType @@ -7,18 +7,26 @@ from primaite.simulator.domain.account import Account, AccountType # placeholder while these objects don't yet exist class temp_node: + """Placeholder for node class for type hinting purposes.""" + pass class temp_application: + """Placeholder for application class for type hinting purposes.""" + pass class temp_folder: + """Placeholder for folder class for type hinting purposes.""" + pass class temp_file: + """Placeholder for file class for type hinting purposes.""" + pass @@ -59,9 +67,12 @@ class DomainController(SimComponent): accounts: List[Account] = [] groups: Final[List[AccountGroup]] = list(AccountGroup) - group_membership: Dict[AccountGroup, List[Account]] + domain_group_membership: Dict[Literal[AccountGroup.domain_admin, AccountGroup.domain_user], List[Account]] = {} + local_group_membership: Dict[ + Tuple(temp_node, Literal[AccountGroup.local_admin, AccountGroup.local_user]), List[Account] + ] = {} - # references to non-owned objects + # references to non-owned objects. Not sure if all are needed here. nodes: List[temp_node] = [] applications: List[temp_application] = [] folders: List[temp_folder] = [] @@ -79,6 +90,10 @@ class DomainController(SimComponent): """TODO.""" ... + def delete_account(self, account: Account) -> None: + """TODO.""" + ... + def rotate_all_credentials(self) -> None: """TODO.""" ... @@ -94,3 +109,15 @@ class DomainController(SimComponent): def remove_account_from_group(self, account: Account, group: AccountGroup) -> None: """TODO.""" ... + + def check_account_permissions(self, account: Account, node: temp_node) -> List[AccountGroup]: + """Return a list of permission groups that this account has on this node.""" + ... + + def register_node(self, node: temp_node) -> None: + """TODO.""" + ... + + def deregister_node(self, node: temp_node) -> None: + """TODO.""" + ... diff --git a/tests/integration_tests/component_creation/test_permission_system.py b/tests/integration_tests/component_creation/test_permission_system.py index acc35b72..93d0267c 100644 --- a/tests/integration_tests/component_creation/test_permission_system.py +++ b/tests/integration_tests/component_creation/test_permission_system.py @@ -8,7 +8,13 @@ from primaite.simulator.domain.controller import AccountGroup, GroupMembershipVa def test_group_action_validation() -> None: - """Check that actions are denied when an unauthorised request is made.""" + """ + Check that actions are denied when an unauthorised request is made. + + This test checks the integration between SimComponent and the permissions validation system. First, we create a + basic node and folder class. We configure the node so that only admins can create a folder. Then, we try to create + a folder as both an admin user and a non-admin user. + """ class Folder(SimComponent): name: str @@ -42,22 +48,28 @@ def test_group_action_validation() -> None: def remove_folder(self, folder: Folder) -> None: self.folders = [x for x in self.folders if x is not folder] + # check that the folder is created when a local admin tried to do it permitted_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_admin"]}} - my_node = Node(uuid="0000-0000-1234", name="pc") my_node.apply_action(["create_folder", "memes"], context=permitted_context) assert len(my_node.folders) == 1 assert my_node.folders[0].name == "memes" + # check that the number of folders is still 1 even after attempting to create a second one without permissions invalid_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_user", "domain_user"]}} - my_node.apply_action(["create_folder", "memes2"], context=invalid_context) assert len(my_node.folders) == 1 assert my_node.folders[0].name == "memes" def test_hierarchical_action_with_validation() -> None: - """Check that validation works with sub-objects""" + """ + Check that validation works with sub-objects. + + This test creates a parent object (Node) and a child object (Application) which both accept actions. The node allows + action passthrough to applications. The purpose of this test is to check that after an action is passed through to + a child object, that the permission system still works as intended. + """ class Application(SimComponent): name: str @@ -100,19 +112,19 @@ def test_hierarchical_action_with_validation() -> None: return super().describe_state() def disable(self) -> None: - self.status = "disabled" + self.state = "disabled" def enable(self) -> None: - if self.status == "disabled": - self.status = "off" + if self.state == "disabled": + self.state = "off" def turn_on(self) -> None: - if self.status == "off": - self.status = "on" + if self.state == "off": + self.state = "on" def turn_off(self) -> None: - if self.status == "on": - self.status = "off" + if self.state == "on": + self.state = "off" class Node(SimComponent): name: str @@ -141,7 +153,7 @@ def test_hierarchical_action_with_validation() -> None: def send_action_to_app(self, app_name: str, options: List[str], context: Dict): for app in self.apps: if app_name == app.name: - app.apply_action(options) + app.apply_action(options, context) break else: msg = f"Node has no app with name {app_name}" @@ -163,9 +175,16 @@ def test_hierarchical_action_with_validation() -> None: } } + # check that a non-admin can't disable this app my_node.apply_action(["apps", "Chrome", "disable"], non_admin_context) - my_node.apply_action(["apps", "Firefox", "turn_on"], non_admin_context) + assert my_node.apps[0].name == "Chrome" # if failure occurs on this line, the test itself is broken + assert my_node.apps[0].state == "off" - assert my_node.apps[0].name == "Chrome" - assert my_node.apps[1].name == "Firefox" - assert my_node.apps[0].state == ... # TODO: finish + # check that a non-admin can turn this app on + my_node.apply_action(["apps", "Firefox", "turn_on"], non_admin_context) + assert my_node.apps[1].name == "Firefox" # if failure occurs on this line, the test itself is broken + assert my_node.apps[1].state == "on" + + # check that an admin can disable this app + my_node.apply_action(["apps", "Chrome", "disable"], admin_context) + assert my_node.apps[0].state == "disabled" diff --git a/tests/unit_tests/_primaite/_simulator/_domain/__init__.py b/tests/unit_tests/_primaite/_simulator/_domain/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py new file mode 100644 index 00000000..d4a57179 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py @@ -0,0 +1,18 @@ +"""Test the account module of the simulator.""" +from primaite.simulator.domain.account import Account, AccountType + + +def test_account_serialise(): + """Test that an account can be serialised.""" + acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.user) + serialised = acct.model_dump_json() + print(serialised) + + +def test_account_deserialise(): + """Test that an account can be deserialised.""" + acct_json = ( + '{"uuid":"dfb2bcaa-d3a1-48fd-af3f-c943354622b4","num_logons":0,"num_logoffs":0,"num_group_changes":0,' + '"username":"Jake","password":"JakePass1!","account_type":2,"status":2,"action_manager":null}' + ) + acct = Account.model_validate_json(acct_json) diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_controller.py b/tests/unit_tests/_primaite/_simulator/_domain/test_controller.py new file mode 100644 index 00000000..e69de29b From 84b6e2206e362e43b344084ced42a367f89824d5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 7 Aug 2023 10:18:27 +0000 Subject: [PATCH 17/63] Updated CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d66257b5..fbdd1d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Permission System - each agent action can define criteria that will be used to permit or deny agent actions. + + ## [2.0.0] - 2023-07-26 ### Added From 22afdc9134a5df947d12b74492e27a3c73f8502a Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 7 Aug 2023 10:19:06 +0000 Subject: [PATCH 18/63] Updated pull_request_template.md --- .azuredevops/pull_request_template.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.azuredevops/pull_request_template.md b/.azuredevops/pull_request_template.md index fd28ed57..f7533b37 100644 --- a/.azuredevops/pull_request_template.md +++ b/.azuredevops/pull_request_template.md @@ -9,5 +9,6 @@ - [ ] I have performed **self-review** of the code - [ ] I have written **tests** for any new functionality added with this PR - [ ] I have updated the **documentation** if this PR changes or adds functionality -- [ ] I have written/updated **design docs** if this PR implements new functionality. +- [ ] I have written/updated **design docs** if this PR implements new functionality +- [ ] I have update the **change log** - [ ] I have run **pre-commit** checks for code style From b58a3a3e24455707fbf5ae13d637565c7f30b9c6 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 7 Aug 2023 11:52:54 +0100 Subject: [PATCH 19/63] #1714: FileSystemItem is no longer an abstract base class + Added enums and enum sizes + stream lined FileSystemFile init --- .../simulator/file_system/file_system_file.py | 20 ++- .../file_system/file_system_file_type.py | 127 ++++++++++++++++-- .../file_system/file_system_folder.py | 4 +- .../file_system/file_system_item_abc.py | 8 +- .../_file_system/test_file_system.py | 4 +- .../_file_system/test_file_system_file.py | 4 +- .../_file_system/test_file_system_folder.py | 8 +- 7 files changed, 143 insertions(+), 32 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index 441a27b0..efb1ae93 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -1,11 +1,11 @@ from random import choice, randint from typing import Dict -from primaite.simulator.file_system.file_system_file_type import FileSystemFileType -from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC +from primaite.simulator.file_system.file_system_file_type import file_type_sizes_KB, FileSystemFileType +from primaite.simulator.file_system.file_system_item_abc import FileSystemItem -class FileSystemFile(FileSystemItemABC): +class FileSystemFile(FileSystemItem): """Class that represents a file in the simulation.""" file_type: FileSystemFileType = None @@ -24,23 +24,19 @@ class FileSystemFile(FileSystemItemABC): :param item_size: The size of the FileSystemItem :type item_size: Optional[float] """ - super().__init__(**kwargs) - # set random file type if none provided if kwargs.get("item_name") is None: raise Exception("File name not provided.") - # set random file size if non provided - if kwargs.get("item_size") is None: - kwargs["item_size"] = float(randint(1, 1000)) - # set random file type if none provided if kwargs.get("file_type") is None: kwargs["file_type"] = choice(list(FileSystemFileType)) - self.item_name = kwargs.get("item_name") - self.item_size = kwargs.get("item_size") - self.file_type = kwargs.get("file_type") + # set random file size if none provided + if kwargs.get("item_size") is None: + kwargs["item_size"] = float(randint(1, file_type_sizes_KB[kwargs["file_type"]])) + + super().__init__(**kwargs) def get_file_name(self) -> str: """Returns the name of the file.""" diff --git a/src/primaite/simulator/file_system/file_system_file_type.py b/src/primaite/simulator/file_system/file_system_file_type.py index fd11f30f..7e2d8706 100644 --- a/src/primaite/simulator/file_system/file_system_file_type.py +++ b/src/primaite/simulator/file_system/file_system_file_type.py @@ -2,12 +2,123 @@ from enum import Enum class FileSystemFileType(str, Enum): - """Enum used to determine the FileSystemFile type.""" + """An enumeration of common file types.""" - CSV = ("CSV",) - DOC = ("DOC",) - EXE = ("EXE",) - PDF = ("PDF",) - TXT = ("TXT",) - XML = ("XML",) - ZIP = "ZIP" + UNKNOWN = 0 + "Unknown file type." + + # Text formats + TXT = 1 + "Plain text file." + DOC = 2 + "Microsoft Word document (.doc)" + DOCX = 3 + "Microsoft Word document (.docx)" + PDF = 4 + "Portable Document Format." + HTML = 5 + "HyperText Markup Language file." + XML = 6 + "Extensible Markup Language file." + CSV = 7 + "Comma-Separated Values file." + + # Spreadsheet formats + XLS = 8 + "Microsoft Excel file (.xls)" + XLSX = 9 + "Microsoft Excel file (.xlsx)" + + # Image formats + JPEG = 10 + "JPEG image file." + PNG = 11 + "PNG image file." + GIF = 12 + "GIF image file." + BMP = 13 + "Bitmap image file." + + # Audio formats + MP3 = 14 + "MP3 audio file." + WAV = 15 + "WAV audio file." + + # Video formats + MP4 = 16 + "MP4 video file." + AVI = 17 + "AVI video file." + MKV = 18 + "MKV video file." + FLV = 19 + "FLV video file." + + # Presentation formats + PPT = 20 + "Microsoft PowerPoint file (.ppt)" + PPTX = 21 + "Microsoft PowerPoint file (.pptx)" + + # Web formats + JS = 22 + "JavaScript file." + CSS = 23 + "Cascading Style Sheets file." + + # Programming languages + PY = 24 + "Python script file." + C = 25 + "C source code file." + CPP = 26 + "C++ source code file." + JAVA = 27 + "Java source code file." + + # Compressed file types + RAR = 28 + "RAR archive file." + ZIP = 29 + "ZIP archive file." + TAR = 30 + "TAR archive file." + GZ = 31 + "Gzip compressed file." + + +file_type_sizes_KB = { + FileSystemFileType.UNKNOWN: 0, + FileSystemFileType.TXT: 4, + FileSystemFileType.DOC: 50, + FileSystemFileType.DOCX: 30, + FileSystemFileType.PDF: 100, + FileSystemFileType.HTML: 15, + FileSystemFileType.XML: 10, + FileSystemFileType.CSV: 15, + FileSystemFileType.XLS: 100, + FileSystemFileType.XLSX: 25, + FileSystemFileType.JPEG: 100, + FileSystemFileType.PNG: 40, + FileSystemFileType.GIF: 30, + FileSystemFileType.BMP: 300, + FileSystemFileType.MP3: 5000, + FileSystemFileType.WAV: 25000, + FileSystemFileType.MP4: 25000, + FileSystemFileType.AVI: 50000, + FileSystemFileType.MKV: 50000, + FileSystemFileType.FLV: 15000, + FileSystemFileType.PPT: 200, + FileSystemFileType.PPTX: 100, + FileSystemFileType.JS: 10, + FileSystemFileType.CSS: 5, + FileSystemFileType.PY: 5, + FileSystemFileType.C: 5, + FileSystemFileType.CPP: 10, + FileSystemFileType.JAVA: 10, + FileSystemFileType.RAR: 1000, + FileSystemFileType.ZIP: 1000, + FileSystemFileType.TAR: 1000, + FileSystemFileType.GZ: 800, +} diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index f5024966..a381e57d 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -1,10 +1,10 @@ from typing import Dict, List, Optional from primaite.simulator.file_system.file_system_file import FileSystemFile -from primaite.simulator.file_system.file_system_item_abc import FileSystemItemABC +from primaite.simulator.file_system.file_system_item_abc import FileSystemItem -class FileSystemFolder(FileSystemItemABC): +class FileSystemFolder(FileSystemItem): """Simulation FileSystemFolder.""" files: List[FileSystemFile] = [] diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py index 4dca0f4e..a1258665 100644 --- a/src/primaite/simulator/file_system/file_system_item_abc.py +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -1,9 +1,9 @@ -from abc import ABC +from typing import Dict from primaite.simulator.core import SimComponent -class FileSystemItemABC(SimComponent, ABC): +class FileSystemItem(SimComponent): """Abstract base class for FileSystemItems used in the file system simulation.""" item_size: float = 0 @@ -11,3 +11,7 @@ class FileSystemItemABC(SimComponent, ABC): item_name: str """The name of the file system item.""" + + def describe_state(self) -> Dict: + """Returns the state of the FileSystemItem.""" + pass diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py index dae8b34e..f4c1ccda 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -10,7 +10,7 @@ def test_create_folder_and_file(): file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) assert len(file_system.get_folders()[0].get_files()) is 1 assert file_system.get_folders()[0].get_files()[0].get_file_name() is "test_file" - assert file_system.get_folders()[0].get_files()[0].get_file_size() is 10 + assert file_system.get_folders()[0].get_files()[0].get_file_size() == 10 def test_create_file(): @@ -92,6 +92,6 @@ def test_serialisation(): assert len(file_system.get_folders()[0].get_files()) is 1 serialised_file_sys = file_system.model_dump_json() - deserialised_file_sys = FileSystem().model_validate_json(serialised_file_sys) + deserialised_file_sys = FileSystem.model_validate_json(serialised_file_sys) assert file_system == deserialised_file_sys diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py index 669be62d..ed4a4ad5 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py @@ -11,13 +11,13 @@ def test_file_type(): def test_get_file_size(): """Tests that the file size is being returned properly.""" file = FileSystemFile(item_name="test", item_size=1.5) - assert file.get_file_size() is 1.5 + assert file.get_file_size() == 1.5 def test_serialisation(): """Test to check that the object serialisation works correctly.""" file = FileSystemFile(item_name="test", item_size=1.5, file_type=FileSystemFileType.DOC) serialised_file = file.model_dump_json() - deserialised_file = FileSystemFile(item_name="test").model_validate_json(serialised_file) + deserialised_file = FileSystemFile.model_validate_json(serialised_file) assert file == deserialised_file diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py index f9ba80f0..871b4e94 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py @@ -10,11 +10,11 @@ def test_adding_removing_file(): file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) - assert folder.get_folder_size() is 10 + assert folder.get_folder_size() == 10 assert len(folder.get_files()) is 1 folder.remove_file(file) - assert folder.get_folder_size() is 0 + assert folder.get_folder_size() == 0 assert len(folder.get_files()) is 0 @@ -27,7 +27,7 @@ def test_get_file_by_id(): folder.add_file(file) folder.add_file(file2) - assert folder.get_folder_size() is 20 + assert folder.get_folder_size() == 20 assert len(folder.get_files()) is 2 assert folder.get_file(file_id=file.uuid) is file @@ -54,6 +54,6 @@ def test_serialisation(): serialised_folder = folder.model_dump_json() - deserialised_folder = FileSystemFolder(item_name="test").model_validate_json(serialised_folder) + deserialised_folder = FileSystemFolder.model_validate_json(serialised_folder) assert folder == deserialised_folder From 554619e4b40a6789b3446ceda9fb94192f260941 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 7 Aug 2023 14:49:59 +0100 Subject: [PATCH 20/63] #1714: conver file and folder lists to dicts + fixing and adding a few more tests --- .../simulator/file_system/file_system.py | 64 +++++++++++++------ .../file_system/file_system_folder.py | 30 ++++++--- .../_file_system/test_file_system.py | 50 ++++++++++++--- .../_file_system/test_file_system_file.py | 2 +- .../_file_system/test_file_system_folder.py | 18 +++++- .../_primaite/_simulator/test_core.py | 4 +- 6 files changed, 128 insertions(+), 40 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index f8ae9d67..ce6eefb2 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -1,16 +1,19 @@ from random import choice -from typing import Dict, List, Optional +from typing import Dict, Optional +from primaite import getLogger from primaite.simulator.core import SimComponent from primaite.simulator.file_system.file_system_file import FileSystemFile from primaite.simulator.file_system.file_system_file_type import FileSystemFileType from primaite.simulator.file_system.file_system_folder import FileSystemFolder +_LOGGER = getLogger(__name__) + class FileSystem(SimComponent): """Class that contains all the simulation File System.""" - folders: List[FileSystemFolder] = [] + folders: Dict = {} """List containing all the folders in the file system.""" def describe_state(self) -> Dict: @@ -21,7 +24,7 @@ class FileSystem(SimComponent): """ pass - def get_folders(self) -> List[FileSystemFolder]: + def get_folders(self) -> Dict: """Returns the list of folders.""" return self.folders @@ -48,6 +51,9 @@ class FileSystem(SimComponent): :param: file_type: The type of the file :type: Optional[FileSystemFileType] + :param: folder: The folder to add the file to + :type: folder: Optional[FileSystemFolder] + :param: folder_uuid: The uuid of the folder to add the file to :type: folder_uuid: Optional[str] """ @@ -75,16 +81,21 @@ class FileSystem(SimComponent): # add file to root folder file = FileSystemFile(item_name=file_name, item_size=file_size, file_type=file_type) folder.add_file(file) - self.folders.append(folder) + self.folders[folder.uuid] = folder return file def create_folder( self, folder_name: str, ) -> FileSystemFolder: - """Creates a FileSystemFolder and adds it to the list of folders.""" + """ + Creates a FileSystemFolder and adds it to the list of folders. + + :param: folder_name: The name of the folder + :type: folder_name: str + """ folder = FileSystemFolder(item_name=folder_name) - self.folders.append(folder) + self.folders[folder.uuid] = folder return folder def delete_file(self, file: Optional[FileSystemFile] = None): @@ -93,13 +104,10 @@ class FileSystem(SimComponent): :param file: The file to delete :type file: Optional[FileSystemFile] - - :param file_id: The UUID of the file item to delete - :type file_id: Optional[str] """ # iterate through folders to delete the item with the matching uuid - for folder in self.folders: - folder.remove_file(file=file) + for key in self.folders: + self.get_folder_by_id(key).remove_file(file) def delete_folder(self, folder: FileSystemFolder): """ @@ -108,7 +116,13 @@ class FileSystem(SimComponent): :param folder: The folder to remove :type folder: FileSystemFolder """ - self.folders.remove(folder) + if folder is None or not isinstance(folder, FileSystemFolder): + raise Exception(f"Invalid folder: {folder}") + + if self.folders.get(folder.uuid): + del self.folders[folder.uuid] + else: + _LOGGER.debug(f"File with UUID {folder.uuid} was not found.") def move_file(self, file: FileSystemFile, src_folder: FileSystemFolder, target_folder: FileSystemFolder): """ @@ -170,8 +184,8 @@ class FileSystem(SimComponent): def get_file_by_id(self, file_id: str) -> FileSystemFile: """Checks if the file exists in any file system folders.""" - for folder in self.folders: - file = folder.get_file(file_id=file_id) + for key in self.folders: + file = self.folders[key].get_file(file_id=file_id) if file is not None: return file @@ -181,12 +195,26 @@ class FileSystem(SimComponent): :return: Returns the first FileSydtemFolder with a matching name """ - return next((f for f in self.folders if f.get_folder_name() == folder_name), None) + matching_folder = None + for key in self.folders: + if self.folders[key].get_folder_name() == folder_name: + matching_folder = self.folders[key] + break + return matching_folder def get_folder_by_id(self, folder_id: str) -> FileSystemFolder: - """Checks if the folder exists.""" - return next((f for f in self.folders if f.uuid == folder_id), None) + """ + Checks if the folder exists. + + :param: folder_id: The id of the folder to find + :type: folder_id: str + """ + return self.folders[folder_id] def get_random_file_type(self) -> FileSystemFileType: - """Returns a random FileSystemFileTypeEnum.""" + """ + Returns a random FileSystemFileTypeEnum. + + :return: A random file type Enum + """ return choice(list(FileSystemFileType)) diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index a381e57d..d6ac3ef1 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -1,13 +1,16 @@ -from typing import Dict, List, Optional +from typing import Dict, Optional +from primaite import getLogger from primaite.simulator.file_system.file_system_file import FileSystemFile from primaite.simulator.file_system.file_system_item_abc import FileSystemItem +_LOGGER = getLogger(__name__) + class FileSystemFolder(FileSystemItem): """Simulation FileSystemFolder.""" - files: List[FileSystemFile] = [] + files: Dict = {} """List of files stored in the folder.""" is_quarantined: bool = False @@ -21,20 +24,23 @@ class FileSystemFolder(FileSystemItem): """Returns the item_size of the folder.""" return self.item_size - def get_files(self) -> List[FileSystemFile]: - """Returns the list of files the folder contains.""" + def get_files(self) -> Dict: + """Returns the files dictionary.""" return self.files def get_file(self, file_id: str) -> FileSystemFile: """Return a FileSystemFile with the matching id.""" - return next((f for f in self.files if f.uuid == file_id), None) + return self.files[file_id] def add_file(self, file: FileSystemFile): """Adds a file to the folder list.""" + if file is None or not isinstance(file, FileSystemFile): + raise Exception(f"Invalid file: {file}") + self.item_size += file.get_file_size() # add to list - self.files.append(file) + self.files[file.uuid] = file def remove_file(self, file: Optional[FileSystemFile]): """ @@ -45,10 +51,16 @@ class FileSystemFolder(FileSystemItem): :param: file: The file to remove :type: Optional[FileSystemFile] """ - self.files.remove(file) + if file is None or not isinstance(file, FileSystemFile): + raise Exception(f"Invalid file: {file}") - # remove folder size from folder - self.item_size -= file.get_file_size() + if self.files.get(file.uuid): + del self.files[file.uuid] + + # remove folder size from folder + self.item_size -= file.get_file_size() + else: + _LOGGER.debug(f"File with UUID {file.uuid} was not found.") def quarantine(self): """Quarantines the File System Folder.""" diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py index f4c1ccda..e0a6a2d9 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -1,4 +1,5 @@ from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.file_system.file_system_folder import FileSystemFolder def test_create_folder_and_file(): @@ -7,10 +8,11 @@ def test_create_folder_and_file(): folder = file_system.create_folder(folder_name="test_folder") assert len(file_system.get_folders()) is 1 - file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) - assert len(file_system.get_folders()[0].get_files()) is 1 - assert file_system.get_folders()[0].get_files()[0].get_file_name() is "test_file" - assert file_system.get_folders()[0].get_files()[0].get_file_size() == 10 + file = file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) + assert len(file_system.get_folder_by_id(folder.uuid).get_files()) is 1 + + assert file_system.get_file_by_id(file.uuid).get_file_name() is "test_file" + assert file_system.get_file_by_id(file.uuid).get_file_size() == 10 def test_create_file(): @@ -19,7 +21,7 @@ def test_create_file(): file = file_system.create_file(file_name="test_file", file_size=10) assert len(file_system.get_folders()) is 1 - assert file_system.get_folders()[0].get_file(file.uuid) is file + assert file_system.get_folder_by_name("root").get_file(file.uuid) is file def test_delete_file(): @@ -28,11 +30,31 @@ def test_delete_file(): file = file_system.create_file(file_name="test_file", file_size=10) assert len(file_system.get_folders()) is 1 - assert file_system.get_folders()[0].get_file(file.uuid) is file + + folder_id = list(file_system.get_folders().keys())[0] + folder = file_system.get_folder_by_id(folder_id) + assert folder.get_file(file.uuid) is file file_system.delete_file(file=file) assert len(file_system.get_folders()) is 1 - assert len(file_system.get_folders()[0].get_files()) is 0 + assert len(file_system.get_folder_by_id(folder.uuid).get_files()) is 0 + + +def test_delete_non_existent_file(): + """Tests deleting a non existent file.""" + file_system = FileSystem() + + file = file_system.create_file(file_name="test_file", file_size=10) + not_added_file = file_system.create_file(file_name="test_file", file_size=10) + assert len(file_system.get_folders()) is 1 + + folder_id = list(file_system.get_folders().keys())[0] + folder = file_system.get_folder_by_id(folder_id) + assert folder.get_file(file.uuid) is file + + file_system.delete_file(file=not_added_file) + assert len(file_system.get_folders()) is 1 + assert len(file_system.get_folder_by_id(folder.uuid).get_files()) is 1 def test_delete_folder(): @@ -44,6 +66,16 @@ def test_delete_folder(): assert len(file_system.get_folders()) is 0 +def test_deleting_a_non_existent_folder(): + file_system = FileSystem() + folder = file_system.create_folder(folder_name="test_folder") + not_added_folder = FileSystemFolder(item_name="fake_folder") + assert len(file_system.get_folders()) is 1 + + file_system.delete_folder(not_added_folder) + assert len(file_system.get_folders()) is 1 + + def test_move_file(): """Tests the file move function.""" file_system = FileSystem() @@ -89,9 +121,9 @@ def test_serialisation(): assert len(file_system.get_folders()) is 1 file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) - assert len(file_system.get_folders()[0].get_files()) is 1 + assert file_system.get_folder_by_id(folder.uuid) is folder serialised_file_sys = file_system.model_dump_json() deserialised_file_sys = FileSystem.model_validate_json(serialised_file_sys) - assert file_system == deserialised_file_sys + assert file_system.model_dump_json() == deserialised_file_sys.model_dump_json() diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py index ed4a4ad5..51f4ce1b 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py @@ -20,4 +20,4 @@ def test_serialisation(): serialised_file = file.model_dump_json() deserialised_file = FileSystemFile.model_validate_json(serialised_file) - assert file == deserialised_file + assert file.model_dump_json() == deserialised_file.model_dump_json() diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py index 871b4e94..c56d2917 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py @@ -18,6 +18,22 @@ def test_adding_removing_file(): assert len(folder.get_files()) is 0 +def test_remove_non_existent_file(): + """Test the removing of a file that does not exist.""" + folder = FileSystemFolder(item_name="test") + + file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) + not_added_file = FileSystemFile(item_name="fake_file", item_size=10, file_type=FileSystemFileType.DOC) + + folder.add_file(file) + assert folder.get_folder_size() == 10 + assert len(folder.get_files()) is 1 + + folder.remove_file(not_added_file) + assert folder.get_folder_size() == 10 + assert len(folder.get_files()) is 1 + + def test_get_file_by_id(): """Test to make sure that the correct file is returned.""" folder = FileSystemFolder(item_name="test") @@ -56,4 +72,4 @@ def test_serialisation(): deserialised_folder = FileSystemFolder.model_validate_json(serialised_folder) - assert folder == deserialised_folder + assert folder.model_dump_json() == deserialised_folder.model_dump_json() diff --git a/tests/unit_tests/_primaite/_simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py index 00f29791..4e2df757 100644 --- a/tests/unit_tests/_primaite/_simulator/test_core.py +++ b/tests/unit_tests/_primaite/_simulator/test_core.py @@ -42,8 +42,8 @@ class TestIsolatedSimComponent: return {} comp = TestComponent(name="computer", size=(5, 10)) - dump = comp.model_dump() - assert dump["name"] is "computer" + dump = comp.model_dump_json() + assert comp == TestComponent.model_validate_json(dump) def test_apply_action(self): """Validate that we can override apply_action behaviour and it updates the state of the component.""" From a4c193cd34f8593e52e36d1a6a7c7f0ec1738092 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 7 Aug 2023 16:20:55 +0100 Subject: [PATCH 21/63] #1714: apply recommended changes with removing get methods and using the properties directly --- src/primaite/simulator/core.py | 2 +- .../simulator/file_system/file_system.py | 20 ++--- .../simulator/file_system/file_system_file.py | 26 ++---- .../file_system/file_system_folder.py | 22 +---- .../file_system/file_system_item_abc.py | 8 +- .../_file_system/test_file_system.py | 84 +++++++++---------- .../_file_system/test_file_system_file.py | 12 +-- .../_file_system/test_file_system_folder.py | 44 +++++----- 8 files changed, 96 insertions(+), 122 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 84b03498..5f8ad57c 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -10,7 +10,7 @@ class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" uuid: str - "The component UUID." + """The component UUID.""" def __init__(self, **kwargs): if not kwargs.get("uuid"): diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index ce6eefb2..3290570e 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -31,7 +31,7 @@ class FileSystem(SimComponent): def create_file( self, file_name: str, - file_size: Optional[float] = None, + size: Optional[float] = None, file_type: Optional[FileSystemFileType] = None, folder: Optional[FileSystemFolder] = None, folder_uuid: Optional[str] = None, @@ -39,14 +39,14 @@ class FileSystem(SimComponent): """ Creates a FileSystemFile and adds it to the list of files. - If no file_size or file_type are provided, one will be chosen randomly. + If no size or file_type are provided, one will be chosen randomly. If no folder_uuid or folder is provided, a new folder will be created. :param: file_name: The file name :type: file_name: str - :param: file_size: The size the file takes on disk. - :type: file_size: Optional[float] + :param: size: The size the file takes on disk. + :type: size: Optional[float] :param: file_type: The type of the file :type: Optional[FileSystemFileType] @@ -69,17 +69,17 @@ class FileSystem(SimComponent): folder = self.get_folder_by_id(folder_uuid) if folder is not None: - file = FileSystemFile(item_name=file_name, item_size=file_size, file_type=file_type) + file = FileSystemFile(name=file_name, size=size, file_type=file_type) folder.add_file(file=file) else: # check if a "root" folder exists folder = self.get_folder_by_name("root") if folder is None: # create a root folder - folder = FileSystemFolder(item_name="root") + folder = FileSystemFolder(name="root") # add file to root folder - file = FileSystemFile(item_name=file_name, item_size=file_size, file_type=file_type) + file = FileSystemFile(name=file_name, size=size, file_type=file_type) folder.add_file(file) self.folders[folder.uuid] = folder return file @@ -94,7 +94,7 @@ class FileSystem(SimComponent): :param: folder_name: The name of the folder :type: folder_name: str """ - folder = FileSystemFolder(item_name=folder_name) + folder = FileSystemFolder(name=folder_name) self.folders[folder.uuid] = folder return folder @@ -185,7 +185,7 @@ class FileSystem(SimComponent): def get_file_by_id(self, file_id: str) -> FileSystemFile: """Checks if the file exists in any file system folders.""" for key in self.folders: - file = self.folders[key].get_file(file_id=file_id) + file = self.folders[key].get_file_by_id(file_id=file_id) if file is not None: return file @@ -197,7 +197,7 @@ class FileSystem(SimComponent): """ matching_folder = None for key in self.folders: - if self.folders[key].get_folder_name() == folder_name: + if self.folders[key].name == folder_name: matching_folder = self.folders[key] break return matching_folder diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index efb1ae93..2de2084b 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -15,17 +15,17 @@ class FileSystemFile(FileSystemItem): """ Initialise FileSystemFile class. - :param item_name: The name of the file. - :type item_name: str + :param name: The name of the file. + :type name: str :param file_type: The FileSystemFileType of the file :type file_type: Optional[FileSystemFileType] - :param item_size: The size of the FileSystemItem - :type item_size: Optional[float] + :param size: The size of the FileSystemItem + :type size: Optional[float] """ # set random file type if none provided - if kwargs.get("item_name") is None: + if kwargs.get("name") is None: raise Exception("File name not provided.") # set random file type if none provided @@ -33,23 +33,11 @@ class FileSystemFile(FileSystemItem): kwargs["file_type"] = choice(list(FileSystemFileType)) # set random file size if none provided - if kwargs.get("item_size") is None: - kwargs["item_size"] = float(randint(1, file_type_sizes_KB[kwargs["file_type"]])) + if kwargs.get("size") is None: + kwargs["size"] = float(randint(1, file_type_sizes_KB[kwargs["file_type"]])) super().__init__(**kwargs) - def get_file_name(self) -> str: - """Returns the name of the file.""" - return self.item_name - - def get_file_size(self) -> float: - """Returns the size of the file system item.""" - return self.item_size - - def get_file_type(self) -> FileSystemFileType: - """Returns the FileSystemFileType of the file.""" - return self.file_type - def describe_state(self) -> Dict: """ Get the current state of the FileSystemFile as a dict. diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index d6ac3ef1..79e19189 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -16,31 +16,18 @@ class FileSystemFolder(FileSystemItem): is_quarantined: bool = False """Flag that marks the folder as quarantined if true.""" - def get_folder_name(self) -> str: - """Returns the item_name of the folder.""" - return self.item_name - - def get_folder_size(self) -> float: - """Returns the item_size of the folder.""" - return self.item_size - - def get_files(self) -> Dict: - """Returns the files dictionary.""" - return self.files - - def get_file(self, file_id: str) -> FileSystemFile: + def get_file_by_id(self, file_id: str) -> FileSystemFile: """Return a FileSystemFile with the matching id.""" - return self.files[file_id] + return self.files.get(file_id) def add_file(self, file: FileSystemFile): """Adds a file to the folder list.""" if file is None or not isinstance(file, FileSystemFile): raise Exception(f"Invalid file: {file}") - self.item_size += file.get_file_size() - # add to list self.files[file.uuid] = file + self.size += file.size def remove_file(self, file: Optional[FileSystemFile]): """ @@ -57,8 +44,7 @@ class FileSystemFolder(FileSystemItem): if self.files.get(file.uuid): del self.files[file.uuid] - # remove folder size from folder - self.item_size -= file.get_file_size() + self.size -= file.size else: _LOGGER.debug(f"File with UUID {file.uuid} was not found.") diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py index a1258665..0594cc35 100644 --- a/src/primaite/simulator/file_system/file_system_item_abc.py +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -6,11 +6,11 @@ from primaite.simulator.core import SimComponent class FileSystemItem(SimComponent): """Abstract base class for FileSystemItems used in the file system simulation.""" - item_size: float = 0 - """The size the item takes up on disk.""" + name: str + """The name of the FileSystemItem.""" - item_name: str - """The name of the file system item.""" + size: float = 0 + """The size the item takes up on disk.""" def describe_state(self) -> Dict: """Returns the state of the FileSystemItem.""" diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py index e0a6a2d9..5bebf487 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -6,121 +6,121 @@ def test_create_folder_and_file(): """Test creating a folder and a file.""" file_system = FileSystem() folder = file_system.create_folder(folder_name="test_folder") - assert len(file_system.get_folders()) is 1 + assert len(file_system.folders) is 1 - file = file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) - assert len(file_system.get_folder_by_id(folder.uuid).get_files()) is 1 + file = file_system.create_file(file_name="test_file", size=10, folder_uuid=folder.uuid) + assert len(file_system.get_folder_by_id(folder.uuid).files) is 1 - assert file_system.get_file_by_id(file.uuid).get_file_name() is "test_file" - assert file_system.get_file_by_id(file.uuid).get_file_size() == 10 + assert file_system.get_file_by_id(file.uuid).name is "test_file" + assert file_system.get_file_by_id(file.uuid).size == 10 def test_create_file(): """Tests that creating a file without a folder creates a folder and sets that as the file's parent.""" file_system = FileSystem() - file = file_system.create_file(file_name="test_file", file_size=10) - assert len(file_system.get_folders()) is 1 - assert file_system.get_folder_by_name("root").get_file(file.uuid) is file + file = file_system.create_file(file_name="test_file", size=10) + assert len(file_system.folders) is 1 + assert file_system.get_folder_by_name("root").get_file_by_id(file.uuid) is file def test_delete_file(): """Tests that a file can be deleted.""" file_system = FileSystem() - file = file_system.create_file(file_name="test_file", file_size=10) - assert len(file_system.get_folders()) is 1 + file = file_system.create_file(file_name="test_file", size=10) + assert len(file_system.folders) is 1 - folder_id = list(file_system.get_folders().keys())[0] + folder_id = list(file_system.folders.keys())[0] folder = file_system.get_folder_by_id(folder_id) - assert folder.get_file(file.uuid) is file + assert folder.get_file_by_id(file.uuid) is file file_system.delete_file(file=file) - assert len(file_system.get_folders()) is 1 - assert len(file_system.get_folder_by_id(folder.uuid).get_files()) is 0 + assert len(file_system.folders) is 1 + assert len(file_system.get_folder_by_id(folder.uuid).files) is 0 def test_delete_non_existent_file(): """Tests deleting a non existent file.""" file_system = FileSystem() - file = file_system.create_file(file_name="test_file", file_size=10) - not_added_file = file_system.create_file(file_name="test_file", file_size=10) - assert len(file_system.get_folders()) is 1 + file = file_system.create_file(file_name="test_file", size=10) + not_added_file = file_system.create_file(file_name="test_file", size=10) + assert len(file_system.folders) is 1 - folder_id = list(file_system.get_folders().keys())[0] + folder_id = list(file_system.folders.keys())[0] folder = file_system.get_folder_by_id(folder_id) - assert folder.get_file(file.uuid) is file + assert folder.get_file_by_id(file.uuid) is file file_system.delete_file(file=not_added_file) - assert len(file_system.get_folders()) is 1 - assert len(file_system.get_folder_by_id(folder.uuid).get_files()) is 1 + assert len(file_system.folders) is 1 + assert len(file_system.get_folder_by_id(folder.uuid).files) is 1 def test_delete_folder(): file_system = FileSystem() folder = file_system.create_folder(folder_name="test_folder") - assert len(file_system.get_folders()) is 1 + assert len(file_system.folders) is 1 file_system.delete_folder(folder) - assert len(file_system.get_folders()) is 0 + assert len(file_system.folders) is 0 def test_deleting_a_non_existent_folder(): file_system = FileSystem() folder = file_system.create_folder(folder_name="test_folder") - not_added_folder = FileSystemFolder(item_name="fake_folder") - assert len(file_system.get_folders()) is 1 + not_added_folder = FileSystemFolder(name="fake_folder") + assert len(file_system.folders) is 1 file_system.delete_folder(not_added_folder) - assert len(file_system.get_folders()) is 1 + assert len(file_system.folders) is 1 def test_move_file(): """Tests the file move function.""" file_system = FileSystem() src_folder = file_system.create_folder(folder_name="test_folder_1") - assert len(file_system.get_folders()) is 1 + assert len(file_system.folders) is 1 target_folder = file_system.create_folder(folder_name="test_folder_2") - assert len(file_system.get_folders()) is 2 + assert len(file_system.folders) is 2 - file = file_system.create_file(file_name="test_file", file_size=10, folder_uuid=src_folder.uuid) - assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 - assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 0 + file = file_system.create_file(file_name="test_file", size=10, folder_uuid=src_folder.uuid) + assert len(file_system.get_folder_by_id(src_folder.uuid).files) is 1 + assert len(file_system.get_folder_by_id(target_folder.uuid).files) is 0 file_system.move_file(file=file, src_folder=src_folder, target_folder=target_folder) - assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 0 - assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 1 + assert len(file_system.get_folder_by_id(src_folder.uuid).files) is 0 + assert len(file_system.get_folder_by_id(target_folder.uuid).files) is 1 def test_copy_file(): """Tests the file copy function.""" file_system = FileSystem() src_folder = file_system.create_folder(folder_name="test_folder_1") - assert len(file_system.get_folders()) is 1 + assert len(file_system.folders) is 1 target_folder = file_system.create_folder(folder_name="test_folder_2") - assert len(file_system.get_folders()) is 2 + assert len(file_system.folders) is 2 - file = file_system.create_file(file_name="test_file", file_size=10, folder_uuid=src_folder.uuid) - assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 - assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 0 + file = file_system.create_file(file_name="test_file", size=10, folder_uuid=src_folder.uuid) + assert len(file_system.get_folder_by_id(src_folder.uuid).files) is 1 + assert len(file_system.get_folder_by_id(target_folder.uuid).files) is 0 file_system.copy_file(file=file, src_folder=src_folder, target_folder=target_folder) - assert len(file_system.get_folder_by_id(src_folder.uuid).get_files()) is 1 - assert len(file_system.get_folder_by_id(target_folder.uuid).get_files()) is 1 + assert len(file_system.get_folder_by_id(src_folder.uuid).files) is 1 + assert len(file_system.get_folder_by_id(target_folder.uuid).files) is 1 def test_serialisation(): """Test to check that the object serialisation works correctly.""" file_system = FileSystem() folder = file_system.create_folder(folder_name="test_folder") - assert len(file_system.get_folders()) is 1 + assert len(file_system.folders) is 1 - file_system.create_file(file_name="test_file", file_size=10, folder_uuid=folder.uuid) + file_system.create_file(file_name="test_file", size=10, folder_uuid=folder.uuid) assert file_system.get_folder_by_id(folder.uuid) is folder serialised_file_sys = file_system.model_dump_json() diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py index 51f4ce1b..629b9bb9 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_file.py @@ -4,19 +4,19 @@ from primaite.simulator.file_system.file_system_file_type import FileSystemFileT def test_file_type(): """Tests tha the FileSystemFile type is set correctly.""" - file = FileSystemFile(item_name="test", file_type=FileSystemFileType.DOC) - assert file.get_file_type() is FileSystemFileType.DOC + file = FileSystemFile(name="test", file_type=FileSystemFileType.DOC) + assert file.file_type is FileSystemFileType.DOC -def test_get_file_size(): +def test_get_size(): """Tests that the file size is being returned properly.""" - file = FileSystemFile(item_name="test", item_size=1.5) - assert file.get_file_size() == 1.5 + file = FileSystemFile(name="test", size=1.5) + assert file.size == 1.5 def test_serialisation(): """Test to check that the object serialisation works correctly.""" - file = FileSystemFile(item_name="test", item_size=1.5, file_type=FileSystemFileType.DOC) + file = FileSystemFile(name="test", size=1.5, file_type=FileSystemFileType.DOC) serialised_file = file.model_dump_json() deserialised_file = FileSystemFile.model_validate_json(serialised_file) diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py index c56d2917..1940e886 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system_folder.py @@ -5,53 +5,53 @@ from primaite.simulator.file_system.file_system_folder import FileSystemFolder def test_adding_removing_file(): """Test the adding and removing of a file from a folder.""" - folder = FileSystemFolder(item_name="test") + folder = FileSystemFolder(name="test") - file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) + file = FileSystemFile(name="test_file", size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) - assert folder.get_folder_size() == 10 - assert len(folder.get_files()) is 1 + assert folder.size == 10 + assert len(folder.files) is 1 folder.remove_file(file) - assert folder.get_folder_size() == 0 - assert len(folder.get_files()) is 0 + assert folder.size == 0 + assert len(folder.files) is 0 def test_remove_non_existent_file(): """Test the removing of a file that does not exist.""" - folder = FileSystemFolder(item_name="test") + folder = FileSystemFolder(name="test") - file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) - not_added_file = FileSystemFile(item_name="fake_file", item_size=10, file_type=FileSystemFileType.DOC) + file = FileSystemFile(name="test_file", size=10, file_type=FileSystemFileType.DOC) + not_added_file = FileSystemFile(name="fake_file", size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) - assert folder.get_folder_size() == 10 - assert len(folder.get_files()) is 1 + assert folder.size == 10 + assert len(folder.files) is 1 folder.remove_file(not_added_file) - assert folder.get_folder_size() == 10 - assert len(folder.get_files()) is 1 + assert folder.size == 10 + assert len(folder.files) is 1 def test_get_file_by_id(): """Test to make sure that the correct file is returned.""" - folder = FileSystemFolder(item_name="test") + folder = FileSystemFolder(name="test") - file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) - file2 = FileSystemFile(item_name="test_file_2", item_size=10, file_type=FileSystemFileType.DOC) + file = FileSystemFile(name="test_file", size=10, file_type=FileSystemFileType.DOC) + file2 = FileSystemFile(name="test_file_2", size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) folder.add_file(file2) - assert folder.get_folder_size() == 20 - assert len(folder.get_files()) is 2 + assert folder.size == 20 + assert len(folder.files) is 2 - assert folder.get_file(file_id=file.uuid) is file + assert folder.get_file_by_id(file_id=file.uuid) is file def test_folder_quarantine_state(): """Tests the changing of folder quarantine status.""" - folder = FileSystemFolder(item_name="test") + folder = FileSystemFolder(name="test") assert folder.quarantine_status() is False @@ -64,8 +64,8 @@ def test_folder_quarantine_state(): def test_serialisation(): """Test to check that the object serialisation works correctly.""" - folder = FileSystemFolder(item_name="test") - file = FileSystemFile(item_name="test_file", item_size=10, file_type=FileSystemFileType.DOC) + folder = FileSystemFolder(name="test") + file = FileSystemFile(name="test_file", size=10, file_type=FileSystemFileType.DOC) folder.add_file(file) serialised_folder = folder.model_dump_json() From 700950b85627506728ebbdb83c5ed7e2bdc85255 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Mon, 7 Aug 2023 15:38:15 +0000 Subject: [PATCH 22/63] Apply suggestions from code review --- src/primaite/simulator/file_system/file_system_file.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index 2de2084b..b3358372 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -25,8 +25,7 @@ class FileSystemFile(FileSystemItem): :type size: Optional[float] """ # set random file type if none provided - if kwargs.get("name") is None: - raise Exception("File name not provided.") + # set random file type if none provided if kwargs.get("file_type") is None: From 7eb0bb428fc26eb487a50ae496b3b2b8f4640eb2 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 7 Aug 2023 17:24:14 +0100 Subject: [PATCH 23/63] Update code based on PR comments. --- src/primaite/simulator/core.py | 38 ++++++++++++++++++--- src/primaite/simulator/domain/account.py | 20 +++++------ src/primaite/simulator/domain/controller.py | 16 +++++---- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 17e09f85..fa5cd6c7 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -21,7 +21,7 @@ class ActionPermissionValidator(ABC): @abstractmethod def __call__(self, request: List[str], context: Dict) -> bool: - """TODO.""" + """Use the request and context paramters to decide whether the action should be permitted.""" pass @@ -52,6 +52,10 @@ class Action: turning it off, then the SimComponent should have a turn_off(self) method that does not need to accept any args. Then, this Action will be given something like ``func = lambda request, context: self.turn_off()``. + ``validator`` is an instance of a subclass of `ActionPermissionValidator`. This is essentially a callable that + accepts `request` and `context` and returns a boolean to represent whether the permission is granted to perform + the action. + :param func: Function that performs the request. :type func: Callable[[List[str], Dict], None] :param validator: Function that checks if the request is authenticated given the context. @@ -62,14 +66,28 @@ class Action: class ActionManager: - """TODO.""" + """ + ActionManager is used by `SimComponent` instances to keep track of actions. + + Its main purpose is to be a lookup from action name to action function and corresponding validation function. This + class is responsible for providing a consistent API for processing actions as well as helpful error messages. + """ def __init__(self) -> None: - """TODO.""" + """Initialise ActionManager with an empty action lookup.""" self.actions: Dict[str, Action] = {} def process_request(self, request: List[str], context: Dict) -> None: - """Process action request.""" + """Process an action request. + + :param request: A list of strings which specify what action to take. The first string must be one of the allowed + actions, i.e. it must be a key of self.actions. The subsequent strings in the list are passed as parameters + to the action function. + :type request: List[str] + :param context: Dictionary of additional information necessary to process or validate the request. + :type context: Dict + :raises RuntimeError: If the request parameter does not have a valid action identifier as the first item. + """ action_key = request[0] if action_key not in self.actions: @@ -90,6 +108,18 @@ class ActionManager: action.func(action_options, context) def add_action(self, name: str, action: Action) -> None: + """Add an action to this action manager. + + :param name: The string associated to this action. + :type name: str + :param action: Action object. + :type action: Action + """ + if name in self.actions: + msg = f"Attempted to register an action but the action name {name} is already taken." + _LOGGER.error(msg) + raise RuntimeError(msg) + self.actions[name] = action diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index 086022e6..2d726624 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -11,25 +11,25 @@ _LOGGER = getLogger(__name__) class AccountType(Enum): """Whether the account is intended for a user to log in or for a service to use.""" - service = 1 + SERVICE = 1 "Service accounts are used to grant permissions to software on nodes to perform actions" - user = 2 + USER = 2 "User accounts are used to allow agents to log in and perform actions" class AccountStatus(Enum): """Whether the account is active.""" - enabled = 1 - disabled = 2 + ENABLED = 1 + DISABLED = 2 class PasswordPolicyLevel(Enum): """Complexity requirements for account passwords.""" - low = 1 - medium = 2 - high = 3 + LOW = 1 + MEDIUM = 2 + HIGH = 3 class Account(SimComponent): @@ -47,7 +47,7 @@ class Account(SimComponent): "Account password." account_type: AccountType "Account Type, currently this can be service account (used by apps) or user account." - status: AccountStatus = AccountStatus.disabled + status: AccountStatus = AccountStatus.DISABLED def describe_state(self) -> Dict: """Describe state for agent observations.""" @@ -55,11 +55,11 @@ class Account(SimComponent): def enable(self): """Set the status to enabled.""" - self.status = AccountStatus.enabled + self.status = AccountStatus.ENABLED def disable(self): """Set the status to disabled.""" - self.status = AccountStatus.disabled + self.status = AccountStatus.DISABLED def log_on(self) -> None: """TODO.""" diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index e4a73b4e..cc8063d6 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -47,7 +47,11 @@ class GroupMembershipValidator(ActionPermissionValidator): """Permit actions based on group membership.""" def __init__(self, allowed_groups: List[AccountGroup]) -> None: - """TODO.""" + """Store a list of groups that should be granted permission. + + :param allowed_groups: List of AccountGroups that are permitted to perform some action. + :type allowed_groups: List[AccountGroup] + """ self.allowed_groups = allowed_groups def __call__(self, request: List[str], context: Dict) -> bool: @@ -64,7 +68,7 @@ class DomainController(SimComponent): """Main object for controlling the domain.""" # owned objects - accounts: List[Account] = [] + accounts: Dict[str, Account] = {} groups: Final[List[AccountGroup]] = list(AccountGroup) domain_group_membership: Dict[Literal[AccountGroup.domain_admin, AccountGroup.domain_user], List[Account]] = {} @@ -73,10 +77,10 @@ class DomainController(SimComponent): ] = {} # references to non-owned objects. Not sure if all are needed here. - nodes: List[temp_node] = [] - applications: List[temp_application] = [] - folders: List[temp_folder] = [] - files: List[temp_file] = [] + nodes: Dict[str, temp_node] = {} + applications: Dict[str, temp_application] = {} + folders: List[temp_folder] = {} + files: List[temp_file] = {} def _register_account(self, account: Account) -> None: """TODO.""" From 139d7397320528a7a7474a4f171c6173b538024e Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 7 Aug 2023 19:33:52 +0100 Subject: [PATCH 24/63] #1706 - Tidies up the sysLog ARPCache, and ICMP classes and integrated them into the Node. Tidied up the base implementation of SoftwareManager and SessionManager. Tidies up the public API for Services and Applications. Added the SwitchPort and Switch classes. Added a basic test in test_frame_transmission.py that tests sending a frame from one node to another across a multi-switch network. --- src/primaite/__init__.py | 2 + src/primaite/simulator/__init__.py | 4 + .../simulator/network/hardware/base.py | 621 +++++++++++++----- .../icmp.py => network/nodes/switch.py} | 0 .../system/applications/application.py | 9 +- src/primaite/simulator/system/arp_cache.py | 30 - .../simulator/system/core/__init__.py | 0 .../system/{ => core}/packet_capture.py | 13 +- .../simulator/system/core/session_manager.py | 177 +++++ .../simulator/system/core/software_manager.py | 99 +++ .../simulator/system/{ => core}/sys_log.py | 10 +- .../simulator/system/processes/process.py | 3 +- .../simulator/system/services/service.py | 8 +- src/primaite/simulator/system/software.py | 48 +- .../network/test_frame_transmission.py | 38 +- 15 files changed, 846 insertions(+), 216 deletions(-) rename src/primaite/simulator/{system/services/icmp.py => network/nodes/switch.py} (100%) delete mode 100644 src/primaite/simulator/system/arp_cache.py create mode 100644 src/primaite/simulator/system/core/__init__.py rename src/primaite/simulator/system/{ => core}/packet_capture.py (82%) create mode 100644 src/primaite/simulator/system/core/session_manager.py create mode 100644 src/primaite/simulator/system/core/software_manager.py rename src/primaite/simulator/system/{ => core}/sys_log.py (90%) diff --git a/src/primaite/__init__.py b/src/primaite/__init__.py index ad157c9c..9a7ba596 100644 --- a/src/primaite/__init__.py +++ b/src/primaite/__init__.py @@ -16,6 +16,8 @@ from platformdirs import PlatformDirs with open(Path(__file__).parent.resolve() / "VERSION", "r") as file: __version__ = file.readline().strip() +_PRIMAITE_ROOT: Path = Path(__file__).parent + class _PrimaitePaths: """ diff --git a/src/primaite/simulator/__init__.py b/src/primaite/simulator/__init__.py index e69de29b..5b65ad40 100644 --- a/src/primaite/simulator/__init__.py +++ b/src/primaite/simulator/__init__.py @@ -0,0 +1,4 @@ +from primaite import _PRIMAITE_ROOT + +TEMP_SIM_OUTPUT = _PRIMAITE_ROOT.parent.parent / "simulation_output" +"A path at the repo root dir to use temporarily for sim output testing while in dev." diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 138c444c..739fb933 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -13,8 +13,10 @@ from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader -from primaite.simulator.system.packet_capture import PacketCapture -from primaite.simulator.system.sys_log import SysLog +from primaite.simulator.system.core.packet_capture import PacketCapture +from primaite.simulator.system.core.session_manager import SessionManager +from primaite.simulator.system.core.software_manager import SoftwareManager +from primaite.simulator.system.core.sys_log import SysLog _LOGGER = getLogger(__name__) @@ -103,8 +105,8 @@ class NIC(SimComponent): kwargs["ip_address"] = IPv4Address(kwargs["ip_address"]) if not isinstance(kwargs["gateway"], IPv4Address): kwargs["gateway"] = IPv4Address(kwargs["gateway"]) - if "mac_address" not in kwargs: - kwargs["mac_address"] = generate_mac_address() + if "mac_address" not in kwargs: + kwargs["mac_address"] = generate_mac_address() super().__init__(**kwargs) if self.ip_address == self.gateway: @@ -163,9 +165,9 @@ class NIC(SimComponent): """ if not self.connected_link: if self.connected_link != link: - _LOGGER.info(f"NIC {self} connected to Link") # TODO: Inform the Node that a link has been connected self.connected_link = link + _LOGGER.info(f"NIC {self} connected to Link {link}") else: _LOGGER.warning(f"Cannot connect link to NIC ({self.mac_address}) as it is already connected") else: @@ -254,23 +256,155 @@ class NIC(SimComponent): return f"{self.mac_address}/{self.ip_address}" +class SwitchPort(SimComponent): + """ + Models a switch port in a network switch device. + + :param mac_address: The MAC address of the SwitchPort. Defaults to a randomly set MAC address. + :param speed: The speed of the SwitchPort in Mbps (default is 100 Mbps). + :param mtu: The Maximum Transmission Unit (MTU) of the SwitchPort in Bytes, representing the largest data packet + size it can handle without fragmentation (default is 1500 B). + """ + + port_num: int = 1 + mac_address: str + "The MAC address of the SwitchPort. Defaults to a randomly set MAC address." + speed: int = 100 + "The speed of the SwitchPort in Mbps. Default is 100 Mbps." + mtu: int = 1500 + "The Maximum Transmission Unit (MTU) of the SwitchPort in Bytes. Default is 1500 B" + connected_node: Optional[Switch] = None + "The Node to which the SwitchPort is connected." + connected_link: Optional[Link] = None + "The Link to which the SwitchPort is connected." + enabled: bool = False + "Indicates whether the SwitchPort is enabled." + pcap: Optional[PacketCapture] = None + + def __init__(self, **kwargs): + """The SwitchPort constructor.""" + if "mac_address" not in kwargs: + kwargs["mac_address"] = generate_mac_address() + super().__init__(**kwargs) + + def enable(self): + """Attempt to enable the SwitchPort.""" + if not self.enabled: + if self.connected_node: + if self.connected_node.operating_state == NodeOperatingState.ON: + self.enabled = True + _LOGGER.info(f"SwitchPort {self} enabled") + self.pcap = PacketCapture(hostname=self.connected_node.hostname) + if self.connected_link: + self.connected_link.endpoint_up() + else: + _LOGGER.info(f"SwitchPort {self} cannot be enabled as the endpoint is not turned on") + else: + msg = f"SwitchPort {self} cannot be enabled as it is not connected to a Node" + _LOGGER.error(msg) + raise NetworkError(msg) + + def disable(self): + """Disable the SwitchPort.""" + if self.enabled: + self.enabled = False + _LOGGER.info(f"SwitchPort {self} disabled") + if self.connected_link: + self.connected_link.endpoint_down() + + def connect_link(self, link: Link): + """ + Connect the SwitchPort to a link. + + :param link: The link to which the SwitchPort is connected. + :raise NetworkError: When an attempt to connect a Link is made while the SwitchPort has a connected Link. + """ + if not self.connected_link: + if self.connected_link != link: + # TODO: Inform the Switch that a link has been connected + self.connected_link = link + _LOGGER.info(f"SwitchPort {self} connected to Link {link}") + self.enable() + else: + _LOGGER.warning(f"Cannot connect link to SwitchPort ({self.mac_address}) as it is already connected") + else: + msg = f"Cannot connect link to SwitchPort ({self.mac_address}) as it already has a connection" + _LOGGER.error(msg) + raise NetworkError(msg) + + def disconnect_link(self): + """Disconnect the SwitchPort from the connected Link.""" + if self.connected_link.endpoint_a == self: + self.connected_link.endpoint_a = None + if self.connected_link.endpoint_b == self: + self.connected_link.endpoint_b = None + self.connected_link = None + + def send_frame(self, frame: Frame) -> bool: + """ + Send a network frame from the SwitchPort to the connected link. + + :param frame: The network frame to be sent. + """ + if self.enabled: + self.pcap.capture(frame) + self.connected_link.transmit_frame(sender_nic=self, frame=frame) + return True + else: + # Cannot send Frame as the SwitchPort is not enabled + return False + + def receive_frame(self, frame: Frame) -> bool: + """ + Receive a network frame from the connected link if the SwitchPort is enabled. + + The Frame is passed to the Node. + + :param frame: The network frame being received. + """ + if self.enabled: + frame.decrement_ttl() + self.pcap.capture(frame) + self.connected_node.forward_frame(frame=frame, incoming_port=self) + return True + else: + return False + + def describe_state(self) -> Dict: + """ + Get the current state of the SwitchPort as a dict. + + :return: A dict containing the current state of the SwitchPort. + """ + pass + + def apply_action(self, action: str): + """ + Apply an action to the SwitchPort. + + :param action: The action to be applied. + :type action: str + """ + pass + + def __str__(self) -> str: + return f"{self.mac_address}" + + class Link(SimComponent): """ - Represents a network link between two network interface cards (NICs). + Represents a network link between NIC<-->, NIC<-->SwitchPort, or SwitchPort<-->SwitchPort. - :param endpoint_a: The first NIC connected to the Link. - :type endpoint_a: NIC - :param endpoint_b: The second NIC connected to the Link. - :type endpoint_b: NIC + :param endpoint_a: The first NIC or SwitchPort connected to the Link. + :param endpoint_b: The second NIC or SwitchPort connected to the Link. :param bandwidth: The bandwidth of the Link in Mbps (default is 100 Mbps). - :type bandwidth: int """ - endpoint_a: NIC - "The first NIC connected to the Link." - endpoint_b: NIC - "The second NIC connected to the Link." - bandwidth: int = 100 + endpoint_a: Union[NIC, SwitchPort] + "The first NIC or SwitchPort connected to the Link." + endpoint_b: Union[NIC, SwitchPort] + "The second NIC or SwitchPort connected to the Link." + bandwidth: float = 100.0 "The bandwidth of the Link in Mbps (default is 100 Mbps)." current_load: float = 0.0 "The current load on the link in Mbps." @@ -284,7 +418,7 @@ class Link(SimComponent): :raises ValueError: If endpoint_a and endpoint_b are the same NIC. """ if kwargs["endpoint_a"] == kwargs["endpoint_b"]: - msg = "endpoint_a and endpoint_b cannot be the same NIC" + msg = "endpoint_a and endpoint_b cannot be the same NIC or SwitchPort" _LOGGER.error(msg) raise ValueError(msg) super().__init__(**kwargs) @@ -292,6 +426,11 @@ class Link(SimComponent): self.endpoint_b.connect_link(self) self.endpoint_up() + @property + def current_load_percent(self) -> str: + """Get the current load formatted as a percentage string.""" + return f"{self.current_load / self.bandwidth:.5f}%" + def endpoint_up(self): """Let the Link know and endpoint has been brought up.""" if self.up: @@ -318,25 +457,30 @@ class Link(SimComponent): return self.current_load + frame_size_Mbits <= self.bandwidth return False - def transmit_frame(self, sender_nic: NIC, frame: Frame) -> bool: + def transmit_frame(self, sender_nic: Union[NIC, SwitchPort], frame: Frame) -> bool: """ - Send a network frame from one NIC to another connected NIC. + Send a network frame from one NIC or SwitchPort to another connected NIC or SwitchPort. - :param sender_nic: The NIC sending the frame. + :param sender_nic: The NIC or SwitchPort sending the frame. :param frame: The network frame to be sent. :return: True if the Frame can be sent, otherwise False. """ if self._can_transmit(frame): - receiver_nic = self.endpoint_a - if receiver_nic == sender_nic: - receiver_nic = self.endpoint_b + receiver = self.endpoint_a + if receiver == sender_nic: + receiver = self.endpoint_b frame_size = frame.size_Mbits - sent = receiver_nic.receive_frame(frame) + sent = receiver.receive_frame(frame) if sent: # Frame transmitted successfully # Load the frame size on the link self.current_load += frame_size - _LOGGER.info(f"Added {frame_size:.3f} Mbits to {self}, current load {self.current_load:.3f} Mbits") + ( + _LOGGER.info( + f"Added {frame_size:.3f} Mbits to {self}, current load {self.current_load:.3f} Mbits " + f"({self.current_load_percent})" + ) + ) return True # Received NIC disabled, reply @@ -345,7 +489,7 @@ class Link(SimComponent): _LOGGER.info(f"Cannot transmit frame as {self} is at capacity") return False - def reset_component_for_episode(self): + def reset_component_for_episode(self, episode: int): """ Link reset function. @@ -356,7 +500,7 @@ class Link(SimComponent): def describe_state(self) -> Dict: """ - Get the current state of the Libk as a dict. + Get the current state of the Link as a dict. :return: A dict containing the current state of the Link. """ @@ -375,108 +519,23 @@ class Link(SimComponent): return f"{self.endpoint_a}<-->{self.endpoint_b}" -class NodeOperatingState(Enum): - """Enumeration of Node Operating States.""" - - OFF = 0 - "The node is powered off." - ON = 1 - "The node is powered on." - SHUTTING_DOWN = 2 - "The node is in the process of shutting down." - BOOTING = 3 - "The node is in the process of booting up." - - -class Node(SimComponent): +class ARPCache: """ - A basic Node class. + The ARPCache (Address Resolution Protocol) class. - :param hostname: The node hostname on the network. - :param operating_state: The node operating state. + Responsible for maintaining a mapping between IP addresses and MAC addresses (ARP cache) for the network. It + provides methods for looking up, adding, and removing entries, and for processing ARPPackets. """ - hostname: str - "The node hostname on the network." - operating_state: NodeOperatingState = NodeOperatingState.OFF - "The hardware state of the node." - nics: Dict[str, NIC] = {} - "The NICs on the node." - - accounts: Dict = {} - "All accounts on the node." - applications: Dict = {} - "All applications on the node." - services: Dict = {} - "All services on the node." - processes: Dict = {} - "All processes on the node." - file_system: Any = None - "The nodes file system." - arp_cache: Dict[IPv4Address, ARPEntry] = {} - "The ARP cache." - sys_log: Optional[SysLog] = None - - revealed_to_red: bool = False - "Informs whether the node has been revealed to a red agent." - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.sys_log = SysLog(self.hostname) - - def turn_on(self): - """Turn on the Node.""" - if self.operating_state == NodeOperatingState.OFF: - self.operating_state = NodeOperatingState.ON - self.sys_log.info("Turned on") - for nic in self.nics.values(): - nic.enable() - - def turn_off(self): - """Turn off the Node.""" - if self.operating_state == NodeOperatingState.ON: - for nic in self.nics.values(): - nic.disable() - self.operating_state = NodeOperatingState.OFF - self.sys_log.info("Turned off") - - def connect_nic(self, nic: NIC): + def __init__(self, sys_log: "SysLog"): """ - Connect a NIC. + Initialize an ARP (Address Resolution Protocol) cache. - :param nic: The NIC to connect. - :raise NetworkError: If the NIC is already connected. + :param sys_log: The nodes sys log. """ - if nic.uuid not in self.nics: - self.nics[nic.uuid] = nic - nic.connected_node = self - self.sys_log.info(f"Connected NIC {nic}") - if self.operating_state == NodeOperatingState.ON: - nic.enable() - else: - msg = f"Cannot connect NIC {nic} as it is already connected" - self.sys_log.logger.error(msg) - _LOGGER.error(msg) - raise NetworkError(msg) - - def disconnect_nic(self, nic: Union[NIC, str]): - """ - Disconnect a NIC. - - :param nic: The NIC to Disconnect. - :raise NetworkError: If the NIC is not connected. - """ - if isinstance(nic, str): - nic = self.nics.get(nic) - if nic or nic.uuid in self.nics: - self.nics.pop(nic.uuid) - nic.disable() - self.sys_log.info(f"Disconnected NIC {nic}") - else: - msg = f"Cannot disconnect NIC {nic} as it is not connected" - self.sys_log.logger.error(msg) - _LOGGER.error(msg) - raise NetworkError(msg) + self.sys_log: "SysLog" = sys_log + self.arp: Dict[IPv4Address, ARPEntry] = {} + self.nics: Dict[str, "NIC"] = {} def _add_arp_cache_entry(self, ip_address: IPv4Address, mac_address: str, nic: NIC): """ @@ -488,7 +547,7 @@ class Node(SimComponent): """ self.sys_log.info(f"Adding ARP cache entry for {mac_address}/{ip_address} via NIC {nic}") arp_entry = ARPEntry(mac_address=mac_address, nic_uuid=nic.uuid) - self.arp_cache[ip_address] = arp_entry + self.arp[ip_address] = arp_entry def _remove_arp_cache_entry(self, ip_address: IPv4Address): """ @@ -496,37 +555,44 @@ class Node(SimComponent): :param ip_address: The IP address to be removed from the cache. """ - if ip_address in self.arp_cache: - del self.arp_cache[ip_address] + if ip_address in self.arp: + del self.arp[ip_address] - def _get_arp_cache_mac_address(self, ip_address: IPv4Address) -> Optional[str]: + def get_arp_cache_mac_address(self, ip_address: IPv4Address) -> Optional[str]: """ Get the MAC address associated with an IP address. :param ip_address: The IP address to look up in the cache. :return: The MAC address associated with the IP address, or None if not found. """ - arp_entry = self.arp_cache.get(ip_address) + arp_entry = self.arp.get(ip_address) if arp_entry: return arp_entry.mac_address - def _get_arp_cache_nic(self, ip_address: IPv4Address) -> Optional[NIC]: + def get_arp_cache_nic(self, ip_address: IPv4Address) -> Optional[NIC]: """ Get the NIC associated with an IP address. :param ip_address: The IP address to look up in the cache. :return: The NIC associated with the IP address, or None if not found. """ - arp_entry = self.arp_cache.get(ip_address) + arp_entry = self.arp.get(ip_address) if arp_entry: return self.nics[arp_entry.nic_uuid] - def _clear_arp_cache(self): - """Clear the entire ARP cache.""" - self.arp_cache.clear() + def clear_arp_cache(self): + """Clear the entire ARP cache, removing all stored entries.""" + self.arp.clear() - def _send_arp_request(self, target_ip_address: Union[IPv4Address, str]): - """Perform a standard ARP request for a given target IP address.""" + def send_arp_request(self, target_ip_address: Union[IPv4Address, str]): + """ + 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. + + :param target_ip_address: The target IP address to send an ARP request for. + """ for nic in self.nics.values(): if nic.enabled: self.sys_log.info(f"Sending ARP request from NIC {nic} for ip {target_ip_address}") @@ -547,12 +613,13 @@ class Node(SimComponent): def process_arp_packet(self, from_nic: NIC, arp_packet: ARPPacket): """ - Process an ARP packet. + Process a received ARP packet, handling both ARP requests and responses. - # TODO: This will become a service that sits on the Node. + If an ARP request is received for the local IP, a response is sent back. + If an ARP response is received, the ARP cache is updated with the new entry. - :param from_nic: The NIC the arp packet was received at. - :param arp_packet:The ARP packet to process. + :param from_nic: The NIC that received the ARP packet. + :param arp_packet: The ARP packet to be processed. """ if arp_packet.request: self.sys_log.info( @@ -581,7 +648,7 @@ class Node(SimComponent): src_mac_addr=arp_packet.sender_mac_addr, dst_mac_addr=arp_packet.target_mac_addr ) frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) - self.send_frame(frame) + from_nic.send_frame(frame) else: self.sys_log.info(f"Ignoring ARP request for {arp_packet.target_ip}") else: @@ -592,18 +659,34 @@ class Node(SimComponent): ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic ) + +class ICMP: + """ + The ICMP (Internet Control Message Protocol) class. + + Provides functionalities for managing and handling ICMP packets, including echo requests and replies. + """ + + def __init__(self, sys_log: SysLog, arp_cache: ARPCache): + """ + Initialize the ICMP (Internet Control Message Protocol) service. + + :param sys_log: The system log to store system messages and information. + :param arp_cache: The ARP cache for resolving IP to MAC address mappings. + """ + self.sys_log: SysLog = sys_log + self.arp: ARPCache = arp_cache + def process_icmp(self, frame: Frame): """ - Process an ICMP packet. + Process an ICMP packet, including handling echo requests and replies. - # TODO: This will become a service that sits on the Node. - - :param frame: The Frame containing the icmp packet to process. + :param frame: The Frame containing the ICMP packet to process. """ if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") - target_mac_address = self._get_arp_cache_mac_address(frame.ip.src_ip) - src_nic = self._get_arp_cache_nic(frame.ip.src_ip) + target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) + src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer @@ -617,19 +700,28 @@ class Node(SimComponent): sequence=frame.icmp.sequence + 1, ) frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) - self.sys_log.info(f"Sending echo reply to {frame.ip.src_ip}") + self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") src_nic.send_frame(frame) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") - def _ping( + def ping( self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None ) -> Tuple[int, Union[int, None]]: - nic = self._get_arp_cache_nic(target_ip_address) + """ + Send an ICMP echo request (ping) to a target IP address and manage the sequence and identifier. + + :param target_ip_address: The target IP address to send the ping. + :param sequence: The sequence number of the echo request. Defaults to 0. + :param identifier: An optional identifier for the ICMP packet. If None, a default will be used. + :return: A tuple containing the next sequence number and the identifier, or (0, None) if the target IP address + was not found in the ARP cache. + """ + nic = self.arp.get_arp_cache_nic(target_ip_address) if nic: sequence += 1 - target_mac_address = self._get_arp_cache_mac_address(target_ip_address) - src_nic = self._get_arp_cache_nic(target_ip_address) + target_mac_address = self.arp.get_arp_cache_mac_address(target_ip_address) + src_nic = self.arp.get_arp_cache_nic(target_ip_address) tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer @@ -647,17 +739,143 @@ class Node(SimComponent): return sequence, icmp_packet.identifier else: self.sys_log.info(f"No entry in ARP cache for {target_ip_address}") - self._send_arp_request(target_ip_address) + self.arp.send_arp_request(target_ip_address) return 0, None + +class NodeOperatingState(Enum): + """Enumeration of Node Operating States.""" + + OFF = 0 + "The node is powered off." + ON = 1 + "The node is powered on." + SHUTTING_DOWN = 2 + "The node is in the process of shutting down." + BOOTING = 3 + "The node is in the process of booting up." + + +class Node(SimComponent): + """ + A basic Node class that represents a node on the network. + + This class manages the state of the node, including the NICs (Network Interface Cards), accounts, applications, + services, processes, file system, and various managers like ARP, ICMP, SessionManager, and SoftwareManager. + + :param hostname: The node hostname on the network. + :param operating_state: The node operating state, either ON or OFF. + """ + + hostname: str + "The node hostname on the network." + operating_state: NodeOperatingState = NodeOperatingState.OFF + "The hardware state of the node." + nics: Dict[str, NIC] = {} + "The NICs on the node." + + accounts: Dict = {} + "All accounts on the node." + applications: Dict = {} + "All applications on the node." + services: Dict = {} + "All services on the node." + processes: Dict = {} + "All processes on the node." + file_system: Any = None + "The nodes file system." + sys_log: SysLog + arp: ARPCache + icmp: ICMP + session_manager: SessionManager + software_manager: SoftwareManager + + revealed_to_red: bool = False + "Informs whether the node has been revealed to a red agent." + + def __init__(self, **kwargs): + """ + Initialize the Node with various components and managers. + + This method initializes the ARP cache, ICMP handler, session manager, and software manager if they are not + provided. + """ + if not kwargs.get("sys_log"): + kwargs["sys_log"] = SysLog(kwargs["hostname"]) + if not kwargs.get("arp_cache"): + kwargs["arp"] = ARPCache(sys_log=kwargs.get("sys_log")) + if not kwargs.get("icmp"): + kwargs["icmp"] = ICMP(sys_log=kwargs.get("sys_log"), arp_cache=kwargs.get("arp")) + if not kwargs.get("session_manager"): + kwargs["session_manager"] = SessionManager(sys_log=kwargs.get("sys_log"), arp_cache=kwargs.get("arp")) + if not kwargs.get("software_manager"): + kwargs["software_manager"] = SoftwareManager( + sys_log=kwargs.get("sys_log"), session_manager=kwargs.get("session_manager") + ) + super().__init__(**kwargs) + self.arp.nics = self.nics + + def turn_on(self): + """Turn on the Node, enabling its NICs if it is in the OFF state.""" + if self.operating_state == NodeOperatingState.OFF: + self.operating_state = NodeOperatingState.ON + self.sys_log.info("Turned on") + for nic in self.nics.values(): + nic.enable() + + def turn_off(self): + """Turn off the Node, disabling its NICs if it is in the ON state.""" + if self.operating_state == NodeOperatingState.ON: + for nic in self.nics.values(): + nic.disable() + self.operating_state = NodeOperatingState.OFF + self.sys_log.info("Turned off") + + def connect_nic(self, nic: NIC): + """ + Connect a NIC (Network Interface Card) to the node. + + :param nic: The NIC to connect. + :raise NetworkError: If the NIC is already connected. + """ + if nic.uuid not in self.nics: + self.nics[nic.uuid] = nic + nic.connected_node = self + self.sys_log.info(f"Connected NIC {nic}") + if self.operating_state == NodeOperatingState.ON: + nic.enable() + else: + msg = f"Cannot connect NIC {nic} as it is already connected" + self.sys_log.logger.error(msg) + _LOGGER.error(msg) + raise NetworkError(msg) + + def disconnect_nic(self, nic: Union[NIC, str]): + """ + Disconnect a NIC (Network Interface Card) from the node. + + :param nic: The NIC to Disconnect, or its UUID. + :raise NetworkError: If the NIC is not connected. + """ + if isinstance(nic, str): + nic = self.nics.get(nic) + if nic or nic.uuid in self.nics: + self.nics.pop(nic.uuid) + nic.disable() + self.sys_log.info(f"Disconnected NIC {nic}") + else: + msg = f"Cannot disconnect NIC {nic} as it is not connected" + self.sys_log.logger.error(msg) + _LOGGER.error(msg) + raise NetworkError(msg) + def ping(self, target_ip_address: Union[IPv4Address, str], pings: int = 4) -> bool: """ - Ping an IP address. - - Performs a standard ICMP echo request/response four times. + Ping an IP address, performing a standard ICMP echo request/response. :param target_ip_address: The target IP address to ping. - :return: True if successful, otherwise False. + :param pings: The number of pings to attempt, default is 4. + :return: True if the ping is successful, otherwise False. """ if not isinstance(target_ip_address, IPv4Address): target_ip_address = IPv4Address(target_ip_address) @@ -665,7 +883,7 @@ class Node(SimComponent): self.sys_log.info(f"Attempting to ping {target_ip_address}") sequence, identifier = 0, None while sequence < pings: - sequence, identifier = self._ping(target_ip_address, sequence, identifier) + sequence, identifier = self.icmp.ping(target_ip_address, sequence, identifier) return True self.sys_log.info("Ping failed as the node is turned off") return False @@ -681,20 +899,101 @@ class Node(SimComponent): def receive_frame(self, frame: Frame, from_nic: NIC): """ - Receive a Frame from the connected NIC. + Receive a Frame from the connected NIC and process it. - The Frame is passed to up to the SessionManager. + Depending on the protocol, the frame is passed to the appropriate handler such as ARP or ICMP, or up to the + SessionManager if no code manager exists. :param frame: The Frame being received. + :param from_nic: The NIC that received the frame. """ if frame.ip.protocol == IPProtocol.TCP: if frame.tcp.src_port == Port.ARP: - self.process_arp_packet(from_nic=from_nic, arp_packet=frame.arp) + self.arp.process_arp_packet(from_nic=from_nic, arp_packet=frame.arp) elif frame.ip.protocol == IPProtocol.UDP: pass elif frame.ip.protocol == IPProtocol.ICMP: - self.process_icmp(frame=frame) + self.icmp.process_icmp(frame=frame) def describe_state(self) -> Dict: - """Describe the state of a Node.""" + """ + Describe the state of the Node. + + :return: A dictionary representing the state of the node. + """ pass + + +class Switch(Node): + """A class representing a Layer 2 network switch.""" + + num_ports: int = 24 + "The number of ports on the switch." + switch_ports: Dict[int, SwitchPort] = {} + "The SwitchPorts on the switch." + dst_mac_table: Dict[str, SwitchPort] = {} + "A MAC address table mapping destination MAC addresses to corresponding SwitchPorts." + + def describe_state(self) -> Dict: + """TODO.""" + pass + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if not self.switch_ports: + 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 = port_num + + def _add_mac_table_entry(self, mac_address: str, switch_port: SwitchPort): + mac_table_port = self.dst_mac_table.get(mac_address) + if not mac_table_port: + self.dst_mac_table[mac_address] = switch_port + self.sys_log.info(f"Added MAC table entry: Port {switch_port.port_num} -> {mac_address}") + else: + if mac_table_port != switch_port: + self.dst_mac_table.pop(mac_address) + self.sys_log.info(f"Removed MAC table entry: Port {mac_table_port.port_num} -> {mac_address}") + self._add_mac_table_entry(mac_address, switch_port) + + def forward_frame(self, frame: Frame, incoming_port: SwitchPort): + """ + Forward a frame to the appropriate port based on the destination MAC address. + + :param frame: The Frame to be forwarded. + :param incoming_port: The port number from which the frame was received. + """ + src_mac = frame.ethernet.src_mac_addr + dst_mac = frame.ethernet.dst_mac_addr + self._add_mac_table_entry(src_mac, incoming_port) + + outgoing_port = self.dst_mac_table.get(dst_mac) + if outgoing_port or 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: + port.send_frame(frame) + + def disconnect_link_from_port(self, link: Link, port_number: int): + """ + Disconnect a given link from the specified port number on the switch. + + :param link: The Link object to be disconnected. + :param port_number: The port number on the switch from where the link should be disconnected. + :raise NetworkError: When an invalid port number is provided or the link does not match the connection. + """ + port = self.switch_ports.get(port_number) + if port is None: + msg = f"Invalid port number {port_number} on the switch" + _LOGGER.error(msg) + raise NetworkError(msg) + + if port.connected_link != link: + msg = f"The link does not match the connection at port number {port_number}" + _LOGGER.error(msg) + raise NetworkError(msg) + + port.disconnect_link() diff --git a/src/primaite/simulator/system/services/icmp.py b/src/primaite/simulator/network/nodes/switch.py similarity index 100% rename from src/primaite/simulator/system/services/icmp.py rename to src/primaite/simulator/network/nodes/switch.py diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 31a645b5..f9c5827d 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -1,6 +1,6 @@ from abc import abstractmethod from enum import Enum -from typing import Any, List, Dict, Set +from typing import Any, Dict, List, Set from primaite.simulator.system.software import IOSoftware @@ -22,6 +22,7 @@ class Application(IOSoftware): Applications are user-facing programs that may perform input/output operations. """ + operating_state: ApplicationOperatingState "The current operating state of the Application." execution_control_status: str @@ -61,9 +62,9 @@ class Application(IOSoftware): """ pass - def send(self, payload: Any) -> bool: + def send(self, payload: Any, session_id: str, **kwargs) -> bool: """ - Sends a payload to the SessionManager + Sends a payload to the SessionManager. The specifics of how the payload is processed and whether a response payload is generated should be implemented in subclasses. @@ -73,7 +74,7 @@ class Application(IOSoftware): """ pass - def receive(self, payload: Any) -> bool: + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: """ Receives a payload from the SessionManager. diff --git a/src/primaite/simulator/system/arp_cache.py b/src/primaite/simulator/system/arp_cache.py deleted file mode 100644 index 1fb830ab..00000000 --- a/src/primaite/simulator/system/arp_cache.py +++ /dev/null @@ -1,30 +0,0 @@ -from ipaddress import IPv4Address - -from pydantic import BaseModel - - -class ARPCacheService(BaseModel): - def __init__(self, node): - super().__init__() - self.node = node - - def _add_arp_cache_entry(self, ip_address: IPv4Address, mac_address: str, nic: NIC): - ... - - def _remove_arp_cache_entry(self, ip_address: IPv4Address): - ... - - def _get_arp_cache_mac_address(self, ip_address: IPv4Address) -> Optional[str]: - ... - - def _get_arp_cache_nic(self, ip_address: IPv4Address) -> Optional[NIC]: - ... - - def _clear_arp_cache(self): - ... - - def _send_arp_request(self, target_ip_address: Union[IPv4Address, str]): - ... - - def process_arp_packet(self, from_nic: NIC, arp_packet: ARPPacket): - ... \ No newline at end of file diff --git a/src/primaite/simulator/system/core/__init__.py b/src/primaite/simulator/system/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/packet_capture.py b/src/primaite/simulator/system/core/packet_capture.py similarity index 82% rename from src/primaite/simulator/system/packet_capture.py rename to src/primaite/simulator/system/core/packet_capture.py index c05b6db9..7741416d 100644 --- a/src/primaite/simulator/system/packet_capture.py +++ b/src/primaite/simulator/system/core/packet_capture.py @@ -1,5 +1,8 @@ import logging from pathlib import Path +from typing import Optional + +from primaite.simulator import TEMP_SIM_OUTPUT class _JSONFilter(logging.Filter): @@ -17,7 +20,7 @@ class PacketCapture: The PCAPs are logged to: //__pcap.log """ - def __init__(self, hostname: str, ip_address: str): + def __init__(self, hostname: str, ip_address: Optional[str] = None): """ Initialize the PacketCapture process. @@ -40,7 +43,7 @@ class PacketCapture: log_format = "%(message)s" file_handler.setFormatter(logging.Formatter(log_format)) - logger_name = f"{self.hostname}_{self.ip_address}_pcap" + logger_name = f"{self.hostname}_{self.ip_address}_pcap" if self.ip_address else f"{self.hostname}_pcap" self.logger = logging.getLogger(logger_name) self.logger.setLevel(60) # Custom log level > CRITICAL to prevent any unwanted standard DEBUG-CRITICAL logs self.logger.addHandler(file_handler) @@ -49,9 +52,11 @@ class PacketCapture: def _get_log_path(self) -> Path: """Get the path for the log file.""" - root = Path(__file__).parent.parent.parent.parent.parent.parent / "simulation_output" / self.hostname + root = TEMP_SIM_OUTPUT / self.hostname root.mkdir(exist_ok=True, parents=True) - return root / f"{self.hostname}_{self.ip_address}_pcap.log" + if self.ip_address: + return root / f"{self.hostname}_{self.ip_address}_pcap.log" + return root / f"{self.hostname}_pcap.log" def capture(self, frame): # noqa - I'll have a circular import and cant use if TYPE_CHECKING ;( """ diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py new file mode 100644 index 00000000..96d6251d --- /dev/null +++ b/src/primaite/simulator/system/core/session_manager.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING + +from primaite.simulator.core import SimComponent +from primaite.simulator.network.transmission.data_link_layer import Frame +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + +if TYPE_CHECKING: + from primaite.simulator.network.hardware.base import ARPCache + from primaite.simulator.system.core.software_manager import SoftwareManager + from primaite.simulator.system.core.sys_log import SysLog + + +class Session(SimComponent): + """ + Models a network session. + + Encapsulates information related to communication between two network endpoints, including the protocol, + source and destination IPs and ports. + + :param protocol: The IP protocol used in the session. + :param src_ip: The source IP address. + :param dst_ip: The destination IP address. + :param src_port: The source port number (optional). + :param dst_port: The destination port number (optional). + :param connected: A flag indicating whether the session is connected. + """ + + protocol: IPProtocol + src_ip: IPv4Address + dst_ip: IPv4Address + src_port: Optional[Port] + dst_port: Optional[Port] + connected: bool = False + + @classmethod + def from_session_key( + cls, session_key: Tuple[IPProtocol, IPv4Address, IPv4Address, Optional[Port], Optional[Port]] + ) -> Session: + """ + Create a Session instance from a session key tuple. + + :param session_key: Tuple containing the session details. + :return: A Session instance. + """ + protocol, src_ip, dst_ip, src_port, dst_port = session_key + return Session(protocol=protocol, src_ip=src_ip, dst_ip=dst_ip, src_port=src_port, dst_port=dst_port) + + def describe_state(self) -> Dict: + """ + Describes the current state of the session as a dictionary. + + :return: A dictionary containing the current state of the session. + """ + pass + + +class SessionManager: + """ + Manages network sessions, including session creation, lookup, and communication with other components. + + :param sys_log: A reference to the system log component. + :param arp_cache: A reference to the ARP cache component. + """ + + def __init__(self, sys_log: SysLog, arp_cache: "ARPCache"): + self.sessions_by_key: Dict[ + Tuple[IPProtocol, IPv4Address, IPv4Address, Optional[Port], Optional[Port]], Session + ] = {} + self.sessions_by_uuid: Dict[str, Session] = {} + self.sys_log: SysLog = sys_log + self.software_manager: SoftwareManager = None # Noqa + self.arp_cache: "ARPCache" = arp_cache + + def describe_state(self) -> Dict: + """ + Describes the current state of the session manager as a dictionary. + + :return: A dictionary containing the current state of the session manager. + """ + pass + + @staticmethod + def _get_session_key( + frame: Frame, from_source: bool = True + ) -> Tuple[IPProtocol, IPv4Address, IPv4Address, Optional[Port], Optional[Port]]: + """ + Extracts the session key from the given frame. + + The session key is a tuple containing the following elements: + - IPProtocol: The transport protocol (e.g. TCP, UDP, ICMP). + - IPv4Address: The source IP address. + - IPv4Address: The destination IP address. + - Optional[Port]: The source port number (if applicable). + - Optional[Port]: The destination port number (if applicable). + + :param frame: The network frame from which to extract the session key. + :param from_source: A flag to indicate if the key should be extracted from the source or destination. + :return: A tuple containing the session key. + """ + protocol = frame.ip.protocol + src_ip = frame.ip.src_ip + dst_ip = frame.ip.dst_ip + if protocol == IPProtocol.TCP: + if from_source: + src_port = frame.tcp.src_port + dst_port = frame.tcp.dst_port + else: + dst_port = frame.tcp.src_port + src_port = frame.tcp.dst_port + elif protocol == IPProtocol.UDP: + if from_source: + src_port = frame.udp.src_port + dst_port = frame.udp.dst_port + else: + dst_port = frame.udp.src_port + src_port = frame.udp.dst_port + else: + src_port = None + dst_port = None + return protocol, src_ip, dst_ip, src_port, dst_port + + def receive_payload_from_software_manager(self, payload: Any, session_id: Optional[int] = None): + """ + Receive a payload from the SoftwareManager. + + If no session_id, a Session is established. Once established, the payload is sent to ``send_payload_to_nic``. + + :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. + """ + # TODO: Implement session creation and + + self.send_payload_to_nic(payload, session_id) + + def send_payload_to_software_manager(self, payload: Any, session_id: int): + """ + Send a payload to the software manager. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload originates from. + """ + self.software_manager.receive_payload_from_session_manger() + + def send_payload_to_nic(self, payload: Any, session_id: int): + """ + Send a payload across the Network. + + Takes a payload and a session_id. Builds a Frame and sends it across the network via a NIC. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload originates from + """ + # TODO: Implement frame construction and sent to NIC. + pass + + def receive_payload_from_nic(self, frame: Frame): + """ + Receive a Frame from the NIC. + + Extract the session key using the _get_session_key method, and forward the payload to the appropriate + session. If the session does not exist, a new one is created. + + :param frame: The frame being received. + """ + session_key = self._get_session_key(frame) + session = self.sessions_by_key.get(session_key) + if not session: + # Create new session + session = Session.from_session_key(session_key) + self.sessions_by_key[session_key] = session + self.sessions_by_uuid[session.uuid] = session + self.software_manager.receive_payload_from_session_manger(payload=frame, session=session) + # TODO: Implement the frame deconstruction and send to SoftwareManager. diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py new file mode 100644 index 00000000..411fb6e9 --- /dev/null +++ b/src/primaite/simulator/system/core/software_manager.py @@ -0,0 +1,99 @@ +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING, Union + +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.core.session_manager import Session +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.services.service import Service +from primaite.simulator.system.software import SoftwareType + +if TYPE_CHECKING: + from primaite.simulator.system.core.session_manager import SessionManager + from primaite.simulator.system.core.sys_log import SysLog + + +class SoftwareManager: + """A class that manages all running Services and Applications on a Node and facilitates their communication.""" + + def __init__(self, session_manager: "SessionManager", sys_log: "SysLog"): + """ + Initialize a new instance of SoftwareManager. + + :param session_manager: The session manager handling network communications. + """ + self.session_manager = session_manager + self.services: Dict[str, Service] = {} + self.applications: Dict[str, Application] = {} + self.port_protocol_mapping: Dict[Tuple[Port, IPProtocol], Union[Service, Application]] = {} + self.sys_log: SysLog = sys_log + + def add_service(self, name: str, service: Service, port: Port, protocol: IPProtocol): + """ + Add a Service to the manager. + + :param name: The name of the service. + :param service: The service instance. + :param port: The port used by the service. + :param protocol: The network protocol used by the service. + """ + service.software_manager = self + self.services[name] = service + self.port_protocol_mapping[(port, protocol)] = service + + def add_application(self, name: str, application: Application, port: Port, protocol: IPProtocol): + """ + Add an Application to the manager. + + :param name: The name of the application. + :param application: The application instance. + :param port: The port used by the application. + :param protocol: The network protocol used by the application. + """ + application.software_manager = self + self.applications[name] = application + self.port_protocol_mapping[(port, protocol)] = application + + def send_internal_payload(self, target_software: str, target_software_type: SoftwareType, payload: Any): + """ + Send a payload to a specific service or application. + + :param target_software: The name of the target service or application. + :param target_software_type: The type of software (Service, Application, Process). + :param payload: The data to be sent. + :param receiver_type: The type of the target, either 'service' or 'application'. + """ + if target_software_type is SoftwareType.SERVICE: + receiver = self.services.get(target_software) + elif target_software_type is SoftwareType.APPLICATION: + receiver = self.applications.get(target_software) + else: + raise ValueError(f"Invalid receiver type {target_software_type}") + + if receiver: + receiver.receive_payload(payload) + else: + raise ValueError(f"No {target_software_type.name.lower()} found with the name {target_software}") + + def send_payload_to_session_manger(self, payload: Any, session_id: Optional[int] = None): + """ + Send a payload to the SessionManager. + + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. + """ + self.session_manager.receive_payload_from_software_manager(payload, session_id) + + def receive_payload_from_session_manger(self, payload: Any, session: Session): + """ + Receive a payload from the SessionManager and forward it to the corresponding service or application. + + :param payload: The payload being received. + :param session: The transport session the payload originates from. + """ + # receiver: Optional[Union[Service, Application]] = self.port_protocol_mapping.get((port, protocol), None) + # if receiver: + # receiver.receive_payload(None, payload) + # else: + # raise ValueError(f"No service or application found for port {port} and protocol {protocol}") + pass diff --git a/src/primaite/simulator/system/sys_log.py b/src/primaite/simulator/system/core/sys_log.py similarity index 90% rename from src/primaite/simulator/system/sys_log.py rename to src/primaite/simulator/system/core/sys_log.py index bb2fd7ec..4b858c2e 100644 --- a/src/primaite/simulator/system/sys_log.py +++ b/src/primaite/simulator/system/core/sys_log.py @@ -1,6 +1,8 @@ import logging from pathlib import Path +from primaite.simulator import TEMP_SIM_OUTPUT + class _NotJSONFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: @@ -31,8 +33,10 @@ class SysLog: def _setup_logger(self): """ - Configures the logger for this SysLog instance. The logger is set to the DEBUG level, - and is equipped with a handler that writes to a file and filters out JSON-like messages. + Configures the logger for this SysLog instance. + + The logger is set to the DEBUG level, and is equipped with a handler that writes to a file and filters out + JSON-like messages. """ log_path = self._get_log_path() @@ -54,7 +58,7 @@ class SysLog: :return: Path object representing the location of the log file. """ - root = Path(__file__).parent.parent.parent.parent.parent.parent / "simulation_output" / self.hostname + root = TEMP_SIM_OUTPUT / self.hostname root.mkdir(exist_ok=True, parents=True) return root / f"{self.hostname}_sys.log" diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py index 68f3102f..bbd94345 100644 --- a/src/primaite/simulator/system/processes/process.py +++ b/src/primaite/simulator/system/processes/process.py @@ -1,6 +1,6 @@ from abc import abstractmethod from enum import Enum -from typing import List, Dict, Any +from typing import Dict from primaite.simulator.system.software import Software @@ -20,6 +20,7 @@ class Process(Software): Processes are executed by a Node and do not have the ability to performing input/output operations. """ + operating_state: ProcessOperatingState "The current operating state of the Process." diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index a66249ad..c820cef3 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -28,6 +28,7 @@ class Service(IOSoftware): Services are programs that run in the background and may perform input/output operations. """ + operating_state: ServiceOperatingState "The current operating state of the Service." @@ -61,9 +62,9 @@ class Service(IOSoftware): """ pass - def send(self, payload: Any) -> bool: + def send(self, payload: Any, session_id: str, **kwargs) -> bool: """ - Sends a payload to the SessionManager + Sends a payload to the SessionManager. The specifics of how the payload is processed and whether a response payload is generated should be implemented in subclasses. @@ -73,7 +74,7 @@ class Service(IOSoftware): """ pass - def receive(self, payload: Any) -> bool: + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: """ Receives a payload from the SessionManager. @@ -84,4 +85,3 @@ class Service(IOSoftware): :return: True if successful, False otherwise. """ pass - diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index e5991429..854e7e2b 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -6,6 +6,24 @@ from primaite.simulator.core import SimComponent from primaite.simulator.network.transmission.transport_layer import Port +class SoftwareType(Enum): + """ + An enumeration representing the different types of software within a simulated environment. + + Members: + - APPLICATION: User-facing programs that may perform input/output operations. + - SERVICE: Represents programs that run in the background and may perform input/output operations. + - PROCESS: Software executed by a Node that does not have the ability to performing input/output operations. + """ + + APPLICATION = 1 + "User-facing software that may perform input/output operations." + SERVICE = 2 + "Software that runs in the background and may perform input/output operations." + PROCESS = 3 + "Software executed by a Node that does not have the ability to performing input/output operations." + + class SoftwareHealthState(Enum): """Enumeration of the Software Health States.""" @@ -41,6 +59,7 @@ class Software(SimComponent): This class is intended to be subclassed by specific types of software entities. It outlines the fundamental attributes and behaviors expected of any software in the simulation. """ + name: str "The name of the software." health_state_actual: SoftwareHealthState @@ -100,6 +119,7 @@ class IOSoftware(Software): OSI Model), process them according to their internals, and send a response payload back to the SessionManager if required. """ + installing_count: int = 0 "The number of times the software has been installed. Default is 0." max_sessions: int = 1 @@ -111,26 +131,44 @@ class IOSoftware(Software): ports: Set[Port] "The set of ports to which the software is connected." - def send(self, payload: Any) -> bool: + @abstractmethod + def describe_state(self) -> Dict: """ - Sends a payload to the SessionManager + 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 + """ + pass + + def send(self, payload: Any, session_id: str, **kwargs) -> bool: + """ + Sends a payload to the SessionManager. The specifics of how the payload is processed and whether a response payload is generated should be implemented in subclasses. :param payload: The payload to send. - :return: True if successful, False otherwise. + :param session_id: The identifier of the session that the payload is associated with. + :param kwargs: Additional keyword arguments specific to the implementation. + :return: True if the payload was successfully sent, False otherwise. """ pass - def receive(self, payload: Any) -> bool: + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: """ Receives a payload from the SessionManager. The specifics of how the payload is processed and whether a response payload is generated should be implemented in subclasses. + :param payload: The payload to receive. - :return: True if successful, False otherwise. + :param session_id: The identifier of the session that the payload is associated with. + :param kwargs: Additional keyword arguments specific to the implementation. + :return: True if the payload was successfully received and processed, False otherwise. """ pass diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py index 9681e72d..27545edc 100644 --- a/tests/integration_tests/network/test_frame_transmission.py +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -1,4 +1,4 @@ -from primaite.simulator.network.hardware.base import Link, NIC, Node +from primaite.simulator.network.hardware.base import Link, NIC, Node, Switch def test_node_to_node_ping(): @@ -35,10 +35,40 @@ def test_multi_nic(): node_c.connect_nic(nic_c) node_c.turn_on() - link_a_b1 = Link(endpoint_a=nic_a, endpoint_b=nic_b1) + Link(endpoint_a=nic_a, endpoint_b=nic_b1) - link_b2_c = Link(endpoint_a=nic_b2, endpoint_b=nic_c) + Link(endpoint_a=nic_b2, endpoint_b=nic_c) node_a.ping("192.168.0.11") - node_c.ping("10.0.0.12") \ No newline at end of file + node_c.ping("10.0.0.12") + + +def test_switched_network(): + node_a = Node(hostname="node_a") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_a.connect_nic(nic_a) + node_a.turn_on() + + node_b = Node(hostname="node_b") + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_b.connect_nic(nic_b) + node_b.turn_on() + + node_c = Node(hostname="node_c") + nic_c = NIC(ip_address="192.168.0.12", subnet_mask="255.255.255.0", gateway="192.168.0.1") + node_c.connect_nic(nic_c) + node_c.turn_on() + + switch_1 = Switch(hostname="switch_1") + switch_1.turn_on() + + switch_2 = Switch(hostname="switch_2") + switch_2.turn_on() + + Link(endpoint_a=nic_a, endpoint_b=switch_1.switch_ports[1]) + Link(endpoint_a=nic_b, endpoint_b=switch_1.switch_ports[2]) + Link(endpoint_a=switch_1.switch_ports[24], endpoint_b=switch_2.switch_ports[24]) + Link(endpoint_a=nic_c, endpoint_b=switch_2.switch_ports[1]) + + node_a.ping("192.168.0.12") From c8ee409b3b26ad8351fa21f802523baf9755ee12 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 8 Aug 2023 08:29:51 +0100 Subject: [PATCH 25/63] #1714: run precommit --- src/primaite/simulator/file_system/file_system_file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index b3358372..5f784072 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -26,7 +26,6 @@ class FileSystemFile(FileSystemItem): """ # set random file type if none provided - # set random file type if none provided if kwargs.get("file_type") is None: kwargs["file_type"] = choice(list(FileSystemFileType)) From f854404ba0d3588443d6bfba328bb8ecfb82add1 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 8 Aug 2023 08:41:50 +0100 Subject: [PATCH 26/63] #1714: added file system to changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d66257b5..dd7e3466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- File System - ability to emulate a node's file system during a simulation + ## [2.0.0] - 2023-07-26 ### Added From c2b783c858e159ceef0596d33d77becfb86c4ce4 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 8 Aug 2023 08:17:40 +0000 Subject: [PATCH 27/63] Apply suggestions from code review --- src/primaite/simulator/file_system/file_system_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index 5f784072..95c824d6 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -32,7 +32,7 @@ class FileSystemFile(FileSystemItem): # set random file size if none provided if kwargs.get("size") is None: - kwargs["size"] = float(randint(1, file_type_sizes_KB[kwargs["file_type"]])) + kwargs["size"] = file_type_sizes_KB[kwargs["file_type"]] super().__init__(**kwargs) From 2f27e02877f8fdd921c8fd510b99fc60951896bb Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 8 Aug 2023 09:53:32 +0100 Subject: [PATCH 28/63] #1714: fix precommit --- src/primaite/simulator/file_system/file_system_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index 95c824d6..f9fc2e1f 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -1,4 +1,4 @@ -from random import choice, randint +from random import choice from typing import Dict from primaite.simulator.file_system.file_system_file_type import file_type_sizes_KB, FileSystemFileType From 9fbc3c91f771fd8dbf96d3ad16633655590a6587 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 8 Aug 2023 20:22:18 +0100 Subject: [PATCH 29/63] #1706 - Finished up the Node and Switch MVP. Added full extensive documentation on what's happening at each step. --- CHANGELOG.md | 7 + docs/_static/four_node_two_switch_network.png | Bin 0 -> 90397 bytes docs/source/simulation.rst | 2 +- .../network/base_hardware.rst | 635 ++++++++++++++++-- .../network/transport_to_data_link_layer.rst | 13 + .../simulator/network/hardware/base.py | 74 +- .../network/{ => hardware}/nodes/__init__.py | 0 .../simulator/network/nodes/switch.py | 0 .../network/test_frame_transmission.py | 58 +- .../network/test_link_connection.py | 4 +- 10 files changed, 702 insertions(+), 91 deletions(-) create mode 100644 docs/_static/four_node_two_switch_network.png rename src/primaite/simulator/network/{ => hardware}/nodes/__init__.py (100%) delete mode 100644 src/primaite/simulator/network/nodes/switch.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7e3466..dd8afbce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Network Hardware - Added base hardware module with NIC, SwitchPort, Node, Switch, and Link. Nodes and Switches have +fundamental services like ARP, ICMP, and PCAP running them by default. +- Network Transmission - Modelled OSI Model layers 1 through to 5 with various classes for creating network frames and +transmitting them from a Service/Application, down through the layers, over the wire, and back up through the layers to +a Service/Application another machine. +- system - Added the core structure of Application, Services, and Components. Also added a SoftwareManager and +SessionManager. - File System - ability to emulate a node's file system during a simulation ## [2.0.0] - 2023-07-26 diff --git a/docs/_static/four_node_two_switch_network.png b/docs/_static/four_node_two_switch_network.png new file mode 100644 index 0000000000000000000000000000000000000000..4283910787c45b0fb9148d92432e19f033d80c85 GIT binary patch literal 90397 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 80:af:f2:f6:58:b7 + 2023-08-08 15:50:08,413 INFO: Added MAC table entry: Port 6 -> 84:20:7c:ec:a5:c6 + + + +**switch_2 sys log** + +.. code-block:: + + 2023-08-08 15:50:08,374 INFO: Turned on + 2023-08-08 15:50:08,381 INFO: SwitchPort aa:58:fa:66:d7:be enabled + 2023-08-08 15:50:08,383 INFO: SwitchPort 72:d2:1e:88:e9:45 enabled + 2023-08-08 15:50:08,384 INFO: SwitchPort 96:77:39:d1:de:44 enabled + 2023-08-08 15:50:08,411 INFO: Added MAC table entry: Port 6 -> 80:af:f2:f6:58:b7 + 2023-08-08 15:50:08,412 INFO: Added MAC table entry: Port 2 -> 84:20:7c:ec:a5:c6 diff --git a/docs/source/simulation_components/network/transport_to_data_link_layer.rst b/docs/source/simulation_components/network/transport_to_data_link_layer.rst index 9332b57c..4961d337 100644 --- a/docs/source/simulation_components/network/transport_to_data_link_layer.rst +++ b/docs/source/simulation_components/network/transport_to_data_link_layer.rst @@ -55,6 +55,19 @@ PrimAITE-specific metadata required for reinforcement learning (RL) purposes. Data Link Layer (Layer 2) ######################### +**ARPEntry:** Represents an entry in the ARP cache. It consists of the following fields: + + - **mac_address:** The MAC address associated with the IP address. + - **nic_uuid:** The NIC (Network Interface Card) UUID through which the NIC with the IP address is reachable. + +**ARPPacket:** Represents the ARP layer of a network frame, and it includes the following fields: + + - **request:** ARP operation. Set to True for a request and False for a reply. + - **sender_mac_addr:** Sender's MAC address. + - **sender_ip:** Sender's IP address (IPv4 format). + - **target_mac_addr:** Target's MAC address. + - **target_ip:** Target's IP address (IPv4 format). + **EthernetHeader:** Represents the Ethernet layer of a network frame. It includes source and destination MAC addresses. This header is used to identify the physical hardware addresses of devices on a local network. diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 739fb933..eb406521 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -6,6 +6,8 @@ from enum import Enum from ipaddress import IPv4Address, IPv4Network from typing import Any, Dict, List, Optional, Tuple, Union +from prettytable import PrettyTable + from primaite import getLogger from primaite.exceptions import NetworkError from primaite.simulator.core import SimComponent @@ -136,22 +138,23 @@ class NIC(SimComponent): if self.connected_node: if self.connected_node.operating_state == NodeOperatingState.ON: self.enabled = True - _LOGGER.info(f"NIC {self} enabled") + self.connected_node.sys_log.info(f"NIC {self} enabled") self.pcap = PacketCapture(hostname=self.connected_node.hostname, ip_address=self.ip_address) if self.connected_link: self.connected_link.endpoint_up() else: - _LOGGER.info(f"NIC {self} cannot be enabled as the endpoint is not turned on") + self.connected_node.sys_log.error(f"NIC {self} cannot be enabled as the endpoint is not turned on") else: - msg = f"NIC {self} cannot be enabled as it is not connected to a Node" - _LOGGER.error(msg) - raise NetworkError(msg) + _LOGGER.error(f"NIC {self} cannot be enabled as it is not connected to a Node") def disable(self): """Disable the NIC.""" if self.enabled: self.enabled = False - _LOGGER.info(f"NIC {self} disabled") + if self.connected_node: + self.connected_node.sys_log.info(f"NIC {self} disabled") + else: + _LOGGER.info(f"NIC {self} disabled") if self.connected_link: self.connected_link.endpoint_down() @@ -161,7 +164,6 @@ class NIC(SimComponent): :param link: The link to which the NIC is connected. :type link: :class:`~primaite.simulator.network.transmission.physical_layer.Link` - :raise NetworkError: When an attempt to connect a Link is made while the NIC has a connected Link. """ if not self.connected_link: if self.connected_link != link: @@ -169,11 +171,9 @@ class NIC(SimComponent): self.connected_link = link _LOGGER.info(f"NIC {self} connected to Link {link}") else: - _LOGGER.warning(f"Cannot connect link to NIC ({self.mac_address}) as it is already connected") + _LOGGER.error(f"Cannot connect Link to NIC ({self.mac_address}) as it is already connected") else: - msg = f"Cannot connect link to NIC ({self.mac_address}) as it already has a connection" - _LOGGER.error(msg) - raise NetworkError(msg) + _LOGGER.error(f"Cannot connect Link to NIC ({self.mac_address}) as it already has a connection") def disconnect_link(self): """Disconnect the NIC from the connected Link.""" @@ -293,12 +293,14 @@ class SwitchPort(SimComponent): if self.connected_node: if self.connected_node.operating_state == NodeOperatingState.ON: self.enabled = True - _LOGGER.info(f"SwitchPort {self} enabled") + self.connected_node.sys_log.info(f"SwitchPort {self} enabled") self.pcap = PacketCapture(hostname=self.connected_node.hostname) if self.connected_link: self.connected_link.endpoint_up() else: - _LOGGER.info(f"SwitchPort {self} cannot be enabled as the endpoint is not turned on") + self.connected_node.sys_log.info( + f"SwitchPort {self} cannot be enabled as the endpoint is not turned on" + ) else: msg = f"SwitchPort {self} cannot be enabled as it is not connected to a Node" _LOGGER.error(msg) @@ -308,7 +310,10 @@ class SwitchPort(SimComponent): """Disable the SwitchPort.""" if self.enabled: self.enabled = False - _LOGGER.info(f"SwitchPort {self} disabled") + if self.connected_node: + self.connected_node.sys_log.info(f"SwitchPort {self} disabled") + else: + _LOGGER.info(f"SwitchPort {self} disabled") if self.connected_link: self.connected_link.endpoint_down() @@ -317,7 +322,6 @@ class SwitchPort(SimComponent): Connect the SwitchPort to a link. :param link: The link to which the SwitchPort is connected. - :raise NetworkError: When an attempt to connect a Link is made while the SwitchPort has a connected Link. """ if not self.connected_link: if self.connected_link != link: @@ -326,11 +330,9 @@ class SwitchPort(SimComponent): _LOGGER.info(f"SwitchPort {self} connected to Link {link}") self.enable() else: - _LOGGER.warning(f"Cannot connect link to SwitchPort ({self.mac_address}) as it is already connected") + _LOGGER.error(f"Cannot connect Link to SwitchPort {self.mac_address} as it is already connected") else: - msg = f"Cannot connect link to SwitchPort ({self.mac_address}) as it already has a connection" - _LOGGER.error(msg) - raise NetworkError(msg) + _LOGGER.error(f"Cannot connect link to SwitchPort {self.mac_address} as it already has a connection") def disconnect_link(self): """Disconnect the SwitchPort from the connected Link.""" @@ -815,16 +817,34 @@ class Node(SimComponent): super().__init__(**kwargs) self.arp.nics = self.nics - def turn_on(self): - """Turn on the Node, enabling its NICs if it is in the OFF state.""" + def show(self): + """Prints a table of the NICs on the Node..""" + from prettytable import PrettyTable + + table = PrettyTable(["MAC Address", "Address", "Default Gateway", "Speed", "Status"]) + + for nic in self.nics.values(): + table.add_row( + [ + nic.mac_address, + f"{nic.ip_address}/{nic.ip_network.prefixlen}", + nic.gateway, + nic.speed, + "Enabled" if nic.enabled else "Disabled", + ] + ) + print(table) + + def power_on(self): + """Power on the Node, enabling its NICs if it is in the OFF state.""" if self.operating_state == NodeOperatingState.OFF: self.operating_state = NodeOperatingState.ON self.sys_log.info("Turned on") for nic in self.nics.values(): nic.enable() - def turn_off(self): - """Turn off the Node, disabling its NICs if it is in the ON state.""" + def power_off(self): + """Power off the Node, disabling its NICs if it is in the ON state.""" if self.operating_state == NodeOperatingState.ON: for nic in self.nics.values(): nic.disable() @@ -934,6 +954,14 @@ class Switch(Node): dst_mac_table: Dict[str, SwitchPort] = {} "A MAC address table mapping destination MAC addresses to corresponding SwitchPorts." + def show(self): + """Prints a table of the SwitchPorts on the Switch.""" + table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) + + for port_num, port in self.switch_ports.items(): + table.add_row([port_num, port.mac_address, port.speed, "Enabled" if port.enabled else "Disabled"]) + print(table) + def describe_state(self) -> Dict: """TODO.""" pass diff --git a/src/primaite/simulator/network/nodes/__init__.py b/src/primaite/simulator/network/hardware/nodes/__init__.py similarity index 100% rename from src/primaite/simulator/network/nodes/__init__.py rename to src/primaite/simulator/network/hardware/nodes/__init__.py diff --git a/src/primaite/simulator/network/nodes/switch.py b/src/primaite/simulator/network/nodes/switch.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py index 27545edc..3840c302 100644 --- a/tests/integration_tests/network/test_frame_transmission.py +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -2,15 +2,17 @@ from primaite.simulator.network.hardware.base import Link, NIC, Node, Switch def test_node_to_node_ping(): + """Tests two Nodes are able to ping each other.""" + # TODO Add actual checks. Manual check performed for now. node_a = Node(hostname="node_a") nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") node_a.connect_nic(nic_a) - node_a.turn_on() + node_a.power_on() node_b = Node(hostname="node_b") nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") node_b.connect_nic(nic_b) - node_b.turn_on() + node_b.power_on() Link(endpoint_a=nic_a, endpoint_b=nic_b) @@ -18,22 +20,24 @@ def test_node_to_node_ping(): def test_multi_nic(): + """Tests that Nodes with multiple NICs can ping each other and the data go across the correct links.""" + # TODO Add actual checks. Manual check performed for now. node_a = Node(hostname="node_a") nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") node_a.connect_nic(nic_a) - node_a.turn_on() + node_a.power_on() node_b = Node(hostname="node_b") nic_b1 = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") nic_b2 = NIC(ip_address="10.0.0.12", subnet_mask="255.0.0.0", gateway="10.0.0.1") node_b.connect_nic(nic_b1) node_b.connect_nic(nic_b2) - node_b.turn_on() + node_b.power_on() node_c = Node(hostname="node_c") nic_c = NIC(ip_address="10.0.0.13", subnet_mask="255.0.0.0", gateway="10.0.0.1") node_c.connect_nic(nic_c) - node_c.turn_on() + node_c.power_on() Link(endpoint_a=nic_a, endpoint_b=nic_b1) @@ -45,30 +49,38 @@ def test_multi_nic(): def test_switched_network(): - node_a = Node(hostname="node_a") + """Tests a larges network of Nodes and Switches with one node pinging another.""" + # TODO Add actual checks. Manual check performed for now. + pc_a = Node(hostname="pc_a") nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") - node_a.connect_nic(nic_a) - node_a.turn_on() + pc_a.connect_nic(nic_a) + pc_a.power_on() - node_b = Node(hostname="node_b") + pc_b = Node(hostname="pc_b") nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") - node_b.connect_nic(nic_b) - node_b.turn_on() + pc_b.connect_nic(nic_b) + pc_b.power_on() - node_c = Node(hostname="node_c") + pc_c = Node(hostname="pc_c") nic_c = NIC(ip_address="192.168.0.12", subnet_mask="255.255.255.0", gateway="192.168.0.1") - node_c.connect_nic(nic_c) - node_c.turn_on() + pc_c.connect_nic(nic_c) + pc_c.power_on() - switch_1 = Switch(hostname="switch_1") - switch_1.turn_on() + pc_d = Node(hostname="pc_d") + nic_d = NIC(ip_address="192.168.0.13", subnet_mask="255.255.255.0", gateway="192.168.0.1") + pc_d.connect_nic(nic_d) + pc_d.power_on() - switch_2 = Switch(hostname="switch_2") - switch_2.turn_on() + switch_1 = Switch(hostname="switch_1", num_ports=6) + switch_1.power_on() - Link(endpoint_a=nic_a, endpoint_b=switch_1.switch_ports[1]) - Link(endpoint_a=nic_b, endpoint_b=switch_1.switch_ports[2]) - Link(endpoint_a=switch_1.switch_ports[24], endpoint_b=switch_2.switch_ports[24]) - Link(endpoint_a=nic_c, endpoint_b=switch_2.switch_ports[1]) + switch_2 = Switch(hostname="switch_2", num_ports=6) + switch_2.power_on() - node_a.ping("192.168.0.12") + link_nic_a_switch_1 = Link(endpoint_a=nic_a, endpoint_b=switch_1.switch_ports[1]) + link_nic_b_switch_1 = Link(endpoint_a=nic_b, endpoint_b=switch_1.switch_ports[2]) + link_nic_c_switch_2 = Link(endpoint_a=nic_c, endpoint_b=switch_2.switch_ports[1]) + link_nic_d_switch_2 = Link(endpoint_a=nic_d, endpoint_b=switch_2.switch_ports[2]) + link_switch_1_switch_2 = Link(endpoint_a=switch_1.switch_ports[6], endpoint_b=switch_2.switch_ports[6]) + + pc_a.ping("192.168.0.13") diff --git a/tests/integration_tests/network/test_link_connection.py b/tests/integration_tests/network/test_link_connection.py index 50abed77..92909cf6 100644 --- a/tests/integration_tests/network/test_link_connection.py +++ b/tests/integration_tests/network/test_link_connection.py @@ -6,13 +6,13 @@ def test_link_up(): node_a = Node(hostname="node_a") nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") node_a.connect_nic(nic_a) - node_a.turn_on() + node_a.power_on() assert nic_a.enabled node_b = Node(hostname="node_b") nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") node_b.connect_nic(nic_b) - node_b.turn_on() + node_b.power_on() assert nic_b.enabled From a840159460b3fc8ba561abf45dff26dd12a58089 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 8 Aug 2023 20:30:37 +0100 Subject: [PATCH 30/63] #1706 - Fixed the "smart" merging of SimComponent that PyCharm performed. Integrated the Filesystem class into the Node. Added prettytable to deps in pyproject.toml --- pyproject.toml | 1 + src/primaite/simulator/core.py | 8 -------- src/primaite/simulator/network/hardware/base.py | 7 +++++-- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 74de37df..082ac16f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "platformdirs==3.5.1", "plotly==5.15.0", "polars==0.18.4", + "prettytable==3.8.0", "PyYAML==6.0", "stable-baselines3==1.6.2", "tensorflow==2.12.0", diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index b157a994..a48709e0 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -9,9 +9,6 @@ from pydantic import BaseModel, ConfigDict class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" - uuid: str - """The component UUID.""" - def __init__(self, **kwargs): if not kwargs.get("uuid"): kwargs["uuid"] = str(uuid4()) @@ -21,11 +18,6 @@ class SimComponent(BaseModel): uuid: str "The component UUID." - def __init__(self, **kwargs): - if not kwargs.get("uuid"): - kwargs["uuid"] = str(uuid4()) - super().__init__(**kwargs) - @abstractmethod def describe_state(self) -> Dict: """ diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index eb406521..11782abd 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -4,13 +4,14 @@ import re import secrets from enum import Enum from ipaddress import IPv4Address, IPv4Network -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union from prettytable import PrettyTable from primaite import getLogger from primaite.exceptions import NetworkError from primaite.simulator.core import SimComponent +from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol @@ -784,7 +785,7 @@ class Node(SimComponent): "All services on the node." processes: Dict = {} "All processes on the node." - file_system: Any = None + file_system: FileSystem "The nodes file system." sys_log: SysLog arp: ARPCache @@ -814,6 +815,8 @@ class Node(SimComponent): kwargs["software_manager"] = SoftwareManager( sys_log=kwargs.get("sys_log"), session_manager=kwargs.get("session_manager") ) + if not kwargs.get("file_system"): + kwargs["file_system"] = FileSystem() super().__init__(**kwargs) self.arp.nics = self.nics From 1de8e0a058d6ee1d633171b154745fc2e9024787 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 9 Aug 2023 09:19:11 +0100 Subject: [PATCH 31/63] Update tests --- .../_simulator/_domain/test_account.py | 2 +- .../_primaite/_simulator/test_core.py | 33 ------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py index d4a57179..aadf1c69 100644 --- a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py +++ b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py @@ -4,7 +4,7 @@ from primaite.simulator.domain.account import Account, AccountType def test_account_serialise(): """Test that an account can be serialised.""" - acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.user) + acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.USER) serialised = acct.model_dump_json() print(serialised) diff --git a/tests/unit_tests/_primaite/_simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py index 4e2df757..0d227633 100644 --- a/tests/unit_tests/_primaite/_simulator/test_core.py +++ b/tests/unit_tests/_primaite/_simulator/test_core.py @@ -44,36 +44,3 @@ class TestIsolatedSimComponent: comp = TestComponent(name="computer", size=(5, 10)) dump = comp.model_dump_json() assert comp == TestComponent.model_validate_json(dump) - - def test_apply_action(self): - """Validate that we can override apply_action behaviour and it updates the state of the component.""" - - class TestComponent(SimComponent): - name: str - status: Literal["on", "off"] = "off" - - def describe_state(self) -> Dict: - return {} - - def _possible_actions(self) -> Dict[str, Callable[[List[str]], None]]: - return { - "turn_off": self._turn_off, - "turn_on": self._turn_on, - } - - def _turn_off(self, options: List[str]) -> None: - assert len(options) == 0, "This action does not support options." - self.status = "off" - - def _turn_on(self, options: List[str]) -> None: - assert len(options) == 0, "This action does not support options." - self.status = "on" - - comp = TestComponent(name="computer", status="off") - - assert comp.status == "off" - comp.apply_action(["turn_on"]) - assert comp.status == "on" - - with pytest.raises(ValueError): - comp.apply_action(["do_nothing"]) From be8c2955ced0c41379f5cd98ac4bc3f93006cf47 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 9 Aug 2023 10:26:52 +0100 Subject: [PATCH 32/63] Change Accountstatus to a bool --- src/primaite/simulator/domain/account.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index 2d726624..79d0de23 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -17,13 +17,6 @@ class AccountType(Enum): "User accounts are used to allow agents to log in and perform actions" -class AccountStatus(Enum): - """Whether the account is active.""" - - ENABLED = 1 - DISABLED = 2 - - class PasswordPolicyLevel(Enum): """Complexity requirements for account passwords.""" @@ -47,7 +40,7 @@ class Account(SimComponent): "Account password." account_type: AccountType "Account Type, currently this can be service account (used by apps) or user account." - status: AccountStatus = AccountStatus.DISABLED + enabled: bool = True def describe_state(self) -> Dict: """Describe state for agent observations.""" @@ -55,11 +48,11 @@ class Account(SimComponent): def enable(self): """Set the status to enabled.""" - self.status = AccountStatus.ENABLED + self.enabled = True def disable(self): """Set the status to disabled.""" - self.status = AccountStatus.DISABLED + self.enabled = False def log_on(self) -> None: """TODO.""" From 572f457231aa99f81e227d6b9513e58df2f014d9 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 9 Aug 2023 11:19:58 +0100 Subject: [PATCH 33/63] #1714: fixing minor error in test + adding a check for existing uuid when adding file --- .../simulator/file_system/file_system_folder.py | 10 +++++++--- .../_simulator/_file_system/test_file_system.py | 13 +++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 79e19189..23f4ca79 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -25,9 +25,13 @@ class FileSystemFolder(FileSystemItem): if file is None or not isinstance(file, FileSystemFile): raise Exception(f"Invalid file: {file}") - # add to list - self.files[file.uuid] = file - self.size += file.size + # check if file with id already exists in folder + if self.get_file_by_id(file.uuid) is not None: + _LOGGER.debug(f"File with id {file.uuid} already exists in folder") + else: + # add to list + self.files[file.uuid] = file + self.size += file.size def remove_file(self, file: Optional[FileSystemFile]): """ diff --git a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py index 5bebf487..348eb440 100644 --- a/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py +++ b/tests/unit_tests/_primaite/_simulator/_file_system/test_file_system.py @@ -1,4 +1,5 @@ from primaite.simulator.file_system.file_system import FileSystem +from primaite.simulator.file_system.file_system_file import FileSystemFile from primaite.simulator.file_system.file_system_folder import FileSystemFolder @@ -37,7 +38,7 @@ def test_delete_file(): file_system.delete_file(file=file) assert len(file_system.folders) is 1 - assert len(file_system.get_folder_by_id(folder.uuid).files) is 0 + assert len(folder.files) is 0 def test_delete_non_existent_file(): @@ -45,16 +46,20 @@ def test_delete_non_existent_file(): file_system = FileSystem() file = file_system.create_file(file_name="test_file", size=10) - not_added_file = file_system.create_file(file_name="test_file", size=10) + not_added_file = FileSystemFile(name="not_added") + # folder should be created assert len(file_system.folders) is 1 - + # should only have 1 file in the file system folder_id = list(file_system.folders.keys())[0] folder = file_system.get_folder_by_id(folder_id) + assert len(list(folder.files)) is 1 + assert folder.get_file_by_id(file.uuid) is file + # deleting should not change how many files are in folder file_system.delete_file(file=not_added_file) assert len(file_system.folders) is 1 - assert len(file_system.get_folder_by_id(folder.uuid).files) is 1 + assert len(list(folder.files)) is 1 def test_delete_folder(): From 596bbaacdeb07b942a14118a09aedf452744ad32 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 9 Aug 2023 12:06:06 +0100 Subject: [PATCH 34/63] Change enum strings to uppercase --- src/primaite/simulator/domain/controller.py | 12 ++++++------ .../component_creation/test_permission_system.py | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 4f09a846..887a065d 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -33,13 +33,13 @@ class temp_file: class AccountGroup(Enum): """Permissions are set at group-level and accounts can belong to these groups.""" - local_user = 1 + LOCAL_USER = 1 "For performing basic actions on a node" - domain_user = 2 + DOMAIN_USER = 2 "For performing basic actions to the domain" - local_admin = 3 + LOCAL_ADMIN = 3 "For full access to actions on a node" - domain_admin = 4 + DOMAIN_ADMIN = 4 "For full access" @@ -71,9 +71,9 @@ class DomainController(SimComponent): accounts: Dict[str, Account] = {} groups: Final[List[AccountGroup]] = list(AccountGroup) - domain_group_membership: Dict[Literal[AccountGroup.domain_admin, AccountGroup.domain_user], List[Account]] = {} + domain_group_membership: Dict[Literal[AccountGroup.DOMAIN_ADMIN, AccountGroup.DOMAIN_USER], List[Account]] = {} local_group_membership: Dict[ - Tuple[temp_node, Literal[AccountGroup.local_admin, AccountGroup.local_user]], List[Account] + Tuple[temp_node, Literal[AccountGroup.LOCAL_ADMIN, AccountGroup.LOCAL_USER]], List[Account] ] = {} # references to non-owned objects. Not sure if all are needed here. diff --git a/tests/integration_tests/component_creation/test_permission_system.py b/tests/integration_tests/component_creation/test_permission_system.py index 93d0267c..6816ba84 100644 --- a/tests/integration_tests/component_creation/test_permission_system.py +++ b/tests/integration_tests/component_creation/test_permission_system.py @@ -34,7 +34,7 @@ def test_group_action_validation() -> None: "create_folder", Action( func=lambda request, context: self.create_folder(request[0]), - validator=GroupMembershipValidator([AccountGroup.local_admin, AccountGroup.domain_admin]), + validator=GroupMembershipValidator([AccountGroup.LOCAL_ADMIN, AccountGroup.DOMAIN_ADMIN]), ), ) @@ -49,14 +49,14 @@ def test_group_action_validation() -> None: self.folders = [x for x in self.folders if x is not folder] # check that the folder is created when a local admin tried to do it - permitted_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_admin"]}} + permitted_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["LOCAL_ADMIN"]}} my_node = Node(uuid="0000-0000-1234", name="pc") my_node.apply_action(["create_folder", "memes"], context=permitted_context) assert len(my_node.folders) == 1 assert my_node.folders[0].name == "memes" # check that the number of folders is still 1 even after attempting to create a second one without permissions - invalid_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_user", "domain_user"]}} + invalid_context = {"request_source": {"agent": "BLUE", "account": "User1", "groups": ["LOCAL_USER", "DOMAIN_USER"]}} my_node.apply_action(["create_folder", "memes2"], context=invalid_context) assert len(my_node.folders) == 1 assert my_node.folders[0].name == "memes" @@ -97,14 +97,14 @@ def test_hierarchical_action_with_validation() -> None: "disable", Action( func=lambda request, context: self.disable(), - validator=GroupMembershipValidator([AccountGroup.local_admin, AccountGroup.domain_admin]), + validator=GroupMembershipValidator([AccountGroup.LOCAL_ADMIN, AccountGroup.DOMAIN_ADMIN]), ), ) self.action_manager.add_action( "enable", Action( func=lambda request, context: self.enable(), - validator=GroupMembershipValidator([AccountGroup.local_admin, AccountGroup.domain_admin]), + validator=GroupMembershipValidator([AccountGroup.LOCAL_ADMIN, AccountGroup.DOMAIN_ADMIN]), ), ) @@ -164,14 +164,14 @@ def test_hierarchical_action_with_validation() -> None: my_node.install_app("Firefox") non_admin_context = { - "request_source": {"agent": "BLUE", "account": "User1", "groups": ["local_user", "domain_user"]} + "request_source": {"agent": "BLUE", "account": "User1", "groups": ["LOCAL_USER", "DOMAIN_USER"]} } admin_context = { "request_source": { "agent": "BLUE", "account": "User1", - "groups": ["local_admin", "domain_admin", "local_user", "domain_user"], + "groups": ["LOCAL_ADMIN", "DOMAIN_ADMIN", "LOCAL_USER", "DOMAIN_USER"], } } From 51baabb35ba27d318db16478d140e92e6d5cb2cb Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 9 Aug 2023 12:34:56 +0100 Subject: [PATCH 35/63] Update enums to uppercase in docs --- docs/source/simulation_structure.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/simulation_structure.rst b/docs/source/simulation_structure.rst index 479b3e7b..2b213f16 100644 --- a/docs/source/simulation_structure.rst +++ b/docs/source/simulation_structure.rst @@ -49,7 +49,7 @@ snippet demonstrates usage of the ``ActionPermissionValidator``. "reset_factory_settings", Action( func = lambda request, context: self.reset_factory_settings(), - validator = GroupMembershipValidator([AccountGroup.domain_admin]), + validator = GroupMembershipValidator([AccountGroup.DOMAIN_ADMIN]), ), ) @@ -59,7 +59,7 @@ snippet demonstrates usage of the ``ActionPermissionValidator``. phone = Smartphone(name="phone1") # try to wipe the phone as a domain user, this will have no effect - phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["domain_user"]}) + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["DOMAIN_USER"]}) # try to wipe the phone as an admin user, this will wipe the phone - phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["domain_admin"]}) + phone.apply_action(["reset_factory_settings"], context={"request_source":{"groups":["DOMAIN_ADMIN"]}) From f198a8b94d22ffa5ffd3b74bed6244105c5a8bbb Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 9 Aug 2023 12:36:09 +0100 Subject: [PATCH 36/63] Fix bad merge --- src/primaite/simulator/core.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 779358ec..caba5210 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -126,6 +126,8 @@ class ActionManager: class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + model_config = ConfigDict(arbitrary_types_allowed=True, extra=Extra.allow) + uuid: str """The component UUID.""" @@ -133,13 +135,6 @@ class SimComponent(BaseModel): if not kwargs.get("uuid"): kwargs["uuid"] = str(uuid4()) super().__init__(**kwargs) - - model_config = ConfigDict(arbitrary_types_allowed=True, extra=Extra.allow) - uuid: str = str(uuid4()) - "The component UUID." - - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) self.action_manager: Optional[ActionManager] = None @abstractmethod From cf241366dc413318739b5e12198ea750765c0e3b Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 9 Aug 2023 15:15:45 +0100 Subject: [PATCH 37/63] #1714: apply suggestions for preventing addition of objects with similar uuid --- src/primaite/simulator/file_system/file_system.py | 8 +++++++- src/primaite/simulator/file_system/file_system_folder.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index 3290570e..6cdcaca2 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -95,7 +95,13 @@ class FileSystem(SimComponent): :type: folder_name: str """ folder = FileSystemFolder(name=folder_name) - self.folders[folder.uuid] = folder + + if folder.uuid in self.folders: + # iterate until a folder with a non-matching uuid is added + # which is VERY unlikely but it'll be weird if it happens twice + return self.create_folder(folder_name=folder_name) + else: + self.folders[folder.uuid] = folder return folder def delete_file(self, file: Optional[FileSystemFile] = None): diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 23f4ca79..62f98029 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -26,7 +26,7 @@ class FileSystemFolder(FileSystemItem): raise Exception(f"Invalid file: {file}") # check if file with id already exists in folder - if self.get_file_by_id(file.uuid) is not None: + if file.uuid in self.files: _LOGGER.debug(f"File with id {file.uuid} already exists in folder") else: # add to list From 34ff9abd7ab8e832e1b3a2cc5e66d193f0846687 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 9 Aug 2023 15:55:28 +0100 Subject: [PATCH 38/63] Apply changes from code review. --- src/primaite/simulator/core.py | 1 + src/primaite/simulator/domain/account.py | 4 ++-- .../unit_tests/_primaite/_simulator/_domain/test_account.py | 6 +++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index caba5210..8b771cd7 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -127,6 +127,7 @@ class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" model_config = ConfigDict(arbitrary_types_allowed=True, extra=Extra.allow) + """Configure pydantic to allow arbitrary types and to let the instance have attributes not present in model.""" uuid: str """The component UUID.""" diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index 79d0de23..e8595afa 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -55,9 +55,9 @@ class Account(SimComponent): self.enabled = False def log_on(self) -> None: - """TODO.""" + """TODO. Once the accounts are integrated with nodes, populate this accordingly.""" self.num_logons += 1 def log_off(self) -> None: - """TODO.""" + """TODO. Once the accounts are integrated with nodes, populate this accordingly.""" self.num_logoffs += 1 diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py index aadf1c69..3a2a5903 100644 --- a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py +++ b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py @@ -3,14 +3,14 @@ from primaite.simulator.domain.account import Account, AccountType def test_account_serialise(): - """Test that an account can be serialised.""" - acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.USER) + """Test that an account can be serialised. If pydantic throws error then this test fails.""" + acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.user) serialised = acct.model_dump_json() print(serialised) def test_account_deserialise(): - """Test that an account can be deserialised.""" + """Test that an account can be deserialised. The test fails if pydantic throws an error.""" acct_json = ( '{"uuid":"dfb2bcaa-d3a1-48fd-af3f-c943354622b4","num_logons":0,"num_logoffs":0,"num_group_changes":0,' '"username":"Jake","password":"JakePass1!","account_type":2,"status":2,"action_manager":null}' From b46057841d49db2fc68fbf5d55efff775d8fdd70 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 9 Aug 2023 20:31:42 +0100 Subject: [PATCH 39/63] #1706 - Refactored a bunch of if statements in base.py to improve readability --- .../simulator/network/hardware/base.py | 297 +++++++++--------- 1 file changed, 154 insertions(+), 143 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 11782abd..3b75fedc 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -135,29 +135,33 @@ class NIC(SimComponent): def enable(self): """Attempt to enable the NIC.""" - if not self.enabled: - if self.connected_node: - if self.connected_node.operating_state == NodeOperatingState.ON: - self.enabled = True - self.connected_node.sys_log.info(f"NIC {self} enabled") - self.pcap = PacketCapture(hostname=self.connected_node.hostname, ip_address=self.ip_address) - if self.connected_link: - self.connected_link.endpoint_up() - else: - self.connected_node.sys_log.error(f"NIC {self} cannot be enabled as the endpoint is not turned on") - else: - _LOGGER.error(f"NIC {self} cannot be enabled as it is not connected to a Node") + if self.enabled: + return + if not self.connected_node: + _LOGGER.error(f"NIC {self} cannot be enabled as it is not connected to a Node") + return + if self.connected_node.operating_state != NodeOperatingState.ON: + self.connected_node.sys_log.error(f"NIC {self} cannot be enabled as the endpoint is not turned on") + return + + self.enabled = True + self.connected_node.sys_log.info(f"NIC {self} enabled") + self.pcap = PacketCapture(hostname=self.connected_node.hostname, ip_address=self.ip_address) + if self.connected_link: + self.connected_link.endpoint_up() def disable(self): """Disable the NIC.""" - if self.enabled: - self.enabled = False - if self.connected_node: - self.connected_node.sys_log.info(f"NIC {self} disabled") - else: - _LOGGER.info(f"NIC {self} disabled") - if self.connected_link: - self.connected_link.endpoint_down() + if not self.enabled: + return + + self.enabled = False + if self.connected_node: + self.connected_node.sys_log.info(f"NIC {self} disabled") + else: + _LOGGER.info(f"NIC {self} disabled") + if self.connected_link: + self.connected_link.endpoint_down() def connect_link(self, link: Link): """ @@ -166,15 +170,17 @@ class NIC(SimComponent): :param link: The link to which the NIC is connected. :type link: :class:`~primaite.simulator.network.transmission.physical_layer.Link` """ - if not self.connected_link: - if self.connected_link != link: - # TODO: Inform the Node that a link has been connected - self.connected_link = link - _LOGGER.info(f"NIC {self} connected to Link {link}") - else: - _LOGGER.error(f"Cannot connect Link to NIC ({self.mac_address}) as it is already connected") - else: + if self.connected_link: _LOGGER.error(f"Cannot connect Link to NIC ({self.mac_address}) as it already has a connection") + return + + if self.connected_link == link: + _LOGGER.error(f"Cannot connect Link to NIC ({self.mac_address}) as it is already connected") + return + + # TODO: Inform the Node that a link has been connected + self.connected_link = link + _LOGGER.info(f"NIC {self} connected to Link {link}") def disconnect_link(self): """Disconnect the NIC from the connected Link.""" @@ -214,9 +220,8 @@ class NIC(SimComponent): self.pcap.capture(frame) self.connected_link.transmit_frame(sender_nic=self, frame=frame) return True - else: - # Cannot send Frame as the NIC is not enabled - return False + # Cannot send Frame as the NIC is not enabled + return False def receive_frame(self, frame: Frame) -> bool: """ @@ -233,8 +238,7 @@ class NIC(SimComponent): self.pcap.capture(frame) self.connected_node.receive_frame(frame=frame, from_nic=self) return True - else: - return False + return False def describe_state(self) -> Dict: """ @@ -290,33 +294,34 @@ class SwitchPort(SimComponent): def enable(self): """Attempt to enable the SwitchPort.""" - if not self.enabled: - if self.connected_node: - if self.connected_node.operating_state == NodeOperatingState.ON: - self.enabled = True - self.connected_node.sys_log.info(f"SwitchPort {self} enabled") - self.pcap = PacketCapture(hostname=self.connected_node.hostname) - if self.connected_link: - self.connected_link.endpoint_up() - else: - self.connected_node.sys_log.info( - f"SwitchPort {self} cannot be enabled as the endpoint is not turned on" - ) - else: - msg = f"SwitchPort {self} cannot be enabled as it is not connected to a Node" - _LOGGER.error(msg) - raise NetworkError(msg) + if self.enabled: + return + + if not self.connected_node: + _LOGGER.error(f"SwitchPort {self} cannot be enabled as it is not connected to a Node") + return + + if self.connected_node.operating_state != NodeOperatingState.ON: + self.connected_node.sys_log.info(f"SwitchPort {self} cannot be enabled as the endpoint is not turned on") + return + + self.enabled = True + self.connected_node.sys_log.info(f"SwitchPort {self} enabled") + self.pcap = PacketCapture(hostname=self.connected_node.hostname) + if self.connected_link: + self.connected_link.endpoint_up() def disable(self): """Disable the SwitchPort.""" - if self.enabled: - self.enabled = False - if self.connected_node: - self.connected_node.sys_log.info(f"SwitchPort {self} disabled") - else: - _LOGGER.info(f"SwitchPort {self} disabled") - if self.connected_link: - self.connected_link.endpoint_down() + if not self.enabled: + return + self.enabled = False + if self.connected_node: + self.connected_node.sys_log.info(f"SwitchPort {self} disabled") + else: + _LOGGER.info(f"SwitchPort {self} disabled") + if self.connected_link: + self.connected_link.endpoint_down() def connect_link(self, link: Link): """ @@ -324,16 +329,18 @@ class SwitchPort(SimComponent): :param link: The link to which the SwitchPort is connected. """ - if not self.connected_link: - if self.connected_link != link: - # TODO: Inform the Switch that a link has been connected - self.connected_link = link - _LOGGER.info(f"SwitchPort {self} connected to Link {link}") - self.enable() - else: - _LOGGER.error(f"Cannot connect Link to SwitchPort {self.mac_address} as it is already connected") - else: + if self.connected_link: _LOGGER.error(f"Cannot connect link to SwitchPort {self.mac_address} as it already has a connection") + return + + if self.connected_link == link: + _LOGGER.error(f"Cannot connect Link to SwitchPort {self.mac_address} as it is already connected") + return + + # TODO: Inform the Switch that a link has been connected + self.connected_link = link + _LOGGER.info(f"SwitchPort {self} connected to Link {link}") + self.enable() def disconnect_link(self): """Disconnect the SwitchPort from the connected Link.""" @@ -353,9 +360,8 @@ class SwitchPort(SimComponent): self.pcap.capture(frame) self.connected_link.transmit_frame(sender_nic=self, frame=frame) return True - else: - # Cannot send Frame as the SwitchPort is not enabled - return False + # Cannot send Frame as the SwitchPort is not enabled + return False def receive_frame(self, frame: Frame) -> bool: """ @@ -370,8 +376,7 @@ class SwitchPort(SimComponent): self.pcap.capture(frame) self.connected_node.forward_frame(frame=frame, incoming_port=self) return True - else: - return False + return False def describe_state(self) -> Dict: """ @@ -468,30 +473,27 @@ class Link(SimComponent): :param frame: The network frame to be sent. :return: True if the Frame can be sent, otherwise False. """ - if self._can_transmit(frame): - receiver = self.endpoint_a - if receiver == sender_nic: - receiver = self.endpoint_b - frame_size = frame.size_Mbits - sent = receiver.receive_frame(frame) - if sent: - # Frame transmitted successfully - # Load the frame size on the link - self.current_load += frame_size - ( - _LOGGER.info( - f"Added {frame_size:.3f} Mbits to {self}, current load {self.current_load:.3f} Mbits " - f"({self.current_load_percent})" - ) - ) - return True - # Received NIC disabled, reply - - return False - else: + can_transmit = self._can_transmit(frame) + if not can_transmit: _LOGGER.info(f"Cannot transmit frame as {self} is at capacity") return False + receiver = self.endpoint_a + if receiver == sender_nic: + receiver = self.endpoint_b + frame_size = frame.size_Mbits + + if receiver.receive_frame(frame): + # Frame transmitted successfully + # Load the frame size on the link + self.current_load += frame_size + _LOGGER.info( + f"Added {frame_size:.3f} Mbits to {self}, current load {self.current_load:.3f} Mbits " + f"({self.current_load_percent})" + ) + return True + return False + def reset_component_for_episode(self, episode: int): """ Link reset function. @@ -624,43 +626,48 @@ class ARPCache: :param from_nic: The NIC that received the ARP packet. :param arp_packet: The ARP packet to be processed. """ - if arp_packet.request: - self.sys_log.info( - f"Received ARP request for {arp_packet.target_ip} from " - f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " - ) - if arp_packet.target_ip == from_nic.ip_address: - self._add_arp_cache_entry( - ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic - ) - arp_packet = arp_packet.generate_reply(from_nic.mac_address) - self.sys_log.info( - f"Sending ARP reply from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " - f"to {arp_packet.target_ip}/{arp_packet.target_mac_addr} " - ) - - tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) - - # Network Layer - ip_packet = IPPacket( - src_ip=arp_packet.sender_ip, - dst_ip=arp_packet.target_ip, - ) - # Data Link Layer - ethernet_header = EthernetHeader( - src_mac_addr=arp_packet.sender_mac_addr, dst_mac_addr=arp_packet.target_mac_addr - ) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) - from_nic.send_frame(frame) - else: - self.sys_log.info(f"Ignoring ARP request for {arp_packet.target_ip}") - else: + # ARP Reply + if not arp_packet.request: self.sys_log.info( f"Received ARP response for {arp_packet.sender_ip} from {arp_packet.sender_mac_addr} via NIC {from_nic}" ) self._add_arp_cache_entry( ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic ) + return + + # ARP Request + self.sys_log.info( + f"Received ARP request for {arp_packet.target_ip} from " + f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " + ) + + # Unmatched ARP Request + if arp_packet.target_ip != from_nic.ip_address: + self.sys_log.info(f"Ignoring ARP request for {arp_packet.target_ip}") + return + + # Matched ARP request + self._add_arp_cache_entry(ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic) + arp_packet = arp_packet.generate_reply(from_nic.mac_address) + self.sys_log.info( + f"Sending ARP reply from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " + f"to {arp_packet.target_ip}/{arp_packet.target_mac_addr} " + ) + + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + # Network Layer + ip_packet = IPPacket( + src_ip=arp_packet.sender_ip, + dst_ip=arp_packet.target_ip, + ) + # Data Link Layer + ethernet_header = EthernetHeader( + src_mac_addr=arp_packet.sender_mac_addr, dst_mac_addr=arp_packet.target_mac_addr + ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) + from_nic.send_frame(frame) class ICMP: @@ -721,30 +728,34 @@ class ICMP: was not found in the ARP cache. """ nic = self.arp.get_arp_cache_nic(target_ip_address) - if nic: - sequence += 1 - target_mac_address = self.arp.get_arp_cache_mac_address(target_ip_address) - src_nic = self.arp.get_arp_cache_nic(target_ip_address) - tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) - - # Network Layer - ip_packet = IPPacket( - src_ip=nic.ip_address, - dst_ip=target_ip_address, - protocol=IPProtocol.ICMP, - ) - # Data Link Layer - ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) - icmp_packet = ICMPPacket(identifier=identifier, sequence=sequence) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_packet) - self.sys_log.info(f"Sending echo request to {target_ip_address}") - nic.send_frame(frame) - return sequence, icmp_packet.identifier - else: + # TODO: Eventually this ARP request needs to be done elsewhere. It's not the resonsibility of the + # ping function to handle ARP lookups + # No existing ARP entry + if not nic: self.sys_log.info(f"No entry in ARP cache for {target_ip_address}") self.arp.send_arp_request(target_ip_address) return 0, None + # ARP entry exists + sequence += 1 + target_mac_address = self.arp.get_arp_cache_mac_address(target_ip_address) + src_nic = self.arp.get_arp_cache_nic(target_ip_address) + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + # Network Layer + ip_packet = IPPacket( + src_ip=nic.ip_address, + dst_ip=target_ip_address, + protocol=IPProtocol.ICMP, + ) + # Data Link Layer + ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) + icmp_packet = ICMPPacket(identifier=identifier, sequence=sequence) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_packet) + self.sys_log.info(f"Sending echo request to {target_ip_address}") + nic.send_frame(frame) + return sequence, icmp_packet.identifier + class NodeOperatingState(Enum): """Enumeration of Node Operating States.""" From ad81a819498e7083578a304b220c235c15454136 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 9 Aug 2023 20:38:45 +0100 Subject: [PATCH 40/63] #1706 - Applied some code suggestions from the PR --- src/primaite/simulator/network/hardware/base.py | 11 ++++++----- .../integration_tests/network/test_link_connection.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 3b75fedc..d3ea9a41 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -46,6 +46,7 @@ def generate_mac_address(oui: Optional[str] = None) -> str: oui_pattern = re.compile(r"^([0-9A-Fa-f]{2}[:-]){2}[0-9A-Fa-f]{2}$") if not oui_pattern.match(oui): msg = f"Invalid oui. The oui should be in the format xx:xx:xx, where x is a hexadecimal digit, got '{oui}'" + _LOGGER.error(msg) raise ValueError(msg) oui_bytes = [int(chunk, 16) for chunk in oui.split(":")] mac = oui_bytes + random_bytes[len(oui_bytes) :] @@ -401,7 +402,7 @@ class SwitchPort(SimComponent): class Link(SimComponent): """ - Represents a network link between NIC<-->, NIC<-->SwitchPort, or SwitchPort<-->SwitchPort. + Represents a network link between NIC<-->NIC, NIC<-->SwitchPort, or SwitchPort<-->SwitchPort. :param endpoint_a: The first NIC or SwitchPort connected to the Link. :param endpoint_b: The second NIC or SwitchPort connected to the Link. @@ -441,17 +442,17 @@ class Link(SimComponent): def endpoint_up(self): """Let the Link know and endpoint has been brought up.""" - if self.up: + if self.is_up: _LOGGER.info(f"Link {self} up") def endpoint_down(self): """Let the Link know and endpoint has been brought down.""" - if not self.up: + if not self.is_up: self.current_load = 0.0 _LOGGER.info(f"Link {self} down") @property - def up(self) -> bool: + def is_up(self) -> bool: """ Informs whether the link is up. @@ -460,7 +461,7 @@ class Link(SimComponent): return self.endpoint_a.enabled and self.endpoint_b.enabled def _can_transmit(self, frame: Frame) -> bool: - if self.up: + if self.is_up: frame_size_Mbits = frame.size_Mbits # noqa - Leaving it as Mbits as this is how they're expressed return self.current_load + frame_size_Mbits <= self.bandwidth return False diff --git a/tests/integration_tests/network/test_link_connection.py b/tests/integration_tests/network/test_link_connection.py index 92909cf6..e08e40b9 100644 --- a/tests/integration_tests/network/test_link_connection.py +++ b/tests/integration_tests/network/test_link_connection.py @@ -18,4 +18,4 @@ def test_link_up(): link = Link(endpoint_a=nic_a, endpoint_b=nic_b) - assert link.up + assert link.is_up From e24d4b88900a6667df725c4da7f95ac71f92e42e Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 10 Aug 2023 09:14:45 +0100 Subject: [PATCH 41/63] Fix typo in test --- tests/unit_tests/_primaite/_simulator/_domain/test_account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py index 3a2a5903..b5632ea7 100644 --- a/tests/unit_tests/_primaite/_simulator/_domain/test_account.py +++ b/tests/unit_tests/_primaite/_simulator/_domain/test_account.py @@ -4,7 +4,7 @@ from primaite.simulator.domain.account import Account, AccountType def test_account_serialise(): """Test that an account can be serialised. If pydantic throws error then this test fails.""" - acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.user) + acct = Account(username="Jake", password="JakePass1!", account_type=AccountType.USER) serialised = acct.model_dump_json() print(serialised) From 9ee0ef2fd65a5414b0619c93d898d2ece321bbc0 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 10 Aug 2023 13:26:51 +0100 Subject: [PATCH 42/63] #1706 - Applied some final changes from PR. Fixed the PCAP log name on SwitchPort so that a pcap file is generated for each port. --- .../network/base_hardware.rst | 2 +- src/primaite/__init__.py | 1 + src/primaite/simulator/__init__.py | 1 + src/primaite/simulator/core.py | 8 ++++---- .../simulator/network/hardware/base.py | 2 +- .../system/applications/application.py | 13 ++++++------ .../simulator/system/core/packet_capture.py | 20 +++++++++++++------ .../simulator/system/services/service.py | 12 +++++------ 8 files changed, 35 insertions(+), 24 deletions(-) diff --git a/docs/source/simulation_components/network/base_hardware.rst b/docs/source/simulation_components/network/base_hardware.rst index 5335091f..452667d2 100644 --- a/docs/source/simulation_components/network/base_hardware.rst +++ b/docs/source/simulation_components/network/base_hardware.rst @@ -324,7 +324,7 @@ This produces: Create Switches *************** -Next, we'll create four six-port switches: +Next, we'll create two six-port switches: .. code-block:: python diff --git a/src/primaite/__init__.py b/src/primaite/__init__.py index 9a7ba596..30fc9ab9 100644 --- a/src/primaite/__init__.py +++ b/src/primaite/__init__.py @@ -17,6 +17,7 @@ with open(Path(__file__).parent.resolve() / "VERSION", "r") as file: __version__ = file.readline().strip() _PRIMAITE_ROOT: Path = Path(__file__).parent +# TODO: Remove once we integrate the simulation into PrimAITE and it uses the primaite session path class _PrimaitePaths: diff --git a/src/primaite/simulator/__init__.py b/src/primaite/simulator/__init__.py index 5b65ad40..1cfe7f49 100644 --- a/src/primaite/simulator/__init__.py +++ b/src/primaite/simulator/__init__.py @@ -2,3 +2,4 @@ from primaite import _PRIMAITE_ROOT TEMP_SIM_OUTPUT = _PRIMAITE_ROOT.parent.parent / "simulation_output" "A path at the repo root dir to use temporarily for sim output testing while in dev." +# TODO: Remove once we integrate the simulation into PrimAITE and it uses the primaite session path diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index a48709e0..03684474 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -9,15 +9,15 @@ from pydantic import BaseModel, ConfigDict class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" + model_config = ConfigDict(arbitrary_types_allowed=True) + uuid: str + "The component UUID." + def __init__(self, **kwargs): if not kwargs.get("uuid"): kwargs["uuid"] = str(uuid4()) super().__init__(**kwargs) - model_config = ConfigDict(arbitrary_types_allowed=True) - uuid: str - "The component UUID." - @abstractmethod def describe_state(self) -> Dict: """ diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index d3ea9a41..ab5d4943 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -308,7 +308,7 @@ class SwitchPort(SimComponent): self.enabled = True self.connected_node.sys_log.info(f"SwitchPort {self} enabled") - self.pcap = PacketCapture(hostname=self.connected_node.hostname) + self.pcap = PacketCapture(hostname=self.connected_node.hostname, switch_port_number=self.port_num) if self.connected_link: self.connected_link.endpoint_up() diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index f9c5827d..36a7bc85 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -8,12 +8,13 @@ from primaite.simulator.system.software import IOSoftware class ApplicationOperatingState(Enum): """Enumeration of Application Operating States.""" - CLOSED = 0 - "The application is closed or not running." - RUNNING = 1 - "The application is running." - INSTALLING = 3 - "The application is being installed or updated." + +RUNNING = 1 +"The application is running." +CLOSED = 2 +"The application is closed or not running." +INSTALLING = 3 +"The application is being installed or updated." class Application(IOSoftware): diff --git a/src/primaite/simulator/system/core/packet_capture.py b/src/primaite/simulator/system/core/packet_capture.py index 7741416d..c985af1f 100644 --- a/src/primaite/simulator/system/core/packet_capture.py +++ b/src/primaite/simulator/system/core/packet_capture.py @@ -20,7 +20,7 @@ class PacketCapture: The PCAPs are logged to: //__pcap.log """ - def __init__(self, hostname: str, ip_address: Optional[str] = None): + def __init__(self, hostname: str, ip_address: Optional[str] = None, switch_port_number: Optional[int] = None): """ Initialize the PacketCapture process. @@ -31,6 +31,8 @@ class PacketCapture: "The hostname for which PCAP logs are being recorded." self.ip_address: str = ip_address "The IP address associated with the PCAP logs." + self.switch_port_number = switch_port_number + "The SwitchPort number." self._setup_logger() def _setup_logger(self): @@ -43,20 +45,26 @@ class PacketCapture: log_format = "%(message)s" file_handler.setFormatter(logging.Formatter(log_format)) - logger_name = f"{self.hostname}_{self.ip_address}_pcap" if self.ip_address else f"{self.hostname}_pcap" - self.logger = logging.getLogger(logger_name) + self.logger = logging.getLogger(self._logger_name) self.logger.setLevel(60) # Custom log level > CRITICAL to prevent any unwanted standard DEBUG-CRITICAL logs self.logger.addHandler(file_handler) self.logger.addFilter(_JSONFilter()) + @property + def _logger_name(self) -> str: + """Get PCAP the logger name.""" + if self.ip_address: + return f"{self.hostname}_{self.ip_address}_pcap" + if self.switch_port_number: + return f"{self.hostname}_port-{self.switch_port_number}_pcap" + return f"{self.hostname}_pcap" + def _get_log_path(self) -> Path: """Get the path for the log file.""" root = TEMP_SIM_OUTPUT / self.hostname root.mkdir(exist_ok=True, parents=True) - if self.ip_address: - return root / f"{self.hostname}_{self.ip_address}_pcap.log" - return root / f"{self.hostname}_pcap.log" + return root / f"{self._logger_name}.log" def capture(self, frame): # noqa - I'll have a circular import and cant use if TYPE_CHECKING ;( """ diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index c820cef3..7be5cb78 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -8,17 +8,17 @@ from primaite.simulator.system.software import IOSoftware class ServiceOperatingState(Enum): """Enumeration of Service Operating States.""" - STOPPED = 0 - "The service is not running." RUNNING = 1 "The service is currently running." - RESTARTING = 2 - "The service is in the process of restarting." + STOPPED = 2 + "The service is not running." INSTALLING = 3 "The service is being installed or updated." - PAUSED = 4 + RESTARTING = 4 + "The service is in the process of restarting." + PAUSED = 5 "The service is temporarily paused." - DISABLED = 5 + DISABLED = 6 "The service is disabled and cannot be started." From 49f855c32072cb2df4a74e1be60a3834776b366a Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 10 Aug 2023 13:33:32 +0100 Subject: [PATCH 43/63] #1706 - Synced with Dev --- src/primaite/simulator/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 8b771cd7..7a183588 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -177,7 +177,7 @@ class SimComponent(BaseModel): """ pass - def reset_component_for_episode(self): + def reset_component_for_episode(self, episode: int): """ Reset this component to its original state for a new episode. From c4aacb8c69281cd462bf6824dd9ec2999aea260f Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Fri, 11 Aug 2023 15:33:07 +0100 Subject: [PATCH 44/63] #1714: Change file and folder uuid checking to check for file or folder names already existing --- .../simulator/file_system/file_system.py | 23 ++++++++++++++----- .../file_system/file_system_folder.py | 4 ++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index 6cdcaca2..d42db3e0 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -69,6 +69,10 @@ class FileSystem(SimComponent): folder = self.get_folder_by_id(folder_uuid) if folder is not None: + # check if file with name already exists + if folder.get_file_by_name(file_name): + raise Exception(f'File with name "{file_name}" already exists.') + file = FileSystemFile(name=file_name, size=size, file_type=file_type) folder.add_file(file=file) else: @@ -94,14 +98,13 @@ class FileSystem(SimComponent): :param: folder_name: The name of the folder :type: folder_name: str """ + # check if folder with name already exists + if self.get_folder_by_name(folder_name): + raise Exception(f'Folder with name "{folder_name}" already exists.') + folder = FileSystemFolder(name=folder_name) - if folder.uuid in self.folders: - # iterate until a folder with a non-matching uuid is added - # which is VERY unlikely but it'll be weird if it happens twice - return self.create_folder(folder_name=folder_name) - else: - self.folders[folder.uuid] = folder + self.folders[folder.uuid] = folder return folder def delete_file(self, file: Optional[FileSystemFile] = None): @@ -155,6 +158,10 @@ class FileSystem(SimComponent): if file is None: raise Exception("File to be moved is None") + # check if file with name already exists + if target_folder.get_file_by_name(file.name): + raise Exception(f'Folder with name "{file.name}" already exists.') + # remove file from src src_folder.remove_file(file) @@ -185,6 +192,10 @@ class FileSystem(SimComponent): if file is None: raise Exception("File to be moved is None") + # check if file with name already exists + if target_folder.get_file_by_name(file.name): + raise Exception(f'Folder with name "{file.name}" already exists.') + # add file to target target_folder.add_file(file) diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 62f98029..b0705804 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -20,6 +20,10 @@ class FileSystemFolder(FileSystemItem): """Return a FileSystemFile with the matching id.""" return self.files.get(file_id) + def get_file_by_name(self, file_name: str) -> FileSystemFile: + """Return a FileSystemFile with the matching id.""" + return next((f for f in list(self.files) if f.name == file_name), None) + def add_file(self, file: FileSystemFile): """Adds a file to the folder list.""" if file is None or not isinstance(file, FileSystemFile): From ced45d427571cf8a8e78a5af44163c67fdabe8e4 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 16 Aug 2023 16:45:52 +0100 Subject: [PATCH 45/63] Connect actions of top-level sim components --- src/primaite/simulator/domain/controller.py | 16 +++++++++- src/primaite/simulator/network/container.py | 23 ++++++++++++++ src/primaite/simulator/sim_container.py | 34 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/primaite/simulator/network/container.py create mode 100644 src/primaite/simulator/sim_container.py diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 887a065d..4e872531 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Dict, Final, List, Literal, Tuple -from primaite.simulator.core import ActionPermissionValidator, SimComponent +from primaite.simulator.core import Action, ActionManager, ActionPermissionValidator, SimComponent from primaite.simulator.domain.account import Account, AccountType @@ -82,6 +82,20 @@ class DomainController(SimComponent): folders: List[temp_folder] = {} files: List[temp_file] = {} + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.action_manager = ActionManager() + # Action 'account' matches requests like: + # ['account', '', *account_action] + self.action_manager.add_action( + "account", + Action( + func=lambda request, context: self.accounts[request.pop(0)].apply_action(request, context), + validator=GroupMembershipValidator([AccountGroup.DOMAIN_ADMIN]), + ), + ) + def _register_account(self, account: Account) -> None: """TODO.""" ... diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py new file mode 100644 index 00000000..346a089e --- /dev/null +++ b/src/primaite/simulator/network/container.py @@ -0,0 +1,23 @@ +from typing import Dict + +from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent +from primaite.simulator.network.hardware.base import Link, Node + + +class NetworkContainer(SimComponent): + """TODO.""" + + nodes: Dict[str, Node] = {} + links: Dict[str, Link] = {} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.action_manager = ActionManager() + self.action_manager.add_action( + "node", + Action( + func=lambda request, context: self.nodes[request.pop(0)].apply_action(request, context), + validator=AllowAllValidator(), + ), + ) diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py new file mode 100644 index 00000000..6989d2b9 --- /dev/null +++ b/src/primaite/simulator/sim_container.py @@ -0,0 +1,34 @@ +from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent +from primaite.simulator.domain.controller import DomainController + + +class __TempNetwork: + """TODO.""" + + pass + + +class SimulationContainer(SimComponent): + """TODO.""" + + network: __TempNetwork + domain: DomainController + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.action_manager = ActionManager() + # pass through network actions to the network objects + self.action_manager.add_action( + "network", + Action( + func=lambda request, context: self.network.apply_action(request, context), validator=AllowAllValidator() + ), + ) + # pass through domain actions to the domain object + self.action_manager.add_action( + "domain", + Action( + func=lambda request, context: self.domain.apply_action(request, context), validator=AllowAllValidator() + ), + ) From 6ca53803cd819334f505f7b2019efd7b67951747 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 17 Aug 2023 15:32:12 +0100 Subject: [PATCH 46/63] Describe state --- .../notebooks/create-simulation.ipynb | 140 +++++++++++++ src/primaite/simulator/core.py | 5 +- src/primaite/simulator/domain/account.py | 23 ++- src/primaite/simulator/domain/controller.py | 13 ++ .../simulator/file_system/file_system.py | 11 +- .../simulator/file_system/file_system_file.py | 14 +- .../file_system/file_system_folder.py | 25 ++- .../file_system/file_system_item_abc.py | 18 +- src/primaite/simulator/network/container.py | 18 ++ .../simulator/network/hardware/base.py | 187 +++++++++++------- src/primaite/simulator/sim_container.py | 37 +++- .../system/applications/application.py | 7 +- .../simulator/system/core/session_manager.py | 14 +- .../simulator/system/processes/process.py | 7 +- .../simulator/system/services/service.py | 7 +- src/primaite/simulator/system/software.py | 27 ++- .../_simulator/test_sim_conatiner.py | 16 ++ 17 files changed, 444 insertions(+), 125 deletions(-) create mode 100644 src/primaite/notebooks/create-simulation.ipynb create mode 100644 tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb new file mode 100644 index 00000000..e5fd63b0 --- /dev/null +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -0,0 +1,140 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Build a simulation using the Python API\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Import the Simulation class" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.sim_container import Simulation\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create an empty simulation. By default this has a network with no nodes or links, and a domain controller with no accounts.\n", + "\n", + "Let's use the simulation's `describe_state()` method to verify that it is empty." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", + " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", + " 'nodes': {},\n", + " 'links': {}},\n", + " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_sim = Simulation()\n", + "my_sim.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.hardware.base import Node\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", + " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", + " 'nodes': {'5f596f4f-4d34-4d1c-9688-9a105e489444': {'uuid': '5f596f4f-4d34-4d1c-9688-9a105e489444',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {},\n", + " 'file_system': {'uuid': 'dc1e7032-7dba-44d5-aedb-5da75ab1eccc',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}}},\n", + " 'links': {}},\n", + " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_pc = Node(hostname=\"primaite_pc\",)\n", + "my_sim.network.nodes[my_pc.uuid] = my_pc\n", + "my_sim.describe_state()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 7a183588..2c802c0f 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -147,7 +147,10 @@ class SimComponent(BaseModel): object. If there are objects referenced by this object that are owned by something else, it is not included in this output. """ - return {} + state = { + "uuid": self.uuid, + } + return state def apply_action(self, action: List[str], context: Dict = {}) -> None: """ diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index e8595afa..e30b7a27 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -43,8 +43,27 @@ class Account(SimComponent): enabled: bool = True def describe_state(self) -> Dict: - """Describe state for agent observations.""" - return super().describe_state() + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "num_logons": self.num_logons, + "num_logoffs": self.num_logoffs, + "num_group_changes": self.num_group_changes, + "username": self.username, + "password": self.password, + "account_type": self.account_type, + "enabled": self.enabled, + } + ) + return state def enable(self): """Set the status to enabled.""" diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 4e872531..f772ab22 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -96,6 +96,19 @@ class DomainController(SimComponent): ), ) + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update({"accounts": {uuid: acct.describe_state() for uuid, acct in self.accounts.items()}}) + return state + def _register_account(self, account: Account) -> None: """TODO.""" ... diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index d42db3e0..a5f603fe 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -18,11 +18,16 @@ class FileSystem(SimComponent): def describe_state(self) -> Dict: """ - Get the current state of the FileSystem as a dict. + Produce a dictionary describing the current state of this object. - :return: A dict containing the current state of the FileSystemFile. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict """ - pass + state = super().describe_state() + state.update({"folders": {uuid: folder for uuid, folder in self.folders.items()}}) + return state def get_folders(self) -> Dict: """Returns the list of folders.""" diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index f9fc2e1f..4bb6e585 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -38,8 +38,16 @@ class FileSystemFile(FileSystemItem): def describe_state(self) -> Dict: """ - Get the current state of the FileSystemFile as a dict. + Produce a dictionary describing the current state of this object. - :return: A dict containing the current state of the FileSystemFile. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict """ - pass + return { + "uuid": self.uuid, + "name": self.name, + "size": self.size, + "file_type": self.file_type, + } diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index b0705804..463f3854 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -16,6 +16,23 @@ class FileSystemFolder(FileSystemItem): is_quarantined: bool = False """Flag that marks the folder as quarantined if true.""" + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + return { + "uuid": self.uuid, + "name": self.name, + "size": self.size, + "files": {uuid: file for uuid, file in self.files.items()}, + "is_quarantined": self.is_quarantined, + } + def get_file_by_id(self, file_id: str) -> FileSystemFile: """Return a FileSystemFile with the matching id.""" return self.files.get(file_id) @@ -67,11 +84,3 @@ class FileSystemFolder(FileSystemItem): def quarantine_status(self) -> bool: """Returns true if the folder is being quarantined.""" return self.is_quarantined - - def describe_state(self) -> Dict: - """ - Get the current state of the FileSystemFolder as a dict. - - :return: A dict containing the current state of the FileSystemFile. - """ - pass diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py index 0594cc35..3b368819 100644 --- a/src/primaite/simulator/file_system/file_system_item_abc.py +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -13,5 +13,19 @@ class FileSystemItem(SimComponent): """The size the item takes up on disk.""" def describe_state(self) -> Dict: - """Returns the state of the FileSystemItem.""" - pass + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "name": self.name, + "size": self.size, + } + ) + return state diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 346a089e..463d5f91 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -21,3 +21,21 @@ class NetworkContainer(SimComponent): validator=AllowAllValidator(), ), ) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + 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()}, + } + ) + return state diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index ab5d4943..b731862b 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -125,6 +125,31 @@ class NIC(SimComponent): _LOGGER.error(msg) raise ValueError(msg) + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "ip_adress": self.ip_address, + "subnet_mask": self.subnet_mask, + "gateway": self.gateway, + "mac_address": self.mac_address, + "speed": self.speed, + "mtu": self.mtu, + "wake_on_lan": self.wake_on_lan, + "dns_servers": self.dns_servers, + "enabled": self.enabled, + } + ) + return state + @property def ip_network(self) -> IPv4Network: """ @@ -241,23 +266,6 @@ class NIC(SimComponent): return True return False - def describe_state(self) -> Dict: - """ - Get the current state of the NIC as a dict. - - :return: A dict containing the current state of the NIC. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the NIC. - - :param action: The action to be applied. - :type action: str - """ - pass - def __str__(self) -> str: return f"{self.mac_address}/{self.ip_address}" @@ -293,6 +301,25 @@ class SwitchPort(SimComponent): kwargs["mac_address"] = generate_mac_address() super().__init__(**kwargs) + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "mac_address": self.mac_address, + "speed": self.speed, + "mtu": self.mtu, + "enabled": self.enabled, + } + ) + def enable(self): """Attempt to enable the SwitchPort.""" if self.enabled: @@ -379,23 +406,6 @@ class SwitchPort(SimComponent): return True return False - def describe_state(self) -> Dict: - """ - Get the current state of the SwitchPort as a dict. - - :return: A dict containing the current state of the SwitchPort. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the SwitchPort. - - :param action: The action to be applied. - :type action: str - """ - pass - def __str__(self) -> str: return f"{self.mac_address}" @@ -435,6 +445,26 @@ class Link(SimComponent): self.endpoint_b.connect_link(self) self.endpoint_up() + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "endpoint_a": self.endpoint_a.uuid, + "endpoint_b": self.endpoint_b.uuid, + "bandwidth": self.bandwidth, + "current_load": self.current_load, + } + ) + return state + @property def current_load_percent(self) -> str: """Get the current load formatted as a percentage string.""" @@ -504,23 +534,6 @@ class Link(SimComponent): """ self.current_load = 0 - def describe_state(self) -> Dict: - """ - Get the current state of the Link as a dict. - - :return: A dict containing the current state of the Link. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the Link. - - :param action: The action to be applied. - :type action: str - """ - pass - def __str__(self) -> str: return f"{self.endpoint_a}<-->{self.endpoint_b}" @@ -832,6 +845,30 @@ class Node(SimComponent): super().__init__(**kwargs) self.arp.nics = self.nics + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "hostname": self.hostname, + "operating_state": self.operating_state.value, + "NICs": {uuid: nic.describe_state() for uuid, nic in self.nics.items()}, + # "switch_ports": {uuid, sp for uuid, sp in self.switch_ports.items()}, + "file_system": self.file_system.describe_state(), + "applications": {uuid: app for uuid, app in self.applications.items()}, + "services": {uuid: svc for uuid, svc in self.services.items()}, + "process": {uuid: proc for uuid, proc in self.processes.items()}, + } + ) + return state + def show(self): """Prints a table of the NICs on the Node..""" from prettytable import PrettyTable @@ -950,14 +987,6 @@ class Node(SimComponent): elif frame.ip.protocol == IPProtocol.ICMP: self.icmp.process_icmp(frame=frame) - def describe_state(self) -> Dict: - """ - Describe the state of the Node. - - :return: A dictionary representing the state of the node. - """ - pass - class Switch(Node): """A class representing a Layer 2 network switch.""" @@ -966,9 +995,17 @@ class Switch(Node): "The number of ports on the switch." switch_ports: Dict[int, SwitchPort] = {} "The SwitchPorts on the switch." - dst_mac_table: Dict[str, SwitchPort] = {} + mac_address_table: Dict[str, SwitchPort] = {} "A MAC address table mapping destination MAC addresses to corresponding SwitchPorts." + def __init__(self, **kwargs): + super().__init__(**kwargs) + if not self.switch_ports: + 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 = port_num + def show(self): """Prints a table of the SwitchPorts on the Switch.""" table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) @@ -978,25 +1015,29 @@ class Switch(Node): print(table) def describe_state(self) -> Dict: - """TODO.""" - pass + """ + Produce a dictionary describing the current state of this object. - def __init__(self, **kwargs): - super().__init__(**kwargs) - if not self.switch_ports: - 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 = port_num + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + return { + "uuid": self.uuid, + "num_ports": self.num_ports, # redundant? + "ports": {port_num: port for port_num, port in self.switch_ports.items()}, + "mac_address_table": {mac: port for mac, port in self.mac_address_table.items()}, + } def _add_mac_table_entry(self, mac_address: str, switch_port: SwitchPort): - mac_table_port = self.dst_mac_table.get(mac_address) + mac_table_port = self.mac_address_table.get(mac_address) if not mac_table_port: - self.dst_mac_table[mac_address] = switch_port + self.mac_address_table[mac_address] = switch_port self.sys_log.info(f"Added MAC table entry: Port {switch_port.port_num} -> {mac_address}") else: if mac_table_port != switch_port: - self.dst_mac_table.pop(mac_address) + self.mac_address_table.pop(mac_address) self.sys_log.info(f"Removed MAC table entry: Port {mac_table_port.port_num} -> {mac_address}") self._add_mac_table_entry(mac_address, switch_port) @@ -1011,7 +1052,7 @@ class Switch(Node): dst_mac = frame.ethernet.dst_mac_addr self._add_mac_table_entry(src_mac, incoming_port) - outgoing_port = self.dst_mac_table.get(dst_mac) + outgoing_port = self.mac_address_table.get(dst_mac) if outgoing_port or dst_mac != "ff:ff:ff:ff:ff:ff": outgoing_port.send_frame(frame) else: diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 6989d2b9..1a37dc18 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -1,20 +1,23 @@ +from typing import Dict + from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent from primaite.simulator.domain.controller import DomainController +from primaite.simulator.network.container import NetworkContainer -class __TempNetwork: +class Simulation(SimComponent): """TODO.""" - pass - - -class SimulationContainer(SimComponent): - """TODO.""" - - network: __TempNetwork + network: NetworkContainer domain: DomainController def __init__(self, **kwargs): + if not kwargs.get("network"): + kwargs["network"] = NetworkContainer() + + if not kwargs.get("domain"): + kwargs["domain"] = DomainController() + super().__init__(**kwargs) self.action_manager = ActionManager() @@ -32,3 +35,21 @@ class SimulationContainer(SimComponent): func=lambda request, context: self.domain.apply_action(request, context), validator=AllowAllValidator() ), ) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + state.update( + { + "network": self.network.describe_state(), + "domain": self.domain.describe_state(), + } + ) + return state diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 36a7bc85..c61afae6 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -36,12 +36,11 @@ class Application(IOSoftware): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index 96d6251d..fe7b06b2 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -51,9 +51,12 @@ class Session(SimComponent): def describe_state(self) -> Dict: """ - Describes the current state of the session as a dictionary. + Produce a dictionary describing the current state of this object. - :return: A dictionary containing the current state of the session. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict """ pass @@ -77,9 +80,12 @@ class SessionManager: def describe_state(self) -> Dict: """ - Describes the current state of the session manager as a dictionary. + Produce a dictionary describing the current state of this object. - :return: A dictionary containing the current state of the session manager. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict """ pass diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py index bbd94345..8e278aa3 100644 --- a/src/primaite/simulator/system/processes/process.py +++ b/src/primaite/simulator/system/processes/process.py @@ -27,12 +27,11 @@ class Process(Software): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 7be5cb78..29a787c5 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -35,12 +35,11 @@ class Service(IOSoftware): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 854e7e2b..5bc08178 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -78,15 +78,25 @@ class Software(SimComponent): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update( + { + "health_state": self.health_state_actual.name, + "health_state_red_view": self.health_state_visible.name, + "criticality": self.criticality.name, + "patching_count": self.patching_count, + "scanning_count": self.scanning_count, + "revealed_to_red": self.revealed_to_red, + } + ) + return state def apply_action(self, action: List[str]) -> None: """ @@ -134,12 +144,11 @@ class IOSoftware(Software): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py b/tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py new file mode 100644 index 00000000..4543259d --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py @@ -0,0 +1,16 @@ +from primaite.simulator.sim_container import Simulation + + +def test_creating_empty_simulation(): + """Check that no errors occur when trying to setup a simulation without providing parameters""" + empty_sim = Simulation() + + +def test_empty_sim_state(): + """Check that describe_state has the right subcomponents.""" + empty_sim = Simulation() + sim_state = empty_sim.describe_state() + network_state = empty_sim.network.describe_state() + domain_state = empty_sim.domain.describe_state() + assert sim_state["network"] == network_state + assert sim_state["domain"] == domain_state From 01c912c094cfcfdf27609db77cff2a841b64dd17 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 20 Aug 2023 18:38:02 +0100 Subject: [PATCH 47/63] fix type hints and describe state functions --- .../notebooks/create-simulation.ipynb | 400 +++++++++++++++++- src/primaite/simulator/domain/account.py | 2 +- .../simulator/file_system/file_system.py | 4 +- .../simulator/file_system/file_system_file.py | 14 +- .../file_system/file_system_folder.py | 17 +- .../simulator/network/hardware/base.py | 27 +- .../system/applications/application.py | 24 +- .../simulator/system/processes/process.py | 4 +- .../simulator/system/services/service.py | 4 +- src/primaite/simulator/system/software.py | 12 +- 10 files changed, 459 insertions(+), 49 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index e5fd63b0..86a7f6a2 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -40,11 +40,11 @@ { "data": { "text/plain": [ - "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", - " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", " 'nodes': {},\n", " 'links': {}},\n", - " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" ] }, "execution_count": 2, @@ -81,19 +81,28 @@ { "data": { "text/plain": [ - "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", - " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", - " 'nodes': {'5f596f4f-4d34-4d1c-9688-9a105e489444': {'uuid': '5f596f4f-4d34-4d1c-9688-9a105e489444',\n", + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", " 'hostname': 'primaite_pc',\n", " 'operating_state': 0,\n", " 'NICs': {},\n", - " 'file_system': {'uuid': 'dc1e7032-7dba-44d5-aedb-5da75ab1eccc',\n", + " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}},\n", + " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {},\n", + " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", " 'folders': {}},\n", " 'applications': {},\n", " 'services': {},\n", " 'process': {}}},\n", " 'links': {}},\n", - " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" ] }, "execution_count": 4, @@ -103,16 +112,387 @@ ], "source": [ "my_pc = Node(hostname=\"primaite_pc\",)\n", + "my_server = Node(hostname=\"google_server\")\n", + "\n", + "# TODO: when there is a proper function for adding nodes, use it instead of manually adding.\n", + "\n", "my_sim.network.nodes[my_pc.uuid] = my_pc\n", + "my_sim.network.nodes[my_server.uuid] = my_server\n", + "\n", + "my_sim.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connect the nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.hardware.base import NIC, Link, Switch\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2023-08-20 18:34:59,328: NIC c3:08:90:23:29:cb/130.1.1.1 connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", + "2023-08-20 18:34:59,329: SwitchPort 40:4a:3f:2e:ee:2e connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", + "2023-08-20 18:34:59,331: NIC 69:50:cb:76:22:10/130.1.1.2 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n", + "2023-08-20 18:34:59,331: SwitchPort 18:5e:49:ed:21:55 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n" + ] + } + ], + "source": [ + "my_swtich = Switch(hostname=\"switch1\", num_ports=12)\n", + "\n", + "pc_nic = NIC(ip_address=\"130.1.1.1\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", + "my_pc.connect_nic(pc_nic)\n", + "\n", + "\n", + "server_nic = NIC(ip_address=\"130.1.1.2\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", + "my_server.connect_nic(server_nic)\n", + "\n", + "\n", + "pc_to_switch = Link(endpoint_a=pc_nic, endpoint_b=my_swtich.switch_ports[1])\n", + "server_to_swtich = Link(endpoint_a=server_nic, endpoint_b=my_swtich.switch_ports[2])\n", + "\n", + "my_sim.network.links[pc_to_switch.uuid] = pc_to_switch\n", + "my_sim.network.links[server_to_swtich.uuid] = server_to_swtich" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'ip_adress': '130.1.1.1',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': 'c3:08:90:23:29:cb',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}},\n", + " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'ip_adress': '130.1.1.2',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '69:50:cb:76:22:10',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}}},\n", + " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", + " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0},\n", + " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", + " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0}}},\n", + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_sim.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add files and folders to nodes\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.file_system.file_system_file_type import FileSystemFileType\n", + "from primaite.simulator.file_system.file_system_file import FileSystemFile" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "my_pc_downloads_folder = my_pc.file_system.create_folder(\"downloads\")\n", + "my_pc_downloads_folder.add_file(FileSystemFile(name=\"firefox_installer.zip\",file_type=FileSystemFileType.ZIP))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "FileSystemFile(uuid='3ecf7223-dafd-4973-8c3b-b85af4e177da', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_server_folder = my_server.file_system.create_folder(\"static\")\n", + "my_server.file_system.create_file(\"favicon.ico\", file_type=FileSystemFileType.PNG)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add applications to nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.system.applications.application import Application, ApplicationOperatingState\n", + "from primaite.simulator.system.software import SoftwareHealthState, SoftwareCriticality\n", + "from primaite.simulator.network.transmission.transport_layer import Port\n", + "\n", + "class MSPaint(Application):\n", + " def describe_state(self):\n", + " return super().describe_state()" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "mspaint = MSPaint(name = \"mspaint\", health_state_actual=SoftwareHealthState.GOOD, health_state_visible=SoftwareHealthState.GOOD, criticality=SoftwareCriticality.MEDIUM, ports={Port.HTTP}, operating_state=ApplicationOperatingState.RUNNING,execution_control_status='manual')" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "my_pc.applications[mspaint.uuid] = mspaint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create a domain account" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.domain.account import Account, AccountType\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "acct = Account(username=\"admin\", password=\"admin12\", account_type=AccountType.USER)\n", + "my_sim.domain.accounts[acct.uuid] = acct" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'ip_adress': '130.1.1.1',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': 'c3:08:90:23:29:cb',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", + " 'folders': {'f1fdf2ae-6377-4417-a28a-3edb4058712d': {'uuid': 'f1fdf2ae-6377-4417-a28a-3edb4058712d',\n", + " 'name': 'downloads',\n", + " 'size': 1000.0,\n", + " 'files': {'409b09a3-0d98-4c03-adf2-09190539be45': {'uuid': '409b09a3-0d98-4c03-adf2-09190539be45',\n", + " 'name': 'firefox_installer.zip',\n", + " 'size': 1000.0,\n", + " 'file_type': 'ZIP'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {'cddee888-d1b9-4289-8512-bc0a6672c880': {'uuid': 'cddee888-d1b9-4289-8512-bc0a6672c880',\n", + " 'health_state': 'GOOD',\n", + " 'health_state_red_view': 'GOOD',\n", + " 'criticality': 'MEDIUM',\n", + " 'patching_count': 0,\n", + " 'scanning_count': 0,\n", + " 'revealed_to_red': False,\n", + " 'installing_count': 0,\n", + " 'max_sessions': 1,\n", + " 'tcp': True,\n", + " 'udp': True,\n", + " 'ports': ['HTTP'],\n", + " 'opearting_state': 'RUNNING',\n", + " 'execution_control_status': 'manual',\n", + " 'num_executions': 0,\n", + " 'groups': []}},\n", + " 'services': {},\n", + " 'process': {}},\n", + " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'ip_adress': '130.1.1.2',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '69:50:cb:76:22:10',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", + " 'folders': {'beb5b535-cf6c-431d-94f6-d1097910130d': {'uuid': 'beb5b535-cf6c-431d-94f6-d1097910130d',\n", + " 'name': 'static',\n", + " 'size': 0,\n", + " 'files': {},\n", + " 'is_quarantined': False},\n", + " '6644cd6c-1eca-4fe4-9313-e3481abb895e': {'uuid': '6644cd6c-1eca-4fe4-9313-e3481abb895e',\n", + " 'name': 'root',\n", + " 'size': 40.0,\n", + " 'files': {'3ecf7223-dafd-4973-8c3b-b85af4e177da': {'uuid': '3ecf7223-dafd-4973-8c3b-b85af4e177da',\n", + " 'name': 'favicon.ico',\n", + " 'size': 40.0,\n", + " 'file_type': 'PNG'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}}},\n", + " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", + " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0},\n", + " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", + " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0}}},\n", + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912',\n", + " 'accounts': {'563a1805-0b32-4ba6-9551-e127e5eb57a8': {'uuid': '563a1805-0b32-4ba6-9551-e127e5eb57a8',\n", + " 'num_logons': 0,\n", + " 'num_logoffs': 0,\n", + " 'num_group_changes': 0,\n", + " 'username': 'admin',\n", + " 'password': 'admin12',\n", + " 'account_type': 'USER',\n", + " 'enabled': True}}}}" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ "my_sim.describe_state()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "import json" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'{\"uuid\": \"f0c06262-1bd9-49ee-81f8-793fb4a5e58e\", \"network\": {\"uuid\": \"455d6a1a-ca23-4135-b326-3ebf75022a45\", \"nodes\": {\"c7c91f06-f128-4891-84a2-83beceea3908\": {\"uuid\": \"c7c91f06-f128-4891-84a2-83beceea3908\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\": {\"uuid\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"c3:08:90:23:29:cb\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"04ffd1e8-dea7-47ad-a088-4856df055ed1\", \"folders\": {\"f1fdf2ae-6377-4417-a28a-3edb4058712d\": {\"uuid\": \"f1fdf2ae-6377-4417-a28a-3edb4058712d\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"409b09a3-0d98-4c03-adf2-09190539be45\": {\"uuid\": \"409b09a3-0d98-4c03-adf2-09190539be45\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"cddee888-d1b9-4289-8512-bc0a6672c880\": {\"uuid\": \"cddee888-d1b9-4289-8512-bc0a6672c880\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"dfcc395a-93ff-4dd5-9684-c80c5885d827\": {\"uuid\": \"dfcc395a-93ff-4dd5-9684-c80c5885d827\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1fd281a0-83ae-49d9-9b40-6aae7b465cab\": {\"uuid\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"69:50:cb:76:22:10\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"aea8f406-05de-4a02-b65f-972aa1fed70e\", \"folders\": {\"beb5b535-cf6c-431d-94f6-d1097910130d\": {\"uuid\": \"beb5b535-cf6c-431d-94f6-d1097910130d\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"6644cd6c-1eca-4fe4-9313-e3481abb895e\": {\"uuid\": \"6644cd6c-1eca-4fe4-9313-e3481abb895e\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"3ecf7223-dafd-4973-8c3b-b85af4e177da\": {\"uuid\": \"3ecf7223-dafd-4973-8c3b-b85af4e177da\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\": {\"uuid\": \"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\", \"endpoint_a\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"endpoint_b\": \"4e6abc87-b4b9-4f95-a9a9-59cac130c6ff\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\": {\"uuid\": \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\", \"endpoint_a\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"endpoint_b\": \"e136553f-333e-4abf-b1f3-ce352ffa4630\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"9da912d5-4c07-4df6-94c2-b3630e178912\", \"accounts\": {\"563a1805-0b32-4ba6-9551-e127e5eb57a8\": {\"uuid\": \"563a1805-0b32-4ba6-9551-e127e5eb57a8\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "d = my_sim.describe_state()\n", + "json.dumps(d)" + ] } ], "metadata": { diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index e30b7a27..d235c00e 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -59,7 +59,7 @@ class Account(SimComponent): "num_group_changes": self.num_group_changes, "username": self.username, "password": self.password, - "account_type": self.account_type, + "account_type": self.account_type.name, "enabled": self.enabled, } ) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index a5f603fe..440b7dc5 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -13,7 +13,7 @@ _LOGGER = getLogger(__name__) class FileSystem(SimComponent): """Class that contains all the simulation File System.""" - folders: Dict = {} + folders: Dict[str, FileSystemFolder] = {} """List containing all the folders in the file system.""" def describe_state(self) -> Dict: @@ -26,7 +26,7 @@ class FileSystem(SimComponent): :rtype: Dict """ state = super().describe_state() - state.update({"folders": {uuid: folder for uuid, folder in self.folders.items()}}) + state.update({"folders": {uuid: folder.describe_state() for uuid, folder in self.folders.items()}}) return state def get_folders(self) -> Dict: diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index 4bb6e585..c25f5973 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -45,9 +45,11 @@ class FileSystemFile(FileSystemItem): :return: Current state of this object and child objects. :rtype: Dict """ - return { - "uuid": self.uuid, - "name": self.name, - "size": self.size, - "file_type": self.file_type, - } + state = super().describe_state() + state.update( + { + "uuid": self.uuid, + "file_type": self.file_type.name, + } + ) + return state diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 463f3854..4e461a3a 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -10,7 +10,7 @@ _LOGGER = getLogger(__name__) class FileSystemFolder(FileSystemItem): """Simulation FileSystemFolder.""" - files: Dict = {} + files: Dict[str, FileSystemFile] = {} """List of files stored in the folder.""" is_quarantined: bool = False @@ -25,13 +25,14 @@ class FileSystemFolder(FileSystemItem): :return: Current state of this object and child objects. :rtype: Dict """ - return { - "uuid": self.uuid, - "name": self.name, - "size": self.size, - "files": {uuid: file for uuid, file in self.files.items()}, - "is_quarantined": self.is_quarantined, - } + state = super().describe_state() + state.update( + { + "files": {uuid: file.describe_state() for uuid, file in self.files.items()}, + "is_quarantined": self.is_quarantined, + } + ) + return state def get_file_by_id(self, file_id: str) -> FileSystemFile: """Return a FileSystemFile with the matching id.""" diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index b731862b..28e7693a 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -11,15 +11,19 @@ from prettytable import PrettyTable from primaite import getLogger from primaite.exceptions import NetworkError from primaite.simulator.core import SimComponent +from primaite.simulator.domain.account import Account from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader +from primaite.simulator.system.applications.application import Application from primaite.simulator.system.core.packet_capture import PacketCapture from primaite.simulator.system.core.session_manager import SessionManager from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.processes.process import Process +from primaite.simulator.system.services.service import Service _LOGGER = getLogger(__name__) @@ -137,9 +141,9 @@ class NIC(SimComponent): state = super().describe_state() state.update( { - "ip_adress": self.ip_address, - "subnet_mask": self.subnet_mask, - "gateway": self.gateway, + "ip_adress": str(self.ip_address), + "subnet_mask": str(self.subnet_mask), + "gateway": str(self.gateway), "mac_address": self.mac_address, "speed": self.speed, "mtu": self.mtu, @@ -319,6 +323,7 @@ class SwitchPort(SimComponent): "enabled": self.enabled, } ) + return state def enable(self): """Attempt to enable the SwitchPort.""" @@ -802,13 +807,13 @@ class Node(SimComponent): nics: Dict[str, NIC] = {} "The NICs on the node." - accounts: Dict = {} + accounts: Dict[str, Account] = {} "All accounts on the node." - applications: Dict = {} + applications: Dict[str, Application] = {} "All applications on the node." - services: Dict = {} + services: Dict[str, Service] = {} "All services on the node." - processes: Dict = {} + processes: Dict[str, Process] = {} "All processes on the node." file_system: FileSystem "The nodes file system." @@ -862,9 +867,9 @@ class Node(SimComponent): "NICs": {uuid: nic.describe_state() for uuid, nic in self.nics.items()}, # "switch_ports": {uuid, sp for uuid, sp in self.switch_ports.items()}, "file_system": self.file_system.describe_state(), - "applications": {uuid: app for uuid, app in self.applications.items()}, - "services": {uuid: svc for uuid, svc in self.services.items()}, - "process": {uuid: proc for uuid, proc in self.processes.items()}, + "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()}, } ) return state @@ -1026,7 +1031,7 @@ class Switch(Node): return { "uuid": self.uuid, "num_ports": self.num_ports, # redundant? - "ports": {port_num: port for port_num, port in self.switch_ports.items()}, + "ports": {port_num: port.describe_state() for port_num, port in self.switch_ports.items()}, "mac_address_table": {mac: port for mac, port in self.mac_address_table.items()}, } diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index c61afae6..37748560 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -8,13 +8,12 @@ from primaite.simulator.system.software import IOSoftware class ApplicationOperatingState(Enum): """Enumeration of Application Operating States.""" - -RUNNING = 1 -"The application is running." -CLOSED = 2 -"The application is closed or not running." -INSTALLING = 3 -"The application is being installed or updated." + RUNNING = 1 + "The application is running." + CLOSED = 2 + "The application is closed or not running." + INSTALLING = 3 + "The application is being installed or updated." class Application(IOSoftware): @@ -43,7 +42,16 @@ class Application(IOSoftware): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update( + { + "opearting_state": self.operating_state.name, + "execution_control_status": self.execution_control_status, + "num_executions": self.num_executions, + "groups": list(self.groups), + } + ) + return state def apply_action(self, action: List[str]) -> None: """ diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py index 8e278aa3..c4e94845 100644 --- a/src/primaite/simulator/system/processes/process.py +++ b/src/primaite/simulator/system/processes/process.py @@ -34,4 +34,6 @@ class Process(Software): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update({"operating_state": self.operating_state.name}) + return state diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 29a787c5..eafff3f0 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -42,7 +42,9 @@ class Service(IOSoftware): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update({"operating_state": self.operating_state.name}) + return state def apply_action(self, action: List[str]) -> None: """ diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 5bc08178..a2acd9fb 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -151,7 +151,17 @@ class IOSoftware(Software): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update( + { + "installing_count": self.installing_count, + "max_sessions": self.max_sessions, + "tcp": self.tcp, + "udp": self.udp, + "ports": [port.name for port in self.ports], # TODO: not sure if this should be port.name or port.value + } + ) + return state def send(self, payload: Any, session_id: str, **kwargs) -> bool: """ From 3911010777ad09451f8da4b2ac1a72b947f0ff61 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 20 Aug 2023 18:42:58 +0100 Subject: [PATCH 48/63] update notebook --- src/primaite/notebooks/create-simulation.ipynb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index 86a7f6a2..a3e2d92c 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -464,6 +464,13 @@ "my_sim.describe_state()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify that the state dictionary contains no non-serialisable objects." + ] + }, { "cell_type": "code", "execution_count": 17, From 7c16a9cdde818093c518844868aecdbb4c38f2e1 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 20 Aug 2023 18:43:21 +0100 Subject: [PATCH 49/63] Update notebook --- .../notebooks/create-simulation.ipynb | 230 +++++------------- 1 file changed, 61 insertions(+), 169 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index a3e2d92c..b0a140a1 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -4,7 +4,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Build a simulation using the Python API\n" + "# Build a simulation using the Python API\n", + "\n", + "Currently, this notbook manipulates the simulation by directly placing objects inside of the attributes of the network and domain. It should be refactored when proper methods exist for adding these objects.\n" ] }, { @@ -40,11 +42,11 @@ { "data": { "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", + " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", " 'nodes': {},\n", " 'links': {}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" + " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a', 'accounts': {}}}" ] }, "execution_count": 2, @@ -77,39 +79,7 @@ "cell_type": "code", "execution_count": 4, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", - " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", - " 'hostname': 'primaite_pc',\n", - " 'operating_state': 0,\n", - " 'NICs': {},\n", - " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}},\n", - " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", - " 'hostname': 'google_server',\n", - " 'operating_state': 0,\n", - " 'NICs': {},\n", - " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}}},\n", - " 'links': {}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_pc = Node(hostname=\"primaite_pc\",)\n", "my_server = Node(hostname=\"google_server\")\n", @@ -117,9 +87,7 @@ "# TODO: when there is a proper function for adding nodes, use it instead of manually adding.\n", "\n", "my_sim.network.nodes[my_pc.uuid] = my_pc\n", - "my_sim.network.nodes[my_server.uuid] = my_server\n", - "\n", - "my_sim.describe_state()" + "my_sim.network.nodes[my_server.uuid] = my_server\n" ] }, { @@ -147,10 +115,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "2023-08-20 18:34:59,328: NIC c3:08:90:23:29:cb/130.1.1.1 connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", - "2023-08-20 18:34:59,329: SwitchPort 40:4a:3f:2e:ee:2e connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", - "2023-08-20 18:34:59,331: NIC 69:50:cb:76:22:10/130.1.1.2 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n", - "2023-08-20 18:34:59,331: SwitchPort 18:5e:49:ed:21:55 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n" + "2023-08-20 18:42:51,310: NIC 5c:b6:26:c0:86:61/130.1.1.1 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", + "2023-08-20 18:42:51,311: SwitchPort 01:ef:b1:a3:24:72 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", + "2023-08-20 18:42:51,314: NIC f6:de:1e:63:8e:7f/130.1.1.2 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n", + "2023-08-20 18:42:51,315: SwitchPort 30:9e:c8:d4:5d:f3 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n" ] } ], @@ -172,74 +140,6 @@ "my_sim.network.links[server_to_swtich.uuid] = server_to_swtich" ] }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", - " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", - " 'hostname': 'primaite_pc',\n", - " 'operating_state': 0,\n", - " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", - " 'ip_adress': '130.1.1.1',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': 'c3:08:90:23:29:cb',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}},\n", - " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", - " 'hostname': 'google_server',\n", - " 'operating_state': 0,\n", - " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", - " 'ip_adress': '130.1.1.2',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': '69:50:cb:76:22:10',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}}},\n", - " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", - " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", - " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0},\n", - " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", - " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", - " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "my_sim.describe_state()" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -249,7 +149,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -259,7 +159,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -269,16 +169,16 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "FileSystemFile(uuid='3ecf7223-dafd-4973-8c3b-b85af4e177da', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + "FileSystemFile(uuid='253e4606-0f6d-4e57-8db0-6fa7e331ecea', name='favicon.ico', size=40.0, file_type=, action_manager=None)" ] }, - "execution_count": 10, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -297,7 +197,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -305,6 +205,7 @@ "from primaite.simulator.system.software import SoftwareHealthState, SoftwareCriticality\n", "from primaite.simulator.network.transmission.transport_layer import Port\n", "\n", + "# no applications exist yet so we will create our own.\n", "class MSPaint(Application):\n", " def describe_state(self):\n", " return super().describe_state()" @@ -312,7 +213,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -321,7 +222,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -337,7 +238,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -346,7 +247,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -354,39 +255,46 @@ "my_sim.domain.accounts[acct.uuid] = acct" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify that the state dictionary contains no non-serialisable objects." + ] + }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", - " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", + "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", + " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", + " 'nodes': {'1fa46446-6681-4e25-a3ba-c4c2cc564630': {'uuid': '1fa46446-6681-4e25-a3ba-c4c2cc564630',\n", " 'hostname': 'primaite_pc',\n", " 'operating_state': 0,\n", - " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'NICs': {'09ca02eb-7733-492c-9eff-f0d6b6ebeeda': {'uuid': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", " 'ip_adress': '130.1.1.1',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': 'c3:08:90:23:29:cb',\n", + " 'mac_address': '5c:b6:26:c0:86:61',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", - " 'folders': {'f1fdf2ae-6377-4417-a28a-3edb4058712d': {'uuid': 'f1fdf2ae-6377-4417-a28a-3edb4058712d',\n", + " 'file_system': {'uuid': '8b533e31-04e9-4838-839d-0656ace3e57a',\n", + " 'folders': {'b450c223-872c-4fe0-90cc-9da80973eaad': {'uuid': 'b450c223-872c-4fe0-90cc-9da80973eaad',\n", " 'name': 'downloads',\n", " 'size': 1000.0,\n", - " 'files': {'409b09a3-0d98-4c03-adf2-09190539be45': {'uuid': '409b09a3-0d98-4c03-adf2-09190539be45',\n", + " 'files': {'8160e685-a76f-4171-8a12-3d6b32a9ea16': {'uuid': '8160e685-a76f-4171-8a12-3d6b32a9ea16',\n", " 'name': 'firefox_installer.zip',\n", " 'size': 1000.0,\n", " 'file_type': 'ZIP'}},\n", " 'is_quarantined': False}}},\n", - " 'applications': {'cddee888-d1b9-4289-8512-bc0a6672c880': {'uuid': 'cddee888-d1b9-4289-8512-bc0a6672c880',\n", + " 'applications': {'c82f1064-f35e-466b-88ae-3f61ba0e5161': {'uuid': 'c82f1064-f35e-466b-88ae-3f61ba0e5161',\n", " 'health_state': 'GOOD',\n", " 'health_state_red_view': 'GOOD',\n", " 'criticality': 'MEDIUM',\n", @@ -404,29 +312,29 @@ " 'groups': []}},\n", " 'services': {},\n", " 'process': {}},\n", - " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " '7f637689-6f91-4026-a685-48a9067f03e8': {'uuid': '7f637689-6f91-4026-a685-48a9067f03e8',\n", " 'hostname': 'google_server',\n", " 'operating_state': 0,\n", - " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'NICs': {'1abc7272-c516-4463-bd07-1a3cefe39313': {'uuid': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", " 'ip_adress': '130.1.1.2',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': '69:50:cb:76:22:10',\n", + " 'mac_address': 'f6:de:1e:63:8e:7f',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", - " 'folders': {'beb5b535-cf6c-431d-94f6-d1097910130d': {'uuid': 'beb5b535-cf6c-431d-94f6-d1097910130d',\n", + " 'file_system': {'uuid': 'ac9a6643-8349-4f7a-98c7-a1a9f97ce123',\n", + " 'folders': {'befa5d92-0878-4da2-9dac-f993c0b4a554': {'uuid': 'befa5d92-0878-4da2-9dac-f993c0b4a554',\n", " 'name': 'static',\n", " 'size': 0,\n", " 'files': {},\n", " 'is_quarantined': False},\n", - " '6644cd6c-1eca-4fe4-9313-e3481abb895e': {'uuid': '6644cd6c-1eca-4fe4-9313-e3481abb895e',\n", + " '27383b5e-8884-4ec0-bb50-a5d43e460dfa': {'uuid': '27383b5e-8884-4ec0-bb50-a5d43e460dfa',\n", " 'name': 'root',\n", " 'size': 40.0,\n", - " 'files': {'3ecf7223-dafd-4973-8c3b-b85af4e177da': {'uuid': '3ecf7223-dafd-4973-8c3b-b85af4e177da',\n", + " 'files': {'253e4606-0f6d-4e57-8db0-6fa7e331ecea': {'uuid': '253e4606-0f6d-4e57-8db0-6fa7e331ecea',\n", " 'name': 'favicon.ico',\n", " 'size': 40.0,\n", " 'file_type': 'PNG'}},\n", @@ -434,18 +342,18 @@ " 'applications': {},\n", " 'services': {},\n", " 'process': {}}},\n", - " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", - " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", - " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", + " 'links': {'a449b1ff-50d9-4342-861e-44f2d4dfef37': {'uuid': 'a449b1ff-50d9-4342-861e-44f2d4dfef37',\n", + " 'endpoint_a': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", + " 'endpoint_b': 'ee4557d9-a309-45dd-a6e0-5b572cc70ee5',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0},\n", - " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", - " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", - " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", + " 'ebd7687b-ec69-4f1b-b2ba-86669aa95723': {'uuid': 'ebd7687b-ec69-4f1b-b2ba-86669aa95723',\n", + " 'endpoint_a': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", + " 'endpoint_b': 'dc26b764-a07e-486a-99a4-798c8e0c187a',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912',\n", - " 'accounts': {'563a1805-0b32-4ba6-9551-e127e5eb57a8': {'uuid': '563a1805-0b32-4ba6-9551-e127e5eb57a8',\n", + " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a',\n", + " 'accounts': {'5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51': {'uuid': '5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51',\n", " 'num_logons': 0,\n", " 'num_logoffs': 0,\n", " 'num_group_changes': 0,\n", @@ -455,7 +363,7 @@ " 'enabled': True}}}}" ] }, - "execution_count": 16, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -464,41 +372,25 @@ "my_sim.describe_state()" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Verify that the state dictionary contains no non-serialisable objects." - ] - }, { "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [], - "source": [ - "import json" - ] - }, - { - "cell_type": "code", - "execution_count": 22, + "execution_count": 16, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'{\"uuid\": \"f0c06262-1bd9-49ee-81f8-793fb4a5e58e\", \"network\": {\"uuid\": \"455d6a1a-ca23-4135-b326-3ebf75022a45\", \"nodes\": {\"c7c91f06-f128-4891-84a2-83beceea3908\": {\"uuid\": \"c7c91f06-f128-4891-84a2-83beceea3908\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\": {\"uuid\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"c3:08:90:23:29:cb\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"04ffd1e8-dea7-47ad-a088-4856df055ed1\", \"folders\": {\"f1fdf2ae-6377-4417-a28a-3edb4058712d\": {\"uuid\": \"f1fdf2ae-6377-4417-a28a-3edb4058712d\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"409b09a3-0d98-4c03-adf2-09190539be45\": {\"uuid\": \"409b09a3-0d98-4c03-adf2-09190539be45\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"cddee888-d1b9-4289-8512-bc0a6672c880\": {\"uuid\": \"cddee888-d1b9-4289-8512-bc0a6672c880\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"dfcc395a-93ff-4dd5-9684-c80c5885d827\": {\"uuid\": \"dfcc395a-93ff-4dd5-9684-c80c5885d827\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1fd281a0-83ae-49d9-9b40-6aae7b465cab\": {\"uuid\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"69:50:cb:76:22:10\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"aea8f406-05de-4a02-b65f-972aa1fed70e\", \"folders\": {\"beb5b535-cf6c-431d-94f6-d1097910130d\": {\"uuid\": \"beb5b535-cf6c-431d-94f6-d1097910130d\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"6644cd6c-1eca-4fe4-9313-e3481abb895e\": {\"uuid\": \"6644cd6c-1eca-4fe4-9313-e3481abb895e\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"3ecf7223-dafd-4973-8c3b-b85af4e177da\": {\"uuid\": \"3ecf7223-dafd-4973-8c3b-b85af4e177da\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\": {\"uuid\": \"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\", \"endpoint_a\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"endpoint_b\": \"4e6abc87-b4b9-4f95-a9a9-59cac130c6ff\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\": {\"uuid\": \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\", \"endpoint_a\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"endpoint_b\": \"e136553f-333e-4abf-b1f3-ce352ffa4630\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"9da912d5-4c07-4df6-94c2-b3630e178912\", \"accounts\": {\"563a1805-0b32-4ba6-9551-e127e5eb57a8\": {\"uuid\": \"563a1805-0b32-4ba6-9551-e127e5eb57a8\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + "'{\"uuid\": \"5304ed6d-de4c-408c-ae24-ada32852d196\", \"network\": {\"uuid\": \"fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756\", \"nodes\": {\"1fa46446-6681-4e25-a3ba-c4c2cc564630\": {\"uuid\": \"1fa46446-6681-4e25-a3ba-c4c2cc564630\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\": {\"uuid\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"5c:b6:26:c0:86:61\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"8b533e31-04e9-4838-839d-0656ace3e57a\", \"folders\": {\"b450c223-872c-4fe0-90cc-9da80973eaad\": {\"uuid\": \"b450c223-872c-4fe0-90cc-9da80973eaad\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"8160e685-a76f-4171-8a12-3d6b32a9ea16\": {\"uuid\": \"8160e685-a76f-4171-8a12-3d6b32a9ea16\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"c82f1064-f35e-466b-88ae-3f61ba0e5161\": {\"uuid\": \"c82f1064-f35e-466b-88ae-3f61ba0e5161\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7f637689-6f91-4026-a685-48a9067f03e8\": {\"uuid\": \"7f637689-6f91-4026-a685-48a9067f03e8\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1abc7272-c516-4463-bd07-1a3cefe39313\": {\"uuid\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"f6:de:1e:63:8e:7f\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"ac9a6643-8349-4f7a-98c7-a1a9f97ce123\", \"folders\": {\"befa5d92-0878-4da2-9dac-f993c0b4a554\": {\"uuid\": \"befa5d92-0878-4da2-9dac-f993c0b4a554\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\": {\"uuid\": \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"253e4606-0f6d-4e57-8db0-6fa7e331ecea\": {\"uuid\": \"253e4606-0f6d-4e57-8db0-6fa7e331ecea\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"a449b1ff-50d9-4342-861e-44f2d4dfef37\": {\"uuid\": \"a449b1ff-50d9-4342-861e-44f2d4dfef37\", \"endpoint_a\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"endpoint_b\": \"ee4557d9-a309-45dd-a6e0-5b572cc70ee5\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\": {\"uuid\": \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\", \"endpoint_a\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"endpoint_b\": \"dc26b764-a07e-486a-99a4-798c8e0c187a\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"320cbb83-eb1b-4911-a4f0-fc46d8038a8a\", \"accounts\": {\"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\": {\"uuid\": \"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" ] }, - "execution_count": 22, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "d = my_sim.describe_state()\n", - "json.dumps(d)" + "import json\n", + "json.dumps(my_sim.describe_state())" ] } ], From 07b740a81e9872a62aa3326520fa50421c2c6186 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 21 Aug 2023 09:49:31 +0100 Subject: [PATCH 50/63] Update docs and changelog. --- CHANGELOG.md | 2 ++ docs/_static/component_relationship.png | Bin 0 -> 82446 bytes docs/source/simulation_structure.rst | 14 +++++++++++--- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 docs/_static/component_relationship.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3f57fb..2b495c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ a Service/Application another machine. SessionManager. - Permission System - each action can define criteria that will be used to permit or deny agent actions. - File System - ability to emulate a node's file system during a simulation +- Example notebooks - There is currently 1 jupyter notebook which walks through using PrimAITE + 1. Creating a simulation - this notebook explains how to build up a simulation using the Python package. (WIP) ## [2.0.0] - 2023-07-26 diff --git a/docs/_static/component_relationship.png b/docs/_static/component_relationship.png new file mode 100644 index 0000000000000000000000000000000000000000..c2dd1102d203e7f99081dc1e0f953729aaa2e314 GIT binary patch literal 82446 zcmeFacT|&E*Ebvn+t?5kL8T2AtRRC*FDi;k6B{5^P*9OxLJy9kfT|8aoZz34lol{Z?badwR1BBDJb)3%Mw z3BZ-$Mv^o+gNAo+HaB0Z?`*h7d;z1Ke`!fRd>C=~=uy`==DG%w+*vb$@*O4Z>%v>q z)|urv%8lpo&3_VuDgFK2fu_l=J9dN-Et#)G741C2JGh6KdVHP}E7FGxcv4G#einmS zct$xxIyHZEH0q4{W(q^~0N>5_cAF`zv3AY$3h|QY<9jt~ zTqkam(TNl017p98J7{&o)9mcl$YZE%0OI8c=WY)N}ze-x0zweh1#fvCt1i0EvJr52}w>>PJ zFZ*Bqc3(5la`tcC-Q5RTU=RIxjXx_?yKkbgOJmycjh9Q*g}Pka1pji0u_{JS>(f7=?Dk33yAZ|QsB=MTH<>OQT);1w<;CeFId zKVkRsZCNw_nT5^mzPl}_nrgiZ`zu@X9sQnG@!{Q9zb@A?HNE7|%rGp=l}}h^u@Zx+ zGdwWA+mB2Aj0IvRX}p?Oq}*-WbJ` zvgkHrLOI0}UF|TfT))1vQ%Il_8*U8-cwT+<`VU`du&Jt~;MvAi-@auex>CbhmM*|7 zlz;wm>xm&nw za4q5B!6AmF00&q($bfkor4cLI$oDujKO@o zfBgH^F&HuaC)}wrh=RIW$R<^M1K`Bh_dU|n)*kK}djc20V0w@Ka{VIb!gYdzNin{c zpX%}rG=~~mi3ti4HCPAWEe12X>BowxD5b9O+bC@_9A$J}c4533iXYnA@chRI0E7jp z{W9yvjln2_Wo2cvAMERcal`n=@BSR+!ABVl@XNYgys+OeLF0n+=9x9o1EGK4&d(<% z=wEC6umvo$|CTk@pBR|my_o6C6{)0=4>GvU^qICQ4dK}hY`V}Wll!W5YA6=pXIAXu zc>dF|Wfsk@C*=ShTg&{|3Sy_&O zx!oT2M5sd9=pdh}73qHN+LoFU73oFJGVFIv;HP)D{@5)Toie;dK~FMvR|vnpKFO&u z&)vK4dRq?Xx=`0Hn>xy+k8pavg<}T5{>!1N%QmCdzIb zwBF@4q9?0woh%3r25_EO*9TvY-u=UG1@Se0?i9+|S*T7K#(=}Gn7Y5E+^U3CBq%sC z6ie3r(4}ZY&8CIW0F{?bDDDacFG$-by8DgqEj;B~HT-6HGB1f?H$t`Utu@al4<%UJWW(yDM}|MnCl4y>7xZ6W@dEcD=^TX6nNZA+{y1@t>EbIoodt zY&bh`GC@SYX4iFDa5`W9_B`=3n&AcHiVAnZ0}D|lEVK83Umg@%GLZ88xe{{Gdv{3Q z8r9n}QZVa14R5Q>bUfi%4hX?B8<_lv8Kptq4pz1{K3#h=Z9AjteHd}uc7*Xp>i9!E z_fi$L9jNaQ#H}xy4THMyO{H@ywt374z0#3$lw?1lDlipmN)!c{M+z@=AiU~5_I4_y zsN;ljQ#j6rNE&l5Jv^p>JL6+p5qj(Eha1e?8ZfO_wTTPZgGuM)w2l+y%EwAN#q3Xo z1|MBbtnrEVQuU(YB{imcE;>1dpg{BY>k%XQ?(xY1<YKTE!4Pg04#c*a^#n`}-pZZniy)={+a43e21fvs)a!=Q;Yw&tmt$rI2a}`@LTIGJiZ&I z-*rqx#M9$|bB<>R6U_#Su1zOpDgX-9Io=$p_0;Yq=kboPsI`#tnQ%$MMwx*JYsop` zlU4<;C)ILi?xN>OeS-6>bg;7=C`$nq?!AK?z>Tn)Mcd3rd}VsA+cV}}_Q z-U9-Yx9?hUZKxOskKHF>RI{($QdOfYtaxvH-gEq^PlcUCNc2gvAb#5KuO4V zX`+XU1LBZFtTpOQu9b)s6)#zZVBNE>;NYQG_iY;p?zVsnjLw{0U$bjh`jnOB(5QMB zJa)G#3#XpWXq8XsPCax}wZWdayso%dvpFT8P&eq0g039q-oq9F2i2Gy8I`{ zsh!#I%#QRrT0!hQ)xQ{gs?GFfk+L?)bsJ>j%!Qmw#@Mn78^kVNz7<9+s$-aLfTv${ z>!)Xw0%wBYj?F37!{nCP-NqvM*;|Ug3xB~}*$=2o+?`T=Y$nz1{Gn(Sf0Lc&9@vU} zW?YrRBa_V0r`q;B*tz9EAgl&hC|&Owz`2oBAm+DQ>2q^);hTjN#1!n!*Q0G# z9lbZlmecImkpRmXaPX$o{cP-G@udUKH>ZG(Nr%;CNqqzB9zDj;LQ2J79dy0FB?70b zGkpR?Xjg-LTB_+n*6TE`Tm))D1?|IUv3&w*vuJTq*!`szbF+(anVFpKK4j6{)TBtL zHgy*W?<(fLLbwnh;KEnV{h@;-uQi&i=-1KIlA`gh=meg?w(YkDBWE0KNL`9u)jpxH zPydQ`E+F{@HeMi69Bf6p}mE?dq>CmQMGFJ@k0#ObmGccdLH@wnf#DH}rhHpzF>} z1*1rqbB~~vtjFygk5b^>@OGJEbw6sS*a87$dxHEh(yE!pxUoj~5RgA1p0thiSQ$1p zYlC86XX@Az=5-glzK`DCZ4J5LNYD?0*1(0(UE?YxHRaN0F5|7=*WYo72y)=E9~RLS z0k@;|OUNH~*^lk7o;FXbs4Z4pDRvRY7xdoxmmCQ%2LT`d*fY8Nu5h?l$5D-Lmm~4W zDK+zaNGPlLR3&#CmcmMJ3z)oV|IWck^UoBbGAsjH@YwYeunN!xTx}0iiinjmk((l( znBi+aPCC;Z^mZEKQ$8x6JSdAGvh?w9$>M3ud_(8=;a(dJGvwZC!0pMET*CeWEDWxE59YFAl4{w~TTX=;&Nu<^Nyjou#OqxRHS(UH- zN`W98ZWv&9#`0M;MjlO-cr|myBIm4UZx7o5FmGjk)$|Tvztk&XTu2x=(e7?hn^aT!+;Dt*%tO z?@;gLd)pm0XD1$j2?pX{PrW;7C|=jI;`|X9u=VbrS=ORcfPeyNnuE5mtQKE>H}me& zWv6v_dJ41B(yQ{p@6+O!{^qgoO6%f9}8$0$YoT!Mk?PMlg}Y~^{#hNKt~DC0HUPNLY-U=<>wf{>qXAGUpb z8?z(0(NK41N!Pc-;T<{I5}}VLHZ1DRB#*_G9NVc02&{-c_VJcZK}s@EB@AM9&T5=2 zKzbWMqN}NZ;}TmqE3>jZ^RTIfGZ`CG6BIw(2>9|+9Ku?>+%H2Z=zV6mB|=~hk)zcJ zj^9VTr)-y81;hhYyA8p?TZ!lMAyOrancj8h+yw7nGj&%uxu8fEfYnH!YVkhny)j0v zzBYN_<^WE+Ir#xD1qWT^#Q!^cJ&R&)6zhFDf%Zky|v4n z3KU!!rwn7nVAF`drAEh>h;Zeen5eVst{M7x%p7(v^+u{XY6|khmW4^Kg zBm?D>V8=NrZ_izk?ov;(VDccg0ZFAMwYM11%EAH-O6rlJkWh%uqua}$St{= zmYt(+)sL_TuaL6UVZ$=^ov#x~iXP?4#xO#P46+5ni4aJ} zbypIwtxiJ^##UD$_8!5s7tARFr8oKc^W3T{d@ba6yDv9uK3uZpUADw|D;lLI0lwW7z$RK_~Qa$N)6{};a^K$c>A@^L{+Tk5Gj1A6+`74<%?QqpA;T}Bh$4xVpJa#z(k?Fc|%&2Lk9vbxlDIEtNIJsLFTpUN!!zC)9@*y zoy8no*Oq73eHaQ)FZ-vMPSpk7IGW!`ehX-|R7?IRUX4F??3OJ(+7toRk%p>{?v|2( zU9r5rekWCf_9R6mV`=Eg`0hx$QVG$8lzP@wCE?Ig}1Z=`j z)coaubs+WFr{QaGdZv(>M_4i`c+-Ay?$D*22$bEF#jTV~47cf)UyyD9&rlJXIK5dau`=vAlOS+ILt* zW8atIhSXvZXhpBx-?r!%ZIYHQObBm<)h)deyNLMU;oAJ@zf@q$;U+&wE^`xKI@QK+ z#Kv(bxISU2r;HgE1nxy8M^DOy0D7^ReEalg;K9GC-B=$t0A5!(3P*BA3TgZRV z`ghmG6)QtIs zr%(o4zc-0-`wc##97F+2CMA{y_@0 zcD8ULaAVDnO0v>jz2sOS=i-SoDdt7{&ZJsy@FROG0Bw_HkXtJ62^aBVOzC&>Xg8k$ zR*{xQNh&zoZ622}7%j;k)M=^wKB(6#(LSHFMO-N4CdeWvtiIkM7d}_?*8ZY2;rn63 zhE+hY!|Nyc^mEs|y0wb=jM1roP@cgoR-*=T3(G82$3c zkTEBQcoxhf+OuDd{`d<&g*I;K-G3`9$PV9M|GnbD&vXf{_V1;B&n)V}kv!k|Gd#t6kF+!@HbWFxO@%)zr7!pII*=T|oSqqQgRdza z{`qW+(jk9E+N&r(n!flf5sJUmbW$bz5y;7qyO&%h>n>n0{KFGB6uUUBThvL%cp;y^c03rJlX=<)OIY^d7R$YGMeYj$*lOayB0tHE%;=2~~>%oC2oQ6^R{w71Kc} z{)^~5PkJN-rSPb}FdG)uV7_dHv|SL@g&?E3wM=eO8%mF3T{%$Yva8MAgMJ=AHtTMq z1g=AbT$@UyTYzb4f_gfC z$S{KFZ%oWGcbIfY@^D65)fGZOvWTbzRMv(h;5cd0h1xZX+$d z1vgW8AQD0I=O%s~2mJo)g;EODPR>?7gVr==L&;!%wTTiyEI3UCLurTt-0VVNy`K*kxg`jqLV zOS75Ba*UGGMSIMmYjZTvQj0|{zctjlc+5qw8A&9xcxXint#F79g;EJ5D?uO!QFd^8 zs*5pj`9J9Vp5O3VWc{|G8MEe|*NudO-yM(AS5$D!U~sRAjAJm$?|2(tdG)+Nm3(6h zA0J5KP7GvVtm_(TL7}HkTds5Sdm+~4AH-jw0xGD~222zoMUK|IWMl6AUS?KOEX0Rd zdilRfz~=-l={{Au^CIvAkxMYwU{si(*h}_GzTED@WwPAH{xCz4e2^s~G&1xf`dmUR z9@5wVtGLW!J*G6?1JKDnkg@Hw%-93Onw$TZV!y(-i-YRWSdH7)O*=Py)!@Wd{aINYH`eMEAGb(?H&$XcY7rRk3zjxM8)mQNjyQO%Akz$B(0Laxa zN{e=(^Oy&}OVKSq^JB!Wj%|ru4YVi3WLBJa21t3d_FuBcU<%Q9G=>jj{pF&) zlG}InP3iA_6E{FxB=1%I5`Ex~?|%^v&W%e97LUQ)ntKYE2x6zJD@Z!|;0B|kda!7N z{z9v4x63;PG3D=j^vmB5ptHc^cMRqgg?tL*62&uIfEm!_y-=CE_3VsdFn3P;&;g;} z{QH(~&M^O4t%aPyrhR+##L06AsB|Ldv^6&>A$Olq~X%psiz**?OXK4yZ9*|^_kb)_C1&Jb3 zjT63>R)X-%#hiHLss3^$!8s48B?0~xX;@CGe!@ZBn^0vbr?nWhU!fvvb5At}7{*XF zTkV_ky`w0y&>khXm4Lp?r>9 zgq5ftzA;X+_LKDoRT(Oksym^o+UF9`wCvg2HxmE}`iSp`E>xnM4Xh{EyR;L6R$rgD z*d@?eo=`nD<={DSUH0luCi z$*b@@9^Huw%=kweRSG7`sokHyen44w!QY4Gv)OPBruQ>-6uvYj9+`qlugO(pw-1Ol z!R0G$`9WviSqu7;?k55}cX+JR>CH)N63$D9fJJ~z(MUPtnJ(r6 z)FF|cYAu5-O8x>%LEA( z;VS?qy81*>UWusf=~GIFV_yXngQ!C7{*)%GA`!92ZeDb#)|FSUi-aaNwe>K`q&^mK zEbr=)_I<%ucnJlW&k-K+i*vfXEuQswo>jGA_yC{qTMQHV!_ zK|(ay_d&1**G`Ww`V4D$*6`MK2Y_GBrmdZfYhfFi+SKuGx$;0ea)uodXDQh4tZ ze8NXHK?emybW@+kYf|oDZoy5cX1;=whS=&K9&5QB65y1#h{&N8+L}&}xSmr1kMS5w z0TmW#OuO1!L=pQ9fDA1$#Gas4dAKZC)UWwv)OfnPpMP7yoVZk5Ebvta9pd~5Y)kPf z&@f1GCo6ctn%oghm1g~-FEf_Jw6T$m(janaqE=DRW`Xq-LX-etRO|QzsExiiX#}JU zvH`Izs-$*nojeJ21*!uVrtVKYtBSLO%{Eyf!`%kUvj3~QlEWzw1`P`0oKcftkGtHa z=F?CjfkB3xRlz}F!pf@fWe2tHzMko&NOopw@zWm5!J}-iSvk^hK!WWa?(e625>it3 zaI>fXQetnkEeJjHXct?xrdR+hE3WB3T7kMokQkuz!yHx5^sY8$&4AoEPGjs!wAaup z@hXbp<5tJ8dGAMbh`Sv;kbAS;LDY5n5-JW^wrIrpu`Zh5jzyZNSS#K>Fm2T2hCwQ} z)v_afas!0pMdh8!nbO@ZtKu_}zd+KNw0*bfvD>EE&~hmM#`jnSaURj*0({I0N0Y_n z`&QrY{vfHMa5!M{AW36&w&u+%eiwAKwd1IjsWikX^hZSCnGc!nJAvK`k3A`jR240I zshzR+0%G&BD+9ecq3B%9Bix6+Q6Y#fo}XvgzR?OIb;49bx*Oc>QKV%HMlx^1mggiG zWcQSnlfm+!S>Zg@-HnZHClxhDjt$bf2L0ubzXR{CTATy8wUH{{!FU?n>1Y!}%qk#z za=WB`ar?U9mPj9ria)~vJvDf*c&1knh@ole>OCeGA@xaXYSIBcF{pp5bKX4T_-B@U zV0vZLz7O44WPG)YQ|2RLK9f|lAx_pq903?i1>yOtNNw%Ey^MSt0^_u`JF_mHL**qb ziTy@fhYq>`*))7Q5L-wnQy`Wm1FPD#>Yt9)8!NH)NDvPC3Af_h+SEK%n%qfML)8+y zvV%p%xkcld#+xsv#H!@Ai1^7l`6#cI%`dR^8N63eP*|!C`Hi8|!qMXJH%AJ4i>7W{(FTGfT_>-9dn_!_=b5S= zV+A=KBt}#6zMskCt#_Y-*3y3ppSJn(fPv4JMhELZ*b;Hdw!UcF*8`$(?uj*Kl#&42MsinqpxN^)#O}+;&FEU4MMXl=E|N zI7xw%CYHF3tG<$rG109vJSiRNILdAR3z|0%_p+APMcUtrZI@`jkhA|v*Q5t1VnQ99 z&FgwXZ_|kSG&l8|=lvi#D*T58Y8y$AV%;@n9|K|(H7tz#cGl7s&?s$<`%q&6md^7P zo9or0W_mlf-ZWL{Xg8Ck}ZW_Eb_o&XQlIr4B}KtA?z9QPb# zx|G74OnnGd3|9FpGe)z zaq_#u0$&u;wgZfG3tC*G9sl6+?)gu$PIEzY@jM}5{P_V9#LGb}_y$s+%@L9yodFo8 z`6ccoG=W4Zl(1?)&`@y_X^^0$j!AWw1I$7gg%;|z9wr1+i_6uN3)GV0!d>ZVLmwoE z+&c4Gx*lLF{to^?E~~Mcx!l$A1)L(9H>)Ctqa~@P+2&V+pk?7$VU&}jdTSzosfcsl zALQ_X=I)M?H4r2mQHyd#r5s4W_vRbdxMQi>?mIiJ_*8NS@&k$F()eomypwfA?F!VTAwf}3>4c6W6O-eb`H%NT{MQG8fR=)?H9Q4 zw^`nKy~7!`H6WvIx0Q;A72oN}$QfM;7kAq6cqA{GJzhuxmjnweuEiZC+&%3ZM8>Hz zeyHl*MKJ?fT0sNr*>yE2_yduO)bd@9vK&-5M)e3#bG;=xh6lVFAz{sW=zb*+S@sxy z+j;SC&>m0_;{mzqh5Am^up`T0nN9}<98MLi1E7#QGCc*jvVF8fWvbVNNx~f|JkkZe z$e;dt%Hr^I-tdLETi~t26v@6jTs!A@ckE7q4a}$IE7`BER+mMApi%aO|f9tTAZ#vAa|{ z1$)yOns;+$P%*|pb>cH2TRlKT@TShUC(sWfs%m%Y0h}xVT`&Kw`>Y?|I z_sK$Q@WV^HAY=GxIdDc+aL5(4O>Wvnl!a~hS;=}{U!E9sTtO_}` z45aJ~@d`6r+a*D^^mW$Rod+p|UPg*IEtajW`gy!ya4Vf5z-JY z?8nHJ6n^WUT(hOPt10EW2AoBHold2KOK5L;i-P$8g@c^y7UG3uzd44Ttr2ZEt zTd~KSz-uZ^_1^O?`WlA1a0#4~FhRf{8N5py2T!L8Rh}@^)>PqUNGyl&Ur_y}uB(6a zsltw)>Q8sY9c4?nG>4GKs1p=Iz3rbntWx){8FloFhAm{v5s;`!t~uYRSrXN%shYb$ zOni<7xswDLn~?Ye5Wq?x7Y<6>adsev9Vt1;3r2@tFhbj8mikO~p9 zIHcMqq!V9?-p%X>?4)YhIL7sx zlrmP6Ni4c!B1=5)CgBd5Nji6N_?F8d!6qEd5=n>-w4eY@8nHPqbpjazx{nGLl*U2v zF@JgUH(0?Q%1qc1>|=j$T=(yk0`?MScWu2kbx3W>Sz_TosK z+(_XQaiKwnCnmChh{Va27Pr@ye_mG0V75(uZv{#9D{+6K>-FReN;4ru_6a{$I+52d zu>*uKSh`gM5TTU!Mko^{*)?>%I1b^vi?&LO7jtT^%~DFAUcjm)jE>$kMm=_cl8SMW zH)HexoN0PrIRxk)^r~#`YR>ELomA@jw63W`-{3S2>`bTf&O468G~5%D*qMqhBfXn`rT@{&k4YRRxh)5aDoUSKg?8ohjhNz-_9+KoF~xOxnS7>ZS6VPX?E4tSgN90OiSMK&*UW_D7x}4+`3D) zU<{|-Vt)&&lJt>0Rk$K@P2m*-l|1i(tI@;*pbE}cTYN~29^XmIyqrq;Xr3wBzh0lW zW0^CZnP&$%Z09&{h8*I#!jJrsEjIQrnLnTam|8dlWp9XQ+qI=0NlA4xTAw5?et~+s zUlF!BMXzheTsPY2bYW~PcB6Fa6vKE+gx`kFXPQn$Bl)Nww8L+!u{}GsolkmY&ZR8k zomSZbN0;~uk~oM+CKE*$)U(x_H8>x%J9;+mI768*1@!R@+$jQPzwx;&pQ|uvNh0oI zqinj9|_1R_t@sltvh>waBy5!meBfy(*FkIvo89 zH|9&%7!I|nL#S%wVgI*1lt|02h=FPg&_<<+bfA{_5jQvR^XfO&wLgTKHE4v3oLcS! zCV`$()Rhn0okVZ!?7U~frLu~1toHUMk9n{J1<`>gmf}T8R--u~eZyZ4Yt!-Hz;!bw zxHTGHhF9FjhREXl)GtpCpUh!-DD%!Wik- zv zG=y%$iLXUKP;E)chr}X;@=sSS0EI){rh)OQQIK5?czRuTC%!ti(;2e2NJrEx!GG4c z7zmR5cPzb{o&r#al>=QRUPaqjLrKV?)DPR{v?$Mll`GR_>Q47bZ5nZ2LBXll+@ZSQ zrL{<+avo5;2`mY|j; zzcp`$Bi|DHMJv^r5{LB9BDhsRygSIx-#hO$SYi{fn#nPNKETLobX^{$|m?sGbZl zjOODE`@m|a#=%Nq1v%ZFJkXe1vJefHMybz8$p zHBf14ny?JDQ54bdlT4q@p7|8*qqwP7CQeK1De7-%cqA2PD%yTJCZ~phwFv` z{@=cohP8Hec{Ld8*!HqFA`&pdL}N$AP63i~LiaeqEp^q*Y=ew_czj%1LVfBtkmIyb z_qFkBh0;GvRBE&&H0>MpJ(xjq{3NrbugKY}!`7=*D$-Wigzw%D#psa)03bThA}B6S zK|L_{Hw4~eE%Ar}12uoo6L8v|6WCkqWpYZIvn5AFGt^(50d&@_a5!|*T6>d~i*wiL z2_OdUDLFA;y0ys>*e9`GI@kOy4I~Daa1eh}h4tT6U&#e;I<|6wN3p&1%8~~(-Nl@Z zcLUT_!e`>bha<}_5u!_sdD($yJ4`yHs#` z^sgsE;)N&!)|+n43!wMLkj~{msn2`$x?g!0Kt@nf7g8L$A&lu)9&`7{f8Vx6_XrtQm!BC;YyJ{s(4sfRey_b@Ze! zRmB{Y5uTGEA4x`m6dFni`OaM>Da49O3D-Auls)Lt+$gm(f`X`FuYwqpq$GLb)Pq)M z1bhQy>?M)>tEjm?(x?gl5K=aZ*wJqcXOlbb=X1?7bdFU8Zqwxz<@WVWcAg#^^Ubq} ztz3R<6_5;CxC`Oy$!0-Cnwiag>uCYI+hBbnc^jm#K$x-Bk}VCV*OwEGO)s^q1ZJU# z81eBgl?rQ#k-#dXq(OnD)Ri!&y{i)d$WE{&UmL^zkiXQOw@@_+ zPFxrsm?<8a66uC^9oi&m*^?ZP8GrC4UIk?6R#CBxwp+X)gSF#tZmo8m&*u_o8Xw7W zR;#0a9uyRmRLT4^y>qya6XXkBQYMAxFGWtE3I#oU16d6rs(J60#-}Ju)vx~@C#9CrT|di~pv3s|t7XshEP&2MJ zflHg3aE6na;FJ@*CnRpzL2H+xZ|B4{vGj{|Q~ppOi5*j@nwEcU;WZ#7S+gquw+)s% z;X_qKITT{x$b_&oIm-u=93YAOXx3dCudVFc#Mj~pVC#`r#Ohnn8hgZJh;TzWf%LfW5^=;dZ3nH)e1}>5>uqp z;Q)hy3=1#oLOGDpX4NGOtP@URu|@M1Whkv@dr*$pFBsPMYL(i>uZ0Pi zlTKjxt!Dw$J_Mi&#wKpors(Zdl>A`yIbXr^)0DU-Z#pOKkha_BLbMYz483l$n2`-J zRv&TW(4ij}seWM!$cA1E2j=bSguNV}rnVj%IF>}b3R=ny{@xFi5ckGH3(Hu0Hjt{D zBG~NTco4}tz1Zr4uDKt0(Nl^9g)E{>b|rmJUF6;A4EwdB!I8r{2fbvbphF~0WK2hB zyK=kR_4BpdBW{~&p^e*k;7>kG&~FaVAq`wzl!-A+Ri1Ke5pn&5Vn|;N?;bB`0djdZ z%6=}_%n{j{uaCPBcQVzUWF*Nwo>pV{dnbX#>7eFZqaEjBFBzH|79VoIXA~S75YwFw0TD_gT%N5BweeOa3 z%*keg_;v#fLunf!A6T3&hs7ho!SlPVE6Jckr{@s+y2y<3nr}05iZWb9>Dj)(Jn{oW zJ*c`v%(Spf!RA;ob%`K&sxD}r(40PM<0GYWky-->fo;-YK$?PdsuHYq>O;=?g{8cH zPSt3>v70FJ(*Fc&*<-epF~+x-6i#QnJPGjtl;`!JxLmKMx_qk^nmP$#&BqCb7Ut);m*N!yYi})l~<-IB4h1oz@yq=@WH7q zzVYX8+CnM0D~U<46&&T8Hk`x#i;e*01Sh2A*-n8y)bhT{6w!@Ooanqjn3~uSTT4}N zXx0>NiuB)y8dOn3tZ|ezpcLN#pAO}h=uj`EmJ({Q3O6&7Cv}v;ah!hPnoc5_q6%Q33 zAVTY@D;@|8AJ1u!I?eVeZ^60wB1X9zx}ya?jY0e!%|QVO8iyk(kAS$cy_|FhXOEWi zsNP+o!bLAXEiepWr!W8J0{8K|TX)dbV+P(ho`a|#eU%R42=TY>8wMUNL4C22_FoX& zje4UR$1MHcZYr?o%NP`q$*y|va7%lEw6vwKk6BuUCX5A?1|B;p>j_ek!*x8ZPf2g$ zGyPRQSh>~9tgb@*SD^+v3rVO71YQ(j8)YF-UqwNw$A+Y-F=FULD**BQ%i`Wl;66*% z!^t^9tAh5=XK;(^B1JkM40Lk8jOvWGGvFlAE$K77Of1jnL=!glo92r)ebyDR{8rJ& z&@px;Fkb5pzwA}fx3pCoZyX`W^NFaJuJ#lU(^#g)fncPQ| zJfWb(;4tY9p$iqsU4!g*T+XNMGwQTu*A?iyaNdUsJO$Q2D4G7}>5CVwMU=3WEM#|+ z43D;vmbEFJ-%NO`G2T7)Cw4^g3NT^kbOp>y$NC1z!6jDJ1l-B=*+Lr%yk0tYxT}ZDS-t z=^JX3?qn;6H115+b4`Pri%y*)sAID%5?xRi#h3n$0yV=cP8#cB+1eT=^&djwUSCy+k$)@S}YVwK2%Ek>VCa^6O8lhsrAHPwQDlF4}Co^*>-wU z!Fu;f{L%BmffX`lU%9A4Bj*^9*w_LzzN#Ygog*9ntU~$f`T5M?=&94E+xq*NHde;D zXG`xTR&{gxG=q!S>)l0!G8Oq!&SuIwSww3dhqDyFr#X5Bznmnkl54x1Bk%ngDQQ19;;u=B# z(VVdR3c8}+ycJz76Y8wc)4pPvGu4Cn&N;w+zvVY+hsuTv@>A}OwzV@3J!nk!RIA_^ zIA$PoLXLn6WmIzR-r1!v5TEMh)`=y?h!7!s)+Zc{nZF#RHYm}2hV!fIcj(_Y{T zr16nvrZE;3^V}!{Z9KXa_nHa(x%+mcxrAqsZ24%3c|Dyx(iISvj%-hn4kcRQS7=VVcIEy96r}1*U`3etYB+ykEu)A< zEvzsLf3nl^-EJ)1J$dD2Z^XiuimxAZ+({SCZ%Uf4&5^Zy%r4mKRhhV8RGVpmZ@O~yn_-bjA3ZKb24r?0DL z`S>MT?J@GBKlWbhc#}}{C@}TR$j(Nlp=^-A#8e{0`k =|845Vs4X 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 literal 0 HcmV?d00001 diff --git a/docs/source/simulation_structure.rst b/docs/source/simulation_structure.rst index 2b213f16..7630ae0f 100644 --- a/docs/source/simulation_structure.rst +++ b/docs/source/simulation_structure.rst @@ -7,11 +7,19 @@ Simulation Structure ==================== The simulation is made up of many smaller components which are related to each other in a tree-like structure. At the -top level, there is an object called the ``SimulationController`` _(doesn't exist yet)_, which has a physical network -and a software controller for managing software and users. +top level, there is the :py:meth:`primaite.simulator.sim_container.Simulation`, which keeps track of the physical network +and a domain controller for managing software and users. -Each node of the simulation 'tree' has responsibility for creating, deleting, and updating its direct descendants. +Each node of the simulation 'tree' has responsibility for creating, deleting, and updating its direct descendants. Also, +when a component's ``describe_state()`` method is called, it will include the state of its descendants. The +``apply_action()`` method can be used to act on a component or one of its descendatnts. The diagram below shows the +relationship between components. +.. image:: _static/component_relationship.png + :width: 500 + :alt: The top level simulation object owns a NetworkContainer and a DomainController. The DomainController has a + list of accounts. The network container has links and nodes. Nodes can own switchports, NICs, FileSystem, + Application, Service, and Process. Actions From 7e64acd36840363be507ea36924d4eeb93f2a07e Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 21 Aug 2023 10:04:23 +0100 Subject: [PATCH 51/63] Update container docstrings --- src/primaite/simulator/network/container.py | 3 ++- src/primaite/simulator/sim_container.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 463d5f91..f89ed2d3 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -5,12 +5,13 @@ from primaite.simulator.network.hardware.base import Link, Node class NetworkContainer(SimComponent): - """TODO.""" + """Top level container object representing the physical network.""" nodes: Dict[str, Node] = {} links: Dict[str, Link] = {} def __init__(self, **kwargs): + """Initialise the network.""" super().__init__(**kwargs) self.action_manager = ActionManager() diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 1a37dc18..50fe412c 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -6,12 +6,13 @@ from primaite.simulator.network.container import NetworkContainer class Simulation(SimComponent): - """TODO.""" + """Top-level simulation object which holds a reference to all other parts of the simulation.""" network: NetworkContainer domain: DomainController def __init__(self, **kwargs): + """Initialise the Simulation.""" if not kwargs.get("network"): kwargs["network"] = NetworkContainer() From 1613bbe27afece0621614b1931daae38848c24e4 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 23 Aug 2023 14:41:30 +0100 Subject: [PATCH 52/63] Add methods for adding/removing nodes form network --- .../notebooks/create-simulation.ipynb | 200 +++--------------- src/primaite/simulator/core.py | 22 ++ src/primaite/simulator/network/container.py | 47 +++- 3 files changed, 93 insertions(+), 176 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index b0a140a1..11d41356 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -18,7 +18,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -36,26 +36,12 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", - " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", - " 'nodes': {},\n", - " 'links': {}},\n", - " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a', 'accounts': {}}}" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_sim = Simulation()\n", + "net = my_sim.network\n", "my_sim.describe_state()" ] }, @@ -68,7 +54,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -77,17 +63,14 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "my_pc = Node(hostname=\"primaite_pc\",)\n", + "net.add_node(my_pc)\n", "my_server = Node(hostname=\"google_server\")\n", - "\n", - "# TODO: when there is a proper function for adding nodes, use it instead of manually adding.\n", - "\n", - "my_sim.network.nodes[my_pc.uuid] = my_pc\n", - "my_sim.network.nodes[my_server.uuid] = my_server\n" + "net.add_node(my_server)\n" ] }, { @@ -99,7 +82,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -108,22 +91,12 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2023-08-20 18:42:51,310: NIC 5c:b6:26:c0:86:61/130.1.1.1 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", - "2023-08-20 18:42:51,311: SwitchPort 01:ef:b1:a3:24:72 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", - "2023-08-20 18:42:51,314: NIC f6:de:1e:63:8e:7f/130.1.1.2 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n", - "2023-08-20 18:42:51,315: SwitchPort 30:9e:c8:d4:5d:f3 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n" - ] - } - ], + "outputs": [], "source": [ "my_swtich = Switch(hostname=\"switch1\", num_ports=12)\n", + "net.add_node(my_swtich)\n", "\n", "pc_nic = NIC(ip_address=\"130.1.1.1\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", "my_pc.connect_nic(pc_nic)\n", @@ -149,7 +122,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -159,7 +132,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -169,20 +142,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "FileSystemFile(uuid='253e4606-0f6d-4e57-8db0-6fa7e331ecea', name='favicon.ico', size=40.0, file_type=, action_manager=None)" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_server_folder = my_server.file_system.create_folder(\"static\")\n", "my_server.file_system.create_file(\"favicon.ico\", file_type=FileSystemFileType.PNG)" @@ -197,7 +159,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -213,7 +175,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -222,7 +184,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -238,7 +200,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -247,7 +209,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -264,130 +226,18 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", - " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", - " 'nodes': {'1fa46446-6681-4e25-a3ba-c4c2cc564630': {'uuid': '1fa46446-6681-4e25-a3ba-c4c2cc564630',\n", - " 'hostname': 'primaite_pc',\n", - " 'operating_state': 0,\n", - " 'NICs': {'09ca02eb-7733-492c-9eff-f0d6b6ebeeda': {'uuid': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", - " 'ip_adress': '130.1.1.1',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': '5c:b6:26:c0:86:61',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': '8b533e31-04e9-4838-839d-0656ace3e57a',\n", - " 'folders': {'b450c223-872c-4fe0-90cc-9da80973eaad': {'uuid': 'b450c223-872c-4fe0-90cc-9da80973eaad',\n", - " 'name': 'downloads',\n", - " 'size': 1000.0,\n", - " 'files': {'8160e685-a76f-4171-8a12-3d6b32a9ea16': {'uuid': '8160e685-a76f-4171-8a12-3d6b32a9ea16',\n", - " 'name': 'firefox_installer.zip',\n", - " 'size': 1000.0,\n", - " 'file_type': 'ZIP'}},\n", - " 'is_quarantined': False}}},\n", - " 'applications': {'c82f1064-f35e-466b-88ae-3f61ba0e5161': {'uuid': 'c82f1064-f35e-466b-88ae-3f61ba0e5161',\n", - " 'health_state': 'GOOD',\n", - " 'health_state_red_view': 'GOOD',\n", - " 'criticality': 'MEDIUM',\n", - " 'patching_count': 0,\n", - " 'scanning_count': 0,\n", - " 'revealed_to_red': False,\n", - " 'installing_count': 0,\n", - " 'max_sessions': 1,\n", - " 'tcp': True,\n", - " 'udp': True,\n", - " 'ports': ['HTTP'],\n", - " 'opearting_state': 'RUNNING',\n", - " 'execution_control_status': 'manual',\n", - " 'num_executions': 0,\n", - " 'groups': []}},\n", - " 'services': {},\n", - " 'process': {}},\n", - " '7f637689-6f91-4026-a685-48a9067f03e8': {'uuid': '7f637689-6f91-4026-a685-48a9067f03e8',\n", - " 'hostname': 'google_server',\n", - " 'operating_state': 0,\n", - " 'NICs': {'1abc7272-c516-4463-bd07-1a3cefe39313': {'uuid': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", - " 'ip_adress': '130.1.1.2',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': 'f6:de:1e:63:8e:7f',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': 'ac9a6643-8349-4f7a-98c7-a1a9f97ce123',\n", - " 'folders': {'befa5d92-0878-4da2-9dac-f993c0b4a554': {'uuid': 'befa5d92-0878-4da2-9dac-f993c0b4a554',\n", - " 'name': 'static',\n", - " 'size': 0,\n", - " 'files': {},\n", - " 'is_quarantined': False},\n", - " '27383b5e-8884-4ec0-bb50-a5d43e460dfa': {'uuid': '27383b5e-8884-4ec0-bb50-a5d43e460dfa',\n", - " 'name': 'root',\n", - " 'size': 40.0,\n", - " 'files': {'253e4606-0f6d-4e57-8db0-6fa7e331ecea': {'uuid': '253e4606-0f6d-4e57-8db0-6fa7e331ecea',\n", - " 'name': 'favicon.ico',\n", - " 'size': 40.0,\n", - " 'file_type': 'PNG'}},\n", - " 'is_quarantined': False}}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}}},\n", - " 'links': {'a449b1ff-50d9-4342-861e-44f2d4dfef37': {'uuid': 'a449b1ff-50d9-4342-861e-44f2d4dfef37',\n", - " 'endpoint_a': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", - " 'endpoint_b': 'ee4557d9-a309-45dd-a6e0-5b572cc70ee5',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0},\n", - " 'ebd7687b-ec69-4f1b-b2ba-86669aa95723': {'uuid': 'ebd7687b-ec69-4f1b-b2ba-86669aa95723',\n", - " 'endpoint_a': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", - " 'endpoint_b': 'dc26b764-a07e-486a-99a4-798c8e0c187a',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a',\n", - " 'accounts': {'5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51': {'uuid': '5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51',\n", - " 'num_logons': 0,\n", - " 'num_logoffs': 0,\n", - " 'num_group_changes': 0,\n", - " 'username': 'admin',\n", - " 'password': 'admin12',\n", - " 'account_type': 'USER',\n", - " 'enabled': True}}}}" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_sim.describe_state()" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'{\"uuid\": \"5304ed6d-de4c-408c-ae24-ada32852d196\", \"network\": {\"uuid\": \"fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756\", \"nodes\": {\"1fa46446-6681-4e25-a3ba-c4c2cc564630\": {\"uuid\": \"1fa46446-6681-4e25-a3ba-c4c2cc564630\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\": {\"uuid\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"5c:b6:26:c0:86:61\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"8b533e31-04e9-4838-839d-0656ace3e57a\", \"folders\": {\"b450c223-872c-4fe0-90cc-9da80973eaad\": {\"uuid\": \"b450c223-872c-4fe0-90cc-9da80973eaad\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"8160e685-a76f-4171-8a12-3d6b32a9ea16\": {\"uuid\": \"8160e685-a76f-4171-8a12-3d6b32a9ea16\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"c82f1064-f35e-466b-88ae-3f61ba0e5161\": {\"uuid\": \"c82f1064-f35e-466b-88ae-3f61ba0e5161\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7f637689-6f91-4026-a685-48a9067f03e8\": {\"uuid\": \"7f637689-6f91-4026-a685-48a9067f03e8\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1abc7272-c516-4463-bd07-1a3cefe39313\": {\"uuid\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"f6:de:1e:63:8e:7f\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"ac9a6643-8349-4f7a-98c7-a1a9f97ce123\", \"folders\": {\"befa5d92-0878-4da2-9dac-f993c0b4a554\": {\"uuid\": \"befa5d92-0878-4da2-9dac-f993c0b4a554\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\": {\"uuid\": \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"253e4606-0f6d-4e57-8db0-6fa7e331ecea\": {\"uuid\": \"253e4606-0f6d-4e57-8db0-6fa7e331ecea\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"a449b1ff-50d9-4342-861e-44f2d4dfef37\": {\"uuid\": \"a449b1ff-50d9-4342-861e-44f2d4dfef37\", \"endpoint_a\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"endpoint_b\": \"ee4557d9-a309-45dd-a6e0-5b572cc70ee5\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\": {\"uuid\": \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\", \"endpoint_a\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"endpoint_b\": \"dc26b764-a07e-486a-99a4-798c8e0c187a\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"320cbb83-eb1b-4911-a4f0-fc46d8038a8a\", \"accounts\": {\"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\": {\"uuid\": \"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "import json\n", "json.dumps(my_sim.describe_state())" diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 2c802c0f..63120ecf 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -137,6 +137,7 @@ class SimComponent(BaseModel): kwargs["uuid"] = str(uuid4()) super().__init__(**kwargs) self.action_manager: Optional[ActionManager] = None + self._parent: Optional["SimComponent"] = None @abstractmethod def describe_state(self) -> Dict: @@ -187,3 +188,24 @@ class SimComponent(BaseModel): Override this method with anything that needs to happen within the component for it to be reset. """ pass + + @property + def parent(self) -> "SimComponent": + """Reference to the parent object which manages this object. + + :return: Parent object. + :rtype: SimComponent + """ + return self._parent + + @parent.setter + def parent(self, new_parent: "SimComponent") -> None: + if self._parent: + msg = f"Overwriting parent of {self}, {self._parent} with {new_parent}" + _LOGGER.warn(msg) + raise RuntimeWarning(msg) + self._parent = new_parent + + @parent.deleter + def parent(self) -> None: + self._parent = None diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index f89ed2d3..be2a3bbb 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -1,8 +1,11 @@ -from typing import Dict +from typing import Any, Dict +from primaite import getLogger from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent from primaite.simulator.network.hardware.base import Link, Node +_LOGGER = getLogger(__name__) + class NetworkContainer(SimComponent): """Top level container object representing the physical network.""" @@ -40,3 +43,45 @@ class NetworkContainer(SimComponent): } ) return state + + def add_node(self, node: Node) -> None: + """ + Add an existing node to the network. + + :param node: Node instance that the network should keep track of. + :type node: Node + """ + if node in self: + _LOGGER.warning(f"Can't add node {node}. It is already in the network.") + self.nodes[node.uuid] = node + node.parent = self + + def remove_node(self, node: Node) -> None: + """ + Remove a node from the network. + + :param node: Node instance that is currently part of the network that should be removed. + :type node: Node + """ + if node not in self: + _LOGGER.warning(f"Can't remove node {node}. It's not in the network.") + del self.nodes[node.uuid] + del node.parent # misleading? + + def connect_nodes(self, node1: Node, node2: Node) -> None: + """TODO.""" + # I think we should not be forcing users to add and remove individual links. + # Clearly if a link exists between two nodes in the network, then the link is also part of the network. + # I'm just not sure how we ought to handle link creation as it requires an unoccupied network device on the node + raise NotImplementedError + + def disconnect_nodes(self, node1: Node, node2: Node) -> None: + """TODO.""" + raise NotImplementedError + + def __contains__(self, item: Any) -> bool: + if isinstance(item, Node): + return item.uuid in self.nodes + elif isinstance(item, Link): + return item.uuid in self.links + raise TypeError("") From 72b019287aef95c014a36b5e1764aba208da7ff6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 23 Aug 2023 14:41:59 +0100 Subject: [PATCH 53/63] Add scratch notebook to gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 60f5f54c..ff86b65f 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,4 @@ src/primaite/outputs/ # benchmark session outputs benchmark/output +src/primaite/notebooks/scratch.ipynb From a82ffb974717273963482d9162579538dac2ffb8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 23 Aug 2023 15:44:23 +0100 Subject: [PATCH 54/63] Add notebook outputs back into src control --- .../notebooks/create-simulation.ipynb | 254 ++++++++++++++++-- 1 file changed, 233 insertions(+), 21 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index 11d41356..e3e7dfb7 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -18,7 +18,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -36,9 +36,24 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", + " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", + " 'nodes': {},\n", + " 'links': {}},\n", + " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e', 'accounts': {}}}" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "my_sim = Simulation()\n", "net = my_sim.network\n", @@ -54,7 +69,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -63,7 +78,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -82,7 +97,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -91,9 +106,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2023-08-23 15:44:02,059: NIC 1b:8f:94:4f:46:99/130.1.1.1 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", + "2023-08-23 15:44:02,062: SwitchPort ad:3c:77:44:98:27 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", + "2023-08-23 15:44:02,064: NIC 50:f4:6b:9b:a8:74/130.1.1.2 connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n", + "2023-08-23 15:44:02,065: SwitchPort fd:b1:68:f9:8f:eb connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n" + ] + } + ], "source": [ "my_swtich = Switch(hostname=\"switch1\", num_ports=12)\n", "net.add_node(my_swtich)\n", @@ -122,7 +148,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -132,7 +158,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -142,9 +168,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "FileSystemFile(uuid='f45bffd7-4aa1-4f6f-81ba-85e746abd28b', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "my_server_folder = my_server.file_system.create_folder(\"static\")\n", "my_server.file_system.create_file(\"favicon.ico\", file_type=FileSystemFileType.PNG)" @@ -159,7 +196,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -175,7 +212,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -184,7 +221,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -200,7 +237,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -209,7 +246,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -226,18 +263,193 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", + " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", + " 'nodes': {'1599c08e-a101-41a7-a86a-4176660c4270': {'uuid': '1599c08e-a101-41a7-a86a-4176660c4270',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {'ab09d298-ac44-40ef-b950-b4ca6268d482': {'uuid': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", + " 'ip_adress': '130.1.1.1',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '1b:8f:94:4f:46:99',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '92120387-14cb-426c-98f2-64d64a85f560',\n", + " 'folders': {'6a11bd03-bc59-4da9-8474-639fcb72b9be': {'uuid': '6a11bd03-bc59-4da9-8474-639fcb72b9be',\n", + " 'name': 'downloads',\n", + " 'size': 1000.0,\n", + " 'files': {'194b2029-4723-4cff-b6d7-e647e4fb687d': {'uuid': '194b2029-4723-4cff-b6d7-e647e4fb687d',\n", + " 'name': 'firefox_installer.zip',\n", + " 'size': 1000.0,\n", + " 'file_type': 'ZIP'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {'ae49273b-f581-44e7-ae8c-18cc766158e8': {'uuid': 'ae49273b-f581-44e7-ae8c-18cc766158e8',\n", + " 'health_state': 'GOOD',\n", + " 'health_state_red_view': 'GOOD',\n", + " 'criticality': 'MEDIUM',\n", + " 'patching_count': 0,\n", + " 'scanning_count': 0,\n", + " 'revealed_to_red': False,\n", + " 'installing_count': 0,\n", + " 'max_sessions': 1,\n", + " 'tcp': True,\n", + " 'udp': True,\n", + " 'ports': ['HTTP'],\n", + " 'opearting_state': 'RUNNING',\n", + " 'execution_control_status': 'manual',\n", + " 'num_executions': 0,\n", + " 'groups': []}},\n", + " 'services': {},\n", + " 'process': {}},\n", + " '7231c745-e186-47a2-8f69-006033b38b8f': {'uuid': '7231c745-e186-47a2-8f69-006033b38b8f',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {'d138788b-2a8e-4c5c-aa5d-b5c28758a78a': {'uuid': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", + " 'ip_adress': '130.1.1.2',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '50:f4:6b:9b:a8:74',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '85f1d50c-ded7-4160-9a11-1305ab25934b',\n", + " 'folders': {'86c2666e-31da-46a9-a267-4dc87e2620f9': {'uuid': '86c2666e-31da-46a9-a267-4dc87e2620f9',\n", + " 'name': 'static',\n", + " 'size': 0,\n", + " 'files': {},\n", + " 'is_quarantined': False},\n", + " '1a4479df-6f52-428c-b7b9-c026ab24d2a3': {'uuid': '1a4479df-6f52-428c-b7b9-c026ab24d2a3',\n", + " 'name': 'root',\n", + " 'size': 40.0,\n", + " 'files': {'f45bffd7-4aa1-4f6f-81ba-85e746abd28b': {'uuid': 'f45bffd7-4aa1-4f6f-81ba-85e746abd28b',\n", + " 'name': 'favicon.ico',\n", + " 'size': 40.0,\n", + " 'file_type': 'PNG'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}},\n", + " '384bab1c-aa23-49cf-9c4e-caababcf30a0': {'uuid': '384bab1c-aa23-49cf-9c4e-caababcf30a0',\n", + " 'num_ports': 12,\n", + " 'ports': {1: {'uuid': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", + " 'mac_address': 'ad:3c:77:44:98:27',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 2: {'uuid': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", + " 'mac_address': 'fd:b1:68:f9:8f:eb',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 3: {'uuid': '8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1',\n", + " 'mac_address': 'bb:ba:58:26:52:2d',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 4: {'uuid': '3cde63c0-38e4-4faa-88ba-3a958118e2b3',\n", + " 'mac_address': '69:bc:6f:e1:30:32',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 5: {'uuid': '37e49743-1723-4b0e-a1e5-61d76e230c08',\n", + " 'mac_address': 'd3:a0:8b:92:25:11',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 6: {'uuid': '3bf0c0c4-27f6-4a90-8279-1f713b46f4bf',\n", + " 'mac_address': '48:88:7c:71:0a:c0',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 7: {'uuid': '40b0ba34-9e70-448a-8fdf-836a5a71ed8f',\n", + " 'mac_address': '24:81:03:09:c0:be',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 8: {'uuid': 'cd23d94b-84b8-441c-bd95-4e310682a095',\n", + " 'mac_address': '27:18:c5:47:fd:82',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 9: {'uuid': '608eb5bd-7875-4b64-a6f8-794e6283a305',\n", + " 'mac_address': '03:dd:34:d2:56:1c',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 10: {'uuid': '4acb48c6-74be-40d3-b706-64c06c55720b',\n", + " 'mac_address': 'a3:55:83:af:b7:6b',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 11: {'uuid': '73e989b5-3c2c-4035-8191-47220ea5ca43',\n", + " 'mac_address': '4f:60:84:21:50:6d',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 12: {'uuid': '961ff733-a07c-433b-9433-8418a3761120',\n", + " 'mac_address': '7a:26:02:14:8d:da',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False}},\n", + " 'mac_address_table': {}}},\n", + " 'links': {'67df55f4-c485-4eed-a4dc-fe6f96f6b2f3': {'uuid': '67df55f4-c485-4eed-a4dc-fe6f96f6b2f3',\n", + " 'endpoint_a': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", + " 'endpoint_b': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0},\n", + " '4fdb61da-7cc9-43ea-9ee6-7d9853deff72': {'uuid': '4fdb61da-7cc9-43ea-9ee6-7d9853deff72',\n", + " 'endpoint_a': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", + " 'endpoint_b': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0}}},\n", + " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e',\n", + " 'accounts': {'d7f5bd32-5071-4bec-a111-a9f4e1aca45a': {'uuid': 'd7f5bd32-5071-4bec-a111-a9f4e1aca45a',\n", + " 'num_logons': 0,\n", + " 'num_logoffs': 0,\n", + " 'num_group_changes': 0,\n", + " 'username': 'admin',\n", + " 'password': 'admin12',\n", + " 'account_type': 'USER',\n", + " 'enabled': True}}}}" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "my_sim.describe_state()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "'{\"uuid\": \"95929b6a-1ce4-4c94-966c-6d3246d7caf9\", \"network\": {\"uuid\": \"4b41398e-d768-47c5-80cf-4278cfc35a24\", \"nodes\": {\"1599c08e-a101-41a7-a86a-4176660c4270\": {\"uuid\": \"1599c08e-a101-41a7-a86a-4176660c4270\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"ab09d298-ac44-40ef-b950-b4ca6268d482\": {\"uuid\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"1b:8f:94:4f:46:99\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"92120387-14cb-426c-98f2-64d64a85f560\", \"folders\": {\"6a11bd03-bc59-4da9-8474-639fcb72b9be\": {\"uuid\": \"6a11bd03-bc59-4da9-8474-639fcb72b9be\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"194b2029-4723-4cff-b6d7-e647e4fb687d\": {\"uuid\": \"194b2029-4723-4cff-b6d7-e647e4fb687d\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"ae49273b-f581-44e7-ae8c-18cc766158e8\": {\"uuid\": \"ae49273b-f581-44e7-ae8c-18cc766158e8\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7231c745-e186-47a2-8f69-006033b38b8f\": {\"uuid\": \"7231c745-e186-47a2-8f69-006033b38b8f\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\": {\"uuid\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"50:f4:6b:9b:a8:74\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"85f1d50c-ded7-4160-9a11-1305ab25934b\", \"folders\": {\"86c2666e-31da-46a9-a267-4dc87e2620f9\": {\"uuid\": \"86c2666e-31da-46a9-a267-4dc87e2620f9\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\": {\"uuid\": \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\": {\"uuid\": \"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}, \"384bab1c-aa23-49cf-9c4e-caababcf30a0\": {\"uuid\": \"384bab1c-aa23-49cf-9c4e-caababcf30a0\", \"num_ports\": 12, \"ports\": {\"1\": {\"uuid\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"mac_address\": \"ad:3c:77:44:98:27\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"2\": {\"uuid\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"mac_address\": \"fd:b1:68:f9:8f:eb\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"3\": {\"uuid\": \"8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1\", \"mac_address\": \"bb:ba:58:26:52:2d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"4\": {\"uuid\": \"3cde63c0-38e4-4faa-88ba-3a958118e2b3\", \"mac_address\": \"69:bc:6f:e1:30:32\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"5\": {\"uuid\": \"37e49743-1723-4b0e-a1e5-61d76e230c08\", \"mac_address\": \"d3:a0:8b:92:25:11\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"6\": {\"uuid\": \"3bf0c0c4-27f6-4a90-8279-1f713b46f4bf\", \"mac_address\": \"48:88:7c:71:0a:c0\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"7\": {\"uuid\": \"40b0ba34-9e70-448a-8fdf-836a5a71ed8f\", \"mac_address\": \"24:81:03:09:c0:be\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"8\": {\"uuid\": \"cd23d94b-84b8-441c-bd95-4e310682a095\", \"mac_address\": \"27:18:c5:47:fd:82\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"9\": {\"uuid\": \"608eb5bd-7875-4b64-a6f8-794e6283a305\", \"mac_address\": \"03:dd:34:d2:56:1c\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"10\": {\"uuid\": \"4acb48c6-74be-40d3-b706-64c06c55720b\", \"mac_address\": \"a3:55:83:af:b7:6b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"11\": {\"uuid\": \"73e989b5-3c2c-4035-8191-47220ea5ca43\", \"mac_address\": \"4f:60:84:21:50:6d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"12\": {\"uuid\": \"961ff733-a07c-433b-9433-8418a3761120\", \"mac_address\": \"7a:26:02:14:8d:da\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}}, \"mac_address_table\": {}}}, \"links\": {\"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\": {\"uuid\": \"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\", \"endpoint_a\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"endpoint_b\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\": {\"uuid\": \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\", \"endpoint_a\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"endpoint_b\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"15920e15-6cd1-4a93-b6af-acbcc6f6468e\", \"accounts\": {\"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\": {\"uuid\": \"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "import json\n", "json.dumps(my_sim.describe_state())" From 4077eb3a5cfabbfe9800a2249a0a733e160a5a7c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 10:26:17 +0100 Subject: [PATCH 55/63] Add tests for network node adding/removal --- src/primaite/simulator/network/container.py | 8 +++- .../network/test_network_creation.py | 38 +++++++++++++++++++ .../_simulator/_network/test_container.py | 16 ++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/integration_tests/network/test_network_creation.py create mode 100644 tests/unit_tests/_primaite/_simulator/_network/test_container.py diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index be2a3bbb..5d7e6a47 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -52,7 +52,9 @@ class NetworkContainer(SimComponent): :type node: Node """ if node in self: - _LOGGER.warning(f"Can't add node {node}. It is already in the network.") + msg = f"Can't add node {node}. It is already in the network." + _LOGGER.warning(msg) + raise RuntimeWarning(msg) self.nodes[node.uuid] = node node.parent = self @@ -64,7 +66,9 @@ class NetworkContainer(SimComponent): :type node: Node """ if node not in self: - _LOGGER.warning(f"Can't remove node {node}. It's not in the network.") + msg = f"Can't remove node {node}. It's not in the network." + _LOGGER.warning(msg) + raise RuntimeWarning(msg) del self.nodes[node.uuid] del node.parent # misleading? diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py new file mode 100644 index 00000000..482c188d --- /dev/null +++ b/tests/integration_tests/network/test_network_creation.py @@ -0,0 +1,38 @@ +import pytest + +from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.hardware.base import Node + + +def test_adding_removing_nodes(): + """Check that we can create and add a node to a network.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + net.add_node(n1) + assert n1.parent is net + assert n1 in net + + net.remove_node(n1) + assert n1.parent is None + assert n1 not in net + + +def test_readding_node(): + """Check that warning is raised when readding a node.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + net.add_node(n1) + with pytest.raises(RuntimeWarning): + net.add_node(n1) + assert n1.parent is net + assert n1 in net + + +def test_removing_nonexistent_node(): + """Check that warning is raised when trying to remove a node that is not in the network.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + with pytest.raises(RuntimeWarning): + net.remove_node(n1) + assert n1.parent is None + assert n1 not in net diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py new file mode 100644 index 00000000..2492dc87 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -0,0 +1,16 @@ +import json + +from primaite.simulator.network.container import NetworkContainer + + +def test_creating_container(): + """Check that we can create a network container""" + net = NetworkContainer() + assert net.nodes and net.links + + +def test_describe_state(): + """Check that we can describe network state without raising errors, and that the result is JSON serialisable.""" + net = NetworkContainer() + state = net.describe_state() + json.dumps(state) # if this function call raises an error, the test fails, state was not JSON-serialisable From f38b423886e45dbf5422a1326f5402e453e99034 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 10:27:30 +0100 Subject: [PATCH 56/63] Update comment --- src/primaite/simulator/network/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 5d7e6a47..db782744 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -76,7 +76,7 @@ class NetworkContainer(SimComponent): """TODO.""" # I think we should not be forcing users to add and remove individual links. # Clearly if a link exists between two nodes in the network, then the link is also part of the network. - # I'm just not sure how we ought to handle link creation as it requires an unoccupied network device on the node + # I'm just not sure how we ought to handle link creation as it requires an unoccupied interface on the node. raise NotImplementedError def disconnect_nodes(self, node1: Node, node2: Node) -> None: From a818de8f0133fcd5c1eb37b2b12cb83dcb9b3c73 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 12:40:00 +0100 Subject: [PATCH 57/63] Add ability to connect nodes via the network. --- src/primaite/simulator/network/container.py | 48 ++++++++++++++----- .../simulator/network/hardware/base.py | 2 + .../network/test_network_creation.py | 46 +++++++++++++++++- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index db782744..432356b8 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -1,8 +1,8 @@ -from typing import Any, Dict +from typing import Any, Dict, Union from primaite import getLogger from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent -from primaite.simulator.network.hardware.base import Link, Node +from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort _LOGGER = getLogger(__name__) @@ -72,16 +72,42 @@ class NetworkContainer(SimComponent): del self.nodes[node.uuid] del node.parent # misleading? - def connect_nodes(self, node1: Node, node2: Node) -> None: - """TODO.""" - # I think we should not be forcing users to add and remove individual links. - # Clearly if a link exists between two nodes in the network, then the link is also part of the network. - # I'm just not sure how we ought to handle link creation as it requires an unoccupied interface on the node. - raise NotImplementedError + def connect_nodes(self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs) -> None: + """Connect two nodes on the network by creating a link between an NIC/SwitchPort of each one. - def disconnect_nodes(self, node1: Node, node2: Node) -> None: - """TODO.""" - raise NotImplementedError + :param endpoint_a: The endpoint to which to connect the link on the first node + :type endpoint_a: Union[NIC, SwitchPort] + :param endpoint_b: The endpoint to which to connct the link on the second node + :type endpoint_b: Union[NIC, SwitchPort] + :raises RuntimeError: _description_ + """ + node_a = endpoint_a.parent + node_b = endpoint_b.parent + msg = "" + if node_a not in self: + msg = f"Cannot create a link to {endpoint_a} because the node is not in the network." + if node_b not in self: + msg = f"Cannot create a link to {endpoint_b} because the node is not in the network." + if node_a is node_b: + msg = f"Cannot link {endpoint_a} to {endpoint_b} because they belong to the same node." + if msg: + _LOGGER.error(msg) + raise RuntimeError(msg) + + link = Link(endpoint_a=endpoint_a, endpoint_b=endpoint_b, **kwargs) + self.links[link.uuid] = link + link.parent = self + + def remove_link(self, link: Link) -> None: + """Disconnect a link from the network. + + :param link: The link to be removed + :type link: Link + """ + link.endpoint_a.disconnect_link() + link.endpoint_b.disconnect_link() + del self.links[link.uuid] + del link.parent def __contains__(self, item: Any) -> bool: if isinstance(item, Node): diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 28e7693a..5b49f008 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -918,6 +918,7 @@ class Node(SimComponent): if nic.uuid not in self.nics: self.nics[nic.uuid] = nic nic.connected_node = self + nic.parent = self self.sys_log.info(f"Connected NIC {nic}") if self.operating_state == NodeOperatingState.ON: nic.enable() @@ -938,6 +939,7 @@ class Node(SimComponent): nic = self.nics.get(nic) if nic or nic.uuid in self.nics: self.nics.pop(nic.uuid) + del nic.parent nic.disable() self.sys_log.info(f"Disconnected NIC {nic}") else: diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 482c188d..0ee827be 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -1,7 +1,7 @@ import pytest from primaite.simulator.network.container import NetworkContainer -from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.hardware.base import NIC, Node def test_adding_removing_nodes(): @@ -36,3 +36,47 @@ def test_removing_nonexistent_node(): net.remove_node(n1) assert n1.parent is None assert n1 not in net + + +def test_connecting_nodes(): + """Check that two nodes on the network can be connected.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + n1_nic = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n1.connect_nic(n1_nic) + n2 = Node(hostname="server") + n2_nic = NIC(ip_address="120.30.0.2", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n2.connect_nic(n2_nic) + + net.add_node(n1) + net.add_node(n2) + + net.connect_nodes(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) + + assert len(net.links) == 1 + link = list(net.links.values())[0] + assert link in net + assert link.parent is net + + +def test_connecting_node_to_itself(): + net = NetworkContainer() + node = Node(hostname="computer") + nic1 = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") + node.connect_nic(nic1) + nic2 = NIC(ip_address="120.30.0.2", gateway="192.168.0.1", subnet_mask="255.255.255.0") + node.connect_nic(nic2) + + net.add_node(node) + + with pytest.raises(RuntimeError): + net.connect_nodes(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) + + assert node in net + assert nic1.connected_link is None + assert nic2.connected_link is None + assert len(net.links) == 0 + + +def test_disconnecting_nodes(): + ... From 7058c7e9a89e1462d924c0b578f92fb789a051b5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 12:41:46 +0100 Subject: [PATCH 58/63] Rename networkcontainer to network --- src/primaite/simulator/network/container.py | 2 +- src/primaite/simulator/sim_container.py | 6 +++--- .../network/test_network_creation.py | 12 ++++++------ .../_primaite/_simulator/_network/test_container.py | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 432356b8..0612069c 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -7,7 +7,7 @@ from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort _LOGGER = getLogger(__name__) -class NetworkContainer(SimComponent): +class Network(SimComponent): """Top level container object representing the physical network.""" nodes: Dict[str, Node] = {} diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 50fe412c..319defe4 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -2,19 +2,19 @@ from typing import Dict from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent from primaite.simulator.domain.controller import DomainController -from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.container import Network class Simulation(SimComponent): """Top-level simulation object which holds a reference to all other parts of the simulation.""" - network: NetworkContainer + network: Network domain: DomainController def __init__(self, **kwargs): """Initialise the Simulation.""" if not kwargs.get("network"): - kwargs["network"] = NetworkContainer() + kwargs["network"] = Network() if not kwargs.get("domain"): kwargs["domain"] = DomainController() diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 0ee827be..70b48806 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -1,12 +1,12 @@ import pytest -from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.base import NIC, Node def test_adding_removing_nodes(): """Check that we can create and add a node to a network.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") net.add_node(n1) assert n1.parent is net @@ -19,7 +19,7 @@ def test_adding_removing_nodes(): def test_readding_node(): """Check that warning is raised when readding a node.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") net.add_node(n1) with pytest.raises(RuntimeWarning): @@ -30,7 +30,7 @@ def test_readding_node(): def test_removing_nonexistent_node(): """Check that warning is raised when trying to remove a node that is not in the network.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") with pytest.raises(RuntimeWarning): net.remove_node(n1) @@ -40,7 +40,7 @@ def test_removing_nonexistent_node(): def test_connecting_nodes(): """Check that two nodes on the network can be connected.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") n1_nic = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") n1.connect_nic(n1_nic) @@ -60,7 +60,7 @@ def test_connecting_nodes(): def test_connecting_node_to_itself(): - net = NetworkContainer() + net = Network() node = Node(hostname="computer") nic1 = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") node.connect_nic(nic1) diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py index 2492dc87..5fc308cc 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/test_container.py +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -1,16 +1,16 @@ import json -from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.container import Network def test_creating_container(): """Check that we can create a network container""" - net = NetworkContainer() + net = Network() assert net.nodes and net.links def test_describe_state(): """Check that we can describe network state without raising errors, and that the result is JSON serialisable.""" - net = NetworkContainer() + net = Network() state = net.describe_state() json.dumps(state) # if this function call raises an error, the test fails, state was not JSON-serialisable From 78008e3c6e80199bd5116455e8afb1f2ccecf15b Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 12:52:38 +0100 Subject: [PATCH 59/63] Fix container test --- .../unit_tests/_primaite/_simulator/_network/test_container.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py index 5fc308cc..290e7cc3 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/test_container.py +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -6,7 +6,8 @@ from primaite.simulator.network.container import Network def test_creating_container(): """Check that we can create a network container""" net = Network() - assert net.nodes and net.links + assert net.nodes == {} + assert net.links == {} def test_describe_state(): From fec44aef53e25b2ac1a83e851f92a5c212a5daef Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 13:03:16 +0100 Subject: [PATCH 60/63] Rename connect_nodes to connect and fix minor bug --- src/primaite/simulator/network/container.py | 2 +- src/primaite/simulator/network/hardware/base.py | 1 + tests/integration_tests/network/test_network_creation.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 0612069c..1c03358c 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -72,7 +72,7 @@ class Network(SimComponent): del self.nodes[node.uuid] del node.parent # misleading? - def connect_nodes(self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs) -> None: + def connect(self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs) -> None: """Connect two nodes on the network by creating a link between an NIC/SwitchPort of each one. :param endpoint_a: The endpoint to which to connect the link on the first node diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 5b49f008..fe3b5b15 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1011,6 +1011,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.parent = self port.port_num = port_num def show(self): diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 70b48806..418f5e5f 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -51,7 +51,7 @@ def test_connecting_nodes(): net.add_node(n1) net.add_node(n2) - net.connect_nodes(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) + net.connect(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) assert len(net.links) == 1 link = list(net.links.values())[0] @@ -70,7 +70,7 @@ def test_connecting_node_to_itself(): net.add_node(node) with pytest.raises(RuntimeError): - net.connect_nodes(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) + net.connect(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) assert node in net assert nic1.connected_link is None From 05bb0f295b25973a0776d0ab4b7e65c6767ce93f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 13:06:45 +0100 Subject: [PATCH 61/63] Update notebook tutorial on creating a simulation --- .../notebooks/create-simulation.ipynb | 123 +++++++++--------- 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index e3e7dfb7..baf7bd2c 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -42,11 +42,11 @@ { "data": { "text/plain": [ - "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", - " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", + "{'uuid': '2ef348c6-32e5-4c5c-83b7-3b82d0b6123b',\n", + " 'network': {'uuid': 'dd2d1a02-d461-4505-8bbd-fd0681750175',\n", " 'nodes': {},\n", " 'links': {}},\n", - " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e', 'accounts': {}}}" + " 'domain': {'uuid': 'ae0423ee-51fa-41e7-be80-c642b39707f6', 'accounts': {}}}" ] }, "execution_count": 2, @@ -113,10 +113,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "2023-08-23 15:44:02,059: NIC 1b:8f:94:4f:46:99/130.1.1.1 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", - "2023-08-23 15:44:02,062: SwitchPort ad:3c:77:44:98:27 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", - "2023-08-23 15:44:02,064: NIC 50:f4:6b:9b:a8:74/130.1.1.2 connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n", - "2023-08-23 15:44:02,065: SwitchPort fd:b1:68:f9:8f:eb connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n" + "2023-08-24 13:06:28,617: NIC cc:be:ec:43:a6:4c/130.1.1.1 connected to Link cc:be:ec:43:a6:4c/130.1.1.1<-->79:2b:4a:70:c3:50\n", + "2023-08-24 13:06:28,618: SwitchPort 79:2b:4a:70:c3:50 connected to Link cc:be:ec:43:a6:4c/130.1.1.1<-->79:2b:4a:70:c3:50\n", + "2023-08-24 13:06:28,619: NIC c2:1e:48:e1:a4:ad/130.1.1.2 connected to Link c2:1e:48:e1:a4:ad/130.1.1.2<-->1a:2d:12:38:80:2f\n", + "2023-08-24 13:06:28,620: SwitchPort 1a:2d:12:38:80:2f connected to Link c2:1e:48:e1:a4:ad/130.1.1.2<-->1a:2d:12:38:80:2f\n" ] } ], @@ -132,11 +132,8 @@ "my_server.connect_nic(server_nic)\n", "\n", "\n", - "pc_to_switch = Link(endpoint_a=pc_nic, endpoint_b=my_swtich.switch_ports[1])\n", - "server_to_swtich = Link(endpoint_a=server_nic, endpoint_b=my_swtich.switch_ports[2])\n", - "\n", - "my_sim.network.links[pc_to_switch.uuid] = pc_to_switch\n", - "my_sim.network.links[server_to_swtich.uuid] = server_to_swtich" + "net.connect(pc_nic, my_swtich.switch_ports[1])\n", + "net.connect(server_nic, my_swtich.switch_ports[2])\n" ] }, { @@ -174,7 +171,7 @@ { "data": { "text/plain": [ - "FileSystemFile(uuid='f45bffd7-4aa1-4f6f-81ba-85e746abd28b', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + "FileSystemFile(uuid='7d56a563-ecc0-4011-8c97-240dd6c885c0', name='favicon.ico', size=40.0, file_type=, action_manager=None)" ] }, "execution_count": 9, @@ -269,31 +266,31 @@ { "data": { "text/plain": [ - "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", - " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", - " 'nodes': {'1599c08e-a101-41a7-a86a-4176660c4270': {'uuid': '1599c08e-a101-41a7-a86a-4176660c4270',\n", + "{'uuid': '2ef348c6-32e5-4c5c-83b7-3b82d0b6123b',\n", + " 'network': {'uuid': 'dd2d1a02-d461-4505-8bbd-fd0681750175',\n", + " 'nodes': {'2f03b32b-7290-4921-8670-faebe4a19d63': {'uuid': '2f03b32b-7290-4921-8670-faebe4a19d63',\n", " 'hostname': 'primaite_pc',\n", " 'operating_state': 0,\n", - " 'NICs': {'ab09d298-ac44-40ef-b950-b4ca6268d482': {'uuid': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", + " 'NICs': {'e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b': {'uuid': 'e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b',\n", " 'ip_adress': '130.1.1.1',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': '1b:8f:94:4f:46:99',\n", + " 'mac_address': 'cc:be:ec:43:a6:4c',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': '92120387-14cb-426c-98f2-64d64a85f560',\n", - " 'folders': {'6a11bd03-bc59-4da9-8474-639fcb72b9be': {'uuid': '6a11bd03-bc59-4da9-8474-639fcb72b9be',\n", + " 'file_system': {'uuid': '0b7206af-3e0a-41b0-8115-ae9e0dbbcd81',\n", + " 'folders': {'c161bc7c-9abd-4666-9b49-2745fdb65ebe': {'uuid': 'c161bc7c-9abd-4666-9b49-2745fdb65ebe',\n", " 'name': 'downloads',\n", " 'size': 1000.0,\n", - " 'files': {'194b2029-4723-4cff-b6d7-e647e4fb687d': {'uuid': '194b2029-4723-4cff-b6d7-e647e4fb687d',\n", + " 'files': {'f807d777-d167-4f37-9f9b-ced634af6ed5': {'uuid': 'f807d777-d167-4f37-9f9b-ced634af6ed5',\n", " 'name': 'firefox_installer.zip',\n", " 'size': 1000.0,\n", " 'file_type': 'ZIP'}},\n", " 'is_quarantined': False}}},\n", - " 'applications': {'ae49273b-f581-44e7-ae8c-18cc766158e8': {'uuid': 'ae49273b-f581-44e7-ae8c-18cc766158e8',\n", + " 'applications': {'ea466b2f-1ed5-49fd-9579-44852bff684d': {'uuid': 'ea466b2f-1ed5-49fd-9579-44852bff684d',\n", " 'health_state': 'GOOD',\n", " 'health_state_red_view': 'GOOD',\n", " 'criticality': 'MEDIUM',\n", @@ -311,29 +308,29 @@ " 'groups': []}},\n", " 'services': {},\n", " 'process': {}},\n", - " '7231c745-e186-47a2-8f69-006033b38b8f': {'uuid': '7231c745-e186-47a2-8f69-006033b38b8f',\n", + " 'e9afc0bc-fb21-48a3-9868-2ede6a3181dc': {'uuid': 'e9afc0bc-fb21-48a3-9868-2ede6a3181dc',\n", " 'hostname': 'google_server',\n", " 'operating_state': 0,\n", - " 'NICs': {'d138788b-2a8e-4c5c-aa5d-b5c28758a78a': {'uuid': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", + " 'NICs': {'956ce240-8fb3-4fde-8635-ac4ea601a582': {'uuid': '956ce240-8fb3-4fde-8635-ac4ea601a582',\n", " 'ip_adress': '130.1.1.2',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': '50:f4:6b:9b:a8:74',\n", + " 'mac_address': 'c2:1e:48:e1:a4:ad',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': '85f1d50c-ded7-4160-9a11-1305ab25934b',\n", - " 'folders': {'86c2666e-31da-46a9-a267-4dc87e2620f9': {'uuid': '86c2666e-31da-46a9-a267-4dc87e2620f9',\n", + " 'file_system': {'uuid': 'c3f99c30-b493-4fb6-b13e-d2005d851b59',\n", + " 'folders': {'869eda49-21f2-4fc1-8681-78725cdd5c70': {'uuid': '869eda49-21f2-4fc1-8681-78725cdd5c70',\n", " 'name': 'static',\n", " 'size': 0,\n", " 'files': {},\n", " 'is_quarantined': False},\n", - " '1a4479df-6f52-428c-b7b9-c026ab24d2a3': {'uuid': '1a4479df-6f52-428c-b7b9-c026ab24d2a3',\n", + " '9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e': {'uuid': '9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e',\n", " 'name': 'root',\n", " 'size': 40.0,\n", - " 'files': {'f45bffd7-4aa1-4f6f-81ba-85e746abd28b': {'uuid': 'f45bffd7-4aa1-4f6f-81ba-85e746abd28b',\n", + " 'files': {'7d56a563-ecc0-4011-8c97-240dd6c885c0': {'uuid': '7d56a563-ecc0-4011-8c97-240dd6c885c0',\n", " 'name': 'favicon.ico',\n", " 'size': 40.0,\n", " 'file_type': 'PNG'}},\n", @@ -341,81 +338,81 @@ " 'applications': {},\n", " 'services': {},\n", " 'process': {}},\n", - " '384bab1c-aa23-49cf-9c4e-caababcf30a0': {'uuid': '384bab1c-aa23-49cf-9c4e-caababcf30a0',\n", + " '47814452-ef47-4e6b-9087-796c438d4698': {'uuid': '47814452-ef47-4e6b-9087-796c438d4698',\n", " 'num_ports': 12,\n", - " 'ports': {1: {'uuid': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", - " 'mac_address': 'ad:3c:77:44:98:27',\n", + " 'ports': {1: {'uuid': 'b76fe86f-bb92-4346-8e83-217a2fb0bc67',\n", + " 'mac_address': '79:2b:4a:70:c3:50',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 2: {'uuid': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", - " 'mac_address': 'fd:b1:68:f9:8f:eb',\n", + " 2: {'uuid': '6f8fc6e7-76a4-441a-b7af-441edbdcc6ac',\n", + " 'mac_address': '1a:2d:12:38:80:2f',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 3: {'uuid': '8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1',\n", - " 'mac_address': 'bb:ba:58:26:52:2d',\n", + " 3: {'uuid': '1aa75a3c-01f1-4293-9894-5396fa412690',\n", + " 'mac_address': 'd1:7b:36:c1:82:c1',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 4: {'uuid': '3cde63c0-38e4-4faa-88ba-3a958118e2b3',\n", - " 'mac_address': '69:bc:6f:e1:30:32',\n", + " 4: {'uuid': 'fe6c9f44-59d5-403e-973a-6f19fce7b9b9',\n", + " 'mac_address': 'e3:6b:cc:0c:98:9b',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 5: {'uuid': '37e49743-1723-4b0e-a1e5-61d76e230c08',\n", - " 'mac_address': 'd3:a0:8b:92:25:11',\n", + " 5: {'uuid': 'e9e83e37-8537-4884-98a6-87017540078f',\n", + " 'mac_address': '32:09:c0:4a:f1:20',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 6: {'uuid': '3bf0c0c4-27f6-4a90-8279-1f713b46f4bf',\n", - " 'mac_address': '48:88:7c:71:0a:c0',\n", + " 6: {'uuid': '747f2cd3-8902-4da8-8829-b0b53fe79735',\n", + " 'mac_address': 'e8:20:0b:04:b8:76',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 7: {'uuid': '40b0ba34-9e70-448a-8fdf-836a5a71ed8f',\n", - " 'mac_address': '24:81:03:09:c0:be',\n", + " 7: {'uuid': '88ed129e-0ddb-4d29-ba3c-58d81efe240e',\n", + " 'mac_address': '7f:b4:f4:2e:b6:71',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 8: {'uuid': 'cd23d94b-84b8-441c-bd95-4e310682a095',\n", - " 'mac_address': '27:18:c5:47:fd:82',\n", + " 8: {'uuid': '6c1a4c3c-25d8-46f6-98a8-54073d0ca0d3',\n", + " 'mac_address': 'f6:22:2d:24:b9:71',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 9: {'uuid': '608eb5bd-7875-4b64-a6f8-794e6283a305',\n", - " 'mac_address': '03:dd:34:d2:56:1c',\n", + " 9: {'uuid': 'b2bfc006-6a6b-4701-a75a-27954592d429',\n", + " 'mac_address': 'b6:a5:92:a5:aa:1b',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 10: {'uuid': '4acb48c6-74be-40d3-b706-64c06c55720b',\n", - " 'mac_address': 'a3:55:83:af:b7:6b',\n", + " 10: {'uuid': '3c607386-87a2-4d0b-ac04-449416ca5b1f',\n", + " 'mac_address': 'b3:75:7d:ce:88:0a',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 11: {'uuid': '73e989b5-3c2c-4035-8191-47220ea5ca43',\n", - " 'mac_address': '4f:60:84:21:50:6d',\n", + " 11: {'uuid': '590002c8-27fa-4c31-b17b-7b89dbf8cdf8',\n", + " 'mac_address': 'c0:25:a6:64:52:8e',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 12: {'uuid': '961ff733-a07c-433b-9433-8418a3761120',\n", - " 'mac_address': '7a:26:02:14:8d:da',\n", + " 12: {'uuid': 'b7e25eed-547a-4c17-8cb9-8b976ce4bbd9',\n", + " 'mac_address': '98:50:96:47:ca:bc',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False}},\n", " 'mac_address_table': {}}},\n", - " 'links': {'67df55f4-c485-4eed-a4dc-fe6f96f6b2f3': {'uuid': '67df55f4-c485-4eed-a4dc-fe6f96f6b2f3',\n", - " 'endpoint_a': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", - " 'endpoint_b': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", + " 'links': {'a51a4435-20ae-43cf-a151-26e824968b3d': {'uuid': 'a51a4435-20ae-43cf-a151-26e824968b3d',\n", + " 'endpoint_a': 'e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b',\n", + " 'endpoint_b': 'b76fe86f-bb92-4346-8e83-217a2fb0bc67',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0},\n", - " '4fdb61da-7cc9-43ea-9ee6-7d9853deff72': {'uuid': '4fdb61da-7cc9-43ea-9ee6-7d9853deff72',\n", - " 'endpoint_a': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", - " 'endpoint_b': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", + " 'ae3486e5-f78e-4092-96d1-d7e8176f2b7d': {'uuid': 'ae3486e5-f78e-4092-96d1-d7e8176f2b7d',\n", + " 'endpoint_a': '956ce240-8fb3-4fde-8635-ac4ea601a582',\n", + " 'endpoint_b': '6f8fc6e7-76a4-441a-b7af-441edbdcc6ac',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e',\n", - " 'accounts': {'d7f5bd32-5071-4bec-a111-a9f4e1aca45a': {'uuid': 'd7f5bd32-5071-4bec-a111-a9f4e1aca45a',\n", + " 'domain': {'uuid': 'ae0423ee-51fa-41e7-be80-c642b39707f6',\n", + " 'accounts': {'917eda28-9a67-4449-bddd-87e2141a3162': {'uuid': '917eda28-9a67-4449-bddd-87e2141a3162',\n", " 'num_logons': 0,\n", " 'num_logoffs': 0,\n", " 'num_group_changes': 0,\n", @@ -442,7 +439,7 @@ { "data": { "text/plain": [ - "'{\"uuid\": \"95929b6a-1ce4-4c94-966c-6d3246d7caf9\", \"network\": {\"uuid\": \"4b41398e-d768-47c5-80cf-4278cfc35a24\", \"nodes\": {\"1599c08e-a101-41a7-a86a-4176660c4270\": {\"uuid\": \"1599c08e-a101-41a7-a86a-4176660c4270\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"ab09d298-ac44-40ef-b950-b4ca6268d482\": {\"uuid\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"1b:8f:94:4f:46:99\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"92120387-14cb-426c-98f2-64d64a85f560\", \"folders\": {\"6a11bd03-bc59-4da9-8474-639fcb72b9be\": {\"uuid\": \"6a11bd03-bc59-4da9-8474-639fcb72b9be\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"194b2029-4723-4cff-b6d7-e647e4fb687d\": {\"uuid\": \"194b2029-4723-4cff-b6d7-e647e4fb687d\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"ae49273b-f581-44e7-ae8c-18cc766158e8\": {\"uuid\": \"ae49273b-f581-44e7-ae8c-18cc766158e8\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7231c745-e186-47a2-8f69-006033b38b8f\": {\"uuid\": \"7231c745-e186-47a2-8f69-006033b38b8f\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\": {\"uuid\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"50:f4:6b:9b:a8:74\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"85f1d50c-ded7-4160-9a11-1305ab25934b\", \"folders\": {\"86c2666e-31da-46a9-a267-4dc87e2620f9\": {\"uuid\": \"86c2666e-31da-46a9-a267-4dc87e2620f9\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\": {\"uuid\": \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\": {\"uuid\": \"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}, \"384bab1c-aa23-49cf-9c4e-caababcf30a0\": {\"uuid\": \"384bab1c-aa23-49cf-9c4e-caababcf30a0\", \"num_ports\": 12, \"ports\": {\"1\": {\"uuid\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"mac_address\": \"ad:3c:77:44:98:27\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"2\": {\"uuid\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"mac_address\": \"fd:b1:68:f9:8f:eb\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"3\": {\"uuid\": \"8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1\", \"mac_address\": \"bb:ba:58:26:52:2d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"4\": {\"uuid\": \"3cde63c0-38e4-4faa-88ba-3a958118e2b3\", \"mac_address\": \"69:bc:6f:e1:30:32\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"5\": {\"uuid\": \"37e49743-1723-4b0e-a1e5-61d76e230c08\", \"mac_address\": \"d3:a0:8b:92:25:11\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"6\": {\"uuid\": \"3bf0c0c4-27f6-4a90-8279-1f713b46f4bf\", \"mac_address\": \"48:88:7c:71:0a:c0\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"7\": {\"uuid\": \"40b0ba34-9e70-448a-8fdf-836a5a71ed8f\", \"mac_address\": \"24:81:03:09:c0:be\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"8\": {\"uuid\": \"cd23d94b-84b8-441c-bd95-4e310682a095\", \"mac_address\": \"27:18:c5:47:fd:82\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"9\": {\"uuid\": \"608eb5bd-7875-4b64-a6f8-794e6283a305\", \"mac_address\": \"03:dd:34:d2:56:1c\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"10\": {\"uuid\": \"4acb48c6-74be-40d3-b706-64c06c55720b\", \"mac_address\": \"a3:55:83:af:b7:6b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"11\": {\"uuid\": \"73e989b5-3c2c-4035-8191-47220ea5ca43\", \"mac_address\": \"4f:60:84:21:50:6d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"12\": {\"uuid\": \"961ff733-a07c-433b-9433-8418a3761120\", \"mac_address\": \"7a:26:02:14:8d:da\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}}, \"mac_address_table\": {}}}, \"links\": {\"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\": {\"uuid\": \"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\", \"endpoint_a\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"endpoint_b\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\": {\"uuid\": \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\", \"endpoint_a\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"endpoint_b\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"15920e15-6cd1-4a93-b6af-acbcc6f6468e\", \"accounts\": {\"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\": {\"uuid\": \"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + "'{\"uuid\": \"2ef348c6-32e5-4c5c-83b7-3b82d0b6123b\", \"network\": {\"uuid\": \"dd2d1a02-d461-4505-8bbd-fd0681750175\", \"nodes\": {\"2f03b32b-7290-4921-8670-faebe4a19d63\": {\"uuid\": \"2f03b32b-7290-4921-8670-faebe4a19d63\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b\": {\"uuid\": \"e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"cc:be:ec:43:a6:4c\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"0b7206af-3e0a-41b0-8115-ae9e0dbbcd81\", \"folders\": {\"c161bc7c-9abd-4666-9b49-2745fdb65ebe\": {\"uuid\": \"c161bc7c-9abd-4666-9b49-2745fdb65ebe\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"f807d777-d167-4f37-9f9b-ced634af6ed5\": {\"uuid\": \"f807d777-d167-4f37-9f9b-ced634af6ed5\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"ea466b2f-1ed5-49fd-9579-44852bff684d\": {\"uuid\": \"ea466b2f-1ed5-49fd-9579-44852bff684d\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"e9afc0bc-fb21-48a3-9868-2ede6a3181dc\": {\"uuid\": \"e9afc0bc-fb21-48a3-9868-2ede6a3181dc\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"956ce240-8fb3-4fde-8635-ac4ea601a582\": {\"uuid\": \"956ce240-8fb3-4fde-8635-ac4ea601a582\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"c2:1e:48:e1:a4:ad\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"c3f99c30-b493-4fb6-b13e-d2005d851b59\", \"folders\": {\"869eda49-21f2-4fc1-8681-78725cdd5c70\": {\"uuid\": \"869eda49-21f2-4fc1-8681-78725cdd5c70\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e\": {\"uuid\": \"9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"7d56a563-ecc0-4011-8c97-240dd6c885c0\": {\"uuid\": \"7d56a563-ecc0-4011-8c97-240dd6c885c0\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}, \"47814452-ef47-4e6b-9087-796c438d4698\": {\"uuid\": \"47814452-ef47-4e6b-9087-796c438d4698\", \"num_ports\": 12, \"ports\": {\"1\": {\"uuid\": \"b76fe86f-bb92-4346-8e83-217a2fb0bc67\", \"mac_address\": \"79:2b:4a:70:c3:50\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"2\": {\"uuid\": \"6f8fc6e7-76a4-441a-b7af-441edbdcc6ac\", \"mac_address\": \"1a:2d:12:38:80:2f\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"3\": {\"uuid\": \"1aa75a3c-01f1-4293-9894-5396fa412690\", \"mac_address\": \"d1:7b:36:c1:82:c1\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"4\": {\"uuid\": \"fe6c9f44-59d5-403e-973a-6f19fce7b9b9\", \"mac_address\": \"e3:6b:cc:0c:98:9b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"5\": {\"uuid\": \"e9e83e37-8537-4884-98a6-87017540078f\", \"mac_address\": \"32:09:c0:4a:f1:20\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"6\": {\"uuid\": \"747f2cd3-8902-4da8-8829-b0b53fe79735\", \"mac_address\": \"e8:20:0b:04:b8:76\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"7\": {\"uuid\": \"88ed129e-0ddb-4d29-ba3c-58d81efe240e\", \"mac_address\": \"7f:b4:f4:2e:b6:71\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"8\": {\"uuid\": \"6c1a4c3c-25d8-46f6-98a8-54073d0ca0d3\", \"mac_address\": \"f6:22:2d:24:b9:71\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"9\": {\"uuid\": \"b2bfc006-6a6b-4701-a75a-27954592d429\", \"mac_address\": \"b6:a5:92:a5:aa:1b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"10\": {\"uuid\": \"3c607386-87a2-4d0b-ac04-449416ca5b1f\", \"mac_address\": \"b3:75:7d:ce:88:0a\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"11\": {\"uuid\": \"590002c8-27fa-4c31-b17b-7b89dbf8cdf8\", \"mac_address\": \"c0:25:a6:64:52:8e\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"12\": {\"uuid\": \"b7e25eed-547a-4c17-8cb9-8b976ce4bbd9\", \"mac_address\": \"98:50:96:47:ca:bc\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}}, \"mac_address_table\": {}}}, \"links\": {\"a51a4435-20ae-43cf-a151-26e824968b3d\": {\"uuid\": \"a51a4435-20ae-43cf-a151-26e824968b3d\", \"endpoint_a\": \"e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b\", \"endpoint_b\": \"b76fe86f-bb92-4346-8e83-217a2fb0bc67\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"ae3486e5-f78e-4092-96d1-d7e8176f2b7d\": {\"uuid\": \"ae3486e5-f78e-4092-96d1-d7e8176f2b7d\", \"endpoint_a\": \"956ce240-8fb3-4fde-8635-ac4ea601a582\", \"endpoint_b\": \"6f8fc6e7-76a4-441a-b7af-441edbdcc6ac\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"ae0423ee-51fa-41e7-be80-c642b39707f6\", \"accounts\": {\"917eda28-9a67-4449-bddd-87e2141a3162\": {\"uuid\": \"917eda28-9a67-4449-bddd-87e2141a3162\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" ] }, "execution_count": 16, From ae6e835955136c815eb496efa2bcb2f4329b0af6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 25 Aug 2023 15:58:07 +0100 Subject: [PATCH 62/63] Apply suggestions from code review. --- src/primaite/simulator/network/container.py | 35 +++++++++---------- .../simulator/network/hardware/base.py | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 1c03358c..85676034 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -52,11 +52,11 @@ class Network(SimComponent): :type node: Node """ if node in self: - msg = f"Can't add node {node}. It is already in the network." - _LOGGER.warning(msg) - raise RuntimeWarning(msg) + _LOGGER.warning(f"Can't add node {node.uuid}. It is already in the network.") + return self.nodes[node.uuid] = node node.parent = self + _LOGGER.info(f"Added node {node.uuid} to Network {self.uuid}") def remove_node(self, node: Node) -> None: """ @@ -66,11 +66,11 @@ class Network(SimComponent): :type node: Node """ if node not in self: - msg = f"Can't remove node {node}. It's not in the network." - _LOGGER.warning(msg) - raise RuntimeWarning(msg) - del self.nodes[node.uuid] - del node.parent # misleading? + _LOGGER.warning(f"Can't remove node {node.uuid}. It's not in the network.") + return + self.nodes.pop(node.uuid) + node.parent = None + _LOGGER.info(f"Removed node {node.uuid} from network {self.uuid}") def connect(self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs) -> None: """Connect two nodes on the network by creating a link between an NIC/SwitchPort of each one. @@ -83,20 +83,18 @@ class Network(SimComponent): """ node_a = endpoint_a.parent node_b = endpoint_b.parent - msg = "" if node_a not in self: - msg = f"Cannot create a link to {endpoint_a} because the node is not in the network." + self.add_node(node_a) if node_b not in self: - msg = f"Cannot create a link to {endpoint_b} because the node is not in the network." + self.add_node(node_b) if node_a is node_b: - msg = f"Cannot link {endpoint_a} to {endpoint_b} because they belong to the same node." - if msg: - _LOGGER.error(msg) - raise RuntimeError(msg) + _LOGGER.warn(f"Cannot link endpoint {endpoint_a} to {endpoint_b} because they belong to the same node.") + return link = Link(endpoint_a=endpoint_a, endpoint_b=endpoint_b, **kwargs) self.links[link.uuid] = link link.parent = self + _LOGGER.info(f"Added link {link.uuid} to connect {endpoint_a} and {endpoint_b}") def remove_link(self, link: Link) -> None: """Disconnect a link from the network. @@ -106,12 +104,13 @@ class Network(SimComponent): """ link.endpoint_a.disconnect_link() link.endpoint_b.disconnect_link() - del self.links[link.uuid] - del link.parent + self.links.pop(link.uuid) + link.parent = None + _LOGGER.info(f"Removed link {link.uuid} from network {self.uuid}.") def __contains__(self, item: Any) -> bool: if isinstance(item, Node): return item.uuid in self.nodes elif isinstance(item, Link): return item.uuid in self.links - raise TypeError("") + return False diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index fe3b5b15..9acdf0b4 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -939,7 +939,7 @@ class Node(SimComponent): nic = self.nics.get(nic) if nic or nic.uuid in self.nics: self.nics.pop(nic.uuid) - del nic.parent + nic.parent = None nic.disable() self.sys_log.info(f"Disconnected NIC {nic}") else: From 6e602aa1514b92dc34ec7324d96bdd58fc72efdb Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 25 Aug 2023 17:56:05 +0100 Subject: [PATCH 63/63] Fix unit tests by removing warning checks --- src/primaite/simulator/core.py | 8 ++--- .../network/test_network_creation.py | 29 ++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 63120ecf..b7dfcf72 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -1,6 +1,6 @@ """Core of the PrimAITE Simulator.""" from abc import ABC, abstractmethod -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, Union from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Extra @@ -199,9 +199,9 @@ class SimComponent(BaseModel): return self._parent @parent.setter - def parent(self, new_parent: "SimComponent") -> None: - if self._parent: - msg = f"Overwriting parent of {self}, {self._parent} with {new_parent}" + 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) raise RuntimeWarning(msg) self._parent = new_parent diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 418f5e5f..356eb1db 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -22,8 +22,7 @@ def test_readding_node(): net = Network() n1 = Node(hostname="computer") net.add_node(n1) - with pytest.raises(RuntimeWarning): - net.add_node(n1) + net.add_node(n1) assert n1.parent is net assert n1 in net @@ -32,8 +31,7 @@ def test_removing_nonexistent_node(): """Check that warning is raised when trying to remove a node that is not in the network.""" net = Network() n1 = Node(hostname="computer") - with pytest.raises(RuntimeWarning): - net.remove_node(n1) + net.remove_node(n1) assert n1.parent is None assert n1 not in net @@ -69,8 +67,7 @@ def test_connecting_node_to_itself(): net.add_node(node) - with pytest.raises(RuntimeError): - net.connect(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) + net.connect(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) assert node in net assert nic1.connected_link is None @@ -79,4 +76,22 @@ def test_connecting_node_to_itself(): def test_disconnecting_nodes(): - ... + net = Network() + + n1 = Node(hostname="computer") + n1_nic = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n1.connect_nic(n1_nic) + net.add_node(n1) + + n2 = Node(hostname="server") + n2_nic = NIC(ip_address="120.30.0.2", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n2.connect_nic(n2_nic) + net.add_node(n2) + + net.connect(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) + assert len(net.links) == 1 + + link = list(net.links.values())[0] + net.remove_link(link) + assert link not in net + assert len(net.links) == 0