diff --git a/CHANGELOG.md b/CHANGELOG.md index a5bc08f8..c29c325a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,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 94aef925..306bc039 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..d2bde80e --- /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 response 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/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index f8d53562..78ae228e 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -722,7 +722,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 diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py index 3ceb5291..0480aca9 100644 --- a/src/primaite/simulator/network/hardware/nodes/computer.py +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -1,4 +1,5 @@ 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 @@ -48,4 +49,7 @@ class Computer(Node): # FTP self.software_manager.install(FTPClient) + # Web Browser + self.software_manager.install(WebBrowser) + super()._install_system_software() diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index f54e1172..be20f89f 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -13,6 +13,7 @@ from primaite.simulator.system.services.database.database_service import Databas 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 def client_server_routed() -> Network: @@ -253,6 +254,8 @@ def arcd_uc2_network() -> Network: database_client.run() database_client.connect() + web_server.software_manager.install(WebServer) + # 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) @@ -266,9 +269,8 @@ def arcd_uc2_network() -> Network: dns_server=IPv4Address("192.168.1.10"), ) 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) + network.connect(endpoint_b=backup_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[4]) # Security Suite security_suite = Server( @@ -298,4 +300,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/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/network/protocols/http.py b/src/primaite/simulator/network/protocols/http.py new file mode 100644 index 00000000..2dba2614 --- /dev/null +++ b/src/primaite/simulator/network/protocols/http.py @@ -0,0 +1,64 @@ +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.""" + + NOT_FOUND = 404 + """Item not found in server.""" + + 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..c48b785e 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: Optional[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. + """ + return super().describe_state() def reset_component_for_episode(self, episode: int): """ @@ -25,30 +47,90 @@ 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 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) + + # 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 0a6de8c3..f7333f97 100644 --- a/src/primaite/simulator/system/services/database/database_service.py +++ b/src/primaite/simulator/system/services/database/database_service.py @@ -155,10 +155,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..266ac4f6 100644 --- a/src/primaite/simulator/system/services/dns/dns_client.py +++ b/src/primaite/simulator/system/services/dns/dns_client.py @@ -72,19 +72,25 @@ 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)) # 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 +110,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 +124,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 +139,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 +147,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 648b2494..3e286da1 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,30 @@ 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 + ) + 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 @@ -119,6 +133,7 @@ class FTPClient(FTPServiceABC): dest_folder_name: str, dest_file_name: str, dest_port: Optional[Port] = Port.FTP, + session_id: Optional[str] = None, real_file_path: Optional[str] = None, ) -> bool: """ @@ -144,6 +159,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) @@ -152,10 +170,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 @@ -206,10 +221,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,10 +244,10 @@ 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 receive(self, payload: FTPPacket, session_id: Optional[str] = None, **kwargs) -> bool: @@ -252,5 +264,14 @@ 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 + 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 93f8b45b..23414601 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. @@ -47,11 +38,16 @@ 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 + 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) @@ -67,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) @@ -79,5 +79,15 @@ class FTPServer(FTPServiceABC): self.sys_log.error(f"{payload} is not an FTP packet") return False + """ + 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 + self.send(self._process_ftp_command(payload=payload, session_id=session_id), session_id) return True diff --git a/src/primaite/simulator/system/services/ftp/ftp_service.py b/src/primaite/simulator/system/services/ftp/ftp_service.py index 5314b6a3..f2c01544 100644 --- a/src/primaite/simulator/system/services/ftp/ftp_service.py +++ b/src/primaite/simulator/system/services/ftp/ftp_service.py @@ -6,7 +6,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 @@ -58,7 +57,7 @@ class FTPServiceABC(Service, ABC): file_name=file_name, folder_name=folder_name, size=file_size, real=is_real ) 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']}" ) if is_real: @@ -77,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. @@ -98,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( @@ -109,13 +112,14 @@ 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, ) - 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 @@ -149,7 +153,32 @@ 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}") 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 6156ec70..c2631455 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Dict, Optional +from typing import Dict, Optional from primaite import getLogger from primaite.simulator.core import RequestManager, RequestType @@ -72,45 +72,6 @@ class Service(IOSoftware): """ pass - def send( - self, - payload: Any, - 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 send. - :param: session_id: The id of the session - - :return: True if successful, False otherwise. - """ - self.software_manager.send_payload_to_session_manager(payload=payload, session_id=session_id) - - 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/__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.py b/src/primaite/simulator/system/services/web_server/web_server.py new file mode 100644 index 00000000..f63d5169 --- /dev/null +++ b/src/primaite/simulator/system/services/web_server/web_server.py @@ -0,0 +1,145 @@ +from ipaddress import IPv4Address +from typing import Any, Optional +from urllib.parse import urlparse + +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 WebServer(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") + + 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.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 + + if 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 + 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 16c614c5..25f764e4 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 RequestManager, RequestType, 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_request("scan", RequestType(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..f4546cbf --- /dev/null +++ b/tests/integration_tests/system/test_web_client_server.py @@ -0,0 +1,52 @@ +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_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/") 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 + 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_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"] 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