From 78a6459d260c96929d4a6e3c7dae8fa0abe4df79 Mon Sep 17 00:00:00 2001 From: damp11113 Date: Fri, 6 Dec 2024 23:33:15 +0700 Subject: [PATCH] update 5.1 New ServerManager for manage multiple server with multiple protocol. ProWrapper for translate protocol from many protocol to one protocol (function). --- README.md | 8 +- demo/demo1.py | 107 ++--- setup.py | 2 +- src/PyserSSH/__init__.py | 55 +-- src/PyserSSH/account.py | 83 +++- src/PyserSSH/extensions/XHandler.py | 64 ++- src/PyserSSH/extensions/__init__.py | 2 +- src/PyserSSH/extensions/dialog.py | 95 +++- src/PyserSSH/extensions/moredisplay.py | 40 +- src/PyserSSH/extensions/moreinteractive.py | 21 +- src/PyserSSH/extensions/processbar.py | 44 +- src/PyserSSH/extensions/remodesk.py | 4 +- src/PyserSSH/extensions/serverutils.py | 42 +- src/PyserSSH/interactive.py | 32 +- src/PyserSSH/server.py | 138 +++--- src/PyserSSH/system/ProWrapper.py | 495 +++++++++++++++++++++ src/PyserSSH/system/SFTP.py | 2 +- src/PyserSSH/system/__init__.py | 2 +- src/PyserSSH/system/clientype.py | 188 +++++++- src/PyserSSH/system/info.py | 9 +- src/PyserSSH/system/inputsystem.py | 6 +- src/PyserSSH/system/interface.py | 4 +- src/PyserSSH/system/remotestatus.py | 8 +- src/PyserSSH/system/syscom.py | 43 +- src/PyserSSH/system/sysfunc.py | 3 +- src/PyserSSH/utils/ServerManager.py | 174 ++++++++ src/PyserSSH/utils/keygen.py | 22 + 27 files changed, 1410 insertions(+), 283 deletions(-) create mode 100644 src/PyserSSH/system/ProWrapper.py create mode 100644 src/PyserSSH/utils/ServerManager.py create mode 100644 src/PyserSSH/utils/keygen.py diff --git a/README.md b/README.md index 675bed5..939910e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # What is PyserSSH +This library will be **Pyserminal** (Python Server Terminal) as it supports multiple protocols such as ssh telnet rlogin and mores... + PyserSSH is a free and open-source Python library designed to facilitate the creation of customizable SSH terminal servers. Initially developed for research purposes to address the lack of suitable SSH server libraries in Python, PyserSSH provides a flexible and user-friendly solution for implementing SSH servers, making it easier for developers to handle user interactions and command processing. The project was started by a solo developer to create a more accessible and flexible tool for managing SSH connections and commands. It offers a simplified API compared to other libraries, such as Paramiko, SSHim, and Twisted, which are either outdated or complex for new users. @@ -32,15 +34,15 @@ pip install git+https://git.damp11113.xyz/DPSoftware-Foundation/PyserSSH.git # Quick Example This Server use port **2222** for default port ```py -from PyserSSH import Server, Send, AccountManager +from PyserSSH import Server, AccountManager -useraccount = AccountManager(anyuser=True) +useraccount = AccountManager(allow_guest=True) ssh = Server(useraccount) @ssh.on_user("command") def command(client, command: str): if command == "hello": - Send(client, "world!") + client.send("world!") ssh.run("your private key file") ``` diff --git a/demo/demo1.py b/demo/demo1.py index 86dd8a9..309d19b 100644 --- a/demo/demo1.py +++ b/demo/demo1.py @@ -1,4 +1,8 @@ import os +os.environ["damp11113_load_all_module"] = "NO" + +from damp11113.utils import TextFormatter +from damp11113.file import sort_files, allfiles import socket import time import cv2 @@ -6,13 +10,14 @@ import requests from bs4 import BeautifulSoup import numpy as np +import logging -#import logging -#logging.basicConfig(level=logging.DEBUG) +# Configure logging +logging.basicConfig(format='[{asctime}] [{levelname}] {name}: {message}', datefmt='%Y-%m-%d %H:%M:%S', style='{', level=logging.DEBUG) from PyserSSH import Server, AccountManager -from PyserSSH.interactive import Send, wait_input, wait_inputkey, wait_choose, Clear, wait_inputmouse -from PyserSSH.system.info import __version__, Flag_TH +from PyserSSH.interactive import Send, wait_input, wait_inputkey, wait_choose, Clear, wait_inputmouse +from PyserSSH.system.info import version, Flag_TH from PyserSSH.extensions.processbar import indeterminateStatus, LoadingProgress from PyserSSH.extensions.dialog import MenuDialog, TextDialog, TextInputDialog from PyserSSH.extensions.moredisplay import clickable_url, Send_karaoke_effect @@ -20,15 +25,18 @@ from PyserSSH.extensions.remodesk import RemoDesk from PyserSSH.extensions.XHandler import XHandler from PyserSSH.system.clientype import Client -from PyserSSH.system.remotestatus import remotestatus +from PyserSSH.system.RemoteStatus import remotestatus +from PyserSSH.utils.ServerManager import ServerManager + +useraccount = AccountManager(allow_guest=True, autoload=True, autosave=True) -useraccount = AccountManager(allow_guest=True) -useraccount.add_account("admin", "") # create user without password -useraccount.add_account("test", "test") # create user without password -useraccount.add_account("demo") -useraccount.add_account("remote", "12345", permissions=["remote_desktop"]) -useraccount.set_user_enable_inputsystem_echo("remote", False) -useraccount.set_user_sftp_allow("admin", True) +if not os.path.isfile("autosave_session.ses"): + useraccount.add_account("admin", "", sudo=True) # create user without password + useraccount.add_account("test", "test") # create user without password + useraccount.add_account("demo") + useraccount.add_account("remote", "12345", permissions=["remote_desktop"]) + useraccount.set_user_enable_inputsystem_echo("remote", False) + useraccount.set_user_sftp_allow("admin", True) XH = XHandler() ssh = Server(useraccount, @@ -42,64 +50,7 @@ servername = "PyserSSH" -loading = ["PyserSSH", "Extensions"] - -class TextFormatter: - RESET = "\033[0m" - TEXT_COLORS = { - "black": "\033[30m", - "red": "\033[31m", - "green": "\033[32m", - "yellow": "\033[33m", - "blue": "\033[34m", - "magenta": "\033[35m", - "cyan": "\033[36m", - "white": "\033[37m" - } - TEXT_COLOR_LEVELS = { - "light": "\033[1;{}m", # Light color prefix - "dark": "\033[2;{}m" # Dark color prefix - } - BACKGROUND_COLORS = { - "black": "\033[40m", - "red": "\033[41m", - "green": "\033[42m", - "yellow": "\033[43m", - "blue": "\033[44m", - "magenta": "\033[45m", - "cyan": "\033[46m", - "white": "\033[47m" - } - TEXT_ATTRIBUTES = { - "bold": "\033[1m", - "italic": "\033[3m", - "underline": "\033[4m", - "blink": "\033[5m", - "reverse": "\033[7m", - "strikethrough": "\033[9m" - } - - @staticmethod - def format_text_truecolor(text, color=None, background=None, attributes=None, target_text=''): - formatted_text = "" - start_index = text.find(target_text) - end_index = start_index + len(target_text) if start_index != -1 else len(text) - - if color: - formatted_text += f"\033[38;2;{color}m" - - if background: - formatted_text += f"\033[48;2;{background}m" - - if attributes in TextFormatter.TEXT_ATTRIBUTES: - formatted_text += TextFormatter.TEXT_ATTRIBUTES[attributes] - - if target_text == "": - formatted_text += text + TextFormatter.RESET - else: - formatted_text += text[:start_index] + text[start_index:end_index] + TextFormatter.RESET + text[end_index:] - - return formatted_text +loading = ["PyserSSH", "openRemoDesk", "XHandler", "RemoteStatus"] @ssh.on_user("pre-shell") def guestauth(client): @@ -185,7 +136,7 @@ def connect(client): wm = f"""{Flag_TH()}{'–'*50} Hello {client['current_user']}, -This is testing server of PyserSSH v{__version__}. +This is testing server of PyserSSH v{version}. Visit: {clickable_url("https://damp11113.xyz", "DPCloudev")} {'–'*50}""" @@ -258,9 +209,9 @@ def xh_typing(client: Client, messages, speed = 1): Send(client, "") @XH.command(name="renimtest") -def xh_renimtest(client: Client, path: str): +def xh_renimtest(client: Client): Clear(client) - image = cv2.imread(f"opensource.png", cv2.IMREAD_COLOR) + image = cv2.imread("opensource.png", cv2.IMREAD_COLOR) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) width, height = client['windowsize']["width"] - 5, client['windowsize']["height"] - 5 @@ -458,12 +409,14 @@ def xh_status(client: Client): #@ssh.on_user("command") #def command(client: Client, command: str): -ssh.run(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'private_key.pem')) +#ssh.run(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'private_key.pem')) -#manager = ServerManager() +manager = ServerManager() # Add servers to the manager -#manager.add_server("server1", server1) +manager.add_server("ssh", ssh, os.path.join(os.path.dirname(os.path.realpath(__file__)), 'private_key.pem')) +manager.add_server("telnet", ssh, "", protocol="telnet") # Start a specific server -#manager.start_server("server1", private_key_path="key") +manager.start_server("ssh") +manager.start_server("telnet") \ No newline at end of file diff --git a/setup.py b/setup.py index d69b377..cd962ff 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='PyserSSH', - version='5.0', + version='5.1', license='MIT', author='DPSoftware Foundation', author_email='contact@damp11113.xyz', diff --git a/src/PyserSSH/__init__.py b/src/PyserSSH/__init__.py index ffbaa19..16ed33c 100644 --- a/src/PyserSSH/__init__.py +++ b/src/PyserSSH/__init__.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -37,15 +37,15 @@ https://en.wikipedia.org/wiki/ANSI_escape_code """ import os -import ctypes import logging from .interactive import * from .server import Server from .account import AccountManager -from .system.info import system_banner +from .system.info import system_banner, version if os.name == 'nt': + import ctypes kernel32 = ctypes.windll.kernel32 kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) @@ -67,45 +67,10 @@ if os.environ["pyserssh_systemmessage"] == "YES": print(system_banner) -# Server Managers - -class ServerManager: - def __init__(self): - self.servers = {} - - def add_server(self, name, server): - if name in self.servers: - raise ValueError(f"Server with name '{name}' already exists.") - self.servers[name] = server - - def remove_server(self, name): - if name not in self.servers: - raise ValueError(f"No server found with name '{name}'.") - del self.servers[name] - - def get_server(self, name): - return self.servers.get(name) - - def start_server(self, name, protocol="ssh", *args, **kwargs): - server = self.get_server(name) - if not server: - raise ValueError(f"No server found with name '{name}'.") - print(f"Starting server '{name}'...") - server.run(*args, **kwargs) - - def stop_server(self, name): - server = self.get_server(name) - if not server: - raise ValueError(f"No server found with name '{name}'.") - print(f"Stopping server '{name}'...") - server.stop_server() - - def start_all_servers(self, *args, **kwargs): - for name, server in self.servers.items(): - print(f"Starting server '{name}'...") - server.run(*args, **kwargs) - - def stop_all_servers(self): - for name, server in self.servers.items(): - print(f"Stopping server '{name}'...") - server.stop_server() \ No newline at end of file +__author__ = "damp11113" +__url__ = "https://github.com/DPSoftware-Foundation/PyserSSH" +__copyright__ = "2023-present" +__license__ = "MIT" +__version__ = version +__department__ = "DPSoftware" +__organization__ = "DOPFoundation" \ No newline at end of file diff --git a/src/PyserSSH/account.py b/src/PyserSSH/account.py index 756f422..d0db841 100644 --- a/src/PyserSSH/account.py +++ b/src/PyserSSH/account.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import logging import os import pickle import time @@ -31,38 +32,51 @@ import threading import hashlib +logger = logging.getLogger("PyserSSH.Account") + class AccountManager: - def __init__(self, allow_guest=False, historylimit=10, autosave=False, autosavedelay=60, autoload=False, autoloadfile="autosave_session.ses"): + def __init__(self, allow_guest=False, historylimit=10, autosave=False, autosavedelay=60, autoload=False, autofile="autosave_session.ses"): self.accounts = {} self.allow_guest = allow_guest self.historylimit = historylimit self.autosavedelay = autosavedelay self.__autosavework = False + self.autosave = autosave self.__autosaveworknexttime = 0 + self.__autofile = autofile if autoload: - self.load(autoloadfile) + self.load(self.__autofile) - if autosave: + if self.autosave: + logger.info("starting autosave") self.__autosavethread = threading.Thread(target=self.__autosave, daemon=True) self.__autosavethread.start() atexit.register(self.__saveexit) def __autosave(self): - self.save("autosave_session.ses") + self.save(self.__autofile) self.__autosaveworknexttime = time.time() + self.autosavedelay self.__autosavework = True while self.__autosavework: if int(self.__autosaveworknexttime) == int(time.time()): - self.save("autosave_session.ses") + self.save(self.__autofile) self.__autosaveworknexttime = time.time() + self.autosavedelay time.sleep(1) # fix cpu load + def __auto_save(func): + def wrapper(self, *args, **kwargs): + result = func(self, *args, **kwargs) + if self.autosave: + self.save(self.__autofile, False) + return result + return wrapper + def __saveexit(self): self.__autosavework = False - self.save("autosave_session.ses") + self.save(self.__autofile) self.__autosavethread.join() def validate_credentials(self, username, password=None, public_key=None): @@ -90,6 +104,9 @@ def validate_credentials(self, username, password=None, public_key=None): def has_user(self, username): return username in self.accounts + def list_users(self): + return list(self.accounts.keys()) + def get_allowed_auths(self, username): if self.has_user(username) and "allowed_auth" in self.accounts[username]: return self.accounts[username]["allowed_auth"] @@ -100,6 +117,7 @@ def get_permissions(self, username): return self.accounts[username]["permissions"] return [] + @__auto_save def set_prompt(self, username, prompt=">"): if self.has_user(username): self.accounts[username]["prompt"] = prompt @@ -109,7 +127,8 @@ def get_prompt(self, username): return self.accounts[username]["prompt"] return ">" # Default prompt if not set for the user - def add_account(self, username, password=None, public_key=None, permissions:list=None): + @__auto_save + def add_account(self, username, password=None, public_key=None, permissions:list=None, sudo=False): if not self.has_user(username): allowedlist = [] accountkey = {} @@ -132,34 +151,63 @@ def add_account(self, username, password=None, public_key=None, permissions:list accountkey["allowed_auth"] = ",".join(allowedlist) self.accounts[username] = accountkey + + if sudo: + if self.has_sudo_user(): + raise Exception(f"sudo user is exist") + + self.accounts[username]["sudo"] = sudo else: raise Exception(f"{username} is exist") + def has_sudo_user(self): + return any(account.get("sudo", False) for account in self.accounts.values()) + + def is_user_has_sudo(self, username): + if self.has_user(username) and "sudo" in self.accounts[username]: + return self.accounts[username]["sudo"] + return False + + @__auto_save def remove_account(self, username): if self.has_user(username): del self.accounts[username] + @__auto_save def change_password(self, username, new_password): if self.has_user(username): self.accounts[username]["password"] = new_password + @__auto_save def set_permissions(self, username, new_permissions): if self.has_user(username): self.accounts[username]["permissions"] = new_permissions - def save(self, filename="session.ses"): - with open(filename, 'wb') as file: - pickle.dump(self.accounts, file) + def save(self, filename="session.ses", keep_log=True): + if keep_log: + logger.info(f"saving session to {filename}") + try: + with open(filename, 'wb') as file: + pickle.dump(self.accounts, file) + + if keep_log: + logger.info(f"saved session to {filename}") + except Exception as e: + if keep_log: + logger.error(f"save session failed: {e}") def load(self, filename): + logger.info(f"loading session from {filename}") try: with open(filename, 'rb') as file: self.accounts = pickle.load(file) + logger.info(f"loaded session") except FileNotFoundError: - print("File not found. No accounts loaded.") + logger.error("can't load session: file not found.") except Exception as e: - print(f"An error occurred: {e}. No accounts loaded.") + logger.error(f"can't load session: {e}") + @__auto_save def set_user_sftp_allow(self, username, allow=True): if self.has_user(username): self.accounts[username]["sftp_allow"] = allow @@ -169,6 +217,7 @@ def get_user_sftp_allow(self, username): return self.accounts[username]["sftp_allow"] return False + @__auto_save def set_user_sftp_readonly(self, username, readonly=False): if self.has_user(username): self.accounts[username]["sftp_readonly"] = readonly @@ -178,6 +227,7 @@ def get_user_sftp_readonly(self, username): return self.accounts[username]["sftp_readonly"] return False + @__auto_save def set_user_sftp_root_path(self, username, path="/"): if self.has_user(username): if path == "/": @@ -190,6 +240,7 @@ def get_user_sftp_root_path(self, username): return self.accounts[username]["sftp_root_path"] return os.getcwd() + @__auto_save def set_user_enable_inputsystem(self, username, enable=True): if self.has_user(username): self.accounts[username]["inputsystem"] = enable @@ -199,6 +250,7 @@ def get_user_enable_inputsystem(self, username): return self.accounts[username]["inputsystem"] return True + @__auto_save def set_user_enable_inputsystem_echo(self, username, echo=True): if self.has_user(username): self.accounts[username]["inputsystem_echo"] = echo @@ -208,6 +260,7 @@ def get_user_enable_inputsystem_echo(self, username): return self.accounts[username]["inputsystem_echo"] return True + @__auto_save def set_banner(self, username, banner): if self.has_user(username): self.accounts[username]["banner"] = banner @@ -222,6 +275,7 @@ def get_user_timeout(self, username): return self.accounts[username]["timeout"] return None + @__auto_save def set_user_timeout(self, username, timeout=None): if self.has_user(username): self.accounts[username]["timeout"] = timeout @@ -231,6 +285,7 @@ def get_user_last_login(self, username): return self.accounts[username]["lastlogin"] return None + @__auto_save def set_user_last_login(self, username, ip, timelogin=time.time()): if self.has_user(username): self.accounts[username]["lastlogin"] = { @@ -238,6 +293,7 @@ def set_user_last_login(self, username, ip, timelogin=time.time()): "time": timelogin } + @__auto_save def add_history(self, username, command): if self.has_user(username): if "history" not in self.accounts[username]: @@ -251,6 +307,7 @@ def add_history(self, username, command): if len(self.accounts[username]["history"]) > history_limit: self.accounts[username]["history"] = self.accounts[username]["history"][-history_limit:] + @__auto_save def clear_history(self, username): if self.has_user(username): self.accounts[username]["history"] = [] # Initialize history list if it doesn't exist diff --git a/src/PyserSSH/extensions/XHandler.py b/src/PyserSSH/extensions/XHandler.py index 5e75117..59be7d6 100644 --- a/src/PyserSSH/extensions/XHandler.py +++ b/src/PyserSSH/extensions/XHandler.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -35,6 +35,13 @@ def are_permissions_met(permission_list, permission_require): class XHandler: def __init__(self, enablehelp=True, showusageonworng=True): + """ + Initializes the command handler with optional settings for help messages and usage. + + Parameters: + enablehelp (bool): Whether help messages are enabled. + showusageonworng (bool): Whether usage information is shown on wrong usage. + """ self.handlers = {} self.categories = {} self.enablehelp = enablehelp @@ -43,6 +50,18 @@ def __init__(self, enablehelp=True, showusageonworng=True): self.commandnotfound = None def command(self, category=None, name=None, aliases=None, permissions: list = None): + """ + Decorator to register a function as a command with optional category, name, aliases, and permissions. + + Parameters: + category (str): The category under which the command falls (default: None). + name (str): The name of the command (default: None). + aliases (list): A list of command aliases (default: None). + permissions (list): A list of permissions required to execute the command (default: None). + + Returns: + function: The wrapped function. + """ def decorator(func): nonlocal name, category if name is None: @@ -87,6 +106,16 @@ def decorator(func): return decorator def call(self, client, command_string): + """ + Processes a command string, validates arguments, and calls the corresponding function. + + Parameters: + client (object): The client sending the command. + command_string (str): The command string to be executed. + + Returns: + Any: The result of the command function, or an error message if invalid. + """ tokens = shlex.split(command_string) command_name = tokens[0] args = tokens[1:] @@ -102,7 +131,7 @@ def call(self, client, command_string): command_func = self.handlers[command_name] command_info = self.get_command_info(command_name) if command_info and command_info.get('permissions'): - if not are_permissions_met(self.serverself.accounts.get_permissions(client.get_name()), command_info.get('permissions')): + if not are_permissions_met(self.serverself.accounts.get_permissions(client.get_name()), command_info.get('permissions')) or not self.serverself.accounts.is_user_has_sudo(client.get_name()): Send(client, f"Permission denied. You do not have permission to execute '{command_name}'.") return @@ -172,6 +201,15 @@ def call(self, client, command_string): return def get_command_info(self, command_name): + """ + Retrieves information about a specific command, including its description, arguments, and permissions. + + Parameters: + command_name (str): The name of the command. + + Returns: + dict: A dictionary containing command details such as name, description, args, and permissions. + """ found_command = None for category, commands in self.categories.items(): if command_name in commands: @@ -197,6 +235,15 @@ def get_command_info(self, command_name): } def get_help_command_info(self, command): + """ + Generates a detailed help message for a specific command. + + Parameters: + command (str): The name of the command. + + Returns: + str: The formatted help message for the command. + """ command_info = self.get_command_info(command) aliases = command_info.get('aliases', []) help_message = f"{command_info['name']}" @@ -220,6 +267,12 @@ def get_help_command_info(self, command): return help_message def get_help_message(self): + """ + Generates a general help message listing all categories and their associated commands. + + Returns: + str: The formatted help message containing all commands and categories. + """ help_message = "" for category, commands in self.categories.items(): help_message += f"{category}:\n" @@ -231,6 +284,13 @@ def get_help_message(self): return help_message def get_all_commands(self): + """ + Retrieves all registered commands, grouped by category. + + Returns: + dict: A dictionary where each key is a category name and the value is a + dictionary of commands within that category. + """ all_commands = {} for category, commands in self.categories.items(): all_commands[category] = commands diff --git a/src/PyserSSH/extensions/__init__.py b/src/PyserSSH/extensions/__init__.py index d8d2ca3..7f02c21 100644 --- a/src/PyserSSH/extensions/__init__.py +++ b/src/PyserSSH/extensions/__init__.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH diff --git a/src/PyserSSH/extensions/dialog.py b/src/PyserSSH/extensions/dialog.py index e6372af..9e76ab9 100644 --- a/src/PyserSSH/extensions/dialog.py +++ b/src/PyserSSH/extensions/dialog.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -30,7 +30,21 @@ from ..interactive import Clear, Send, wait_inputkey from ..system.sysfunc import text_centered_screen + class TextDialog: + """ + A dialog that displays a simple text message with an optional title. + + Args: + client (Client): The client to display the dialog to. + content (str, optional): The content to be displayed in the dialog. Defaults to an empty string. + title (str, optional): The title of the dialog. Defaults to an empty string. + + Methods: + render(): Renders the dialog, displaying the title and content in the center of the screen. + waituserenter(): Waits for the user to press the 'enter' key to close the dialog. + """ + def __init__(self, client, content="", title=""): self.client = client @@ -39,11 +53,15 @@ def __init__(self, client, content="", title=""): self.content = content def render(self): + """ + Renders the dialog by displaying the title, content, and waiting for the user's input. + """ Clear(self.client) Send(self.client, self.title) Send(self.client, "-" * self.windowsize["width"]) - generatedwindow = text_centered_screen(self.content, self.windowsize["width"], self.windowsize["height"]-3, " ") + generatedwindow = text_centered_screen(self.content, self.windowsize["width"], self.windowsize["height"] - 3, + " ") Send(self.client, generatedwindow) @@ -52,13 +70,32 @@ def render(self): self.waituserenter() def waituserenter(self): + """ + Waits for the user to press the 'enter' key to close the dialog. + """ while True: if wait_inputkey(self.client, raw=True) == b'\r': Clear(self.client) break pass + class MenuDialog: + """ + A menu dialog that allows the user to choose from a list of options, with navigation and selection using arrow keys. + + Args: + client (Client): The client to display the menu to. + choose (list): A list of options to be displayed. + title (str, optional): The title of the menu. + desc (str, optional): A description to display above the menu options. + + Methods: + render(): Renders the menu dialog and waits for user input. + _waituserinput(): Handles user input for selecting options or canceling. + output(): Returns the selected option index or `None` if canceled. + """ + def __init__(self, client, choose: list, title="", desc=""): self.client = client @@ -67,9 +104,12 @@ def __init__(self, client, choose: list, title="", desc=""): self.desc = desc self.contentallindex = len(choose) - 1 self.selectedindex = 0 - self.selectstatus = 0 # 0 none 1 selected 2 cancel + self.selectstatus = 0 # 0 none, 1 selected, 2 canceled def render(self): + """ + Renders the menu dialog, displaying the options and allowing the user to navigate and select an option. + """ tempcontentlist = self.choose.copy() Clear(self.client) @@ -90,7 +130,8 @@ def render(self): f"{exported}" ) - generatedwindow = text_centered_screen(contenttoshow, self.client["windowsize"]["width"], self.client["windowsize"]["height"]-3, " ") + generatedwindow = text_centered_screen(contenttoshow, self.client["windowsize"]["width"], + self.client["windowsize"]["height"] - 3, " ") Send(self.client, generatedwindow) @@ -99,6 +140,9 @@ def render(self): self._waituserinput() def _waituserinput(self): + """ + Waits for user input and updates the selection based on key presses. + """ keyinput = wait_inputkey(self.client, raw=True) if keyinput == b'\r': # Enter key @@ -124,12 +168,31 @@ def _waituserinput(self): self.render() def output(self): + """ + Returns the selected option index or `None` if the action was canceled. + """ if self.selectstatus == 2: return None elif self.selectstatus == 1: return self.selectedindex + class TextInputDialog: + """ + A text input dialog that allows the user to input text with optional password masking. + + Args: + client (Client): The client to display the dialog to. + title (str, optional): The title of the input dialog. + inputtitle (str, optional): The prompt text for the user input. + password (bool, optional): If `True`, the input will be masked as a password. Defaults to `False`. + + Methods: + render(): Renders the input dialog, displaying the prompt and capturing user input. + _waituserinput(): Handles user input, including text input and special keys. + output(): Returns the input text if selected, or `None` if canceled. + """ + def __init__(self, client, title="", inputtitle="Input Here", password=False): self.client = client @@ -137,27 +200,31 @@ def __init__(self, client, title="", inputtitle="Input Here", password=False): self.inputtitle = inputtitle self.ispassword = password - self.inputstatus = 0 # 0 none 1 selected 2 cancel + self.inputstatus = 0 # 0 none, 1 selected, 2 canceled self.buffer = bytearray() self.cursor_position = 0 def render(self): + """ + Renders the text input dialog and waits for user input. + """ Clear(self.client) Send(self.client, self.title) Send(self.client, "-" * self.client["windowsize"]["width"]) if self.ispassword: texts = ( - f"{self.inputtitle}\n\n" - "> " + ("*" * len(self.buffer.decode('utf-8'))) + f"{self.inputtitle}\n\n" + "> " + ("*" * len(self.buffer.decode('utf-8'))) ) else: texts = ( - f"{self.inputtitle}\n\n" - "> " + self.buffer.decode('utf-8') + f"{self.inputtitle}\n\n" + "> " + self.buffer.decode('utf-8') ) - generatedwindow = text_centered_screen(texts, self.client["windowsize"]["width"], self.client["windowsize"]["height"]-3, " ") + generatedwindow = text_centered_screen(texts, self.client["windowsize"]["width"], + self.client["windowsize"]["height"] - 3, " ") Send(self.client, generatedwindow) @@ -166,6 +233,9 @@ def render(self): self._waituserinput() def _waituserinput(self): + """ + Waits for the user to input text or special commands (backspace, cancel, enter). + """ keyinput = wait_inputkey(self.client, raw=True) if keyinput == b'\r': # Enter key @@ -182,7 +252,7 @@ def _waituserinput(self): self.buffer = self.buffer[:self.cursor_position - 1] + self.buffer[self.cursor_position:] self.cursor_position -= 1 elif bool(re.compile(b'\x1b\[[0-9;]*[mGK]').search(keyinput)): - pass + pass # Ignore ANSI escape codes else: # Regular character self.buffer = self.buffer[:self.cursor_position] + keyinput + self.buffer[self.cursor_position:] self.cursor_position += 1 @@ -197,6 +267,9 @@ def _waituserinput(self): self.render() def output(self): + """ + Returns the input text if the input was selected, or `None` if canceled. + """ if self.inputstatus == 2: return None elif self.inputstatus == 1: diff --git a/src/PyserSSH/extensions/moredisplay.py b/src/PyserSSH/extensions/moredisplay.py index b9238e0..0b79bbb 100644 --- a/src/PyserSSH/extensions/moredisplay.py +++ b/src/PyserSSH/extensions/moredisplay.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -29,30 +29,52 @@ from ..interactive import Send def clickable_url(url, link_text=""): + """ + Creates a clickable URL in a terminal client with optional link text. + + Args: + url (str): The URL to be linked. + link_text (str, optional): The text to be displayed for the link. Defaults to an empty string, which will display the URL itself. + + Returns: + str: A terminal escape sequence that makes the URL clickable with the provided link text. + """ return f"\033]8;;{url}\033\\{link_text}\033]8;;\033\\" def Send_karaoke_effect(client, text, delay=0.1, ln=True): + """ + Sends a text with a 'karaoke' effect where the text is printed one character at a time, + with the remaining text dimmed until it is printed. + + Args: + client (Client): The client to send the text to. + text (str): The text to be printed with the karaoke effect. + delay (float, optional): The delay in seconds between printing each character. Defaults to 0.1. + ln (bool, optional): Whether to send a newline after the text is finished. Defaults to True. + + This function simulates a typing effect by printing the text character by character, + while dimming the unprinted characters. + """ printed_text = "" for i, char in enumerate(text): - # Print already printed text normally + # Print the already printed text normally Send(client, printed_text + char, ln=False) - # Calculate not yet printed text to dim + # Calculate the unprinted text and dim it not_printed_text = text[i + 1:] dimmed_text = ''.join([f"\033[2m{char}\033[0m" for char in not_printed_text]) - # Print dimmed text + # Print the dimmed text for the remaining characters Send(client, dimmed_text, ln=False) # Wait before printing the next character time.sleep(delay) - # Clear the line for the next iteration - Send(client, '\r' ,ln=False) + # Clear the line to update the text in the next iteration + Send(client, '\r', ln=False) - # Prepare the updated printed_text for the next iteration + # Update the printed_text to include the current character printed_text += char if ln: - Send(client, "") # new line - + Send(client, "") # Send a newline after the entire text is printed diff --git a/src/PyserSSH/extensions/moreinteractive.py b/src/PyserSSH/extensions/moreinteractive.py index 04be378..c3d8e63 100644 --- a/src/PyserSSH/extensions/moreinteractive.py +++ b/src/PyserSSH/extensions/moreinteractive.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -28,10 +28,23 @@ from ..interactive import Send def ShowCursor(client, show=True): + """ + Shows or hides the cursor for a specific client. + + Args: + client (Client): The client to show/hide the cursor for. + show (bool, optional): A flag to determine whether to show or hide the cursor. Defaults to True (show cursor). + """ if show: - Send(client, "\033[?25h", ln=False) + Send(client, "\033[?25h", ln=False) # Show cursor else: - Send(client, "\033[?25l", ln=False) + Send(client, "\033[?25l", ln=False) # Hide cursor def SendBell(client): - Send(client, "\x07", ln=False) \ No newline at end of file + """ + Sends a bell character (alert) to a client. + + Args: + client (Client): The client to send the bell character to. + """ + Send(client, "\x07", ln=False) # Bell character (alert) diff --git a/src/PyserSSH/extensions/processbar.py b/src/PyserSSH/extensions/processbar.py index f3571e4..0439a7c 100644 --- a/src/PyserSSH/extensions/processbar.py +++ b/src/PyserSSH/extensions/processbar.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -24,7 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -# this file is from DPSoftware Foundation-library +# this file is from damp11113-library from itertools import cycle import math @@ -129,17 +129,27 @@ def format_text(text, color=None, color_level=None, background=None, attributes= def insert_string(base, inserted, position=0): return base[:position] + inserted + base[position + len(inserted):] -steps1 = ['[ ]', '[- ]', '[-- ]', '[---]', '[ --]', '[ -]'] -steps2 = ['[ ]', '[- ]', '[ - ]', '[ -]'] -steps3 = ['[ ]', '[- ]', '[-- ]', '[ --]', '[ -]', '[ ]', '[ -]', '[ --]', '[-- ]', '[- ]'] -steps4 = ['[ ]', '[- ]', '[ - ]', '[ -]', '[ ]', '[ -]', '[ - ]', '[- ]', '[ ]'] -steps5 = ['[ ]', '[ -]', '[ --]', '[---]', '[-- ]', '[- ]'] -steps6 = ['[ ]', '[ -]', '[ - ]', '[- ]'] +class Steps: + steps1 = ['[ ]', '[- ]', '[-- ]', '[---]', '[ --]', '[ -]'] + steps2 = ['[ ]', '[- ]', '[ - ]', '[ -]'] + steps3 = ['[ ]', '[- ]', '[-- ]', '[ --]', '[ -]', '[ ]', '[ -]', '[ --]', '[-- ]', '[- ]'] + steps4 = ['[ ]', '[- ]', '[ - ]', '[ -]', '[ ]', '[ -]', '[ - ]', '[- ]', '[ ]'] + steps5 = ['[ ]', '[ -]', '[ --]', '[---]', '[-- ]', '[- ]'] + steps6 = ['[ ]', '[ -]', '[ - ]', '[- ]'] + expand_contract = ['[ ]', '[= ]', '[== ]', '[=== ]', '[====]', '[ ===]', '[ ==]', '[ =]', '[ ]'] + rotating_dots = ['. ', '.. ', '... ', '.... ', '.....', ' ....', ' ...', ' ..', ' .', ' '] + bouncing_ball = ['o ', ' o ', ' o ', ' o ', ' o ', ' o', ' o ', ' o ', ' o ', ' o ', 'o '] + left_right_dots = ['[ ]', '[. ]', '[.. ]', '[... ]', '[....]', '[ ...]', '[ ..]', '[ .]', '[ ]'] + expanding_square = ['[ ]', '[■]', '[■■]', '[■■■]', '[■■■■]', '[■■■]', '[■■]', '[■]', '[ ]'] + spinner = ['|', '/', '-', '\\', '|', '/', '-', '\\'] + zigzag = ['/ ', ' / ', ' / ', ' /', ' / ', ' / ', '/ ', '\\ ', ' \\ ', ' \\ ', ' \\', ' \\ ', ' \\ ', '\\ '] + arrows = ['← ', '←← ', '←←←', '←← ', '← ', '→ ', '→→ ', '→→→', '→→ ', '→ '] + snake = ['[> ]', '[=> ]', '[==> ]', '[===> ]', '[====>]', '[ ===>]', '[ ==>]', '[ =>]', '[ >]'] + loading_bar = ['[ ]', '[= ]', '[== ]', '[=== ]', '[==== ]', '[===== ]', '[====== ]', '[======= ]', '[======== ]', '[========= ]', '[==========]'] class indeterminateStatus: def __init__(self, client, desc="Loading...", end="[ OK ]", timeout=0.1, fail='[FAILED]', steps=None): self.client = client - self.desc = desc self.end = end self.timeout = timeout @@ -147,13 +157,14 @@ def __init__(self, client, desc="Loading...", end="[ OK ]", timeout=0.1, fail= self._thread = Thread(target=self._animate, daemon=True) if steps is None: - self.steps = steps1 + self.steps = Steps.steps1 else: self.steps = steps self.done = False self.fail = False def start(self): + """Start progress bar""" self._thread.start() return self @@ -168,12 +179,14 @@ def __enter__(self): self.start() def stop(self): + """stop progress""" self.done = True cols = self.client["windowsize"]["width"] Print(self.client['channel'], "\r" + " " * cols, end="") Print(self.client['channel'], f"\r{self.end}") def stopfail(self): + """stop progress with error or fail""" self.done = True self.fail = True cols = self.client["windowsize"]["width"] @@ -234,7 +247,7 @@ def __init__(self, client, total=100, totalbuffer=None, length=50, fill='█', f self._thread = Thread(target=self._animate, daemon=True) if steps is None: - self.steps = steps1 + self.steps = Steps.steps1 else: self.steps = steps @@ -251,14 +264,17 @@ def __init__(self, client, total=100, totalbuffer=None, length=50, fill='█', f self.currentprint = "" def start(self): + """Start progress bar""" self._thread.start() self.startime = time.perf_counter() return self - def update(self, i): + def update(self, i=1): + """update progress""" self.current += i - def updatebuffer(self, i): + def updatebuffer(self, i=1): + """update buffer progress""" self.currentbuffer += i def _animate(self): @@ -374,12 +390,14 @@ def __enter__(self): self.start() def stop(self): + """stop progress""" self.done = True cols = self.client["windowsize"]["width"] Print(self.client["channel"], "\r" + " " * cols, end="") Print(self.client["channel"], f"\r{self.end}") def stopfail(self): + """stop progress with error or fail""" self.done = True self.fail = True cols = self.client["windowsize"]["width"] diff --git a/src/PyserSSH/extensions/remodesk.py b/src/PyserSSH/extensions/remodesk.py index 8947f38..f30a745 100644 --- a/src/PyserSSH/extensions/remodesk.py +++ b/src/PyserSSH/extensions/remodesk.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -40,7 +40,7 @@ from ..system.clientype import Client -logger = logging.getLogger("RemoDeskSSH") +logger = logging.getLogger("PyserSSH.Ext.RemoDeskSSH") class Protocol: def __init__(self, server): diff --git a/src/PyserSSH/extensions/serverutils.py b/src/PyserSSH/extensions/serverutils.py index 882a355..b785f3e 100644 --- a/src/PyserSSH/extensions/serverutils.py +++ b/src/PyserSSH/extensions/serverutils.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -28,9 +28,17 @@ from ..interactive import Send -logger = logging.getLogger("PyserSSH") +logger = logging.getLogger("PyserSSH.Ext.ServerUtils") def kickbyusername(server, username, reason=None): + """ + Kicks a user from the server by their username. + + Args: + server (Server): The server object where clients are connected. + username (str): The username of the client to be kicked. + reason (str, optional): The reason for kicking the user. If None, no reason is provided. + """ for peername, client_handler in list(server.client_handlers.items()): if client_handler["current_user"] == username: channel = client_handler.get("channel") @@ -46,6 +54,14 @@ def kickbyusername(server, username, reason=None): logger.info(f"User '{username}' has been kicked by reason {reason}.") def kickbypeername(server, peername, reason=None): + """ + Kicks a user from the server by their peername. + + Args: + server (Server): The server object where clients are connected. + peername (str): The peername of the client to be kicked. + reason (str, optional): The reason for kicking the user. If None, no reason is provided. + """ client_handler = server.client_handlers.get(peername) if client_handler: channel = client_handler.get("channel") @@ -61,6 +77,13 @@ def kickbypeername(server, peername, reason=None): logger.info(f"peername '{peername}' has been kicked by reason {reason}.") def kickall(server, reason=None): + """ + Kicks all users from the server. + + Args: + server (Server): The server object where clients are connected. + reason (str, optional): The reason for kicking all users. If None, no reason is provided. + """ for peername, client_handler in server.client_handlers.items(): channel = client_handler.get("channel") server._handle_event("disconnected", channel.getpeername(), server.client_handlers[channel.getpeername()]["current_user"]) @@ -78,6 +101,13 @@ def kickall(server, reason=None): logger.info(f"All users have been kicked by reason {reason}.") def broadcast(server, message): + """ + Broadcasts a message to all connected clients. + + Args: + server (Server): The server object where clients are connected. + message (str): The message to send to all clients. + """ for client_handler in server.client_handlers.values(): channel = client_handler.get("channel") if channel: @@ -88,6 +118,14 @@ def broadcast(server, message): logger.error(f"Error occurred while broadcasting message: {e}") def sendto(server, username, message): + """ + Sends a message to a specific user by their username. + + Args: + server (Server): The server object where clients are connected. + username (str): The username of the client to send the message to. + message (str): The message to send to the specified user. + """ for client_handler in server.client_handlers.values(): if client_handler.get("current_user") == username: channel = client_handler.get("channel") diff --git a/src/PyserSSH/interactive.py b/src/PyserSSH/interactive.py index b76c377..08a59af 100644 --- a/src/PyserSSH/interactive.py +++ b/src/PyserSSH/interactive.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -82,8 +82,11 @@ def Clear(client, oldclear=False, keep=False): def Title(client, title): Send(client, f"\033]0;{title}\007", ln=False) -def wait_input(client, prompt="", defaultvalue=None, cursor_scroll=False, echo=True, password=False, passwordmask=b"*", noabort=False, timeout=0): - channel = client["channel"] +def wait_input(client, prompt="", defaultvalue=None, cursor_scroll=False, echo=True, password=False, passwordmask=b"*", noabort=False, timeout=0, directchannel=False): + if directchannel: + channel = client + else: + channel = client["channel"] channel.send(replace_enter_with_crlf(prompt)) @@ -144,6 +147,8 @@ def wait_input(client, prompt="", defaultvalue=None, cursor_scroll=False, echo=T channel.sendall(b'\r\n') raise else: + channel.setblocking(False) + channel.settimeout(None) output = buffer.decode('utf-8') # Return default value if specified and no input given @@ -171,9 +176,17 @@ def wait_inputkey(client, prompt="", raw=True, timeout=0): if bool(re.compile(b'\x1b\[[0-9;]*[mGK]').search(byte)): pass + channel.setblocking(False) + channel.settimeout(None) + if prompt != "": + channel.send("\r\n") return byte.decode('utf-8') # only regular character else: + channel.setblocking(False) + channel.settimeout(None) + if prompt != "": + channel.send("\r\n") return byte except socket.timeout: @@ -185,7 +198,8 @@ def wait_inputkey(client, prompt="", raw=True, timeout=0): except Exception: channel.setblocking(False) channel.settimeout(None) - channel.send("\r\n") + if prompt != "": + channel.send("\r\n") raise def wait_inputmouse(client, timeout=0): @@ -204,6 +218,8 @@ def wait_inputmouse(client, timeout=0): if byte.startswith(b'\x1b[M'): # Parse mouse event if len(byte) < 6 or not byte.startswith(b'\x1b[M'): + channel.setblocking(False) + channel.settimeout(None) Send(client, "\033[?1000l", ln=False) return None, None, None @@ -212,9 +228,13 @@ def wait_inputmouse(client, timeout=0): x = byte[4] - 32 y = byte[5] - 32 + channel.setblocking(False) + channel.settimeout(None) Send(client, "\033[?1000l", ln=False) return button, x, y else: + channel.setblocking(False) + channel.settimeout(None) Send(client, "\033[?1000l", ln=False) return byte, None, None @@ -255,9 +275,13 @@ def wait_choose(client, choose, prompt="", timeout=0): keyinput = wait_inputkey(client, raw=True) if keyinput == b'\r': # Enter key + channel.setblocking(False) + channel.settimeout(None) Send(client, "\033[K") return chooseindex elif keyinput == b'\x03': # ' ctrl+c' key for cancel + channel.setblocking(False) + channel.settimeout(None) Send(client, "\033[K") return 0 elif keyinput == b'\x1b[D': # Up arrow key diff --git a/src/PyserSSH/server.py b/src/PyserSSH/server.py index ec2cfe9..10fe5bf 100644 --- a/src/PyserSSH/server.py +++ b/src/PyserSSH/server.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -38,17 +38,18 @@ from .system.sysfunc import replace_enter_with_crlf from .system.interface import Sinterface from .system.inputsystem import expect -from .system.info import __version__, system_banner +from .system.info import version, system_banner from .system.clientype import Client as Clientype +from .system.ProWrapper import SSHTransport, TelnetTransport, ITransport # paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = pow(2, 22) sftpclient = ["WinSCP", "Xplore"] -logger = logging.getLogger("PyserSSH") +logger = logging.getLogger("PyserSSH.Server") class Server: - def __init__(self, accounts, system_message=True, disable_scroll_with_arrow=True, sftp=False, system_commands=True, compression=True, usexternalauth=False, history=True, inputsystem=True, XHandler=None, title=f"PyserSSH v{__version__}", inspeed=32768, enable_preauth_banner=False, enable_exec_system_command=True, enable_remote_status=False, inputsystem_echo=True): + def __init__(self, accounts, system_message=True, disable_scroll_with_arrow=True, sftp=False, system_commands=True, compression=True, usexternalauth=False, history=True, inputsystem=True, XHandler=None, title=f"PyserSSH v{version}", inspeed=32768, enable_preauth_banner=False, enable_exec_system_command=True, enable_remote_status=False, inputsystem_echo=True): """ system_message set to False to disable welcome message from system disable_scroll_with_arrow set to False to enable seek text with arrow (Beta) @@ -80,13 +81,18 @@ def __init__(self, accounts, system_message=True, disable_scroll_with_arrow=True self._event_handlers = {} self.client_handlers = {} # Dictionary to store event handlers for each client self.__processmode = None - self.__serverisrunning = False + self.isrunning = False self.__daemon = False + self.private_key = "" + self.__custom_server_args = () + self.__custom_server = None + self.__protocol = "ssh" if self.enasyscom: print("\033[33m!!Warning!! System commands is enable! \033[0m") def on_user(self, event_name): + """Handle event""" def decorator(func): @wraps(func) def wrapper(client, *args, **kwargs): @@ -98,7 +104,7 @@ def wrapper(client, *args, **kwargs): return decorator def handle_client_disconnection(self, handler, chandlers): - if not chandlers["channel"].get_transport().is_active(): + if not chandlers["transport"].is_active(): if handler: handler(chandlers) del self.client_handlers[chandlers["peername"]] @@ -113,32 +119,29 @@ def _handle_event(self, event_name, *args, **kwargs): elif handler: return handler(*args, **kwargs) - def handle_client(self, socketchannel, addr): - self._handle_event("pressh", socketchannel) + def _handle_client(self, socketchannel, addr): + self._handle_event("preserver", socketchannel) - try: - bh_session = paramiko.Transport(socketchannel) - except OSError: - return - - bh_session.add_server_key(self.private_key) + logger.info("Starting session...") + server = Sinterface(self) - bh_session.use_compression(self.compressena) + if not self.__custom_server: + if self.__protocol.lower() == "telnet": + bh_session = TelnetTransport(socketchannel, server) # Telnet server + else: + bh_session = SSHTransport(socketchannel, server, self.private_key) # SSH server + else: + bh_session = self.__custom_server(socketchannel, server, *self.__custom_server_args) # custom server - bh_session.default_window_size = 2147483647 - bh_session.packetizer.REKEY_BYTES = pow(2, 40) - bh_session.packetizer.REKEY_PACKETS = pow(2, 40) + bh_session.enable_compression(self.compressena) - bh_session.default_max_packet_size = self.inspeed + bh_session.max_packet_size(self.inspeed) - server = Sinterface(self) try: - bh_session.start_server(server=server) + bh_session.start_server() except: return - logger.info(bh_session.remote_version) - channel = bh_session.accept() if self.sftpena: @@ -175,8 +178,7 @@ def handle_client(self, socketchannel, addr): logger.info("saved user data to client handlers") #if not any(bh_session.remote_version.split("-")[2].startswith(prefix) for prefix in sftpclient): - if int(channel.out_window_size) != int(bh_session.default_window_size): - logger.info("user is ssh") + if not (int(channel.get_out_window_size()) == int(bh_session.get_default_window_size()) and bh_session.get_connection_type() == "SSH"): #timeout for waiting 10 sec for i in range(100): if self.client_handlers[channel.getpeername()]["windowsize"]: @@ -260,7 +262,6 @@ def handle_client(self, socketchannel, addr): time.sleep(0.1) self._handle_event("disconnected", self.client_handlers[peername]) - else: self._handle_event("disconnected", self.client_handlers[peername]) channel.close() @@ -271,13 +272,14 @@ def handle_client(self, socketchannel, addr): bh_session.close() def stop_server(self): + """Stop server""" logger.info("Stopping the server...") try: for client_handler in self.client_handlers.values(): channel = client_handler.channel if channel: channel.close() - self.__serverisrunning = False + self.isrunning = False self.server.close() logger.info("Server stopped.") @@ -286,42 +288,64 @@ def stop_server(self): def _start_listening_thread(self): try: - logger.info("Start Listening for connections...") - while self.__serverisrunning: - client, addr = self.server.accept() - if self.__processmode == "thread": - client_thread = threading.Thread(target=self.handle_client, args=(client, addr), daemon=True) - client_thread.start() - else: - self.handle_client(client, addr) - time.sleep(1) - except KeyboardInterrupt: - self.stop_server() + self.isrunning = True + try: + logger.info("Listening for connections...") + while self.isrunning: + client, addr = self.server.accept() + if self.__processmode == "thread": + logger.info(f"Starting client thread for connection {addr}") + client_thread = threading.Thread(target=self._handle_client, args=(client, addr), daemon=True) + client_thread.start() + else: + logger.info(f"Starting client for connection {addr}") + self._handle_client(client, addr) + time.sleep(1) + except KeyboardInterrupt: + self.stop_server() except Exception as e: logger.error(e) - def run(self, private_key_path=None, host="0.0.0.0", port=2222, mode="thread", maxuser=0, daemon=False): - """mode: single, thread - protocol: ssh, telnet + def run(self, private_key_path=None, host="0.0.0.0", port=2222, waiting_mode="thread", maxuser=0, daemon=False, listen_thread=True, protocol="ssh", custom_server: ITransport = None, custom_server_args: tuple = None, custom_server_require_socket=True): + """mode: single, thread, + protocol: ssh, telnet (beta), serial, custom + For serial need to set serial port at host (ex. host="com3") and set baudrate at port (ex. port=9600) and change listen_mode to "single". """ - self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) - self.server.bind((host, port)) + if protocol.lower() == "ssh": + if private_key_path != None: + logger.info("Loading private key") + self.private_key = paramiko.RSAKey(filename=private_key_path) + else: + raise ValueError("No private key") - if private_key_path != None: - self.private_key = paramiko.RSAKey(filename=private_key_path) - else: - raise ValueError("No private key") + self.__processmode = waiting_mode.lower() + self.__daemon = daemon - if maxuser == 0: - self.server.listen() - else: - self.server.listen(maxuser) + if custom_server: + self.__custom_server = custom_server + self.__custom_server_args = custom_server_args - self.__processmode = mode.lower() - self.__serverisrunning = True - self.__daemon = daemon + if ((custom_server and protocol.lower() == "custom") and custom_server_require_socket) or protocol.lower() in ["ssh", "telnet"]: + logger.info("Creating server...") + self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) + self.server.bind((host, port)) + + logger.info("Set listen limit") + if maxuser == 0: + self.server.listen() + else: + self.server.listen(maxuser) - client_thread = threading.Thread(target=self._start_listening_thread, daemon=self.__daemon) - client_thread.start() + if listen_thread: + logger.info("Starting listening in threading") + client_thread = threading.Thread(target=self._start_listening_thread, daemon=self.__daemon) + client_thread.start() + else: + print(f"\033[32mServer is running on {host}:{port}\033[0m") + self._start_listening_thread() + else: + client_thread = threading.Thread(target=self._handle_client, args=(None, None), daemon=True) + client_thread.start() + print(f"\033[32mServer is running on {host}:{port}\033[0m") \ No newline at end of file diff --git a/src/PyserSSH/system/ProWrapper.py b/src/PyserSSH/system/ProWrapper.py new file mode 100644 index 0000000..8bc2888 --- /dev/null +++ b/src/PyserSSH/system/ProWrapper.py @@ -0,0 +1,495 @@ +""" +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-present DPSoftware Foundation (MIT) + +Visit https://github.com/DPSoftware-Foundation/PyserSSH + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import serial +import socket +import paramiko +from abc import ABC, abstractmethod +from typing import Union + +from .interface import Sinterface +from ..interactive import Send, wait_input + +class ITransport(ABC): + @abstractmethod + def enable_compression(self, enable: bool) -> None: + """ + Enables or disables data compression for the transport. + + Args: + enable (bool): If True, enable compression. If False, disable it. + """ + pass + + @abstractmethod + def max_packet_size(self, size: int) -> None: + """ + Sets the maximum packet size for the transport. + + Args: + size (int): The maximum packet size in bytes. + """ + pass + + @abstractmethod + def start_server(self) -> None: + """ + Starts the server for the transport, allowing it to accept incoming connections. + """ + pass + + @abstractmethod + def accept(self, timeout: Union[int, None] = None) -> "IChannel": + """ + Accepts an incoming connection and returns an IChannel instance for communication. + + Args: + timeout (Union[int, None]): The time in seconds to wait for a connection. + If None, waits indefinitely. + + Returns: + IChannel: An instance of IChannel representing the connection. + """ + pass + + @abstractmethod + def set_subsystem_handler(self, name: str, handler: callable, *args: any, **kwargs: any) -> None: + """ + Sets a handler for a specific subsystem in the transport. + + Args: + name (str): The name of the subsystem. + handler (callable): The handler function to be called for the subsystem. + *args: Arguments to pass to the handler. + **kwargs: Keyword arguments to pass to the handler. + """ + pass + + @abstractmethod + def close(self) -> None: + """ + Closes the transport connection, releasing any resources used. + """ + pass + + @abstractmethod + def is_authenticated(self) -> bool: + """ + Checks if the transport is authenticated. + + Returns: + bool: True if the transport is authenticated, otherwise False. + """ + pass + + @abstractmethod + def getpeername(self) -> tuple[str, int]: # (host, port) + """ + Retrieves the peer's address and port. + + Returns: + tuple[str, int]: The host and port of the peer. + """ + pass + + @abstractmethod + def get_username(self) -> str: + """ + Retrieves the username associated with the transport. + + Returns: + str: The username. + """ + pass + + @abstractmethod + def is_active(self) -> bool: + """ + Checks if the transport is active. + + Returns: + bool: True if the transport is active, otherwise False. + """ + pass + + @abstractmethod + def get_auth_method(self) -> str: + """ + Retrieves the authentication method used for the transport. + + Returns: + str: The authentication method (e.g., password, public key). + """ + pass + + @abstractmethod + def set_username(self, username: str) -> None: + """ + Sets the username for the transport. + + Args: + username (str): The username to be set. + """ + pass + + @abstractmethod + def get_default_window_size(self) -> int: + """ + Retrieves the default window size for the transport. + + Returns: + int: The default window size. + """ + pass + + @abstractmethod + def get_connection_type(self) -> str: + """ + Retrieves the type of connection for the transport. + + Returns: + str: The connection type (e.g., TCP, UDP). + """ + pass + + +class IChannel(ABC): + @abstractmethod + def send(self, s: Union[bytes, bytearray]) -> None: + """ + Sends data over the channel. + + Args: + s (Union[bytes, bytearray]): The data to send. + """ + pass + + @abstractmethod + def sendall(self, s: Union[bytes, bytearray]) -> None: + """ + Sends all data over the channel, blocking until all data is sent. + + Args: + s (Union[bytes, bytearray]): The data to send. + """ + pass + + @abstractmethod + def getpeername(self) -> tuple[str, int]: + """ + Retrieves the peer's address and port. + + Returns: + tuple[str, int]: The host and port of the peer. + """ + pass + + @abstractmethod + def settimeout(self, timeout: Union[float, None]) -> None: + """ + Sets the timeout for blocking operations on the channel. + + Args: + timeout (Union[float, None]): The timeout in seconds. If None, the operation will block indefinitely. + """ + pass + + @abstractmethod + def setblocking(self, blocking: bool) -> None: + """ + Sets whether the channel operates in blocking mode or non-blocking mode. + + Args: + blocking (bool): If True, the channel operates in blocking mode. If False, non-blocking mode. + """ + pass + + @abstractmethod + def recv(self, nbytes: int) -> bytes: + """ + Receives data from the channel. + + Args: + nbytes (int): The number of bytes to receive. + + Returns: + bytes: The received data. + """ + pass + + @abstractmethod + def get_id(self) -> int: + """ + Retrieves the unique identifier for the channel. + + Returns: + int: The channel's unique identifier. + """ + pass + + @abstractmethod + def close(self) -> None: + """ + Closes the channel and releases any resources used. + """ + pass + + @abstractmethod + def get_out_window_size(self) -> int: + """ + Retrieves the output window size for the channel. + + Returns: + int: The output window size. + """ + pass + +#-------------------------------------------------------------------------------------------- + +class SSHTransport(ITransport): + def __init__(self, socketchannel: socket.socket, interface: Sinterface, key): + self.socket: socket.socket = socketchannel + self.interface: Sinterface = interface + self.key = key + + self.bh_session = paramiko.Transport(self.socket) + self.bh_session.add_server_key(self.key) + self.bh_session.default_window_size = 2147483647 + + def enable_compression(self, enable): + self.bh_session.use_compression(enable) + + def max_packet_size(self, size): + self.bh_session.default_max_packet_size = size + + def start_server(self): + self.bh_session.start_server(server=self.interface) + + def accept(self, timeout=None): + return SSHChannel(self.bh_session.accept(timeout)) + + def set_subsystem_handler(self, name, handler, *args, **kwargs): + self.bh_session.set_subsystem_handler(name, handler, *args, **kwargs) + + def close(self): + self.bh_session.close() + + def is_authenticated(self): + return self.bh_session.is_authenticated() + + def getpeername(self): + return self.bh_session.getpeername() + + def get_username(self): + return self.bh_session.get_username() + + def is_active(self): + return self.bh_session.is_active() + + def get_auth_method(self): + return self.bh_session.auth_handler.auth_method + + def set_username(self, username): + self.bh_session.auth_handler.username = username + + def get_default_window_size(self): + return self.bh_session.default_window_size + + def get_connection_type(self): + return "SSH" + +class SSHChannel(IChannel): + def __init__(self, channel: paramiko.Channel): + self.channel: paramiko.Channel = channel + + def send(self, s): + self.channel.send(s) + + def sendall(self, s): + self.channel.sendall(s) + + def getpeername(self): + return self.channel.getpeername() + + def settimeout(self, timeout): + self.channel.settimeout(timeout) + + def setblocking(self, blocking): + self.channel.setblocking(blocking) + + def recv(self, nbytes): + return self.channel.recv(nbytes) + + def get_id(self): + return self.channel.get_id() + + def close(self): + self.channel.close() + + def get_out_window_size(self): + return self.channel.out_window_size + +#-------------------------------------------------------------------------------------------- + +# Telnet command and option codes +IAC = 255 +DO = 253 +WILL = 251 +TTYPE = 24 +ECHO = 1 +SGA = 3 # Suppress Go Ahead + +def send_telnet_command(sock, command, option): + sock.send(bytes([IAC, command, option])) + +class TelnetTransport(ITransport): + def __init__(self, socketchannel: socket.socket, interface: Sinterface): + self.socket: socket.socket = socketchannel + self.interface: Sinterface = interface + self.username = None + self.isactive = True + self.isauth = False + self.auth_method = None + + def enable_compression(self, enable): + pass + + def max_packet_size(self, size): + pass + + def start_server(self): + pass + + def set_subsystem_handler(self, name: str, handler: callable, *args: any, **kwargs: any) -> None: + pass + + def negotiate_options(self): + # Negotiating TTYPE (Terminal Type), ECHO, and SGA (Suppress Go Ahead) + send_telnet_command(self.socket, DO, TTYPE) + send_telnet_command(self.socket, WILL, ECHO) + send_telnet_command(self.socket, WILL, SGA) + + def accept(self, timeout=None): + # Perform Telnet negotiation + self.negotiate_options() + + # Simple authentication prompt + username = wait_input(self.socket, "Login as: ", directchannel=True) + + try: + allowauth = self.interface.get_allowed_auths(username).split(',') + except: + allowauth = self.interface.get_allowed_auths(username) + + if allowauth[0] == "password": + password = wait_input(self.socket, "Password", password=True, directchannel=True) + result = self.interface.check_auth_password(username, password) + + if result == 0: + self.isauth = True + self.username = username + self.auth_method = "password" + return TelnetChannel(self.socket) + else: + Send(self.socket, "Access denied", directchannel=True) + self.close() + elif allowauth[0] == "public_key": + Send(self.socket, "Public key isn't supported for telnet", directchannel=True) + self.close() + elif allowauth[0] == "none": + result = self.interface.check_auth_none(username) + + if result == 0: + self.username = username + self.isauth = True + self.auth_method = "none" + return TelnetChannel(self.socket) + else: + Send(self.socket, "Access denied", directchannel=True) + self.close() + else: + Send(self.socket, "Access denied", directchannel=True) + + def close(self): + self.isactive = False + self.socket.close() + + def is_authenticated(self): + return self.isauth + + def getpeername(self): + return self.socket.getpeername() + + def get_username(self): + return self.username + + def is_active(self): + return self.isactive + + def get_auth_method(self): + return self.auth_method + + def set_username(self, username): + self.username = username + + def get_default_window_size(self): + return 0 + + def get_connection_type(self): + return "Telnet" + + +class TelnetChannel(IChannel): + def __init__(self, channel: socket.socket): + self.channel: socket.socket = channel + + def send(self, s): + self.channel.send(s) + + def sendall(self, s): + self.channel.sendall(s) + + def getpeername(self): + return self.channel.getpeername() + + def settimeout(self, timeout): + self.channel.settimeout(timeout) + + def setblocking(self, blocking): + self.channel.setblocking(blocking) + + def recv(self, nbytes): + return self.channel.recv(nbytes) + + def get_id(self): + return 0 + + def close(self) -> None: + return self.channel.close() + + def get_out_window_size(self) -> int: + return 0 \ No newline at end of file diff --git a/src/PyserSSH/system/SFTP.py b/src/PyserSSH/system/SFTP.py index 2e778b6..e398eaa 100644 --- a/src/PyserSSH/system/SFTP.py +++ b/src/PyserSSH/system/SFTP.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH diff --git a/src/PyserSSH/system/__init__.py b/src/PyserSSH/system/__init__.py index d8d2ca3..7f02c21 100644 --- a/src/PyserSSH/system/__init__.py +++ b/src/PyserSSH/system/__init__.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH diff --git a/src/PyserSSH/system/clientype.py b/src/PyserSSH/system/clientype.py index 5f434ed..12c48f4 100644 --- a/src/PyserSSH/system/clientype.py +++ b/src/PyserSSH/system/clientype.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -25,14 +25,23 @@ SOFTWARE. """ import time -from paramiko.transport import Transport -from paramiko.channel import Channel + +from ..interactive import Send +from .ProWrapper import IChannel, ITransport class Client: def __init__(self, channel, transport, peername): + """ + Initializes a new client instance. + + Args: + channel (IChannel): The communication channel for the client. + transport (ITransport): The transport layer for the client. + peername (tuple): The peer's address and port (host, port). + """ self.current_user = None - self.transport: Transport = transport - self.channel: Channel = channel + self.transport: ITransport = transport + self.channel: IChannel = channel self.subchannel = {} self.connecttype = None self.last_activity_time = None @@ -42,7 +51,7 @@ def __init__(self, channel, transport, peername): self.prompt = None self.inputbuffer = None self.peername = peername - self.auth_method = self.transport.auth_handler.auth_method + self.auth_method = self.transport.get_auth_method() self.session_id = None self.terminal_type = None self.env_variables = {} @@ -51,54 +60,165 @@ def __init__(self, channel, transport, peername): self.isexeccommandrunning = False def get_id(self): + """ + Retrieves the client's session ID. + + Returns: + str or None: The session ID of the client. + """ return self.session_id def get_name(self): + """ + Retrieves the current username of the client. + + Returns: + str: The current username of the client. + """ return self.current_user def get_peername(self): - return self.current_user + """ + Retrieves the peer's address (host, port) for the client. + + Returns: + tuple: The peer's address (host, port). + """ + return self.peername def get_prompt(self): + """ + Retrieves the prompt string for the client. + + Returns: + str: The prompt string for the client. + """ return self.prompt def get_channel(self): + """ + Retrieves the communication channel for the client. + + Returns: + IChannel: The channel instance for the client. + """ return self.channel def get_prompt_buffer(self): + """ + Retrieves the current input buffer for the client as a string. + + Returns: + str: The input buffer as a string. + """ return str(self.inputbuffer) def get_terminal_size(self): + """ + Retrieves the terminal size (width, height) for the client. + + Returns: + tuple[int, int]: The terminal's width and height. + """ return self.windowsize["width"], self.windowsize["height"] def get_connection_type(self): + """ + Retrieves the connection type for the client. + + Returns: + str: The connection type (e.g., TCP, UDP). + """ return self.connecttype def get_auth_with(self): + """ + Retrieves the authentication method used for the client. + + Returns: + str: The authentication method (e.g., password, public key). + """ return self.auth_method def get_session_duration(self): + """ + Calculates the duration of the current session for the client. + + Returns: + float: The duration of the session in seconds. + """ return time.time() - self.last_login_time def get_environment(self, variable): - return self.env_variables[variable] + """ + Retrieves the value of an environment variable for the client. + + Args: + variable (str): The name of the environment variable. + + Returns: + str: The value of the environment variable. + """ + return self.env_variables.get(variable) def get_last_error(self): + """ + Retrieves the last error message encountered by the client. + + Returns: + str: The last error message, or None if no error occurred. + """ return self.last_error def get_last_command(self): + """ + Retrieves the last command executed by the client. + + Returns: + str: The last command executed. + """ return self.last_command def set_name(self, name): + """ + Sets the current username for the client. + + Args: + name (str): The username to set for the client. + """ self.current_user = name def set_prompt(self, prompt): + """ + Sets the prompt string for the client. + + Args: + prompt (str): The prompt string to set for the client. + """ self.prompt = prompt def set_environment(self, variable, value): + """ + Sets the value of an environment variable for the client. + + Args: + variable (str): The name of the environment variable. + value (str): The value to set for the environment variable. + """ self.env_variables[variable] = value def open_new_subchannel(self, timeout=None): + """ + Opens a new subchannel for communication with the client. + + Args: + timeout (Union[int, None]): The timeout duration in seconds. + If None, the operation waits indefinitely. + + Returns: + tuple: A tuple containing the subchannel ID and the new subchannel + (IChannel). If an error occurs, returns (None, None). + """ try: channel = self.transport.accept(timeout) id = channel.get_id() @@ -109,35 +229,65 @@ def open_new_subchannel(self, timeout=None): return id, channel def get_subchannel(self, id): - return self.subchannel[id] + """ + Retrieves a subchannel by its ID. + + Args: + id (int): The ID of the subchannel to retrieve. + + Returns: + IChannel: The subchannel instance. + """ + return self.subchannel.get(id) def switch_user(self, user): + """ + Switches the current user for the client. + + Args: + user (str): The new username to switch to. + """ self.current_user = user - self.transport.auth_handler.username = user + self.transport.set_username(user) def close_subchannel(self, id): + """ + Closes a specific subchannel by its ID. + + Args: + id (int): The ID of the subchannel to close. + """ self.subchannel[id].close() def close(self): + """ + Closes the main communication channel for the client. + """ self.channel.close() - # for backward compatibility only - def __getitem__(self, key): - return getattr(self, key) + def send(self, data): + """ + Sends data over the main communication channel. - def __setitem__(self, key, value): - setattr(self, key, value) + Args: + data (str): The data to send. + """ + Send(self.channel, data, directchannel=True) def __str__(self): return f"client id: {self.session_id}" def __repr__(self): - # Get the dictionary of instance attributes attrs = vars(self) # or self.__dict__ - # Filter out attributes that are None non_none_attrs = {key: value for key, value in attrs.items() if value is not None} - # Build a string representation attrs_repr = ', '.join(f"{key}={value!r}" for key, value in non_none_attrs.items()) - return f"Client({attrs_repr})" \ No newline at end of file + return f"Client({attrs_repr})" + + # for backward compatibility only + def __getitem__(self, key): + return getattr(self, key) + + def __setitem__(self, key, value): + setattr(self, key, value) diff --git a/src/PyserSSH/system/info.py b/src/PyserSSH/system/info.py index fca0e87..ed322bb 100644 --- a/src/PyserSSH/system/info.py +++ b/src/PyserSSH/system/info.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -24,12 +24,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import re -__version__ = "5.0" +version = "5.1" system_banner = ( - f"\033[36mPyserSSH V{__version__} \033[0m" + f"\033[36mPyserSSH V{version} \033[0m" #"\033[33m!!Warning!! This is Testing Version of PyserSSH \033[0m\n" #"\033[35mUse Putty and WinSCP (SFTP) for best experience \033[0m" ) @@ -43,7 +42,7 @@ def Flag_TH(returnlist=False): f"\033[34m ===== == ===== ==== === == ===== ===== ======== \033[0m\n", f"\033[37m == == === === == == === === == == \033[0m\n", f"\033[31m == == ====== ======= == == ====== ====== == == \033[0m\n", - " Made by \033[33mD\033[38;2;255;126;1mP\033[38;2;43;205;150mSoftware\033[0m Foundation from Thailand\n", + " Made by \033[33mD\033[38;2;255;126;1mP\033[38;2;43;205;150mSoftware\033[0m \033[38;2;204;208;43mFoundation\033[0m from Thailand\n", "\n" ] diff --git a/src/PyserSSH/system/inputsystem.py b/src/PyserSSH/system/inputsystem.py index ddb4d64..868182b 100644 --- a/src/PyserSSH/system/inputsystem.py +++ b/src/PyserSSH/system/inputsystem.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -31,7 +31,7 @@ from .sysfunc import replace_enter_with_crlf from .syscom import systemcommand -logger = logging.getLogger("PyserSSH") +logger = logging.getLogger("PyserSSH.InputSystem") def expect(self, client, echo=True): buffer = bytearray() @@ -177,7 +177,7 @@ def expect(self, client, echo=True): try: if self.enasyscom: - sct = systemcommand(client, command) + sct = systemcommand(client, command, self) else: sct = False diff --git a/src/PyserSSH/system/interface.py b/src/PyserSSH/system/interface.py index 19a84e6..4248229 100644 --- a/src/PyserSSH/system/interface.py +++ b/src/PyserSSH/system/interface.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -29,7 +29,7 @@ import ast from .syscom import systemcommand -from .remotestatus import startremotestatus +from .RemoteStatus import startremotestatus def parse_exec_request(command_string): try: diff --git a/src/PyserSSH/system/remotestatus.py b/src/PyserSSH/system/remotestatus.py index 1cb08ba..b6234fd 100644 --- a/src/PyserSSH/system/remotestatus.py +++ b/src/PyserSSH/system/remotestatus.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -35,12 +35,12 @@ import platform from ..interactive import Send -from .info import __version__ +from .info import version if platform.system() == "Windows": import ctypes -logger = logging.getLogger("PyserSSH") +logger = logging.getLogger("PyserSSH.RemoteStatus") class LASTINPUTINFO(ctypes.Structure): _fields_ = [ @@ -221,7 +221,7 @@ def remotestatus(serverself, channel, oneloop=False): Send(channel, "", directchannel=True) Send(channel, "==> /proc/version <==", directchannel=True) - Send(channel, f"PyserSSH v{__version__} run on {platform.platform()} {platform.machine()} {platform.architecture()[0]} with python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} {sys.version_info.releaselevel} {platform.python_build()[0]} {platform.python_build()[1]} {platform.python_compiler()} {platform.python_implementation()} {platform.python_revision()}", directchannel=True) + Send(channel, f"PyserSSH v{version} run on {platform.platform()} {platform.machine()} {platform.architecture()[0]} with python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} {sys.version_info.releaselevel} {platform.python_build()[0]} {platform.python_build()[1]} {platform.python_compiler()} {platform.python_implementation()} {platform.python_revision()}", directchannel=True) Send(channel, "", directchannel=True) Send(channel, "==> /proc/uptime <==", directchannel=True) diff --git a/src/PyserSSH/system/syscom.py b/src/PyserSSH/system/syscom.py index 74f778c..757ace3 100644 --- a/src/PyserSSH/system/syscom.py +++ b/src/PyserSSH/system/syscom.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -26,9 +26,39 @@ """ import shlex -from ..interactive import Send, Clear, Title +from ..interactive import Send, Clear, Title, wait_choose -def systemcommand(client, command): +def system_account_command(client, accounts, action): + banner = "accman adduser \naccman deluser \naccman passwd \naccman list" + try: + if action[0] == "adduser": + accounts.add_account(action[1], action[2]) + Send(client, f"Added {action[1]}") + elif action[0] == "deluser": + if accounts.has_user(action[1]): + if not accounts.is_user_has_sudo(action[1]): + if wait_choose(client, ["No", "Yes"], prompt="Sure? ") == 1: + accounts.remove_account(action[1]) + Send(client, f"Removed {action[1]}") + else: + Send(client, f"{action[1]} isn't removable.") + else: + Send(client, f"{action[1]} not found") + elif action[0] == "passwd": + if accounts.has_user(action[1]): + accounts.change_password(action[1], action[2]) + Send(client, f"Password updated successfully.") + else: + Send(client, f"{action[1]} not found") + elif action[0] == "list": + for user in accounts.list_users(): + Send(client, user) + else: + Send(client, banner) + except: + Send(client, banner) + +def systemcommand(client, command, serverself): if command == "whoami": Send(client, client["current_user"]) return True @@ -37,6 +67,13 @@ def systemcommand(client, command): title = args[1] Title(client, title) return True + elif command.startswith("accman"): + args = shlex.split(command) + if serverself.accounts.is_user_has_sudo(client.current_user): + system_account_command(client, serverself.accounts, args[1:]) + else: + Send(client, "accman: Permission denied.") + return True elif command == "exit": client["channel"].close() return True diff --git a/src/PyserSSH/system/sysfunc.py b/src/PyserSSH/system/sysfunc.py index 9293ce9..2d252d4 100644 --- a/src/PyserSSH/system/sysfunc.py +++ b/src/PyserSSH/system/sysfunc.py @@ -1,6 +1,6 @@ """ PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH -Copyright (C) 2023-2024 DPSoftware Foundation (MIT) +Copyright (C) 2023-present DPSoftware Foundation (MIT) Visit https://github.com/DPSoftware-Foundation/PyserSSH @@ -37,6 +37,7 @@ def replace_enter_with_crlf(input_string): # Replace '\n' with '\r\n' in the string modified_string = decoded_string.replace('\n', '\r\n') # Encode the modified string back to bytes + return modified_string.encode() else: raise TypeError("Input must be a string or bytes") diff --git a/src/PyserSSH/utils/ServerManager.py b/src/PyserSSH/utils/ServerManager.py new file mode 100644 index 0000000..f96400d --- /dev/null +++ b/src/PyserSSH/utils/ServerManager.py @@ -0,0 +1,174 @@ +import time +import logging + +logger = logging.getLogger("PyserSSH.Utils.ServerManager") + +class ServerManager: + def __init__(self): + self.servers = {} + + def add_server(self, name, server, *args, **kwargs): + """ + Adds a server to the manager with the specified name. Raises an error if a server with the same name already exists. + + Args: + name (str): The name of the server. + server (object): The server instance to be added. + *args: Arguments for server initialization. + **kwargs: Keyword arguments for server initialization. + + Raises: + ValueError: If a server with the same name already exists. + """ + if name in self.servers: + raise ValueError(f"Server with name '{name}' already exists.") + self.servers[name] = {"server": server, "args": args, "kwargs": kwargs, "status": "stopped"} + logger.info(f"Server '{name}' added.") + + def remove_server(self, name): + """ + Removes a server from the manager by name. Raises an error if the server does not exist. + + Args: + name (str): The name of the server to be removed. + + Raises: + ValueError: If no server with the specified name exists. + """ + if name not in self.servers: + raise ValueError(f"No server found with name '{name}'.") + del self.servers[name] + logger.info(f"Server '{name}' removed.") + + def get_server(self, name): + """ + Retrieves a server by its name. + + Args: + name (str): The name of the server to retrieve. + + Returns: + dict: A dictionary containing the server instance, arguments, keyword arguments, and its status, or None if the server is not found. + """ + return self.servers.get(name, None) + + def start_server(self, name): + """ + Starts a server with the specified name if it is not already running. Blocks until the server starts. + + Args: + name (str): The name of the server to start. + + Raises: + ValueError: If no server with the specified name exists or the server cannot be started. + """ + server_info = self.get_server(name) + if not server_info: + raise ValueError(f"No server found with name '{name}'.") + + if server_info["status"] == "running": + logger.info(f"Server '{name}' is already running.") + return + + server = server_info["server"] + args, kwargs = server_info["args"], server_info["kwargs"] + + logger.info(f"Starting server '{name}' with arguments {args}...") + server_info["status"] = "starting" + server.run(*args, **kwargs) + + while not server.isrunning: + logger.debug(f"Waiting for server '{name}' to start...") + time.sleep(0.1) + + server_info["status"] = "running" + logger.info(f"Server '{name}' is now running.") + + def stop_server(self, name): + """ + Stops a server with the specified name if it is running. Blocks until the server stops. + + Args: + name (str): The name of the server to stop. + + Raises: + ValueError: If no server with the specified name exists or the server cannot be stopped. + """ + server_info = self.get_server(name) + if not server_info: + raise ValueError(f"No server found with name '{name}'.") + + if server_info["status"] == "stopped": + logger.info(f"Server '{name}' is already stopped.") + return + + server = server_info["server"] + + logger.info(f"Shutting down server '{name}'...") + server_info["status"] = "shutting down" + server.stop_server() + + while server.isrunning: + logger.debug(f"Waiting for server '{name}' to shut down...") + time.sleep(0.1) + + server_info["status"] = "stopped" + logger.info(f"Server '{name}' has been stopped.") + + def start_all_servers(self): + """ + Starts all servers managed by the ServerManager. Blocks until each server starts. + """ + for name, server_info in self.servers.items(): + if server_info["status"] == "running": + logger.info(f"Server '{name}' is already running.") + continue + server, args, kwargs = server_info["server"], server_info["args"], server_info["kwargs"] + logger.info(f"Starting server '{name}' with arguments {args}...") + server_info["status"] = "starting" + server.run(*args, **kwargs) + + while not server.isrunning: + logger.debug(f"Waiting for server '{name}' to start...") + time.sleep(0.1) + + server_info["status"] = "running" + logger.info(f"Server '{name}' is now running.") + + def stop_all_servers(self): + """ + Stops all servers managed by the ServerManager. Blocks until each server stops. + """ + for name, server_info in self.servers.items(): + if server_info["status"] == "stopped": + logger.info(f"Server '{name}' is already stopped.") + continue + server = server_info["server"] + logger.info(f"Shutting down server '{name}'...") + server_info["status"] = "shutting down" + server.stop_server() + + while server.isrunning: + logger.debug(f"Waiting for server '{name}' to shut down...") + time.sleep(0.1) + + server_info["status"] = "stopped" + logger.info(f"Server '{name}' has been stopped.") + + def get_status(self, name): + """ + Retrieves the status of a server by name. + + Args: + name (str): The name of the server to get the status of. + + Returns: + str: The current status of the server (e.g., 'running', 'stopped', etc.). + + Raises: + ValueError: If no server with the specified name exists. + """ + server_info = self.get_server(name) + if not server_info: + raise ValueError(f"No server found with name '{name}'.") + return server_info["status"] diff --git a/src/PyserSSH/utils/keygen.py b/src/PyserSSH/utils/keygen.py new file mode 100644 index 0000000..b606df0 --- /dev/null +++ b/src/PyserSSH/utils/keygen.py @@ -0,0 +1,22 @@ +import paramiko + +def generate_ssh_keypair(private_key_path='id_rsa', public_key_path='id_rsa.pub', key_size=2048): + """ + Generates an SSH key pair (private and public) and saves them to specified files. + + Args: + - private_key_path (str): Path to save the private key. + - public_key_path (str): Path to save the public key. + - key_size (int): Size of the RSA key (default is 2048). + """ + # Generate RSA key pair + private_key = paramiko.RSAKey.generate(key_size) + + # Save the private key to a file + private_key.write_private_key_file(private_key_path) + + # Save the public key to a file + with open(public_key_path, 'w') as pub_file: + pub_file.write(f"{private_key.get_name()} {private_key.get_base64()}") + + print(f"SSH Key pair generated: {private_key_path} and {public_key_path}")