From ade5f133d0491398e090cebe8213778183fb3e5b Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 22 Dec 2023 10:31:11 +0000 Subject: [PATCH 01/49] #2139 - Implemented routing --- CHANGELOG.md | 4 + src/primaite/simulator/network/creation.py | 148 ++++++++++++++++++ .../simulator/network/hardware/base.py | 26 +-- .../network/hardware/nodes/router.py | 148 ++++++++++++++---- .../network/hardware/nodes/switch.py | 4 +- .../integration_tests/network/test_routing.py | 93 +++++++++++ 6 files changed, 381 insertions(+), 42 deletions(-) create mode 100644 src/primaite/simulator/network/creation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 541a39d5..96634b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,10 @@ SessionManager. - Fixed an issue where the services were still able to run even though the node the service is installed on is turned off - NTP Services: `NTPClient` and `NTPServer` +### Changed +- Integrated the RouteTable into the Routers frame processing. +- Frames are now dropped when their TTL reaches 0 + ### Removed - Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` - Removed legacy training modules diff --git a/src/primaite/simulator/network/creation.py b/src/primaite/simulator/network/creation.py new file mode 100644 index 00000000..48313a1f --- /dev/null +++ b/src/primaite/simulator/network/creation.py @@ -0,0 +1,148 @@ +from ipaddress import IPv4Address +from typing import Optional + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +def num_of_switches_required(num_nodes: int, max_switch_ports: int = 24) -> int: + """ + Calculate the minimum number of network switches required to connect a given number of nodes. + + Each switch is assumed to have one port reserved for connecting to a router, reducing the effective + number of ports available for PCs. The function calculates the total number of switches needed + to accommodate all nodes under this constraint. + + :param num_nodes: The total number of nodes that need to be connected in the network. + :param max_switch_ports: The maximum number of ports available on each switch. Defaults to 24. + + :return: The minimum number of switches required to connect all PCs. + + Example: + >>> num_of_switches_required(5) + 1 + >>> num_of_switches_required(24,24) + 2 + >>> num_of_switches_required(48,24) + 3 + >>> num_of_switches_required(25,10) + 3 + """ + # Reduce the effective number of switch ports by 1 to leave space for the router + effective_switch_ports = max_switch_ports - 1 + + # Calculate the number of fully utilised switches and any additional switch for remaining PCs + full_switches = num_nodes // effective_switch_ports + extra_pcs = num_nodes % effective_switch_ports + + # Return the total number of switches required + return full_switches + (1 if extra_pcs > 0 else 0) + + +def create_office_lan( + lan_name: str, + subnet_base: int, + pcs_ip_block_start: int, + num_pcs: int, + network: Optional[Network] = None, + include_router: bool = True, +) -> Network: + """ + Creates a 2-Tier or 3-Tier office local area network (LAN). + + The LAN is configured with a specified number of personal computers (PCs), optionally including a router, + and multiple edge switches to connect them. A core switch is added only if more than one edge switch is required. + The network topology involves edge switches connected either directly to the router in a 2-Tier setup or + to a core switch in a 3-Tier setup. If a router is included, it is connected to the core switch (if present) + and configured with basic access control list (ACL) rules. PCs are distributed across the edge switches. + + + :param str lan_name: The name to be assigned to the LAN. + :param int subnet_base: The subnet base number to be used in the IP addresses. + :param int pcs_ip_block_start: The starting block for assigning IP addresses to PCs. + :param int num_pcs: The number of PCs to be added to the LAN. + :param Optional[Network] network: The network to which the LAN components will be added. If None, a new network is + created. + :param bool include_router: Flag to determine if a router should be included in the LAN. Defaults to True. + :return: The network object with the LAN components added. + :raises ValueError: If pcs_ip_block_start is less than or equal to the number of required switches. + """ + # Initialise the network if not provided + if not network: + network = Network() + + # Calculate the required number of switches + num_of_switches = num_of_switches_required(num_nodes=num_pcs) + effective_switch_ports = 23 # One port less for router connection + if pcs_ip_block_start <= num_of_switches: + raise ValueError(f"pcs_ip_block_start must be greater than the number of required switches {num_of_switches}") + + # Create a core switch if more than one edge switch is needed + if num_of_switches > 1: + core_switch = Switch(hostname=f"switch_core_{lan_name}", start_up_duration=0) + core_switch.power_on() + network.add_node(core_switch) + core_switch_port = 1 + + # Initialise the default gateway to None + default_gateway = None + + # Optionally include a router in the LAN + if include_router: + default_gateway = IPv4Address(f"192.168.{subnet_base}.1") + router = Router(hostname=f"router_{lan_name}", start_up_duration=0) + router.power_on() + router.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + network.add_node(router) + router.configure_port(port=1, ip_address=default_gateway, subnet_mask="255.255.255.0") + router.enable_port(1) + + # Initialise the first edge switch and connect to the router or core switch + switch_port = 0 + switch_n = 1 + switch = Switch(hostname=f"switch_edge_{switch_n}_{lan_name}", start_up_duration=0) + switch.power_on() + network.add_node(switch) + if num_of_switches > 1: + network.connect(core_switch.switch_ports[core_switch_port], switch.switch_ports[24]) + else: + network.connect(router.ethernet_ports[1], switch.switch_ports[24]) + + # Add PCs to the LAN and connect them to switches + for i in range(1, num_pcs + 1): + # Add a new edge switch if the current one is full + if switch_port == effective_switch_ports: + switch_n += 1 + switch_port = 0 + switch = Switch(hostname=f"switch_edge_{switch_n}_{lan_name}", start_up_duration=0) + switch.power_on() + network.add_node(switch) + # Connect the new switch to the router or core switch + if num_of_switches > 1: + core_switch_port += 1 + network.connect(core_switch.switch_ports[core_switch_port], switch.switch_ports[24]) + else: + network.connect(router.ethernet_ports[1], switch.switch_ports[24]) + + # Create and add a PC to the network + pc = Computer( + hostname=f"pc_{i}_{lan_name}", + ip_address=f"192.168.{subnet_base}.{i+pcs_ip_block_start-1}", + subnet_mask="255.255.255.0", + default_gateway=default_gateway, + start_up_duration=0, + ) + pc.power_on() + network.add_node(pc) + + # Connect the PC to the switch + switch_port += 1 + network.connect(switch.switch_ports[switch_port], pc.ethernet_port[1]) + switch.switch_ports[switch_port].enable() + + return network diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index ad3d73aa..c27378a8 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -4,7 +4,7 @@ import re import secrets from ipaddress import IPv4Address, IPv4Network from pathlib import Path -from typing import Any, Dict, Literal, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, Union from prettytable import MARKDOWN, PrettyTable @@ -282,6 +282,9 @@ class NIC(SimComponent): """ if self.enabled: frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info("Frame discarded as TTL limit reached") + return False frame.set_received_timestamp() self.pcap.capture(frame) # If this destination or is broadcast @@ -436,6 +439,9 @@ class SwitchPort(SimComponent): """ if self.enabled: frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info("Frame discarded as TTL limit reached") + return False self.pcap.capture(frame) connected_node: Node = self._connected_node connected_node.forward_frame(frame=frame, incoming_port=self) @@ -671,7 +677,9 @@ class ARPCache: """Clear the entire ARP cache, removing all stored entries.""" self.arp.clear() - def send_arp_request(self, target_ip_address: Union[IPv4Address, str]): + def send_arp_request( + self, target_ip_address: Union[IPv4Address, str], ignore_networks: Optional[List[IPv4Address]] = None + ): """ Perform a standard ARP request for a given target IP address. @@ -681,7 +689,12 @@ class ARPCache: :param target_ip_address: The target IP address to send an ARP request for. """ for nic in self.nics.values(): - if nic.enabled: + use_nic = True + if ignore_networks: + for ipv4 in ignore_networks: + if ipv4 in nic.ip_network: + use_nic = False + if nic.enabled and use_nic: self.sys_log.info(f"Sending ARP request from NIC {nic} for ip {target_ip_address}") tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) @@ -806,7 +819,6 @@ class ICMP: self.arp.send_arp_request(frame.ip.src_ip_address) self.process_icmp(frame=frame, from_nic=from_nic, is_reattempt=True) return - tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer ip_packet = IPPacket( @@ -821,9 +833,7 @@ class ICMP: sequence=frame.icmp.sequence + 1, ) payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size - frame = Frame( - ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet, payload=payload - ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, icmp=icmp_reply_packet, payload=payload) self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip_address}") src_nic.send_frame(frame) @@ -1447,7 +1457,6 @@ class Node(SimComponent): service.parent = self service.install() # Perform any additional setup, such as creating files for this service on the node. self.sys_log.info(f"Installed service {service.name}") - _LOGGER.info(f"Added service {service.uuid} to node {self.uuid}") self._service_request_manager.add_request(service.uuid, RequestType(func=service._request_manager)) def uninstall_service(self, service: Service) -> None: @@ -1480,7 +1489,6 @@ class Node(SimComponent): self.applications[application.uuid] = application application.parent = self self.sys_log.info(f"Installed application {application.name}") - _LOGGER.info(f"Added application {application.uuid} to node {self.uuid}") self._application_request_manager.add_request(application.uuid, RequestType(func=application._request_manager)) def uninstall_application(self, application: Application) -> None: diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 0234934d..1e3d8022 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -324,11 +324,10 @@ class RouteEntry(SimComponent): """ Represents a single entry in a routing table. - Attributes: - address (IPv4Address): The destination IP address or network address. - subnet_mask (IPv4Address): The subnet mask for the network. - next_hop_ip_address (IPv4Address): The next hop IP address to which packets should be forwarded. - metric (int): The cost metric for this route. Default is 0.0. + :ivar address: The destination IP address or network address. + :ivar subnet_mask: The subnet mask for the network. + :ivar next_hop_ip_address: The next hop IP address to which packets should be forwarded. + :ivar metric: The cost metric for this route. Default is 0.0. Example: >>> entry = RouteEntry( @@ -348,12 +347,6 @@ class RouteEntry(SimComponent): metric: float = 0.0 "The cost metric for this route. Default is 0.0." - def __init__(self, **kwargs): - for key in {"address", "subnet_mask", "next_hop_ip_address"}: - if not isinstance(kwargs[key], IPv4Address): - kwargs[key] = IPv4Address(kwargs[key]) - super().__init__(**kwargs) - def set_original_state(self): """Sets the original state.""" vals_to_include = {"address", "subnet_mask", "next_hop_ip_address", "metric"} @@ -388,6 +381,7 @@ class RouteTable(SimComponent): """ routes: List[RouteEntry] = [] + default_route: Optional[RouteEntry] = None sys_log: SysLog def set_original_state(self): @@ -433,12 +427,35 @@ class RouteTable(SimComponent): ) self.routes.append(route) + def set_default_route_next_hop_ip_address(self, ip_address: IPv4Address): + """ + Sets the next-hop IP address for the default route in a routing table. + + This method checks if a default route (0.0.0.0/0) exists in the routing table. If it does not exist, + the method creates a new default route with the specified next-hop IP address. If a default route already + exists, it updates the next-hop IP address of the existing default route. After setting the next-hop + IP address, the method logs this action. + + :param ip_address: The next-hop IP address to be set for the default route. + """ + if not self.default_route: + self.default_route = RouteEntry( + ip_address=IPv4Address("0.0.0.0"), + subnet_mask=IPv4Address("0.0.0.0"), + next_hop_ip_address=ip_address, + ) + else: + self.default_route.next_hop_ip_address = ip_address + self.sys_log.info(f"Default configured to use {ip_address} as the next-hop") + def find_best_route(self, destination_ip: Union[str, IPv4Address]) -> Optional[RouteEntry]: """ Find the best route for a given destination IP. This method uses the Longest Prefix Match algorithm and considers metrics to find the best route. + If no dedicated route exists but a default route does, then the default route is returned as a last resort. + :param destination_ip: The destination IP to find the route for. :return: The best matching RouteEntry, or None if no route matches. """ @@ -458,6 +475,9 @@ class RouteTable(SimComponent): longest_prefix = prefix_len lowest_metric = route.metric + if not best_route and self.default_route: + best_route = self.default_route + return best_route def show(self, markdown: bool = False): @@ -489,12 +509,26 @@ class RouterARPCache(ARPCache): super().__init__(sys_log) self.router: Router = router - def process_arp_packet(self, from_nic: NIC, frame: Frame): + def process_arp_packet( + self, from_nic: NIC, frame: Frame, route_table: RouteTable, is_reattempt: bool = False + ) -> None: """ - Overridden method to process a received ARP packet in a router-specific way. + Processes a received ARP (Address Resolution Protocol) packet in a router-specific way. + + This method is responsible for handling both ARP requests and responses. It processes ARP packets received on a + Network Interface Card (NIC) and performs actions based on whether the packet is a request or a reply. This + includes updating the ARP cache, forwarding ARP replies, sending ARP requests for unknown destinations, and + handling packet TTL (Time To Live). + + The method first checks if the ARP packet is a request or a reply. For ARP replies, it updates the ARP cache + and forwards the reply if necessary. For ARP requests, it checks if the target IP matches one of the router's + NICs and sends an ARP reply if so. If the destination is not directly connected, it consults the routing table + to find the best route and reattempts ARP request processing if needed. :param from_nic: The NIC that received the ARP packet. - :param frame: The original ARP frame. + :param frame: The frame containing the ARP packet. + :param route_table: The routing table of the router. + :param is_reattempt: Flag to indicate if this is a reattempt of processing the ARP packet, defaults to False. """ arp_packet = frame.arp @@ -522,7 +556,11 @@ class RouterARPCache(ARPCache): ) arp_packet.sender_mac_addr = nic.mac_address frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + return nic.send_frame(frame) + return # ARP Request self.sys_log.info( @@ -533,16 +571,32 @@ class RouterARPCache(ARPCache): self.add_arp_cache_entry( ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic ) - arp_packet = arp_packet.generate_reply(from_nic.mac_address) - self.send_arp_reply(arp_packet, from_nic) # If the target IP matches one of the router's NICs for nic in self.nics.values(): - if nic.enabled and nic.ip_address == arp_packet.target_ip_address: + if arp_packet.target_ip_address in nic.ip_network: + # if nic.enabled and nic.ip_address == arp_packet.target_ip_address: arp_reply = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_reply, from_nic) return + # Check Route Table + route = route_table.find_best_route(arp_packet.target_ip_address) + if route: + nic = self.get_arp_cache_nic(route.next_hop_ip_address) + + if not nic: + if not is_reattempt: + self.send_arp_request(route.next_hop_ip_address, ignore_networks=[frame.ip.src_ip_address]) + return self.process_arp_packet(from_nic, frame, route_table, is_reattempt=True) + else: + pass + # TODO: destination unavailable/No ARP netry found + else: + arp_reply = arp_packet.generate_reply(from_nic.mac_address) + self.send_arp_reply(arp_reply, from_nic) + return + class RouterICMP(ICMP): """ @@ -613,7 +667,7 @@ class RouterICMP(ICMP): return # Route the frame - self.router.route_frame(frame, from_nic) + self.router.process_frame(frame, from_nic) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: for nic in self.router.nics.values(): @@ -633,7 +687,7 @@ class RouterICMP(ICMP): return # Route the frame - self.router.route_frame(frame, from_nic) + self.router.process_frame(frame, from_nic) class Router(Node): @@ -720,9 +774,9 @@ class Router(Node): state["acl"] = (self.acl.describe_state(),) return state - def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: + def process_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: """ - Route a given frame from a source NIC to its destination. + Process a Frame. :param frame: The frame to be routed. :param from_nic: The source network interface. @@ -737,8 +791,10 @@ class Router(Node): return if not nic: - self.arp.send_arp_request(frame.ip.dst_ip_address) - return self.route_frame(frame=frame, from_nic=from_nic, re_attempt=True) + self.arp.send_arp_request( + frame.ip.dst_ip_address, ignore_networks=[frame.ip.src_ip_address, from_nic.ip_address] + ) + return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) if not nic.enabled: # TODO: Add sys_log here @@ -747,15 +803,45 @@ class Router(Node): if frame.ip.dst_ip_address in nic.ip_network: from_port = self._get_port_of_nic(from_nic) to_port = self._get_port_of_nic(nic) - self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") + self.sys_log.info(f"Forwarding frame to internally from port {from_port} to port {to_port}") frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + return frame.ethernet.src_mac_addr = nic.mac_address frame.ethernet.dst_mac_addr = target_mac nic.send_frame(frame) return else: - pass - # TODO: Deal with routing from route tables + self._route_frame(frame, from_nic) + + def _route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: + route = self.route_table.find_best_route(frame.ip.dst_ip_address) + if route: + nic = self.arp.get_arp_cache_nic(route.next_hop_ip_address) + target_mac = self.arp.get_arp_cache_mac_address(route.next_hop_ip_address) + if re_attempt and not nic: + self.sys_log.info(f"Destination {frame.ip.dst_ip_address} is unreachable") + return + + if not nic: + self.arp.send_arp_request(frame.ip.dst_ip_address, ignore_networks=[frame.ip.src_ip_address]) + return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) + + if not nic.enabled: + # TODO: Add sys_log here + return + + from_port = self._get_port_of_nic(from_nic) + to_port = self._get_port_of_nic(nic) + self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self.sys_log.info("Frame discarded as TTL limit reached") + return + frame.ethernet.src_mac_addr = nic.mac_address + frame.ethernet.dst_mac_addr = target_mac + nic.send_frame(frame) def receive_frame(self, frame: Frame, from_nic: NIC): """ @@ -764,7 +850,7 @@ class Router(Node): :param frame: The incoming frame. :param from_nic: The network interface where the frame is coming from. """ - route_frame = False + process_frame = False protocol = frame.ip.protocol src_ip_address = frame.ip.src_ip_address dst_ip_address = frame.ip.dst_ip_address @@ -796,12 +882,12 @@ class Router(Node): self.icmp.process_icmp(frame=frame, from_nic=from_nic) else: if src_port == Port.ARP: - self.arp.process_arp_packet(from_nic=from_nic, frame=frame) + self.arp.process_arp_packet(from_nic=from_nic, frame=frame, route_table=self.route_table) else: # All other traffic - route_frame = True - if route_frame: - self.route_frame(frame, from_nic) + process_frame = True + if process_frame: + self.process_frame(frame, from_nic) def configure_port(self, port: int, ip_address: Union[IPv4Address, str], subnet_mask: Union[IPv4Address, str]): """ diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py index fffae6e2..ead857f2 100644 --- a/src/primaite/simulator/network/hardware/nodes/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -90,12 +90,12 @@ class Switch(Node): self._add_mac_table_entry(src_mac, incoming_port) outgoing_port = self.mac_address_table.get(dst_mac) - if outgoing_port or dst_mac != "ff:ff:ff:ff:ff:ff": + if outgoing_port and dst_mac != "ff:ff:ff:ff:ff:ff": outgoing_port.send_frame(frame) else: # If the destination MAC is not in the table, flood to all ports except incoming for port in self.switch_ports.values(): - if port != incoming_port: + if port.enabled and port != incoming_port: port.send_frame(frame) def disconnect_link_from_port(self, link: Link, port_number: int): diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index 6053c457..3f636eae 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -1,8 +1,11 @@ +from ipaddress import IPv4Address from typing import Tuple import pytest +from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.base import Link, NIC, Node, NodeOperatingState +from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.router import ACLAction, Router from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -34,6 +37,69 @@ def pc_a_pc_b_router_1() -> Tuple[Node, Node, Router]: return pc_a, pc_b, router_1 +@pytest.fixture(scope="function") +def multi_hop_network() -> Network: + network = Network() + + # Configure PC A + pc_a = Computer( + hostname="pc_a", + ip_address="192.168.0.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.0.1", + start_up_duration=0, + ) + pc_a.power_on() + network.add_node(pc_a) + + # Configure Router 1 + router_1 = Router(hostname="router_1", start_up_duration=0) + router_1.power_on() + network.add_node(router_1) + + # Configure the connection between PC A and Router 1 port 2 + router_1.configure_port(2, "192.168.0.1", "255.255.255.0") + network.connect(pc_a.ethernet_port[1], router_1.ethernet_ports[2]) + router_1.enable_port(2) + + # Configure Router 1 ACLs + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure PC B + pc_b = Computer( + hostname="pc_b", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1", + start_up_duration=0, + ) + pc_b.power_on() + network.add_node(pc_b) + + # Configure Router 2 + router_2 = Router(hostname="router_2", start_up_duration=0) + router_2.power_on() + network.add_node(router_2) + + # Configure the connection between PC B and Router 2 port 2 + router_2.configure_port(2, "192.168.2.1", "255.255.255.0") + network.connect(pc_b.ethernet_port[1], router_2.ethernet_ports[2]) + router_2.enable_port(2) + + # Configure Router 2 ACLs + router_2.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + router_2.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + + # Configure the connection between Router 1 port 1 and Router 2 port 1 + router_2.configure_port(1, "192.168.1.2", "255.255.255.252") + router_1.configure_port(1, "192.168.1.1", "255.255.255.252") + network.connect(router_1.ethernet_ports[1], router_2.ethernet_ports[1]) + router_1.enable_port(1) + router_2.enable_port(1) + return network + + def test_ping_default_gateway(pc_a_pc_b_router_1): pc_a, pc_b, router_1 = pc_a_pc_b_router_1 @@ -50,3 +116,30 @@ def test_host_on_other_subnet(pc_a_pc_b_router_1): pc_a, pc_b, router_1 = pc_a_pc_b_router_1 assert pc_a.ping("192.168.1.10") + + +def test_no_route_no_ping(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + assert not pc_a.ping(pc_b.ethernet_port[1].ip_address) + + +def test_with_routes_can_ping(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + router_1: Router = multi_hop_network.get_node_by_hostname("router_1") # noqa + router_2: Router = multi_hop_network.get_node_by_hostname("router_2") # noqa + + # Configure Route from Router 1 to PC B subnet + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + assert pc_a.ping(pc_b.ethernet_port[1].ip_address) From 4266618ba57346f94c164fa8a7581507713b05d0 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 5 Jan 2024 11:25:57 +0000 Subject: [PATCH 02/49] Make pcap logs and sys logs optional --- CHANGELOG.md | 2 +- .../config/_package_data/example_config.yaml | 2 ++ src/primaite/session/io.py | 9 +++++++-- src/primaite/session/session.py | 2 +- src/primaite/simulator/__init__.py | 6 ++++-- .../simulator/system/core/packet_capture.py | 8 ++++++-- src/primaite/simulator/system/core/sys_log.py | 18 +++++++++++++----- 7 files changed, 34 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccaa411a..9e44efe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - +- Made packet capture and system logging optional ### Added diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index a764703b..81c9643e 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -14,6 +14,8 @@ io_settings: save_checkpoints: true checkpoint_interval: 5 save_step_metadata: false + save_pcap_logs: true + save_sys_logs: true game: diff --git a/src/primaite/session/io.py b/src/primaite/session/io.py index 0d80a385..b4b740e9 100644 --- a/src/primaite/session/io.py +++ b/src/primaite/session/io.py @@ -24,9 +24,13 @@ class SessionIOSettings(BaseModel): save_transactions: bool = True """Whether to save transactions, If true, the session path will have a transactions folder.""" save_tensorboard_logs: bool = False - """Whether to save tensorboard logs. If true, the session path will have a tenorboard_logs folder.""" + """Whether to save tensorboard logs. If true, the session path will have a tensorboard_logs folder.""" save_step_metadata: bool = False """Whether to save the RL agents' action, environment state, and other data at every single step.""" + save_pcap_logs: bool = False + """Whether to save PCAP logs.""" + save_sys_logs: bool = False + """Whether to save system logs.""" class SessionIO: @@ -39,9 +43,10 @@ class SessionIO: def __init__(self, settings: SessionIOSettings = SessionIOSettings()) -> None: self.settings: SessionIOSettings = settings self.session_path: Path = self.generate_session_path() - # set global SIM_OUTPUT path SIM_OUTPUT.path = self.session_path / "simulation_output" + SIM_OUTPUT.save_pcap_logs = self.settings.save_pcap_logs + SIM_OUTPUT.save_sys_logs = self.settings.save_sys_logs # warning TODO: must be careful not to re-initialise sessionIO because it will create a new path each time it's # possible refactor needed diff --git a/src/primaite/session/session.py b/src/primaite/session/session.py index ef462d83..0197ac9d 100644 --- a/src/primaite/session/session.py +++ b/src/primaite/session/session.py @@ -54,7 +54,7 @@ class PrimaiteSession: self.policy: PolicyABC """The reinforcement learning policy.""" - self.io_manager = SessionIO() + self.io_manager: Optional["SessionIO"] = None """IO manager for the session.""" self.game: PrimaiteGame = game diff --git a/src/primaite/simulator/__init__.py b/src/primaite/simulator/__init__.py index 19c86e28..aebd77cf 100644 --- a/src/primaite/simulator/__init__.py +++ b/src/primaite/simulator/__init__.py @@ -7,11 +7,13 @@ from primaite import _PRIMAITE_ROOT __all__ = ["SIM_OUTPUT"] -class __SimOutput: +class _SimOutput: def __init__(self): self._path: Path = ( _PRIMAITE_ROOT.parent.parent / "simulation_output" / datetime.now().strftime("%Y-%m-%d_%H-%M-%S") ) + self.save_pcap_logs: bool = False + self.save_sys_logs: bool = False @property def path(self) -> Path: @@ -23,4 +25,4 @@ class __SimOutput: self._path.mkdir(exist_ok=True, parents=True) -SIM_OUTPUT = __SimOutput() +SIM_OUTPUT = _SimOutput() diff --git a/src/primaite/simulator/system/core/packet_capture.py b/src/primaite/simulator/system/core/packet_capture.py index 1539e024..bfb6a055 100644 --- a/src/primaite/simulator/system/core/packet_capture.py +++ b/src/primaite/simulator/system/core/packet_capture.py @@ -41,6 +41,9 @@ class PacketCapture: def setup_logger(self): """Set up the logger configuration.""" + if not SIM_OUTPUT.save_pcap_logs: + return + log_path = self._get_log_path() file_handler = logging.FileHandler(filename=log_path) @@ -88,5 +91,6 @@ class PacketCapture: :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 + if SIM_OUTPUT.save_pcap_logs: + 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/core/sys_log.py b/src/primaite/simulator/system/core/sys_log.py index 41ce8fee..00e6920b 100644 --- a/src/primaite/simulator/system/core/sys_log.py +++ b/src/primaite/simulator/system/core/sys_log.py @@ -41,6 +41,9 @@ class SysLog: 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. """ + if not SIM_OUTPUT.save_sys_logs: + return + log_path = self._get_log_path() file_handler = logging.FileHandler(filename=log_path) file_handler.setLevel(logging.DEBUG) @@ -91,7 +94,8 @@ class SysLog: :param msg: The message to be logged. """ - self.logger.debug(msg) + if SIM_OUTPUT.save_sys_logs: + self.logger.debug(msg) def info(self, msg: str): """ @@ -99,7 +103,8 @@ class SysLog: :param msg: The message to be logged. """ - self.logger.info(msg) + if SIM_OUTPUT.save_sys_logs: + self.logger.info(msg) def warning(self, msg: str): """ @@ -107,7 +112,8 @@ class SysLog: :param msg: The message to be logged. """ - self.logger.warning(msg) + if SIM_OUTPUT.save_sys_logs: + self.logger.warning(msg) def error(self, msg: str): """ @@ -115,7 +121,8 @@ class SysLog: :param msg: The message to be logged. """ - self.logger.error(msg) + if SIM_OUTPUT.save_sys_logs: + self.logger.error(msg) def critical(self, msg: str): """ @@ -123,4 +130,5 @@ class SysLog: :param msg: The message to be logged. """ - self.logger.critical(msg) + if SIM_OUTPUT.save_sys_logs: + self.logger.critical(msg) From f75c10aafb40f973d22e666f0ee7538b7e0e8676 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 5 Jan 2024 13:10:49 +0000 Subject: [PATCH 03/49] Make flattening observation spaces optional. --- CHANGELOG.md | 3 +- docs/source/config.rst | 24 +- .../config/_package_data/example_config.yaml | 2 +- src/primaite/game/agent/interface.py | 3 + src/primaite/game/game.py | 1 + .../training_example_ray_single_agent.ipynb | 221 +++++++++++++++++- src/primaite/session/environment.py | 15 +- src/primaite/session/session.py | 4 +- 8 files changed, 259 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e44efe3..c712ef66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- Made packet capture and system logging optional +- Made packet capture and system logging optional (off by default). To turn on, change the io_settings.save_pcap_logs and io_settings.save_sys_logs settings in the config. +- Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. ### Added diff --git a/docs/source/config.rst b/docs/source/config.rst index f4452c7e..23bf6097 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -13,7 +13,25 @@ This section allows selecting which training framework and algorithm to use, and ``io_settings`` --------------- -This section configures how the ``PrimaiteSession`` saves data. +This section configures how PrimAITE saves data during simulation and training. + +**save_final_model**: Only used if training with PrimaiteSession, if true, the policy will be saved after the final training iteration. + +**save_checkpoints**: Only used if training with PrimaiteSession, if true, the policy will be saved periodically during training. + +**checkpoint_interval**: Only used if training with PrimaiteSession and if ``save_checkpoints`` is true. Defines how often to save the policy during training. + +**save_logs**: *currently unused*. + +**save_transactions**: *currently unused*. + +**save_tensorboard_logs**: *currently unused*. + +**save_step_metadata**: Whether to save the RL agents' action, environment state, and other data at every single step. + +**save_pcap_logs**: Whether to save pcap files of all network traffic during the simulation. + +**save_sys_logs**: Whether to save system logs from all nodes during the simulation. ``game`` -------- @@ -56,6 +74,10 @@ Description of configurable items: **agent_settings**: Settings passed to the agent during initialisation. These depend on the agent class. +Reinforcement learning agents use the ``ProxyAgent`` class, they accept these agent settings: + +**flatten_obs**: If true, gymnasium flattening will be performed on the observation space before sending to the agent. Set this to true if your agent does not support nested observation spaces. + ``simulation`` -------------- In this section the network layout is defined. This part of the config follows a hierarchical structure. Almost every component defines a ``ref`` field which acts as a human-readable unique identifier, used by other parts of the config, such as agents. diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index 81c9643e..2ac23661 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -525,7 +525,7 @@ agents: agent_settings: - # ... + flatten_obs: true diff --git a/src/primaite/game/agent/interface.py b/src/primaite/game/agent/interface.py index fbbe5473..8657fc45 100644 --- a/src/primaite/game/agent/interface.py +++ b/src/primaite/game/agent/interface.py @@ -44,6 +44,7 @@ class AgentSettings(BaseModel): start_settings: Optional[AgentStartSettings] = None "Configuration for when an agent begins performing it's actions" + flatten_obs: bool = True @classmethod def from_config(cls, config: Optional[Dict]) -> "AgentSettings": @@ -166,6 +167,7 @@ class ProxyAgent(AbstractAgent): action_space: Optional[ActionManager], observation_space: Optional[ObservationManager], reward_function: Optional[RewardFunction], + agent_settings: Optional[AgentSettings] = None, ) -> None: super().__init__( agent_name=agent_name, @@ -174,6 +176,7 @@ class ProxyAgent(AbstractAgent): reward_function=reward_function, ) self.most_recent_action: ActType + self.flatten_obs: bool = agent_settings.flatten_obs def get_action(self, obs: ObsType, reward: float = 0.0) -> Tuple[str, Dict]: """ diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index d2db4bea..586bca79 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -427,6 +427,7 @@ class PrimaiteGame: action_space=action_space, observation_space=obs_space, reward_function=rew_function, + agent_settings=agent_settings, ) game.agents.append(new_agent) game.rl_agents.append(new_agent) diff --git a/src/primaite/notebooks/training_example_ray_single_agent.ipynb b/src/primaite/notebooks/training_example_ray_single_agent.ipynb index a89b29e4..993e81ff 100644 --- a/src/primaite/notebooks/training_example_ray_single_agent.ipynb +++ b/src/primaite/notebooks/training_example_ray_single_agent.ipynb @@ -10,9 +10,64 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n", + "2024-01-05 12:46:28,650\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", + "2024-01-05 12:46:31,581\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", + "2024-01-05 12:46:31,903\tWARNING __init__.py:10 -- PG has/have been moved to `rllib_contrib` and will no longer be maintained by the RLlib team. You can still use it/them normally inside RLlib util Ray 2.8, but from Ray 2.9 on, all `rllib_contrib` algorithms will no longer be part of the core repo, and will therefore have to be installed separately with pinned dependencies for e.g. ray[rllib] and other packages! See https://github.com/ray-project/ray/tree/master/rllib_contrib#rllib-contrib for more information on the RLlib contrib effort.\n", + "2024-01-05 12:46:35,016\tINFO worker.py:1673 -- Started a local Ray instance.\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "
\n", + "
\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Python version:3.10.12
Ray version:2.8.0
\n", + "\n", + "
\n", + "
\n" + ], + "text/plain": [ + "RayContext(dashboard_url='', python_version='3.10.12', ray_version='2.8.0', ray_commit='105355bd253d6538ed34d331f6a4bdf0e38ace3a', protocol_version=None)" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "from primaite.game.game import PrimaiteGame\n", "import yaml\n", @@ -41,7 +96,24 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'flatten_obs': False}\n" + ] + } + ], + "source": [ + "print(cfg['agents'][2]['agent_settings'])" + ] + }, + { + "cell_type": "code", + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -64,9 +136,141 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + ":job_id:01000000\n", + ":task_name:bundle_reservation_check_func\n", + ":actor_name:PPO\n", + "2024-01-05 12:46:40,174: Added service 6589f0e3-f427-4382-9e29-1344624bfe33 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", + "2024-01-05 12:46:40,175: Added service c3299cad-9a05-4fa1-bf6b-3fb82e9c9717 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", + "2024-01-05 12:46:40,176: Added application 2cba2c28-dc88-4688-b8bc-ac5cf2b7652c to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", + "2024-01-05 12:46:40,178: Added service 23207441-15d6-42f8-bae4-1810bbff7d8b to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", + "2024-01-05 12:46:40,179: Added service 01a61864-52d1-4980-b88c-6bec7ad58c3e to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", + "2024-01-05 12:46:40,180: Added application a51c4db0-028c-440c-845e-43f7a1354544 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", + "2024-01-05 12:46:40,181: Added service 7beff81f-6083-421b-a212-e02d9eb3ad69 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", + "2024-01-05 12:46:40,184: Added service e49fd236-0195-4571-a992-af490c2d27c4 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,186: Added service 9fdc6bb7-a338-4a64-b7a3-8467c88f79fd to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,188: Added application 3f99407c-1642-47e5-ade5-5106a1b49004 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,189: Added service 0e4c4e77-1bbb-45c3-aa4b-fdfd6c439091 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,190: Added service 711608ae-5f71-4bb7-8c99-95974f28f964 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,191: Added application bfbe2fb3-4d7e-4f07-9454-aee8404ca4b3 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,192: Added application 2cd88860-c7c5-4e64-b07a-4f0c9a0d8324 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,194: Added service 3cafdb32-3a89-4ab4-a22c-00beb29d6e71 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", + "2024-01-05 12:46:40,196: Added service 649ff374-b9b3-4f17-94de-d95472cc94be to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,198: Added service 561374dc-8844-4a71-a577-67659130afaf to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,200: Added application 14eb20b8-ea9e-4027-a9ef-bf438b1f2b5e to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,202: Added service c7721159-10ad-4fd1-9fc7-a4403f89743a to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,203: Added service 907aff5d-c7d3-4d23-ab97-3bdaf92c8707 to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,204: Added application c8a55900-00af-46a7-90b5-bf8591130534 to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,206: Added service 9ae26c20-4c51-4283-b791-3c278c85aaef to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,207: Added service d3f108af-6a58-430b-9fc8-495e7db16968 to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", + "2024-01-05 12:46:40,211: Added service b759a0a5-7fe9-4f29-830e-6c50fe3d5ac0 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", + "2024-01-05 12:46:40,212: Added service d07213b5-d35b-4343-96ff-76399f80d12c to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", + "2024-01-05 12:46:40,213: Added application f4cb45da-c81c-4fbf-adcf-461ca8728576 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", + "2024-01-05 12:46:40,215: Added service 44dadb4d-09b2-4569-97ed-18ed5e050437 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", + "2024-01-05 12:46:40,216: Added service 6c2e121a-fe1e-45fd-b0d4-587c0f6aafba to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", + "2024-01-05 12:46:40,217: Added application e1ed96b9-221a-4a26-8330-1142f7681bf3 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", + "2024-01-05 12:46:40,218: Added service 4a9b52fb-747f-4921-bd73-2ee17557b2de to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", + "2024-01-05 12:46:40,220: Added service 38f3dfa9-6974-4122-b731-63a9cc3a13b2 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", + "2024-01-05 12:46:40,220: Added service 5e2b34f4-9ac6-4e9d-b2db-48aac4eeff32 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", + "2024-01-05 12:46:40,221: Added application 2db51ce9-391f-4e82-acf6-b565819b6c6d to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", + "2024-01-05 12:46:40,223: Added service e33f7cfb-6940-4076-9a2f-5874ba385c57 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", + "2024-01-05 12:46:40,224: Added service 346687ac-a032-479b-9ccb-ab2df7d5b84b to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", + "2024-01-05 12:46:40,225: Added application 7adcddf8-4d1f-428b-8722-7ce44f1e64d7 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", + "2024-01-05 12:46:40,229: Added service c498af8f-5648-4340-b117-7dd958d8bccb to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,231: Added service 0218fc4e-fb25-47fb-b03c-9ecfa90986eb to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,232: Added application edfe50af-01ac-45e4-8b96-fdb5ec6d61d7 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,233: Added service fb4b25f9-4a3f-41ec-a2db-57eac73201a9 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,234: Added service 062e3e3e-65a4-4a30-ad34-418bdc2f5886 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,235: Added application 72cdbee1-3ed9-4189-8198-788bfacacb44 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,236: Added service f5b741a0-25a5-42cb-86e8-e42b2fec7433 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,237: Added application 3fc736ef-9308-49a6-b63c-559ec878fc30 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", + "2024-01-05 12:46:40,240: Added service 463f9765-6d2b-427c-9319-f4af92de3815 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", + "2024-01-05 12:46:40,241: Added service c4e6a1fc-7512-45e4-b8c1-60d0913b22d3 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", + "2024-01-05 12:46:40,242: Added application c1f981ba-db6b-4a1e-98a8-d8f0efb0ead9 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", + "2024-01-05 12:46:40,244: Added service 815c2d28-75e5-4ea6-a283-a18b389d4e6e to node 65549a8a-9788-462b-9a12-883747b73a3b\n", + "2024-01-05 12:46:40,246: Added service c9ee86ec-9490-4fd8-8aef-6cab5c9e7c67 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", + "2024-01-05 12:46:40,247: Added application 91763a2f-8135-4fd3-a4cc-81c883d0d33f to node 65549a8a-9788-462b-9a12-883747b73a3b\n", + "2024-01-05 12:46:40,248: Added service c71b0658-1e43-4881-b603-87dbf1c171a2 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", + "2024-01-05 12:46:40,249: Added application 39ad54a1-28cd-4cd9-88d6-32e3f00844ae to node 65549a8a-9788-462b-9a12-883747b73a3b\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + ":job_id:01000000\n", + ":task_name:bundle_reservation_check_func\n", + ":actor_name:PPO\n", + "Resetting environment, episode -1, avg. reward: 0.0\n", + ":actor_name:PPO\n", + "Resetting environment, episode 0, avg. reward: 0.0\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + ":actor_name:PPO\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Resetting environment, episode 1, avg. reward: -101.0\n", + "Resetting environment, episode 2, avg. reward: -126.5\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2024-01-05 12:46:46,735\tINFO storage.py:563 -- Checkpoint successfully created at: Checkpoint(filesystem=local, path=/home/cade/ray_results/PPO_2024-01-05_12-46-39/PPO_PrimaiteRayEnv_7899c_00000_0_2024-01-05_12-46-40/checkpoint_000000)\n", + "2024-01-05 12:46:46,847\tINFO tune.py:1047 -- Total run time: 6.85 seconds (6.77 seconds for the tuning loop).\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + }, + { + "data": { + "text/plain": [ + "ResultGrid<[\n", + " Result(\n", + " metrics={'custom_metrics': {}, 'episode_media': {}, 'info': {'learner': {'__all__': {'num_agent_steps_trained': 128.0, 'num_env_steps_trained': 128.0, 'total_loss': 9.46448793411255}, 'default_policy': {'total_loss': 9.46448793411255, 'policy_loss': -0.06344481200600664, 'vf_loss': 9.525621096293131, 'vf_loss_unclipped': 509.6841542561849, 'vf_explained_var': 0.004743536313374837, 'entropy': 3.855365761121114, 'mean_kl_loss': 0.011559122211959523, 'default_optimizer_lr': 4.999999999999999e-05, 'curr_lr': 5e-05, 'curr_entropy_coeff': 0.0, 'curr_kl_coeff': 0.20000000298023224}}, 'num_env_steps_sampled': 512, 'num_env_steps_trained': 0, 'num_agent_steps_sampled': 512, 'num_agent_steps_trained': 0}, 'sampler_results': {'episode_reward_max': -101.0, 'episode_reward_min': -126.5, 'episode_reward_mean': -113.75, 'episode_len_mean': 256.0, 'episode_media': {}, 'episodes_this_iter': 1, 'policy_reward_min': {}, 'policy_reward_max': {}, 'policy_reward_mean': {}, 'custom_metrics': {}, 'hist_stats': {'episode_reward': [-101.0, -126.5], 'episode_lengths': [256, 256]}, 'sampler_perf': {'mean_raw_obs_processing_ms': 1.4790121096531605, 'mean_inference_ms': 2.438426005102573, 'mean_action_processing_ms': 0.12985192105746982, 'mean_env_wait_ms': 2.5040151965470656, 'mean_env_render_ms': 0.0}, 'num_faulty_episodes': 0, 'connector_metrics': {'ObsPreprocessorConnector_ms': 1.051938533782959, 'StateBufferConnector_ms': 0.009810924530029297, 'ViewRequirementAgentConnector_ms': 0.46378374099731445}}, 'episode_reward_max': -101.0, 'episode_reward_min': -126.5, 'episode_reward_mean': -113.75, 'episode_len_mean': 256.0, 'episodes_this_iter': 1, 'policy_reward_min': {}, 'policy_reward_max': {}, 'policy_reward_mean': {}, 'hist_stats': {'episode_reward': [-101.0, -126.5], 'episode_lengths': [256, 256]}, 'sampler_perf': {'mean_raw_obs_processing_ms': 1.4790121096531605, 'mean_inference_ms': 2.438426005102573, 'mean_action_processing_ms': 0.12985192105746982, 'mean_env_wait_ms': 2.5040151965470656, 'mean_env_render_ms': 0.0}, 'num_faulty_episodes': 0, 'connector_metrics': {'ObsPreprocessorConnector_ms': 1.051938533782959, 'StateBufferConnector_ms': 0.009810924530029297, 'ViewRequirementAgentConnector_ms': 0.46378374099731445}, 'num_healthy_workers': 0, 'num_in_flight_async_reqs': 0, 'num_remote_worker_restarts': 0, 'num_agent_steps_sampled': 512, 'num_agent_steps_trained': 0, 'num_env_steps_sampled': 512, 'num_env_steps_trained': 0, 'num_env_steps_sampled_this_iter': 128, 'num_env_steps_trained_this_iter': 0, 'num_env_steps_sampled_throughput_per_sec': 57.18780249848288, 'num_env_steps_trained_throughput_per_sec': 0.0, 'num_steps_trained_this_iter': 0, 'agent_timesteps_total': 512, 'timers': {'training_iteration_time_ms': 1392.194, 'sample_time_ms': 995.05, 'synch_weights_time_ms': 1.92}, 'counters': {'num_env_steps_sampled': 512, 'num_env_steps_trained': 0, 'num_agent_steps_sampled': 512, 'num_agent_steps_trained': 0}, 'perf': {'cpu_util_percent': 54.06666666666666, 'ram_util_percent': 53.53333333333334}},\n", + " path='/home/cade/ray_results/PPO_2024-01-05_12-46-39/PPO_PrimaiteRayEnv_7899c_00000_0_2024-01-05_12-46-40',\n", + " filesystem='local',\n", + " checkpoint=Checkpoint(filesystem=local, path=/home/cade/ray_results/PPO_2024-01-05_12-46-39/PPO_PrimaiteRayEnv_7899c_00000_0_2024-01-05_12-46-40/checkpoint_000000)\n", + " )\n", + "]>" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "tune.Tuner(\n", " \"PPO\",\n", @@ -76,6 +280,13 @@ " param_space=config\n", ").fit()\n" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index ca71a0c0..36ab3f58 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -23,6 +23,7 @@ class PrimaiteGymEnv(gymnasium.Env): super().__init__() self.game: "PrimaiteGame" = game self.agent: ProxyAgent = self.game.rl_agents[0] + self.flatten_obs: bool = False def step(self, action: ActType) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict[str, Any]]: """Perform a step in the environment.""" @@ -81,13 +82,19 @@ class PrimaiteGymEnv(gymnasium.Env): @property def observation_space(self) -> gymnasium.Space: """Return the observation space of the environment.""" - return gymnasium.spaces.flatten_space(self.agent.observation_manager.space) + if self.agent.flatten_obs: + return gymnasium.spaces.flatten_space(self.agent.observation_manager.space) + else: + return self.agent.observation_manager.space def _get_obs(self) -> ObsType: """Return the current observation.""" - unflat_space = self.agent.observation_manager.space - unflat_obs = self.agent.observation_manager.current_observation - return gymnasium.spaces.flatten(unflat_space, unflat_obs) + if not self.agent.flatten_obs: + return self.agent.observation_manager.current_observation + else: + unflat_space = self.agent.observation_manager.space + unflat_obs = self.agent.observation_manager.current_observation + return gymnasium.spaces.flatten(unflat_space, unflat_obs) class PrimaiteRayEnv(gymnasium.Env): diff --git a/src/primaite/session/session.py b/src/primaite/session/session.py index 0197ac9d..5c663cfd 100644 --- a/src/primaite/session/session.py +++ b/src/primaite/session/session.py @@ -101,9 +101,9 @@ class PrimaiteSession: # CREATE ENVIRONMENT if sess.training_options.rl_framework == "RLLIB_single_agent": - sess.env = PrimaiteRayEnv(env_config={"game": game}) + sess.env = PrimaiteRayEnv(env_config={"cfg": cfg}) elif sess.training_options.rl_framework == "RLLIB_multi_agent": - sess.env = PrimaiteRayMARLEnv(env_config={"game": game}) + sess.env = PrimaiteRayMARLEnv(env_config={"cfg": cfg}) elif sess.training_options.rl_framework == "SB3": sess.env = PrimaiteGymEnv(game=game) From d038f63fda3897c6f7169e882938ca47bd62c720 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 5 Jan 2024 13:11:47 +0000 Subject: [PATCH 04/49] Clear notebook output --- .../training_example_ray_single_agent.ipynb | 209 +----------------- 1 file changed, 7 insertions(+), 202 deletions(-) diff --git a/src/primaite/notebooks/training_example_ray_single_agent.ipynb b/src/primaite/notebooks/training_example_ray_single_agent.ipynb index 993e81ff..ea006ae9 100644 --- a/src/primaite/notebooks/training_example_ray_single_agent.ipynb +++ b/src/primaite/notebooks/training_example_ray_single_agent.ipynb @@ -10,64 +10,9 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n", - "2024-01-05 12:46:28,650\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2024-01-05 12:46:31,581\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2024-01-05 12:46:31,903\tWARNING __init__.py:10 -- PG has/have been moved to `rllib_contrib` and will no longer be maintained by the RLlib team. You can still use it/them normally inside RLlib util Ray 2.8, but from Ray 2.9 on, all `rllib_contrib` algorithms will no longer be part of the core repo, and will therefore have to be installed separately with pinned dependencies for e.g. ray[rllib] and other packages! See https://github.com/ray-project/ray/tree/master/rllib_contrib#rllib-contrib for more information on the RLlib contrib effort.\n", - "2024-01-05 12:46:35,016\tINFO worker.py:1673 -- Started a local Ray instance.\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "
\n", - "
\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Python version:3.10.12
Ray version:2.8.0
\n", - "\n", - "
\n", - "
\n" - ], - "text/plain": [ - "RayContext(dashboard_url='', python_version='3.10.12', ray_version='2.8.0', ray_commit='105355bd253d6538ed34d331f6a4bdf0e38ace3a', protocol_version=None)" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "from primaite.game.game import PrimaiteGame\n", "import yaml\n", @@ -96,24 +41,16 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "{'flatten_obs': False}\n" - ] - } - ], + "outputs": [], "source": [ "print(cfg['agents'][2]['agent_settings'])" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -136,141 +73,9 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - ":job_id:01000000\n", - ":task_name:bundle_reservation_check_func\n", - ":actor_name:PPO\n", - "2024-01-05 12:46:40,174: Added service 6589f0e3-f427-4382-9e29-1344624bfe33 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", - "2024-01-05 12:46:40,175: Added service c3299cad-9a05-4fa1-bf6b-3fb82e9c9717 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", - "2024-01-05 12:46:40,176: Added application 2cba2c28-dc88-4688-b8bc-ac5cf2b7652c to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", - "2024-01-05 12:46:40,178: Added service 23207441-15d6-42f8-bae4-1810bbff7d8b to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", - "2024-01-05 12:46:40,179: Added service 01a61864-52d1-4980-b88c-6bec7ad58c3e to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", - "2024-01-05 12:46:40,180: Added application a51c4db0-028c-440c-845e-43f7a1354544 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", - "2024-01-05 12:46:40,181: Added service 7beff81f-6083-421b-a212-e02d9eb3ad69 to node 6f2396a1-80f4-4822-8d98-42811a89521e\n", - "2024-01-05 12:46:40,184: Added service e49fd236-0195-4571-a992-af490c2d27c4 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,186: Added service 9fdc6bb7-a338-4a64-b7a3-8467c88f79fd to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,188: Added application 3f99407c-1642-47e5-ade5-5106a1b49004 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,189: Added service 0e4c4e77-1bbb-45c3-aa4b-fdfd6c439091 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,190: Added service 711608ae-5f71-4bb7-8c99-95974f28f964 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,191: Added application bfbe2fb3-4d7e-4f07-9454-aee8404ca4b3 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,192: Added application 2cd88860-c7c5-4e64-b07a-4f0c9a0d8324 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,194: Added service 3cafdb32-3a89-4ab4-a22c-00beb29d6e71 to node 5a8e7052-0094-4104-aedb-beda65db2214\n", - "2024-01-05 12:46:40,196: Added service 649ff374-b9b3-4f17-94de-d95472cc94be to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,198: Added service 561374dc-8844-4a71-a577-67659130afaf to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,200: Added application 14eb20b8-ea9e-4027-a9ef-bf438b1f2b5e to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,202: Added service c7721159-10ad-4fd1-9fc7-a4403f89743a to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,203: Added service 907aff5d-c7d3-4d23-ab97-3bdaf92c8707 to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,204: Added application c8a55900-00af-46a7-90b5-bf8591130534 to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,206: Added service 9ae26c20-4c51-4283-b791-3c278c85aaef to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,207: Added service d3f108af-6a58-430b-9fc8-495e7db16968 to node 0053cdf7-44aa-4a44-b71c-a0351927e797\n", - "2024-01-05 12:46:40,211: Added service b759a0a5-7fe9-4f29-830e-6c50fe3d5ac0 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", - "2024-01-05 12:46:40,212: Added service d07213b5-d35b-4343-96ff-76399f80d12c to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", - "2024-01-05 12:46:40,213: Added application f4cb45da-c81c-4fbf-adcf-461ca8728576 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", - "2024-01-05 12:46:40,215: Added service 44dadb4d-09b2-4569-97ed-18ed5e050437 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", - "2024-01-05 12:46:40,216: Added service 6c2e121a-fe1e-45fd-b0d4-587c0f6aafba to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", - "2024-01-05 12:46:40,217: Added application e1ed96b9-221a-4a26-8330-1142f7681bf3 to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", - "2024-01-05 12:46:40,218: Added service 4a9b52fb-747f-4921-bd73-2ee17557b2de to node 92240e65-db56-4b90-a1e3-a0e7d0d7e9a6\n", - "2024-01-05 12:46:40,220: Added service 38f3dfa9-6974-4122-b731-63a9cc3a13b2 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", - "2024-01-05 12:46:40,220: Added service 5e2b34f4-9ac6-4e9d-b2db-48aac4eeff32 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", - "2024-01-05 12:46:40,221: Added application 2db51ce9-391f-4e82-acf6-b565819b6c6d to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", - "2024-01-05 12:46:40,223: Added service e33f7cfb-6940-4076-9a2f-5874ba385c57 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", - "2024-01-05 12:46:40,224: Added service 346687ac-a032-479b-9ccb-ab2df7d5b84b to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", - "2024-01-05 12:46:40,225: Added application 7adcddf8-4d1f-428b-8722-7ce44f1e64d7 to node 0c55c8bd-252b-420e-8ba6-e81091c21ff9\n", - "2024-01-05 12:46:40,229: Added service c498af8f-5648-4340-b117-7dd958d8bccb to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,231: Added service 0218fc4e-fb25-47fb-b03c-9ecfa90986eb to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,232: Added application edfe50af-01ac-45e4-8b96-fdb5ec6d61d7 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,233: Added service fb4b25f9-4a3f-41ec-a2db-57eac73201a9 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,234: Added service 062e3e3e-65a4-4a30-ad34-418bdc2f5886 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,235: Added application 72cdbee1-3ed9-4189-8198-788bfacacb44 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,236: Added service f5b741a0-25a5-42cb-86e8-e42b2fec7433 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,237: Added application 3fc736ef-9308-49a6-b63c-559ec878fc30 to node 88b3a7a8-bd87-48e3-b74b-5a66d2d770eb\n", - "2024-01-05 12:46:40,240: Added service 463f9765-6d2b-427c-9319-f4af92de3815 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", - "2024-01-05 12:46:40,241: Added service c4e6a1fc-7512-45e4-b8c1-60d0913b22d3 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", - "2024-01-05 12:46:40,242: Added application c1f981ba-db6b-4a1e-98a8-d8f0efb0ead9 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", - "2024-01-05 12:46:40,244: Added service 815c2d28-75e5-4ea6-a283-a18b389d4e6e to node 65549a8a-9788-462b-9a12-883747b73a3b\n", - "2024-01-05 12:46:40,246: Added service c9ee86ec-9490-4fd8-8aef-6cab5c9e7c67 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", - "2024-01-05 12:46:40,247: Added application 91763a2f-8135-4fd3-a4cc-81c883d0d33f to node 65549a8a-9788-462b-9a12-883747b73a3b\n", - "2024-01-05 12:46:40,248: Added service c71b0658-1e43-4881-b603-87dbf1c171a2 to node 65549a8a-9788-462b-9a12-883747b73a3b\n", - "2024-01-05 12:46:40,249: Added application 39ad54a1-28cd-4cd9-88d6-32e3f00844ae to node 65549a8a-9788-462b-9a12-883747b73a3b\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - ":job_id:01000000\n", - ":task_name:bundle_reservation_check_func\n", - ":actor_name:PPO\n", - "Resetting environment, episode -1, avg. reward: 0.0\n", - ":actor_name:PPO\n", - "Resetting environment, episode 0, avg. reward: 0.0\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - ":actor_name:PPO\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "Resetting environment, episode 1, avg. reward: -101.0\n", - "Resetting environment, episode 2, avg. reward: -126.5\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2024-01-05 12:46:46,735\tINFO storage.py:563 -- Checkpoint successfully created at: Checkpoint(filesystem=local, path=/home/cade/ray_results/PPO_2024-01-05_12-46-39/PPO_PrimaiteRayEnv_7899c_00000_0_2024-01-05_12-46-40/checkpoint_000000)\n", - "2024-01-05 12:46:46,847\tINFO tune.py:1047 -- Total run time: 6.85 seconds (6.77 seconds for the tuning loop).\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "data": { - "text/plain": [ - "ResultGrid<[\n", - " Result(\n", - " metrics={'custom_metrics': {}, 'episode_media': {}, 'info': {'learner': {'__all__': {'num_agent_steps_trained': 128.0, 'num_env_steps_trained': 128.0, 'total_loss': 9.46448793411255}, 'default_policy': {'total_loss': 9.46448793411255, 'policy_loss': -0.06344481200600664, 'vf_loss': 9.525621096293131, 'vf_loss_unclipped': 509.6841542561849, 'vf_explained_var': 0.004743536313374837, 'entropy': 3.855365761121114, 'mean_kl_loss': 0.011559122211959523, 'default_optimizer_lr': 4.999999999999999e-05, 'curr_lr': 5e-05, 'curr_entropy_coeff': 0.0, 'curr_kl_coeff': 0.20000000298023224}}, 'num_env_steps_sampled': 512, 'num_env_steps_trained': 0, 'num_agent_steps_sampled': 512, 'num_agent_steps_trained': 0}, 'sampler_results': {'episode_reward_max': -101.0, 'episode_reward_min': -126.5, 'episode_reward_mean': -113.75, 'episode_len_mean': 256.0, 'episode_media': {}, 'episodes_this_iter': 1, 'policy_reward_min': {}, 'policy_reward_max': {}, 'policy_reward_mean': {}, 'custom_metrics': {}, 'hist_stats': {'episode_reward': [-101.0, -126.5], 'episode_lengths': [256, 256]}, 'sampler_perf': {'mean_raw_obs_processing_ms': 1.4790121096531605, 'mean_inference_ms': 2.438426005102573, 'mean_action_processing_ms': 0.12985192105746982, 'mean_env_wait_ms': 2.5040151965470656, 'mean_env_render_ms': 0.0}, 'num_faulty_episodes': 0, 'connector_metrics': {'ObsPreprocessorConnector_ms': 1.051938533782959, 'StateBufferConnector_ms': 0.009810924530029297, 'ViewRequirementAgentConnector_ms': 0.46378374099731445}}, 'episode_reward_max': -101.0, 'episode_reward_min': -126.5, 'episode_reward_mean': -113.75, 'episode_len_mean': 256.0, 'episodes_this_iter': 1, 'policy_reward_min': {}, 'policy_reward_max': {}, 'policy_reward_mean': {}, 'hist_stats': {'episode_reward': [-101.0, -126.5], 'episode_lengths': [256, 256]}, 'sampler_perf': {'mean_raw_obs_processing_ms': 1.4790121096531605, 'mean_inference_ms': 2.438426005102573, 'mean_action_processing_ms': 0.12985192105746982, 'mean_env_wait_ms': 2.5040151965470656, 'mean_env_render_ms': 0.0}, 'num_faulty_episodes': 0, 'connector_metrics': {'ObsPreprocessorConnector_ms': 1.051938533782959, 'StateBufferConnector_ms': 0.009810924530029297, 'ViewRequirementAgentConnector_ms': 0.46378374099731445}, 'num_healthy_workers': 0, 'num_in_flight_async_reqs': 0, 'num_remote_worker_restarts': 0, 'num_agent_steps_sampled': 512, 'num_agent_steps_trained': 0, 'num_env_steps_sampled': 512, 'num_env_steps_trained': 0, 'num_env_steps_sampled_this_iter': 128, 'num_env_steps_trained_this_iter': 0, 'num_env_steps_sampled_throughput_per_sec': 57.18780249848288, 'num_env_steps_trained_throughput_per_sec': 0.0, 'num_steps_trained_this_iter': 0, 'agent_timesteps_total': 512, 'timers': {'training_iteration_time_ms': 1392.194, 'sample_time_ms': 995.05, 'synch_weights_time_ms': 1.92}, 'counters': {'num_env_steps_sampled': 512, 'num_env_steps_trained': 0, 'num_agent_steps_sampled': 512, 'num_agent_steps_trained': 0}, 'perf': {'cpu_util_percent': 54.06666666666666, 'ram_util_percent': 53.53333333333334}},\n", - " path='/home/cade/ray_results/PPO_2024-01-05_12-46-39/PPO_PrimaiteRayEnv_7899c_00000_0_2024-01-05_12-46-40',\n", - " filesystem='local',\n", - " checkpoint=Checkpoint(filesystem=local, path=/home/cade/ray_results/PPO_2024-01-05_12-46-39/PPO_PrimaiteRayEnv_7899c_00000_0_2024-01-05_12-46-40/checkpoint_000000)\n", - " )\n", - "]>" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "tune.Tuner(\n", " \"PPO\",\n", From 33f72db1cb61021bc202d19dbbf0ffcdb8230dfc Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 5 Jan 2024 14:03:06 +0000 Subject: [PATCH 05/49] Updated VERSION --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 6da222f2..9414e127 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b3dev +3.0.0b4 From ddf7fbf88bce2f95448053eb2152cb5c3d6875c1 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 5 Jan 2024 15:27:10 +0000 Subject: [PATCH 06/49] #2139 - Included a test that tests services over multi-hop routing. Added some PR suggestions around logging. --- .../network/hardware/nodes/router.py | 8 ++-- .../network/hardware/nodes/switch.py | 2 +- .../integration_tests/network/test_routing.py | 40 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 1e3d8022..172cc711 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -590,8 +590,8 @@ class RouterARPCache(ARPCache): self.send_arp_request(route.next_hop_ip_address, ignore_networks=[frame.ip.src_ip_address]) return self.process_arp_packet(from_nic, frame, route_table, is_reattempt=True) else: - pass - # TODO: destination unavailable/No ARP netry found + self.sys_log.info("Ignoring ARP request as destination unavailable/No ARP entry found") + return else: arp_reply = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_reply, from_nic) @@ -797,7 +797,7 @@ class Router(Node): return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) if not nic.enabled: - # TODO: Add sys_log here + self.sys_log.info(f"Frame dropped as NIC {nic} is not enabled") return if frame.ip.dst_ip_address in nic.ip_network: @@ -829,7 +829,7 @@ class Router(Node): return self.process_frame(frame=frame, from_nic=from_nic, re_attempt=True) if not nic.enabled: - # TODO: Add sys_log here + self.sys_log.info(f"Frame dropped as NIC {nic} is not enabled") return from_port = self._get_port_of_nic(from_nic) diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py index ead857f2..b394bae0 100644 --- a/src/primaite/simulator/network/hardware/nodes/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -90,7 +90,7 @@ class Switch(Node): self._add_mac_table_entry(src_mac, incoming_port) outgoing_port = self.mac_address_table.get(dst_mac) - if outgoing_port and dst_mac != "ff:ff:ff:ff:ff:ff": + if outgoing_port and dst_mac.lower() != "ff:ff:ff:ff:ff:ff": outgoing_port.send_frame(frame) else: # If the destination MAC is not in the table, flood to all ports except incoming diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index 3f636eae..042debca 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -9,6 +9,8 @@ from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.router import ACLAction, Router from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.ntp.ntp_server import NTPServer @pytest.fixture(scope="function") @@ -143,3 +145,41 @@ def test_with_routes_can_ping(multi_hop_network): ) assert pc_a.ping(pc_b.ethernet_port[1].ip_address) + + +def test_routing_services(multi_hop_network): + pc_a = multi_hop_network.get_node_by_hostname("pc_a") + + pc_b = multi_hop_network.get_node_by_hostname("pc_b") + + pc_a.software_manager.install(NTPClient) + ntp_client = pc_a.software_manager.software["NTPClient"] + ntp_client.start() + + pc_b.software_manager.install(NTPServer) + pc_b.software_manager.software["NTPServer"].start() + + ntp_client.configure(ntp_server_ip_address=pc_b.ethernet_port[1].ip_address) + + router_1: Router = multi_hop_network.get_node_by_hostname("router_1") # noqa + router_2: Router = multi_hop_network.get_node_by_hostname("router_2") # noqa + + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=21) + router_2.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.NTP, dst_port=Port.NTP, position=21) + + assert ntp_client.time is None + ntp_client.request_time() + assert ntp_client.time is None + + # Configure Route from Router 1 to PC B subnet + router_1.route_table.add_route( + address="192.168.2.0", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.2" + ) + + # Configure Route from Router 2 to PC A subnet + router_2.route_table.add_route( + address="192.168.0.2", subnet_mask="255.255.255.0", next_hop_ip_address="192.168.1.1" + ) + + ntp_client.request_time() + assert ntp_client.time is not None From d2d628b67653fff4339d90ec72f88ef3cf693e6e Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 5 Jan 2024 22:11:37 +0000 Subject: [PATCH 07/49] #2139 - Fixed unicast and broadcast functionality properly --- CHANGELOG.md | 8 + .../simulator/network/hardware/base.py | 27 ++- .../network/hardware/nodes/router.py | 45 ++++- .../simulator/system/core/session_manager.py | 83 +++++--- .../simulator/system/core/software_manager.py | 22 ++- src/primaite/simulator/system/software.py | 21 +- .../network/test_broadcast.py | 180 ++++++++++++++++++ 7 files changed, 341 insertions(+), 45 deletions(-) create mode 100644 tests/integration_tests/network/test_broadcast.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 96634b28..60961802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,10 +38,18 @@ SessionManager. - HTTP Services: `WebBrowser` to simulate a web client and `WebServer` - Fixed an issue where the services were still able to run even though the node the service is installed on is turned off - NTP Services: `NTPClient` and `NTPServer` +- **RouterNIC Class**: Introduced a new class `RouterNIC`, extending the standard `NIC` functionality. This class is specifically designed for router operations, optimizing the processing and routing of network traffic. + - **Custom Layer-3 Processing**: The `RouterNIC` class includes custom handling for network frames, bypassing standard Node NIC's Layer 3 broadcast/unicast checks. This allows for more efficient routing behavior in network scenarios where router-specific frame processing is required. + - **Enhanced Frame Reception**: The `receive_frame` method in `RouterNIC` is tailored to handle frames based on Layer 2 (Ethernet) checks, focusing on MAC address-based routing and broadcast frame acceptance. + ### Changed - Integrated the RouteTable into the Routers frame processing. - Frames are now dropped when their TTL reaches 0 +- **NIC Functionality Update**: Updated the Network Interface Card (`NIC`) functionality to support Layer 3 (L3) broadcasts. + - **Layer 3 Broadcast Handling**: Enhanced the existing `NIC` classes to correctly process and handle Layer 3 broadcasts. This update allows devices using standard NICs to effectively participate in network activities that involve L3 broadcasting. + - **Improved Frame Reception Logic**: The `receive_frame` method of the `NIC` class has been updated to include additional checks and handling for L3 broadcasts, ensuring proper frame processing in a wider range of network scenarios. + ### Removed - Removed legacy simulation modules: `acl`, `common`, `environment`, `links`, `nodes`, `pol` diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index c27378a8..7e6e0a3b 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -274,11 +274,20 @@ class NIC(SimComponent): def receive_frame(self, frame: Frame) -> bool: """ - Receive a network frame from the connected link if the NIC is enabled. + Receive a network frame from the connected link, processing it if the NIC is enabled. - The Frame is passed to the Node. + This method decrements the Time To Live (TTL) of the frame, captures it using PCAP (Packet Capture), and checks + if the frame is either a broadcast or destined for this NIC. If the frame is acceptable, it is passed to the + connected node. The method also handles the discarding of frames with TTL expired and logs this event. - :param frame: The network frame being received. + The frame's reception is based on various conditions: + - If the NIC is disabled, the frame is not processed. + - If the TTL of the frame reaches zero after decrement, it is discarded and logged. + - If the frame is a broadcast or its destination MAC/IP address matches this NIC's, it is accepted. + - All other frames are dropped and logged or printed to the console. + + :param frame: The network frame being received. This should be an instance of the Frame class. + :return: Returns True if the frame is processed and passed to the node, False otherwise. """ if self.enabled: frame.decrement_ttl() @@ -288,7 +297,17 @@ class NIC(SimComponent): frame.set_received_timestamp() self.pcap.capture(frame) # If this destination or is broadcast - if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + accept_frame = False + + # Check if it's a broadcast: + if frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + if frame.ip.dst_ip_address in {self.ip_address, self.ip_network.broadcast_address}: + accept_frame = True + else: + if frame.ethernet.dst_mac_addr == self.mac_address: + accept_frame = True + + if accept_frame: self._connected_node.receive_frame(frame=frame, from_nic=self) return True return False diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 172cc711..473712ea 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -690,6 +690,47 @@ class RouterICMP(ICMP): self.router.process_frame(frame, from_nic) +class RouterNIC(NIC): + """ + A Router-specific Network Interface Card (NIC) that extends the standard NIC functionality. + + This class overrides the standard Node NIC's Layer 3 (L3) broadcast/unicast checks. It is designed + to handle network frames in a manner specific to routers, allowing them to efficiently process + and route network traffic. + """ + + def receive_frame(self, frame: Frame) -> bool: + """ + Receive and process a network frame from the connected link, provided the NIC is enabled. + + This method is tailored for router behavior. It decrements the frame's Time To Live (TTL), checks for TTL + expiration, and captures the frame using PCAP (Packet Capture). The frame is accepted if it is destined for + this NIC's MAC address or is a broadcast frame. + + Key Differences from Standard NIC: + - Does not perform Layer 3 (IP-based) broadcast checks. + - Only checks for Layer 2 (Ethernet) destination MAC address and broadcast frames. + + :param frame: The network frame being received. This should be an instance of the Frame class. + :return: Returns True if the frame is processed and passed to the connected node, False otherwise. + """ + if self.enabled: + frame.decrement_ttl() + if frame.ip and frame.ip.ttl < 1: + self._connected_node.sys_log.info("Frame discarded as TTL limit reached") + return False + frame.set_received_timestamp() + self.pcap.capture(frame) + # If this destination or is broadcast + if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + self._connected_node.receive_frame(frame=frame, from_nic=self) + return True + return False + + def __str__(self) -> str: + return f"{self.mac_address}/{self.ip_address}" + + class Router(Node): """ A class to represent a network router node. @@ -700,7 +741,7 @@ class Router(Node): """ num_ports: int - ethernet_ports: Dict[int, NIC] = {} + ethernet_ports: Dict[int, RouterNIC] = {} acl: AccessControlList route_table: RouteTable arp: RouterARPCache @@ -719,7 +760,7 @@ class Router(Node): kwargs["icmp"] = RouterICMP(sys_log=kwargs.get("sys_log"), arp_cache=kwargs.get("arp"), router=self) super().__init__(hostname=hostname, num_ports=num_ports, **kwargs) for i in range(1, self.num_ports + 1): - nic = NIC(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0") + nic = RouterNIC(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0") self.connect_nic(nic) self.ethernet_ports[i] = nic diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index 8658f155..a95846a3 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ipaddress import IPv4Address +from ipaddress import IPv4Address, IPv4Network from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING, Union from prettytable import MARKDOWN, PrettyTable @@ -141,41 +141,76 @@ class SessionManager: def receive_payload_from_software_manager( self, payload: Any, - dst_ip_address: Optional[IPv4Address] = None, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, dst_port: Optional[Port] = None, session_id: Optional[str] = None, is_reattempt: bool = False, ) -> Union[Any, None]: """ - Receive a payload from the SoftwareManager. + Receive a payload from the SoftwareManager and send it to the appropriate NIC for transmission. - If no session_id, a Session is established. Once established, the payload is sent to ``send_payload_to_nic``. + This method supports both unicast and Layer 3 broadcast transmissions. If `dst_ip_address` is an + IPv4Network, a broadcast is initiated. For unicast, the destination MAC address is resolved via ARP. + A new session is established if `session_id` is not provided, and an existing session is used otherwise. :param payload: The payload to be sent. - :param session_id: The Session ID the payload is to originate from. Optional. If None, one will be created. + :param dst_ip_address: The destination IP address or network for broadcast. Optional. + :param dst_port: The destination port for the TCP packet. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :param is_reattempt: Flag to indicate if this is a reattempt after an ARP request. Default is False. + :return: The outcome of sending the frame, or None if sending was unsuccessful. """ + is_broadcast = False + outbound_nic = None + dst_mac_address = None + + # Use session details if session_id is provided if session_id: session = self.sessions_by_uuid[session_id] - dst_ip_address = self.sessions_by_uuid[session_id].with_ip_address - dst_port = self.sessions_by_uuid[session_id].dst_port + dst_ip_address = session.with_ip_address + dst_port = session.dst_port - dst_mac_address = self.arp_cache.get_arp_cache_mac_address(dst_ip_address) + # Determine if the payload is for broadcast or unicast - if dst_mac_address: - outbound_nic = self.arp_cache.get_arp_cache_nic(dst_ip_address) + # Handle broadcast transmission + if isinstance(dst_ip_address, IPv4Network): + is_broadcast = True + dst_ip_address = dst_ip_address.broadcast_address + if dst_ip_address: + # Find a suitable NIC for the broadcast + for nic in self.arp_cache.nics.values(): + if dst_ip_address in nic.ip_network and nic.enabled: + dst_mac_address = "ff:ff:ff:ff:ff:ff" + outbound_nic = nic else: - if not is_reattempt: - self.arp_cache.send_arp_request(dst_ip_address) - return self.receive_payload_from_software_manager( - payload=payload, - dst_ip_address=dst_ip_address, - dst_port=dst_port, - session_id=session_id, - is_reattempt=True, - ) - else: - return + # Resolve MAC address for unicast transmission + dst_mac_address = self.arp_cache.get_arp_cache_mac_address(dst_ip_address) + # Resolve outbound NIC for unicast transmission + if dst_mac_address: + outbound_nic = self.arp_cache.get_arp_cache_nic(dst_ip_address) + + # If MAC address not found, initiate ARP request + else: + if not is_reattempt: + self.arp_cache.send_arp_request(dst_ip_address) + # Reattempt payload transmission after ARP request + return self.receive_payload_from_software_manager( + payload=payload, + dst_ip_address=dst_ip_address, + dst_port=dst_port, + session_id=session_id, + is_reattempt=True, + ) + else: + # Return None if reattempt fails + return + + # Check if outbound NIC and destination MAC address are resolved + if not outbound_nic or not dst_mac_address: + return False + + # Construct the frame for transmission frame = Frame( ethernet=EthernetHeader(src_mac_addr=outbound_nic.mac_address, dst_mac_addr=dst_mac_address), ip=IPPacket( @@ -189,15 +224,17 @@ class SessionManager: payload=payload, ) - if not session_id: + # Manage session for unicast transmission + if not (is_broadcast and session_id): session_key = self._get_session_key(frame, inbound_frame=False) session = self.sessions_by_key.get(session_key) if not session: - # Create new session + # Create a new session if it doesn't exist session = Session.from_session_key(session_key) self.sessions_by_key[session_key] = session self.sessions_by_uuid[session.uuid] = session + # Send the frame through the NIC return outbound_nic.send_frame(frame) def receive_frame(self, frame: Frame): diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index 21a121c1..95948a1e 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -1,4 +1,4 @@ -from ipaddress import IPv4Address +from ipaddress import IPv4Address, IPv4Network from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union from prettytable import MARKDOWN, PrettyTable @@ -130,20 +130,28 @@ class SoftwareManager: def send_payload_to_session_manager( self, payload: Any, - dest_ip_address: Optional[IPv4Address] = None, + dest_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, dest_port: Optional[Port] = None, session_id: Optional[str] = None, ) -> bool: """ - Send a payload to the SessionManager. + Sends a payload to the SessionManager for network transmission. + + This method is responsible for initiating the process of sending network payloads. It supports both + unicast and Layer 3 broadcast transmissions. For broadcasts, the destination IP should be specified + as an IPv4Network. :param payload: The payload to be sent. - :param dest_ip_address: The ip address of the payload destination. - :param dest_port: The port of the payload destination. - :param session_id: The Session ID the payload is to originate from. Optional. + :param dest_ip_address: The IP address or network (for broadcasts) of the payload destination. + :param dest_port: The destination port for the payload. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :return: True if the payload was successfully sent, False otherwise. """ return self.session_manager.receive_payload_from_software_manager( - payload=payload, dst_ip_address=dest_ip_address, dst_port=dest_port, session_id=session_id + payload=payload, + dst_ip_address=dest_ip_address, + dst_port=dest_port, + session_id=session_id, ) def receive_payload_from_session_manager(self, payload: Any, port: Port, protocol: IPProtocol, session_id: str): diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index b393ffd8..d8aed2fb 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -2,8 +2,8 @@ import copy from abc import abstractmethod from datetime import datetime from enum import Enum -from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, Optional, Union from primaite.simulator.core import _LOGGER, RequestManager, RequestType, SimComponent from primaite.simulator.file_system.file_system import FileSystem, Folder @@ -317,19 +317,22 @@ class IOSoftware(Software): self, payload: Any, session_id: Optional[str] = None, - dest_ip_address: Optional[IPv4Address] = None, + dest_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, dest_port: Optional[Port] = None, **kwargs, ) -> bool: """ - Sends a payload to the SessionManager. + Sends a payload to the SessionManager for network transmission. + + This method is responsible for initiating the process of sending network payloads. It supports both + unicast and Layer 3 broadcast transmissions. For broadcasts, the destination IP should be specified + as an IPv4Network. It delegates the actual sending process to the SoftwareManager. :param payload: The payload to be sent. - :param dest_ip_address: The ip address of the payload destination. - :param dest_port: The port of the payload destination. - :param session_id: The Session ID the payload is to originate from. Optional. - - :return: True if successful, False otherwise. + :param dest_ip_address: The IP address or network (for broadcasts) of the payload destination. + :param dest_port: The destination port for the payload. Optional. + :param session_id: The Session ID from which the payload originates. Optional. + :return: True if the payload was successfully sent, False otherwise. """ if not self._can_perform_action(): return False diff --git a/tests/integration_tests/network/test_broadcast.py b/tests/integration_tests/network/test_broadcast.py new file mode 100644 index 00000000..b9ecb28b --- /dev/null +++ b/tests/integration_tests/network/test_broadcast.py @@ -0,0 +1,180 @@ +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, List, Tuple + +import pytest + +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.server import Server +from primaite.simulator.network.hardware.nodes.switch import Switch +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.application import Application +from primaite.simulator.system.services.service import Service + + +class BroadcastService(Service): + """A service for sending broadcast and unicast messages over a network.""" + + def __init__(self, **kwargs): + # Set default service properties for broadcasting + kwargs["name"] = "BroadcastService" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + # Implement state description for the service + pass + + def unicast(self, ip_address: IPv4Address): + # Send a unicast payload to a specific IP address + super().send( + payload="unicast", + dest_ip_address=ip_address, + dest_port=Port.HTTP, + ) + + def broadcast(self, ip_network: IPv4Network): + # Send a broadcast payload to an entire IP network + super().send( + payload="broadcast", + dest_ip_address=ip_network, + dest_port=Port.HTTP, + ) + + +class BroadcastClient(Application): + """A client application to receive broadcast and unicast messages.""" + + payloads_received: List = [] + + def __init__(self, **kwargs): + # Set default client properties + kwargs["name"] = "BroadcastClient" + kwargs["port"] = Port.HTTP + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + # Implement state description for the application + pass + + def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + # Append received payloads to the list and print a message + self.payloads_received.append(payload) + print(f"Payload: {payload} received on node {self.sys_log.hostname}") + + +@pytest.fixture(scope="function") +def broadcast_network() -> Network: + network = Network() + + client_1 = Computer( + hostname="client_1", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client_1.power_on() + client_1.software_manager.install(BroadcastClient) + application_1 = client_1.software_manager.software["BroadcastClient"] + application_1.run() + + client_2 = Computer( + hostname="client_2", + ip_address="192.168.1.3", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + client_2.power_on() + client_2.software_manager.install(BroadcastClient) + application_2 = client_2.software_manager.software["BroadcastClient"] + application_2.run() + + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.1", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + start_up_duration=0, + ) + server_1.power_on() + + server_1.software_manager.install(BroadcastService) + service: BroadcastService = server_1.software_manager.software["BroadcastService"] + service.start() + + switch_1 = Switch(hostname="switch_1", num_ports=6, start_up_duration=0) + switch_1.power_on() + + network.connect(endpoint_a=client_1.ethernet_port[1], endpoint_b=switch_1.switch_ports[1]) + network.connect(endpoint_a=client_2.ethernet_port[1], endpoint_b=switch_1.switch_ports[2]) + network.connect(endpoint_a=server_1.ethernet_port[1], endpoint_b=switch_1.switch_ports[3]) + + return network + + +@pytest.fixture(scope="function") +def broadcast_service_and_clients(broadcast_network) -> Tuple[BroadcastService, BroadcastClient, BroadcastClient]: + client_1: BroadcastClient = broadcast_network.get_node_by_hostname("client_1").software_manager.software[ + "BroadcastClient" + ] + client_2: BroadcastClient = broadcast_network.get_node_by_hostname("client_2").software_manager.software[ + "BroadcastClient" + ] + service: BroadcastService = broadcast_network.get_node_by_hostname("server_1").software_manager.software[ + "BroadcastService" + ] + + return service, client_1, client_2 + + +def test_broadcast_correct_subnet(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.broadcast(IPv4Network("192.168.1.0/24")) + + assert client_1.payloads_received == ["broadcast"] + assert client_2.payloads_received == ["broadcast"] + + +def test_broadcast_incorrect_subnet(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.broadcast(IPv4Network("192.168.2.0/24")) + + assert not client_1.payloads_received + assert not client_2.payloads_received + + +def test_unicast_correct_address(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.unicast(IPv4Address("192.168.1.2")) + + assert client_1.payloads_received == ["unicast"] + assert not client_2.payloads_received + + +def test_unicast_incorrect_address(broadcast_service_and_clients): + service, client_1, client_2 = broadcast_service_and_clients + + assert not client_1.payloads_received + assert not client_2.payloads_received + + service.unicast(IPv4Address("192.168.2.2")) + + assert not client_1.payloads_received + assert not client_2.payloads_received From 59d1a6668e21972b4d162e420794e9c595e05699 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 5 Jan 2024 22:19:54 +0000 Subject: [PATCH 08/49] #2139 - Updated the CHANGELOG.md with broadcast entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60961802..e82a1038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ SessionManager. - **RouterNIC Class**: Introduced a new class `RouterNIC`, extending the standard `NIC` functionality. This class is specifically designed for router operations, optimizing the processing and routing of network traffic. - **Custom Layer-3 Processing**: The `RouterNIC` class includes custom handling for network frames, bypassing standard Node NIC's Layer 3 broadcast/unicast checks. This allows for more efficient routing behavior in network scenarios where router-specific frame processing is required. - **Enhanced Frame Reception**: The `receive_frame` method in `RouterNIC` is tailored to handle frames based on Layer 2 (Ethernet) checks, focusing on MAC address-based routing and broadcast frame acceptance. - +- **Subnet-Wide Broadcasting for Services and Applications**: Implemented the ability for services and applications to conduct broadcasts across an entire IPv4 subnet within the network simulation framework. ### Changed - Integrated the RouteTable into the Routers frame processing. From 8b43f6abe36952bec7c678e4accf3e48f1defa26 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 8 Jan 2024 10:30:38 +0000 Subject: [PATCH 09/49] #2139 - updated docstring in send_arp_request function in base.py --- src/primaite/simulator/network/hardware/base.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 7e6e0a3b..54fd1238 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -703,9 +703,15 @@ class ARPCache: Perform a standard ARP request for a given target IP address. Broadcasts the request through all enabled NICs to determine the MAC address corresponding to the target IP - address. + address. This method can be configured to ignore specific networks when sending out ARP requests, + which is useful in environments where certain addresses should not be queried. :param target_ip_address: The target IP address to send an ARP request for. + :param ignore_networks: An optional list of IPv4 addresses representing networks to be excluded from the ARP + request broadcast. Each address in this list indicates a network which will not be queried during the ARP + request process. This is particularly useful in complex network environments where traffic should be + minimized or controlled to specific subnets. It is mainly used by the router to prevent ARP requests being + sent back to their source. """ for nic in self.nics.values(): use_nic = True From 5d89820a159a17b2bdc614f5898c5728a32174ec Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 12:38:01 +0000 Subject: [PATCH 10/49] Apply PR review suggestions --- src/primaite/game/agent/interface.py | 3 ++- src/primaite/session/environment.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/game/agent/interface.py b/src/primaite/game/agent/interface.py index 8657fc45..01df33de 100644 --- a/src/primaite/game/agent/interface.py +++ b/src/primaite/game/agent/interface.py @@ -45,6 +45,7 @@ class AgentSettings(BaseModel): start_settings: Optional[AgentStartSettings] = None "Configuration for when an agent begins performing it's actions" flatten_obs: bool = True + "Whether to flatten the observation space before passing it to the agent. True by default." @classmethod def from_config(cls, config: Optional[Dict]) -> "AgentSettings": @@ -176,7 +177,7 @@ class ProxyAgent(AbstractAgent): reward_function=reward_function, ) self.most_recent_action: ActType - self.flatten_obs: bool = agent_settings.flatten_obs + self.flatten_obs: bool = agent_settings.flatten_obs if agent_settings else False def get_action(self, obs: ObsType, reward: float = 0.0) -> Tuple[str, Dict]: """ diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index 36ab3f58..6701f183 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -23,7 +23,6 @@ class PrimaiteGymEnv(gymnasium.Env): super().__init__() self.game: "PrimaiteGame" = game self.agent: ProxyAgent = self.game.rl_agents[0] - self.flatten_obs: bool = False def step(self, action: ActType) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict[str, Any]]: """Perform a step in the environment.""" From 82cd8780f9467c760dd3cacc8fdf79dcb21ec035 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:03:10 +0000 Subject: [PATCH 11/49] Align Software health state enum with CAOS --- src/primaite/simulator/system/software.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 048e6fec..562c9e0d 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -36,12 +36,12 @@ class SoftwareHealthState(Enum): "Unused state." 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 + PATCHING = 2 "The software is undergoing patching or updates." + COMPROMISED = 3 + "The software's security has been compromised." + OVERWHELMED = 4 + "he software is overwhelmed and not functioning properly." class SoftwareCriticality(Enum): From 716bd626a5e67715aa9f5360208c5457880250f2 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:29:23 +0000 Subject: [PATCH 12/49] Hide software health state until scan. --- src/primaite/game/agent/observations.py | 5 ++++- src/primaite/simulator/system/software.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index 767514b4..eecf4163 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -140,7 +140,10 @@ class ServiceObservation(AbstractObservation): service_state = access_from_nested_dict(state, self.where) if service_state is NOT_PRESENT_IN_STATE: return self.default_observation - return {"operating_status": service_state["operating_state"], "health_status": service_state["health_state"]} + return { + "operating_status": service_state["operating_state"], + "health_status": service_state["health_state_visible"], + } @property def space(self) -> spaces.Space: diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 048e6fec..d7c1fa4e 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -145,8 +145,8 @@ class Software(SimComponent): state = super().describe_state() state.update( { - "health_state": self.health_state_actual.value, - "health_state_red_view": self.health_state_visible.value, + "health_state_actual": self.health_state_actual.value, + "health_state_visible": self.health_state_visible.value, "criticality": self.criticality.value, "patching_count": self.patching_count, "scanning_count": self.scanning_count, From f2a496893cc49cbe90b3b11fd67443f030d9c269 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:33:24 +0000 Subject: [PATCH 13/49] Bump VERSION --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 9414e127..52f460a5 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b4 +3.0.0b5dev From daa34385e550dc43e984706faa2df024e74d1ad7 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 14:53:15 +0000 Subject: [PATCH 14/49] Add agent reset for episodes --- src/primaite/game/agent/data_manipulation_bot.py | 9 +++++++++ src/primaite/game/agent/interface.py | 4 ++++ src/primaite/game/game.py | 1 + 3 files changed, 14 insertions(+) diff --git a/src/primaite/game/agent/data_manipulation_bot.py b/src/primaite/game/agent/data_manipulation_bot.py index 8237ce06..3b558087 100644 --- a/src/primaite/game/agent/data_manipulation_bot.py +++ b/src/primaite/game/agent/data_manipulation_bot.py @@ -15,6 +15,7 @@ class DataManipulationAgent(AbstractScriptedAgent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + print("red start step: ", self.agent_settings.start_settings.start_step) self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) @@ -27,6 +28,7 @@ class DataManipulationAgent(AbstractScriptedAgent): -self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance ) self.next_execution_timestep = timestep + random_timestep_increment + print("next execution red step: ", self.next_execution_timestep) def get_action(self, obs: ObsType, reward: float = None) -> Tuple[str, Dict]: """Randomly sample an action from the action space. @@ -41,8 +43,15 @@ class DataManipulationAgent(AbstractScriptedAgent): current_timestep = self.action_manager.game.step_counter if current_timestep < self.next_execution_timestep: + print("red agent doing nothing") return "DONOTHING", {"dummy": 0} self._set_next_execution_timestep(current_timestep + self.agent_settings.start_settings.frequency) + print("red agent doing an execute") return "NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0} + + def reset_agent_for_episode(self) -> None: + """Set the next execution timestep when the episode resets.""" + super().reset_agent_for_episode() + self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) diff --git a/src/primaite/game/agent/interface.py b/src/primaite/game/agent/interface.py index 8657fc45..8b6dd6d4 100644 --- a/src/primaite/game/agent/interface.py +++ b/src/primaite/game/agent/interface.py @@ -135,6 +135,10 @@ class AbstractAgent(ABC): request = self.action_manager.form_request(action_identifier=action, action_options=options) return request + def reset_agent_for_episode(self) -> None: + """Agent reset logic should go here.""" + pass + class AbstractScriptedAgent(AbstractAgent): """Base class for actors which generate their own behaviour.""" diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 586bca79..08098754 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -162,6 +162,7 @@ class PrimaiteGame: self.simulation.reset_component_for_episode(episode=self.episode_counter) for agent in self.agents: agent.reward_function.total_reward = 0.0 + agent.reset_agent_for_episode() def close(self) -> None: """Close the game, this will close the simulation.""" From 6fc4e156603b7cb81389765eac7ce4d8dabdcf1d Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 9 Jan 2024 15:18:31 +0000 Subject: [PATCH 15/49] #2151: remove changing of health_state_actual in actions and tests --- .../simulator/system/services/service.py | 14 +-- src/primaite/simulator/system/software.py | 6 +- tests/conftest.py | 2 +- .../_system/_services/test_services.py | 100 +++++++++++++++++- .../_system/_services/test_web_server.py | 7 +- 5 files changed, 110 insertions(+), 19 deletions(-) diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index d45ef3a6..1de52e92 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -43,9 +43,6 @@ class Service(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def _can_perform_action(self) -> bool: """ Checks if the service can perform actions. @@ -118,7 +115,6 @@ class Service(IOSoftware): if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Stopping service {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.UNUSED def start(self, **kwargs) -> None: """Start the service.""" @@ -129,42 +125,39 @@ class Service(IOSoftware): if self.operating_state == ServiceOperatingState.STOPPED: self.sys_log.info(f"Starting service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.health_state_actual = SoftwareHealthState.GOOD def pause(self) -> None: """Pause the service.""" if self.operating_state == ServiceOperatingState.RUNNING: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.PAUSED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def resume(self) -> None: """Resume paused service.""" if self.operating_state == ServiceOperatingState.PAUSED: self.sys_log.info(f"Resuming service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD def restart(self) -> None: """Restart running service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING - self.health_state_actual = SoftwareHealthState.OVERWHELMED self.restart_countdown = self.restart_duration def disable(self) -> None: """Disable the service.""" self.sys_log.info(f"Disabling Application {self.name}") self.operating_state = ServiceOperatingState.DISABLED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def enable(self) -> None: """Enable the disabled service.""" if self.operating_state == ServiceOperatingState.DISABLED: self.sys_log.info(f"Enabling Application {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def apply_timestep(self, timestep: int) -> None: """ @@ -181,5 +174,4 @@ class Service(IOSoftware): if self.restart_countdown <= 0: _LOGGER.debug(f"Restarting finished for service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD self.restart_countdown -= 1 diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 38e1f30b..f41a5a86 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -71,9 +71,9 @@ class Software(SimComponent): name: str "The name of the software." - health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_actual: SoftwareHealthState = SoftwareHealthState.UNUSED "The actual health state of the software." - health_state_visible: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_visible: SoftwareHealthState = SoftwareHealthState.UNUSED "The health state of the software visible to the red agent." criticality: SoftwareCriticality = SoftwareCriticality.LOWEST "The criticality level of the software." @@ -282,7 +282,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state is NodeOperatingState.OFF: + if self.software_manager and self.software_manager.node.operating_state == NodeOperatingState.OFF: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 1ab07dd8..37289674 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -167,7 +167,7 @@ def example_network() -> Network: -------------- -------------- | client_1 |----- ----| server_1 | -------------- | -------------- -------------- -------------- | -------------- - ------| switch_1 |------| router_1 |------| switch_2 |------ + ------| switch_2 |------| router_1 |------| switch_1 |------ -------------- | -------------- -------------- -------------- | -------------- | client_2 |---- ----| server_2 | -------------- -------------- diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py index 016cf011..2c0671d5 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py @@ -19,55 +19,149 @@ def test_scan(service): def test_start_service(service): assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD def test_stop_service(service): service.start() assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD service.stop() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.GOOD def test_pause_and_resume_service(service): assert service.operating_state == ServiceOperatingState.STOPPED service.resume() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD service.pause() assert service.operating_state == ServiceOperatingState.PAUSED + assert service.health_state_actual == SoftwareHealthState.GOOD service.resume() assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD def test_restart(service): assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.restart() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD service.restart() assert service.operating_state == ServiceOperatingState.RESTARTING + assert service.health_state_actual == SoftwareHealthState.GOOD timestep = 0 while service.operating_state == ServiceOperatingState.RESTARTING: service.apply_timestep(timestep) + assert service.health_state_actual == SoftwareHealthState.GOOD timestep += 1 assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.GOOD + + +def test_restart_compromised(service): + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + service.restart() + assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED + + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + # compromise the service + service.health_state_actual = SoftwareHealthState.COMPROMISED + + service.restart() + assert service.operating_state == ServiceOperatingState.RESTARTING + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + """ + Service should be compromised even after reset. + + Only way to remove compromised status is via patching. + """ + + timestep = 0 + while service.operating_state == ServiceOperatingState.RESTARTING: + service.apply_timestep(timestep) + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + timestep += 1 + + assert service.operating_state == ServiceOperatingState.RUNNING + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + +def test_compromised_service_remains_compromised(service): + """ + Tests that a compromised service stays compromised. + + The only way that the service can be uncompromised is by running patch. + """ + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.health_state_actual = SoftwareHealthState.COMPROMISED + + service.stop() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.start() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.disable() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.enable() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.pause() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + service.resume() + assert service.health_state_actual == SoftwareHealthState.COMPROMISED + + +def test_service_patching(service): + service.start() + assert service.health_state_actual == SoftwareHealthState.GOOD + + service.health_state_actual = SoftwareHealthState.COMPROMISED + + service.patch() + assert service.health_state_actual == SoftwareHealthState.PATCHING + + for i in range(service.patching_duration + 1): + service.apply_timestep(i) + + assert service.health_state_actual == SoftwareHealthState.GOOD def test_enable_disable(service): service.disable() assert service.operating_state == ServiceOperatingState.DISABLED + assert service.health_state_actual == SoftwareHealthState.UNUSED service.enable() assert service.operating_state == ServiceOperatingState.STOPPED + assert service.health_state_actual == SoftwareHealthState.UNUSED def test_overwhelm_service(service): @@ -76,13 +170,13 @@ def test_overwhelm_service(service): uuid = str(uuid4()) assert service.add_connection(connection_id=uuid) # should be true - assert service.health_state_actual is SoftwareHealthState.GOOD + assert service.health_state_actual == SoftwareHealthState.GOOD assert not service.add_connection(connection_id=uuid) # fails because connection already exists - assert service.health_state_actual is SoftwareHealthState.GOOD + assert service.health_state_actual == SoftwareHealthState.GOOD assert service.add_connection(connection_id=str(uuid4())) # succeed - assert service.health_state_actual is SoftwareHealthState.GOOD + assert service.health_state_actual == SoftwareHealthState.GOOD assert not service.add_connection(connection_id=str(uuid4())) # fail because at capacity assert service.health_state_actual is SoftwareHealthState.OVERWHELMED diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py index bbccda27..64277356 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py @@ -1,5 +1,6 @@ import pytest +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.protocols.http import ( HttpRequestMethod, @@ -15,7 +16,11 @@ from primaite.simulator.system.services.web_server.web_server import WebServer @pytest.fixture(scope="function") def web_server() -> Server: node = Server( - hostname="web_server", ip_address="192.168.1.10", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + hostname="web_server", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + operating_state=NodeOperatingState.ON, ) node.software_manager.install(software_class=WebServer) node.software_manager.software.get("WebServer").start() From a4d372d3ebe556fca63ebf6beb7f4d6f55c7fe60 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 9 Jan 2024 16:29:40 +0000 Subject: [PATCH 16/49] #2151: utilise set_health_state method instead of directly changing software states --- .../system/applications/application.py | 5 +--- .../services/database/database_service.py | 2 +- .../simulator/system/services/service.py | 2 +- src/primaite/simulator/system/software.py | 4 +-- .../test_dos_bot_and_server.py | 4 +-- .../_network/_hardware/test_node_actions.py | 6 ++-- .../_system/_services/test_services.py | 15 ++++------ .../_simulator/_system/test_software.py | 29 +++++++++++++++++++ 8 files changed, 45 insertions(+), 22 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/test_software.py diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 898e5917..09828b89 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Dict, Set from primaite import getLogger -from primaite.simulator.system.software import IOSoftware, SoftwareHealthState +from primaite.simulator.system.software import IOSoftware _LOGGER = getLogger(__name__) @@ -38,9 +38,6 @@ class Application(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def set_original_state(self): """Sets the original state.""" super().set_original_state() diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 6f333091..1df1db9e 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -196,7 +196,7 @@ class DatabaseService(Service): return {"status_code": 404, "data": False} elif query == "DELETE": if self.health_state_actual == SoftwareHealthState.GOOD: - self.health_state_actual = SoftwareHealthState.COMPROMISED + self.set_health_state(SoftwareHealthState.COMPROMISED) return { "status_code": 200, "type": "sql", diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 1de52e92..43c85471 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -127,7 +127,7 @@ class Service(IOSoftware): self.operating_state = ServiceOperatingState.RUNNING # set software health state to GOOD if initially set to UNUSED if self.health_state_actual == SoftwareHealthState.UNUSED: - self.health_state_actual = SoftwareHealthState.GOOD + self.set_health_state(SoftwareHealthState.GOOD) def pause(self) -> None: """Pause the service.""" diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index f41a5a86..4072fab1 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -303,13 +303,13 @@ class IOSoftware(Software): """ # if over or at capacity, set to overwhelmed if len(self._connections) >= self.max_sessions: - self.health_state_actual = SoftwareHealthState.OVERWHELMED + self.set_health_state(SoftwareHealthState.OVERWHELMED) self.sys_log.error(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.") return False else: # if service was previously overwhelmed, set to good because there is enough space for connections if self.health_state_actual == SoftwareHealthState.OVERWHELMED: - self.health_state_actual = SoftwareHealthState.GOOD + self.set_health_state(SoftwareHealthState.GOOD) # check that connection already doesn't exist if not self._connections.get(connection_id): diff --git a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py index 85028d75..fb768127 100644 --- a/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py +++ b/tests/integration_tests/system/red_applications/test_dos_bot_and_server.py @@ -90,7 +90,7 @@ def test_repeating_dos_attack(dos_bot_and_db_server): assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED db_server_service.clear_connections() - db_server_service.health_state_actual = SoftwareHealthState.GOOD + db_server_service.set_health_state(SoftwareHealthState.GOOD) assert len(db_server_service.connections) == 0 computer.apply_timestep(timestep=1) @@ -121,7 +121,7 @@ def test_non_repeating_dos_attack(dos_bot_and_db_server): assert db_server_service.health_state_actual is SoftwareHealthState.OVERWHELMED db_server_service.clear_connections() - db_server_service.health_state_actual = SoftwareHealthState.GOOD + db_server_service.set_health_state(SoftwareHealthState.GOOD) assert len(db_server_service.connections) == 0 computer.apply_timestep(timestep=1) diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py index 5fe5df16..b6f7a86d 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_node_actions.py @@ -53,12 +53,12 @@ def test_node_os_scan(node, service, application): # TODO implement processes # add services to node - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) node.install_service(service=service) assert service.health_state_visible == SoftwareHealthState.UNUSED # add application to node - application.health_state_actual = SoftwareHealthState.COMPROMISED + application.set_health_state(SoftwareHealthState.COMPROMISED) node.install_application(application=application) assert application.health_state_visible == SoftwareHealthState.UNUSED @@ -101,7 +101,7 @@ def test_node_red_scan(node, service, application): assert service.revealed_to_red is False # add application to node - application.health_state_actual = SoftwareHealthState.COMPROMISED + application.set_health_state(SoftwareHealthState.COMPROMISED) node.install_application(application=application) assert application.revealed_to_red is False diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py index 2c0671d5..ac36c660 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_services.py @@ -57,12 +57,15 @@ def test_restart(service): assert service.operating_state == ServiceOperatingState.STOPPED assert service.health_state_actual == SoftwareHealthState.UNUSED service.restart() + # Service is STOPPED. Restart will only work if the service was PAUSED or RUNNING assert service.operating_state == ServiceOperatingState.STOPPED assert service.health_state_actual == SoftwareHealthState.UNUSED service.start() + assert service.operating_state == ServiceOperatingState.RUNNING assert service.health_state_actual == SoftwareHealthState.GOOD service.restart() + # Service is RUNNING. Restart should work assert service.operating_state == ServiceOperatingState.RESTARTING assert service.health_state_actual == SoftwareHealthState.GOOD @@ -77,17 +80,11 @@ def test_restart(service): def test_restart_compromised(service): - assert service.operating_state == ServiceOperatingState.STOPPED - assert service.health_state_actual == SoftwareHealthState.UNUSED - service.restart() - assert service.operating_state == ServiceOperatingState.STOPPED - assert service.health_state_actual == SoftwareHealthState.UNUSED - service.start() assert service.health_state_actual == SoftwareHealthState.GOOD # compromise the service - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) service.restart() assert service.operating_state == ServiceOperatingState.RESTARTING @@ -118,7 +115,7 @@ def test_compromised_service_remains_compromised(service): service.start() assert service.health_state_actual == SoftwareHealthState.GOOD - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) service.stop() assert service.health_state_actual == SoftwareHealthState.COMPROMISED @@ -143,7 +140,7 @@ def test_service_patching(service): service.start() assert service.health_state_actual == SoftwareHealthState.GOOD - service.health_state_actual = SoftwareHealthState.COMPROMISED + service.set_health_state(SoftwareHealthState.COMPROMISED) service.patch() assert service.health_state_actual == SoftwareHealthState.PATCHING diff --git a/tests/unit_tests/_primaite/_simulator/_system/test_software.py b/tests/unit_tests/_primaite/_simulator/_system/test_software.py new file mode 100644 index 00000000..e77cd895 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/test_software.py @@ -0,0 +1,29 @@ +from typing import Dict + +import pytest + +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.software import Software, SoftwareHealthState + + +class TestSoftware(Software): + def describe_state(self) -> Dict: + pass + + +@pytest.fixture(scope="function") +def software(file_system): + return TestSoftware( + name="TestSoftware", port=Port.ARP, file_system=file_system, sys_log=SysLog(hostname="test_service") + ) + + +def test_software_creation(software): + assert software is not None + + +def test_software_set_health_state(software): + assert software.health_state_actual == SoftwareHealthState.UNUSED + software.set_health_state(SoftwareHealthState.GOOD) + assert software.health_state_actual == SoftwareHealthState.GOOD From e73783f6fa2dc03c5c3274f923ae42460690d561 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 9 Jan 2024 17:10:12 +0000 Subject: [PATCH 17/49] Fixed issue where data manipulation was always executing --- .../services/red_services/data_manipulation_bot.py | 10 ++++++++-- src/primaite/simulator/system/software.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py index 44a56cf1..fcd9a3cc 100644 --- a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py +++ b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py @@ -73,7 +73,7 @@ class DataManipulationBot(DatabaseClient): def _init_request_manager(self) -> RequestManager: rm = super()._init_request_manager() - rm.add_request(name="execute", request_type=RequestType(func=lambda request, context: self.run())) + rm.add_request(name="execute", request_type=RequestType(func=lambda request, context: self.attack())) return rm @@ -169,6 +169,12 @@ class DataManipulationBot(DatabaseClient): Calls the parent classes execute method before starting the application loop. """ super().run() + + def attack(self): + """Perform the attack steps after opening the application.""" + if not self._can_perform_action(): + _LOGGER.debug("Data manipulation application attempted to execute but it cannot perform actions right now.") + self.run() self._application_loop() def _application_loop(self): @@ -199,4 +205,4 @@ class DataManipulationBot(DatabaseClient): :param timestep: The timestep value to update the bot's state. """ - self._application_loop() + pass diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 048e6fec..ca667f46 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -278,7 +278,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state is NodeOperatingState.OFF: + if self.software_manager and self.software_manager.node.operating_state is not NodeOperatingState.ON: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True From b7cc940e9dd1485b4aada9d0f208bb17b3c4bee4 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 09:07:51 +0000 Subject: [PATCH 18/49] Remove temporary print statements --- src/primaite/game/agent/data_manipulation_bot.py | 5 ----- src/primaite/game/agent/rewards.py | 1 - 2 files changed, 6 deletions(-) diff --git a/src/primaite/game/agent/data_manipulation_bot.py b/src/primaite/game/agent/data_manipulation_bot.py index 3b558087..7ad45518 100644 --- a/src/primaite/game/agent/data_manipulation_bot.py +++ b/src/primaite/game/agent/data_manipulation_bot.py @@ -15,8 +15,6 @@ class DataManipulationAgent(AbstractScriptedAgent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - print("red start step: ", self.agent_settings.start_settings.start_step) - self._set_next_execution_timestep(self.agent_settings.start_settings.start_step) def _set_next_execution_timestep(self, timestep: int) -> None: @@ -28,7 +26,6 @@ class DataManipulationAgent(AbstractScriptedAgent): -self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance ) self.next_execution_timestep = timestep + random_timestep_increment - print("next execution red step: ", self.next_execution_timestep) def get_action(self, obs: ObsType, reward: float = None) -> Tuple[str, Dict]: """Randomly sample an action from the action space. @@ -43,12 +40,10 @@ class DataManipulationAgent(AbstractScriptedAgent): current_timestep = self.action_manager.game.step_counter if current_timestep < self.next_execution_timestep: - print("red agent doing nothing") return "DONOTHING", {"dummy": 0} self._set_next_execution_timestep(current_timestep + self.agent_settings.start_settings.frequency) - print("red agent doing an execute") return "NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0} def reset_agent_for_episode(self) -> None: diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 9b3dfb80..cb8f8cb1 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -181,7 +181,6 @@ class WebServer404Penalty(AbstractReward): """ web_service_state = access_from_nested_dict(state, self.location_in_state) if web_service_state is NOT_PRESENT_IN_STATE: - print("error getting web service state") return 0.0 most_recent_return_code = web_service_state["last_response_status_code"] # TODO: reward needs to use the current web state. Observation should return web state at the time of last scan. From b6e414bd705615ea2bad75f1376f6c825d4e7004 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 09:14:07 +0000 Subject: [PATCH 19/49] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c712ef66..7a0ef4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Made packet capture and system logging optional (off by default). To turn on, change the io_settings.save_pcap_logs and io_settings.save_sys_logs settings in the config. - Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. - +- Fixed an issue where the data manipulation attack was triggered at episode start. ### Added - Network Hardware - Added base hardware module with NIC, SwitchPort, Node, and Link. Nodes have From c985b8793dfe4b1a1a9f2c309bd3ce28bce40aec Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 10 Jan 2024 11:58:36 +0000 Subject: [PATCH 20/49] #2151 and #2166: added tests for application being unused + even more tests --- .../system/applications/application.py | 5 +- .../system/services/ftp/ftp_service.py | 6 ++- .../simulator/system/services/service.py | 2 + src/primaite/simulator/system/software.py | 2 +- tests/conftest.py | 5 +- .../system/test_application_on_node.py | 23 +++++---- .../system/test_service_on_node.py | 7 +-- .../_applications/test_application_actions.py | 0 .../_applications/test_applications.py | 50 +++++++++++++++++++ 9 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 09828b89..322ac808 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Dict, Set from primaite import getLogger -from primaite.simulator.system.software import IOSoftware +from primaite.simulator.system.software import IOSoftware, SoftwareHealthState _LOGGER = getLogger(__name__) @@ -92,6 +92,9 @@ class Application(IOSoftware): if self.operating_state == ApplicationOperatingState.CLOSED: self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.set_health_state(SoftwareHealthState.GOOD) def _application_loop(self): """The main application loop.""" diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index f2c01544..276a9d5f 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -1,7 +1,7 @@ import shutil from abc import ABC from ipaddress import IPv4Address -from typing import Optional +from typing import Dict, Optional from primaite.simulator.file_system.file_system import File from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode @@ -16,6 +16,10 @@ class FTPServiceABC(Service, ABC): Contains shared methods between both classes. """ + def describe_state(self) -> Dict: + """Returns a Dict of the FTPService state.""" + return super().describe_state() + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: """ Process the command in the FTP Packet. diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 43c85471..162678a0 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from enum import Enum from typing import Any, Dict, Optional @@ -95,6 +96,7 @@ class Service(IOSoftware): rm.add_request("enable", RequestType(func=lambda request, context: self.enable())) return rm + @abstractmethod def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 4072fab1..8656154c 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -282,7 +282,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state == NodeOperatingState.OFF: + if self.software_manager and self.software_manager.node.operating_state != NodeOperatingState.ON: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 37289674..c37226a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,9 @@ from primaite.simulator.network.hardware.base import Link, Node class TestService(Service): """Test Service class""" + def describe_state(self) -> Dict: + return super().describe_state() + def __init__(self, **kwargs): kwargs["name"] = "TestService" kwargs["port"] = Port.HTTP @@ -60,7 +63,7 @@ class TestApplication(Application): super().__init__(**kwargs) def describe_state(self) -> Dict: - pass + return super().describe_state() @pytest.fixture(scope="function") diff --git a/tests/integration_tests/system/test_application_on_node.py b/tests/integration_tests/system/test_application_on_node.py index 46be5e55..60497f22 100644 --- a/tests/integration_tests/system/test_application_on_node.py +++ b/tests/integration_tests/system/test_application_on_node.py @@ -24,8 +24,8 @@ def populated_node(application_class) -> Tuple[Application, Computer]: return app, computer -def test_service_on_offline_node(application_class): - """Test to check that the service cannot be interacted with when node it is on is off.""" +def test_application_on_offline_node(application_class): + """Test to check that the application cannot be interacted with when node it is on is off.""" computer: Computer = Computer( hostname="test_computer", ip_address="192.168.1.2", @@ -49,8 +49,8 @@ def test_service_on_offline_node(application_class): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_off_service(populated_node): - """Check that the service is turned off when the server is turned off""" +def test_server_turns_off_application(populated_node): + """Check that the application is turned off when the server is turned off""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -65,8 +65,8 @@ def test_server_turns_off_service(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_service_cannot_be_turned_on_when_server_is_off(populated_node): - """Check that the service cannot be started when the server is off.""" +def test_application_cannot_be_turned_on_when_computer_is_off(populated_node): + """Check that the application cannot be started when the computer is off.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -86,8 +86,8 @@ def test_service_cannot_be_turned_on_when_server_is_off(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_on_service(populated_node): - """Check that turning on the server turns on service.""" +def test_computer_runs_applications(populated_node): + """Check that turning on the computer will turn on applications.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -109,13 +109,14 @@ def test_server_turns_on_service(populated_node): assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING - computer.start_up_duration = 0 - computer.shut_down_duration = 0 - computer.power_off() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.OFF assert app.operating_state is ApplicationOperatingState.CLOSED computer.power_on() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING diff --git a/tests/integration_tests/system/test_service_on_node.py b/tests/integration_tests/system/test_service_on_node.py index aab1e4da..9b0084bd 100644 --- a/tests/integration_tests/system/test_service_on_node.py +++ b/tests/integration_tests/system/test_service_on_node.py @@ -117,13 +117,14 @@ def test_server_turns_on_service(populated_node): assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING - server.start_up_duration = 0 - server.shut_down_duration = 0 - server.power_off() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.OFF assert service.operating_state is ServiceOperatingState.STOPPED server.power_on() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_application_actions.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py new file mode 100644 index 00000000..6247a100 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_applications.py @@ -0,0 +1,50 @@ +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.software import SoftwareHealthState + + +def test_scan(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_visible == SoftwareHealthState.UNUSED + + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_visible == SoftwareHealthState.UNUSED + + application.scan() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_visible == SoftwareHealthState.GOOD + + +def test_run_application(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.UNUSED + + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_actual == SoftwareHealthState.GOOD + + +def test_close_application(application): + application.run() + assert application.operating_state == ApplicationOperatingState.RUNNING + assert application.health_state_actual == SoftwareHealthState.GOOD + + application.close() + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.GOOD + + +def test_application_describe_states(application): + assert application.operating_state == ApplicationOperatingState.CLOSED + assert application.health_state_actual == SoftwareHealthState.UNUSED + + assert SoftwareHealthState.UNUSED.value == application.describe_state().get("health_state_actual") + + application.run() + assert SoftwareHealthState.GOOD.value == application.describe_state().get("health_state_actual") + + application.set_health_state(SoftwareHealthState.COMPROMISED) + assert SoftwareHealthState.COMPROMISED.value == application.describe_state().get("health_state_actual") + + application.patch() + assert SoftwareHealthState.PATCHING.value == application.describe_state().get("health_state_actual") From 66a42ebc6962fde7ebef8166e577716d78a3c392 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 13:06:48 +0000 Subject: [PATCH 21/49] Make database failure based on file status not service status --- .../system/applications/application.py | 2 ++ .../services/database/database_service.py | 20 +++++++++---------- .../system/services/ftp/ftp_service.py | 3 +++ .../_system/_services/test_ftp_client.py | 2 ++ .../_system/_services/test_ftp_server.py | 2 ++ 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 898e5917..0ae13228 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -95,6 +95,8 @@ class Application(IOSoftware): if self.operating_state == ApplicationOperatingState.CLOSED: self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.health_state_actual = SoftwareHealthState.GOOD def _application_loop(self): """The main application loop.""" diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 61cf1560..89329a17 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -4,6 +4,7 @@ from typing import Any, Dict, List, Literal, Optional, Union from primaite import getLogger from primaite.simulator.file_system.file_system import File +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.core.software_manager import SoftwareManager @@ -24,7 +25,7 @@ class DatabaseService(Service): password: Optional[str] = None connections: Dict[str, datetime] = {} - backup_server: IPv4Address = None + backup_server_ip: IPv4Address = None """IP address of the backup server.""" latest_backup_directory: str = None @@ -66,7 +67,7 @@ class DatabaseService(Service): :param: backup_server_ip: The IP address of the backup server """ - self.backup_server = backup_server + self.backup_server_ip = backup_server def backup_database(self) -> bool: """Create a backup of the database to the configured backup server.""" @@ -75,7 +76,7 @@ class DatabaseService(Service): return False # check if the backup server was configured - if self.backup_server is None: + if self.backup_server_ip is None: self.sys_log.error(f"{self.name} - {self.sys_log.hostname}: not configured.") return False @@ -84,7 +85,7 @@ class DatabaseService(Service): # send backup copy of database file to FTP server response = ftp_client_service.send_file( - dest_ip_address=self.backup_server, + dest_ip_address=self.backup_server_ip, src_file_name=self._db_file.name, src_folder_name=self.folder.name, dest_folder_name=str(self.uuid), @@ -112,7 +113,7 @@ class DatabaseService(Service): src_file_name="database.db", dest_folder_name="downloads", dest_file_name="database.db", - dest_ip_address=self.backup_server, + dest_ip_address=self.backup_server_ip, ) if not response: @@ -170,16 +171,13 @@ class DatabaseService(Service): """ self.sys_log.info(f"{self.name}: Running {query}") if query == "SELECT": - if self.health_state_actual == SoftwareHealthState.GOOD: + if self._db_file.health_status == FileSystemItemHealthStatus.GOOD: return {"status_code": 200, "type": "sql", "data": True, "uuid": query_id} else: return {"status_code": 404, "data": False} elif query == "DELETE": - if self.health_state_actual == SoftwareHealthState.GOOD: - self.health_state_actual = SoftwareHealthState.COMPROMISED - return {"status_code": 200, "type": "sql", "data": False, "uuid": query_id} - else: - return {"status_code": 404, "data": False} + self._db_file.health_status = FileSystemItemHealthStatus.COMPROMISED + return {"status_code": 200, "type": "sql", "data": False, "uuid": query_id} else: # Invalid query return {"status_code": 500, "data": False} diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index f2c01544..8d9bb6fb 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -52,10 +52,12 @@ class FTPServiceABC(Service, ABC): folder_name = payload.ftp_command_args["dest_folder_name"] file_size = payload.ftp_command_args["file_size"] real_file_path = payload.ftp_command_args.get("real_file_path") + health_status = payload.ftp_command_args["health_status"] is_real = real_file_path is not None file = self.file_system.create_file( file_name=file_name, folder_name=folder_name, size=file_size, real=is_real ) + file.health_status = health_status self.sys_log.info( f"{self.name}: Created item in {self.sys_log.hostname}: {payload.ftp_command_args['dest_folder_name']}/" f"{payload.ftp_command_args['dest_file_name']}" @@ -110,6 +112,7 @@ class FTPServiceABC(Service, ABC): "dest_file_name": dest_file_name, "file_size": file.sim_size, "real_file_path": file.sim_path if file.real else None, + "health_status": file.health_status, }, packet_payload_size=file.sim_size, status_code=FTPStatusCode.OK if is_response else None, diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py index 134f82bd..941a465e 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_client.py @@ -2,6 +2,7 @@ from ipaddress import IPv4Address import pytest +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.computer import Computer @@ -42,6 +43,7 @@ def test_ftp_client_store_file(ftp_client): "dest_folder_name": "downloads", "dest_file_name": "file.txt", "file_size": 24, + "health_status": FileSystemItemHealthStatus.GOOD, }, packet_payload_size=24, status_code=FTPStatusCode.OK, diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py index 2b26c932..137e74d0 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp_server.py @@ -1,5 +1,6 @@ import pytest +from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.server import Server @@ -41,6 +42,7 @@ def test_ftp_server_store_file(ftp_server): "dest_folder_name": "downloads", "dest_file_name": "file.txt", "file_size": 24, + "health_status": FileSystemItemHealthStatus.GOOD, }, packet_payload_size=24, ) From 1505d087214e724bbcc5661328f79931eb98a1b4 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 18:04:48 +0000 Subject: [PATCH 22/49] Fix backup issues and align with Yak --- .../config/_package_data/example_config.yaml | 4 +- src/primaite/game/agent/observations.py | 2 +- src/primaite/session/environment.py | 1 - .../services/database/database_service.py | 44 +++++++++++++------ .../system/services/ftp/ftp_server.py | 3 +- .../red_services/data_manipulation_bot.py | 2 +- .../system/services/web_server/web_server.py | 4 ++ 7 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index 2ac23661..ee0eb7ff 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -112,10 +112,8 @@ agents: - service_ref: domain_controller_dns_server - node_ref: web_server services: - - service_ref: web_server_database_client + - service_ref: web_server_web_service - node_ref: database_server - services: - - service_ref: database_service folders: - folder_name: database files: diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index eecf4163..0cb3e8f6 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -555,7 +555,7 @@ class NodeObservation(AbstractObservation): folder_configs = config.get("folders", {}) folders = [ FolderObservation.from_config( - config=c, game=game, parent_where=where, num_files_per_folder=num_files_per_folder + config=c, game=game, parent_where=where + ["file_system"], num_files_per_folder=num_files_per_folder ) for c in folder_configs ] diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index 36ab3f58..6701f183 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -23,7 +23,6 @@ class PrimaiteGymEnv(gymnasium.Env): super().__init__() self.game: "PrimaiteGame" = game self.agent: ProxyAgent = self.game.rl_agents[0] - self.flatten_obs: bool = False def step(self, action: ActType) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict[str, Any]]: """Perform a step in the environment.""" diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 89329a17..7c665b9a 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Literal, Optional, Union from primaite import getLogger from primaite.simulator.file_system.file_system import File from primaite.simulator.file_system.file_system_item_abc import FileSystemItemHealthStatus +from primaite.simulator.file_system.folder import Folder from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.core.software_manager import SoftwareManager @@ -39,7 +40,6 @@ class DatabaseService(Service): kwargs["port"] = Port.POSTGRES_SERVER kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) - self._db_file: File self._create_db_file() def set_original_state(self): @@ -49,7 +49,7 @@ class DatabaseService(Service): vals_to_include = { "password", "connections", - "backup_server", + "backup_server_ip", "latest_backup_directory", "latest_backup_file_name", } @@ -86,8 +86,8 @@ class DatabaseService(Service): # send backup copy of database file to FTP server response = ftp_client_service.send_file( dest_ip_address=self.backup_server_ip, - src_file_name=self._db_file.name, - src_folder_name=self.folder.name, + src_file_name=self.db_file.name, + src_folder_name="database", dest_folder_name=str(self.uuid), dest_file_name="database.db", ) @@ -121,13 +121,10 @@ class DatabaseService(Service): return False # replace db file - self.file_system.delete_file(folder_name=self.folder.name, file_name="downloads.db") - self.file_system.copy_file( - src_folder_name="downloads", src_file_name="database.db", dst_folder_name=self.folder.name - ) - self._db_file = self.file_system.get_file(folder_name=self.folder.name, file_name="database.db") + self.file_system.delete_file(folder_name="database", file_name="downloads.db") + self.file_system.copy_file(src_folder_name="downloads", src_file_name="database.db", dst_folder_name="database") - if self._db_file is None: + if self.db_file is None: self.sys_log.error("Copying database backup failed.") return False @@ -137,8 +134,17 @@ class DatabaseService(Service): def _create_db_file(self): """Creates the Simulation File and sqlite file in the file system.""" - self._db_file: File = self.file_system.create_file(folder_name="database", file_name="database.db") - self.folder = self.file_system.get_folder_by_id(self._db_file.folder_id) + self.file_system.create_file(folder_name="database", file_name="database.db") + + @property + def db_file(self) -> File: + """Returns the database file.""" + return self.file_system.get_file(folder_name="database", file_name="database.db") + + @property + def folder(self) -> Folder: + """Returns the database folder.""" + return self.file_system.get_folder_by_id(self.db_file.folder_id) def _process_connect( self, session_id: str, password: Optional[str] = None @@ -171,12 +177,12 @@ class DatabaseService(Service): """ self.sys_log.info(f"{self.name}: Running {query}") if query == "SELECT": - if self._db_file.health_status == FileSystemItemHealthStatus.GOOD: + if self.db_file.health_status == FileSystemItemHealthStatus.GOOD: return {"status_code": 200, "type": "sql", "data": True, "uuid": query_id} else: return {"status_code": 404, "data": False} elif query == "DELETE": - self._db_file.health_status = FileSystemItemHealthStatus.COMPROMISED + self.db_file.health_status = FileSystemItemHealthStatus.COMPROMISED return {"status_code": 200, "type": "sql", "data": False, "uuid": query_id} else: # Invalid query @@ -231,3 +237,13 @@ class DatabaseService(Service): software_manager.send_payload_to_session_manager(payload=payload, session_id=session_id) return payload["status_code"] == 200 + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a single timestep of simulation dynamics to this service. + + Here at the first step, the database backup is created, in addition to normal service update logic. + """ + if timestep == 1: + self.backup_database() + return super().apply_timestep(timestep) diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 0278b616..87f38597 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -106,5 +106,6 @@ class FTPServer(FTPServiceABC): if payload.status_code is not None: return False - self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) + # self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) + self._process_ftp_command(payload=payload, session_id=session_id) return True diff --git a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py index fcd9a3cc..48a05a67 100644 --- a/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py +++ b/src/primaite/simulator/system/services/red_services/data_manipulation_bot.py @@ -84,7 +84,7 @@ class DataManipulationBot(DatabaseClient): payload: Optional[str] = None, port_scan_p_of_success: float = 0.1, data_manipulation_p_of_success: float = 0.1, - repeat: bool = False, + repeat: bool = True, ): """ Configure the DataManipulatorBot to communicate with a DatabaseService. diff --git a/src/primaite/simulator/system/services/web_server/web_server.py b/src/primaite/simulator/system/services/web_server/web_server.py index afd6cb74..eaea6bb1 100644 --- a/src/primaite/simulator/system/services/web_server/web_server.py +++ b/src/primaite/simulator/system/services/web_server/web_server.py @@ -13,6 +13,7 @@ from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.services.service import Service +from primaite.simulator.system.software import SoftwareHealthState _LOGGER = getLogger(__name__) @@ -123,7 +124,10 @@ class WebServer(Service): # get all users if db_client.query("SELECT"): # query succeeded + self.set_health_state(SoftwareHealthState.GOOD) response.status_code = HttpStatusCode.OK + else: + self.set_health_state(SoftwareHealthState.COMPROMISED) return response except Exception: From 2d1041e7b3331c4349a8e906fc715e32899f365a Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 10 Jan 2024 18:38:37 +0000 Subject: [PATCH 23/49] Fix final bugs --- src/primaite/game/agent/observations.py | 11 +++++++---- src/primaite/simulator/network/hardware/base.py | 2 +- .../test_uc2_data_manipulation_scenario.py | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index 0cb3e8f6..e5216e4a 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -205,12 +205,15 @@ class LinkObservation(AbstractObservation): bandwidth = link_state["bandwidth"] load = link_state["current_load"] - utilisation_fraction = load / bandwidth - # 0 is UNUSED, 1 is 0%-10%. 2 is 10%-20%. 3 is 20%-30%. And so on... 10 is exactly 100% - utilisation_category = int(utilisation_fraction * 10) + 1 + if load == 0: + utilisation_category = 0 + else: + utilisation_fraction = load / bandwidth + # 0 is UNUSED, 1 is 0%-10%. 2 is 10%-20%. 3 is 20%-30%. And so on... 10 is exactly 100% + utilisation_category = int(utilisation_fraction * 9) + 1 # TODO: once the links support separte load per protocol, this needs amendment to reflect that. - return {"PROTOCOLS": {"ALL": utilisation_category}} + return {"PROTOCOLS": {"ALL": min(utilisation_category, 10)}} @property def space(self) -> spaces.Space: diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index a310a3f5..f41c1ab6 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1271,8 +1271,8 @@ class Node(SimComponent): self.start_up_countdown = self.start_up_duration if self.start_up_duration <= 0: - self._start_up_actions() self.operating_state = NodeOperatingState.ON + self._start_up_actions() self.sys_log.info("Turned on") for nic in self.nics.values(): if nic._connected_link: diff --git a/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py index 0dc2c031..dad6f879 100644 --- a/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py +++ b/tests/e2e_integration_tests/test_uc2_data_manipulation_scenario.py @@ -22,7 +22,7 @@ def test_data_manipulation(uc2_network): assert db_client.query("SELECT") # Now we run the DataManipulationBot - db_manipulation_bot.run() + db_manipulation_bot.attack() # Now check that the DB client on the web_server cannot query the users table on the database assert not db_client.query("SELECT") From e57c240b9b1d5fa1fff2e52482e9d3895c97f47d Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 09:55:09 +0000 Subject: [PATCH 24/49] Apply cosmetic changes based on review. --- src/primaite/simulator/system/applications/application.py | 2 +- src/primaite/simulator/system/services/ftp/ftp_server.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 0ae13228..e15b9f1c 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -96,7 +96,7 @@ class Application(IOSoftware): self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING if self.health_state_actual == SoftwareHealthState.UNUSED: - self.health_state_actual = SoftwareHealthState.GOOD + self.set_health_state(SoftwareHealthState.GOOD) def _application_loop(self): """The main application loop.""" diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 87f38597..f176f58b 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -106,6 +106,5 @@ class FTPServer(FTPServiceABC): if payload.status_code is not None: return False - # self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) self._process_ftp_command(payload=payload, session_id=session_id) return True From d2a2472e5f08f14eaa20e8ff5e24d0a83be2fde6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 10:49:32 +0000 Subject: [PATCH 25/49] Apply bugfix 2151 --- .../system/applications/application.py | 4 +--- .../system/services/ftp/ftp_service.py | 6 ++++- .../simulator/system/services/service.py | 16 ++++--------- src/primaite/simulator/system/software.py | 6 ++--- tests/conftest.py | 5 +++- .../system/test_application_on_node.py | 23 ++++++++++--------- .../system/test_service_on_node.py | 7 +++--- .../_system/_services/test_web_server.py | 7 +++++- 8 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index e15b9f1c..322ac808 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -38,9 +38,6 @@ class Application(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def set_original_state(self): """Sets the original state.""" super().set_original_state() @@ -95,6 +92,7 @@ class Application(IOSoftware): if self.operating_state == ApplicationOperatingState.CLOSED: self.sys_log.info(f"Running Application {self.name}") self.operating_state = ApplicationOperatingState.RUNNING + # set software health state to GOOD if initially set to UNUSED if self.health_state_actual == SoftwareHealthState.UNUSED: self.set_health_state(SoftwareHealthState.GOOD) diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index 8d9bb6fb..70ba74d7 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -1,7 +1,7 @@ import shutil from abc import ABC from ipaddress import IPv4Address -from typing import Optional +from typing import Dict, Optional from primaite.simulator.file_system.file_system import File from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode @@ -16,6 +16,10 @@ class FTPServiceABC(Service, ABC): Contains shared methods between both classes. """ + def describe_state(self) -> Dict: + """Returns a Dict of the FTPService state.""" + return super().describe_state() + def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: """ Process the command in the FTP Packet. diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index e60b7700..f10d8776 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from enum import Enum from typing import Any, Dict, Optional @@ -77,9 +78,6 @@ class Service(IOSoftware): def __init__(self, **kwargs): super().__init__(**kwargs) - self.health_state_visible = SoftwareHealthState.UNUSED - self.health_state_actual = SoftwareHealthState.UNUSED - def set_original_state(self): """Sets the original state.""" super().set_original_state() @@ -98,6 +96,7 @@ class Service(IOSoftware): rm.add_request("enable", RequestType(func=lambda request, context: self.enable())) return rm + @abstractmethod def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -118,7 +117,6 @@ class Service(IOSoftware): if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Stopping service {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.UNUSED def start(self, **kwargs) -> None: """Start the service.""" @@ -129,42 +127,39 @@ class Service(IOSoftware): if self.operating_state == ServiceOperatingState.STOPPED: self.sys_log.info(f"Starting service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD + # set software health state to GOOD if initially set to UNUSED + if self.health_state_actual == SoftwareHealthState.UNUSED: + self.set_health_state(SoftwareHealthState.GOOD) def pause(self) -> None: """Pause the service.""" if self.operating_state == ServiceOperatingState.RUNNING: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.PAUSED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def resume(self) -> None: """Resume paused service.""" if self.operating_state == ServiceOperatingState.PAUSED: self.sys_log.info(f"Resuming service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD def restart(self) -> None: """Restart running service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING - self.health_state_actual = SoftwareHealthState.OVERWHELMED self.restart_countdown = self.restart_duration def disable(self) -> None: """Disable the service.""" self.sys_log.info(f"Disabling Application {self.name}") self.operating_state = ServiceOperatingState.DISABLED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def enable(self) -> None: """Enable the disabled service.""" if self.operating_state == ServiceOperatingState.DISABLED: self.sys_log.info(f"Enabling Application {self.name}") self.operating_state = ServiceOperatingState.STOPPED - self.health_state_actual = SoftwareHealthState.OVERWHELMED def apply_timestep(self, timestep: int) -> None: """ @@ -181,5 +176,4 @@ class Service(IOSoftware): if self.restart_countdown <= 0: _LOGGER.debug(f"Restarting finished for service {self.name}") self.operating_state = ServiceOperatingState.RUNNING - self.health_state_actual = SoftwareHealthState.GOOD self.restart_countdown -= 1 diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 7be270c0..a58e4c48 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -69,9 +69,9 @@ class Software(SimComponent): name: str "The name of the software." - health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_actual: SoftwareHealthState = SoftwareHealthState.UNUSED "The actual health state of the software." - health_state_visible: SoftwareHealthState = SoftwareHealthState.GOOD + health_state_visible: SoftwareHealthState = SoftwareHealthState.UNUSED "The health state of the software visible to the red agent." criticality: SoftwareCriticality = SoftwareCriticality.LOWEST "The criticality level of the software." @@ -278,7 +278,7 @@ class IOSoftware(Software): Returns true if the software can perform actions. """ - if self.software_manager and self.software_manager.node.operating_state is not NodeOperatingState.ON: + if self.software_manager and self.software_manager.node.operating_state != NodeOperatingState.ON: _LOGGER.debug(f"{self.name} Error: {self.software_manager.node.hostname} is not online.") return False return True diff --git a/tests/conftest.py b/tests/conftest.py index 1ab07dd8..1400f93b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,9 @@ from primaite.simulator.network.hardware.base import Link, Node class TestService(Service): """Test Service class""" + def describe_state(self) -> Dict: + return super().describe_state() + def __init__(self, **kwargs): kwargs["name"] = "TestService" kwargs["port"] = Port.HTTP @@ -60,7 +63,7 @@ class TestApplication(Application): super().__init__(**kwargs) def describe_state(self) -> Dict: - pass + return super().describe_state() @pytest.fixture(scope="function") diff --git a/tests/integration_tests/system/test_application_on_node.py b/tests/integration_tests/system/test_application_on_node.py index 46be5e55..3c9afe43 100644 --- a/tests/integration_tests/system/test_application_on_node.py +++ b/tests/integration_tests/system/test_application_on_node.py @@ -24,8 +24,8 @@ def populated_node(application_class) -> Tuple[Application, Computer]: return app, computer -def test_service_on_offline_node(application_class): - """Test to check that the service cannot be interacted with when node it is on is off.""" +def test_application_on_offline_node(application_class): + """Test to check that the application cannot be interacted with when node it is on is off.""" computer: Computer = Computer( hostname="test_computer", ip_address="192.168.1.2", @@ -49,8 +49,8 @@ def test_service_on_offline_node(application_class): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_off_service(populated_node): - """Check that the service is turned off when the server is turned off""" +def test_server_turns_off_application(populated_node): + """Check that the application is turned off when the server is turned off""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -65,8 +65,8 @@ def test_server_turns_off_service(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_service_cannot_be_turned_on_when_server_is_off(populated_node): - """Check that the service cannot be started when the server is off.""" +def test_application_cannot_be_turned_on_when_server_is_off(populated_node): + """Check that the application cannot be started when the server is off.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -86,8 +86,8 @@ def test_service_cannot_be_turned_on_when_server_is_off(populated_node): assert app.operating_state is ApplicationOperatingState.CLOSED -def test_server_turns_on_service(populated_node): - """Check that turning on the server turns on service.""" +def test_server_turns_on_application(populated_node): + """Check that turning on the server turns on application.""" app, computer = populated_node assert computer.operating_state is NodeOperatingState.ON @@ -109,13 +109,14 @@ def test_server_turns_on_service(populated_node): assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING - computer.start_up_duration = 0 - computer.shut_down_duration = 0 - computer.power_off() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.OFF assert app.operating_state is ApplicationOperatingState.CLOSED computer.power_on() + for i in range(computer.start_up_duration + 1): + computer.apply_timestep(timestep=i) assert computer.operating_state is NodeOperatingState.ON assert app.operating_state is ApplicationOperatingState.RUNNING diff --git a/tests/integration_tests/system/test_service_on_node.py b/tests/integration_tests/system/test_service_on_node.py index aab1e4da..9b0084bd 100644 --- a/tests/integration_tests/system/test_service_on_node.py +++ b/tests/integration_tests/system/test_service_on_node.py @@ -117,13 +117,14 @@ def test_server_turns_on_service(populated_node): assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING - server.start_up_duration = 0 - server.shut_down_duration = 0 - server.power_off() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.OFF assert service.operating_state is ServiceOperatingState.STOPPED server.power_on() + for i in range(server.start_up_duration + 1): + server.apply_timestep(timestep=i) assert server.operating_state is NodeOperatingState.ON assert service.operating_state is ServiceOperatingState.RUNNING diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py index bbccda27..64277356 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py @@ -1,5 +1,6 @@ import pytest +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.protocols.http import ( HttpRequestMethod, @@ -15,7 +16,11 @@ from primaite.simulator.system.services.web_server.web_server import WebServer @pytest.fixture(scope="function") def web_server() -> Server: node = Server( - hostname="web_server", ip_address="192.168.1.10", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + hostname="web_server", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + operating_state=NodeOperatingState.ON, ) node.software_manager.install(software_class=WebServer) node.software_manager.software.get("WebServer").start() From b11e4c8ccd06f7638f243f8a2fb506ce4792f6d8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 11:05:00 +0000 Subject: [PATCH 26/49] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0ef4c7..1c67d16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made packet capture and system logging optional (off by default). To turn on, change the io_settings.save_pcap_logs and io_settings.save_sys_logs settings in the config. - Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. - Fixed an issue where the data manipulation attack was triggered at episode start. +- Fixed a bug where FTP STOR stored an additional copy on the client machine's filesystem + ### Added - Network Hardware - Added base hardware module with NIC, SwitchPort, Node, and Link. Nodes have From 4d1c0d268e0b5619851fda92baf6b94f1f0c17a8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 11:05:55 +0000 Subject: [PATCH 27/49] Fix reward data type --- src/primaite/game/agent/rewards.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index cb8f8cb1..30baad6f 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -111,9 +111,9 @@ class DatabaseFileIntegrity(AbstractReward): """ database_file_state = access_from_nested_dict(state, self.location_in_state) health_status = database_file_state["health_status"] - if health_status == "corrupted": + if health_status == 3: return -1 - elif health_status == "good": + elif health_status == 1: return 1 else: return 0 From 63b9bc5bc69a8f125f7d1ecad6378de56a833003 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 11:13:43 +0000 Subject: [PATCH 28/49] 3.0.0b5 --- CHANGELOG.md | 4 ++++ src/primaite/VERSION | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c67d16c..227cec69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. - Fixed an issue where the data manipulation attack was triggered at episode start. - Fixed a bug where FTP STOR stored an additional copy on the client machine's filesystem +- Fixed a bug where the red agent acted to early +- Fixed the order of service health state +- Fixed an issue where starting a node didn't start the services on it + ### Added diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 52f460a5..09fb39d2 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b5dev +3.0.0b5 From ed5591caf8cf11a3727e27028f670dc915687a4c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 14:49:36 +0000 Subject: [PATCH 29/49] Minor fix --- src/primaite/game/agent/observations.py | 2 +- src/primaite/game/agent/rewards.py | 2 +- src/primaite/simulator/file_system/folder.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index e5216e4a..cac5b91e 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -79,7 +79,7 @@ class FileObservation(AbstractObservation): file_state = access_from_nested_dict(state, self.where) if file_state is NOT_PRESENT_IN_STATE: return self.default_observation - return {"health_status": file_state["health_status"]} + return {"health_status": file_state["visible_status"]} @property def space(self) -> spaces.Space: diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 30baad6f..8f064be3 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -111,7 +111,7 @@ class DatabaseFileIntegrity(AbstractReward): """ database_file_state = access_from_nested_dict(state, self.location_in_state) health_status = database_file_state["health_status"] - if health_status == 3: + if health_status == 2: return -1 elif health_status == 1: return 1 diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index d4e72f63..237a6341 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -105,7 +105,7 @@ class Folder(FileSystemItemABC): self._file_request_manager = RequestManager() rm.add_request( name="file", - request_type=RequestType(func=lambda request, context: self._file_request_manager), + request_type=RequestType(func=self._file_request_manager), ) return rm From 842e59f5964612213787e21a459368e136276cf3 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 11 Jan 2024 15:40:37 +0000 Subject: [PATCH 30/49] Database patch --- .../simulator/system/services/database/database_service.py | 5 +++++ src/primaite/simulator/system/software.py | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 7c665b9a..1cdd0390 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -247,3 +247,8 @@ class DatabaseService(Service): if timestep == 1: self.backup_database() return super().apply_timestep(timestep) + + def _update_patch_status(self) -> None: + super()._update_patch_status() + if self._patching_countdown is None: + self.restore_backup() diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index a58e4c48..b7c0bd9b 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -193,8 +193,9 @@ class Software(SimComponent): def patch(self) -> None: """Perform a patch on the software.""" - self._patching_countdown = self.patching_duration - self.set_health_state(SoftwareHealthState.PATCHING) + if self.health_state_actual in (SoftwareHealthState.COMPROMISED, SoftwareHealthState.GOOD): + self._patching_countdown = self.patching_duration + self.set_health_state(SoftwareHealthState.PATCHING) def _update_patch_status(self) -> None: """Update the patch status of the software.""" From e0033de7b671eeaea3d56ab4e0b4898ad85f592c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 12 Jan 2024 14:54:55 +0000 Subject: [PATCH 31/49] Fix folder reset --- src/primaite/simulator/file_system/folder.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index 237a6341..027547bb 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -17,8 +17,6 @@ class Folder(FileSystemItemABC): files: Dict[str, File] = {} "Files stored in the folder." - _files_by_name: Dict[str, File] = {} - "Files by their name as .." deleted_files: Dict[str, File] = {} "Files that have been deleted." @@ -78,7 +76,6 @@ class Folder(FileSystemItemABC): file = self.deleted_files[uuid] self.deleted_files.pop(uuid) self.files[uuid] = file - self._files_by_name[file.name] = file # Clear any other deleted files that aren't original (have been created by agent) self.deleted_files.clear() @@ -89,7 +86,6 @@ class Folder(FileSystemItemABC): if uuid not in original_file_uuids: file = self.files[uuid] self.files.pop(uuid) - self._files_by_name.pop(file.name) # Now reset all remaining files for file in self.files.values(): @@ -219,7 +215,10 @@ class Folder(FileSystemItemABC): :return: The matching File. """ # TODO: Increment read count? - return self._files_by_name.get(file_name) + for file in self.files.values(): + if file.name == file_name: + return file + return None def get_file_by_id(self, file_uuid: str, include_deleted: Optional[bool] = False) -> File: """ @@ -250,15 +249,14 @@ class Folder(FileSystemItemABC): raise Exception(f"Invalid file: {file}") # check if file with id or name already exists in folder - if (force is not True) and file.name in self._files_by_name: + if self.get_file(file.name) is not None and not force: raise Exception(f"File with name {file.name} already exists in folder") - if (force is not True) and file.uuid in self.files: + if (file.uuid in self.files) and not force: raise Exception(f"File with uuid {file.uuid} already exists in folder") # add to list self.files[file.uuid] = file - self._files_by_name[file.name] = file self._file_request_manager.add_request(file.uuid, RequestType(func=file._request_manager)) file.folder = self @@ -275,7 +273,6 @@ class Folder(FileSystemItemABC): if self.files.get(file.uuid): self.files.pop(file.uuid) - self._files_by_name.pop(file.name) self.deleted_files[file.uuid] = file file.delete() self.sys_log.info(f"Removed file {file.name} (id: {file.uuid})") @@ -300,7 +297,6 @@ class Folder(FileSystemItemABC): self.deleted_files[file_id] = file self.files = {} - self._files_by_name = {} def restore_file(self, file_uuid: str): """ @@ -316,7 +312,6 @@ class Folder(FileSystemItemABC): file.restore() self.files[file.uuid] = file - self._files_by_name[file.name] = file if file.deleted: self.deleted_files.pop(file_uuid) From 728f80cc2162b5dc1ce7878921dd90e70ca0d740 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 15 Jan 2024 09:48:14 +0000 Subject: [PATCH 32/49] Temporarily disable file delete action --- src/primaite/game/agent/actions.py | 10 ++++++++++ src/primaite/game/agent/rewards.py | 7 +++++++ src/primaite/simulator/file_system/folder.py | 2 +- .../system/services/database/database_service.py | 4 ++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 4c47bfaa..585e2dfa 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -296,6 +296,16 @@ class NodeFileDeleteAction(NodeFileAbstractAction): super().__init__(manager, num_nodes=num_nodes, num_folders=num_folders, num_files=num_files, **kwargs) self.verb: str = "delete" + def form_request(self, node_id: int, folder_id: int, file_id: int) -> List[str]: + """Return the action formatted as a request which can be ingested by the PrimAITE simulation.""" + node_uuid = self.manager.get_node_uuid_by_idx(node_id) + folder_uuid = self.manager.get_folder_uuid_by_idx(node_idx=node_id, folder_idx=folder_id) + file_uuid = self.manager.get_file_uuid_by_idx(node_idx=node_id, folder_idx=folder_id, file_idx=file_id) + if node_uuid is None or folder_uuid is None or file_uuid is None: + return ["do_nothing"] + return ["do_nothing"] + # return ["network", "node", node_uuid, "file_system", "delete", "file", folder_uuid, file_uuid] + class NodeFileRepairAction(NodeFileAbstractAction): """Action which repairs a file.""" diff --git a/src/primaite/game/agent/rewards.py b/src/primaite/game/agent/rewards.py index 8f064be3..6cee127f 100644 --- a/src/primaite/game/agent/rewards.py +++ b/src/primaite/game/agent/rewards.py @@ -110,6 +110,13 @@ class DatabaseFileIntegrity(AbstractReward): :type state: Dict """ database_file_state = access_from_nested_dict(state, self.location_in_state) + if database_file_state is NOT_PRESENT_IN_STATE: + _LOGGER.info( + f"Could not calculate {self.__class__} reward because " + "simulation state did not contain enough information." + ) + return 0.0 + health_status = database_file_state["health_status"] if health_status == 2: return -1 diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index 027547bb..ab862898 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -276,7 +276,7 @@ class Folder(FileSystemItemABC): self.deleted_files[file.uuid] = file file.delete() self.sys_log.info(f"Removed file {file.name} (id: {file.uuid})") - self._file_request_manager.remove_request(file.uuid) + # self._file_request_manager.remove_request(file.uuid) else: _LOGGER.debug(f"File with UUID {file.uuid} was not found.") diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 1cdd0390..6fcced25 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -84,6 +84,10 @@ class DatabaseService(Service): ftp_client_service: FTPClient = software_manager.software.get("FTPClient") # send backup copy of database file to FTP server + if not self.db_file: + self.sys_log.error("Attempted to backup database file but it doesn't exist.") + return False + response = ftp_client_service.send_file( dest_ip_address=self.backup_server_ip, src_file_name=self.db_file.name, From edc9772d0a4a452256b10b03dd839496738adda5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 15 Jan 2024 10:10:30 +0000 Subject: [PATCH 33/49] Fix typo in database restore --- .../simulator/system/services/database/database_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 6fcced25..14190dd2 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -125,7 +125,7 @@ class DatabaseService(Service): return False # replace db file - self.file_system.delete_file(folder_name="database", file_name="downloads.db") + self.file_system.delete_file(folder_name="database", file_name="database.db") self.file_system.copy_file(src_folder_name="downloads", src_file_name="database.db", dst_folder_name="database") if self.db_file is None: From 7d218c520115acb611f9de21a55aaea1a1964987 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 15 Jan 2024 10:31:13 +0000 Subject: [PATCH 34/49] bump version --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 09fb39d2..72f12ef8 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b5 +3.0.0b6dev From 42d00e04408ed6a6857cdb67d668478eb027a733 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 21 Jan 2024 16:33:51 +0000 Subject: [PATCH 35/49] Fix issue where file deleted flag wouldn't be reset --- .gitignore | 1 + .../simulator/file_system/file_system.py | 17 +++++++---------- .../file_system/file_system_item_abc.py | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 892751d9..1ce2ca9d 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,4 @@ benchmark/output # src/primaite/notebooks/scratch.ipynb src/primaite/notebooks/scratch.py sandbox.py +sandbox/ diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index c2eb0d2d..149bf083 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -23,7 +23,6 @@ class FileSystem(SimComponent): "List containing all the folders in the file system." deleted_folders: Dict[str, Folder] = {} "List containing all the folders that have been deleted." - _folders_by_name: Dict[str, Folder] = {} sys_log: SysLog "Instance of SysLog used to create system logs." sim_root: Path @@ -56,7 +55,6 @@ class FileSystem(SimComponent): folder = self.deleted_folders[uuid] self.deleted_folders.pop(uuid) self.folders[uuid] = folder - self._folders_by_name[folder.name] = folder # Clear any other deleted folders that aren't original (have been created by agent) self.deleted_folders.clear() @@ -67,7 +65,6 @@ class FileSystem(SimComponent): if uuid not in original_folder_uuids: folder = self.folders[uuid] self.folders.pop(uuid) - self._folders_by_name.pop(folder.name) # Now reset all remaining folders for folder in self.folders.values(): @@ -173,7 +170,6 @@ class FileSystem(SimComponent): folder = Folder(name=folder_name, sys_log=self.sys_log) self.folders[folder.uuid] = folder - self._folders_by_name[folder.name] = folder self._folder_request_manager.add_request( name=folder.uuid, request_type=RequestType(func=folder._request_manager) ) @@ -188,14 +184,13 @@ class FileSystem(SimComponent): if folder_name == "root": self.sys_log.warning("Cannot delete the root folder.") return - folder = self._folders_by_name.get(folder_name) + folder = self.get_folder(folder_name) if folder: # set folder to deleted state folder.delete() # remove from folder list self.folders.pop(folder.uuid) - self._folders_by_name.pop(folder.name) # add to deleted list folder.remove_all_files() @@ -221,7 +216,10 @@ class FileSystem(SimComponent): :param folder_name: The folder name. :return: The matching Folder. """ - return self._folders_by_name.get(folder_name) + for folder in self.folders.values(): + if folder.name == folder_name: + return folder + return None def get_folder_by_id(self, folder_uuid: str, include_deleted: bool = False) -> Optional[Folder]: """ @@ -261,13 +259,13 @@ class FileSystem(SimComponent): """ if folder_name: # check if file with name already exists - folder = self._folders_by_name.get(folder_name) + folder = self.get_folder(folder_name) # If not then create it if not folder: folder = self.create_folder(folder_name) else: # Use root folder if folder_name not supplied - folder = self._folders_by_name["root"] + folder = self.get_folder("root") # Create the file and add it to the folder file = File( @@ -474,7 +472,6 @@ class FileSystem(SimComponent): folder.restore() self.folders[folder.uuid] = folder - self._folders_by_name[folder.name] = folder if folder.deleted: self.deleted_folders.pop(folder.uuid) diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py index 86cd1ee7..c3e1426b 100644 --- a/src/primaite/simulator/file_system/file_system_item_abc.py +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -87,7 +87,7 @@ class FileSystemItemABC(SimComponent): def set_original_state(self): """Sets the original state.""" - vals_to_keep = {"name", "health_status", "visible_health_status", "previous_hash", "revealed_to_red"} + vals_to_keep = {"name", "health_status", "visible_health_status", "previous_hash", "revealed_to_red", "deleted"} self._original_state = self.model_dump(include=vals_to_keep) def describe_state(self) -> Dict: From 8e19e05f570b57fee370190eb59384bda6adc77f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 21 Jan 2024 17:29:19 +0000 Subject: [PATCH 36/49] Fix acl actions for blue agent. --- .../config/_package_data/example_config.yaml | 60 ++++++++++++------- src/primaite/game/agent/actions.py | 15 ++++- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index ee0eb7ff..7393f5a3 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -304,63 +304,63 @@ agents: action: "NODE_RESET" options: node_id: 5 - 22: + 22: # "ACL: ADDRULE - Block outgoing traffic from client 1" (not supported in Primaite) action: "NETWORK_ACL_ADDRULE" options: position: 1 permission: 2 - source_ip_id: 7 - dest_ip_id: 1 + source_ip_id: 7 # client 1 + dest_ip_id: 1 # ALL source_port_id: 1 dest_port_id: 1 protocol_id: 1 - 23: + 23: # "ACL: ADDRULE - Block outgoing traffic from client 2" (not supported in Primaite) action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 2 permission: 2 - source_ip_id: 8 - dest_ip_id: 1 + source_ip_id: 8 # client 2 + dest_ip_id: 1 # ALL source_port_id: 1 dest_port_id: 1 protocol_id: 1 - 24: + 24: # block tcp traffic from client 1 to web app action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 3 permission: 2 - source_ip_id: 7 - dest_ip_id: 3 + source_ip_id: 7 # client 1 + dest_ip_id: 3 # web server source_port_id: 1 dest_port_id: 1 protocol_id: 3 - 25: + 25: # block tcp traffic from client 2 to web app action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 4 permission: 2 - source_ip_id: 8 - dest_ip_id: 3 + source_ip_id: 8 # client 2 + dest_ip_id: 3 # web server source_port_id: 1 dest_port_id: 1 protocol_id: 3 26: action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 5 permission: 2 - source_ip_id: 7 - dest_ip_id: 4 + source_ip_id: 7 # client 1 + dest_ip_id: 4 # database source_port_id: 1 dest_port_id: 1 protocol_id: 3 27: action: "NETWORK_ACL_ADDRULE" options: - position: 1 + position: 6 permission: 2 - source_ip_id: 8 - dest_ip_id: 4 + source_ip_id: 8 # client 2 + dest_ip_id: 4 # database source_port_id: 1 dest_port_id: 1 protocol_id: 3 @@ -504,6 +504,24 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 + reward_function: reward_components: diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 585e2dfa..6b15c5f8 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -470,13 +470,13 @@ class NetworkACLAddRuleAction(AbstractAction): dst_ip = "ALL" return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS else: - dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id) + dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 if dest_port_id == 1: dst_port = "ALL" else: - dst_port = self.manager.get_port_by_idx(dest_port_id) + dst_port = self.manager.get_port_by_idx(dest_port_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 return [ @@ -924,6 +924,15 @@ class ActionManager: :return: The constructed ActionManager. :rtype: ActionManager """ + ip_address_order = cfg["options"].pop("ip_address_order", {}) + ip_address_list = [] + for entry in ip_address_order: + node_ref = entry["node_ref"] + nic_num = entry["nic_num"] + node_obj = game.simulation.network.get_node_by_hostname(node_ref) + ip_address = node_obj.ethernet_port[nic_num].ip_address + ip_address_list.append(ip_address) + obj = cls( game=game, actions=cfg["action_list"], @@ -931,7 +940,7 @@ class ActionManager: **cfg["options"], protocols=game.options.protocols, ports=game.options.ports, - ip_address_list=None, + ip_address_list=ip_address_list or None, act_map=cfg.get("action_map"), ) From 88c1d16f1150734fa3db6c5c303ea322b25f94a6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 23 Jan 2024 14:34:05 +0000 Subject: [PATCH 37/49] Fix Router acl not clearing --- src/primaite/game/game.py | 30 +------ .../network/hardware/nodes/router.py | 79 ++++++++++++++++++- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 08098754..159f5bbb 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -13,11 +13,9 @@ from primaite.game.agent.rewards import RewardFunction from primaite.session.io import SessionIO, SessionIOSettings from primaite.simulator.network.hardware.base import NIC, NodeOperatingState from primaite.simulator.network.hardware.nodes.computer import Computer -from primaite.simulator.network.hardware.nodes.router import ACLAction, Router +from primaite.simulator.network.hardware.nodes.router import Router from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.hardware.nodes.switch import Switch -from primaite.simulator.network.transmission.network_layer import IPProtocol -from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.sim_container import Simulation from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.applications.web_browser import WebBrowser @@ -227,31 +225,7 @@ class PrimaiteGame: operating_state=NodeOperatingState.ON, ) elif n_type == "router": - new_node = Router( - hostname=node_cfg["hostname"], - num_ports=node_cfg.get("num_ports"), - operating_state=NodeOperatingState.ON, - ) - if "ports" in node_cfg: - for port_num, port_cfg in node_cfg["ports"].items(): - new_node.configure_port( - port=port_num, ip_address=port_cfg["ip_address"], subnet_mask=port_cfg["subnet_mask"] - ) - # new_node.enable_port(port_num) - if "acl" in node_cfg: - for r_num, r_cfg in node_cfg["acl"].items(): - # excuse the uncommon walrus operator ` := `. It's just here as a shorthand, to avoid repeating - # this: 'r_cfg.get('src_port')' - # Port/IPProtocol. TODO Refactor - new_node.acl.add_rule( - action=ACLAction[r_cfg["action"]], - src_port=None if not (p := r_cfg.get("src_port")) else Port[p], - dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], - protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], - src_ip_address=r_cfg.get("ip_address"), - dst_ip_address=r_cfg.get("ip_address"), - position=r_num, - ) + new_node = Router.from_config(node_cfg) else: _LOGGER.warning(f"invalid node type {n_type} in config") if "services" in node_cfg: diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 0234934d..bb923d62 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -9,6 +9,7 @@ from prettytable import MARKDOWN, PrettyTable from primaite.simulator.core import RequestManager, RequestType, SimComponent from primaite.simulator.network.hardware.base import ARPCache, ICMP, NIC, Node +from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState 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 @@ -89,6 +90,8 @@ class AccessControlList(SimComponent): implicit_rule: ACLRule max_acl_rules: int = 25 _acl: List[Optional[ACLRule]] = [None] * 24 + _default_config: dict[int, dict] = {} + """Config dict describing how the ACL list should look at episode start""" def __init__(self, **kwargs) -> None: if not kwargs.get("implicit_action"): @@ -110,6 +113,21 @@ class AccessControlList(SimComponent): """Reset the original state of the SimComponent.""" self.implicit_rule.reset_component_for_episode(episode) super().reset_component_for_episode(episode) + self._reset_rules_to_default() + + def _reset_rules_to_default(self) -> None: + """Clear all ACL rules and set them to the default rules config.""" + self._acl = [None] * (self.max_acl_rules - 1) + for r_num, r_cfg in self._default_config.items(): + self.add_rule( + action=ACLAction[r_cfg["action"]], + src_port=None if not (p := r_cfg.get("src_port")) else Port[p], + dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], + protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], + src_ip_address=r_cfg.get("ip_address"), + dst_ip_address=r_cfg.get("ip_address"), + position=r_num, + ) def _init_request_manager(self) -> RequestManager: rm = super()._init_request_manager() @@ -391,7 +409,6 @@ class RouteTable(SimComponent): sys_log: SysLog def set_original_state(self): - """Sets the original state.""" """Sets the original state.""" super().set_original_state() self._original_state["routes_orig"] = self.routes @@ -864,3 +881,63 @@ class Router(Node): ] ) print(table) + + @classmethod + def from_config(cls, cfg: dict) -> "Router": + """Create a router based on a config dict. + + Schema: + - hostname (str): unique name for this router. + - num_ports (int, optional): Number of network ports on the router. 8 by default + - ports (dict): Dict with integers from 1 - num_ports as keys. The values should be another dict specifying + ip_address and subnet_mask assigned to that ports (as strings) + - acl (dict): Dict with integers from 1 - max_acl_rules as keys. The key defines the position within the ACL + where the rule will be added (lower number is resolved first). The values should describe valid ACL + Rules as: + - action (str): either PERMIT or DENY + - src_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - dst_port (str, optional): the named port such as HTTP, HTTPS, or POSTGRES_SERVER + - protocol (str, optional): the named IP protocol such as ICMP, TCP, or UDP + - src_ip_address (str, optional): IP address octet written in base 10 + - dst_ip_address (str, optional): IP address octet written in base 10 + + Example config: + ``` + { + 'hostname': 'router_1', + 'num_ports': 5, + 'ports': { + 1: { + 'ip_address' : '192.168.1.1', + 'subnet_mask' : '255.255.255.0', + } + }, + 'acl' : { + 21: {'action': 'PERMIT', 'src_port': 'HTTP', dst_port: 'HTTP'}, + 22: {'action': 'PERMIT', 'src_port': 'ARP', 'dst_port': 'ARP'}, + 23: {'action': 'PERMIT', 'protocol': 'ICMP'}, + }, + } + ``` + + :param cfg: Router config adhering to schema described in main docstring body + :type cfg: dict + :return: Configured router. + :rtype: Router + """ + new = Router( + hostname=cfg["hostname"], + num_ports=cfg.get("num_ports"), + operating_state=NodeOperatingState.ON, + ) + if "ports" in cfg: + for port_num, port_cfg in cfg["ports"].items(): + new.configure_port( + port=port_num, + ip_address=port_cfg["ip_address"], + subnet_mask=port_cfg["subnet_mask"], + ) + if "acl" in cfg: + new.acl._default_config = cfg["acl"] # save the config to allow resetting + new.acl._reset_rules_to_default() # read the config and apply rules + return new From 0a65f32adfa47cd84c640f3ae9c1b0aed0bc1b94 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 09:27:08 +0000 Subject: [PATCH 38/49] Fix ACL observations --- src/primaite/game/agent/observations.py | 35 +++++++++++++------ .../network/hardware/nodes/router.py | 12 +++---- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/primaite/game/agent/observations.py b/src/primaite/game/agent/observations.py index cac5b91e..b7962827 100644 --- a/src/primaite/game/agent/observations.py +++ b/src/primaite/game/agent/observations.py @@ -1,5 +1,6 @@ """Manages the observation space for the agent.""" from abc import ABC, abstractmethod +from ipaddress import IPv4Address from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from gymnasium import spaces @@ -648,10 +649,13 @@ class AclObservation(AbstractObservation): # TODO: what if the ACL has more rules than num of max rules for obs space obs = {} - for i, rule_state in acl_state.items(): + acl_items = dict(acl_state.items()) + i = 1 # don't show rule 0 for compatibility reasons. + while i < self.num_rules + 1: + rule_state = acl_items[i] if rule_state is None: - obs[i + 1] = { - "position": i, + obs[i] = { + "position": i - 1, "permission": 0, "source_node_id": 0, "source_port": 0, @@ -660,15 +664,26 @@ class AclObservation(AbstractObservation): "protocol": 0, } else: - obs[i + 1] = { - "position": i, + src_ip = rule_state["src_ip_address"] + src_node_id = 1 if src_ip is None else self.node_to_id[IPv4Address(src_ip)] + dst_ip = rule_state["dst_ip_address"] + dst_node_ip = 1 if dst_ip is None else self.node_to_id[IPv4Address(dst_ip)] + src_port = rule_state["src_port"] + src_port_id = 1 if src_port is None else self.port_to_id[src_port] + dst_port = rule_state["dst_port"] + dst_port_id = 1 if dst_port is None else self.port_to_id[dst_port] + protocol = rule_state["protocol"] + protocol_id = 1 if protocol is None else self.protocol_to_id[protocol] + obs[i] = { + "position": i - 1, "permission": rule_state["action"], - "source_node_id": self.node_to_id[rule_state["src_ip_address"]], - "source_port": self.port_to_id[rule_state["src_port"]], - "dest_node_id": self.node_to_id[rule_state["dst_ip_address"]], - "dest_port": self.port_to_id[rule_state["dst_port"]], - "protocol": self.protocol_to_id[rule_state["protocol"]], + "source_node_id": src_node_id, + "source_port": src_port_id, + "dest_node_id": dst_node_ip, + "dest_port": dst_port_id, + "protocol": protocol_id, } + i += 1 return obs @property diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index bb923d62..0c5d0ce9 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -19,8 +19,8 @@ from primaite.simulator.system.core.sys_log import SysLog class ACLAction(Enum): """Enum for defining the ACL action types.""" - DENY = 0 PERMIT = 1 + DENY = 2 class ACLRule(SimComponent): @@ -66,11 +66,11 @@ class ACLRule(SimComponent): """ state = super().describe_state() state["action"] = self.action.value - state["protocol"] = self.protocol.value if self.protocol else None + state["protocol"] = self.protocol.name if self.protocol else None state["src_ip_address"] = str(self.src_ip_address) if self.src_ip_address else None - state["src_port"] = self.src_port.value if self.src_port else None + state["src_port"] = self.src_port.name if self.src_port else None state["dst_ip_address"] = str(self.dst_ip_address) if self.dst_ip_address else None - state["dst_port"] = self.dst_port.value if self.dst_port else None + state["dst_port"] = self.dst_port.name if self.dst_port else None return state @@ -733,8 +733,8 @@ class Router(Node): :return: A dictionary representing the current state. """ state = super().describe_state() - state["num_ports"] = (self.num_ports,) - state["acl"] = (self.acl.describe_state(),) + state["num_ports"] = self.num_ports + state["acl"] = self.acl.describe_state() return state def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: From 28acb5dcaed89364bb920591a8d1bd82f51e6da1 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 12:04:09 +0000 Subject: [PATCH 39/49] Populate step info in environment, and finish notebook --- .../config/_package_data/example_config.yaml | 2 +- .../example_config_2_rl_agents.yaml | 2 +- src/primaite/game/game.py | 5 +- .../notebooks/_package_data/uc2_network.png | Bin 0 -> 70887 bytes src/primaite/notebooks/uc2_demo.ipynb | 1038 +++++++++++++---- src/primaite/session/environment.py | 8 +- .../assets/configs/bad_primaite_session.yaml | 2 +- .../configs/eval_only_primaite_session.yaml | 2 +- tests/assets/configs/multi_agent_session.yaml | 2 +- .../assets/configs/test_primaite_session.yaml | 2 +- .../configs/train_only_primaite_session.yaml | 2 +- 11 files changed, 850 insertions(+), 215 deletions(-) create mode 100644 src/primaite/notebooks/_package_data/uc2_network.png diff --git a/src/primaite/config/_package_data/example_config.yaml b/src/primaite/config/_package_data/example_config.yaml index 7393f5a3..d8cd0099 100644 --- a/src/primaite/config/_package_data/example_config.yaml +++ b/src/primaite/config/_package_data/example_config.yaml @@ -31,7 +31,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/src/primaite/config/_package_data/example_config_2_rl_agents.yaml b/src/primaite/config/_package_data/example_config_2_rl_agents.yaml index c1e2ea81..6aa54487 100644 --- a/src/primaite/config/_package_data/example_config_2_rl_agents.yaml +++ b/src/primaite/config/_package_data/example_config_2_rl_agents.yaml @@ -25,7 +25,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 159f5bbb..146261f9 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -113,7 +113,7 @@ class PrimaiteGame: self.update_agents(sim_state) # Apply all actions to simulation as requests - self.apply_agent_actions() + agent_actions = self.apply_agent_actions() # noqa # Advance timestep self.advance_timestep() @@ -131,12 +131,15 @@ class PrimaiteGame: def apply_agent_actions(self) -> None: """Apply all actions to simulation as requests.""" + agent_actions = {} for agent in self.agents: obs = agent.observation_manager.current_observation rew = agent.reward_function.current_reward action_choice, options = agent.get_action(obs, rew) + agent_actions[agent.agent_name] = (action_choice, options) request = agent.format_request(action_choice, options) self.simulation.apply_request(request) + return agent_actions def advance_timestep(self) -> None: """Advance timestep.""" diff --git a/src/primaite/notebooks/_package_data/uc2_network.png b/src/primaite/notebooks/_package_data/uc2_network.png new file mode 100644 index 0000000000000000000000000000000000000000..20fa43c996bb7c3bce61778a7aa233ee4b8852f6 GIT binary patch literal 70887 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](_package_data/uc2_network.png)\n", + "\n", + "_(click image to enlarge)_\n", + "\n", + "The red agent deletes the contents of the database. When this happens, the web app cannot fetch data and users navigating to the website get a 404 error.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Network\n", + "\n", + "- The web server has:\n", + " - a web service that replies to user HTTP requests\n", + " - a database client that fetches data for the web service\n", + "- The database server has:\n", + " - a POSTGRES database service\n", + " - a database file which is accessed by the database service\n", + " - FTP client used for backing up the data to the backup_server\n", + "- The backup server has:\n", + " - a copy of the database file in a known good state\n", + " - FTP server that can send the backed up file back to the database server\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Green agent\n", + "\n", + "The green agent is logged onto client 2. It sometimes uses the web browser on client 2 to navigate to `http://arcd.com/users`. The web server replies with a status code 200 if the data is available on the database or 404 if not available." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Red agent\n", + "\n", + "The red agent waits a bit then sends a DELETE query to the database from client 1. If the delete is successful, the database file is flagged as compromised to signal that data is not available." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Blue agent\n", + "\n", + "The blue agent can view the entire network, but the health statuses of components are not updated until a scan is performed. The blue agent should restore the database file from backup after it was compromised. It can also prevent further attacks by blocking client 1 from reaching the database server. This can be done by removing client 1's network connection or adding ACL rules on the router to stop the packets from arriving." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Reinforcement learning details" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Scripted agents:\n", + "### Red\n", + "The red agent sits on client 1 and uses an application called DataManipulationBot whose sole purpose is to send a DELETE query to the database.\n", + "The red agent can choose one of two action each timestep:\n", + "1. do nothing\n", + "2. execute the data manipulation application\n", + "The schedule for selecting when to execute the application is controlled by three parameters:\n", + "- start time\n", + "- frequency\n", + "- variance\n", + "Attacks start at a random timestep between (start_time - variance) and (start_time + variance). After each attack, another is attempted after a random delay between (frequency - variance) and (frequency + variance) timesteps.\n", + "\n", + "The data manipulation app itself has an element of randomness because the attack has a probability of success. The default is 0.8 to succeed with the port scan step and 0.8 to succeed with the attack itself.\n", + "Upon a successful attack, the database file becomes corrupted which incurs a negative reward for the RL defender.\n", + "\n", + "The red agent does not use information about the state of the network to decide its action.\n", + "\n", + "### Green\n", + "The green agent sits on client 2 and uses the web browser application to send requests to the web server. The schedule of the green agent is currently random, meaning it will request webpage with a 50% probability, and do nothing with a 50% probability.\n", + "\n", + "When the green agent is blocked from accessing the data through the webpage, this incurs a negative reward to the RL defender." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Observation Space\n", + "\n", + "The blue agent's observation space is structured as nested dictionary with the following information:\n", + "```\n", + "\n", + "- NODES\n", + " - \n", + " - SERVICES\n", + " - \n", + " - operating_status\n", + " - health_status\n", + " - FOLDERS\n", + " - \n", + " - health_status\n", + " - FILES\n", + " - \n", + " - health_status\n", + " - NICS\n", + " - \n", + " - nic_status\n", + " - operating_status\n", + "- LINKS\n", + " - \n", + " - PROTOCOLS\n", + " - ALL\n", + " - load\n", + "- ACL\n", + " - \n", + " - position\n", + " - permission\n", + " - source_node_id\n", + " - source_port\n", + " - dest_node_id\n", + " - dest_port\n", + " - protocol\n", + "- ICS\n", + "```\n", + "\n", + "### Mappings\n", + "\n", + "The dict keys for `node_id` are in the following order:\n", + "|node_id|node name|\n", + "|--|--|\n", + "|1|domain_controller|\n", + "|2|web_server|\n", + "|3|database_server|\n", + "|4|backup_server|\n", + "|5|security_suite|\n", + "|6|client_1|\n", + "|7|client_2|\n", + "\n", + "Service 1 on node 2 (web_server) corresponds to the Web Server service. Other services are only there for padding to ensure that each node's observation space has the same shape. They are filled with zeroes.\n", + "\n", + "Folder 1 on node 3 corresponds to the database folder. File 1 in that folder corresponds to the database storage file. Other files and folders are only there for padding to ensure that each node's observation space has the same shape. They are filled with zeroes.\n", + "\n", + "The dict keys for `link_id` are in the following order:\n", + "|link_id|endpoint_a|endpoint_b|\n", + "|--|--|--|\n", + "|1|router_1|switch_1|\n", + "|1|router_1|switch_2|\n", + "|1|switch_1|domain_controller|\n", + "|1|switch_1|web_server|\n", + "|1|switch_1|database_server|\n", + "|1|switch_1|backup_server|\n", + "|1|switch_1|security_suite|\n", + "|1|switch_2|client_1|\n", + "|1|switch_2|client_2|\n", + "|1|switch_2|security_suite|\n", + "\n", + "The ACL rules in the observation space appear in the same order that they do in the actual ACL. Though, only the first 10 rules are shown, there are default rules lower down that cannot be changed by the agent. The extra rules just allow the network to function normally, by allowing pings, ARP traffic, etc.\n", + "\n", + "Most nodes have only 1 nic, so the observation for those is placed at NIC index 1 in the observation space. Only the security suite has 2 NICs, the second NIC in the observation space is the one that connects the security suite with swtich_2.\n", + "\n", + "The meaning of the services' operating_state is:\n", + "|operating_state|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|RUNNING|\n", + "|2|STOPPED|\n", + "|3|PAUSED|\n", + "|4|DISABLED|\n", + "|5|INSTALLING|\n", + "|6|RESTARTING|\n", + "\n", + "The meaning of the services' health_state is:\n", + "|health_state|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|GOOD|\n", + "|2|PATCHING|\n", + "|3|COMPROMISED|\n", + "|4|OVERWHELMED|\n", + "\n", + "The meaning of the files' and folders' health_state is:\n", + "|health_state|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|GOOD|\n", + "|2|COMPROMISED|\n", + "|3|CORRUPT|\n", + "|4|RESTORING|\n", + "|5|REPAIRING|\n", + "\n", + "The meaning of the NICs' operating_status is:\n", + "|operating_status|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|ENABLED|\n", + "|2|DISABLED|\n", + "\n", + "Link load has the following meaning:\n", + "|load|percent utilisation|\n", + "|--|--|\n", + "|0|exactly 0%|\n", + "|1|0-11%|\n", + "|2|11-22%|\n", + "|3|22-33%|\n", + "|4|33-44%|\n", + "|5|44-55%|\n", + "|6|55-66%|\n", + "|7|66-77%|\n", + "|8|77-88%|\n", + "|9|88-99%|\n", + "|10|exactly 100%|\n", + "\n", + "ACL permission has the following meaning:\n", + "|permission|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|ALLOW|\n", + "|2|DENY|\n", + "\n", + "ACL source / destination node ids actually correspond to IP addresses (since ACLs work with IP addresses)\n", + "|source / dest node id|ip_address|label|\n", + "|--|--|--|\n", + "|0| | UNUSED|\n", + "|1| |ALL addresses|\n", + "|2| 192.168.1.10 | domain_controller|\n", + "|3| 192.168.1.12 | web_server \n", + "|4| 192.168.1.14 | database_server|\n", + "|5| 192.168.1.16 | backup_server|\n", + "|6| 192.168.1.110 | security_suite (eth-1)|\n", + "|7| 192.168.10.21 | client_1|\n", + "|8| 192.168.10.22 | client_2|\n", + "|9| 192.168.10.110| security_suite (eth-2)|\n", + "\n", + "ACL source / destination port ids have the following encoding:\n", + "|port id|port number| port use |\n", + "|--|--|--|\n", + "|0||UNUSED|\n", + "|1||ALL|\n", + "|2|219|ARP|\n", + "|3|53|DNS|\n", + "|4|80|HTTP|\n", + "|5|5432|POSTGRES_SERVER|\n", + "\n", + "ACL protocol ids have the following encoding:\n", + "|protocol id|label|\n", + "|--|--|\n", + "|0|UNUSED|\n", + "|1|ALL|\n", + "|2|ICMP|\n", + "|3|TCP|\n", + "|4|UDP|\n", + "\n", + "protocol" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Action Space\n", + "\n", + "The blue agent chooses from a list of 54 pre-defined actions. The full list is defined in the `action_map` in the config. The most important ones are explained here:\n", + "\n", + "- `0`: Do nothing\n", + "- `1`: Scan the web service - this refreshes the health status in the observation space\n", + "- `9`: Scan the database file - this refreshes the health status of the database file\n", + "- `13`: Patch the database service - This triggers the database to restore data from the backup server\n", + "- `19`: Shut down client 1\n", + "- `22`: Block outgoing traffic from client 1\n", + "- `26`: Block TCP traffic from client 1 to the database node\n", + "- `28-37`: Remove ACL rules 1-10\n", + "- `42`: Disconnect client 1 from the network\n", + "\n", + "The other actions will either have no effect or will negatively impact the network, so the blue agent should avoid taking other actions, and learn about these actions." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reward Function\n", + "\n", + "The blue agent's reward is calculated using two measures:\n", + "1. Whether the database file is in a good state (+1 for good, -1 for corrupted, 0 for any other state)\n", + "2. Whether the green agent's most recent webpage request was successful (+1 for a `200` return code, -1 for a `404` return code and 0 otherwise).\n", + "These two components are averaged to get the final reward.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Demonstration" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "First, load the required modules" + ] + }, { "cell_type": "code", "execution_count": 1, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n", - "2023-11-26 23:25:47,985\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2023-11-26 23:25:51,213\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2023-11-26 23:25:51,491\tWARNING __init__.py:10 -- PG has/have been moved to `rllib_contrib` and will no longer be maintained by the RLlib team. You can still use it/them normally inside RLlib util Ray 2.8, but from Ray 2.9 on, all `rllib_contrib` algorithms will no longer be part of the core repo, and will therefore have to be installed separately with pinned dependencies for e.g. ray[rllib] and other packages! See https://github.com/ray-project/ray/tree/master/rllib_contrib#rllib-contrib for more information on the RLlib contrib effort.\n" - ] - } - ], + "outputs": [], "source": [ - "from primaite.session.session import PrimaiteSession\n", - "from primaite.game.game import PrimaiteGame\n", - "from primaite.config.load import example_config_path\n", - "\n", - "from primaite.simulator.system.services.database.database_service import DatabaseService\n", - "\n", - "import yaml" + "%load_ext autoreload\n", + "%autoreload 2" ] }, { @@ -36,61 +339,181 @@ "name": "stderr", "output_type": "stream", "text": [ - "2023-11-26 23:25:51,579::ERROR::primaite.simulator.network.hardware.base::175::NIC a9:92:0a:5e:1b:e4/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,580::ERROR::primaite.simulator.network.hardware.base::175::NIC ef:03:23:af:3c:19/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,581::ERROR::primaite.simulator.network.hardware.base::175::NIC ae:cf:83:2f:94:17/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,582::ERROR::primaite.simulator.network.hardware.base::175::NIC 4c:b2:99:e2:4a:5d/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,583::ERROR::primaite.simulator.network.hardware.base::175::NIC b9:eb:f9:c2:17:2f/127.0.0.1 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,590::ERROR::primaite.simulator.network.hardware.base::175::NIC cb:df:ca:54:be:01/192.168.1.10 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,595::ERROR::primaite.simulator.network.hardware.base::175::NIC 6e:32:12:da:4d:0d/192.168.1.12 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,600::ERROR::primaite.simulator.network.hardware.base::175::NIC 58:6e:9b:a7:68:49/192.168.1.14 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,604::ERROR::primaite.simulator.network.hardware.base::175::NIC 33:db:a6:40:dd:a3/192.168.1.16 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,608::ERROR::primaite.simulator.network.hardware.base::175::NIC 72:aa:2b:c0:4c:5f/192.168.1.110 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,610::ERROR::primaite.simulator.network.hardware.base::175::NIC 11:d7:0e:90:d9:a4/192.168.10.110 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,614::ERROR::primaite.simulator.network.hardware.base::175::NIC 86:2b:a4:e5:4d:0f/192.168.10.21 cannot be enabled as it is not connected to a Link\n", - "2023-11-26 23:25:51,631::ERROR::primaite.simulator.network.hardware.base::175::NIC af:ad:8f:84:f1:db/192.168.10.22 cannot be enabled as it is not connected to a Link\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "installing DNSServer on node domain_controller\n", - "installing DatabaseClient on node web_server\n", - "installing WebServer on node web_server\n", - "installing DatabaseService on node database_server\n", - "installing FTPClient on node database_server\n", - "installing FTPServer on node backup_server\n", - "installing DNSClient on node client_1\n", - "installing DNSClient on node client_2\n" + "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n", + "2024-01-25 11:19:29,199\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", + "2024-01-25 11:19:31,924\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n" ] } ], "source": [ + "# Imports\n", + "from primaite.config.load import example_config_path\n", + "from primaite.session.environment import PrimaiteGymEnv\n", + "from primaite.game.game import PrimaiteGame\n", + "import yaml\n", + "from pprint import pprint\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Instantiate the environment. We also disable the agent observation flattening.\n", "\n", - "with open(example_config_path(),'r') as cfgfile:\n", - " cfg = yaml.safe_load(cfgfile)\n", - "game = PrimaiteGame.from_config(cfg)\n", - "net = game.simulation.network\n", - "database_server = net.get_node_by_hostname('database_server')\n", - "web_server = net.get_node_by_hostname('web_server')\n", - "client_1 = net.get_node_by_hostname('client_1')\n", - "\n", - "db_service = database_server.software_manager.software[\"DatabaseService\"]\n", - "db_client = web_server.software_manager.software[\"DatabaseClient\"]\n", - "# db_client.run()\n", - "db_manipulation_bot = client_1.software_manager.software[\"DataManipulationBot\"]\n", - "db_manipulation_bot.port_scan_p_of_success=1.0\n", - "db_manipulation_bot.data_manipulation_p_of_success=1.0\n" + "This cell will print the observation when the network is healthy. You should be able to verify Node file and service statuses against the description above." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Resetting environment, episode 0, avg. reward: 0.0\n", + "env created successfully\n", + "{'ACL': {1: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 0,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 2: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 1,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 3: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 2,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 4: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 3,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 5: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 4,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 6: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 5,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 7: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 6,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 8: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 7,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 9: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 8,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0},\n", + " 10: {'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'permission': 0,\n", + " 'position': 9,\n", + " 'protocol': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0}},\n", + " 'ICS': 0,\n", + " 'LINKS': {1: {'PROTOCOLS': {'ALL': 1}},\n", + " 2: {'PROTOCOLS': {'ALL': 1}},\n", + " 3: {'PROTOCOLS': {'ALL': 1}},\n", + " 4: {'PROTOCOLS': {'ALL': 1}},\n", + " 5: {'PROTOCOLS': {'ALL': 1}},\n", + " 6: {'PROTOCOLS': {'ALL': 1}},\n", + " 7: {'PROTOCOLS': {'ALL': 1}},\n", + " 8: {'PROTOCOLS': {'ALL': 1}},\n", + " 9: {'PROTOCOLS': {'ALL': 1}},\n", + " 10: {'PROTOCOLS': {'ALL': 1}}},\n", + " 'NODES': {1: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 2: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 3: {'FOLDERS': {1: {'FILES': {1: {'health_status': 1}},\n", + " 'health_status': 1}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 4: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 5: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 6: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 7: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}},\n", + " 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1}}}\n" + ] + } + ], "source": [ - "db_client.run()" + "# create the env\n", + "with open(example_config_path(), 'r') as f:\n", + " cfg = yaml.safe_load(f)\n", + "game = PrimaiteGame.from_config(cfg)\n", + "env = PrimaiteGymEnv(game = game)\n", + "# Don't flatten obs as we are not training an agent and we wish to see the dict-formatted observations\n", + "env.agent.flatten_obs = False\n", + "obs, info = env.reset()\n", + "print('env created successfully')\n", + "pprint(obs)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The red agent will start attacking at some point between step 20 and 30.\n", + "\n", + "The red agent has a random chance of failing its attack, so you may need run the following cell multiple times until the reward goes from 1.0 to -1.0." ] }, { @@ -99,18 +522,53 @@ "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 1, Red action: DONOTHING, Blue reward:1.0\n", + "step: 2, Red action: DONOTHING, Blue reward:1.0\n", + "step: 3, Red action: DONOTHING, Blue reward:1.0\n", + "step: 4, Red action: DONOTHING, Blue reward:1.0\n", + "step: 5, Red action: DONOTHING, Blue reward:1.0\n", + "step: 6, Red action: DONOTHING, Blue reward:1.0\n", + "step: 7, Red action: DONOTHING, Blue reward:1.0\n", + "step: 8, Red action: DONOTHING, Blue reward:1.0\n", + "step: 9, Red action: DONOTHING, Blue reward:1.0\n", + "step: 10, Red action: DONOTHING, Blue reward:1.0\n", + "step: 11, Red action: DONOTHING, Blue reward:1.0\n", + "step: 12, Red action: DONOTHING, Blue reward:1.0\n", + "step: 13, Red action: DONOTHING, Blue reward:1.0\n", + "step: 14, Red action: DONOTHING, Blue reward:1.0\n", + "step: 15, Red action: DONOTHING, Blue reward:1.0\n", + "step: 16, Red action: DONOTHING, Blue reward:1.0\n", + "step: 17, Red action: DONOTHING, Blue reward:1.0\n", + "step: 18, Red action: DONOTHING, Blue reward:1.0\n", + "step: 19, Red action: DONOTHING, Blue reward:1.0\n", + "step: 20, Red action: DONOTHING, Blue reward:1.0\n", + "step: 21, Red action: DONOTHING, Blue reward:1.0\n", + "step: 22, Red action: DONOTHING, Blue reward:1.0\n", + "step: 23, Red action: DONOTHING, Blue reward:1.0\n", + "step: 24, Red action: DONOTHING, Blue reward:1.0\n", + "step: 25, Red action: DONOTHING, Blue reward:1.0\n", + "step: 26, Red action: DONOTHING, Blue reward:1.0\n", + "step: 27, Red action: NODE_APPLICATION_EXECUTE, Blue reward:0.0\n", + "step: 28, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 29, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 30, Red action: DONOTHING, Blue reward:-1.0\n" + ] } ], "source": [ - "db_service.backup_database()" + "for step in range(30):\n", + " obs, reward, terminated, truncated, info = env.step(0)\n", + " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now the reward is -1, let's have a look at blue agent's observation." ] }, { @@ -119,27 +577,110 @@ "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" + "name": "stdout", + "output_type": "stream", + "text": [ + "{1: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 2: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 3: {'FOLDERS': {1: {'FILES': {1: {'health_status': 1}}, 'health_status': 1}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 4: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 5: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 6: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 7: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1}}\n" + ] } ], "source": [ - "db_client.query(\"SELECT\")" + "pprint(obs['NODES'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The true statuses of the database file and webapp are not updated. The blue agent needs to perform a scan to see that they have degraded." ] }, { "cell_type": "code", "execution_count": 6, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{1: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 2: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 3, 'operating_status': 1}},\n", + " 'operating_status': 1},\n", + " 3: {'FOLDERS': {1: {'FILES': {1: {'health_status': 2}}, 'health_status': 1}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 4: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 5: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 6: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1},\n", + " 7: {'FOLDERS': {1: {'FILES': {1: {'health_status': 0}}, 'health_status': 0}},\n", + " 'NICS': {1: {'nic_status': 1}, 2: {'nic_status': 0}},\n", + " 'SERVICES': {1: {'health_status': 0, 'operating_status': 0}},\n", + " 'operating_status': 1}}\n" + ] + } + ], "source": [ - "db_manipulation_bot.run()" + "obs, reward, terminated, truncated, info = env.step(9) # scan database file\n", + "obs, reward, terminated, truncated, info = env.step(1) # scan webapp service\n", + "pprint(obs['NODES'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now service 1 on node 2 has `health_status = 3`, indicating that the webapp is compromised.\n", + "File 1 in folder 1 on node 3 has `health_status = 2`, indicating that the database file is compromised." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The blue agent can now patch the database to restore the file to a good health status." ] }, { @@ -148,130 +689,221 @@ "metadata": {}, "outputs": [ { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "db_client.query(\"SELECT\")" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "db_service.restore_backup()" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "db_client.query(\"SELECT\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "db_manipulation_bot.run()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "client_1.ping(database_server.ethernet_port[1].ip_address)" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [], - "source": [ - "from pydantic import validate_call, BaseModel" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "class A(BaseModel):\n", - " x:int\n", - "\n", - " @validate_call\n", - " def increase_x(self, by:int) -> None:\n", - " self.x += 1" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ - "my_a = A(x=3)" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": {}, - "outputs": [ - { - "ename": "ValidationError", - "evalue": "1 validation error for increase_x\n0\n Input should be a valid integer, got a number with a fractional part [type=int_from_float, input_value=3.2, input_type=float]\n For further information visit https://errors.pydantic.dev/2.1/v/int_from_float", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValidationError\u001b[0m Traceback (most recent call last)", - "\u001b[1;32m/home/cade/repos/PrimAITE/src/primaite/notebooks/uc2_demo.ipynb Cell 15\u001b[0m line \u001b[0;36m1\n\u001b[0;32m----> 1\u001b[0m my_a\u001b[39m.\u001b[39;49mincrease_x(\u001b[39m3.2\u001b[39;49m)\n", - "File \u001b[0;32m~/repos/PrimAITE/venv/lib/python3.10/site-packages/pydantic/_internal/_validate_call.py:91\u001b[0m, in \u001b[0;36mValidateCallWrapper.__call__\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 90\u001b[0m \u001b[39mdef\u001b[39;00m \u001b[39m__call__\u001b[39m(\u001b[39mself\u001b[39m, \u001b[39m*\u001b[39margs: Any, \u001b[39m*\u001b[39m\u001b[39m*\u001b[39mkwargs: Any) \u001b[39m-\u001b[39m\u001b[39m>\u001b[39m Any:\n\u001b[0;32m---> 91\u001b[0m res \u001b[39m=\u001b[39m \u001b[39mself\u001b[39;49m\u001b[39m.\u001b[39;49m__pydantic_validator__\u001b[39m.\u001b[39;49mvalidate_python(pydantic_core\u001b[39m.\u001b[39;49mArgsKwargs(args, kwargs))\n\u001b[1;32m 92\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m__return_pydantic_validator__:\n\u001b[1;32m 93\u001b[0m \u001b[39mreturn\u001b[39;00m \u001b[39mself\u001b[39m\u001b[39m.\u001b[39m__return_pydantic_validator__\u001b[39m.\u001b[39mvalidate_python(res)\n", - "\u001b[0;31mValidationError\u001b[0m: 1 validation error for increase_x\n0\n Input should be a valid integer, got a number with a fractional part [type=int_from_float, input_value=3.2, input_type=float]\n For further information visit https://errors.pydantic.dev/2.1/v/int_from_float" + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 33\n", + "Red action: DONOTHING\n", + "Green action: DONOTHING\n", + "Blue reward:-1.0\n" ] } ], "source": [ - "my_a.increase_x(3.2)" + "obs, reward, terminated, truncated, info = env.step(13) # patch the database\n", + "print(f\"step: {env.game.step_counter}\")\n", + "print(f\"Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_2_green_user'][0]}\" )\n", + "print(f\"Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The patching takes two steps, so the reward hasn't changed yet. Let's do nothing for another timestep, the reward should improve.\n", + "\n", + "The reward will be 0 as soon as the file finishes restoring. Then, the reward will increase to 1 when the green agent makes a request. (Because the webapp access part of the reward does not update until a successful request is made.)\n", + "\n", + "Run the following cell until the green action is `NODE_APPLICATION_EXECUTE`, then the reward should become 1. If you run it enough times, another red attack will happen and the reward will drop again." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 44\n", + "Red action: DONOTHING\n", + "Green action: NODE_APPLICATION_EXECUTE\n", + "Blue reward:-1.0\n" + ] + } + ], + "source": [ + "obs, reward, terminated, truncated, info = env.step(0) # patch the database\n", + "print(f\"step: {env.game.step_counter}\")\n", + "print(f\"Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}\" )\n", + "print(f\"Green action: {info['agent_actions']['client_2_green_user'][0]}\" )\n", + "print(f\"Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The blue agent can prevent attacks by implementing an ACL rule to stop client_1 from sending POSTGRES traffic to the database. (Let's also patch the database file to get the reward back up.)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "step: 107, Red action: DONOTHING, Blue reward:1.0\n", + "step: 108, Red action: DONOTHING, Blue reward:1.0\n", + "step: 109, Red action: DONOTHING, Blue reward:1.0\n", + "step: 110, Red action: DONOTHING, Blue reward:1.0\n", + "step: 111, Red action: DONOTHING, Blue reward:1.0\n", + "step: 112, Red action: DONOTHING, Blue reward:1.0\n", + "step: 113, Red action: DONOTHING, Blue reward:1.0\n", + "step: 114, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", + "step: 115, Red action: DONOTHING, Blue reward:1.0\n", + "step: 116, Red action: DONOTHING, Blue reward:1.0\n", + "step: 117, Red action: DONOTHING, Blue reward:1.0\n", + "step: 118, Red action: DONOTHING, Blue reward:1.0\n", + "step: 119, Red action: DONOTHING, Blue reward:1.0\n", + "step: 120, Red action: DONOTHING, Blue reward:1.0\n", + "step: 121, Red action: DONOTHING, Blue reward:1.0\n", + "step: 122, Red action: DONOTHING, Blue reward:1.0\n", + "step: 123, Red action: DONOTHING, Blue reward:1.0\n", + "step: 124, Red action: DONOTHING, Blue reward:1.0\n", + "step: 125, Red action: DONOTHING, Blue reward:1.0\n", + "step: 126, Red action: DONOTHING, Blue reward:1.0\n", + "step: 127, Red action: DONOTHING, Blue reward:1.0\n", + "step: 128, Red action: DONOTHING, Blue reward:1.0\n", + "step: 129, Red action: DONOTHING, Blue reward:1.0\n", + "step: 130, Red action: DONOTHING, Blue reward:1.0\n", + "step: 131, Red action: DONOTHING, Blue reward:1.0\n", + "step: 132, Red action: DONOTHING, Blue reward:1.0\n", + "step: 133, Red action: DONOTHING, Blue reward:1.0\n", + "step: 134, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", + "step: 135, Red action: DONOTHING, Blue reward:1.0\n", + "step: 136, Red action: DONOTHING, Blue reward:1.0\n" + ] + } + ], + "source": [ + "env.step(13) # Patch the database\n", + "print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )\n", + "\n", + "env.step(26) # Block client 1\n", + "print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )\n", + "\n", + "for step in range(30):\n", + " obs, reward, terminated, truncated, info = env.step(0) # do nothing\n", + " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, even though the red agent executes an attack, the reward stays at 1.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Let's also have a look at the ACL observation to verify our new ACL rule at position 5." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{1: {'position': 0,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 2: {'position': 1,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 3: {'position': 2,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 4: {'position': 3,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 5: {'position': 4,\n", + " 'permission': 2,\n", + " 'source_node_id': 7,\n", + " 'source_port': 1,\n", + " 'dest_node_id': 4,\n", + " 'dest_port': 1,\n", + " 'protocol': 3},\n", + " 6: {'position': 5,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 7: {'position': 6,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 8: {'position': 7,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 9: {'position': 8,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0},\n", + " 10: {'position': 9,\n", + " 'permission': 0,\n", + " 'source_node_id': 0,\n", + " 'source_port': 0,\n", + " 'dest_node_id': 0,\n", + " 'dest_port': 0,\n", + " 'protocol': 0}}" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "obs['ACL']" ] }, { diff --git a/src/primaite/session/environment.py b/src/primaite/session/environment.py index 6701f183..a3831bc1 100644 --- a/src/primaite/session/environment.py +++ b/src/primaite/session/environment.py @@ -29,7 +29,7 @@ class PrimaiteGymEnv(gymnasium.Env): # make ProxyAgent store the action chosen my the RL policy self.agent.store_action(action) # apply_agent_actions accesses the action we just stored - self.game.apply_agent_actions() + agent_actions = self.game.apply_agent_actions() self.game.advance_timestep() state = self.game.get_sim_state() @@ -39,7 +39,7 @@ class PrimaiteGymEnv(gymnasium.Env): reward = self.agent.reward_function.current_reward terminated = False truncated = self.game.calculate_truncated() - info = {} + info = {"agent_actions": agent_actions} # tell us what all the agents did for convenience. if self.game.save_step_metadata: self._write_step_metadata_json(action, state, reward) return next_obs, reward, terminated, truncated, info @@ -172,7 +172,7 @@ class PrimaiteRayMARLEnv(MultiAgentEnv): # 1. Perform actions for agent_name, action in actions.items(): self.agents[agent_name].store_action(action) - self.game.apply_agent_actions() + agent_actions = self.game.apply_agent_actions() # 2. Advance timestep self.game.advance_timestep() @@ -186,7 +186,7 @@ class PrimaiteRayMARLEnv(MultiAgentEnv): rewards = {name: agent.reward_function.current_reward for name, agent in self.agents.items()} terminateds = {name: False for name, _ in self.agents.items()} truncateds = {name: self.game.calculate_truncated() for name, _ in self.agents.items()} - infos = {} + infos = {"agent_actions": agent_actions} terminateds["__all__"] = len(self.terminateds) == len(self.agents) truncateds["__all__"] = self.game.calculate_truncated() if self.game.save_step_metadata: diff --git a/tests/assets/configs/bad_primaite_session.yaml b/tests/assets/configs/bad_primaite_session.yaml index 9070f246..e5458670 100644 --- a/tests/assets/configs/bad_primaite_session.yaml +++ b/tests/assets/configs/bad_primaite_session.yaml @@ -19,7 +19,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/eval_only_primaite_session.yaml b/tests/assets/configs/eval_only_primaite_session.yaml index e67f6606..767279ce 100644 --- a/tests/assets/configs/eval_only_primaite_session.yaml +++ b/tests/assets/configs/eval_only_primaite_session.yaml @@ -23,7 +23,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/multi_agent_session.yaml b/tests/assets/configs/multi_agent_session.yaml index 220ca21e..6290fa53 100644 --- a/tests/assets/configs/multi_agent_session.yaml +++ b/tests/assets/configs/multi_agent_session.yaml @@ -29,7 +29,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/test_primaite_session.yaml b/tests/assets/configs/test_primaite_session.yaml index d7e94cb6..89b88475 100644 --- a/tests/assets/configs/test_primaite_session.yaml +++ b/tests/assets/configs/test_primaite_session.yaml @@ -27,7 +27,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: diff --git a/tests/assets/configs/train_only_primaite_session.yaml b/tests/assets/configs/train_only_primaite_session.yaml index b89349c0..b9fa1216 100644 --- a/tests/assets/configs/train_only_primaite_session.yaml +++ b/tests/assets/configs/train_only_primaite_session.yaml @@ -23,7 +23,7 @@ game: - UDP agents: - - ref: client_1_green_user + - ref: client_2_green_user team: GREEN type: GreenWebBrowsingAgent observation_space: From 99723b6578d332d49eb739bb51bda1c0d1a5ef99 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 12:33:18 +0000 Subject: [PATCH 40/49] Update notebook with more images. --- .../notebooks/_package_data/uc2_attack.png | Bin 0 -> 112286 bytes src/primaite/notebooks/uc2_demo.ipynb | 6 +++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 src/primaite/notebooks/_package_data/uc2_attack.png diff --git a/src/primaite/notebooks/_package_data/uc2_attack.png b/src/primaite/notebooks/_package_data/uc2_attack.png new file mode 100644 index 0000000000000000000000000000000000000000..8b8df5ce8ddf74ad717c734cd52945164580f0f7 GIT binary patch literal 112286 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

q7w?w|>VNDk-YR_&_h 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 literal 0 HcmV?d00001 diff --git a/src/primaite/notebooks/uc2_demo.ipynb b/src/primaite/notebooks/uc2_demo.ipynb index 7bcfdd29..6a20e3e8 100644 --- a/src/primaite/notebooks/uc2_demo.ipynb +++ b/src/primaite/notebooks/uc2_demo.ipynb @@ -55,7 +55,11 @@ "source": [ "## Red agent\n", "\n", - "The red agent waits a bit then sends a DELETE query to the database from client 1. If the delete is successful, the database file is flagged as compromised to signal that data is not available." + "The red agent waits a bit then sends a DELETE query to the database from client 1. If the delete is successful, the database file is flagged as compromised to signal that data is not available.\n", + "\n", + "[](_package_data/uc2_attack.png)\n", + "\n", + "_(click image to enlarge)_" ] }, { From e7aac754a08772f771c4dc895376eb89c453afe1 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 12:38:28 +0000 Subject: [PATCH 41/49] Update the changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 227cec69..cb2f418b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Fixed a bug where ACL rules were not resetting on episode reset. +- Fixed a bug where blue agent's ACL actions were being applied against the wrong IP addresses +- Fixed a bug where deleted files and folders did not reset correctly on episode reset. +- Fixed a bug where service health status was using the actual health state instead of the visible health state +- Fixed a bug where the database file health status was using the incorrect value for negative rewards +- Fixed a bug preventing file actions from reaching their intended file +- Made database patch correctly take 2 timesteps instead of being immediate +- Made database patch only possible when the software is compromised or good, it's no longer possible when the software is OFF or RESETTING +- Temporarily disable the blue agent file delete action due to crashes. This issue is resolved in another branch that will be merged into dev soon. +- Fix a bug where ACLs were not showing up correctly in the observation space. +- Added a notebook which explains Data manipulation scenario, demonstrates the attack, and shows off blue agent's action space, observation space, and reward function. - Made packet capture and system logging optional (off by default). To turn on, change the io_settings.save_pcap_logs and io_settings.save_sys_logs settings in the config. - Made observation space flattening optional (on by default). To turn off for an agent, change the agent_settings.flatten_obs setting in the config. - Fixed an issue where the data manipulation attack was triggered at episode start. From 73a75c497b612bbacb352373070c7391205d4c73 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 13:13:50 +0000 Subject: [PATCH 42/49] Fix test --- .../network/hardware/nodes/router.py | 21 ++++++++++-- .../assets/configs/bad_primaite_session.yaml | 17 ++++++++++ .../configs/eval_only_primaite_session.yaml | 17 ++++++++++ tests/assets/configs/multi_agent_session.yaml | 34 +++++++++++++++++++ .../assets/configs/test_primaite_session.yaml | 17 ++++++++++ .../configs/train_only_primaite_session.yaml | 17 ++++++++++ .../_simulator/_network/test_container.py | 24 ++++++++++--- 7 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 0c5d0ce9..41c14967 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -90,7 +90,7 @@ class AccessControlList(SimComponent): implicit_rule: ACLRule max_acl_rules: int = 25 _acl: List[Optional[ACLRule]] = [None] * 24 - _default_config: dict[int, dict] = {} + _default_config: Dict[int, dict] = {} """Config dict describing how the ACL list should look at episode start""" def __init__(self, **kwargs) -> None: @@ -109,6 +109,21 @@ class AccessControlList(SimComponent): vals_to_keep = {"implicit_action", "max_acl_rules", "acl"} self._original_state = self.model_dump(include=vals_to_keep, exclude_none=True) + for i, rule in enumerate(self._acl): + if not rule: + continue + self._default_config[i] = {"action": rule.action.name} + if rule.src_ip_address: + self._default_config[i]["src_ip"] = str(rule.src_ip_address) + if rule.dst_ip_address: + self._default_config[i]["dst_ip"] = str(rule.dst_ip_address) + if rule.src_port: + self._default_config[i]["src_port"] = rule.src_port.name + if rule.dst_port: + self._default_config[i]["dst_port"] = rule.dst_port.name + if rule.protocol: + self._default_config[i]["protocol"] = rule.protocol.name + def reset_component_for_episode(self, episode: int): """Reset the original state of the SimComponent.""" self.implicit_rule.reset_component_for_episode(episode) @@ -124,8 +139,8 @@ class AccessControlList(SimComponent): src_port=None if not (p := r_cfg.get("src_port")) else Port[p], dst_port=None if not (p := r_cfg.get("dst_port")) else Port[p], protocol=None if not (p := r_cfg.get("protocol")) else IPProtocol[p], - src_ip_address=r_cfg.get("ip_address"), - dst_ip_address=r_cfg.get("ip_address"), + src_ip_address=r_cfg.get("src_ip"), + dst_ip_address=r_cfg.get("dst_ip"), position=r_num, ) diff --git a/tests/assets/configs/bad_primaite_session.yaml b/tests/assets/configs/bad_primaite_session.yaml index e5458670..4a1fc275 100644 --- a/tests/assets/configs/bad_primaite_session.yaml +++ b/tests/assets/configs/bad_primaite_session.yaml @@ -491,6 +491,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/eval_only_primaite_session.yaml b/tests/assets/configs/eval_only_primaite_session.yaml index 767279ce..c8ffa23f 100644 --- a/tests/assets/configs/eval_only_primaite_session.yaml +++ b/tests/assets/configs/eval_only_primaite_session.yaml @@ -502,6 +502,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/multi_agent_session.yaml b/tests/assets/configs/multi_agent_session.yaml index 6290fa53..6cd22694 100644 --- a/tests/assets/configs/multi_agent_session.yaml +++ b/tests/assets/configs/multi_agent_session.yaml @@ -509,6 +509,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: @@ -940,6 +957,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/test_primaite_session.yaml b/tests/assets/configs/test_primaite_session.yaml index 89b88475..99087798 100644 --- a/tests/assets/configs/test_primaite_session.yaml +++ b/tests/assets/configs/test_primaite_session.yaml @@ -507,6 +507,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/assets/configs/train_only_primaite_session.yaml b/tests/assets/configs/train_only_primaite_session.yaml index b9fa1216..c2842a06 100644 --- a/tests/assets/configs/train_only_primaite_session.yaml +++ b/tests/assets/configs/train_only_primaite_session.yaml @@ -503,6 +503,23 @@ agents: max_services_per_node: 2 max_nics_per_node: 8 max_acl_rules: 10 + ip_address_order: + - node_ref: domain_controller + nic_num: 1 + - node_ref: web_server + nic_num: 1 + - node_ref: database_server + nic_num: 1 + - node_ref: backup_server + nic_num: 1 + - node_ref: security_suite + nic_num: 1 + - node_ref: client_1 + nic_num: 1 + - node_ref: client_2 + nic_num: 1 + - node_ref: security_suite + nic_num: 2 reward_function: reward_components: diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py index e348838e..7667a59f 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/test_container.py +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -10,6 +10,22 @@ from primaite.simulator.system.applications.database_client import DatabaseClien from primaite.simulator.system.services.database.database_service import DatabaseService +def filter_keys_nested_item(data, keys): + stack = [(data, {})] + while stack: + current, filtered = stack.pop() + if isinstance(current, dict): + for k, v in current.items(): + if k in keys: + filtered[k] = filter_keys_nested_item(v, keys) + elif isinstance(v, (dict, list)): + stack.append((v, {})) + elif isinstance(current, list): + for item in current: + stack.append((item, {})) + return filtered + + @pytest.fixture(scope="function") def network(example_network) -> Network: assert len(example_network.routers) is 1 @@ -59,10 +75,10 @@ def test_reset_network(network): assert client_1.operating_state is NodeOperatingState.ON assert server_1.operating_state is NodeOperatingState.ON - - assert json.dumps(network.describe_state(), sort_keys=True, indent=2) == json.dumps( - state_before, sort_keys=True, indent=2 - ) + # don't worry if UUIDs change + a = filter_keys_nested_item(json.dumps(network.describe_state(), sort_keys=True, indent=2), ["uuid"]) + b = filter_keys_nested_item(json.dumps(state_before, sort_keys=True, indent=2), ["uuid"]) + assert a == b def test_creating_container(): From 4b98c1f630feccfde1eb12f5b3cebffef8170345 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:43:49 +0000 Subject: [PATCH 43/49] Update uc2 notebook --- src/primaite/notebooks/uc2_demo.ipynb | 115 ++++++++++++++------------ 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/src/primaite/notebooks/uc2_demo.ipynb b/src/primaite/notebooks/uc2_demo.ipynb index 6a20e3e8..679e8226 100644 --- a/src/primaite/notebooks/uc2_demo.ipynb +++ b/src/primaite/notebooks/uc2_demo.ipynb @@ -345,8 +345,8 @@ "text": [ "/home/cade/repos/PrimAITE/venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", " from .autonotebook import tqdm as notebook_tqdm\n", - "2024-01-25 11:19:29,199\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", - "2024-01-25 11:19:31,924\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n" + "2024-01-25 14:43:32,056\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n", + "2024-01-25 14:43:35,213\tINFO util.py:159 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n" ] } ], @@ -502,6 +502,9 @@ "# create the env\n", "with open(example_config_path(), 'r') as f:\n", " cfg = yaml.safe_load(f)\n", + " # set success probability to 1.0 to avoid rerunning cells.\n", + " cfg['simulation']['network']['nodes'][8]['applications'][0]['options']['data_manipulation_p_of_success'] = 1.0\n", + " cfg['simulation']['network']['nodes'][8]['applications'][0]['options']['port_scan_p_of_success'] = 1.0\n", "game = PrimaiteGame.from_config(cfg)\n", "env = PrimaiteGymEnv(game = game)\n", "# Don't flatten obs as we are not training an agent and we wish to see the dict-formatted observations\n", @@ -515,9 +518,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The red agent will start attacking at some point between step 20 and 30.\n", - "\n", - "The red agent has a random chance of failing its attack, so you may need run the following cell multiple times until the reward goes from 1.0 to -1.0." + "The red agent will start attacking at some point between step 20 and 30. When this happens, the reward will go from 1.0 to 0.0, and to -1.0 when the green agent tries to access the webpage." ] }, { @@ -529,10 +530,10 @@ "name": "stdout", "output_type": "stream", "text": [ - "step: 1, Red action: DONOTHING, Blue reward:1.0\n", - "step: 2, Red action: DONOTHING, Blue reward:1.0\n", - "step: 3, Red action: DONOTHING, Blue reward:1.0\n", - "step: 4, Red action: DONOTHING, Blue reward:1.0\n", + "step: 1, Red action: DONOTHING, Blue reward:0.5\n", + "step: 2, Red action: DONOTHING, Blue reward:0.5\n", + "step: 3, Red action: DONOTHING, Blue reward:0.5\n", + "step: 4, Red action: DONOTHING, Blue reward:0.5\n", "step: 5, Red action: DONOTHING, Blue reward:1.0\n", "step: 6, Red action: DONOTHING, Blue reward:1.0\n", "step: 7, Red action: DONOTHING, Blue reward:1.0\n", @@ -550,20 +551,22 @@ "step: 19, Red action: DONOTHING, Blue reward:1.0\n", "step: 20, Red action: DONOTHING, Blue reward:1.0\n", "step: 21, Red action: DONOTHING, Blue reward:1.0\n", - "step: 22, Red action: DONOTHING, Blue reward:1.0\n", - "step: 23, Red action: DONOTHING, Blue reward:1.0\n", - "step: 24, Red action: DONOTHING, Blue reward:1.0\n", - "step: 25, Red action: DONOTHING, Blue reward:1.0\n", - "step: 26, Red action: DONOTHING, Blue reward:1.0\n", - "step: 27, Red action: NODE_APPLICATION_EXECUTE, Blue reward:0.0\n", + "step: 22, Red action: NODE_APPLICATION_EXECUTE, Blue reward:0.0\n", + "step: 23, Red action: DONOTHING, Blue reward:0.0\n", + "step: 24, Red action: DONOTHING, Blue reward:0.0\n", + "step: 25, Red action: DONOTHING, Blue reward:0.0\n", + "step: 26, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 27, Red action: DONOTHING, Blue reward:-1.0\n", "step: 28, Red action: DONOTHING, Blue reward:-1.0\n", "step: 29, Red action: DONOTHING, Blue reward:-1.0\n", - "step: 30, Red action: DONOTHING, Blue reward:-1.0\n" + "step: 30, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 31, Red action: DONOTHING, Blue reward:-1.0\n", + "step: 32, Red action: DONOTHING, Blue reward:-1.0\n" ] } ], "source": [ - "for step in range(30):\n", + "for step in range(32):\n", " obs, reward, terminated, truncated, info = env.step(0)\n", " print(f\"step: {env.game.step_counter}, Red action: {info['agent_actions']['client_1_data_manipulation_red_bot'][0]}, Blue reward:{reward}\" )" ] @@ -696,9 +699,9 @@ "name": "stdout", "output_type": "stream", "text": [ - "step: 33\n", + "step: 35\n", "Red action: DONOTHING\n", - "Green action: DONOTHING\n", + "Green action: NODE_APPLICATION_EXECUTE\n", "Blue reward:-1.0\n" ] } @@ -724,17 +727,17 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 8, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "step: 44\n", + "step: 36\n", "Red action: DONOTHING\n", "Green action: NODE_APPLICATION_EXECUTE\n", - "Blue reward:-1.0\n" + "Blue reward:0.0\n" ] } ], @@ -755,43 +758,45 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "step: 107, Red action: DONOTHING, Blue reward:1.0\n", - "step: 108, Red action: DONOTHING, Blue reward:1.0\n", - "step: 109, Red action: DONOTHING, Blue reward:1.0\n", - "step: 110, Red action: DONOTHING, Blue reward:1.0\n", - "step: 111, Red action: DONOTHING, Blue reward:1.0\n", - "step: 112, Red action: DONOTHING, Blue reward:1.0\n", - "step: 113, Red action: DONOTHING, Blue reward:1.0\n", - "step: 114, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", - "step: 115, Red action: DONOTHING, Blue reward:1.0\n", - "step: 116, Red action: DONOTHING, Blue reward:1.0\n", - "step: 117, Red action: DONOTHING, Blue reward:1.0\n", - "step: 118, Red action: DONOTHING, Blue reward:1.0\n", - "step: 119, Red action: DONOTHING, Blue reward:1.0\n", - "step: 120, Red action: DONOTHING, Blue reward:1.0\n", - "step: 121, Red action: DONOTHING, Blue reward:1.0\n", - "step: 122, Red action: DONOTHING, Blue reward:1.0\n", - "step: 123, Red action: DONOTHING, Blue reward:1.0\n", - "step: 124, Red action: DONOTHING, Blue reward:1.0\n", - "step: 125, Red action: DONOTHING, Blue reward:1.0\n", - "step: 126, Red action: DONOTHING, Blue reward:1.0\n", - "step: 127, Red action: DONOTHING, Blue reward:1.0\n", - "step: 128, Red action: DONOTHING, Blue reward:1.0\n", - "step: 129, Red action: DONOTHING, Blue reward:1.0\n", - "step: 130, Red action: DONOTHING, Blue reward:1.0\n", - "step: 131, Red action: DONOTHING, Blue reward:1.0\n", - "step: 132, Red action: DONOTHING, Blue reward:1.0\n", - "step: 133, Red action: DONOTHING, Blue reward:1.0\n", - "step: 134, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", - "step: 135, Red action: DONOTHING, Blue reward:1.0\n", - "step: 136, Red action: DONOTHING, Blue reward:1.0\n" + "step: 37, Red action: DONOTHING, Blue reward:0.0\n", + "step: 38, Red action: DONOTHING, Blue reward:0.0\n", + "step: 39, Red action: DONOTHING, Blue reward:1.0\n", + "step: 40, Red action: DONOTHING, Blue reward:1.0\n", + "step: 41, Red action: DONOTHING, Blue reward:1.0\n", + "step: 42, Red action: DONOTHING, Blue reward:1.0\n", + "step: 43, Red action: DONOTHING, Blue reward:1.0\n", + "step: 44, Red action: DONOTHING, Blue reward:1.0\n", + "step: 45, Red action: DONOTHING, Blue reward:1.0\n", + "step: 46, Red action: NODE_APPLICATION_EXECUTE, Blue reward:1.0\n", + "step: 47, Red action: DONOTHING, Blue reward:1.0\n", + "step: 48, Red action: DONOTHING, Blue reward:1.0\n", + "step: 49, Red action: DONOTHING, Blue reward:1.0\n", + "step: 50, Red action: DONOTHING, Blue reward:1.0\n", + "step: 51, Red action: DONOTHING, Blue reward:1.0\n", + "step: 52, Red action: DONOTHING, Blue reward:1.0\n", + "step: 53, Red action: DONOTHING, Blue reward:1.0\n", + "step: 54, Red action: DONOTHING, Blue reward:1.0\n", + "step: 55, Red action: DONOTHING, Blue reward:1.0\n", + "step: 56, Red action: DONOTHING, Blue reward:1.0\n", + "step: 57, Red action: DONOTHING, Blue reward:1.0\n", + "step: 58, Red action: DONOTHING, Blue reward:1.0\n", + "step: 59, Red action: DONOTHING, Blue reward:1.0\n", + "step: 60, Red action: DONOTHING, Blue reward:1.0\n", + "step: 61, Red action: DONOTHING, Blue reward:1.0\n", + "step: 62, Red action: DONOTHING, Blue reward:1.0\n", + "step: 63, Red action: DONOTHING, Blue reward:1.0\n", + "step: 64, Red action: DONOTHING, Blue reward:1.0\n", + "step: 65, Red action: DONOTHING, Blue reward:1.0\n", + "step: 66, Red action: DONOTHING, Blue reward:1.0\n", + "step: 67, Red action: DONOTHING, Blue reward:1.0\n", + "step: 68, Red action: DONOTHING, Blue reward:1.0\n" ] } ], @@ -823,7 +828,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -901,7 +906,7 @@ " 'protocol': 0}}" ] }, - "execution_count": 24, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } From 7f996ca16acf57c028991af52d79944efde0a2e5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:52:48 +0000 Subject: [PATCH 44/49] Make sure notebook images get copied --- src/primaite/setup/reset_demo_notebooks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/primaite/setup/reset_demo_notebooks.py b/src/primaite/setup/reset_demo_notebooks.py index a4ee4c4d..bcf89b6a 100644 --- a/src/primaite/setup/reset_demo_notebooks.py +++ b/src/primaite/setup/reset_demo_notebooks.py @@ -44,3 +44,12 @@ def run(overwrite_existing: bool = True) -> None: print(dst_fp) shutil.copy2(src_fp, dst_fp) _LOGGER.info(f"Reset example notebook: {dst_fp}") + + for src_fp in primaite_root.glob("notebooks/_package_data/*"): + dst_fp = example_notebooks_user_dir / "_package_data" / src_fp.name + if should_copy_file(src_fp, dst_fp, overwrite_existing): + if not Path.exists(example_notebooks_user_dir / "_package_data/"): + Path.mkdir(example_notebooks_user_dir / "_package_data/") + print(dst_fp) + shutil.copy2(src_fp, dst_fp) + _LOGGER.info(f"Copied notebook resource to: {dst_fp}") From 5a9eaeb1851c83d42bc69de240d34a137815edba Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:59:00 +0000 Subject: [PATCH 45/49] Update the readme --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec335108..416bd0ec 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Currently, the PrimAITE wheel can only be installed from GitHub. This may change #### Windows (PowerShell) **Prerequisites:** -* Manual install of Python >= 3.8 < 3.11 +* Manual install of Python >= 3.9 < 3.12 **Install:** @@ -56,7 +56,7 @@ primaite session #### Unix **Prerequisites:** -* Manual install of Python >= 3.8 < 3.11 +* Manual install of Python >= 3.9 < 3.12 ``` bash sudo add-apt-repository ppa:deadsnakes/ppa @@ -82,6 +82,7 @@ primaite session ``` + ### Developer Install from Source To make your own changes to PrimAITE, perform the install from source (developer install) @@ -138,3 +139,7 @@ make html cd docs .\make.bat html ``` + + +## Example notebooks +Check out the example notebooks to learn more about how PrimAITE works and how you can use it to train agents. They are automatically copied to your primaite installation directory when you run `primaite setup`. From 0056bfddeea4544bf0b783dfdac1c68e9fd248b8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 14:59:24 +0000 Subject: [PATCH 46/49] Bump version to 3.0.0b6 --- src/primaite/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/VERSION b/src/primaite/VERSION index 72f12ef8..43662e8c 100644 --- a/src/primaite/VERSION +++ b/src/primaite/VERSION @@ -1 +1 @@ -3.0.0b6dev +3.0.0b6 From 2ba05e73484c5af75b833a17fb4c1b3a9db23339 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 25 Jan 2024 15:17:09 +0000 Subject: [PATCH 47/49] Fixed being unable to specify all addresses in acl rule --- src/primaite/game/agent/actions.py | 18 ++++++++++++------ .../simulator/network/hardware/nodes/router.py | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/primaite/game/agent/actions.py b/src/primaite/game/agent/actions.py index 6b15c5f8..fa85dbf7 100644 --- a/src/primaite/game/agent/actions.py +++ b/src/primaite/game/agent/actions.py @@ -453,27 +453,33 @@ class NetworkACLAddRuleAction(AbstractAction): protocol = self.manager.get_internet_protocol_by_idx(protocol_id - 2) # subtract 2 to account for UNUSED=0 and ALL=1. - if source_ip_id in [0, 1]: + if source_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif source_ip_id == 1: src_ip = "ALL" - return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS else: src_ip = self.manager.get_ip_address_by_idx(source_ip_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 - if source_port_id == 1: + if source_port_id == 0: + return ["do_nothing"] # invalid formulation + elif source_port_id == 1: src_port = "ALL" else: src_port = self.manager.get_port_by_idx(source_port_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 - if dest_ip_id in (0, 1): + if source_ip_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_ip_id == 1: dst_ip = "ALL" - return ["do_nothing"] # NOT SUPPORTED, JUST DO NOTHING IF WE COME ACROSS THIS else: dst_ip = self.manager.get_ip_address_by_idx(dest_ip_id - 2) # subtract 2 to account for UNUSED=0, and ALL=1 - if dest_port_id == 1: + if dest_port_id == 0: + return ["do_nothing"] # invalid formulation + elif dest_port_id == 1: dst_port = "ALL" else: dst_port = self.manager.get_port_by_idx(dest_port_id - 2) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 41c14967..a17d2ebf 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -162,9 +162,9 @@ class AccessControlList(SimComponent): func=lambda request, context: self.add_rule( ACLAction[request[0]], None if request[1] == "ALL" else IPProtocol[request[1]], - IPv4Address(request[2]), + None if request[2] == "ALL" else IPv4Address(request[2]), None if request[3] == "ALL" else Port[request[3]], - IPv4Address(request[4]), + None if request[4] == "ALL" else IPv4Address(request[4]), None if request[5] == "ALL" else Port[request[5]], int(request[6]), ) From bea72aa6a99cf146bdcd35caf359ee38354fdd26 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 29 Jan 2024 12:28:44 +0000 Subject: [PATCH 48/49] Fix ftp client connection list --- src/primaite/simulator/system/services/ftp/ftp_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py index 7faa5d32..39bc57f0 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_client.py +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -89,6 +89,7 @@ class FTPClient(FTPServiceABC): f"{self.name}: Successfully connected to FTP Server " f"{dest_ip_address} via port {payload.ftp_command_args.value}" ) + self.add_connection(connection_id="server_connection", session_id=session_id) return True else: if is_reattempt: From def52f94e3a708898ac815c80d2133a98dc16d4b Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 30 Jan 2024 09:56:16 +0000 Subject: [PATCH 49/49] Add docstrings and update typos --- README.md | 4 ++-- src/primaite/simulator/file_system/folder.py | 1 - .../system/services/database/database_service.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 416bd0ec..7dfe15bd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Currently, the PrimAITE wheel can only be installed from GitHub. This may change #### Windows (PowerShell) **Prerequisites:** -* Manual install of Python >= 3.9 < 3.12 +* Manual install of Python >= 3.8 < 3.12 **Install:** @@ -56,7 +56,7 @@ primaite session #### Unix **Prerequisites:** -* Manual install of Python >= 3.9 < 3.12 +* Manual install of Python >= 3.8 < 3.12 ``` bash sudo add-apt-repository ppa:deadsnakes/ppa diff --git a/src/primaite/simulator/file_system/folder.py b/src/primaite/simulator/file_system/folder.py index ab862898..dae32cd5 100644 --- a/src/primaite/simulator/file_system/folder.py +++ b/src/primaite/simulator/file_system/folder.py @@ -276,7 +276,6 @@ class Folder(FileSystemItemABC): self.deleted_files[file.uuid] = file file.delete() self.sys_log.info(f"Removed file {file.name} (id: {file.uuid})") - # self._file_request_manager.remove_request(file.uuid) else: _LOGGER.debug(f"File with UUID {file.uuid} was not found.") diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index a7c2bb69..c9c4d6fa 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -151,6 +151,15 @@ class DatabaseService(Service): def _process_connect( self, connection_id: str, password: Optional[str] = None ) -> Dict[str, Union[int, Dict[str, bool]]]: + """Process an incoming connection request. + + :param connection_id: A unique identifier for the connection + :type connection_id: str + :param password: Supplied password. It must match self.password for connection success, defaults to None + :type password: Optional[str], optional + :return: Response to connection request containing success info. + :rtype: Dict[str, Union[int, Dict[str, bool]]] + """ status_code = 500 # Default internal server error if self.operating_state == ServiceOperatingState.RUNNING: status_code = 503 # service unavailable @@ -279,6 +288,7 @@ class DatabaseService(Service): return super().apply_timestep(timestep) def _update_patch_status(self) -> None: + """Perform a database restore when the patching countdown is finished.""" super()._update_patch_status() if self._patching_countdown is None: self.restore_backup()