Merge branch 'dev' into feature/2689-command-and-control
This commit is contained in:
@@ -6,12 +6,11 @@ import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, TypeVar, Union
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar, Union
|
||||
|
||||
from prettytable import MARKDOWN, PrettyTable
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, validate_call
|
||||
|
||||
import primaite.simulator.network.nmne
|
||||
from primaite import getLogger
|
||||
from primaite.exceptions import NetworkError
|
||||
from primaite.interface.request import RequestResponse
|
||||
@@ -20,17 +19,10 @@ from primaite.simulator.core import RequestFormat, RequestManager, RequestPermis
|
||||
from primaite.simulator.domain.account import Account
|
||||
from primaite.simulator.file_system.file_system import FileSystem
|
||||
from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState
|
||||
from primaite.simulator.network.nmne import (
|
||||
CAPTURE_BY_DIRECTION,
|
||||
CAPTURE_BY_IP_ADDRESS,
|
||||
CAPTURE_BY_KEYWORD,
|
||||
CAPTURE_BY_PORT,
|
||||
CAPTURE_BY_PROTOCOL,
|
||||
CAPTURE_NMNE,
|
||||
NMNE_CAPTURE_KEYWORDS,
|
||||
)
|
||||
from primaite.simulator.network.nmne import NMNEConfig
|
||||
from primaite.simulator.network.transmission.data_link_layer import Frame
|
||||
from primaite.simulator.network.transmission.network_layer import IPProtocol
|
||||
from primaite.simulator.network.transmission.transport_layer import Port
|
||||
from primaite.simulator.system.applications.application import Application
|
||||
from primaite.simulator.system.core.packet_capture import PacketCapture
|
||||
from primaite.simulator.system.core.session_manager import SessionManager
|
||||
@@ -38,7 +30,8 @@ from primaite.simulator.system.core.software_manager import SoftwareManager
|
||||
from primaite.simulator.system.core.sys_log import SysLog
|
||||
from primaite.simulator.system.processes.process import Process
|
||||
from primaite.simulator.system.services.service import Service
|
||||
from primaite.simulator.system.software import IOSoftware
|
||||
from primaite.simulator.system.services.terminal.terminal import Terminal
|
||||
from primaite.simulator.system.software import IOSoftware, Software
|
||||
from primaite.utils.converters import convert_dict_enum_keys_to_enum_values
|
||||
from primaite.utils.validators import IPV4Address
|
||||
|
||||
@@ -108,8 +101,11 @@ class NetworkInterface(SimComponent, ABC):
|
||||
pcap: Optional[PacketCapture] = None
|
||||
"A PacketCapture instance for capturing and analysing packets passing through this interface."
|
||||
|
||||
nmne_config: ClassVar[NMNEConfig] = NMNEConfig()
|
||||
"A dataclass defining malicious network events to be captured."
|
||||
|
||||
nmne: Dict = Field(default_factory=lambda: {})
|
||||
"A dict containing details of the number of malicious network events captured."
|
||||
"A dict containing details of the number of malicious events captured."
|
||||
|
||||
traffic: Dict = Field(default_factory=lambda: {})
|
||||
"A dict containing details of the inbound and outbound traffic by port and protocol."
|
||||
@@ -167,8 +163,8 @@ class NetworkInterface(SimComponent, ABC):
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
)
|
||||
if CAPTURE_NMNE:
|
||||
state.update({"nmne": {k: v for k, v in self.nmne.items()}})
|
||||
if self.nmne_config and self.nmne_config.capture_nmne:
|
||||
state.update({"nmne": self.nmne})
|
||||
state.update({"traffic": convert_dict_enum_keys_to_enum_values(self.traffic)})
|
||||
return state
|
||||
|
||||
@@ -201,7 +197,7 @@ class NetworkInterface(SimComponent, ABC):
|
||||
:param inbound: Boolean indicating if the frame direction is inbound. Defaults to True.
|
||||
"""
|
||||
# Exit function if NMNE capturing is disabled
|
||||
if not CAPTURE_NMNE:
|
||||
if not (self.nmne_config and self.nmne_config.capture_nmne):
|
||||
return
|
||||
|
||||
# Initialise basic frame data variables
|
||||
@@ -222,27 +218,27 @@ class NetworkInterface(SimComponent, ABC):
|
||||
frame_str = str(frame.payload)
|
||||
|
||||
# Proceed only if any NMNE keyword is present in the frame payload
|
||||
if any(keyword in frame_str for keyword in NMNE_CAPTURE_KEYWORDS):
|
||||
if any(keyword in frame_str for keyword in self.nmne_config.nmne_capture_keywords):
|
||||
# Start with the root of the NMNE capture structure
|
||||
current_level = self.nmne
|
||||
|
||||
# Update NMNE structure based on enabled settings
|
||||
if CAPTURE_BY_DIRECTION:
|
||||
if self.nmne_config.capture_by_direction:
|
||||
# Set or get the dictionary for the current direction
|
||||
current_level = current_level.setdefault("direction", {})
|
||||
current_level = current_level.setdefault(direction, {})
|
||||
|
||||
if CAPTURE_BY_IP_ADDRESS:
|
||||
if self.nmne_config.capture_by_ip_address:
|
||||
# Set or get the dictionary for the current IP address
|
||||
current_level = current_level.setdefault("ip_address", {})
|
||||
current_level = current_level.setdefault(ip_address, {})
|
||||
|
||||
if CAPTURE_BY_PROTOCOL:
|
||||
if self.nmne_config.capture_by_protocol:
|
||||
# Set or get the dictionary for the current protocol
|
||||
current_level = current_level.setdefault("protocol", {})
|
||||
current_level = current_level.setdefault(protocol, {})
|
||||
|
||||
if CAPTURE_BY_PORT:
|
||||
if self.nmne_config.capture_by_port:
|
||||
# Set or get the dictionary for the current port
|
||||
current_level = current_level.setdefault("port", {})
|
||||
current_level = current_level.setdefault(port, {})
|
||||
@@ -251,8 +247,8 @@ class NetworkInterface(SimComponent, ABC):
|
||||
keyword_level = current_level.setdefault("keywords", {})
|
||||
|
||||
# Increment the count for detected keywords in the payload
|
||||
if CAPTURE_BY_KEYWORD:
|
||||
for keyword in NMNE_CAPTURE_KEYWORDS:
|
||||
if self.nmne_config.capture_by_keyword:
|
||||
for keyword in self.nmne_config.nmne_capture_keywords:
|
||||
if keyword in frame_str:
|
||||
# Update the count for each keyword found
|
||||
keyword_level[keyword] = keyword_level.get(keyword, 0) + 1
|
||||
@@ -794,6 +790,650 @@ class Link(SimComponent):
|
||||
self.current_load = 0.0
|
||||
|
||||
|
||||
class User(SimComponent):
|
||||
"""
|
||||
Represents a user in the PrimAITE system.
|
||||
|
||||
:ivar username: The username of the user
|
||||
:ivar password: The password of the user
|
||||
:ivar disabled: Boolean flag indicating whether the user is disabled
|
||||
:ivar is_admin: Boolean flag indicating whether the user has admin privileges
|
||||
"""
|
||||
|
||||
username: str
|
||||
"""The username of the user"""
|
||||
|
||||
password: str
|
||||
"""The password of the user"""
|
||||
|
||||
disabled: bool = False
|
||||
"""Boolean flag indicating whether the user is disabled"""
|
||||
|
||||
is_admin: bool = False
|
||||
"""Boolean flag indicating whether the user has admin privileges"""
|
||||
|
||||
num_of_logins: int = 0
|
||||
"""Counts the number of the User has logged in"""
|
||||
|
||||
def describe_state(self) -> Dict:
|
||||
"""
|
||||
Returns a dictionary representing the current state of the user.
|
||||
|
||||
:return: A dict containing the state of the user
|
||||
"""
|
||||
return self.model_dump()
|
||||
|
||||
|
||||
class UserManager(Service):
|
||||
"""
|
||||
Manages users within the PrimAITE system, handling creation, authentication, and administration.
|
||||
|
||||
:param users: A dictionary of all users by their usernames
|
||||
:param admins: A dictionary of admin users by their usernames
|
||||
:param disabled_admins: A dictionary of currently disabled admin users by their usernames
|
||||
"""
|
||||
|
||||
users: Dict[str, User] = {}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes a UserManager instanc.
|
||||
|
||||
:param username: The username for the default admin user
|
||||
:param password: The password for the default admin user
|
||||
"""
|
||||
kwargs["name"] = "UserManager"
|
||||
kwargs["port"] = Port.NONE
|
||||
kwargs["protocol"] = IPProtocol.NONE
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.start()
|
||||
|
||||
def _init_request_manager(self) -> RequestManager:
|
||||
"""
|
||||
Initialise the request manager.
|
||||
|
||||
More information in user guide and docstring for SimComponent._init_request_manager.
|
||||
"""
|
||||
rm = super()._init_request_manager()
|
||||
|
||||
# todo add doc about requeest schemas
|
||||
rm.add_request(
|
||||
"change_password",
|
||||
RequestType(
|
||||
func=lambda request, context: RequestResponse.from_bool(
|
||||
self.change_user_password(username=request[0], current_password=request[1], new_password=request[2])
|
||||
)
|
||||
),
|
||||
)
|
||||
return rm
|
||||
|
||||
def describe_state(self) -> Dict:
|
||||
"""
|
||||
Returns the state of the UserManager along with the number of users and admins.
|
||||
|
||||
:return: A dict containing detailed state information
|
||||
"""
|
||||
state = super().describe_state()
|
||||
state.update({"total_users": len(self.users), "total_admins": len(self.admins) + len(self.disabled_admins)})
|
||||
state["users"] = {k: v.describe_state() for k, v in self.users.items()}
|
||||
return state
|
||||
|
||||
def show(self, markdown: bool = False):
|
||||
"""
|
||||
Display the Users.
|
||||
|
||||
:param markdown: Whether to display the table in Markdown format or not. Default is `False`.
|
||||
"""
|
||||
table = PrettyTable(["Username", "Admin", "Disabled"])
|
||||
if markdown:
|
||||
table.set_style(MARKDOWN)
|
||||
table.align = "l"
|
||||
table.title = f"{self.sys_log.hostname} User Manager"
|
||||
for user in self.users.values():
|
||||
table.add_row([user.username, user.is_admin, user.disabled])
|
||||
print(table.get_string(sortby="Username"))
|
||||
|
||||
@property
|
||||
def non_admins(self) -> Dict[str, User]:
|
||||
"""
|
||||
Returns a dictionary of all enabled non-admin users.
|
||||
|
||||
:return: A dictionary with usernames as keys and User objects as values for non-admin, non-disabled users.
|
||||
"""
|
||||
return {k: v for k, v in self.users.items() if not v.is_admin and not v.disabled}
|
||||
|
||||
@property
|
||||
def disabled_non_admins(self) -> Dict[str, User]:
|
||||
"""
|
||||
Returns a dictionary of all disabled non-admin users.
|
||||
|
||||
:return: A dictionary with usernames as keys and User objects as values for non-admin, disabled users.
|
||||
"""
|
||||
return {k: v for k, v in self.users.items() if not v.is_admin and v.disabled}
|
||||
|
||||
@property
|
||||
def admins(self) -> Dict[str, User]:
|
||||
"""
|
||||
Returns a dictionary of all enabled admin users.
|
||||
|
||||
:return: A dictionary with usernames as keys and User objects as values for admin, non-disabled users.
|
||||
"""
|
||||
return {k: v for k, v in self.users.items() if v.is_admin and not v.disabled}
|
||||
|
||||
@property
|
||||
def disabled_admins(self) -> Dict[str, User]:
|
||||
"""
|
||||
Returns a dictionary of all disabled admin users.
|
||||
|
||||
:return: A dictionary with usernames as keys and User objects as values for admin, disabled users.
|
||||
"""
|
||||
return {k: v for k, v in self.users.items() if v.is_admin and v.disabled}
|
||||
|
||||
def install(self) -> None:
|
||||
"""Setup default user during first-time installation."""
|
||||
self.add_user(username="admin", password="admin", is_admin=True, bypass_can_perform_action=True)
|
||||
|
||||
def _is_last_admin(self, username: str) -> bool:
|
||||
return username in self.admins and len(self.admins) == 1
|
||||
|
||||
def add_user(
|
||||
self, username: str, password: str, is_admin: bool = False, bypass_can_perform_action: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Adds a new user to the system.
|
||||
|
||||
:param username: The username for the new user
|
||||
:param password: The password for the new user
|
||||
:param is_admin: Flag indicating if the new user is an admin
|
||||
:return: True if user was successfully added, False otherwise
|
||||
"""
|
||||
if not bypass_can_perform_action and not self._can_perform_action():
|
||||
return False
|
||||
if username in self.users:
|
||||
self.sys_log.info(f"{self.name}: Failed to create new user {username} as this user name already exists")
|
||||
return False
|
||||
user = User(username=username, password=password, is_admin=is_admin)
|
||||
self.users[username] = user
|
||||
self.sys_log.info(f"{self.name}: Added new {'admin' if is_admin else 'user'}: {username}")
|
||||
return True
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||
"""
|
||||
Authenticates a user's login attempt.
|
||||
|
||||
:param username: The username of the user trying to log in
|
||||
:param password: The password provided by the user
|
||||
:return: The User object if authentication is successful, None otherwise
|
||||
"""
|
||||
if not self._can_perform_action():
|
||||
return None
|
||||
user = self.users.get(username)
|
||||
if user and not user.disabled and user.password == password:
|
||||
self.sys_log.info(f"{self.name}: User authenticated: {username}")
|
||||
return user
|
||||
self.sys_log.info(f"{self.name}: Authentication failed for: {username}")
|
||||
return None
|
||||
|
||||
def change_user_password(self, username: str, current_password: str, new_password: str) -> bool:
|
||||
"""
|
||||
Changes a user's password.
|
||||
|
||||
:param username: The username of the user changing their password
|
||||
:param current_password: The current password of the user
|
||||
:param new_password: The new password for the user
|
||||
:return: True if the password was changed successfully, False otherwise
|
||||
"""
|
||||
if not self._can_perform_action():
|
||||
return False
|
||||
user = self.users.get(username)
|
||||
if user and user.password == current_password:
|
||||
user.password = new_password
|
||||
self.sys_log.info(f"{self.name}: Password changed for {username}")
|
||||
return True
|
||||
self.sys_log.info(f"{self.name}: Password change failed for {username}")
|
||||
return False
|
||||
|
||||
def disable_user(self, username: str) -> bool:
|
||||
"""
|
||||
Disables a user account, preventing them from logging in.
|
||||
|
||||
:param username: The username of the user to disable
|
||||
:return: True if the user was disabled successfully, False otherwise
|
||||
"""
|
||||
if not self._can_perform_action():
|
||||
return False
|
||||
if username in self.users and not self.users[username].disabled:
|
||||
if self._is_last_admin(username):
|
||||
self.sys_log.info(f"{self.name}: Cannot disable User {username} as they are the only enabled admin")
|
||||
return False
|
||||
self.users[username].disabled = True
|
||||
self.sys_log.info(f"{self.name}: User disabled: {username}")
|
||||
return True
|
||||
self.sys_log.info(f"{self.name}: Failed to disable user: {username}")
|
||||
return False
|
||||
|
||||
def enable_user(self, username: str) -> bool:
|
||||
"""
|
||||
Enables a previously disabled user account.
|
||||
|
||||
:param username: The username of the user to enable
|
||||
:return: True if the user was enabled successfully, False otherwise
|
||||
"""
|
||||
if username in self.users and self.users[username].disabled:
|
||||
self.users[username].disabled = False
|
||||
self.sys_log.info(f"{self.name}: User enabled: {username}")
|
||||
return True
|
||||
self.sys_log.info(f"{self.name}: Failed to enable user: {username}")
|
||||
return False
|
||||
|
||||
|
||||
class UserSession(SimComponent):
|
||||
"""
|
||||
Represents a user session on the Node.
|
||||
|
||||
This class manages the state of a user session, including the user, session start, last active step,
|
||||
and end step. It also indicates whether the session is local.
|
||||
|
||||
:ivar user: The user associated with this session.
|
||||
:ivar start_step: The timestep when the session was started.
|
||||
:ivar last_active_step: The last timestep when the session was active.
|
||||
:ivar end_step: The timestep when the session ended, if applicable.
|
||||
:ivar local: Indicates if the session is local. Defaults to True.
|
||||
"""
|
||||
|
||||
user: User
|
||||
"""The user associated with this session."""
|
||||
|
||||
start_step: int
|
||||
"""The timestep when the session was started."""
|
||||
|
||||
last_active_step: int
|
||||
"""The last timestep when the session was active."""
|
||||
|
||||
end_step: Optional[int] = None
|
||||
"""The timestep when the session ended, if applicable."""
|
||||
|
||||
local: bool = True
|
||||
"""Indicates if the session is a local session or a remote session. Defaults to True as a local session."""
|
||||
|
||||
@classmethod
|
||||
def create(cls, user: User, timestep: int) -> UserSession:
|
||||
"""
|
||||
Creates a new instance of UserSession.
|
||||
|
||||
This class method initialises a user session with the given user and timestep.
|
||||
|
||||
:param user: The user associated with this session.
|
||||
:param timestep: The timestep when the session is created.
|
||||
:return: An instance of UserSession.
|
||||
"""
|
||||
user.num_of_logins += 1
|
||||
return UserSession(user=user, start_step=timestep, last_active_step=timestep)
|
||||
|
||||
def describe_state(self) -> Dict:
|
||||
"""
|
||||
Describes the current state of the user session.
|
||||
|
||||
:return: A dictionary representing the state of the user session.
|
||||
"""
|
||||
return self.model_dump()
|
||||
|
||||
|
||||
class RemoteUserSession(UserSession):
|
||||
"""
|
||||
Represents a remote user session on the Node.
|
||||
|
||||
This class extends the UserSession class to include additional attributes and methods specific to remote sessions.
|
||||
|
||||
:ivar remote_ip_address: The IP address of the remote user.
|
||||
:ivar local: Indicates that this is not a local session. Always set to False.
|
||||
"""
|
||||
|
||||
remote_ip_address: IPV4Address
|
||||
"""The IP address of the remote user."""
|
||||
|
||||
local: bool = False
|
||||
"""Indicates that this is not a local session. Always set to False."""
|
||||
|
||||
@classmethod
|
||||
def create(cls, user: User, timestep: int, remote_ip_address: IPV4Address) -> RemoteUserSession: # noqa
|
||||
"""
|
||||
Creates a new instance of RemoteUserSession.
|
||||
|
||||
This class method initialises a remote user session with the given user, timestep, and remote IP address.
|
||||
|
||||
:param user: The user associated with this session.
|
||||
:param timestep: The timestep when the session is created.
|
||||
:param remote_ip_address: The IP address of the remote user.
|
||||
:return: An instance of RemoteUserSession.
|
||||
"""
|
||||
return RemoteUserSession(
|
||||
user=user, start_step=timestep, last_active_step=timestep, remote_ip_address=remote_ip_address
|
||||
)
|
||||
|
||||
def describe_state(self) -> Dict:
|
||||
"""
|
||||
Describes the current state of the remote user session.
|
||||
|
||||
This method extends the base describe_state method to include the remote IP address.
|
||||
|
||||
:return: A dictionary representing the state of the remote user session.
|
||||
"""
|
||||
state = super().describe_state()
|
||||
state["remote_ip_address"] = str(self.remote_ip_address)
|
||||
return state
|
||||
|
||||
|
||||
class UserSessionManager(Service):
|
||||
"""
|
||||
Manages user sessions on a Node, including local and remote sessions.
|
||||
|
||||
This class handles authentication, session management, and session timeouts for users interacting with the Node.
|
||||
"""
|
||||
|
||||
local_session: Optional[UserSession] = None
|
||||
"""The current local user session, if any."""
|
||||
|
||||
remote_sessions: Dict[str, RemoteUserSession] = {}
|
||||
"""A dictionary of active remote user sessions."""
|
||||
|
||||
historic_sessions: List[UserSession] = Field(default_factory=list)
|
||||
"""A list of historic user sessions."""
|
||||
|
||||
local_session_timeout_steps: int = 30
|
||||
"""The number of steps before a local session times out due to inactivity."""
|
||||
|
||||
remote_session_timeout_steps: int = 5
|
||||
"""The number of steps before a remote session times out due to inactivity."""
|
||||
|
||||
max_remote_sessions: int = 3
|
||||
"""The maximum number of concurrent remote sessions allowed."""
|
||||
|
||||
current_timestep: int = 0
|
||||
"""The current timestep in the simulation."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initializes a UserSessionManager instance.
|
||||
|
||||
:param username: The username for the default admin user
|
||||
:param password: The password for the default admin user
|
||||
"""
|
||||
kwargs["name"] = "UserSessionManager"
|
||||
kwargs["port"] = Port.NONE
|
||||
kwargs["protocol"] = IPProtocol.NONE
|
||||
super().__init__(**kwargs)
|
||||
self.start()
|
||||
|
||||
def _init_request_manager(self) -> RequestManager:
|
||||
"""
|
||||
Initialise the request manager.
|
||||
|
||||
More information in user guide and docstring for SimComponent._init_request_manager.
|
||||
"""
|
||||
rm = super()._init_request_manager()
|
||||
|
||||
# todo add doc about requeest schemas
|
||||
rm.add_request(
|
||||
"remote_login",
|
||||
RequestType(
|
||||
func=lambda request, context: RequestResponse.from_bool(
|
||||
self.remote_login(username=request[0], password=request[1], remote_ip_address=request[2])
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
rm.add_request(
|
||||
"remote_logout",
|
||||
RequestType(
|
||||
func=lambda request, context: RequestResponse.from_bool(
|
||||
self.remote_logout(remote_session_id=request[0])
|
||||
)
|
||||
),
|
||||
)
|
||||
return rm
|
||||
|
||||
def show(self, markdown: bool = False, include_session_id: bool = False, include_historic: bool = False):
|
||||
"""
|
||||
Displays a table of the user sessions on the Node.
|
||||
|
||||
:param markdown: Whether to display the table in markdown format.
|
||||
:param include_session_id: Whether to include session IDs in the table.
|
||||
:param include_historic: Whether to include historic sessions in the table.
|
||||
"""
|
||||
headers = ["Session ID", "Username", "Type", "Remote IP", "Start Step", "Step Last Active", "End Step"]
|
||||
|
||||
if not include_session_id:
|
||||
headers = headers[1:]
|
||||
|
||||
table = PrettyTable(headers)
|
||||
|
||||
if markdown:
|
||||
table.set_style(MARKDOWN)
|
||||
table.align = "l"
|
||||
table.title = f"{self.parent.hostname} User Sessions"
|
||||
|
||||
def _add_session_to_table(user_session: UserSession):
|
||||
"""
|
||||
Adds a user session to the table for display.
|
||||
|
||||
This helper function determines whether the session is local or remote and formats the session data
|
||||
accordingly. It then adds the session data to the table.
|
||||
|
||||
:param user_session: The user session to add to the table.
|
||||
"""
|
||||
session_type = "local"
|
||||
remote_ip = ""
|
||||
if isinstance(user_session, RemoteUserSession):
|
||||
session_type = "remote"
|
||||
remote_ip = str(user_session.remote_ip_address)
|
||||
data = [
|
||||
user_session.uuid,
|
||||
user_session.user.username,
|
||||
session_type,
|
||||
remote_ip,
|
||||
user_session.start_step,
|
||||
user_session.last_active_step,
|
||||
user_session.end_step if user_session.end_step else "",
|
||||
]
|
||||
if not include_session_id:
|
||||
data = data[1:]
|
||||
table.add_row(data)
|
||||
|
||||
if self.local_session is not None:
|
||||
_add_session_to_table(self.local_session)
|
||||
|
||||
for user_session in self.remote_sessions.values():
|
||||
_add_session_to_table(user_session)
|
||||
|
||||
if include_historic:
|
||||
for user_session in self.historic_sessions:
|
||||
_add_session_to_table(user_session)
|
||||
|
||||
print(table.get_string(sortby="Step Last Active", reversesort=True))
|
||||
|
||||
def describe_state(self) -> Dict:
|
||||
"""
|
||||
Describes the current state of the UserSessionManager.
|
||||
|
||||
:return: A dictionary representing the current state.
|
||||
"""
|
||||
state = super().describe_state()
|
||||
state["active_remote_logins"] = len(self.remote_sessions)
|
||||
return state
|
||||
|
||||
@property
|
||||
def _user_manager(self) -> UserManager:
|
||||
"""
|
||||
Returns the UserManager instance.
|
||||
|
||||
:return: The UserManager instance.
|
||||
"""
|
||||
return self.software_manager.software["UserManager"] # noqa
|
||||
|
||||
def pre_timestep(self, timestep: int) -> None:
|
||||
"""Apply any pre-timestep logic that helps make sure we have the correct observations."""
|
||||
self.current_timestep = timestep
|
||||
if self.local_session:
|
||||
if self.local_session.last_active_step + self.local_session_timeout_steps <= timestep:
|
||||
self._timeout_session(self.local_session)
|
||||
|
||||
def _timeout_session(self, session: UserSession) -> None:
|
||||
"""
|
||||
Handles session timeout logic.
|
||||
|
||||
:param session: The session to be timed out.
|
||||
"""
|
||||
session.end_step = self.current_timestep
|
||||
session_identity = session.user.username
|
||||
if session.local:
|
||||
self.local_session = None
|
||||
session_type = "Local"
|
||||
else:
|
||||
self.remote_sessions.pop(session.uuid)
|
||||
session_type = "Remote"
|
||||
session_identity = f"{session_identity} {session.remote_ip_address}"
|
||||
|
||||
self.sys_log.info(f"{self.name}: {session_type} {session_identity} session timeout due to inactivity")
|
||||
|
||||
@property
|
||||
def remote_session_limit_reached(self) -> bool:
|
||||
"""
|
||||
Checks if the maximum number of remote sessions has been reached.
|
||||
|
||||
:return: True if the limit is reached, otherwise False.
|
||||
"""
|
||||
return len(self.remote_sessions) >= self.max_remote_sessions
|
||||
|
||||
def validate_remote_session_uuid(self, remote_session_id: str) -> bool:
|
||||
"""
|
||||
Validates if a given remote session ID exists.
|
||||
|
||||
:param remote_session_id: The remote session ID to validate.
|
||||
:return: True if the session ID exists, otherwise False.
|
||||
"""
|
||||
return remote_session_id in self.remote_sessions
|
||||
|
||||
def _login(
|
||||
self, username: str, password: str, local: bool = True, remote_ip_address: Optional[IPv4Address] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Logs a user in either locally or remotely.
|
||||
|
||||
:param username: The username of the account.
|
||||
:param password: The password of the account.
|
||||
:param local: Whether the login is local or remote.
|
||||
:param remote_ip_address: The remote IP address for remote login.
|
||||
:return: The session ID if login is successful, otherwise None.
|
||||
"""
|
||||
if not self._can_perform_action():
|
||||
return None
|
||||
|
||||
user = self._user_manager.authenticate_user(username=username, password=password)
|
||||
|
||||
if not user:
|
||||
self.sys_log.info(f"{self.name}: Incorrect username or password")
|
||||
return None
|
||||
|
||||
session_id = None
|
||||
if local:
|
||||
create_new_session = True
|
||||
if self.local_session:
|
||||
if self.local_session.user != user:
|
||||
# logout the current user
|
||||
self.local_logout()
|
||||
else:
|
||||
# not required as existing logged-in user attempting to re-login
|
||||
create_new_session = False
|
||||
|
||||
if create_new_session:
|
||||
self.local_session = UserSession.create(user=user, timestep=self.current_timestep)
|
||||
|
||||
session_id = self.local_session.uuid
|
||||
else:
|
||||
if not self.remote_session_limit_reached:
|
||||
remote_session = RemoteUserSession.create(
|
||||
user=user, timestep=self.current_timestep, remote_ip_address=remote_ip_address
|
||||
)
|
||||
session_id = remote_session.uuid
|
||||
self.remote_sessions[session_id] = remote_session
|
||||
self.sys_log.info(f"{self.name}: User {user.username} logged in")
|
||||
return session_id
|
||||
|
||||
def local_login(self, username: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
Logs a user in locally.
|
||||
|
||||
:param username: The username of the account.
|
||||
:param password: The password of the account.
|
||||
:return: The session ID if login is successful, otherwise None.
|
||||
"""
|
||||
return self._login(username=username, password=password, local=True)
|
||||
|
||||
@validate_call()
|
||||
def remote_login(self, username: str, password: str, remote_ip_address: IPV4Address) -> Optional[str]:
|
||||
"""
|
||||
Logs a user in remotely.
|
||||
|
||||
:param username: The username of the account.
|
||||
:param password: The password of the account.
|
||||
:param remote_ip_address: The remote IP address for the remote login.
|
||||
:return: The session ID if login is successful, otherwise None.
|
||||
"""
|
||||
return self._login(username=username, password=password, local=False, remote_ip_address=remote_ip_address)
|
||||
|
||||
def _logout(self, local: bool = True, remote_session_id: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Logs a user out either locally or remotely.
|
||||
|
||||
:param local: Whether the logout is local or remote.
|
||||
:param remote_session_id: The remote session ID for remote logout.
|
||||
:return: True if logout successful, otherwise False.
|
||||
"""
|
||||
if not self._can_perform_action():
|
||||
return False
|
||||
session = None
|
||||
if local and self.local_session:
|
||||
session = self.local_session
|
||||
session.end_step = self.current_timestep
|
||||
self.local_session = None
|
||||
|
||||
if not local and remote_session_id:
|
||||
session = self.remote_sessions.pop(remote_session_id)
|
||||
if session:
|
||||
self.historic_sessions.append(session)
|
||||
self.sys_log.info(f"{self.name}: User {session.user.username} logged out")
|
||||
return True
|
||||
return False
|
||||
|
||||
def local_logout(self) -> bool:
|
||||
"""
|
||||
Logs out the current local user.
|
||||
|
||||
:return: True if logout successful, otherwise False.
|
||||
"""
|
||||
return self._logout(local=True)
|
||||
|
||||
def remote_logout(self, remote_session_id: str) -> bool:
|
||||
"""
|
||||
Logs out a remote user by session ID.
|
||||
|
||||
:param remote_session_id: The remote session ID.
|
||||
:return: True if logout successful, otherwise False.
|
||||
"""
|
||||
return self._logout(local=False, remote_session_id=remote_session_id)
|
||||
|
||||
@property
|
||||
def local_user_logged_in(self) -> bool:
|
||||
"""
|
||||
Checks if a local user is currently logged in.
|
||||
|
||||
:return: True if a local user is logged in, otherwise False.
|
||||
"""
|
||||
return self.local_session is not None
|
||||
|
||||
|
||||
class Node(SimComponent):
|
||||
"""
|
||||
A basic Node class that represents a node on the network.
|
||||
@@ -861,11 +1501,14 @@ class Node(SimComponent):
|
||||
red_scan_countdown: int = 0
|
||||
"Time steps until reveal to red scan is complete."
|
||||
|
||||
SYSTEM_SOFTWARE: ClassVar[Dict[str, Type[Software]]] = {}
|
||||
"Base system software that must be preinstalled."
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Initialize the Node with various components and managers.
|
||||
|
||||
This method initializes the ARP cache, ICMP handler, session manager, and software manager if they are not
|
||||
This method initialises the ARP cache, ICMP handler, session manager, and software manager if they are not
|
||||
provided.
|
||||
"""
|
||||
if not kwargs.get("sys_log"):
|
||||
@@ -885,9 +1528,45 @@ class Node(SimComponent):
|
||||
dns_server=kwargs.get("dns_server"),
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
self._install_system_software()
|
||||
self.session_manager.node = self
|
||||
self.session_manager.software_manager = self.software_manager
|
||||
self._install_system_software()
|
||||
|
||||
@property
|
||||
def user_manager(self) -> Optional[UserManager]:
|
||||
"""The Nodes User Manager."""
|
||||
return self.software_manager.software.get("UserManager") # noqa
|
||||
|
||||
@property
|
||||
def user_session_manager(self) -> Optional[UserSessionManager]:
|
||||
"""The Nodes User Session Manager."""
|
||||
return self.software_manager.software.get("UserSessionManager") # noqa
|
||||
|
||||
@property
|
||||
def terminal(self) -> Optional[Terminal]:
|
||||
"""The Nodes Terminal."""
|
||||
return self.software_manager.software.get("Terminal")
|
||||
|
||||
def local_login(self, username: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
Attempt to log in to the node uas a local user.
|
||||
|
||||
This method attempts to authenticate a local user with the given username and password. If successful, it
|
||||
returns a session token. If authentication fails, it returns None.
|
||||
|
||||
:param username: The username of the account attempting to log in.
|
||||
:param password: The password of the account attempting to log in.
|
||||
:return: A session token if the login is successful, otherwise None.
|
||||
"""
|
||||
return self.user_session_manager.local_login(username, password)
|
||||
|
||||
def local_logout(self) -> None:
|
||||
"""
|
||||
Log out the current local user from the node.
|
||||
|
||||
This method ends the current local user's session and invalidates the session token.
|
||||
"""
|
||||
return self.user_session_manager.local_logout()
|
||||
|
||||
def ip_is_network_interface(self, ip_address: IPv4Address, enabled_only: bool = False) -> bool:
|
||||
"""
|
||||
@@ -942,7 +1621,7 @@ class Node(SimComponent):
|
||||
@property
|
||||
def fail_message(self) -> str:
|
||||
"""Message that is reported when a request is rejected by this validator."""
|
||||
return f"Cannot perform request on node '{self.node.hostname}' because it is not turned on."
|
||||
return f"Cannot perform request on node '{self.node.hostname}' because it is not powered on."
|
||||
|
||||
class _NodeIsOffValidator(RequestPermissionValidator):
|
||||
"""
|
||||
@@ -984,7 +1663,7 @@ class Node(SimComponent):
|
||||
application_name = request[0]
|
||||
if self.software_manager.software.get(application_name):
|
||||
self.sys_log.warning(f"Can't install {application_name}. It's already installed.")
|
||||
return RequestResponse.from_bool(False)
|
||||
return RequestResponse(status="success", data={"reason": "already installed"})
|
||||
application_class = Application._application_registry[application_name]
|
||||
self.software_manager.install(application_class)
|
||||
application_instance = self.software_manager.software.get(application_name)
|
||||
@@ -1091,10 +1770,6 @@ class Node(SimComponent):
|
||||
|
||||
return rm
|
||||
|
||||
def _install_system_software(self):
|
||||
"""Install System Software - software that is usually provided with the OS."""
|
||||
pass
|
||||
|
||||
def describe_state(self) -> Dict:
|
||||
"""
|
||||
Produce a dictionary describing the current state of this object.
|
||||
@@ -1173,7 +1848,7 @@ class Node(SimComponent):
|
||||
ip_address,
|
||||
network_interface.speed,
|
||||
"Enabled" if network_interface.enabled else "Disabled",
|
||||
network_interface.nmne if primaite.simulator.network.nmne.CAPTURE_NMNE else "Disabled",
|
||||
network_interface.nmne if network_interface.nmne_config.capture_nmne else "Disabled",
|
||||
]
|
||||
)
|
||||
print(table)
|
||||
@@ -1455,74 +2130,6 @@ class Node(SimComponent):
|
||||
else:
|
||||
return
|
||||
|
||||
def install_service(self, service: Service) -> None:
|
||||
"""
|
||||
Install a service on this node.
|
||||
|
||||
:param service: Service instance that has not been installed on any node yet.
|
||||
:type service: Service
|
||||
"""
|
||||
if service in self:
|
||||
_LOGGER.warning(f"Can't add service {service.name} to node {self.hostname}. It's already installed.")
|
||||
return
|
||||
self.services[service.uuid] = service
|
||||
service.parent = self
|
||||
service.install() # Perform any additional setup, such as creating files for this service on the node.
|
||||
self.sys_log.info(f"Installed service {service.name}")
|
||||
_LOGGER.debug(f"Added service {service.name} to node {self.hostname}")
|
||||
self._service_request_manager.add_request(service.name, RequestType(func=service._request_manager))
|
||||
|
||||
def uninstall_service(self, service: Service) -> None:
|
||||
"""
|
||||
Uninstall and completely remove service from this node.
|
||||
|
||||
:param service: Service object that is currently associated with this node.
|
||||
:type service: Service
|
||||
"""
|
||||
if service not in self:
|
||||
_LOGGER.warning(f"Can't remove service {service.name} from node {self.hostname}. It's not installed.")
|
||||
return
|
||||
service.uninstall() # Perform additional teardown, such as removing files or restarting the machine.
|
||||
self.services.pop(service.uuid)
|
||||
service.parent = None
|
||||
self.sys_log.info(f"Uninstalled service {service.name}")
|
||||
self._service_request_manager.remove_request(service.name)
|
||||
|
||||
def install_application(self, application: Application) -> None:
|
||||
"""
|
||||
Install an application on this node.
|
||||
|
||||
:param application: Application instance that has not been installed on any node yet.
|
||||
:type application: Application
|
||||
"""
|
||||
if application in self:
|
||||
_LOGGER.warning(
|
||||
f"Can't add application {application.name} to node {self.hostname}. It's already installed."
|
||||
)
|
||||
return
|
||||
self.applications[application.uuid] = application
|
||||
application.parent = self
|
||||
self.sys_log.info(f"Installed application {application.name}")
|
||||
_LOGGER.debug(f"Added application {application.name} to node {self.hostname}")
|
||||
self._application_request_manager.add_request(application.name, RequestType(func=application._request_manager))
|
||||
|
||||
def uninstall_application(self, application: Application) -> None:
|
||||
"""
|
||||
Uninstall and completely remove application from this node.
|
||||
|
||||
:param application: Application object that is currently associated with this node.
|
||||
:type application: Application
|
||||
"""
|
||||
if application not in self:
|
||||
_LOGGER.warning(
|
||||
f"Can't remove application {application.name} from node {self.hostname}. It's not installed."
|
||||
)
|
||||
return
|
||||
self.applications.pop(application.uuid)
|
||||
application.parent = None
|
||||
self.sys_log.info(f"Uninstalled application {application.name}")
|
||||
self._application_request_manager.remove_request(application.name)
|
||||
|
||||
def _shut_down_actions(self):
|
||||
"""Actions to perform when the node is shut down."""
|
||||
# Turn off all the services in the node
|
||||
@@ -1551,6 +2158,11 @@ class Node(SimComponent):
|
||||
# for process_id in self.processes:
|
||||
# self.processes[process_id]
|
||||
|
||||
def _install_system_software(self) -> None:
|
||||
"""Preinstall required software."""
|
||||
for _, software_class in self.SYSTEM_SOFTWARE.items():
|
||||
self.software_manager.install(software_class)
|
||||
|
||||
def __contains__(self, item: Any) -> bool:
|
||||
if isinstance(item, Service):
|
||||
return item.uuid in self.services
|
||||
|
||||
@@ -5,7 +5,13 @@ from ipaddress import IPv4Address
|
||||
from typing import Any, ClassVar, Dict, Optional
|
||||
|
||||
from primaite import getLogger
|
||||
from primaite.simulator.network.hardware.base import IPWiredNetworkInterface, Link, Node
|
||||
from primaite.simulator.network.hardware.base import (
|
||||
IPWiredNetworkInterface,
|
||||
Link,
|
||||
Node,
|
||||
UserManager,
|
||||
UserSessionManager,
|
||||
)
|
||||
from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState
|
||||
from primaite.simulator.network.transmission.data_link_layer import Frame
|
||||
from primaite.simulator.system.applications.application import ApplicationOperatingState
|
||||
@@ -15,6 +21,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__)
|
||||
@@ -292,6 +299,7 @@ class HostNode(Node):
|
||||
* DNS (Domain Name System) Client: Resolves domain names to IP addresses.
|
||||
* FTP (File Transfer Protocol) Client: Enables file transfers between the host and FTP servers.
|
||||
* NTP (Network Time Protocol) Client: Synchronizes the system clock with NTP servers.
|
||||
* Terminal Client: Handles SSH requests between HostNode and external components.
|
||||
|
||||
Applications:
|
||||
------------
|
||||
@@ -306,6 +314,9 @@ class HostNode(Node):
|
||||
"NTPClient": NTPClient,
|
||||
"WebBrowser": WebBrowser,
|
||||
"NMAP": NMAP,
|
||||
"UserSessionManager": UserSessionManager,
|
||||
"UserManager": UserManager,
|
||||
"Terminal": Terminal,
|
||||
}
|
||||
"""List of system software that is automatically installed on nodes."""
|
||||
|
||||
@@ -338,18 +349,6 @@ class HostNode(Node):
|
||||
"""
|
||||
return self.software_manager.software.get("ARP")
|
||||
|
||||
def _install_system_software(self):
|
||||
"""
|
||||
Installs the system software and network services typically found on an operating system.
|
||||
|
||||
This method equips the host with essential network services and applications, preparing it for various
|
||||
network-related tasks and operations.
|
||||
"""
|
||||
for _, software_class in self.SYSTEM_SOFTWARE.items():
|
||||
self.software_manager.install(software_class)
|
||||
|
||||
super()._install_system_software()
|
||||
|
||||
def default_gateway_hello(self):
|
||||
"""
|
||||
Sends a hello message to the default gateway to establish connectivity and resolve the gateway's MAC address.
|
||||
|
||||
@@ -4,14 +4,14 @@ from __future__ import annotations
|
||||
import secrets
|
||||
from enum import Enum
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from prettytable import MARKDOWN, PrettyTable
|
||||
from pydantic import validate_call
|
||||
|
||||
from primaite.interface.request import RequestResponse
|
||||
from primaite.simulator.core import RequestManager, RequestType, SimComponent
|
||||
from primaite.simulator.network.hardware.base import IPWiredNetworkInterface
|
||||
from primaite.simulator.network.hardware.base import IPWiredNetworkInterface, UserManager, UserSessionManager
|
||||
from primaite.simulator.network.hardware.node_operating_state import NodeOperatingState
|
||||
from primaite.simulator.network.hardware.nodes.network.network_node import NetworkNode
|
||||
from primaite.simulator.network.protocols.arp import ARPPacket
|
||||
@@ -24,6 +24,7 @@ from primaite.simulator.system.core.session_manager import SessionManager
|
||||
from primaite.simulator.system.core.sys_log import SysLog
|
||||
from primaite.simulator.system.services.arp.arp import ARP
|
||||
from primaite.simulator.system.services.icmp.icmp import ICMP
|
||||
from primaite.simulator.system.services.terminal.terminal import Terminal
|
||||
from primaite.utils.validators import IPV4Address
|
||||
|
||||
|
||||
@@ -1200,6 +1201,12 @@ class Router(NetworkNode):
|
||||
RouteTable, RouterARP, and RouterICMP services.
|
||||
"""
|
||||
|
||||
SYSTEM_SOFTWARE: ClassVar[Dict] = {
|
||||
"UserSessionManager": UserSessionManager,
|
||||
"UserManager": UserManager,
|
||||
"Terminal": Terminal,
|
||||
}
|
||||
|
||||
num_ports: int
|
||||
network_interfaces: Dict[str, RouterInterface] = {}
|
||||
"The Router Interfaces on the node."
|
||||
@@ -1235,6 +1242,7 @@ class Router(NetworkNode):
|
||||
resolution within the network. These services are crucial for the router's operation, enabling it to manage
|
||||
network traffic efficiently.
|
||||
"""
|
||||
super()._install_system_software()
|
||||
self.software_manager.install(RouterICMP)
|
||||
icmp: RouterICMP = self.software_manager.icmp # noqa
|
||||
icmp.router = self
|
||||
|
||||
@@ -108,6 +108,9 @@ class Switch(NetworkNode):
|
||||
for i in range(1, self.num_ports + 1):
|
||||
self.connect_nic(SwitchPort())
|
||||
|
||||
def _install_system_software(self):
|
||||
pass
|
||||
|
||||
def show(self, markdown: bool = False):
|
||||
"""
|
||||
Prints a table of the SwitchPorts on the Switch.
|
||||
|
||||
@@ -1,48 +1,25 @@
|
||||
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
|
||||
from typing import Dict, Final, List
|
||||
from typing import List
|
||||
|
||||
CAPTURE_NMNE: bool = True
|
||||
"""Indicates whether Malicious Network Events (MNEs) should be captured. Default is True."""
|
||||
|
||||
NMNE_CAPTURE_KEYWORDS: List[str] = []
|
||||
"""List of keywords to identify malicious network events."""
|
||||
|
||||
# TODO: Remove final and make configurable after example layout when the NICObservation creates nmne structure dynamically
|
||||
CAPTURE_BY_DIRECTION: Final[bool] = True
|
||||
"""Flag to determine if captures should be organized by traffic direction (inbound/outbound)."""
|
||||
CAPTURE_BY_IP_ADDRESS: Final[bool] = False
|
||||
"""Flag to determine if captures should be organized by source or destination IP address."""
|
||||
CAPTURE_BY_PROTOCOL: Final[bool] = False
|
||||
"""Flag to determine if captures should be organized by network protocol (e.g., TCP, UDP)."""
|
||||
CAPTURE_BY_PORT: Final[bool] = False
|
||||
"""Flag to determine if captures should be organized by source or destination port."""
|
||||
CAPTURE_BY_KEYWORD: Final[bool] = False
|
||||
"""Flag to determine if captures should be filtered and categorised based on specific keywords."""
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
def set_nmne_config(nmne_config: Dict):
|
||||
"""
|
||||
Sets the configuration for capturing Malicious Network Events (MNEs) based on a provided dictionary.
|
||||
class NMNEConfig(BaseModel):
|
||||
"""Store all the information to perform NMNE operations."""
|
||||
|
||||
This function updates global settings related to NMNE capture, including whether to capture NMNEs and what
|
||||
keywords to use for identifying NMNEs.
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
The function ensures that the settings are updated only if they are provided in the `nmne_config` dictionary,
|
||||
and maintains type integrity by checking the types of the provided values.
|
||||
|
||||
:param nmne_config: A dictionary containing the NMNE configuration settings. Possible keys include:
|
||||
"capture_nmne" (bool) to indicate whether NMNEs should be captured, "nmne_capture_keywords" (list of strings)
|
||||
to specify keywords for NMNE identification.
|
||||
"""
|
||||
global NMNE_CAPTURE_KEYWORDS
|
||||
global CAPTURE_NMNE
|
||||
|
||||
# Update the NMNE capture flag, defaulting to False if not specified or if the type is incorrect
|
||||
CAPTURE_NMNE = nmne_config.get("capture_nmne", False)
|
||||
if not isinstance(CAPTURE_NMNE, bool):
|
||||
CAPTURE_NMNE = True # Revert to default True if the provided value is not a boolean
|
||||
|
||||
# Update the NMNE capture keywords, appending new keywords if provided
|
||||
NMNE_CAPTURE_KEYWORDS += nmne_config.get("nmne_capture_keywords", [])
|
||||
if not isinstance(NMNE_CAPTURE_KEYWORDS, list):
|
||||
NMNE_CAPTURE_KEYWORDS = [] # Reset to empty list if the provided value is not a list
|
||||
capture_nmne: bool = False
|
||||
"""Indicates whether Malicious Network Events (MNEs) should be captured."""
|
||||
nmne_capture_keywords: List[str] = []
|
||||
"""List of keywords to identify malicious network events."""
|
||||
capture_by_direction: bool = True
|
||||
"""Captures should be organized by traffic direction (inbound/outbound)."""
|
||||
capture_by_ip_address: bool = False
|
||||
"""Captures should be organized by source or destination IP address."""
|
||||
capture_by_protocol: bool = False
|
||||
"""Captures should be organized by network protocol (e.g., TCP, UDP)."""
|
||||
capture_by_port: bool = False
|
||||
"""Captures should be organized by source or destination port."""
|
||||
capture_by_keyword: bool = False
|
||||
"""Captures should be filtered and categorised based on specific keywords."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
from typing import Union
|
||||
|
||||
from pydantic import BaseModel, field_validator, validate_call
|
||||
from pydantic_core.core_schema import FieldValidationInfo
|
||||
from pydantic_core.core_schema import ValidationInfo
|
||||
|
||||
from primaite import getLogger
|
||||
|
||||
@@ -96,7 +96,7 @@ class ICMPPacket(BaseModel):
|
||||
|
||||
@field_validator("icmp_code") # noqa
|
||||
@classmethod
|
||||
def _icmp_type_must_have_icmp_code(cls, v: int, info: FieldValidationInfo) -> int:
|
||||
def _icmp_type_must_have_icmp_code(cls, v: int, info: ValidationInfo) -> int:
|
||||
"""Validates the icmp_type and icmp_code."""
|
||||
icmp_type = info.data["icmp_type"]
|
||||
if get_icmp_type_code_description(icmp_type, v):
|
||||
|
||||
89
src/primaite/simulator/network/protocols/ssh.py
Normal file
89
src/primaite/simulator/network/protocols/ssh.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import Optional
|
||||
|
||||
from primaite.interface.request import RequestResponse
|
||||
from primaite.simulator.network.protocols.packet import DataPacket
|
||||
|
||||
|
||||
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 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 SSHUserCredentials(DataPacket):
|
||||
"""Hold Username and Password in SSH Packets."""
|
||||
|
||||
username: str
|
||||
"""Username for login"""
|
||||
|
||||
password: str
|
||||
"""Password for login"""
|
||||
|
||||
|
||||
class SSHPacket(DataPacket):
|
||||
"""Represents an SSHPacket."""
|
||||
|
||||
transport_message: SSHTransportMessage
|
||||
"""Message Transport Type"""
|
||||
|
||||
connection_message: SSHConnectionMessage
|
||||
"""Message Connection Status"""
|
||||
|
||||
user_account: Optional[SSHUserCredentials] = None
|
||||
"""User Account Credentials if passed"""
|
||||
|
||||
connection_request_uuid: Optional[str] = None
|
||||
"""Connection Request UUID used when establishing a remote connection"""
|
||||
|
||||
connection_uuid: Optional[str] = None
|
||||
"""Connection UUID used when validating a remote connection"""
|
||||
|
||||
ssh_output: Optional[RequestResponse] = None
|
||||
"""RequestResponse from Request Manager"""
|
||||
|
||||
ssh_command: Optional[list] = None
|
||||
"""Request String"""
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from prettytable import MARKDOWN, PrettyTable
|
||||
@@ -54,6 +54,12 @@ class DatabaseClientConnection(BaseModel):
|
||||
if self.client and self.is_active:
|
||||
self.client._disconnect(self.connection_id) # noqa
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.__class__.__name__}(connection_id='{self.connection_id}', is_active={self.is_active})"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
||||
|
||||
|
||||
class DatabaseClient(Application, identifier="DatabaseClient"):
|
||||
"""
|
||||
@@ -76,7 +82,7 @@ class DatabaseClient(Application, identifier="DatabaseClient"):
|
||||
"""Connection ID to the Database Server."""
|
||||
client_connections: Dict[str, DatabaseClientConnection] = {}
|
||||
"""Keep track of active connections to Database Server."""
|
||||
_client_connection_requests: Dict[str, Optional[str]] = {}
|
||||
_client_connection_requests: Dict[str, Optional[Union[str, DatabaseClientConnection]]] = {}
|
||||
"""Dictionary of connection requests to Database Server."""
|
||||
connected: bool = False
|
||||
"""Boolean Value for whether connected to DB Server."""
|
||||
@@ -187,7 +193,7 @@ class DatabaseClient(Application, identifier="DatabaseClient"):
|
||||
return False
|
||||
return self._query("SELECT * FROM pg_stat_activity", connection_id=connection_id)
|
||||
|
||||
def _check_client_connection(self, connection_id: str) -> bool:
|
||||
def _validate_client_connection_request(self, connection_id: str) -> bool:
|
||||
"""Check that client_connection_id is valid."""
|
||||
return True if connection_id in self._client_connection_requests else False
|
||||
|
||||
@@ -211,23 +217,30 @@ class DatabaseClient(Application, identifier="DatabaseClient"):
|
||||
:type: is_reattempt: Optional[bool]
|
||||
"""
|
||||
if is_reattempt:
|
||||
valid_connection = self._check_client_connection(connection_id=connection_request_id)
|
||||
if valid_connection:
|
||||
valid_connection_request = self._validate_client_connection_request(connection_id=connection_request_id)
|
||||
if valid_connection_request:
|
||||
database_client_connection = self._client_connection_requests.pop(connection_request_id)
|
||||
self.sys_log.info(
|
||||
f"{self.name}: DatabaseClient connection to {server_ip_address} authorised."
|
||||
f"Connection Request ID was {connection_request_id}."
|
||||
)
|
||||
self.connected = True
|
||||
self._last_connection_successful = True
|
||||
return database_client_connection
|
||||
if isinstance(database_client_connection, DatabaseClientConnection):
|
||||
self.sys_log.info(
|
||||
f"{self.name}: Connection request ({connection_request_id}) to {server_ip_address} authorised. "
|
||||
f"Using connection id {database_client_connection}"
|
||||
)
|
||||
self.connected = True
|
||||
self._last_connection_successful = True
|
||||
return database_client_connection
|
||||
else:
|
||||
self.sys_log.info(
|
||||
f"{self.name}: Connection request ({connection_request_id}) to {server_ip_address} declined"
|
||||
)
|
||||
self._last_connection_successful = False
|
||||
return None
|
||||
else:
|
||||
self.sys_log.warning(
|
||||
f"{self.name}: DatabaseClient connection to {server_ip_address} declined."
|
||||
f"Connection Request ID was {connection_request_id}."
|
||||
self.sys_log.info(
|
||||
f"{self.name}: Connection request ({connection_request_id}) to {server_ip_address} declined "
|
||||
f"due to unknown client-side connection request id"
|
||||
)
|
||||
self._last_connection_successful = False
|
||||
return None
|
||||
|
||||
payload = {"type": "connect_request", "password": password, "connection_request_id": connection_request_id}
|
||||
software_manager: SoftwareManager = self.software_manager
|
||||
software_manager.send_payload_to_session_manager(
|
||||
@@ -300,9 +313,14 @@ class DatabaseClient(Application, identifier="DatabaseClient"):
|
||||
"""
|
||||
if not self._can_perform_action():
|
||||
return None
|
||||
|
||||
connection_request_id = str(uuid4())
|
||||
self._client_connection_requests[connection_request_id] = None
|
||||
|
||||
self.sys_log.info(
|
||||
f"{self.name}: Sending new connection request ({connection_request_id}) to {self.server_ip_address}"
|
||||
)
|
||||
|
||||
return self._connect(
|
||||
server_ip_address=self.server_ip_address,
|
||||
password=self.server_password,
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING, Union
|
||||
|
||||
from prettytable import MARKDOWN, PrettyTable
|
||||
|
||||
from primaite.simulator.core import RequestType
|
||||
from primaite.simulator.file_system.file_system import FileSystem
|
||||
from primaite.simulator.network.transmission.data_link_layer import Frame
|
||||
from primaite.simulator.network.transmission.network_layer import IPProtocol
|
||||
@@ -20,9 +21,7 @@ if TYPE_CHECKING:
|
||||
from primaite.simulator.system.services.arp.arp import ARP
|
||||
from primaite.simulator.system.services.icmp.icmp import ICMP
|
||||
|
||||
from typing import Type, TypeVar
|
||||
|
||||
IOSoftwareClass = TypeVar("IOSoftwareClass", bound=IOSoftware)
|
||||
from typing import Type
|
||||
|
||||
|
||||
class SoftwareManager:
|
||||
@@ -51,7 +50,7 @@ class SoftwareManager:
|
||||
self.node = parent_node
|
||||
self.session_manager = session_manager
|
||||
self.software: Dict[str, Union[Service, Application]] = {}
|
||||
self._software_class_to_name_map: Dict[Type[IOSoftwareClass], str] = {}
|
||||
self._software_class_to_name_map: Dict[Type[IOSoftware], str] = {}
|
||||
self.port_protocol_mapping: Dict[Tuple[Port, IPProtocol], Union[Service, Application]] = {}
|
||||
self.sys_log: SysLog = sys_log
|
||||
self.file_system: FileSystem = file_system
|
||||
@@ -104,33 +103,38 @@ class SoftwareManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
def install(self, software_class: Type[IOSoftwareClass]):
|
||||
def install(self, software_class: Type[IOSoftware], **install_kwargs):
|
||||
"""
|
||||
Install an Application or Service.
|
||||
|
||||
:param software_class: The software class.
|
||||
"""
|
||||
# TODO: Software manager and node itself both have an install method. Need to refactor to have more logical
|
||||
# separation of concerns.
|
||||
if software_class in self._software_class_to_name_map:
|
||||
self.sys_log.warning(f"Cannot install {software_class} as it is already installed")
|
||||
return
|
||||
software = software_class(
|
||||
software_manager=self, sys_log=self.sys_log, file_system=self.file_system, dns_server=self.dns_server
|
||||
software_manager=self,
|
||||
sys_log=self.sys_log,
|
||||
file_system=self.file_system,
|
||||
dns_server=self.dns_server,
|
||||
**install_kwargs,
|
||||
)
|
||||
software.parent = self.node
|
||||
if isinstance(software, Application):
|
||||
software.install()
|
||||
self.node.applications[software.uuid] = software
|
||||
self.node._application_request_manager.add_request(
|
||||
software.name, RequestType(func=software._request_manager)
|
||||
)
|
||||
elif isinstance(software, Service):
|
||||
self.node.services[software.uuid] = software
|
||||
self.node._service_request_manager.add_request(software.name, RequestType(func=software._request_manager))
|
||||
software.install()
|
||||
software.software_manager = self
|
||||
self.software[software.name] = software
|
||||
self.port_protocol_mapping[(software.port, software.protocol)] = software
|
||||
if isinstance(software, Application):
|
||||
software.operating_state = ApplicationOperatingState.CLOSED
|
||||
|
||||
# add the software to the node's registry after it has been fully initialized
|
||||
if isinstance(software, Service):
|
||||
self.node.install_service(software)
|
||||
elif isinstance(software, Application):
|
||||
self.node.install_application(software)
|
||||
self.node.sys_log.info(f"Installed {software.name}")
|
||||
|
||||
def uninstall(self, software_name: str):
|
||||
"""
|
||||
@@ -138,25 +142,31 @@ class SoftwareManager:
|
||||
|
||||
:param software_name: The software name.
|
||||
"""
|
||||
if software_name in self.software:
|
||||
self.software[software_name].uninstall()
|
||||
software = self.software.pop(software_name) # noqa
|
||||
if isinstance(software, Application):
|
||||
self.node.uninstall_application(software)
|
||||
elif isinstance(software, Service):
|
||||
self.node.uninstall_service(software)
|
||||
for key, value in self.port_protocol_mapping.items():
|
||||
if value.name == software_name:
|
||||
self.port_protocol_mapping.pop(key)
|
||||
break
|
||||
for key, value in self._software_class_to_name_map.items():
|
||||
if value == software_name:
|
||||
self._software_class_to_name_map.pop(key)
|
||||
break
|
||||
del software
|
||||
self.sys_log.info(f"Uninstalled {software_name}")
|
||||
if software_name not in self.software:
|
||||
self.sys_log.error(f"Cannot uninstall {software_name} as it is not installed")
|
||||
return
|
||||
self.sys_log.error(f"Cannot uninstall {software_name} as it is not installed")
|
||||
|
||||
self.software[software_name].uninstall()
|
||||
software = self.software.pop(software_name) # noqa
|
||||
if isinstance(software, Application):
|
||||
self.node.applications.pop(software.uuid)
|
||||
self.node._application_request_manager.remove_request(software.name)
|
||||
elif isinstance(software, Service):
|
||||
self.node.services.pop(software.uuid)
|
||||
software.uninstall()
|
||||
self.node._service_request_manager.remove_request(software.name)
|
||||
software.parent = None
|
||||
for key, value in self.port_protocol_mapping.items():
|
||||
if value.name == software_name:
|
||||
self.port_protocol_mapping.pop(key)
|
||||
break
|
||||
for key, value in self._software_class_to_name_map.items():
|
||||
if value == software_name:
|
||||
self._software_class_to_name_map.pop(key)
|
||||
break
|
||||
del software
|
||||
self.sys_log.info(f"Uninstalled {software_name}")
|
||||
return
|
||||
|
||||
def send_internal_payload(self, target_software: str, payload: Any):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
|
||||
@@ -0,0 +1 @@
|
||||
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
|
||||
@@ -0,0 +1 @@
|
||||
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
|
||||
@@ -191,12 +191,16 @@ class DatabaseService(Service):
|
||||
:return: Response to connection request containing success info.
|
||||
:rtype: Dict[str, Union[int, Dict[str, bool]]]
|
||||
"""
|
||||
self.sys_log.info(f"{self.name}: Processing new connection request ({connection_request_id}) from {src_ip}")
|
||||
status_code = 500 # Default internal server error
|
||||
connection_id = None
|
||||
if self.operating_state == ServiceOperatingState.RUNNING:
|
||||
status_code = 503 # service unavailable
|
||||
if self.health_state_actual == SoftwareHealthState.OVERWHELMED:
|
||||
self.sys_log.error(f"{self.name}: Connect request for {src_ip=} declined. Service is at capacity.")
|
||||
self.sys_log.info(
|
||||
f"{self.name}: Connection request ({connection_request_id}) from {src_ip} declined, service is at "
|
||||
f"capacity."
|
||||
)
|
||||
if self.health_state_actual in [
|
||||
SoftwareHealthState.GOOD,
|
||||
SoftwareHealthState.FIXING,
|
||||
@@ -208,12 +212,16 @@ class DatabaseService(Service):
|
||||
# try to create connection
|
||||
if not self.add_connection(connection_id=connection_id, session_id=session_id):
|
||||
status_code = 500
|
||||
self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined")
|
||||
else:
|
||||
self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised")
|
||||
self.sys_log.info(
|
||||
f"{self.name}: Connection request ({connection_request_id}) from {src_ip} declined, "
|
||||
f"returning status code 500"
|
||||
)
|
||||
else:
|
||||
status_code = 401 # Unauthorised
|
||||
self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined")
|
||||
self.sys_log.info(
|
||||
f"{self.name}: Connection request ({connection_request_id}) from {src_ip} unauthorised "
|
||||
f"(incorrect password), returning status code 401"
|
||||
)
|
||||
else:
|
||||
status_code = 404 # service not found
|
||||
return {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
|
||||
523
src/primaite/simulator/system/services/terminal/terminal.py
Normal file
523
src/primaite/simulator/system/services/terminal/terminal.py
Normal file
@@ -0,0 +1,523 @@
|
||||
# © Crown-owned copyright 2024, Defence Science and Technology Laboratory UK
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime
|
||||
from ipaddress import IPv4Address
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from primaite.interface.request import RequestFormat, RequestResponse
|
||||
from primaite.simulator.core import RequestManager, RequestType
|
||||
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
|
||||
from primaite.simulator.system.services.service import Service, ServiceOperatingState
|
||||
|
||||
|
||||
class TerminalClientConnection(BaseModel):
|
||||
"""
|
||||
TerminalClientConnection Class.
|
||||
|
||||
This class is used to record current User Connections to the Terminal class.
|
||||
"""
|
||||
|
||||
parent_terminal: Terminal
|
||||
"""The parent Node that this connection was created on."""
|
||||
|
||||
session_id: str = None
|
||||
"""Session ID that connection is linked to"""
|
||||
|
||||
connection_uuid: str = None
|
||||
"""Connection UUID"""
|
||||
|
||||
connection_request_id: str = None
|
||||
"""Connection request ID"""
|
||||
|
||||
time: datetime = None
|
||||
"""Timestamp connection was created."""
|
||||
|
||||
ip_address: IPv4Address
|
||||
"""Source IP of Connection"""
|
||||
|
||||
is_active: bool = True
|
||||
"""Flag to state whether the connection is active or not"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.__class__.__name__}(connection_id='{self.connection_uuid}')"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
return getattr(self, key)
|
||||
|
||||
@property
|
||||
def client(self) -> Optional[Terminal]:
|
||||
"""The Terminal that holds this connection."""
|
||||
return self.parent_terminal
|
||||
|
||||
def disconnect(self) -> bool:
|
||||
"""Disconnect the session."""
|
||||
return self.parent_terminal._disconnect(connection_uuid=self.connection_uuid)
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, command: Any) -> bool:
|
||||
"""Execute a given command."""
|
||||
pass
|
||||
|
||||
|
||||
class LocalTerminalConnection(TerminalClientConnection):
|
||||
"""
|
||||
LocalTerminalConnectionClass.
|
||||
|
||||
This class represents a local terminal when connected.
|
||||
"""
|
||||
|
||||
ip_address: str = "Local Connection"
|
||||
|
||||
def execute(self, command: Any) -> Optional[RequestResponse]:
|
||||
"""Execute a given command on local Terminal."""
|
||||
if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING:
|
||||
self.parent_terminal.sys_log.warning("Cannot process command as system not running")
|
||||
return None
|
||||
if not self.is_active:
|
||||
self.parent_terminal.sys_log.warning("Connection inactive, cannot execute")
|
||||
return None
|
||||
return self.parent_terminal.execute(command, connection_id=self.connection_uuid)
|
||||
|
||||
|
||||
class RemoteTerminalConnection(TerminalClientConnection):
|
||||
"""
|
||||
RemoteTerminalConnection Class.
|
||||
|
||||
This class acts as broker between the terminal and remote.
|
||||
|
||||
"""
|
||||
|
||||
def execute(self, command: Any) -> bool:
|
||||
"""Execute a given command on the remote Terminal."""
|
||||
if self.parent_terminal.operating_state != ServiceOperatingState.RUNNING:
|
||||
self.parent_terminal.sys_log.warning("Cannot process command as system not running")
|
||||
return False
|
||||
if not self.is_active:
|
||||
self.parent_terminal.sys_log.warning("Connection inactive, cannot execute")
|
||||
return False
|
||||
# Send command to remote terminal to process.
|
||||
|
||||
transport_message: SSHTransportMessage = SSHTransportMessage.SSH_MSG_SERVICE_REQUEST
|
||||
connection_message: SSHConnectionMessage = SSHConnectionMessage.SSH_MSG_CHANNEL_DATA
|
||||
|
||||
payload: SSHPacket = SSHPacket(
|
||||
transport_message=transport_message,
|
||||
connection_message=connection_message,
|
||||
connection_request_uuid=self.connection_request_id,
|
||||
connection_uuid=self.connection_uuid,
|
||||
ssh_command=command,
|
||||
)
|
||||
|
||||
return self.parent_terminal.send(payload=payload, session_id=self.session_id)
|
||||
|
||||
|
||||
class Terminal(Service):
|
||||
"""Class used to simulate a generic terminal service. Can be interacted with by other terminals via SSH."""
|
||||
|
||||
_client_connection_requests: Dict[str, Optional[Union[str, TerminalClientConnection]]] = {}
|
||||
|
||||
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()
|
||||
return state
|
||||
|
||||
def show(self, markdown: bool = False):
|
||||
"""
|
||||
Display the remote connections to this terminal instance in tabular format.
|
||||
|
||||
:param markdown: Whether to display the table in Markdown format or not. Default is `False`.
|
||||
"""
|
||||
self.show_connections(markdown=markdown)
|
||||
|
||||
def _init_request_manager(self) -> RequestManager:
|
||||
"""Initialise Request manager."""
|
||||
rm = super()._init_request_manager()
|
||||
rm.add_request(
|
||||
"send",
|
||||
request_type=RequestType(func=lambda request, context: RequestResponse.from_bool(self.send())),
|
||||
)
|
||||
|
||||
def _login(request: RequestFormat, context: Dict) -> RequestResponse:
|
||||
login = self._process_local_login(username=request[0], password=request[1])
|
||||
if login:
|
||||
return RequestResponse(status="success", data={})
|
||||
else:
|
||||
return RequestResponse(status="failure", data={})
|
||||
|
||||
def _remote_login(request: RequestFormat, context: Dict) -> RequestResponse:
|
||||
login = self._send_remote_login(username=request[0], password=request[1], ip_address=request[2])
|
||||
if login:
|
||||
return RequestResponse(status="success", data={})
|
||||
else:
|
||||
return RequestResponse(status="failure", data={})
|
||||
|
||||
def _execute_request(request: RequestFormat, context: Dict) -> RequestResponse:
|
||||
"""Execute an instruction."""
|
||||
command: str = request[0]
|
||||
connection_id: str = request[1]
|
||||
self.execute(command, connection_id=connection_id)
|
||||
return RequestResponse(status="success", data={})
|
||||
|
||||
def _logoff(request: RequestFormat, context: Dict) -> RequestResponse:
|
||||
"""Logoff from connection."""
|
||||
connection_uuid = request[0]
|
||||
# TODO: Uncomment this when UserSessionManager merged.
|
||||
# self.parent.UserSessionManager.logoff(connection_uuid)
|
||||
self._disconnect(connection_uuid)
|
||||
|
||||
return RequestResponse(status="success", data={})
|
||||
|
||||
rm.add_request(
|
||||
"Login",
|
||||
request_type=RequestType(func=_login),
|
||||
)
|
||||
|
||||
rm.add_request(
|
||||
"Remote Login",
|
||||
request_type=RequestType(func=_remote_login),
|
||||
)
|
||||
|
||||
rm.add_request(
|
||||
"Execute",
|
||||
request_type=RequestType(func=_execute_request),
|
||||
)
|
||||
|
||||
rm.add_request("Logoff", request_type=RequestType(func=_logoff))
|
||||
|
||||
return rm
|
||||
|
||||
def execute(self, command: List[Any], connection_id: str) -> Optional[RequestResponse]:
|
||||
"""Execute a passed ssh command via the request manager."""
|
||||
valid_connection = self._check_client_connection(connection_id=connection_id)
|
||||
if valid_connection:
|
||||
return self.parent.apply_request(command)
|
||||
else:
|
||||
self.sys_log.error("Invalid connection ID provided")
|
||||
return None
|
||||
|
||||
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 = LocalTerminalConnection(
|
||||
parent_terminal=self,
|
||||
connection_uuid=connection_uuid,
|
||||
session_id=session_id,
|
||||
time=datetime.now(),
|
||||
)
|
||||
self._connections[connection_uuid] = new_connection
|
||||
self._client_connection_requests[connection_uuid] = new_connection
|
||||
|
||||
return new_connection
|
||||
|
||||
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.
|
||||
|
||||
: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
|
||||
connection_request_id = str(uuid4())
|
||||
self._client_connection_requests[connection_request_id] = None
|
||||
if ip_address:
|
||||
# Assuming that if IP is passed we are connecting to remote
|
||||
return self._send_remote_login(
|
||||
username=username, password=password, ip_address=ip_address, connection_request_id=connection_request_id
|
||||
)
|
||||
else:
|
||||
return self._process_local_login(username=username, password=password)
|
||||
|
||||
def _process_local_login(self, username: str, password: str) -> Optional[TerminalClientConnection]:
|
||||
"""Local session login to terminal.
|
||||
|
||||
:param username: Username for login.
|
||||
:param password: Password for login.
|
||||
:return: boolean, True if successful, else False
|
||||
"""
|
||||
# TODO: Un-comment this when UserSessionManager is merged.
|
||||
# connection_uuid = self.parent.UserSessionManager.login(username=username, password=password)
|
||||
connection_uuid = str(uuid4())
|
||||
if connection_uuid:
|
||||
self.sys_log.info(f"Login request authorised, connection uuid: {connection_uuid}")
|
||||
# Add new local session to list of connections and return
|
||||
return self._create_local_connection(connection_uuid=connection_uuid, session_id="Local_Connection")
|
||||
else:
|
||||
self.sys_log.warning("Login failed, incorrect Username or Password")
|
||||
return None
|
||||
|
||||
def _validate_client_connection_request(self, connection_id: str) -> bool:
|
||||
"""Check that client_connection_id is valid."""
|
||||
return True if connection_id in self._client_connection_requests else False
|
||||
|
||||
def _check_client_connection(self, connection_id: str) -> bool:
|
||||
"""Check that client_connection_id is valid."""
|
||||
return True if connection_id in self._connections else False
|
||||
|
||||
def _send_remote_login(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
ip_address: IPv4Address,
|
||||
connection_request_id: str,
|
||||
is_reattempt: bool = False,
|
||||
) -> Optional[RemoteTerminalConnection]:
|
||||
"""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_request = self._validate_client_connection_request(connection_id=connection_request_id)
|
||||
if valid_connection_request:
|
||||
remote_terminal_connection = self._client_connection_requests.pop(connection_request_id)
|
||||
if isinstance(remote_terminal_connection, RemoteTerminalConnection):
|
||||
self.sys_log.info(f"{self.name}: Remote Connection to {ip_address} authorised.")
|
||||
return remote_terminal_connection
|
||||
else:
|
||||
self.sys_log.warning(f"Connection request{connection_request_id} declined")
|
||||
return None
|
||||
else:
|
||||
self.sys_log.warning(f"{self.name}: Remote connection to {ip_address} declined.")
|
||||
return None
|
||||
|
||||
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,
|
||||
connection_request_uuid=connection_request_id,
|
||||
)
|
||||
|
||||
software_manager: SoftwareManager = self.software_manager
|
||||
software_manager.send_payload_to_session_manager(
|
||||
payload=payload, dest_ip_address=ip_address, dest_port=self.port
|
||||
)
|
||||
return self._send_remote_login(
|
||||
username=username,
|
||||
password=password,
|
||||
ip_address=ip_address,
|
||||
is_reattempt=True,
|
||||
connection_request_id=connection_request_id,
|
||||
)
|
||||
|
||||
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,
|
||||
ip_address=source_ip,
|
||||
connection_request_id=connection_request_id,
|
||||
time=datetime.now(),
|
||||
)
|
||||
self._connections[connection_id] = client_connection
|
||||
self._client_connection_requests[connection_request_id] = client_connection
|
||||
|
||||
def receive(self, session_id: str, payload: Union[SSHPacket, Dict], **kwargs) -> bool:
|
||||
"""
|
||||
Receive a payload from the Software Manager.
|
||||
|
||||
:param payload: A payload to receive.
|
||||
:param session_id: The session id the payload relates to.
|
||||
:return: True.
|
||||
"""
|
||||
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=payload.connection_uuid,
|
||||
connection_request_id=payload.connection_request_uuid,
|
||||
session_id=session_id,
|
||||
source_ip=source_ip,
|
||||
)
|
||||
|
||||
elif payload.transport_message == SSHTransportMessage.SSH_MSG_SERVICE_REQUEST:
|
||||
# Requesting a command to be executed
|
||||
self.sys_log.info("Received command to execute")
|
||||
command = payload.ssh_command
|
||||
valid_connection = self._check_client_connection(payload.connection_uuid)
|
||||
self.sys_log.info(f"Connection uuid is {valid_connection}")
|
||||
if valid_connection:
|
||||
return self.execute(command, payload.connection_uuid)
|
||||
else:
|
||||
self.sys_log.error(f"Connection UUID:{payload.connection_uuid} is not valid. Rejecting Command.")
|
||||
|
||||
if isinstance(payload, dict) and payload.get("type"):
|
||||
if payload["type"] == "disconnect":
|
||||
connection_id = payload["connection_id"]
|
||||
valid_id = self._check_client_connection(connection_id)
|
||||
if valid_id:
|
||||
self.sys_log.info(f"{self.name}: Received disconnect command for {connection_id=} from remote.")
|
||||
self._disconnect(payload["connection_id"])
|
||||
else:
|
||||
self.sys_log.info("No Active connection held for received connection ID.")
|
||||
|
||||
return True
|
||||
|
||||
def _disconnect(self, connection_uuid: str) -> bool:
|
||||
"""Disconnect from the remote.
|
||||
|
||||
:param connection_uuid: Connection ID that we want to disconnect.
|
||||
:return True if successful, False otherwise.
|
||||
"""
|
||||
if not self._connections:
|
||||
self.sys_log.warning("No remote connection present")
|
||||
return False
|
||||
|
||||
connection = self._connections.pop(connection_uuid)
|
||||
connection.is_active = False
|
||||
|
||||
if isinstance(connection, RemoteTerminalConnection):
|
||||
# Send disconnect command via software manager
|
||||
session_id = connection.session_id
|
||||
|
||||
software_manager: SoftwareManager = self.software_manager
|
||||
software_manager.send_payload_to_session_manager(
|
||||
payload={"type": "disconnect", "connection_id": connection_uuid},
|
||||
dest_port=self.port,
|
||||
session_id=session_id,
|
||||
)
|
||||
self.sys_log.info(f"{self.name}: Disconnected {connection_uuid}")
|
||||
return True
|
||||
|
||||
elif isinstance(connection, LocalTerminalConnection):
|
||||
# No further action needed
|
||||
return True
|
||||
|
||||
def send(
|
||||
self, payload: SSHPacket, dest_ip_address: Optional[IPv4Address] = None, session_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send a payload out from the Terminal.
|
||||
|
||||
:param payload: The payload to be sent.
|
||||
:param dest_up_address: The IP address of the payload destination.
|
||||
"""
|
||||
if self.operating_state != ServiceOperatingState.RUNNING:
|
||||
self.sys_log.warning(f"Cannot send commands when Operating state is {self.operating_state}!")
|
||||
return False
|
||||
|
||||
self.sys_log.debug(f"Sending payload: {payload}")
|
||||
return super().send(
|
||||
payload=payload, dest_ip_address=dest_ip_address, dest_port=self.port, session_id=session_id
|
||||
)
|
||||
@@ -291,7 +291,7 @@ class IOSoftware(Software):
|
||||
"""
|
||||
if self.software_manager and self.software_manager.node.operating_state != NodeOperatingState.ON:
|
||||
self.software_manager.node.sys_log.error(
|
||||
f"{self.name} Error: {self.software_manager.node.hostname} is not online."
|
||||
f"{self.name} Error: {self.software_manager.node.hostname} is not powered on."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -313,7 +313,7 @@ class IOSoftware(Software):
|
||||
# if over or at capacity, set to overwhelmed
|
||||
if len(self._connections) >= self.max_sessions:
|
||||
self.set_health_state(SoftwareHealthState.OVERWHELMED)
|
||||
self.sys_log.warning(f"{self.name}: Connect request for {connection_id=} declined. Service is at capacity.")
|
||||
self.sys_log.warning(f"{self.name}: Connection request ({connection_id}) declined. Service is at capacity.")
|
||||
return False
|
||||
else:
|
||||
# if service was previously overwhelmed, set to good because there is enough space for connections
|
||||
@@ -330,11 +330,11 @@ class IOSoftware(Software):
|
||||
"ip_address": session_details.with_ip_address if session_details else None,
|
||||
"time": datetime.now(),
|
||||
}
|
||||
self.sys_log.info(f"{self.name}: Connect request for {connection_id=} authorised")
|
||||
self.sys_log.info(f"{self.name}: Connection request ({connection_id}) authorised")
|
||||
return True
|
||||
# connection with given id already exists
|
||||
self.sys_log.warning(
|
||||
f"{self.name}: Connect request for {connection_id=} declined. Connection already exists."
|
||||
f"{self.name}: Connection request ({connection_id}) declined. Connection already exists."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user