diff --git a/CHANGELOG.md b/CHANGELOG.md index 17bf3557..1f2db4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Made observation space flattening optional (on by default). To turn off for an agent, change the `agent_settings.flatten_obs` setting in the config. - Added support for SQL INSERT command. - Added ability to log each agent's action choices in each step to a JSON file. -- Removal of Link bandwidth hardcoding. This can now be configured via the network configuraiton yaml. Will default to 100 if not present. +- Removal of Link bandwidth hardcoding. This can now be configured via the network configuration yaml. Will default to 100 if not present. - Added NMAP application to all host and layer-3 network nodes. +- Added Terminal Class for HostNode components ### Bug Fixes diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst new file mode 100644 index 00000000..afa79c0a --- /dev/null +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -0,0 +1,56 @@ +.. only:: comment + + © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +.. _Terminal: + +Terminal +######## + +The ``Terminal`` provides a generic terminal simulation, by extending the base Service class + +Key capabilities +================ + + - Authenticates User connection by maintaining an active User account. + - Ensures packets are matched to an existing session + - Simulates common Terminal commands + - Leverages the Service base class for install/uninstall, status tracking etc. + + +Usage +===== + + - Install on a node via the ``SoftwareManager`` to start the Terminal + - Terminal Clients connect, execute commands and disconnect. + - Service runs on SSH port 22 by default. + +Implementation +============== + +- Manages SSH commands +- Ensures User login before sending commands +- Processes SSH commands +- Returns results in a ** format. + + +Python +"""""" + +.. code-block:: python + + from ipaddress import IPv4Address + + from primaite.simulator.network.hardware.nodes.host.computer import Computer + from primaite.simulator.system.services.terminal.terminal import Terminal + from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState + + client = Computer( + hostname="client", + ip_address="192.168.10.21", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1", + operating_state=NodeOperatingState.ON, + ) + + terminal: Terminal = client.software_manager.software.get("Terminal") diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 3dc9571f..e7ef4d50 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -41,6 +41,7 @@ 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.ntp.ntp_client import NTPClient from primaite.simulator.system.services.ntp.ntp_server import NTPServer +from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.simulator.system.services.web_server.web_server import WebServer _LOGGER = getLogger(__name__) @@ -54,6 +55,7 @@ SERVICE_TYPES_MAPPING = { "FTPServer": FTPServer, "NTPClient": NTPClient, "NTPServer": NTPServer, + "Terminal": Terminal, } """List of available services that can be installed on nodes in the PrimAITE Simulation.""" diff --git a/src/primaite/simulator/network/hardware/nodes/host/host_node.py b/src/primaite/simulator/network/hardware/nodes/host/host_node.py index fdb28339..5848ade4 100644 --- a/src/primaite/simulator/network/hardware/nodes/host/host_node.py +++ b/src/primaite/simulator/network/hardware/nodes/host/host_node.py @@ -15,6 +15,7 @@ from primaite.simulator.system.services.arp.arp import ARP, ARPPacket from primaite.simulator.system.services.dns.dns_client import DNSClient from primaite.simulator.system.services.icmp.icmp import ICMP from primaite.simulator.system.services.ntp.ntp_client import NTPClient +from primaite.simulator.system.services.terminal.terminal import Terminal from primaite.utils.validators import IPV4Address _LOGGER = getLogger(__name__) @@ -306,6 +307,7 @@ class HostNode(Node): "NTPClient": NTPClient, "WebBrowser": WebBrowser, "NMAP": NMAP, + "Terminal": Terminal, } """List of system software that is automatically installed on nodes.""" diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py new file mode 100644 index 00000000..7be81982 --- /dev/null +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -0,0 +1,71 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK + +from enum import IntEnum +from typing import Dict, Optional + +from primaite.interface.request import RequestResponse +from primaite.simulator.network.protocols.packet import DataPacket + +# TODO: Elaborate / Confirm / Validate - See 2709. +# Placeholder implementation for Terminal Class implementation. + + +class SSHTransportMessage(IntEnum): + """ + Enum list of Transport layer messages that can be handled by the simulation. + + Each msg value is equivalent to the real-world. + """ + + SSH_MSG_USERAUTH_REQUEST = 50 + """Requests User Authentication.""" + + SSH_MSG_USERAUTH_FAILURE = 51 + """Indicates User Authentication failed.""" + + SSH_MSG_USERAUTH_SUCCESS = 52 + """Indicates User Authentication failed was successful.""" + + SSH_MSG_SERVICE_REQUEST = 24 + """Requests a service - such as executing a command.""" + + # These two msgs are invented for primAITE however are modelled on reality + + SSH_MSG_SERVICE_FAILED = 25 + """Indicates that the requested service failed.""" + + SSH_MSG_SERVICE_SUCCESS = 26 + """Indicates that the requested service was successful.""" + + +class SSHConnectionMessage(IntEnum): + """Int Enum list of all SSH's connection protocol messages that can be handled by the simulation.""" + + SSH_MSG_CHANNEL_OPEN = 80 + """Requests an open channel - Used in combination with SSH_MSG_USERAUTH_REQUEST.""" + + SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 81 + """Confirms an open channel.""" + + SSH_MSG_CHANNEL_OPEN_FAILED = 82 + """Indicates that channel opening failure.""" + + SSH_MSG_CHANNEL_DATA = 84 + """Indicates that data is being sent through the channel.""" + + SSH_MSG_CHANNEL_CLOSE = 87 + """Closes the channel.""" + + +class SSHPacket(DataPacket): + """Represents an SSHPacket.""" + + transport_message: SSHTransportMessage = None + + connection_message: SSHConnectionMessage = None + + ssh_command: Optional[str] = None # This is the request string + + ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse + + user_account: Optional[Dict] = None # The user account we will use to login if we do not have a current connection. diff --git a/src/primaite/simulator/system/services/terminal/__init__.py b/src/primaite/simulator/system/services/terminal/__init__.py new file mode 100644 index 00000000..be6c00e7 --- /dev/null +++ b/src/primaite/simulator/system/services/terminal/__init__.py @@ -0,0 +1 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py new file mode 100644 index 00000000..5f8719ac --- /dev/null +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -0,0 +1,219 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from __future__ import annotations + +from ipaddress import IPv4Address +from typing import Dict, List, Optional +from uuid import uuid4 + +from pydantic import BaseModel + +from primaite.interface.request import RequestResponse +from primaite.simulator.core import RequestManager +from primaite.simulator.network.hardware.nodes.host.host_node import HostNode +from primaite.simulator.network.protocols.ssh import SSHConnectionMessage, SSHPacket, SSHTransportMessage +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 +from primaite.simulator.system.services.service import Service, ServiceOperatingState + + +class TerminalClientConnection(BaseModel): + """ + TerminalClientConnection Class. + + This class is used to record current User Connections within the Terminal class. + """ + + connection_id: str + """Connection UUID.""" + + parent_node: HostNode + """The parent Node that this connection was created on.""" + + is_active: bool = True + """Flag to state whether the connection is still active or not.""" + + _dest_ip_address: IPv4Address + """Destination IP address of connection""" + + @property + def dest_ip_address(self) -> Optional[IPv4Address]: + """Destination IP Address.""" + return self._dest_ip_address + + @property + def client(self) -> Optional[Terminal]: + """The Terminal that holds this connection.""" + return self.parent_node.software_manager.software.get("Terminal") + + def disconnect(self): + """Disconnect the connection.""" + if self.client and self.is_active: + self.client._disconnect(self.connection_id) # noqa + + +class Terminal(Service): + """Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH.""" + + user_account: Optional[str] = None + "The User Account used for login" + + is_connected: bool = False + "Boolean Value for whether connected" + + connection_uuid: Optional[str] = None + "Uuid for connection requests" + + operating_state: ServiceOperatingState = ServiceOperatingState.RUNNING + """Initial Operating State""" + + user_connections: Dict[str, TerminalClientConnection] = {} + """List of authenticated connected users""" + + def __init__(self, **kwargs): + kwargs["name"] = "Terminal" + kwargs["port"] = Port.SSH + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. + + :return: Current state of this object and child objects. + :rtype: Dict + """ + state = super().describe_state() + + state.update({"hostname": self.name}) + return state + + def apply_request(self, request: List[str | int | float | Dict], context: Dict | None = None) -> RequestResponse: + """Apply Temrinal Request.""" + return super().apply_request(request, context) + + def _init_request_manager(self) -> RequestManager: + """Initialise Request manager.""" + # TODO: Expand with a login validator? + rm = super()._init_request_manager() + return rm + + # %% Inbound + + def _generate_connection_id(self) -> str: + """Generate a unique connection ID.""" + return str(uuid4()) + + def process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: + """Process User request to login to Terminal.""" + if user_account in self.user_connections: + self.sys_log.debug("User authentication passed") + return True + else: + self._ssh_process_login(dest_ip_address=dest_ip_address, user_account=user_account) + self.process_login(dest_ip_address=dest_ip_address, user_account=user_account) + + def _ssh_process_login(self, dest_ip_address: IPv4Address, user_account: dict, **kwargs) -> bool: + """Processes the login attempt. Returns a SSHPacket which either rejects the login or accepts it.""" + # we assume that the login fails unless we meet all the criteria. + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_FAILURE + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_FAILED + + # Hard coded at current - replace with another method to handle local accounts. + if user_account == f"{self.user_name:} placeholder, {self.password:} placeholder": # hardcoded + connection_id = self._generate_connection_id() + if not self.add_connection(self, connection_id=connection_id): + self.sys_log.warning( + f"{self.name}: Connect request for {dest_ip_address} declined. Service is at capacity." + ) + return False + else: + self.sys_log.info(f"{self.name}: Connect request for ID: {connection_id} authorised") + transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION + new_connection = TerminalClientConnection(connection_id=connection_id, dest_ip_address=dest_ip_address) + self.user_connections[connection_id] = new_connection + self.is_connected = True + + payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message) + + self.send(payload=payload, dest_ip_address=dest_ip_address) + return True + + # %% Outbound + + def login(self, dest_ip_address: IPv4Address) -> bool: + """ + Perform an initial login request. + + If this fails, raises an error. + """ + # TODO: This will need elaborating when user accounts are implemented + self.sys_log.info("Attempting Login") + return self.ssh_remote_login(self, dest_ip_address=dest_ip_address, user_account=self.user_account) + + def ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: + """Remote login to terminal via SSH.""" + if not user_account: + # Setting default creds (Best to use this until we have more clarification around user accounts) + user_account = {self.user_name: "placeholder", self.password: "placeholder"} + + # Implement SSHPacket class + payload: SSHPacket = SSHPacket( + transport_message=SSHTransportMessage.SSH_MSG_USERAUTH_REQUEST, + connection_message=SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN, + user_account=user_account, + ) + # self.send will return bool, payload unchanged? + if self.send(payload=payload, dest_ip_address=dest_ip_address): + if payload.connection_message == SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS: + self.sys_log.info(f"{self.name} established an ssh connection with {dest_ip_address}") + # Need to confirm if self.uuid is correct. + self.add_connection(self, connection_id=self.uuid, session_id=self.session_id) + return True + else: + self.sys_log.error("Login Failed. Incorrect credentials provided.") + return False + else: + self.sys_log.error("Login Failed. Incorrect credentials provided.") + return False + + def check_connection(self, connection_id: str) -> bool: + """Check whether the connection is valid.""" + if self.is_connected: + return self.send(dest_ip_address=self.dest_ip_address, connection_id=connection_id) + else: + return False + + def disconnect(self, connection_id: str): + """Disconnect from remote.""" + self._disconnect(connection_id) + self.is_connected = False + + def _disconnect(self, connection_id: str) -> bool: + if not self.is_connected: + return False + + if len(self.user_connections) == 0: + self.sys_log.warning(f"{self.name}: Unable to disconnect, no active connections.") + return False + if not self.user_connections.get(connection_id): + return False + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload={"type": "disconnect", "connection_id": connection_id}, + dest_ip_address=self.server_ip_address, + dest_port=self.port, + ) + connection = self.user_connections.pop(connection_id) + self.terminate_connection(connection_id=connection_id) + + connection.is_active = False + + self.sys_log.info( + f"{self.name}: Disconnected {connection_id} from: {self.user_connections[connection_id]._dest_ip_address}" + ) + self.connected = False + return True