From bd05f4d4e81b1c5038dc45fc74916de2e53f6fe4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 2 Jul 2024 15:02:59 +0100 Subject: [PATCH 1/4] #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 From ebf6e7a90ebf8b713ac0ae3dc958896a80ed0bea Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Tue, 2 Jul 2024 16:47:39 +0100 Subject: [PATCH 2/4] #2711 - Added in remote_login and process_login methods. Minor updates to make pydantic happy. Starting to flesh out functionality of Terminal Service in more detail --- .../simulator/network/protocols/ssh.py | 6 +- .../system/services/terminal/terminal.py | 139 +++++++++--------- 2 files changed, 73 insertions(+), 72 deletions(-) diff --git a/src/primaite/simulator/network/protocols/ssh.py b/src/primaite/simulator/network/protocols/ssh.py index 448f0fec..7be81982 100644 --- a/src/primaite/simulator/network/protocols/ssh.py +++ b/src/primaite/simulator/network/protocols/ssh.py @@ -60,11 +60,11 @@ class SSHConnectionMessage(IntEnum): class SSHPacket(DataPacket): """Represents an SSHPacket.""" - transport_message: SSHTransportMessage + transport_message: SSHTransportMessage = None - connection_message: SSHConnectionMessage + connection_message: SSHConnectionMessage = None - ssh_command: Optional[any] = None # This is the request string + ssh_command: Optional[str] = None # This is the request string ssh_output: Optional[RequestResponse] = None # The Request Manager's returned RequestResponse diff --git a/src/primaite/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index d86d21c6..e1964f78 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -8,8 +8,7 @@ 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.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.services.service import Service, ServiceOperatingState @@ -21,19 +20,22 @@ class Terminal(Service): user_account: Optional[str] = None "The User Account used for login" - connected: bool = False + is_connected: bool = False "Boolean Value for whether connected" connection_uuid: Optional[str] = None "Uuid for connection requests" + operating_state: ServiceOperatingState = ServiceOperatingState.INSTALLING + """Service Operating State""" # Install at start ??? Maybe ??? + def __init__(self, **kwargs): kwargs["name"] = "Terminal" kwargs["port"] = Port.SSH kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) - self.operating_state = ServiceOperatingState.RUNNING + # self.operating_state = ServiceOperatingState.RUNNING class _LoginValidator(RequestPermissionValidator): """ @@ -46,34 +48,22 @@ class Terminal(Service): def __call__(self, request: RequestFormat, context: Dict) -> bool: """Return whether the login credentials are valid.""" - pass + # TODO: Expand & Implement logic when we have User Accounts. + if self.terminal.is_connected: + return True + else: + self.terminal.sys_log.error("terminal is not logged in.") @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" - ) + return f"Cannot perform request on Terminal '{self.terminal.name}' 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) @@ -101,7 +91,7 @@ class Terminal(Service): """ state = super().describe_state() # TBD - state.update({"hostname": self.hostname}) + state.update({"hostname": self.name}) return state def execute(self, command: Any, request: Any) -> Optional[RequestResponse]: @@ -133,58 +123,69 @@ class Terminal(Service): # %% - # 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) + 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 + # 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 + self.is_connected = True - # payload: SSHPacket = SSHPacket(transport_message = transport_message, connection_message = connection_message) - # return payload + 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"} + def ssh_remote_login(self, dest_ip_address: IPv4Address, user_account: Optional[dict] = None) -> bool: + """Remote login to terminal via SSH.""" + if user_account: + # Setting default creds (Best to use this until we have more clarification around 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 - # # 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 + 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() From 219d448adc0f7a0be2bbaa5c246af46a25cb66b4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 8 Jul 2024 07:58:10 +0100 Subject: [PATCH 3/4] #2711 - Rewrite of the majority of the terminal class after not liking how I originally did it. This takes a heavier inspiration for handling connections from the database_client/server --- .../system/services/terminal.rst | 30 +++ .../system/services/terminal/terminal.py | 254 ++++++++++-------- 2 files changed, 171 insertions(+), 113 deletions(-) diff --git a/docs/source/simulation_components/system/services/terminal.rst b/docs/source/simulation_components/system/services/terminal.rst index bf8072e8..afa79c0a 100644 --- a/docs/source/simulation_components/system/services/terminal.rst +++ b/docs/source/simulation_components/system/services/terminal.rst @@ -24,3 +24,33 @@ 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/simulator/system/services/terminal/terminal.py b/src/primaite/simulator/system/services/terminal/terminal.py index e1964f78..5f8719ac 100644 --- a/src/primaite/simulator/system/services/terminal/terminal.py +++ b/src/primaite/simulator/system/services/terminal/terminal.py @@ -1,19 +1,57 @@ # © 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 ipaddress import IPv4Address +from typing import Dict, List, Optional 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 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.""" @@ -26,59 +64,17 @@ class Terminal(Service): connection_uuid: Optional[str] = None "Uuid for connection requests" - operating_state: ServiceOperatingState = ServiceOperatingState.INSTALLING - """Service Operating State""" # Install at start ??? Maybe ??? + 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) - # 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.""" - # TODO: Expand & Implement logic when we have User Accounts. - if self.terminal.is_connected: - return True - else: - self.terminal.sys_log.error("terminal is not logged in.") - - @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.name}' because login credentials are invalid" - - def _validate_login(self) -> bool: - """Validate login credentials when receiving commands.""" - # TODO: Implement - return True - - 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: """ @@ -90,23 +86,64 @@ class Terminal(Service): :rtype: Dict """ state = super().describe_state() - # TBD + state.update({"hostname": self.name}) 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 _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. @@ -115,45 +152,13 @@ class Terminal(Service): """ # 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 - self.is_connected = True - - payload: SSHPacket = SSHPacket(transport_message=transport_message, connection_message=connection_message) - return payload - - # %% - # Copy + Paste from Terminal Wiki + 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 user_account: + if not user_account: # Setting default creds (Best to use this until we have more clarification around user accounts) - self.user_account = {self.user_name: "placeholder", self.password: "placeholder"} + user_account = {self.user_name: "placeholder", self.password: "placeholder"} # Implement SSHPacket class payload: SSHPacket = SSHPacket( @@ -161,6 +166,7 @@ class Terminal(Service): 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}") @@ -168,24 +174,46 @@ class Terminal(Service): 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") + self.sys_log.error("Login Failed. Incorrect credentials provided.") return False else: - self.sys_log.error("Incorrect credentials provided. Login Failed.") + 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 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 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 From 252214b4689444fb6f54f53a9ae9199158b1c5e4 Mon Sep 17 00:00:00 2001 From: Charlie Crane Date: Mon, 8 Jul 2024 08:25:42 +0100 Subject: [PATCH 4/4] #2711 Updating Changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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