From 4b5a73bd3241fd863dd92d841d6e71a32a27ecf0 Mon Sep 17 00:00:00 2001 From: "Czar.Echavez" Date: Tue, 3 Oct 2023 14:59:48 +0100 Subject: [PATCH 1/5] #1943: web server + client + tests + a few improvements to syslogging --- .../simulator/network/hardware/base.py | 10 +- .../network/hardware/nodes/computer.py | 19 +++ .../network/hardware/nodes/router.py | 2 + .../network/hardware/nodes/switch.py | 2 + src/primaite/simulator/network/networks.py | 9 +- .../simulator/network/protocols/http.py | 61 ++++++++ .../simulator/network/protocols/packet.py | 5 + .../system/applications/application.py | 12 -- .../system/applications/database_client.py | 34 ++++- .../system/applications/web_browser.py | 114 ++++++++++++--- .../simulator/system/core/session_manager.py | 2 +- .../simulator/system/core/software_manager.py | 4 +- .../services/database/database_service.py | 4 +- .../system/services/dns/dns_client.py | 30 ++-- .../system/services/dns/dns_server.py | 4 +- .../system/services/ftp/ftp_client.py | 78 +++++++--- .../system/services/ftp/ftp_server.py | 12 +- .../system/services/ftp/ftp_service.py | 33 ++++- .../red_services/data_manipulation_bot.py | 8 +- .../simulator/system/services/service.py | 17 ++- .../system/services/web_server/__init__.py | 0 .../services/web_server/web_server_service.py | 136 ++++++++++++++++++ src/primaite/simulator/system/software.py | 33 ++++- .../system/test_web_client_server.py | 19 +++ 24 files changed, 536 insertions(+), 112 deletions(-) create mode 100644 src/primaite/simulator/network/protocols/http.py create mode 100644 src/primaite/simulator/system/services/web_server/__init__.py create mode 100644 src/primaite/simulator/system/services/web_server/web_server_service.py create mode 100644 tests/integration_tests/system/test_web_client_server.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index dd2130d2..4263f835 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -714,7 +714,9 @@ class ARPCache: # Unmatched ARP Request if arp_packet.target_ip_address != from_nic.ip_address: - self.sys_log.info(f"Ignoring ARP request for {arp_packet.target_ip_address}") + self.sys_log.info( + f"Ignoring ARP request for {arp_packet.target_ip_address}. Current IP address is {from_nic.ip_address}" + ) return # Matched ARP request @@ -937,6 +939,12 @@ class Node(SimComponent): self.arp.nics = self.nics self.session_manager.software_manager = self.software_manager + self._install_system_software() + + def _install_system_software(self): + """Install System Software - software that is usually provided with the OS.""" + pass + def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py index 5452666b..61c62a5f 100644 --- a/src/primaite/simulator/network/hardware/nodes/computer.py +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -1,4 +1,8 @@ from primaite.simulator.network.hardware.base import NIC, Node +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.dns.dns_client import DNSClient +from primaite.simulator.system.services.ftp.ftp_client import FTPClient +from primaite.simulator.system.services.ftp.ftp_server import FTPServer class Computer(Node): @@ -36,3 +40,18 @@ class Computer(Node): def __init__(self, **kwargs): super().__init__(**kwargs) self.connect_nic(NIC(ip_address=kwargs["ip_address"], subnet_mask=kwargs["subnet_mask"])) + self._install_system_software() + + def _install_system_software(self): + """Install System Software - software that is usually provided with the OS.""" + # DNS Client + self.software_manager.install(DNSClient) + + # FTP + self.software_manager.install(FTPClient) + self.software_manager.install(FTPServer) + + # Web Browser + self.software_manager.install(WebBrowser) + + super()._install_system_software() diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 092680a7..90eb5935 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -596,6 +596,8 @@ class Router(Node): self.arp.nics = self.nics self.icmp.arp = self.arp + self._install_system_software() + def _get_port_of_nic(self, target_nic: NIC) -> Optional[int]: """ Retrieve the port number for a given NIC. diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py index b7cc1242..8b3fe5cd 100644 --- a/src/primaite/simulator/network/hardware/nodes/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -34,6 +34,8 @@ class Switch(Node): port.parent = self port.port_num = port_num + self._install_system_software() + def show(self, markdown: bool = False): """ Prints a table of the SwitchPorts on the Switch. diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index 63cb05e0..1ddeb82f 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -13,8 +13,8 @@ from primaite.simulator.system.services.database.database_service import Databas from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.ftp.ftp_client import FTPClient -from primaite.simulator.system.services.ftp.ftp_server import FTPServer from primaite.simulator.system.services.red_services.data_manipulation_bot import DataManipulationBot +from primaite.simulator.system.services.web_server.web_server_service import WebServerService def client_server_routed() -> Network: @@ -260,6 +260,8 @@ def arcd_uc2_network() -> Network: database_client.run() database_client.connect() + web_server.software_manager.install(WebServerService) + # register the web_server to a domain dns_server_service: DNSServer = domain_controller.software_manager.software["DNSServer"] # noqa dns_server_service.dns_register("arcd.com", web_server.ip_address) @@ -275,8 +277,6 @@ def arcd_uc2_network() -> Network: backup_server.power_on() network.connect(endpoint_b=backup_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[4]) - backup_server.software_manager.install(FTPServer) - # Security Suite security_suite = Server( hostname="security_suite", @@ -305,4 +305,7 @@ def arcd_uc2_network() -> Network: # Allow FTP requests router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.FTP, dst_port=Port.FTP, position=2) + # Open port 80 for web server + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.HTTP, dst_port=Port.HTTP, position=3) + return network diff --git a/src/primaite/simulator/network/protocols/http.py b/src/primaite/simulator/network/protocols/http.py new file mode 100644 index 00000000..4be0ed88 --- /dev/null +++ b/src/primaite/simulator/network/protocols/http.py @@ -0,0 +1,61 @@ +from enum import Enum + +from primaite.simulator.network.protocols.packet import DataPacket + + +class HTTPRequestMethod(Enum): + """Enum list of HTTP Request methods that can be handled by the simulation.""" + + GET = "GET" + """HTTP GET Method. Requests using GET should only retrieve data.""" + + HEAD = "HEAD" + """Asks for a response identical to a GET request, but without the response body.""" + + POST = "POST" + """Submit an entity to the specified resource, often causing a change in state or side effects on the server.""" + + PUT = "PUT" + """Replace all current representations of the target resource with the request payload.""" + + DELETE = "DELETE" + """Delete the specified resource.""" + + PATCH = "PATCH" + """Apply partial modifications to a resource.""" + + +class HTTPStatusCode(Enum): + """List of available HTTP Statuses.""" + + OK = 200 + """request has succeeded.""" + + BAD_REQUEST = 400 + """Payload cannot be parsed.""" + + UNAUTHORIZED = 401 + """Auth required.""" + + METHOD_NOT_ALLOWED = 405 + """Method is not supported by server.""" + + INTERNAL_SERVER_ERROR = 500 + """Error on the server side.""" + + +class HTTPRequestPacket(DataPacket): + """Class that represents an HTTP Request Packet.""" + + request_method: HTTPRequestMethod + """The HTTP Request method.""" + + request_url: str + """URL of request.""" + + +class HTTPResponsePacket(DataPacket): + """Class that reprensents an HTTP Response Packet.""" + + status_code: HTTPStatusCode = None + """Status code of the HTTP response.""" diff --git a/src/primaite/simulator/network/protocols/packet.py b/src/primaite/simulator/network/protocols/packet.py index 1adcc800..3c99aa68 100644 --- a/src/primaite/simulator/network/protocols/packet.py +++ b/src/primaite/simulator/network/protocols/packet.py @@ -1,9 +1,14 @@ +from typing import Any + from pydantic import BaseModel class DataPacket(BaseModel): """Data packet abstract class.""" + payload: Any = None + """Payload content of the packet.""" + packet_payload_size: float = 0 """Size of the packet.""" diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 30efd5b7..69b64aac 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -81,18 +81,6 @@ class Application(IOSoftware): """ pass - def send(self, payload: Any, session_id: str, **kwargs) -> bool: - """ - Sends a payload to the SessionManager. - - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. - - :param payload: The payload to send. - :return: True if successful, False otherwise. - """ - pass - def receive(self, payload: Any, session_id: str, **kwargs) -> bool: """ Receives a payload from the SessionManager. diff --git a/src/primaite/simulator/system/applications/database_client.py b/src/primaite/simulator/system/applications/database_client.py index 9d59a2f4..d021cb78 100644 --- a/src/primaite/simulator/system/applications/database_client.py +++ b/src/primaite/simulator/system/applications/database_client.py @@ -49,7 +49,7 @@ class DatabaseClient(Application): """ self.server_ip_address = server_ip_address self.server_password = server_password - self.sys_log.info(f"Configured the {self.name} with {server_ip_address=}, {server_password=}.") + self.sys_log.info(f"{self.name}: Configured the {self.name} with {server_ip_address=}, {server_password=}.") def connect(self) -> bool: """Connect to a Database Service.""" @@ -60,13 +60,25 @@ class DatabaseClient(Application): def _connect( self, server_ip_address: IPv4Address, password: Optional[str] = None, is_reattempt: bool = False ) -> bool: + """ + Connects the DatabaseClient to the DatabaseServer. + + :param: server_ip_address: IP address of the database server + :type: server_ip_address: IPv4Address + + :param: password: Password used to connect to the database server. Optional. + :type: password: Optional[str] + + :param: is_reattempt: True if the connect request has been reattempted. Default False + :type: is_reattempt: Optional[bool] + """ if is_reattempt: if self.connected: - self.sys_log.info(f"DatabaseClient connected to {server_ip_address} authorised") + self.sys_log.info(f"{self.name}: DatabaseClient connected to {server_ip_address} authorised") self.server_ip_address = server_ip_address return self.connected else: - self.sys_log.info(f"DatabaseClient connected to {server_ip_address} declined") + self.sys_log.info(f"{self.name}: DatabaseClient connected to {server_ip_address} declined") return False payload = {"type": "connect_request", "password": password} software_manager: SoftwareManager = self.software_manager @@ -83,15 +95,29 @@ class DatabaseClient(Application): payload={"type": "disconnect"}, dest_ip_address=self.server_ip_address, dest_port=self.port ) - self.sys_log.info(f"DatabaseClient disconnected from {self.server_ip_address}") + self.sys_log.info(f"{self.name}: DatabaseClient disconnected from {self.server_ip_address}") self.server_ip_address = None self.connected = False def _query(self, sql: str, query_id: str, is_reattempt: bool = False) -> bool: + """ + Send a query to the connected database server. + + :param: sql: SQL query to send to the database server. + :type: sql: str + + :param: query_id: ID of the query, used as reference + :type: query_id: str + + :param: is_reattempt: True if the query request has been reattempted. Default False + :type: is_reattempt: Optional[bool] + """ if is_reattempt: success = self._query_success_tracker.get(query_id) if success: + self.sys_log.info(f"{self.name}: Query successful {sql}") return True + self.sys_log.info(f"{self.name}: Unable to run query {sql}") return False else: software_manager: SoftwareManager = self.software_manager diff --git a/src/primaite/simulator/system/applications/web_browser.py b/src/primaite/simulator/system/applications/web_browser.py index 78d196b7..9d2c31b1 100644 --- a/src/primaite/simulator/system/applications/web_browser.py +++ b/src/primaite/simulator/system/applications/web_browser.py @@ -1,7 +1,12 @@ from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from typing import Dict, Optional +from urllib.parse import urlparse +from primaite.simulator.network.protocols.http import HTTPRequestMethod, HTTPRequestPacket, HTTPResponsePacket +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.dns.dns_client import DNSClient class WebBrowser(Application): @@ -11,12 +16,29 @@ class WebBrowser(Application): The application requests and loads web pages using its domain name and requesting IP addresses using DNS. """ - domain_name: str - "The domain name of the webpage." - domain_name_ip_address: Optional[IPv4Address] + domain_name_ip_address: Optional[IPv4Address] = None "The IP address of the domain name for the webpage." - history: Dict[str] - "A dict that stores all of the previous domain names." + + latest_response: HTTPResponsePacket = None + """Keeps track of the latest HTTP response.""" + + def __init__(self, **kwargs): + kwargs["name"] = "WebBrowser" + kwargs["protocol"] = IPProtocol.TCP + # default for web is port 80 + if kwargs.get("port") is None: + kwargs["port"] = Port.HTTP + + super().__init__(**kwargs) + self.run() + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of the WebBrowser. + + :return: A dictionary capturing the current state of the WebBrowser and its child objects. + """ + pass def reset_component_for_episode(self, episode: int): """ @@ -25,30 +47,84 @@ class WebBrowser(Application): This method ensures the Application is ready for a new episode, including resetting any stateful properties or statistics, and clearing any message queues. """ - self.domain_name = "" self.domain_name_ip_address = None - self.history = {} + self.latest_response = None - def send(self, payload: Any, session_id: str, **kwargs) -> bool: + def get_webpage(self, url: str) -> bool: + """ + Retrieve the webpage. + + This should send a request to the web server which also requests for a list of users + + :param: url: The address of the web page the browser requests + :type: url: str + """ + # reset latest response + self.latest_response = None + + try: + parsed_url = urlparse(url) + except Exception: + self.sys_log.error(f"{url} is not a valid URL") + return False + + # get the IP address of the domain name via DNS + dns_client: DNSClient = self.software_manager.software["DNSClient"] + + domain_exists = dns_client.check_domain_exists(target_domain=parsed_url.hostname) + + # if domain does not exist, the request fails + if not domain_exists: + return False + + # set current domain name IP address + self.domain_name_ip_address = dns_client.dns_cache[parsed_url.hostname] + + # create HTTPRequest payload + payload = HTTPRequestPacket(request_method=HTTPRequestMethod.GET, request_url=url) + + # send request + return self.send( + payload=payload, + dest_ip_address=self.domain_name_ip_address, + dest_port=parsed_url.port if parsed_url.port else Port.HTTP, + ) + + def send( + self, + payload: HTTPRequestPacket, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = Port.HTTP, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: """ Sends a payload to the SessionManager. - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. + :param payload: The payload to 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 payload: The payload to send. :return: True if successful, False otherwise. """ - pass + self.sys_log.info(f"{self.name}: Sending HTTP {payload.request_method.name} {payload.request_url}") - def receive(self, payload: Any, session_id: str, **kwargs) -> bool: + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) + + def receive(self, payload: HTTPResponsePacket, session_id: Optional[str] = None, **kwargs) -> bool: """ Receives a payload from the SessionManager. - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. - - :param payload: The payload to receive. + :param payload: The payload to be sent. + :param session_id: The Session ID the payload is to originate from. Optional. :return: True if successful, False otherwise. """ - pass + if not isinstance(payload, HTTPResponsePacket): + self.sys_log.error(f"{self.name} received a packet that is not an HTTPResponsePacket") + return False + self.sys_log.info(f"{self.name}: Received HTTP {payload.status_code.value}") + self.latest_response = payload + return True diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index 95ece9f9..360b5e73 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -193,7 +193,7 @@ class SessionManager: self.sessions_by_key[session_key] = session self.sessions_by_uuid[session.uuid] = session - outbound_nic.send_frame(frame) + 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 99445bf8..973b17b4 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -110,7 +110,7 @@ class SoftwareManager: dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = None, session_id: Optional[str] = None, - ): + ) -> bool: """ Send a payload to the SessionManager. @@ -119,7 +119,7 @@ class SoftwareManager: :param dest_port: The port of the payload destination. :param session_id: The Session ID the payload is to originate from. Optional. """ - self.session_manager.receive_payload_from_software_manager( + 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 ) diff --git a/src/primaite/simulator/system/services/database/database_service.py b/src/primaite/simulator/system/services/database/database_service.py index 62120fc7..73365de6 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -73,10 +73,10 @@ class DatabaseService(Service): if self.password == password: status_code = 200 # ok self.connections[session_id] = datetime.now() - self.sys_log.info(f"Connect request for {session_id=} authorised") + self.sys_log.info(f"{self.name}: Connect request for {session_id=} authorised") else: status_code = 401 # Unauthorised - self.sys_log.info(f"Connect request for {session_id=} declined") + self.sys_log.info(f"{self.name}: Connect request for {session_id=} declined") else: status_code = 404 # service not found return {"status_code": status_code, "type": "connect_response", "response": status_code == 200} diff --git a/src/primaite/simulator/system/services/dns/dns_client.py b/src/primaite/simulator/system/services/dns/dns_client.py index 56d5d8b4..620a9a32 100644 --- a/src/primaite/simulator/system/services/dns/dns_client.py +++ b/src/primaite/simulator/system/services/dns/dns_client.py @@ -78,13 +78,14 @@ class DNSClient(Service): # check if the domain is already in the DNS cache if target_domain in self.dns_cache: self.sys_log.info( - f"DNS Client: Domain lookup for {target_domain} successful, resolves to {self.dns_cache[target_domain]}" + f"{self.name}: Domain lookup for {target_domain} successful," + f"resolves to {self.dns_cache[target_domain]}" ) return True else: # return False if already reattempted if is_reattempt: - self.sys_log.info(f"DNS Client: Domain lookup for {target_domain} failed") + self.sys_log.info(f"{self.name}: Domain lookup for {target_domain} failed") return False else: # send a request to check if domain name exists in the DNS Server @@ -104,14 +105,13 @@ class DNSClient(Service): self, payload: DNSPacket, session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, **kwargs, ) -> bool: """ Sends a payload to the SessionManager. - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. - :param payload: The payload to be sent. :param dest_ip_address: The ip address of the payload destination. :param dest_port: The port of the payload destination. @@ -119,10 +119,11 @@ class DNSClient(Service): :return: True if successful, False otherwise. """ - # create DNS request packet - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager(payload=payload, session_id=session_id) - return True + self.sys_log.info(f"{self.name}: Sending DNS request to resolve {payload.dns_request.domain_name_request}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) def receive( self, @@ -133,9 +134,6 @@ class DNSClient(Service): """ Receives a payload from the SessionManager. - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. - :param payload: The payload to be sent. :param session_id: The Session ID the payload is to originate from. Optional. :return: True if successful, False otherwise. @@ -144,12 +142,16 @@ class DNSClient(Service): if not isinstance(payload, DNSPacket): _LOGGER.debug(f"{payload} is not a DNSPacket") return False - # cast payload into a DNS packet - payload: DNSPacket = payload + if payload.dns_reply is not None: # add the IP address to the client cache if payload.dns_reply.domain_name_ip_address: + self.sys_log.info( + f"{self.name}: Resolved domain name {payload.dns_request.domain_name_request} " + f"to {payload.dns_reply.domain_name_ip_address}" + ) self.dns_cache[payload.dns_request.domain_name_request] = payload.dns_reply.domain_name_ip_address return True + self.sys_log.error(f"Failed to resolve domain name {payload.dns_request.domain_name_request}") return False diff --git a/src/primaite/simulator/system/services/dns/dns_server.py b/src/primaite/simulator/system/services/dns/dns_server.py index c3c39595..90a350c8 100644 --- a/src/primaite/simulator/system/services/dns/dns_server.py +++ b/src/primaite/simulator/system/services/dns/dns_server.py @@ -96,13 +96,13 @@ class DNSServer(Service): payload: DNSPacket = payload if payload.dns_request is not None: self.sys_log.info( - f"DNS Server: Received domain lookup request for {payload.dns_request.domain_name_request} " + f"{self.name}: Received domain lookup request for {payload.dns_request.domain_name_request} " f"from session {session_id}" ) # generate a reply with the correct DNS IP address payload = payload.generate_reply(self.dns_lookup(payload.dns_request.domain_name_request)) self.sys_log.info( - f"DNS Server: Responding to domain lookup request for {payload.dns_request.domain_name_request} " + f"{self.name}: Responding to domain lookup request for {payload.dns_request.domain_name_request} " f"with ip address: {payload.dns_reply.domain_name_ip_address}" ) # send reply diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py index 33fe32be..4986d3a1 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_client.py +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -39,9 +39,12 @@ class FTPClient(FTPServiceABC): """ # if client service is down, return error if self.operating_state != ServiceOperatingState.RUNNING: + self.sys_log.error("FTP Client is not running") payload.status_code = FTPStatusCode.ERROR return payload + self.sys_log.info(f"{self.name}: Received FTP {payload.ftp_command.name} {payload.ftp_command_args}") + # process client specific commands, otherwise call super return super()._process_ftp_command(payload=payload, session_id=session_id, **kwargs) @@ -49,6 +52,7 @@ class FTPClient(FTPServiceABC): self, dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = Port.FTP, + session_id: Optional[str] = None, is_reattempt: Optional[bool] = False, ) -> bool: """ @@ -72,20 +76,27 @@ class FTPClient(FTPServiceABC): ftp_command=FTPCommand.PORT, ftp_command_args=Port.FTP, ) - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( - payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port - ) - if payload.status_code == FTPStatusCode.OK: - return True - else: - if is_reattempt: - # reattempt failed - return False + if self.send(payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id): + if payload.status_code == FTPStatusCode.OK: + self.sys_log.info( + f"{self.name}: Successfully connected to FTP Server " + f"{dest_ip_address} via port {payload.ftp_command_args.value}" + ) + return True else: - # try again - self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port, is_reattempt=True) + if is_reattempt: + # reattempt failed + self.sys_log.info( + f"{self.name}: Unable to connect to FTP Server " + f"{dest_ip_address} via port {payload.ftp_command_args.value}" + ) + return False + else: + # try again + self._connect_to_server( + dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, is_reattempt=True + ) def _disconnect_from_server( self, dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = Port.FTP @@ -119,6 +130,7 @@ class FTPClient(FTPServiceABC): dest_folder_name: str, dest_file_name: str, dest_port: Optional[Port] = Port.FTP, + session_id: Optional[str] = None, ) -> bool: """ Send a file to a target IP address. @@ -143,6 +155,9 @@ class FTPClient(FTPServiceABC): :param: dest_port: The open port of the machine that hosts the FTP Server. Default is Port.FTP. :type: dest_port: Optional[Port] + + :param: session_id: The id of the session + :type: session_id: Optional[str] """ # check if the file to transfer exists on the client file_to_transfer: File = self.file_system.get_file(folder_name=src_folder_name, file_name=src_file_name) @@ -151,10 +166,7 @@ class FTPClient(FTPServiceABC): return False # check if FTP is currently connected to IP - self.connected = self._connect_to_server( - dest_ip_address=dest_ip_address, - dest_port=dest_port, - ) + self.connected = self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) if not self.connected: return False @@ -166,6 +178,7 @@ class FTPClient(FTPServiceABC): dest_file_name=dest_file_name, dest_ip_address=dest_ip_address, dest_port=dest_port, + session_id=session_id, ) # send disconnect @@ -204,10 +217,7 @@ class FTPClient(FTPServiceABC): :type: dest_port: Optional[Port] """ # check if FTP is currently connected to IP - self.connected = self._connect_to_server( - dest_ip_address=dest_ip_address, - dest_port=dest_port, - ) + self.connected = self._connect_to_server(dest_ip_address=dest_ip_address, dest_port=dest_port) if not self.connected: return False @@ -232,12 +242,36 @@ class FTPClient(FTPServiceABC): # the payload should have ok status code if payload.status_code == FTPStatusCode.OK: - self.sys_log.info(f"File {src_folder_name}/{src_file_name} found in FTP server.") + self.sys_log.info(f"{self.name}: File {src_folder_name}/{src_file_name} found in FTP server.") return True else: - self.sys_log.error(f"File {src_folder_name}/{src_file_name} does not exist in FTP server") + self.sys_log.error(f"{self.name}: File {src_folder_name}/{src_file_name} does not exist in FTP server") return False + def send( + self, + payload: FTPPacket, + session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager. + + :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. + """ + self.sys_log.info(f"{self.name}: Sending FTP {payload.ftp_command.name} {payload.ftp_command_args}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) + def receive(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> bool: """ Receives a payload from the SessionManager. diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 83c883f1..af0728eb 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -4,7 +4,6 @@ from typing import Any, Dict, Optional from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.core.session_manager import Session from primaite.simulator.system.services.ftp.ftp_service import FTPServiceABC from primaite.simulator.system.services.service import ServiceOperatingState @@ -30,14 +29,6 @@ class FTPServer(FTPServiceABC): super().__init__(**kwargs) self.start() - def _get_session_details(self, session_id: str) -> Session: - """ - Returns the Session object from the given session id. - - :param: session_id: ID of the session that needs details retrieved - """ - return self.software_manager.session_manager.sessions_by_uuid[session_id] - def _process_ftp_command(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> FTPPacket: """ Process the command in the FTP Packet. @@ -50,8 +41,11 @@ class FTPServer(FTPServiceABC): # if server service is down, return error if self.operating_state != ServiceOperatingState.RUNNING: payload.status_code = FTPStatusCode.ERROR + self.sys_log.error("FTP Server not running") return payload + self.sys_log.info(f"{self.name}: Received FTP {payload.ftp_command.name} {payload.ftp_command_args}") + if session_id: session_details = self._get_session_details(session_id) diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index f47b8f64..b35b7e9e 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -5,7 +5,6 @@ from typing import Optional from primaite.simulator.file_system.file_system import File from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service @@ -54,7 +53,7 @@ class FTPServiceABC(Service, ABC): size=file_size, ) self.sys_log.info( - f"Created item in {self.sys_log.hostname}: {payload.ftp_command_args['dest_folder_name']}/" + 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']}" ) # file should exist @@ -103,12 +102,12 @@ class FTPServiceABC(Service, ABC): }, packet_payload_size=file.sim_size, ) - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( + self.sys_log.info(f"{self.name}: Sending file {file.folder.name}/{file.name}") + response = self.send( payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id ) - if payload.status_code == FTPStatusCode.OK: + if response and payload.status_code == FTPStatusCode.OK: return True return False @@ -146,3 +145,27 @@ class FTPServiceABC(Service, ABC): except Exception as e: self.sys_log.error(f"Unable to retrieve file from {self.sys_log.hostname}: {e}") return False + + def send( + self, + payload: FTPPacket, + session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager. + + :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. + """ + self.sys_log.info(f"{self.name}: Sending FTP {payload.ftp_command.name} {payload.ftp_command_args}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) 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 30643b32..996e6790 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 @@ -33,12 +33,14 @@ class DataManipulationBot(DatabaseClient): self.server_ip_address = server_ip_address self.payload = payload self.server_password = server_password - self.sys_log.info(f"Configured the {self.name} with {server_ip_address=}, {payload=}, {server_password=}.") + self.sys_log.info( + f"{self.name}: Configured the {self.name} with {server_ip_address=}, {payload=}, {server_password=}." + ) def run(self): """Run the DataManipulationBot.""" if self.server_ip_address and self.payload: - self.sys_log.info(f"Attempting to start the {self.name}") + self.sys_log.info(f"{self.name}: Attempting to start the {self.name}") super().run() if not self.connected: self.connect() @@ -46,4 +48,4 @@ class DataManipulationBot(DatabaseClient): self.query(self.payload) self.sys_log.info(f"{self.name} payload delivered: {self.payload}") else: - self.sys_log.error(f"Failed to start the {self.name} as it requires both a target_io_address and payload.") + self.sys_log.error(f"Failed to start the {self.name} as it requires both a target_ip_address and payload.") diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 20b92027..d79487a3 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,8 +1,10 @@ from enum import Enum +from ipaddress import IPv4Address from typing import Any, Dict, Optional from primaite import getLogger from primaite.simulator.core import Action, ActionManager +from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.software import IOSoftware _LOGGER = getLogger(__name__) @@ -76,20 +78,23 @@ class Service(IOSoftware): self, payload: Any, session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, **kwargs, ) -> bool: """ Sends a payload to the SessionManager. - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. - - :param: payload: The payload to send. - :param: session_id: The id of the session + :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. """ - self.software_manager.send_payload_to_session_manager(payload=payload, session_id=session_id) + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) def receive( self, diff --git a/src/primaite/simulator/system/services/web_server/__init__.py b/src/primaite/simulator/system/services/web_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/primaite/simulator/system/services/web_server/web_server_service.py b/src/primaite/simulator/system/services/web_server/web_server_service.py new file mode 100644 index 00000000..276cb57f --- /dev/null +++ b/src/primaite/simulator/system/services/web_server/web_server_service.py @@ -0,0 +1,136 @@ +from ipaddress import IPv4Address +from typing import Any, Optional + +from primaite.simulator.network.protocols.http import ( + HTTPRequestMethod, + HTTPRequestPacket, + HTTPResponsePacket, + HTTPStatusCode, +) +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 + + +class WebServerService(Service): + """Class used to represent a Web Server Service in simulation.""" + + def __init__(self, **kwargs): + kwargs["name"] = "WebServer" + kwargs["protocol"] = IPProtocol.TCP + # default for web is port 80 + if kwargs.get("port") is None: + kwargs["port"] = Port.HTTP + + super().__init__(**kwargs) + self._install_web_files() + self.start() + + def _install_web_files(self): + """ + Installs the files hosted by the web service. + + This is usually HTML, CSS, JS or PHP files requested by browsers to display the webpage. + """ + # index HTML main file + self.file_system.create_file(file_name="index.html", folder_name="primaite", real=True) + + def _process_http_request(self, payload: HTTPRequestPacket, session_id: Optional[str] = None) -> bool: + """ + Parse the HTTPRequestPacket. + + :param: payload: Payload containing th HTTPRequestPacket + :type: payload: HTTPRequestPacket + + :param: session_id: Session id of the http request + :type: session_id: Optional[str] + """ + response = HTTPResponsePacket() + + self.sys_log.info(f"{self.name}: Received HTTP {payload.request_method.name} {payload.request_url}") + + # check the type of HTTP request + if payload.request_method == HTTPRequestMethod.GET: + response = self._handle_get_request(payload=payload) + + elif payload.request_method == HTTPRequestMethod.POST: + pass + + else: + # send a method not allowed response + response.status_code = HTTPStatusCode.METHOD_NOT_ALLOWED + + # send response to web client + self.send(payload=response, session_id=session_id) + + # return true if response is OK + return response.status_code == HTTPStatusCode.OK + + def _handle_get_request(self, payload: HTTPRequestPacket) -> HTTPResponsePacket: + """ + Handle a GET HTTP request. + + :param: payload: HTTP request payload + :type: payload: HTTPRequestPacket + """ + response = HTTPResponsePacket(status_code=HTTPStatusCode.BAD_REQUEST, payload=payload) + try: + # get data from DatabaseServer + db_client: DatabaseClient = self.software_manager.software["DatabaseClient"] + # get all users + if db_client.query("SELECT * FROM user;"): + # query succeeded + response.status_code = HTTPStatusCode.OK + + return response + except Exception: + # something went wrong on the server + response.status_code = HTTPStatusCode.INTERNAL_SERVER_ERROR + return response + + def send( + self, + payload: HTTPResponsePacket, + session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + **kwargs, + ) -> bool: + """ + Sends a payload to the SessionManager. + + The specifics of how the payload is processed and whether a response payload + is generated should be implemented in subclasses. + + :param: payload: The payload to send. + :param: session_id: The id of the session + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. + + :return: True if successful, False otherwise. + """ + self.sys_log.info(f"{self.name}: Sending HTTP Response {payload.status_code}") + + return super().send( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs + ) + + def receive( + self, + payload: Any, + session_id: Optional[str] = None, + **kwargs, + ) -> bool: + """ + Receives a payload from the SessionManager. + + :param: payload: The payload to send. + :param: session_id: The id of the session. Optional. + """ + # check if the payload is an HTTPPacket + if not isinstance(payload, HTTPRequestPacket): + self.sys_log.error("Payload is not an HTTPPacket") + return False + + return self._process_http_request(payload=payload, session_id=session_id) diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 70c1bbf2..e8defd11 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -1,10 +1,12 @@ from abc import abstractmethod from enum import Enum +from ipaddress import IPv4Address from typing import Any, Dict, Optional from primaite.simulator.core import Action, ActionManager, SimComponent from primaite.simulator.file_system.file_system import FileSystem, Folder from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.session_manager import Session from primaite.simulator.system.core.sys_log import SysLog @@ -96,6 +98,14 @@ class Software(SimComponent): am.add_action("scan", Action(func=lambda request, context: self.scan())) return am + def _get_session_details(self, session_id: str) -> Session: + """ + Returns the Session object from the given session id. + + :param: session_id: ID of the session that needs details retrieved + """ + return self.software_manager.session_manager.sessions_by_uuid[session_id] + @abstractmethod def describe_state(self) -> Dict: """ @@ -209,18 +219,27 @@ class IOSoftware(Software): ) return state - def send(self, payload: Any, session_id: str, **kwargs) -> bool: + def send( + self, + payload: Any, + session_id: Optional[str] = None, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + **kwargs, + ) -> bool: """ Sends a payload to the SessionManager. - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. + :param payload: The payload to 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 payload: The payload to send. - :param session_id: The identifier of the session that the payload is associated with. - :param kwargs: Additional keyword arguments specific to the implementation. - :return: True if the payload was successfully sent, False otherwise. + :return: True if successful, False otherwise. """ + return self.software_manager.send_payload_to_session_manager( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id + ) def receive(self, payload: Any, session_id: str, **kwargs) -> bool: """ diff --git a/tests/integration_tests/system/test_web_client_server.py b/tests/integration_tests/system/test_web_client_server.py new file mode 100644 index 00000000..fee51297 --- /dev/null +++ b/tests/integration_tests/system/test_web_client_server.py @@ -0,0 +1,19 @@ +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.protocols.http import HTTPStatusCode +from primaite.simulator.system.applications.application import ApplicationOperatingState +from primaite.simulator.system.applications.web_browser import WebBrowser +from primaite.simulator.system.services.service import ServiceOperatingState + + +def test_web_page_get_request(uc2_network): + """Test to see if the client retrieves the correct web files.""" + client_1: Computer = uc2_network.get_node_by_hostname("client_1") + web_client: WebBrowser = client_1.software_manager.software["WebBrowser"] + web_client.run() + assert web_client.operating_state == ApplicationOperatingState.RUNNING + + assert web_client.get_webpage("http://arcd.com/index.html") is True + + # latest reponse should have status code 200 + assert web_client.latest_response is not None + assert web_client.latest_response.status_code == HTTPStatusCode.OK From 82da21b0737b194eb1a53438faa9a4c7f3c7f5f2 Mon Sep 17 00:00:00 2001 From: "Czar.Echavez" Date: Tue, 3 Oct 2023 16:56:35 +0100 Subject: [PATCH 2/5] \#1943: - changelog added - added documentation + example of using web server + web browser - extended web server so that it also accepts ip addresses - web server can differentiate between a normal page request and one that propagates into a DB request - rename WebServerService -> WebServer --- CHANGELOG.md | 1 + .../system/ftp_client_server.rst | 2 +- .../simulation_components/system/software.rst | 1 + .../web_browser_and_web_server_service.rst | 110 ++++++++++++++++++ src/primaite/simulator/network/networks.py | 4 +- .../system/applications/web_browser.py | 16 ++- .../system/services/dns/dns_client.py | 5 + .../services/web_server/web_server_service.py | 18 ++- .../system/test_web_client_server.py | 39 ++++++- 9 files changed, 180 insertions(+), 16 deletions(-) create mode 100644 docs/source/simulation_components/system/web_browser_and_web_server_service.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index 7147f82b..5d73f454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ SessionManager. - Data Manipulator Bot - A red agent service which sends a payload to a target machine. (By default this payload is a SQL query that breaks a database) - DNS Services: `DNSClient` and `DNSServer` - FTP Services: `FTPClient` and `FTPServer` +- HTTP Services: `WebBrowser` to simulate a web client and `WebServer` ## [2.0.0] - 2023-07-26 diff --git a/docs/source/simulation_components/system/ftp_client_server.rst b/docs/source/simulation_components/system/ftp_client_server.rst index 0e4aeea3..f6011de2 100644 --- a/docs/source/simulation_components/system/ftp_client_server.rst +++ b/docs/source/simulation_components/system/ftp_client_server.rst @@ -63,7 +63,7 @@ Implementation Example Usage ----------- +------------- Dependencies ^^^^^^^^^^^^ diff --git a/docs/source/simulation_components/system/software.rst b/docs/source/simulation_components/system/software.rst index 921dfb9e..b2985393 100644 --- a/docs/source/simulation_components/system/software.rst +++ b/docs/source/simulation_components/system/software.rst @@ -19,3 +19,4 @@ Contents data_manipulation_bot dns_client_server ftp_client_server + web_browser_and_web_server_service diff --git a/docs/source/simulation_components/system/web_browser_and_web_server_service.rst b/docs/source/simulation_components/system/web_browser_and_web_server_service.rst new file mode 100644 index 00000000..a02ac621 --- /dev/null +++ b/docs/source/simulation_components/system/web_browser_and_web_server_service.rst @@ -0,0 +1,110 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +Web Browser and Web Server Service +================================== + +Web Server Service +------------------ +Provides a Web Server simulation by extending the base Service class. + +Key capabilities +^^^^^^^^^^^^^^^^ + +- Simulates a web server with the capability to also request data from a database +- Allows the emulation of HTTP requests between client (e.g. a web browser) and server + - GET request sends a get all users request to the database server and returns an HTTP 200 status if the database is responsive +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +^^^^^ +- Install on a Node via the ``SoftwareManager`` to start the `WebServer`. +- Service runs on HTTP port 80 by default. (TODO: HTTPS) + +Implementation +^^^^^^^^^^^^^^ + +- HTTP request uses a ``HTTPRequestPacket`` object +- HTTP reaponse uses a ``HTTPResponsePacket`` object +- Extends Service class for integration with ``SoftwareManager``. + +Web Browser (Web Client) +------------------------ + +The ``WebBrowser`` provides a client interface for connecting to the ``WebServer``. + +Key features +^^^^^^^^^^^^ + +- Connects to the ``WebServer`` via the ``SoftwareManager``. +- Simulates HTTP requests and HTTP packet transfer across a network +- Allows the emulation of HTTP requests between client and server: + - Automatically uses ``DNSClient`` to resolve domain names + - GET: performs an HTTP GET request from client to server +- Leverages the Service base class for install/uninstall, status tracking, etc. + +Usage +^^^^^ + +- Install on a Node via the ``SoftwareManager`` to start the ``WebBrowser``. +- Service runs on HTTP port 80 by default. (TODO: HTTPS) +- Execute sending an HTTP GET request with ``get_webpage`` + +Implementation +^^^^^^^^^^^^^^ + +- Leverages ``SoftwareManager`` for sending payloads over the network. +- Provides easy interface for making HTTP requests between an HTTP client and server. +- Extends base Service class. + + +Example Usage +------------- + +Dependencies +^^^^^^^^^^^^ + +.. code-block:: python + + 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.system.applications.web_browser import WebBrowser + from primaite.simulator.system.services.web_server.web_server_service import WebServer + +Example peer to peer network +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + net = Network() + + pc1 = Computer(hostname="pc1", ip_address="192.168.1.50", subnet_mask="255.255.255.0") + srv = Server(hostname="srv", ip_address="192.168.1.10", subnet_mask="255.255.255.0") + pc1.power_on() + srv.power_on() + net.connect(pc1.ethernet_port[1], srv.ethernet_port[1]) + +Install the Web Server +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + # web browser is automatically installed in computer nodes + # IRL this is usually included with an OS + client: WebBrowser = pc1.software_manager.software['WebBrowser'] + + # install web server + srv.software_manager.install(WebServer) + webserv: WebServer = srv.software_manager.software['WebServer'] + +Open the web page +^^^^^^^^^^^^^^^^^ + +Using a domain name to connect to a website requires setting up DNS Servers. For this example, it is possible to use the IP address directly + +.. code-block:: python + + # check that the get request succeeded + print(client.get_webpage("http://192.168.1.10")) # should be True diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index 1ddeb82f..4f9aebdc 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -14,7 +14,7 @@ from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.ftp.ftp_client import FTPClient from primaite.simulator.system.services.red_services.data_manipulation_bot import DataManipulationBot -from primaite.simulator.system.services.web_server.web_server_service import WebServerService +from primaite.simulator.system.services.web_server.web_server_service import WebServer def client_server_routed() -> Network: @@ -260,7 +260,7 @@ def arcd_uc2_network() -> Network: database_client.run() database_client.connect() - web_server.software_manager.install(WebServerService) + web_server.software_manager.install(WebServer) # register the web_server to a domain dns_server_service: DNSServer = domain_controller.software_manager.software["DNSServer"] # noqa diff --git a/src/primaite/simulator/system/applications/web_browser.py b/src/primaite/simulator/system/applications/web_browser.py index 9d2c31b1..69de333e 100644 --- a/src/primaite/simulator/system/applications/web_browser.py +++ b/src/primaite/simulator/system/applications/web_browser.py @@ -74,11 +74,17 @@ class WebBrowser(Application): domain_exists = dns_client.check_domain_exists(target_domain=parsed_url.hostname) # if domain does not exist, the request fails - if not domain_exists: - return False - - # set current domain name IP address - self.domain_name_ip_address = dns_client.dns_cache[parsed_url.hostname] + if domain_exists: + # set current domain name IP address + self.domain_name_ip_address = dns_client.dns_cache[parsed_url.hostname] + else: + # check if url is an ip address + try: + self.domain_name_ip_address = IPv4Address(parsed_url.hostname) + except Exception: + # unable to deal with this request + self.sys_log.error(f"{self.name}: Unable to resolve URL {url}") + return False # create HTTPRequest payload payload = HTTPRequestPacket(request_method=HTTPRequestMethod.GET, request_url=url) diff --git a/src/primaite/simulator/system/services/dns/dns_client.py b/src/primaite/simulator/system/services/dns/dns_client.py index 620a9a32..266ac4f6 100644 --- a/src/primaite/simulator/system/services/dns/dns_client.py +++ b/src/primaite/simulator/system/services/dns/dns_client.py @@ -72,6 +72,11 @@ class DNSClient(Service): :param: session_id: The Session ID the payload is to originate from. Optional. :param: is_reattempt: Checks if the request has been reattempted. Default is False. """ + # check if DNS server is configured + if self.dns_server is None: + self.sys_log.error(f"{self.name}: DNS Server is not configured") + return False + # check if the target domain is in the client's DNS cache payload = DNSPacket(dns_request=DNSRequest(domain_name_request=target_domain)) diff --git a/src/primaite/simulator/system/services/web_server/web_server_service.py b/src/primaite/simulator/system/services/web_server/web_server_service.py index 276cb57f..68624930 100644 --- a/src/primaite/simulator/system/services/web_server/web_server_service.py +++ b/src/primaite/simulator/system/services/web_server/web_server_service.py @@ -1,5 +1,6 @@ from ipaddress import IPv4Address from typing import Any, Optional +from urllib.parse import urlparse from primaite.simulator.network.protocols.http import ( HTTPRequestMethod, @@ -13,7 +14,7 @@ from primaite.simulator.system.applications.database_client import DatabaseClien from primaite.simulator.system.services.service import Service -class WebServerService(Service): +class WebServer(Service): """Class used to represent a Web Server Service in simulation.""" def __init__(self, **kwargs): @@ -76,13 +77,20 @@ class WebServerService(Service): """ response = HTTPResponsePacket(status_code=HTTPStatusCode.BAD_REQUEST, payload=payload) try: - # get data from DatabaseServer - db_client: DatabaseClient = self.software_manager.software["DatabaseClient"] - # get all users - if db_client.query("SELECT * FROM user;"): + parsed_url = urlparse(payload.request_url) + + if parsed_url.path is None or len(parsed_url.path) < 1: # query succeeded response.status_code = HTTPStatusCode.OK + if parsed_url.path.startswith("/users"): + # get data from DatabaseServer + db_client: DatabaseClient = self.software_manager.software["DatabaseClient"] + # get all users + if db_client.query("SELECT * FROM user;"): + # query succeeded + response.status_code = HTTPStatusCode.OK + return response except Exception: # something went wrong on the server diff --git a/tests/integration_tests/system/test_web_client_server.py b/tests/integration_tests/system/test_web_client_server.py index fee51297..8b6f4072 100644 --- a/tests/integration_tests/system/test_web_client_server.py +++ b/tests/integration_tests/system/test_web_client_server.py @@ -1,18 +1,51 @@ from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.protocols.http import HTTPStatusCode from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.web_browser import WebBrowser from primaite.simulator.system.services.service import ServiceOperatingState -def test_web_page_get_request(uc2_network): - """Test to see if the client retrieves the correct web files.""" +def test_web_page_home_page(uc2_network): + """Test to see if the browser is able to open the main page of the web server.""" client_1: Computer = uc2_network.get_node_by_hostname("client_1") web_client: WebBrowser = client_1.software_manager.software["WebBrowser"] web_client.run() assert web_client.operating_state == ApplicationOperatingState.RUNNING - assert web_client.get_webpage("http://arcd.com/index.html") is True + assert web_client.get_webpage("http://arcd.com/") is True + + # latest reponse should have status code 200 + assert web_client.latest_response is not None + assert web_client.latest_response.status_code == HTTPStatusCode.OK + + +def test_web_page_get_users_page_request_with_domain_name(uc2_network): + """Test to see if the client can handle requests with domain names""" + client_1: Computer = uc2_network.get_node_by_hostname("client_1") + web_client: WebBrowser = client_1.software_manager.software["WebBrowser"] + web_client.run() + assert web_client.operating_state == ApplicationOperatingState.RUNNING + + assert web_client.get_webpage("http://arcd.com/users/") is True + + # latest reponse should have status code 200 + assert web_client.latest_response is not None + assert web_client.latest_response.status_code == HTTPStatusCode.OK + + +def test_web_page_get_users_page_request_with_ip_address(uc2_network): + """Test to see if the client can handle requests that use ip_address.""" + client_1: Computer = uc2_network.get_node_by_hostname("client_1") + web_client: WebBrowser = client_1.software_manager.software["WebBrowser"] + web_client.run() + + web_server: Server = uc2_network.get_node_by_hostname("web_server") + web_server_ip = web_server.nics.get(next(iter(web_server.nics))).ip_address + + assert web_client.operating_state == ApplicationOperatingState.RUNNING + + assert web_client.get_webpage(f"http://{web_server_ip}/users/") is True # latest reponse should have status code 200 assert web_client.latest_response is not None From be6b904db9540fc55bf51ba645bc3804c7f0c12a Mon Sep 17 00:00:00 2001 From: "Czar.Echavez" Date: Thu, 5 Oct 2023 16:24:48 +0100 Subject: [PATCH 3/5] - Fixed FTP client server infinite recursion - ftp server and clients can be installed on the same node, this could cause a loop of requests - fixed tests broken by merged with dev --- .../system/services/ftp/ftp_client.py | 30 +++--------- .../system/services/ftp/ftp_server.py | 14 +++++- .../simulator/system/services/service.py | 46 +------------------ .../services/web_server/web_server_service.py | 5 +- .../_simulator/_system/_services/test_dns.py | 6 ++- .../_simulator/_system/_services/test_ftp.py | 3 +- 6 files changed, 30 insertions(+), 74 deletions(-) diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py index 4575f985..b2a1e8bf 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_client.py +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -97,6 +97,9 @@ class FTPClient(FTPServiceABC): self._connect_to_server( dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, is_reattempt=True ) + else: + self.sys_log.error(f"{self.name}: Unable to send FTPPacket") + return False def _disconnect_from_server( self, dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = Port.FTP @@ -247,30 +250,6 @@ class FTPClient(FTPServiceABC): self.sys_log.error(f"{self.name}: File {src_folder_name}/{src_file_name} does not exist in FTP server") return False - def send( - self, - payload: FTPPacket, - session_id: Optional[str] = None, - dest_ip_address: Optional[IPv4Address] = None, - dest_port: Optional[Port] = None, - **kwargs, - ) -> bool: - """ - Sends a payload to the SessionManager. - - :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. - """ - self.sys_log.info(f"{self.name}: Sending FTP {payload.ftp_command.name} {payload.ftp_command_args}") - - return super().send( - payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs - ) - def receive(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> bool: """ Receives a payload from the SessionManager. @@ -285,5 +264,8 @@ class FTPClient(FTPServiceABC): self.sys_log.error(f"{payload} is not an FTP packet") return False + if payload.status_code is None: + return False + self._process_ftp_command(payload=payload, session_id=session_id) return True diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index 62358ff2..d93150e0 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -38,9 +38,11 @@ class FTPServer(FTPServiceABC): :param: session_id: session ID linked to the FTP Packet. Optional. :type: session_id: Optional[str] """ + # error code by default + payload.status_code = FTPStatusCode.ERROR + # if server service is down, return error if self.operating_state != ServiceOperatingState.RUNNING: - payload.status_code = FTPStatusCode.ERROR self.sys_log.error("FTP Server not running") return payload @@ -61,9 +63,13 @@ class FTPServer(FTPServiceABC): payload.status_code = FTPStatusCode.OK return payload + self.sys_log.error(f"Invalid Port {payload.ftp_command_args}") + return payload + if payload.ftp_command == FTPCommand.QUIT: self.connections.pop(session_id) payload.status_code = FTPStatusCode.OK + return payload return super()._process_ftp_command(payload=payload, session_id=session_id, **kwargs) @@ -73,5 +79,11 @@ class FTPServer(FTPServiceABC): self.sys_log.error(f"{payload} is not an FTP packet") return False + """ + Usually + """ + if payload.status_code is not None: + return False + self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) return True diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index d79487a3..aa1d5031 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,10 +1,8 @@ from enum import Enum -from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from typing import Dict, Optional from primaite import getLogger from primaite.simulator.core import Action, ActionManager -from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.software import IOSoftware _LOGGER = getLogger(__name__) @@ -74,48 +72,6 @@ class Service(IOSoftware): """ pass - def send( - self, - payload: Any, - session_id: Optional[str] = None, - dest_ip_address: Optional[IPv4Address] = None, - dest_port: Optional[Port] = None, - **kwargs, - ) -> bool: - """ - Sends a payload to the SessionManager. - - :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. - """ - return super().send( - payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs - ) - - def receive( - self, - payload: Any, - session_id: Optional[str] = None, - **kwargs, - ) -> bool: - """ - Receives a payload from the SessionManager. - - The specifics of how the payload is processed and whether a response payload - is generated should be implemented in subclasses. - - :param: payload: The payload to send. - :param: session_id: The id of the session - - :return: True if successful, False otherwise. - """ - - pass - def stop(self) -> None: """Stop the service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: diff --git a/src/primaite/simulator/system/services/web_server/web_server_service.py b/src/primaite/simulator/system/services/web_server/web_server_service.py index 68624930..59686388 100644 --- a/src/primaite/simulator/system/services/web_server/web_server_service.py +++ b/src/primaite/simulator/system/services/web_server/web_server_service.py @@ -78,12 +78,13 @@ class WebServer(Service): response = HTTPResponsePacket(status_code=HTTPStatusCode.BAD_REQUEST, payload=payload) try: parsed_url = urlparse(payload.request_url) + path = parsed_url.path.strip("/") - if parsed_url.path is None or len(parsed_url.path) < 1: + if len(path) < 1: # query succeeded response.status_code = HTTPStatusCode.OK - if parsed_url.path.startswith("/users"): + if path.startswith("users"): # get data from DatabaseServer db_client: DatabaseClient = self.software_manager.software["DatabaseClient"] # get all users diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns.py index 31718387..dc6df5d4 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_dns.py @@ -25,7 +25,11 @@ def dns_server() -> Node: @pytest.fixture(scope="function") def dns_client() -> Node: node = Computer( - hostname="dns_client", ip_address="192.168.1.11", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + hostname="dns_client", + ip_address="192.168.1.11", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1", + dns_server=IPv4Address("192.168.1.10"), ) return node diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp.py index 3ccb0c99..d382b8dd 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_ftp.py @@ -5,7 +5,7 @@ import pytest from primaite.simulator.network.hardware.base import Node from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.server import Server -from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket +from primaite.simulator.network.protocols.ftp import FTPCommand, FTPPacket, FTPStatusCode from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.services.ftp.ftp_client import FTPClient @@ -78,6 +78,7 @@ def test_ftp_client_store_file(ftp_client): "file_size": 24, }, packet_payload_size=24, + status_code=FTPStatusCode.OK, ) ftp_client_service: FTPClient = ftp_client.software_manager.software["FTPClient"] From 853bb9eecc30af1215b738c9096674048a0a69df Mon Sep 17 00:00:00 2001 From: "Czar.Echavez" Date: Fri, 6 Oct 2023 12:10:57 +0100 Subject: [PATCH 4/5] #1943: unit tests + refactoring HTTP -> Http --- .../web_browser_and_web_server_service.rst | 4 +- src/primaite/simulator/network/networks.py | 2 +- .../simulator/network/protocols/http.py | 15 +++-- .../system/applications/web_browser.py | 14 ++-- .../{web_server_service.py => web_server.py} | 42 ++++++------ .../system/test_web_client_server.py | 8 +-- .../_system/_applications/__init__.py | 0 .../_system/_applications/test_web_browser.py | 39 +++++++++++ .../_system/_services/test_web_server.py | 64 +++++++++++++++++++ 9 files changed, 147 insertions(+), 41 deletions(-) rename src/primaite/simulator/system/services/web_server/{web_server_service.py => web_server.py} (78%) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_applications/test_web_browser.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py diff --git a/docs/source/simulation_components/system/web_browser_and_web_server_service.rst b/docs/source/simulation_components/system/web_browser_and_web_server_service.rst index a02ac621..d2bde80e 100644 --- a/docs/source/simulation_components/system/web_browser_and_web_server_service.rst +++ b/docs/source/simulation_components/system/web_browser_and_web_server_service.rst @@ -25,8 +25,8 @@ Usage Implementation ^^^^^^^^^^^^^^ -- HTTP request uses a ``HTTPRequestPacket`` object -- HTTP reaponse uses a ``HTTPResponsePacket`` object +- HTTP request uses a ``HttpRequestPacket`` object +- HTTP response uses a ``HttpResponsePacket`` object - Extends Service class for integration with ``SoftwareManager``. Web Browser (Web Client) diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index 23a7ab1a..e465e08a 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -12,7 +12,7 @@ from primaite.simulator.system.applications.database_client import DatabaseClien from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_server import DNSServer from primaite.simulator.system.services.red_services.data_manipulation_bot import DataManipulationBot -from primaite.simulator.system.services.web_server.web_server_service import WebServer +from primaite.simulator.system.services.web_server.web_server import WebServer def client_server_routed() -> Network: diff --git a/src/primaite/simulator/network/protocols/http.py b/src/primaite/simulator/network/protocols/http.py index 4be0ed88..2dba2614 100644 --- a/src/primaite/simulator/network/protocols/http.py +++ b/src/primaite/simulator/network/protocols/http.py @@ -3,7 +3,7 @@ from enum import Enum from primaite.simulator.network.protocols.packet import DataPacket -class HTTPRequestMethod(Enum): +class HttpRequestMethod(Enum): """Enum list of HTTP Request methods that can be handled by the simulation.""" GET = "GET" @@ -25,7 +25,7 @@ class HTTPRequestMethod(Enum): """Apply partial modifications to a resource.""" -class HTTPStatusCode(Enum): +class HttpStatusCode(Enum): """List of available HTTP Statuses.""" OK = 200 @@ -37,6 +37,9 @@ class HTTPStatusCode(Enum): UNAUTHORIZED = 401 """Auth required.""" + NOT_FOUND = 404 + """Item not found in server.""" + METHOD_NOT_ALLOWED = 405 """Method is not supported by server.""" @@ -44,18 +47,18 @@ class HTTPStatusCode(Enum): """Error on the server side.""" -class HTTPRequestPacket(DataPacket): +class HttpRequestPacket(DataPacket): """Class that represents an HTTP Request Packet.""" - request_method: HTTPRequestMethod + request_method: HttpRequestMethod """The HTTP Request method.""" request_url: str """URL of request.""" -class HTTPResponsePacket(DataPacket): +class HttpResponsePacket(DataPacket): """Class that reprensents an HTTP Response Packet.""" - status_code: HTTPStatusCode = None + status_code: HttpStatusCode = None """Status code of the HTTP response.""" diff --git a/src/primaite/simulator/system/applications/web_browser.py b/src/primaite/simulator/system/applications/web_browser.py index 69de333e..4f6b81c1 100644 --- a/src/primaite/simulator/system/applications/web_browser.py +++ b/src/primaite/simulator/system/applications/web_browser.py @@ -2,7 +2,7 @@ from ipaddress import IPv4Address from typing import Dict, Optional from urllib.parse import urlparse -from primaite.simulator.network.protocols.http import HTTPRequestMethod, HTTPRequestPacket, HTTPResponsePacket +from primaite.simulator.network.protocols.http import HttpRequestMethod, HttpRequestPacket, HttpResponsePacket 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 @@ -19,7 +19,7 @@ class WebBrowser(Application): domain_name_ip_address: Optional[IPv4Address] = None "The IP address of the domain name for the webpage." - latest_response: HTTPResponsePacket = None + latest_response: HttpResponsePacket = None """Keeps track of the latest HTTP response.""" def __init__(self, **kwargs): @@ -87,7 +87,7 @@ class WebBrowser(Application): return False # create HTTPRequest payload - payload = HTTPRequestPacket(request_method=HTTPRequestMethod.GET, request_url=url) + payload = HttpRequestPacket(request_method=HttpRequestMethod.GET, request_url=url) # send request return self.send( @@ -98,7 +98,7 @@ class WebBrowser(Application): def send( self, - payload: HTTPRequestPacket, + payload: HttpRequestPacket, dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = Port.HTTP, session_id: Optional[str] = None, @@ -120,7 +120,7 @@ class WebBrowser(Application): payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id, **kwargs ) - def receive(self, payload: HTTPResponsePacket, session_id: Optional[str] = None, **kwargs) -> bool: + def receive(self, payload: HttpResponsePacket, session_id: Optional[str] = None, **kwargs) -> bool: """ Receives a payload from the SessionManager. @@ -128,8 +128,8 @@ class WebBrowser(Application): :param session_id: The Session ID the payload is to originate from. Optional. :return: True if successful, False otherwise. """ - if not isinstance(payload, HTTPResponsePacket): - self.sys_log.error(f"{self.name} received a packet that is not an HTTPResponsePacket") + if not isinstance(payload, HttpResponsePacket): + self.sys_log.error(f"{self.name} received a packet that is not an HttpResponsePacket") return False self.sys_log.info(f"{self.name}: Received HTTP {payload.status_code.value}") self.latest_response = payload diff --git a/src/primaite/simulator/system/services/web_server/web_server_service.py b/src/primaite/simulator/system/services/web_server/web_server.py similarity index 78% rename from src/primaite/simulator/system/services/web_server/web_server_service.py rename to src/primaite/simulator/system/services/web_server/web_server.py index 59686388..4566f3b3 100644 --- a/src/primaite/simulator/system/services/web_server/web_server_service.py +++ b/src/primaite/simulator/system/services/web_server/web_server.py @@ -3,10 +3,10 @@ from typing import Any, Optional from urllib.parse import urlparse from primaite.simulator.network.protocols.http import ( - HTTPRequestMethod, - HTTPRequestPacket, - HTTPResponsePacket, - HTTPStatusCode, + HttpRequestMethod, + HttpRequestPacket, + HttpResponsePacket, + HttpStatusCode, ) from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -37,52 +37,52 @@ class WebServer(Service): # index HTML main file self.file_system.create_file(file_name="index.html", folder_name="primaite", real=True) - def _process_http_request(self, payload: HTTPRequestPacket, session_id: Optional[str] = None) -> bool: + def _process_http_request(self, payload: HttpRequestPacket, session_id: Optional[str] = None) -> bool: """ - Parse the HTTPRequestPacket. + Parse the HttpRequestPacket. - :param: payload: Payload containing th HTTPRequestPacket - :type: payload: HTTPRequestPacket + :param: payload: Payload containing th HttpRequestPacket + :type: payload: HttpRequestPacket :param: session_id: Session id of the http request :type: session_id: Optional[str] """ - response = HTTPResponsePacket() + response = HttpResponsePacket() self.sys_log.info(f"{self.name}: Received HTTP {payload.request_method.name} {payload.request_url}") # check the type of HTTP request - if payload.request_method == HTTPRequestMethod.GET: + if payload.request_method == HttpRequestMethod.GET: response = self._handle_get_request(payload=payload) - elif payload.request_method == HTTPRequestMethod.POST: + elif payload.request_method == HttpRequestMethod.POST: pass else: # send a method not allowed response - response.status_code = HTTPStatusCode.METHOD_NOT_ALLOWED + response.status_code = HttpStatusCode.METHOD_NOT_ALLOWED # send response to web client self.send(payload=response, session_id=session_id) # return true if response is OK - return response.status_code == HTTPStatusCode.OK + return response.status_code == HttpStatusCode.OK - def _handle_get_request(self, payload: HTTPRequestPacket) -> HTTPResponsePacket: + def _handle_get_request(self, payload: HttpRequestPacket) -> HttpResponsePacket: """ Handle a GET HTTP request. :param: payload: HTTP request payload - :type: payload: HTTPRequestPacket + :type: payload: HttpRequestPacket """ - response = HTTPResponsePacket(status_code=HTTPStatusCode.BAD_REQUEST, payload=payload) + response = HttpResponsePacket(status_code=HttpStatusCode.NOT_FOUND, payload=payload) try: parsed_url = urlparse(payload.request_url) path = parsed_url.path.strip("/") if len(path) < 1: # query succeeded - response.status_code = HTTPStatusCode.OK + response.status_code = HttpStatusCode.OK if path.startswith("users"): # get data from DatabaseServer @@ -90,17 +90,17 @@ class WebServer(Service): # get all users if db_client.query("SELECT * FROM user;"): # query succeeded - response.status_code = HTTPStatusCode.OK + response.status_code = HttpStatusCode.OK return response except Exception: # something went wrong on the server - response.status_code = HTTPStatusCode.INTERNAL_SERVER_ERROR + response.status_code = HttpStatusCode.INTERNAL_SERVER_ERROR return response def send( self, - payload: HTTPResponsePacket, + payload: HttpResponsePacket, session_id: Optional[str] = None, dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = None, @@ -138,7 +138,7 @@ class WebServer(Service): :param: session_id: The id of the session. Optional. """ # check if the payload is an HTTPPacket - if not isinstance(payload, HTTPRequestPacket): + if not isinstance(payload, HttpRequestPacket): self.sys_log.error("Payload is not an HTTPPacket") return False diff --git a/tests/integration_tests/system/test_web_client_server.py b/tests/integration_tests/system/test_web_client_server.py index 8b6f4072..f4546cbf 100644 --- a/tests/integration_tests/system/test_web_client_server.py +++ b/tests/integration_tests/system/test_web_client_server.py @@ -1,6 +1,6 @@ from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.server import Server -from primaite.simulator.network.protocols.http import HTTPStatusCode +from primaite.simulator.network.protocols.http import HttpStatusCode from primaite.simulator.system.applications.application import ApplicationOperatingState from primaite.simulator.system.applications.web_browser import WebBrowser from primaite.simulator.system.services.service import ServiceOperatingState @@ -17,7 +17,7 @@ def test_web_page_home_page(uc2_network): # latest reponse should have status code 200 assert web_client.latest_response is not None - assert web_client.latest_response.status_code == HTTPStatusCode.OK + assert web_client.latest_response.status_code == HttpStatusCode.OK def test_web_page_get_users_page_request_with_domain_name(uc2_network): @@ -31,7 +31,7 @@ def test_web_page_get_users_page_request_with_domain_name(uc2_network): # latest reponse should have status code 200 assert web_client.latest_response is not None - assert web_client.latest_response.status_code == HTTPStatusCode.OK + assert web_client.latest_response.status_code == HttpStatusCode.OK def test_web_page_get_users_page_request_with_ip_address(uc2_network): @@ -49,4 +49,4 @@ def test_web_page_get_users_page_request_with_ip_address(uc2_network): # latest reponse should have status code 200 assert web_client.latest_response is not None - assert web_client.latest_response.status_code == HTTPStatusCode.OK + assert web_client.latest_response.status_code == HttpStatusCode.OK diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_applications/test_web_browser.py b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_web_browser.py new file mode 100644 index 00000000..b2724369 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_applications/test_web_browser.py @@ -0,0 +1,39 @@ +import pytest + +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.protocols.http import HttpResponsePacket, HttpStatusCode +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.applications.web_browser import WebBrowser + + +@pytest.fixture(scope="function") +def web_client() -> Computer: + node = Computer( + hostname="web_client", ip_address="192.168.1.11", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" + ) + return node + + +def test_create_web_client(web_client): + assert web_client is not None + web_browser: WebBrowser = web_client.software_manager.software["WebBrowser"] + assert web_browser.name is "WebBrowser" + assert web_browser.port is Port.HTTP + assert web_browser.protocol is IPProtocol.TCP + + +def test_receive_invalid_payload(web_client): + web_browser: WebBrowser = web_client.software_manager.software["WebBrowser"] + + assert web_browser.receive(payload={}) is False + + +def test_receive_payload(web_client): + payload = HttpResponsePacket(status_code=HttpStatusCode.OK) + web_browser: WebBrowser = web_client.software_manager.software["WebBrowser"] + assert web_browser.latest_response is None + + web_browser.receive(payload=payload) + + assert web_browser.latest_response is not None 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 new file mode 100644 index 00000000..e6f0b9d9 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_web_server.py @@ -0,0 +1,64 @@ +import pytest + +from primaite.simulator.network.hardware.nodes.server import Server +from primaite.simulator.network.protocols.http import ( + HttpRequestMethod, + HttpRequestPacket, + HttpResponsePacket, + HttpStatusCode, +) +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +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" + ) + node.software_manager.install(software_class=WebServer) + node.software_manager.software["WebServer"].start() + return node + + +def test_create_web_server(web_server): + assert web_server is not None + web_server_service: WebServer = web_server.software_manager.software["WebServer"] + assert web_server_service.name is "WebServer" + assert web_server_service.port is Port.HTTP + assert web_server_service.protocol is IPProtocol.TCP + + +def test_handling_get_request_not_found_path(web_server): + payload = HttpRequestPacket(request_method=HttpRequestMethod.GET, request_url="http://domain.com/fake-path") + + web_server_service: WebServer = web_server.software_manager.software["WebServer"] + + response: HttpResponsePacket = web_server_service._handle_get_request(payload=payload) + assert response.status_code == HttpStatusCode.NOT_FOUND + + +def test_handling_get_request_home_page(web_server): + payload = HttpRequestPacket(request_method=HttpRequestMethod.GET, request_url="http://domain.com/") + + web_server_service: WebServer = web_server.software_manager.software["WebServer"] + + response: HttpResponsePacket = web_server_service._handle_get_request(payload=payload) + assert response.status_code == HttpStatusCode.OK + + +def test_process_http_request_get(web_server): + payload = HttpRequestPacket(request_method=HttpRequestMethod.GET, request_url="http://domain.com/") + + web_server_service: WebServer = web_server.software_manager.software["WebServer"] + + assert web_server_service._process_http_request(payload=payload) is True + + +def test_process_http_request_method_not_allowed(web_server): + payload = HttpRequestPacket(request_method=HttpRequestMethod.DELETE, request_url="http://domain.com/") + + web_server_service: WebServer = web_server.software_manager.software["WebServer"] + + assert web_server_service._process_http_request(payload=payload) is False From 318539fd8f7c442615297f1a400e18d1b45d6441 Mon Sep 17 00:00:00 2001 From: "Czar.Echavez" Date: Mon, 9 Oct 2023 13:25:12 +0100 Subject: [PATCH 5/5] #1943: apply suggestions from PR + fixing FTP bug + elaborating --- src/primaite/simulator/network/hardware/nodes/computer.py | 2 -- src/primaite/simulator/network/hardware/nodes/router.py | 2 -- src/primaite/simulator/network/hardware/nodes/switch.py | 2 -- src/primaite/simulator/network/networks.py | 2 ++ src/primaite/simulator/network/protocols/ftp.py | 4 ++-- src/primaite/simulator/system/applications/web_browser.py | 4 ++-- src/primaite/simulator/system/services/ftp/ftp_client.py | 6 ++++++ src/primaite/simulator/system/services/ftp/ftp_server.py | 6 +++++- src/primaite/simulator/system/services/ftp/ftp_service.py | 6 ++++++ .../simulator/system/services/web_server/web_server.py | 2 +- 10 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py index 61c62a5f..0480aca9 100644 --- a/src/primaite/simulator/network/hardware/nodes/computer.py +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -2,7 +2,6 @@ from primaite.simulator.network.hardware.base import NIC, Node from primaite.simulator.system.applications.web_browser import WebBrowser from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.ftp.ftp_client import FTPClient -from primaite.simulator.system.services.ftp.ftp_server import FTPServer class Computer(Node): @@ -49,7 +48,6 @@ class Computer(Node): # FTP self.software_manager.install(FTPClient) - self.software_manager.install(FTPServer) # Web Browser self.software_manager.install(WebBrowser) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 90eb5935..092680a7 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -596,8 +596,6 @@ class Router(Node): self.arp.nics = self.nics self.icmp.arp = self.arp - self._install_system_software() - def _get_port_of_nic(self, target_nic: NIC) -> Optional[int]: """ Retrieve the port number for a given NIC. diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py index 8b3fe5cd..b7cc1242 100644 --- a/src/primaite/simulator/network/hardware/nodes/switch.py +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -34,8 +34,6 @@ class Switch(Node): port.parent = self port.port_num = port_num - self._install_system_software() - def show(self, markdown: bool = False): """ Prints a table of the SwitchPorts on the Switch. diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index e465e08a..be20f89f 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -11,6 +11,7 @@ from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.database_client import DatabaseClient from primaite.simulator.system.services.database.database_service import DatabaseService from primaite.simulator.system.services.dns.dns_server import DNSServer +from primaite.simulator.system.services.ftp.ftp_server import FTPServer from primaite.simulator.system.services.red_services.data_manipulation_bot import DataManipulationBot from primaite.simulator.system.services.web_server.web_server import WebServer @@ -268,6 +269,7 @@ def arcd_uc2_network() -> Network: dns_server=IPv4Address("192.168.1.10"), ) backup_server.power_on() + backup_server.software_manager.install(FTPServer) network.connect(endpoint_b=backup_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[4]) # Security Suite diff --git a/src/primaite/simulator/network/protocols/ftp.py b/src/primaite/simulator/network/protocols/ftp.py index 91080219..9ecc7df8 100644 --- a/src/primaite/simulator/network/protocols/ftp.py +++ b/src/primaite/simulator/network/protocols/ftp.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Optional +from typing import Any, Optional, Union from primaite.simulator.network.protocols.packet import DataPacket @@ -51,5 +51,5 @@ class FTPPacket(DataPacket): ftp_command_args: Optional[Any] = None """Arguments for command.""" - status_code: FTPStatusCode = None + status_code: Union[FTPStatusCode, None] = None """Status of the response.""" diff --git a/src/primaite/simulator/system/applications/web_browser.py b/src/primaite/simulator/system/applications/web_browser.py index 4f6b81c1..c48b785e 100644 --- a/src/primaite/simulator/system/applications/web_browser.py +++ b/src/primaite/simulator/system/applications/web_browser.py @@ -19,7 +19,7 @@ class WebBrowser(Application): domain_name_ip_address: Optional[IPv4Address] = None "The IP address of the domain name for the webpage." - latest_response: HttpResponsePacket = None + latest_response: Optional[HttpResponsePacket] = None """Keeps track of the latest HTTP response.""" def __init__(self, **kwargs): @@ -38,7 +38,7 @@ class WebBrowser(Application): :return: A dictionary capturing the current state of the WebBrowser and its child objects. """ - pass + return super().describe_state() def reset_component_for_episode(self, episode: int): """ diff --git a/src/primaite/simulator/system/services/ftp/ftp_client.py b/src/primaite/simulator/system/services/ftp/ftp_client.py index b2a1e8bf..3e286da1 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_client.py +++ b/src/primaite/simulator/system/services/ftp/ftp_client.py @@ -264,6 +264,12 @@ class FTPClient(FTPServiceABC): self.sys_log.error(f"{payload} is not an FTP packet") return False + """ + Ignore ftp payload if status code is None. + + This helps prevent an FTP request loop - FTP client and servers can exist on + the same node. + """ if payload.status_code is None: return False diff --git a/src/primaite/simulator/system/services/ftp/ftp_server.py b/src/primaite/simulator/system/services/ftp/ftp_server.py index d93150e0..23414601 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_server.py +++ b/src/primaite/simulator/system/services/ftp/ftp_server.py @@ -80,7 +80,11 @@ class FTPServer(FTPServiceABC): return False """ - Usually + Ignore ftp payload if status code is defined. + + This means that an FTP server has already handled the packet and + prevents an FTP request loop - FTP client and servers can exist on + the same node. """ if payload.status_code is not None: return False diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index 61f83be0..f2c01544 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -76,6 +76,7 @@ class FTPServiceABC(Service, ABC): dest_ip_address: Optional[IPv4Address] = None, dest_port: Optional[Port] = None, session_id: Optional[str] = None, + is_response: bool = False, ) -> bool: """ Sends data from the host FTP Service's machine to another FTP Service's host machine. @@ -97,6 +98,9 @@ class FTPServiceABC(Service, ABC): :param: session_id: session ID linked to the FTP Packet. Optional. :type: session_id: Optional[str] + + :param: is_response: is true if the data being sent is in response to a request. Default False. + :type: is_response: bool """ # send STOR request payload: FTPPacket = FTPPacket( @@ -108,6 +112,7 @@ class FTPServiceABC(Service, ABC): "real_file_path": file.sim_path if file.real else None, }, packet_payload_size=file.sim_size, + status_code=FTPStatusCode.OK if is_response else None, ) self.sys_log.info(f"{self.name}: Sending file {file.folder.name}/{file.name}") response = self.send( @@ -148,6 +153,7 @@ class FTPServiceABC(Service, ABC): dest_file_name=dest_file_name, dest_folder_name=dest_folder_name, session_id=session_id, + is_response=True, ) except Exception as e: self.sys_log.error(f"Unable to retrieve file from {self.sys_log.hostname}: {e}") 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 4566f3b3..f63d5169 100644 --- a/src/primaite/simulator/system/services/web_server/web_server.py +++ b/src/primaite/simulator/system/services/web_server/web_server.py @@ -35,7 +35,7 @@ class WebServer(Service): This is usually HTML, CSS, JS or PHP files requested by browsers to display the webpage. """ # index HTML main file - self.file_system.create_file(file_name="index.html", folder_name="primaite", real=True) + self.file_system.create_file(file_name="index.html", folder_name="primaite") def _process_http_request(self, payload: HttpRequestPacket, session_id: Optional[str] = None) -> bool: """