diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index 4b02a6db..37872b5b 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -19,7 +19,6 @@ installed on Nodes when they are instantiated. Key capabilities ================ - - Authenticates User connection by maintaining an active User account. - Ensures packets are matched to an existing session - Simulates common Terminal processes/commands. - Leverages the Service base class for install/uninstall, status tracking etc. @@ -27,21 +26,18 @@ Key capabilities Usage ===== - - Pre-Installs on any `HostNode` component. See the below code example of how to access the terminal. - - Terminal Clients connect, execute commands and disconnect from remote components. + - Pre-Installs on any `Node` (component with the exception of `Switches`). + - Terminal Clients connect, execute commands and disconnect from remote nodes. - Ensures that users are logged in to the component before executing any commands. - Service runs on SSH port 22 by default. Implementation ============== -The terminal takes inspiration from the `Database Client` and `Database Service` classes, and leverages the `UserSessionManager` -to provide User Credential authentication when receiving/processing commands. - -Terminal acts as the interface between the user/component and both the Session and Requests Managers, facilitating -the passing of requests to both. - -A more detailed example of how to use the Terminal class can be found in the Terminal-Processing jupyter notebook. + - Manages remote connections in a dictionary by session ID. + - Processes commands, forwarding to the ``RequestManager`` or ``SessionManager`` where appropriate. + - Extends Service class. + - A detailed guide on the implementation and functionality of the Terminal class can be found in the "Terminal-Processing" jupyter notebook. Python """""" diff --git a/src/primaite/notebooks/Terminal-Processing.ipynb b/src/primaite/notebooks/Terminal-Processing.ipynb index 77be3822..30b1a5e7 100644 --- a/src/primaite/notebooks/Terminal-Processing.ipynb +++ b/src/primaite/notebooks/Terminal-Processing.ipynb @@ -15,7 +15,7 @@ "source": [ "This notebook serves as a guide on the functionality and use of the new Terminal simulation component.\n", "\n", - "By default, the Terminal will come pre-installed on any simulation component which inherits from `HostNode` (Computer, Server, Printer), and simulates the Secure Shell (SSH) protocol as the communication method." + "The Terminal service comes pre-installed on most Nodes (The exception being Switches, as these are currently dumb). " ] }, { @@ -27,15 +27,9 @@ "from primaite.simulator.system.services.terminal.terminal import Terminal\n", "from primaite.simulator.network.container import Network\n", "from primaite.simulator.network.hardware.nodes.host.computer import Computer\n", - "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ + "from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript\n", + "from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection\n", + "\n", "def basic_network() -> Network:\n", " \"\"\"Utility function for creating a default network to demonstrate Terminal functionality\"\"\"\n", " network = Network()\n", @@ -51,9 +45,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The terminal can be accessed from a `HostNode` via the `software_manager` as demonstrated below. \n", + "The terminal can be accessed from a `Node` via the `software_manager` as demonstrated below. \n", "\n", - "In the example, we have a basic network consisting of two computers " + "In the example, we have a basic network consisting of two computers, connected to form a basic network." ] }, { @@ -66,15 +60,17 @@ "computer_a: Computer = network.get_node_by_hostname(\"node_a\")\n", "terminal_a: Terminal = computer_a.software_manager.software.get(\"Terminal\")\n", "computer_b: Computer = network.get_node_by_hostname(\"node_b\")\n", - "terminal_b: Terminal = computer_b.software_manager.software.get(\"Terminal\")\n" + "terminal_b: Terminal = computer_b.software_manager.software.get(\"Terminal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "To be able to send commands from `node_a` to `node_b`, you will need to `login` to `node_b` first, using valid user credentials. In the example below, we are logging in to the 'admin' account on `node_b`. \n", - "If you are not logged in, any commands sent will be rejected." + "To be able to send commands from `node_a` to `node_b`, you will need to `login` to `node_b` first, using valid user credentials. In the example below, we are remotely logging in to the 'admin' account on `node_b`, from `node_a`. \n", + "If you are not logged in, any commands sent will be rejected by the remote.\n", + "\n", + "Remote Logins return a RemoteTerminalConnection object, which can be used for sending commands to the remote node. " ] }, { @@ -84,10 +80,14 @@ "outputs": [], "source": [ "# Login to the remote (node_b) from local (node_a)\n", - "from primaite.simulator.system.services.terminal.terminal import RemoteTerminalConnection\n", - "\n", - "\n", - "term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=computer_b.network_interface[1].ip_address)" + "term_a_term_b_remote_connection: RemoteTerminalConnection = terminal_a.login(username=\"admin\", password=\"Admin123!\", ip_address=\"192.168.0.11\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can view all active connections to a terminal through use of the `show()` method" ] }, { @@ -96,7 +96,14 @@ "metadata": {}, "outputs": [], "source": [ - "computer_b.software_manager.show()" + "terminal_b.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The new connection object allows us to forward commands to be executed on the target node. The example below demonstrates how you can remotely install an application on the target node." ] }, { @@ -105,7 +112,6 @@ "metadata": {}, "outputs": [], "source": [ - "print(type(term_a_term_b_remote_connection))\n", "term_a_term_b_remote_connection.execute([\"software_manager\", \"application\", \"install\", \"RansomwareScript\"])" ] }, @@ -122,7 +128,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "You can view all remote connections to a terminal through use of the `show()` method" + "The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to create a downloads folder. \n" ] }, { @@ -131,23 +137,11 @@ "metadata": {}, "outputs": [], "source": [ - "terminal_b.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The Terminal can be used to send requests to install new software. The code block below demonstrates how the Terminal class allows the user of `terminal_a`, on `computer_a`, to send a command to `computer_b` to install the `RansomwareScript` application. \n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The below example shows how you can send a command via the terminal to create a folder on the target Node.\n", + "# Display the current state of the file system on computer_b\n", + "computer_b.file_system.show()\n", "\n", - "Here, we send a command to `computer_b` to create a new folder titled \"Downloads\"." + "# Send command\n", + "term_a_term_b_remote_connection.execute([\"file_system\", \"create\", \"folder\", \"downloads\"])" ] }, { @@ -156,6 +150,39 @@ "source": [ "The resultant call to `computer_b.file_system.show()` shows that the new folder has been created." ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "computer_b.file_system.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "When finished, the connection can be closed by calling the `disconnect` function of the Remote Client object" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display active connection\n", + "terminal_a.show()\n", + "terminal_b.show()\n", + "\n", + "term_a_term_b_remote_connection.disconnect()\n", + "\n", + "terminal_a.show()\n", + "\n", + "terminal_b.show()" + ] } ], "metadata": { diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index b7bc5287..0f8e180e 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -2,7 +2,7 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from uuid import uuid4 from prettytable import MARKDOWN, PrettyTable @@ -10,7 +10,12 @@ from pydantic import BaseModel from primaite.interface.request import RequestResponse from primaite.simulator.core import RequestManager, RequestType -from primaite.simulator.network.protocols.ssh import SSHPacket +from primaite.simulator.network.protocols.ssh import ( + SSHConnectionMessage, + SSHPacket, + SSHTransportMessage, + SSHUserCredentials, +) from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.core.software_manager import SoftwareManager @@ -33,6 +38,9 @@ class TerminalClientConnection(BaseModel): connection_uuid: str = None """Connection UUID""" + connection_request_id: str = None + """Connection request ID""" + @property def client(self) -> Optional[Terminal]: """The Terminal that holds this connection.""" @@ -51,6 +59,9 @@ class RemoteTerminalConnection(TerminalClientConnection): """ + source_ip: IPv4Address + """Source IP of Connection""" + def execute(self, command: Any) -> bool: """Execute a given command on the remote Terminal.""" if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING: @@ -88,13 +99,13 @@ class Terminal(Service): :param markdown: Whether to display the table in Markdown format or not. Default is `False`. """ - table = PrettyTable(["Connection ID", "Session_ID"]) + table = PrettyTable(["Connection ID", "Connection request ID", "Source IP"]) if markdown: table.set_style(MARKDOWN) table.align = "l" table.title = f"{self.sys_log.hostname} {self.name} Connections" for connection_id, connection in self._connections.items(): - table.add_row([connection_id, connection.session_id]) + table.add_row([connection_id, connection.connection_request_id, connection.source_ip]) print(table.get_string(sortby="Connection ID")) def _init_request_manager(self) -> RequestManager: @@ -130,7 +141,7 @@ class Terminal(Service): connection_uuid = request[0] # TODO: Uncomment this when UserSessionManager merged. # self.parent.UserSessionManager.logoff(connection_uuid) - self.disconnect(connection_uuid) + self._disconnect(connection_uuid) return RequestResponse(status="success", data={}) @@ -157,8 +168,13 @@ class Terminal(Service): """Execute a passed ssh command via the request manager.""" return self.parent.apply_request(command) - def _create_local_connection(self, connection_uuid: str, session_id: str) -> RemoteTerminalConnection: - """Create a new connection object and amend to list of active connections.""" + def _create_local_connection(self, connection_uuid: str, session_id: str) -> TerminalClientConnection: + """Create a new connection object and amend to list of active connections. + + :param connection_uuid: Connection ID of the new local connection + :param session_id: Session ID of the new local connection + :return: TerminalClientConnection object + """ new_connection = TerminalClientConnection( parent_terminal=self, connection_uuid=connection_uuid, @@ -172,7 +188,17 @@ class Terminal(Service): def login( self, username: str, password: str, ip_address: Optional[IPv4Address] = None ) -> Optional[TerminalClientConnection]: - """Login to the terminal. Will attempt a remote login if ip_address is given, else local.""" + """Login to the terminal. Will attempt a remote login if ip_address is given, else local. + + :param: username: Username used to connect to the remote node. + :type: username: str + + :param: password: Password used to connect to the remote node + :type: password: str + + :param: ip_address: Target Node IP address for login attempt. If None, login is assumed local. + :type: ip_address: Optional[IPv4Address] + """ if self.operating_state != ServiceOperatingState.RUNNING: self.sys_log.warning("Cannot login as service is not running.") return None @@ -199,8 +225,10 @@ class Terminal(Service): if connection_uuid: self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}") # Add new local session to list of connections - self._create_local_connection(connection_uuid=connection_uuid, session_id="") - return TerminalClientConnection(parent_terminal=self, session_id="", connection_uuid=connection_uuid) + self._create_local_connection(connection_uuid=connection_uuid, session_id="Local_Connection") + return TerminalClientConnection( + parent_terminal=self, session_id="Local_Connection", connection_uuid=connection_uuid + ) else: self.sys_log.warning("Login failed, incorrect Username or Password") return None @@ -217,7 +245,26 @@ class Terminal(Service): connection_request_id: str, is_reattempt: bool = False, ) -> Optional[RemoteTerminalConnection]: - """Process a remote login attempt.""" + """Send a remote login attempt and connect to Node. + + :param: username: Username used to connect to the remote node. + :type: username: str + + :param: password: Password used to connect to the remote node + :type: password: str + + :param: ip_address: Target Node IP address for login attempt. + :type: ip_address: IPv4Address + + :param: connection_request_id: Connection Request ID + :type: connection_request_id: str + + :param: is_reattempt: True if the request has been reattempted. Default False. + :type: is_reattempt: Optional[bool] + + :return: RemoteTerminalConnection: Connection Object for sending further commands if successful, else False. + + """ self.sys_log.info(f"Sending Remote login attempt to {ip_address}. Connection_id is {connection_request_id}") if is_reattempt: valid_connection = self._check_client_connection(connection_id=connection_request_id) @@ -229,12 +276,24 @@ class Terminal(Service): self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.") return None - payload = { + transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST + connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA + user_details: SSHUserCredentials = SSHUserCredentials(username=username, password=password) + + payload_contents = { "type": "login_request", "username": username, "password": password, "connection_request_id": connection_request_id, } + + payload: SSHPacket = SSHPacket( + payload=payload_contents, + transport_message=transport_message, + connection_message=connection_message, + user_account=user_details, + ) + software_manager: SoftwareManager = self.software_manager software_manager.send_payload_to_session_manager( payload=payload, dest_ip_address=ip_address, dest_port=self.port @@ -247,15 +306,28 @@ class Terminal(Service): connection_request_id=connection_request_id, ) - def _create_remote_connection(self, connection_id: str, connection_request_id: str, session_id: str) -> None: - """Create a new TerminalClientConnection Object.""" + def _create_remote_connection( + self, connection_id: str, connection_request_id: str, session_id: str, source_ip: str + ) -> None: + """Create a new TerminalClientConnection Object. + + :param: connection_request_id: Connection Request ID + :type: connection_request_id: str + + :param: session_id: Session ID of connection. + :type: session_id: str + """ client_connection = RemoteTerminalConnection( - parent_terminal=self, session_id=session_id, connection_uuid=connection_id + parent_terminal=self, + session_id=session_id, + connection_uuid=connection_id, + source_ip=source_ip, + connection_request_id=connection_request_id, ) self._connections[connection_id] = client_connection self._client_connection_requests[connection_request_id] = client_connection - def receive(self, session_id: str, payload: Any, **kwargs) -> bool: + def receive(self, session_id: str, payload: Union[SSHPacket, Dict, List], **kwargs) -> bool: """ Receive a payload from the Software Manager. @@ -263,42 +335,62 @@ class Terminal(Service): :param session_id: The session id the payload relates to. :return: True. """ - self.sys_log.info(f"Received payload: {payload}") - if isinstance(payload, dict) and payload.get("type"): - if payload["type"] == "login_request": - # add connection - connection_request_id = payload["connection_request_id"] - username = payload["username"] - password = payload["password"] - print(f"Connection ID is: {connection_request_id}") - self.sys_log.info(f"Connection authorised, session_id: {session_id}") + source_ip = kwargs["from_network_interface"].ip_address + self.sys_log.info(f"Received payload: {payload}. Source: {source_ip}") + if isinstance(payload, SSHPacket): + if payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST: + # validate & add connection + # TODO: uncomment this as part of 2781 + # connection_id = self.parent.UserSessionManager.login(username=username, password=password) + connection_id = str(uuid4()) + if connection_id: + connection_request_id = payload.connection_request_uuid + username = payload.user_account.username + password = payload.user_account.password + print(f"Connection ID is: {connection_request_id}") + self.sys_log.info(f"Connection authorised, session_id: {session_id}") + self._create_remote_connection( + connection_id=connection_id, + connection_request_id=connection_request_id, + session_id=session_id, + source_ip=source_ip, + ) + + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA + + payload_contents = { + "type": "login_success", + "username": username, + "password": password, + "connection_request_id": connection_request_id, + "connection_id": connection_id, + } + payload: SSHPacket = SSHPacket( + payload=payload_contents, + transport_message=transport_message, + connection_message=connection_message, + connection_request_uuid=connection_request_id, + connection_uuid=connection_id, + ) + + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload=payload, dest_port=self.port, session_id=session_id + ) + elif payload.transport_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: + self.sys_log.info("Login Successful") self._create_remote_connection( - connection_id=connection_request_id, - connection_request_id=payload["connection_request_id"], + connection_id=payload.connection_uuid, + connection_request_id=payload.connection_request_uuid, session_id=session_id, - ) - payload = { - "type": "login_success", - "username": username, - "password": password, - "connection_request_id": connection_request_id, - } - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( - payload=payload, dest_port=self.port, session_id=session_id - ) - elif payload["type"] == "login_success": - self.sys_log.info(f"Login was successful! session_id is:{session_id}") - connection_request_id = payload["connection_request_id"] - self._create_remote_connection( - connection_id=connection_request_id, - session_id=session_id, - connection_request_id=connection_request_id, + source_ip=source_ip, ) - elif payload["type"] == "disconnect": + if isinstance(payload, dict) and payload.get("type"): + if payload["type"] == "disconnect": connection_id = payload["connection_id"] - self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from the server") + self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from remote.") self._disconnect(payload["connection_id"]) if isinstance(payload, list): diff --git a/tests/integration_tests/system/test_nmap.py b/tests/integration_tests/system/test_nmap.py index 08251d71..2b8691cc 100644 --- a/tests/integration_tests/system/test_nmap.py +++ b/tests/integration_tests/system/test_nmap.py @@ -107,7 +107,7 @@ def test_port_scan_full_subnet_all_ports_and_protocols(example_network): expected_result = { IPv4Address("192.168.10.1"): {IPProtocol.UDP: [Port.ARP]}, IPv4Address("192.168.10.22"): { - IPProtocol.TCP: [Port.HTTP, Port.FTP, Port.DNS, Port.SSH], + IPProtocol.TCP: [Port.HTTP, Port.FTP, Port.DNS], IPProtocol.UDP: [Port.ARP, Port.NTP], }, } diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py index 794e88bf..c86d6466 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_terminal.py @@ -1,5 +1,6 @@ # © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK from typing import Tuple +from uuid import uuid4 import pytest @@ -11,7 +12,12 @@ from primaite.simulator.network.hardware.nodes.host.server import Server from primaite.simulator.network.hardware.nodes.network.router import ACLAction, Router from primaite.simulator.network.hardware.nodes.network.switch import Switch from primaite.simulator.network.hardware.nodes.network.wireless_router import WirelessRouter -from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage +from primaite.simulator.network.protocols.ssh import ( + SSHConnectionMessage, + SSHPacket, + SSHTransportMessage, + SSHUserCredentials, +) from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.red_applications.ransomware_script import RansomwareScript @@ -155,8 +161,10 @@ def test_terminal_send(basic_network): payload: SSHPacket = SSHPacket( payload="Test_Payload", - transport_message=SSHTransportMessage.SSH_MSG_SERVICE_REQUEST, - connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + transport_message=SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_DATA, + user_account=SSHUserCredentials(username="username", password="password"), + connection_request_uuid=str(uuid4()), ) assert terminal_a.send(payload=payload, dest_ip_address=computer_b.network_interface[1].ip_address) @@ -283,8 +291,6 @@ def test_router_remote_login_to_computer(wireless_wan_network): """Test to confirm that a router can ssh into a computer.""" pc_a, _, router_1, _ = wireless_wan_network - router_1: Router = router_1 - router_1_terminal: Terminal = router_1.software_manager.software.get("Terminal") assert len(router_1_terminal._connections) == 0 @@ -304,8 +310,6 @@ def test_router_blocks_SSH_traffic(wireless_wan_network): """Test to check that router will block SSH traffic if no ACL rule.""" pc_a, _, router_1, _ = wireless_wan_network - router_1: Router = router_1 - # Remove rule that allows SSH traffic. router_1.acl.remove_rule(position=21)