From 4b5a73bd3241fd863dd92d841d6e71a32a27ecf0 Mon Sep 17 00:00:00 2001 From: "Czar.Echavez" Date: Tue, 3 Oct 2023 14:59:48 +0100 Subject: [PATCH] #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