From 897dbdf10c83e2bebd33952c38bbe267703a4f4f Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 2 Aug 2023 21:54:21 +0100 Subject: [PATCH 01/11] #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 02/11] #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 cac4779244cb7ae1f42cb76bd1c276bb0f1adf7f Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 3 Aug 2023 14:37:55 +0100 Subject: [PATCH 03/11] #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 04f1cb0dc6720c4ddf1dec60faaef8ca5595f154 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 3 Aug 2023 21:30:13 +0100 Subject: [PATCH 04/11] #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 139d7397320528a7a7474a4f171c6173b538024e Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 7 Aug 2023 19:33:52 +0100 Subject: [PATCH 05/11] #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 9fbc3c91f771fd8dbf96d3ad16633655590a6587 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 8 Aug 2023 20:22:18 +0100 Subject: [PATCH 06/11] #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 07/11] #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 b46057841d49db2fc68fbf5d55efff775d8fdd70 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 9 Aug 2023 20:31:42 +0100 Subject: [PATCH 08/11] #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 09/11] #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 9ee0ef2fd65a5414b0619c93d898d2ece321bbc0 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 10 Aug 2023 13:26:51 +0100 Subject: [PATCH 10/11] #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 11/11] #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.