From bd05f4d4e81b1c5038dc45fc74916de2e53f6fe4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 2 Jul 2024 15:02:59 +0100 Subject: [PATCH] #2711 - Initial commit of Terminal Service Skeleton framework. Added in a placeholder SSHPacket class. Currently, this allows the Terminal 'service' to be installed onto a HostNode class, and Port 22 - SSH to be visible when using .show(). Functionality and testing still to be completed --- .../system/services/terminal.rst | 26 +++ src/primaite/game/game.py | 2 + .../network/hardware/nodes/host/host_node.py | 2 + .../simulator/network/protocols/ssh.py | 71 +++++++ .../system/services/terminal/__init__.py | 1 + .../system/services/terminal/terminal.py | 190 ++++++++++++++++++ 6 files changed, 292 insertions(+) create mode 100644 docs/source/simulation_components/system/services/terminal.rst create mode 100644 src/primaite/simulator/network/protocols/ssh.py create mode 100644 src/primaite/simulator/system/services/terminal/__init__.py create mode 100644 src/primaite/simulator/system/services/terminal/terminal.py 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..bf8072e8 --- /dev/null +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -0,0 +1,26 @@ +.. 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. diff --git a/src/primaite/game/game.py b/src/primaite/game/game.py index 8a79d068..908eecbb 100644 --- a/src/primaite/game/game.py +++ b/src/primaite/game/game.py @@ -38,6 +38,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__) @@ -60,6 +61,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..448f0fec --- /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 + + connection_message: SSHConnectionMessage + + ssh_command: Optional[any] = 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..d86d21c6 --- /dev/null +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -0,0 +1,190 @@ +# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK +from __future__ import annotations + +from ipaddress import IPv4Address, IPv4Network +from typing import Any, Dict, List, Optional, Union +from uuid import uuid4 + +from primaite.interface.request import RequestFormat, RequestResponse +from primaite.simulator.core import RequestManager, RequestPermissionValidator +from primaite.simulator.network.protocols.icmp import ICMPPacket + +# from primaite.simulator.network.protocols.ssh import SSHPacket, SSHTransportMessage, SSHConnectionMessage +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.service import Service, ServiceOperatingState + + +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" + + connected: bool = False + "Boolean Value for whether connected" + + connection_uuid: Optional[str] = None + "Uuid for connection requests" + + def __init__(self, **kwargs): + kwargs["name"] = "Terminal" + kwargs["port"] = Port.SSH + kwargs["protocol"] = IPProtocol.TCP + + super().__init__(**kwargs) + self.operating_state = ServiceOperatingState.RUNNING + + class _LoginValidator(RequestPermissionValidator): + """ + When requests come in, this validator will only let them through if we have valid login credentials. + + This should ensure that no actions are resolved without valid user credentials. + """ + + terminal: Terminal + + def __call__(self, request: RequestFormat, context: Dict) -> bool: + """Return whether the login credentials are valid.""" + pass + + @property + def fail_message(self) -> str: + """Message that is reported when a request is rejected by this validator.""" + return ( + f"Cannot perform request on Terminal '{self.terminal.hostname}' because login credentials are invalid" + ) + + def _validate_login(self) -> bool: + """Validate login credentials when receiving commands.""" + # TODO: Implement + return True + + def receive_payload_from_software_manager( + self, + payload: Any, + dst_ip_address: Optional[Union[IPv4Address, IPv4Network]] = None, + src_port: Optional[Port] = None, + dst_port: Optional[Port] = None, + session_id: Optional[str] = None, + ip_protocol: IPProtocol = IPProtocol.TCP, + icmp_packet: Optional[ICMPPacket] = None, + connection_id: Optional[str] = None, + ) -> Union[Any, None]: + """Receive Software Manager Payload.""" + self._validate_login() + + def _init_request_manager(self) -> RequestManager: + """Initialise Request manager.""" + # _login_is_valid = Terminal._LoginValidator(terminal=self) + rm = super()._init_request_manager() + + return rm + + def send( + self, + payload: Any, + dest_ip_address: Optional[IPv4Address] = None, + session_id: Optional[str] = None, + ) -> bool: + """Send Request to Software Manager.""" + return super().send(payload=payload, dest_ip_address=dest_ip_address, dest_port=Port.SSH, session_id=session_id) + + 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() + # TBD + state.update({"hostname": self.hostname}) + return state + + def execute(self, command: Any, request: Any) -> Optional[RequestResponse]: + """Execute Command.""" + # Returning the request to the request manager. + if self._validate_login(): + return self.apply_request(request) + else: + self.sys_log.error("Invalid login credentials provided.") + return None + + 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 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") + self._ssh_process_login(self, dest_ip_address=dest_ip_address, user_account=self.user_account) + + def _generate_connection_id(self) -> str: + """Generate a unique connection ID.""" + return str(uuid4()) + + # %% + + # def _ssh_process_login(self, user_account: dict, **kwargs) -> SSHPacket: + # """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 + # # operating state validation here(if overwhelmed) + + # # 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="ssh_connection", session_id=self.session_id): + # self.sys_log.warning(f"{self.name}: Connect request for {self.src_ip} declined. + # Service is at capacity.") + # ... + # else: + # self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised") + # transport_message = SSHTransportMessage.SSH_MSG_USERAUTH_SUCCESS + # connection_message = SSHConnectionMessage.SSH_MSG_CHANNEL_OPEN_CONFIRMATION + + # payload: SSHPacket = SSHPacket(transport_message = transport_message, connection_message = connection_message) + # return payload + + # %% + # Copy + Paste from Terminal Wiki + + # def ssh_remote_login(self, dest_ip_address = IPv4Address, user_account: Optional[dict] = None) -> bool: + # if user_account: + # # Setting default creds (Best to use this until we have more clarification on the specifics of user accounts) + # self.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) + # 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("Payload type incorrect, Login Failed") + # return False + # else: + # self.sys_log.error("Incorrect credentials provided. Login Failed.") + # return False + # %% + + def connect(self, **kwargs): + """Send connect request.""" + self._connect(self, **kwargs) + + def _connect(self): + """Do something.""" + pass