From b6455ce6a380c903f62e8f9e43ebd11c89732598 Mon Sep 17 00:00:00 2001 From: damp11113 Date: Tue, 3 Sep 2024 20:27:12 +0700 Subject: [PATCH] Update 5.0 New main features - ServerManager - RemoDesk protocol support - New client system - Supported auth with none, password and public key - Support remote monitor for mobaxterm user (beta) - SFTP can set specific folder for user - Support exec_request New function - Send_karaoke_effect - ShowCursor - SendBell - NewSend for advance sending like print() - Flag_TH for about - wait_inputmouse for mouse input Fixing - (only python) fixing can't print color without damp11113 library or print color library only in windows on python console - Fixing sometime can't connect SFTP and more --- README.md | 51 +- demo/Save Your Tears lyrics.srt | 157 ++++++ demo/demo1.py | 469 ++++++++++++++++++ demo/demo2.py | 29 ++ {src/PyserSSH/demo => demo}/opensource.png | Bin {src/PyserSSH/demo => demo}/private_key.pem | 0 setup.py | 40 +- src/PyserSSH/__init__.py | 69 ++- src/PyserSSH/account.py | 208 +++++--- src/PyserSSH/demo/demo1.py | 199 -------- src/PyserSSH/extensions/XHandler.py | 129 +++-- src/PyserSSH/extensions/__init__.py | 6 +- src/PyserSSH/extensions/dialog.py | 8 +- src/PyserSSH/extensions/moredisplay.py | 83 +--- .../moreinteractive.py} | 23 +- src/PyserSSH/extensions/processbar.py | 103 +++- src/PyserSSH/extensions/ptop.py | 30 -- src/PyserSSH/extensions/remodesk.py | 294 +++++++++++ src/PyserSSH/extensions/serverutils.py | 102 ++++ src/PyserSSH/interactive.py | 98 +++- src/PyserSSH/server.py | 303 +++++------ src/PyserSSH/system/SFTP.py | 67 ++- src/PyserSSH/system/__init__.py | 6 +- src/PyserSSH/system/clientype.py | 143 ++++++ src/PyserSSH/system/info.py | 35 +- src/PyserSSH/system/inputsystem.py | 32 +- src/PyserSSH/system/interface.py | 177 ++++++- src/PyserSSH/system/remotestatus.py | 271 ++++++++++ src/PyserSSH/system/syscom.py | 6 +- src/PyserSSH/system/sysfunc.py | 22 +- 30 files changed, 2445 insertions(+), 715 deletions(-) create mode 100644 demo/Save Your Tears lyrics.srt create mode 100644 demo/demo1.py create mode 100644 demo/demo2.py rename {src/PyserSSH/demo => demo}/opensource.png (100%) rename {src/PyserSSH/demo => demo}/private_key.pem (100%) delete mode 100644 src/PyserSSH/demo/demo1.py rename src/PyserSSH/{demo/__init__.py => extensions/moreinteractive.py} (74%) delete mode 100644 src/PyserSSH/extensions/ptop.py create mode 100644 src/PyserSSH/extensions/remodesk.py create mode 100644 src/PyserSSH/extensions/serverutils.py create mode 100644 src/PyserSSH/system/clientype.py create mode 100644 src/PyserSSH/system/remotestatus.py diff --git a/README.md b/README.md index 145269b..83de008 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,40 @@ # What is PyserSSH -PyserSSH is a library for remote control your code with ssh client. The aim is to provide a scriptable SSH server which can be made to behave like any SSH-enabled device. +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. This project is part from [damp11113-library](https://github.com/damp11113/damp11113-library) -This Server use port **2222** for default port +## Some small PyserSSH history +PyserSSH version [1.0](https://github.com/DPSoftware-Foundation/PyserSSH/releases/download/Legacy/PyserSSH10.py) (real filename is "test277.py") was created in 2023/9/3 for experimental purposes only. Because I couldn't find the best ssh server library for python and I started this project only for research. But I have time to develop this research into a real library for use. In software or server. -> [!WARNING] -> For use in product please **generate new private key**! If you still use this demo private key maybe your product getting **hacked**! up to 90%. Please don't use this demo private key for real product. +read full history from [docs](https://damp11113.xyz/PyserSSHDocs/history.html) # Install Install from pypi ```bash pip install PyserSSH ``` -Install from github +Install with [openRemoDesk](https://github.com/DPSoftware-Foundation/openRemoDesk) protocol +```bash +pip install PyserSSH[RemoDesk] +``` +Install from Github ```bash pip install git+https://github.com/damp11113/PyserSSH.git ``` +Install from DPCloudev Git +```bash +pip install git+https://git.damp11113.xyz/DPSoftware-Foundation/PyserSSH.git +``` # Quick Example +This Server use port **2222** for default port ```py -import os - from PyserSSH import Server, Send, AccountManager -useraccount = AccountManager() -useraccount.add_account("admin", "") # create user without password - +useraccount = AccountManager(anyuser=True) ssh = Server(useraccount) @ssh.on_user("command") @@ -35,32 +42,16 @@ def command(client, command: str): if command == "hello": Send(client, "world!") -ssh.run(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'private_key.pem')) +ssh.run("your private key file") ``` This example you can connect with `ssh admin@localhost -p 2222` and press enter on login If you input `hello` the response is `world` # Demo -https://github.com/damp11113/PyserSSH/assets/64675096/49bef3e2-3b15-4b64-b88e-3ca84a955de7 +> [!WARNING] +> For use in product please **generate new private key**! If you still use this demo private key maybe your product getting **hacked**! up to 90%. Please don't use this demo private key for real product. -For run this demo you can use this command -``` -$ python -m PyserSSH -``` -then -``` -Do you want to run demo? (y/n): y -``` -But if no [damp11113-library](https://github.com/damp11113/damp11113-library) -``` -No 'damp11113-library' -This demo is require 'damp11113-library' for run -``` -you need to install [damp11113-library](https://github.com/damp11113/damp11113-library) for run this demo by choose `y` or `yes` in lowercase or uppercase -``` -Do you want to install 'damp11113-library'? (y/n): y -``` -For exit demo you can use `ctrl+c` or use `shutdown now` in PyserSSH shell **(not in real terminal)** +https://github.com/damp11113/PyserSSH/assets/64675096/49bef3e2-3b15-4b64-b88e-3ca84a955de7 I intend to leaked private key because that key i generated new. I recommend to generate new key if you want to use on your host because that key is for demo only. why i talk about this? because when i push private key into this repo in next 5 min++ i getting new email from GitGuardian. in that email say " diff --git a/demo/Save Your Tears lyrics.srt b/demo/Save Your Tears lyrics.srt new file mode 100644 index 0000000..6f745df --- /dev/null +++ b/demo/Save Your Tears lyrics.srt @@ -0,0 +1,157 @@ +0 +00:00:00,000 --> 00:00:09,200 +(Intro) + +1 +00:00:09,200 --> 00:00:13,100 +I saw you dancing in a crowded room + +2 +00:00:13,200 --> 00:00:17,200 +You look so happy when +I'm not with you + +3 +00:00:17,300 --> 00:00:21,300 +But then you saw me, caught +you by surprise + +4 +00:00:21,400 --> 00:00:26,200 +A single teardrop falling +from your eye + +5 +00:00:26,300 --> 00:00:31,400 +I don't know why I run away + +6 +00:00:34,400 --> 00:00:40,900 +I'll make you cry when I run away + +7 +00:00:41,700 --> 00:00:45,950 +You could've asked me why +I broke your heart + +8 +00:00:46,000 --> 00:00:49,800 +You could've told me +that you fell apart + +9 +00:00:49,900 --> 00:00:53,700 +But you walked past me +like I wasn't there + +10 +00:00:53,800 --> 00:00:58,450 +And just pretended like +you didn't care + +11 +00:00:58,500 --> 00:01:03,000 +I don't know why I run away + +12 +00:01:06,400 --> 00:01:11,300 +I'll make you cry when I run away + +13 +00:01:14,700 --> 00:01:18,600 +Take me back 'cause I wanna stay + +14 +00:01:18,700 --> 00:01:21,600 +Save your tears for another + +15 +00:01:21,700 --> 00:01:28,900 +Save your tears for another day + +16 +00:01:29,800 --> 00:01:34,700 +Save your tears for another day + +17 +00:01:37,000 --> 00:01:42,900 +So, I made you think that +I would always stay + +18 +00:01:43,000 --> 00:01:46,600 +I said some things that +I should never say + +19 +00:01:46,700 --> 00:01:50,600 +Yeah, I broke your heart like +someone did to mine + +20 +00:01:50,700 --> 00:01:55,500 +And now you won't love +me for a second time + +21 +00:01:55,600 --> 00:02:00,400 +I don't know why I run away, oh, girl + +22 +00:02:03,300 --> 00:02:08,800 +Said I make you cry when I run away + +23 +00:02:11,500 --> 00:02:15,600 +Girl, take me back 'cause I wanna stay + +24 +00:02:15,700 --> 00:02:19,200 +Save your tears for another + +25 +00:02:19,300 --> 00:02:23,600 +I realize that I'm much too late + +26 +00:02:23,700 --> 00:02:26,800 +And you deserve someone better + +27 +00:02:26,900 --> 00:02:32,000 +Save your tears for another +day (Ooh, yeah) + +28 +00:02:34,900 --> 00:02:40,900 +Save your tears for another day (Yeah) + +29 +00:02:46,100 --> 00:02:52,300 +I don't know why I run away + +30 +00:02:52,700 --> 00:02:57,400 +I'll make you cry when I run away + +31 +00:02:59,400 --> 00:03:06,650 +Save your tears for another +day, ooh, girl (Ah) + +32 +00:03:06,700 --> 00:03:13,600 +I said save your tears +for another day (Ah) + +33 +00:03:15,700 --> 00:03:23,200 +Save your tears for another day (Ah) + +34 +00:03:23,700 --> 00:03:33,300 +Save your tears for another day (Ah) + +35 +00:03:34,300 --> 00:03:43,300 +by DPSoftware Foundation diff --git a/demo/demo1.py b/demo/demo1.py new file mode 100644 index 0000000..86dd8a9 --- /dev/null +++ b/demo/demo1.py @@ -0,0 +1,469 @@ +import os +import socket +import time +import cv2 +import traceback +import requests +from bs4 import BeautifulSoup +import numpy as np + +#import logging +#logging.basicConfig(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.extensions.processbar import indeterminateStatus, LoadingProgress +from PyserSSH.extensions.dialog import MenuDialog, TextDialog, TextInputDialog +from PyserSSH.extensions.moredisplay import clickable_url, Send_karaoke_effect +from PyserSSH.extensions.moreinteractive import ShowCursor +from PyserSSH.extensions.remodesk import RemoDesk +from PyserSSH.extensions.XHandler import XHandler +from PyserSSH.system.clientype import Client +from PyserSSH.system.remotestatus import remotestatus + +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) + +XH = XHandler() +ssh = Server(useraccount, + system_commands=True, + system_message=False, + sftp=True, + enable_preauth_banner=True, + XHandler=XH) + +remotedesktopserver = RemoDesk() + +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 + +@ssh.on_user("pre-shell") +def guestauth(client): + if client.get_name() == "remote": + return + + if not useraccount.has_user(client.get_name()): + while True: + Clear(client) + Send(client, f"You are currently logged in as a guest. To access, please login or register.\nYour current account: {client.get_name()}\n") + method = wait_choose(client, ["Login", "Register", "Exit"], prompt="Action: ") + Clear(client) + if method == 0: # login + username = wait_input(client, "Username: ", noabort=True) + + if not username: + Send(client, "Please Enter username") + wait_inputkey(client, "Press any key to continue...") + continue + + password = wait_input(client, "Password: ", password=True, noabort=True) + + Send(client, "Please wait...") + if not useraccount.has_user(username): + Send(client, f"Username isn't exist. Please try again") + wait_inputkey(client, "Press any key to continue...") + continue + + if not useraccount.validate_credentials(username, password): + Send(client, f"Password incorrect. Please try again") + wait_inputkey(client, "Press any key to continue...") + continue + + Clear(client) + client.switch_user(username) + break + elif method == 1: # register + username = wait_input(client, "Please choose a username: ", noabort=True) + if not username: + Send(client, "Please Enter username") + wait_inputkey(client, "Press any key to continue...") + continue + + if useraccount.has_user(username): + Send(client, f"Username is exist. Please try again") + wait_inputkey(client, "Press any key to continue...") + continue + + password = wait_input(client, "Password: ", password=True, noabort=True) + + if not password: + Send(client, "Please Enter password") + wait_inputkey(client, "Press any key to continue...") + continue + + confirmpassword = wait_input(client, "Confirm Password: ", password=True, noabort=True) + + if not password: + Send(client, "Please Enter confirm password") + wait_inputkey(client, "Press any key to continue...") + continue + + if password != confirmpassword: + Send(client, "Password do not matching the confirm password. Please try again.") + wait_inputkey(client, "Press any key to continue...") + continue + + Send(client, "Please wait...") + useraccount.add_account(username, password, ["user"]) + client.switch_user(username) + Clear(client) + break + else: + client.close() + +@ssh.on_user("connect") +def connect(client): + if client.get_name() == "remote": + return + + client.set_prompt(client["current_user"] + "@" + servername + ":~$") + + wm = f"""{Flag_TH()}{'–'*50} +Hello {client['current_user']}, + +This is testing server of PyserSSH v{__version__}. + +Visit: {clickable_url("https://damp11113.xyz", "DPCloudev")} +{'–'*50}""" + + for i in loading: + P = indeterminateStatus(client, f"Starting {i}", f"[ OK ] Started {i}") + P.start() + + time.sleep(len(i) / 20) + + P.stop() + + Di1 = TextDialog(client, "Welcome!\n to PyserSSH test server", "PyserSSH Extension") + Di1.render() + + for char in wm: + Send(client, char, ln=False) + #time.sleep(0.005) # Adjust the delay as needed + Send(client, '\n') # Send newline after each line + +@ssh.on_user("authbanner") +def banner(tmp): + return "Hello World!\n", "en" + +@ssh.on_user("error") +def error(client, error): + if isinstance(error, socket.error): + pass + else: + Send(client, traceback.format_exc()) + +@XH.command(name="startremotedesktop", category="Remote", permissions=["remote_desktop"]) +def remotedesktop(client): + remotedesktopserver.handle_new_client(client) + +@XH.command(name="passtest", category="Test Function") +def xh_passtest(client): + user = wait_input(client, "username: ") + password = wait_input(client, "password: ", password=True) + Send(client, f"username: {user} | password: {password}") + +@XH.command(name="colortest", category="Test Function") +def xh_colortest(client): + for i in range(0, 255, 5): + Send(client, TextFormatter.format_text_truecolor(" ", background=f"{i};0;0"), ln=False) + Send(client, "") + for i in range(0, 255, 5): + Send(client, TextFormatter.format_text_truecolor(" ", background=f"0;{i};0"), ln=False) + Send(client, "") + for i in range(0, 255, 5): + Send(client, TextFormatter.format_text_truecolor(" ", background=f"0;0;{i}"), ln=False) + Send(client, "") + Send(client, "TrueColors 24-Bit") + +@XH.command(name="keytest", category="Test Function") +def xh_keytest(client: Client): + user = wait_inputkey(client, "press any key", raw=True, timeout=1) + Send(client, "") + Send(client, f"key: {user}") + for i in range(10): + user = wait_inputkey(client, "press any key", raw=True, timeout=1) + Send(client, "") + Send(client, f"key: {user}") + +@XH.command(name="typing") +def xh_typing(client: Client, messages, speed = 1): + for w in messages: + Send(client, w, ln=False) + time.sleep(float(speed)) + Send(client, "") + +@XH.command(name="renimtest") +def xh_renimtest(client: Client, path: str): + Clear(client) + image = cv2.imread(f"opensource.png", cv2.IMREAD_COLOR) + image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + width, height = client['windowsize']["width"] - 5, client['windowsize']["height"] - 5 + + # resize image + resized = cv2.resize(image, (width, height)) + t = "" + + # Scan all pixels + for y in range(0, height): + for x in range(0, width): + pixel_color = resized[y, x] + if pixel_color.tolist() != [0, 0, 0]: + t += TextFormatter.format_text_truecolor(" ", + background=f"{pixel_color[0]};{pixel_color[1]};{pixel_color[2]}") + else: + t += " " + + Send(client, t, ln=False) + Send(client, "") + t = "" + +@XH.command(name="errortest", category="Test Function") +def xh_errortest(client: Client): + raise Exception("hello error") + +@XH.command(name="inloadtest", category="Test Function") +def xh_inloadtest(client: Client): + loading = indeterminateStatus(client) + loading.start() + time.sleep(5) + loading.stop() + +@XH.command(name="loadtest", category="Test Function") +def xh_loadtest(client: Client): + l = LoadingProgress(client, total=100, color=True) + l.start() + for i in range(101): + l.current = i + l.status = f"loading {i}" + time.sleep(0.05) + l.stop() + +@XH.command(name="dialogtest", category="Test Function") +def xh_dialogtest(client: Client): + Di1 = TextDialog(client, "Hello Dialog!", "PyserSSH Extension") + Di1.render() + +@XH.command(name="dialogtest2", category="Test Function") +def xh_dialogtest2(client: Client): + Di2 = MenuDialog(client, ["H1", "H2", "H3"], "PyserSSH Extension", "Hello world") + Di2.render() + Send(client, f"selected index: {Di2.output()}") + +@XH.command(name="dialogtest3", category="Test Function") +def xh_dialogtest3(client: Client): + Di3 = TextInputDialog(client, "PyserSSH Extension") + Di3.render() + Send(client, f"input: {Di3.output()}") + +@XH.command(name="passdialogtest3", category="Test Function") +def xh_passdialogtest3(client: Client): + Di3 = TextInputDialog(client, "PyserSSH Extension", inputtitle="Password Here", password=True) + Di3.render() + Send(client, f"password: {Di3.output()}") + +@XH.command(name="choosetest", category="Test Function") +def xh_choosetest(client: Client): + mylist = ["H1", "H2", "H3"] + cindex = wait_choose(client, mylist, "select: ") + Send(client, f"selected: {mylist[cindex]}") + +@XH.command(name="vieweb") +def xh_vieweb(client: Client, url: str): + loading = indeterminateStatus(client, desc=f"requesting {url}...") + loading.start() + try: + content = requests.get(url).content + except: + loading.stopfail() + return + loading.stop() + loading = indeterminateStatus(client, desc=f"parsing html {url}...") + loading.start() + try: + soup = BeautifulSoup(content, 'html.parser') + # Extract only the text content + text_content = soup.get_text() + except: + loading.stopfail() + return + loading.stop() + Di1 = TextDialog(client, text_content, url) + Di1.render() + +@XH.command(name="shutdown") +def xh_shutdown(client: Client, at: str): + if at == "now": + ssh.stop_server() + +@XH.command(name="mouseinput", category="Test Function") +def xh_mouseinput(client: Client): + for i in range(10): + button, x, y = wait_inputmouse(client) + if button == 0: + Send(client, "Left Button") + elif button == 1: + Send(client, "Middle Button") + elif button == 2: + Send(client, "Right Button") + elif button == 3: + Send(client, "Button Up") + + Send(client, f"Current POS: X {x} | Y {y} with button {button}") + +@XH.command(name="karaoke") +def xh_karaoke(client: Client): + ShowCursor(client, False) + Send_karaoke_effect(client, "Python can print like karaoke!") + ShowCursor(client) + +R1 = 1 +R2 = 2 +K2 = 5 + +theta_spacing = 0.07 +phi_spacing = 0.02 + +illumination = np.fromiter(".,-~:;=!*#$@", dtype=" np.ndarray: + K1 = screen_size * K2 * 3 / (8 * (R1 + R2)) + """ + Returns a frame of the spinning 3D donut. + Based on the pseudocode from: https://www.a1k0n.net/2011/07/20/donut-math.html + """ + cos_A = np.cos(A) + sin_A = np.sin(A) + cos_B = np.cos(B) + sin_B = np.sin(B) + + output = np.full((screen_size, screen_size), " ") # (40, 40) + zbuffer = np.zeros((screen_size, screen_size)) # (40, 40) + + cos_phi = np.cos(phi := np.arange(0, 2 * np.pi, phi_spacing)) # (315,) + sin_phi = np.sin(phi) # (315,) + cos_theta = np.cos(theta := np.arange(0, 2 * np.pi, theta_spacing)) # (90,) + sin_theta = np.sin(theta) # (90,) + circle_x = R2 + R1 * cos_theta # (90,) + circle_y = R1 * sin_theta # (90,) + + x = (np.outer(cos_B * cos_phi + sin_A * sin_B * sin_phi, circle_x) - circle_y * cos_A * sin_B).T # (90, 315) + y = (np.outer(sin_B * cos_phi - sin_A * cos_B * sin_phi, circle_x) + circle_y * cos_A * cos_B).T # (90, 315) + z = ((K2 + cos_A * np.outer(sin_phi, circle_x)) + circle_y * sin_A).T # (90, 315) + ooz = np.reciprocal(z) # Calculates 1/z + xp = (screen_size / 2 + K1 * ooz * x).astype(int) # (90, 315) + yp = (screen_size / 2 - K1 * ooz * y).astype(int) # (90, 315) + L1 = (((np.outer(cos_phi, cos_theta) * sin_B) - cos_A * np.outer(sin_phi, cos_theta)) - sin_A * sin_theta) # (315, 90) + L2 = cos_B * (cos_A * sin_theta - np.outer(sin_phi, cos_theta * sin_A)) # (315, 90) + L = np.around(((L1 + L2) * 8)).astype(int).T # (90, 315) + mask_L = L >= 0 # (90, 315) + chars = illumination[L] # (90, 315) + + for i in range(90): + mask = mask_L[i] & (ooz[i] > zbuffer[xp[i], yp[i]]) # (315,) + + zbuffer[xp[i], yp[i]] = np.where(mask, ooz[i], zbuffer[xp[i], yp[i]]) + output[xp[i], yp[i]] = np.where(mask, chars[i], output[xp[i], yp[i]]) + + return output + +@XH.command() +def donut(client, screen_size=40): + screen_size = int(screen_size) + + A = 1 + B = 1 + + for _ in range(screen_size * screen_size): + A += theta_spacing + B += phi_spacing + Clear(client) + array = render_frame(A, B, screen_size) + for row in array: + Send(client, " ".join(row)) + Send(client, "\n") + + if wait_inputkey(client, raw=True, timeout=0.01) == "\x03": break + + +@XH.command(name="status") +def xh_status(client: Client): + remotestatus(ssh, client.channel, True) + +#@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')) + +#manager = ServerManager() + +# Add servers to the manager +#manager.add_server("server1", server1) + +# Start a specific server +#manager.start_server("server1", private_key_path="key") diff --git a/demo/demo2.py b/demo/demo2.py new file mode 100644 index 0000000..2b9ad31 --- /dev/null +++ b/demo/demo2.py @@ -0,0 +1,29 @@ +import os +import time +from damp11113 import SRTParser + +from PyserSSH import Server, Send, AccountManager +from PyserSSH.extensions.XHandler import XHandler +from PyserSSH.extensions.moredisplay import Send_karaoke_effect, ShowCursor + +accountmanager = AccountManager() +accountmanager.add_account("admin", "") + +XH = XHandler() +server = Server(accountmanager, XHandler=XH) + +@XH.command() +def karaoke(client): + ShowCursor(client, False) + subtitle = SRTParser("Save Your Tears lyrics.srt", removeln=True) + + for sub in subtitle: + delay = sub["duration"] / len(sub["text"]) + Send_karaoke_effect(client, sub["text"], delay) + + if sub["next_text_duration"] is not None: + time.sleep(sub["next_text_duration"]) + + ShowCursor(client) + +server.run(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'private_key.pem')) \ No newline at end of file diff --git a/src/PyserSSH/demo/opensource.png b/demo/opensource.png similarity index 100% rename from src/PyserSSH/demo/opensource.png rename to demo/opensource.png diff --git a/src/PyserSSH/demo/private_key.pem b/demo/private_key.pem similarity index 100% rename from src/PyserSSH/demo/private_key.pem rename to demo/private_key.pem diff --git a/setup.py b/setup.py index 46c6856..b692125 100644 --- a/setup.py +++ b/setup.py @@ -1,21 +1,43 @@ from setuptools import setup, find_packages -with open('README.md', 'r', encoding='utf-8') as f: - long_description = f.read() - setup( name='PyserSSH', - version='4.4', + version='5.0', license='MIT', - author='damp11113', - author_email='damp51252@gmail.com', + author='DPSoftware Foundation', + author_email='contact@damp11113.xyz', packages=find_packages('src'), package_dir={'': 'src'}, url='https://github.com/damp11113/PyserSSH', description="python scriptable ssh server library. based on Paramiko", - long_description=long_description, + long_description=open('README.md', 'r', encoding='utf-8').read(), long_description_content_type='text/markdown', + keywords="SSH server", + python_requires='>=3.6', + classifiers=[ + "Development Status :: 5 - Production/Stable" + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators" + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.10", + "Topic :: Communications", + "Topic :: Internet", + "Topic :: Internet :: File Transfer Protocol (FTP)", + "Topic :: Software Development", + "Topic :: Terminals" + ], install_requires=[ - "paramiko" - ] + "paramiko", + "psutil" + ], + extras_require={ + 'RemoDesk': [ + "mouse", + "keyboard", + "Brotli", + "pillow", + "numpy" + ], + } ) \ No newline at end of file diff --git a/src/PyserSSH/__init__.py b/src/PyserSSH/__init__.py index 8310b8a..ffbaa19 100644 --- a/src/PyserSSH/__init__.py +++ b/src/PyserSSH/__init__.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -37,40 +37,75 @@ right - \x1b[C 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 +if os.name == 'nt': + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + try: os.environ["pyserssh_systemmessage"] except: os.environ["pyserssh_systemmessage"] = "YES" -try: - os.environ["pyserssh_enable_damp11113"] -except: - os.environ["pyserssh_enable_damp11113"] = "YES" - try: os.environ["pyserssh_log"] except: os.environ["pyserssh_log"] = "NO" if os.environ["pyserssh_log"] == "NO": + logging.basicConfig(level=logging.CRITICAL) logger = logging.getLogger("PyserSSH") - logger.disabled = True + #logger.disabled = False if os.environ["pyserssh_systemmessage"] == "YES": print(system_banner) -if __name__ == "__main__": - stadem = input("Do you want to run demo? (y/n): ") - if stadem.upper() in ["Y", "YES"]: - from .demo import demo1 - else: - exit() \ No newline at end of file +# 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 diff --git a/src/PyserSSH/account.py b/src/PyserSSH/account.py index be8aa12..756f422 100644 --- a/src/PyserSSH/account.py +++ b/src/PyserSSH/account.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -24,30 +24,28 @@ 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 os import pickle import time import atexit import threading +import hashlib class AccountManager: - def __init__(self, anyuser=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, autoloadfile="autosave_session.ses"): self.accounts = {} - self.anyuser = anyuser + self.allow_guest = allow_guest self.historylimit = historylimit self.autosavedelay = autosavedelay self.__autosavework = False self.__autosaveworknexttime = 0 - if self.anyuser: - print("history system can't work if 'anyuser' is enable") - if autoload: self.load(autoloadfile) if autosave: - self.__autosavethread = threading.Thread(target=self.__autosave) + self.__autosavethread = threading.Thread(target=self.__autosave, daemon=True) self.__autosavethread.start() atexit.register(self.__saveexit) @@ -67,37 +65,89 @@ class AccountManager: self.save("autosave_session.ses") self.__autosavethread.join() - def validate_credentials(self, username, password): - if username in self.accounts and self.accounts[username]["password"] == password or self.anyuser: + def validate_credentials(self, username, password=None, public_key=None): + if self.allow_guest and not self.has_user(username): return True + + allowed_auth_list = str(self.accounts[username].get("allowed_auth", "")).split(",") + + # Check password authentication + if password is not None and "password" in allowed_auth_list: + stored_password = self.accounts[username].get("password", "") + return stored_password == hashlib.md5(password.encode()).hexdigest() + + # Check public key authentication + if public_key is not None and "publickey" in allowed_auth_list: + stored_public_key = self.accounts[username].get("public_key", "") + return stored_public_key == public_key + + # Check if 'none' authentication is allowed + if "none" in allowed_auth_list: + return True + return False + def has_user(self, username): + return username in self.accounts + + def get_allowed_auths(self, username): + if self.has_user(username) and "allowed_auth" in self.accounts[username]: + return self.accounts[username]["allowed_auth"] + return "none" + def get_permissions(self, username): - if username in self.accounts: + if self.has_user(username): return self.accounts[username]["permissions"] return [] def set_prompt(self, username, prompt=">"): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["prompt"] = prompt def get_prompt(self, username): - if username in self.accounts and "prompt" in self.accounts[username]: + if self.has_user(username) and "prompt" in self.accounts[username]: return self.accounts[username]["prompt"] return ">" # Default prompt if not set for the user - def add_account(self, username, password, permissions={}): - self.accounts[username] = {"password": password, "permissions": permissions} + def add_account(self, username, password=None, public_key=None, permissions:list=None): + if not self.has_user(username): + allowedlist = [] + accountkey = {} + + if permissions is None: + permissions = [] + + if password != None: + allowedlist.append("password") + accountkey["password"] = hashlib.md5(password.encode()).hexdigest() + + if public_key != None: + allowedlist.append("publickey") + accountkey["public_key"] = public_key + + if password is None and public_key is None: + allowedlist.append("none") + + accountkey["permissions"] = permissions + accountkey["allowed_auth"] = ",".join(allowedlist) + + self.accounts[username] = accountkey + else: + raise Exception(f"{username} is exist") + + def remove_account(self, username): + if self.has_user(username): + del self.accounts[username] def change_password(self, username, new_password): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["password"] = new_password def set_permissions(self, username, new_permissions): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["permissions"] = new_permissions - def save(self, filename="session.ssh"): + def save(self, filename="session.ses"): with open(filename, 'wb') as file: pickle.dump(self.accounts, file) @@ -111,103 +161,115 @@ class AccountManager: print(f"An error occurred: {e}. No accounts loaded.") def set_user_sftp_allow(self, username, allow=True): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["sftp_allow"] = allow def get_user_sftp_allow(self, username): - if username in self.accounts and "sftp_allow" in self.accounts[username]: - if self.anyuser: - return True + if self.has_user(username) and "sftp_allow" in self.accounts[username]: return self.accounts[username]["sftp_allow"] - return True + return False def set_user_sftp_readonly(self, username, readonly=False): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["sftp_readonly"] = readonly def get_user_sftp_readonly(self, username): - if username in self.accounts and "sftp_readonly" in self.accounts[username]: + if self.has_user(username) and "sftp_readonly" in self.accounts[username]: return self.accounts[username]["sftp_readonly"] return False - def set_user_sftp_path(self, username, path="/"): - if username in self.accounts: + def set_user_sftp_root_path(self, username, path="/"): + if self.has_user(username): if path == "/": - self.accounts[username]["sftp_path"] = "" + self.accounts[username]["sftp_root_path"] = os.getcwd() else: - self.accounts[username]["sftp_path"] = path + self.accounts[username]["sftp_root_path"] = path - def get_user_sftp_path(self, username): - if username in self.accounts and "sftp_path" in self.accounts[username]: - return self.accounts[username]["sftp_path"] - return "" + def get_user_sftp_root_path(self, username): + if self.has_user(username) and "sftp_root_path" in self.accounts[username]: + return self.accounts[username]["sftp_root_path"] + return os.getcwd() + + def set_user_enable_inputsystem(self, username, enable=True): + if self.has_user(username): + self.accounts[username]["inputsystem"] = enable + + def get_user_enable_inputsystem(self, username): + if self.has_user(username) and "inputsystem" in self.accounts[username]: + return self.accounts[username]["inputsystem"] + return True + + def set_user_enable_inputsystem_echo(self, username, echo=True): + if self.has_user(username): + self.accounts[username]["inputsystem_echo"] = echo + + def get_user_enable_inputsystem_echo(self, username): + if self.has_user(username) and "inputsystem_echo" in self.accounts[username]: + return self.accounts[username]["inputsystem_echo"] + return True def set_banner(self, username, banner): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["banner"] = banner def get_banner(self, username): - if username in self.accounts and "banner" in self.accounts[username]: + if self.has_user(username) and "banner" in self.accounts[username]: return self.accounts[username]["banner"] return None def get_user_timeout(self, username): - if username in self.accounts and "timeout" in self.accounts[username]: + if self.has_user(username) and "timeout" in self.accounts[username]: return self.accounts[username]["timeout"] return None def set_user_timeout(self, username, timeout=None): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["timeout"] = timeout def get_user_last_login(self, username): - if username in self.accounts and "lastlogin" in self.accounts[username]: + if self.has_user(username) and "lastlogin" in self.accounts[username]: return self.accounts[username]["lastlogin"] return None def set_user_last_login(self, username, ip, timelogin=time.time()): - if username in self.accounts: + if self.has_user(username): self.accounts[username]["lastlogin"] = { "ip": ip, "time": timelogin } def add_history(self, username, command): - if not self.anyuser: - if username in self.accounts: - if "history" not in self.accounts[username]: - self.accounts[username]["history"] = [] # Initialize history list if it doesn't exist - - history_limit = self.historylimit if self.historylimit is not None else float('inf') - self.accounts[username]["history"].append(command) - self.accounts[username]["lastcommand"] = command - # Trim history to the specified limit - if self.historylimit != None: - if len(self.accounts[username]["history"]) > history_limit: - self.accounts[username]["history"] = self.accounts[username]["history"][-history_limit:] - - def clear_history(self, username): - if not self.anyuser: - if username in self.accounts: + if self.has_user(username): + if "history" not in self.accounts[username]: self.accounts[username]["history"] = [] # Initialize history list if it doesn't exist + history_limit = self.historylimit if self.historylimit is not None else float('inf') + self.accounts[username]["history"].append(command) + self.accounts[username]["lastcommand"] = command + # Trim history to the specified limit + if self.historylimit != None: + if len(self.accounts[username]["history"]) > history_limit: + self.accounts[username]["history"] = self.accounts[username]["history"][-history_limit:] + + def clear_history(self, username): + if self.has_user(username): + self.accounts[username]["history"] = [] # Initialize history list if it doesn't exist + def get_history(self, username, index, getall=False): - if not self.anyuser: - if username in self.accounts and "history" in self.accounts[username]: - history = self.accounts[username]["history"] - history.reverse() - if getall: - return history + if self.has_user(username) and "history" in self.accounts[username]: + history = self.accounts[username]["history"] + history.reverse() + if getall: + return history + else: + if index < len(history): + return history[index] else: - if index < len(history): - return history[index] - else: - return None # Index out of range - return None # User or history not found + return None # Index out of range + return None # User or history not found def get_lastcommand(self, username): - if not self.anyuser: - if username in self.accounts and "lastcommand" in self.accounts[username]: - command = self.accounts[username]["lastcommand"] - return command - return None # User or history not found + if self.has_user(username) and "lastcommand" in self.accounts[username]: + command = self.accounts[username]["lastcommand"] + return command + return None # User or history not found diff --git a/src/PyserSSH/demo/demo1.py b/src/PyserSSH/demo/demo1.py deleted file mode 100644 index 44d7408..0000000 --- a/src/PyserSSH/demo/demo1.py +++ /dev/null @@ -1,199 +0,0 @@ -import os -import socket -import time -import shlex -import cv2 -import traceback -import requests -from bs4 import BeautifulSoup -import pyfiglet - -from ..server import Server -from ..account import AccountManager -from ..interactive import Send, Clear, wait_input, wait_inputkey, wait_choose -from ..system.info import system_banner, __version__ -from ..extensions.processbar import (indeterminateStatus, LoadingProgress) -from ..extensions.dialog import MenuDialog, TextDialog, TextInputDialog -from ..extensions.moredisplay import clickable_url - -try: - from damp11113 import TextFormatter -except: - print("No 'damp11113-library'") - print("This demo is require 'damp11113-library' for run") - ins = input("Do you want to install 'damp11113-library'? (y/n): ") - if ins.upper() in ["Y", "YES"]: - import pip - pip.main(["install", "damp11113"]) - from damp11113 import TextFormatter - else: - exit() - -useraccount = AccountManager() -useraccount.add_account("admin", "") # create user without password - -ssh = Server(useraccount, system_commands=True, system_message=False, sftp=False) - -loading = ["PyserSSH", "Extensions"] - -print("you connect to this demo using 'ssh admin@localhost -p 2222' (no password)") -print("command list: passtest, colortest, typing , renimtest, errortest, inloadtest, loadtest, dialogtest, dialogtest2, dialogtest3, passdialogtest3, choosetest, vieweb , shutdown now") -print("Do not you this demo private key for real production") - -@ssh.on_user("connect") -def connect(client): - wm = f"""{pyfiglet.figlet_format('PyserSSH', font='usaflag', width=client["windowsize"]["width"])}********************************************************************************************* -Hello {client['current_user']}, - -This is the testing server of PyserSSH v{__version__}. -For use in product please use new private key. - -Visit: {clickable_url("https://damp11113.xyz", "DPCloudev")} - -{system_banner} -*********************************************************************************************""" - - for i in loading: - P = indeterminateStatus(client, f"Starting {i}", f"[ OK ] Started {i}") - P.start() - - time.sleep(len(i) / 20) - - P.stop() - - Di1 = TextDialog(client, "PyserSSH Extension", "Welcome!\n to PyserSSH test server") - Di1.render() - - for char in wm: - Send(client, char, ln=False) - # time.sleep(0.005) # Adjust the delay as needed - Send(client, '\n') # Send newline after each line - -@ssh.on_user("error") -def error(client, error): - if isinstance(error, socket.error): - pass - else: - Send(client, traceback.format_exc()) - -#@ssh.on_user("onrawtype") -#def onrawtype(client, key): -# print(key) - -@ssh.on_user("command") -def command(client, command: str): - if command == "passtest": - user = wait_input(client, "username: ") - password = wait_input(client, "password: ", password=True) - Send(client, f"username: {user} | password: {password}") - elif command == "colortest": - for i in range(0, 255, 5): - Send(client, TextFormatter.format_text_truecolor(" ", background=f"{i};0;0"), ln=False) - Send(client, "") - for i in range(0, 255, 5): - Send(client, TextFormatter.format_text_truecolor(" ", background=f"0;{i};0"), ln=False) - Send(client, "") - for i in range(0, 255, 5): - Send(client, TextFormatter.format_text_truecolor(" ", background=f"0;0;{i}"), ln=False) - Send(client, "") - Send(client, "TrueColors 24-Bit") - elif command == "keytest": - user = wait_inputkey(client, "press any key", raw=True, timeout=1) - Send(client, "") - Send(client, f"key: {user}") - for i in range(10): - user = wait_inputkey(client, "press any key", raw=True, timeout=1) - Send(client, "") - Send(client, f"key: {user}") - elif command.startswith("typing"): - args = shlex.split(command) - messages = args[1] - speed = float(args[2]) - for w in messages: - Send(client, w, ln=False) - time.sleep(speed) - Send(client, "") - elif command == "renimtest": - Clear(client) - image = cv2.imread(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'opensource.png'), cv2.IMREAD_COLOR) - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - - width, height = client['windowsize']["width"]-5, client['windowsize']["height"]-5 - - # resize image - resized = cv2.resize(image, (width, height)) - t = "" - - # Scan all pixels - for y in range(0, height): - for x in range(0, width): - pixel_color = resized[y, x] - if pixel_color.tolist() != [0, 0, 0]: - t += TextFormatter.format_text_truecolor(" ", background=f"{pixel_color[0]};{pixel_color[1]};{pixel_color[2]}") - else: - t += " " - - Send(client, t, ln=False) - Send(client, "") - t = "" - - elif command == "errortest": - raise Exception("hello error") - elif command == "inloadtest": - loading = indeterminateStatus(client) - loading.start() - time.sleep(5) - loading.stop() - elif command == "loadtest": - l = LoadingProgress(client, total=100, color=True) - l.start() - for i in range(101): - l.current = i - l.status = f"loading {i}" - time.sleep(0.05) - l.stop() - elif command == "dialogtest": - Di1 = TextDialog(client, "PyserSSH Extension", "Hello Dialog!") - Di1.render() - elif command == "dialogtest2": - Di2 = MenuDialog(client, ["H1", "H2", "H3"], "PyserSSH Extension", "Hello world") - Di2.render() - Send(client, f"selected index: {Di2.output()}") - elif command == "dialogtest3": - Di3 = TextInputDialog(client, "PyserSSH Extension") - Di3.render() - Send(client, f"input: {Di3.output()}") - elif command == "passdialogtest3": - Di3 = TextInputDialog(client, "PyserSSH Extension", inputtitle="Password Here", password=True) - Di3.render() - Send(client, f"password: {Di3.output()}") - elif command == "choosetest": - cindex = wait_choose(client, ["H1", "H2", "H3"], "select: ") - Send(client, f"selected index: {cindex}") - elif command.startswith("vieweb"): - args = shlex.split(command) - url = args[1] - loading = indeterminateStatus(client, desc=f"requesting {url}...") - loading.start() - try: - content = requests.get(url).content - except: - loading.stopfail() - return - loading.stop() - loading = indeterminateStatus(client, desc=f"parsing html {url}...") - loading.start() - try: - soup = BeautifulSoup(content, 'html.parser') - # Extract only the text content - text_content = soup.get_text() - except: - loading.stopfail() - return - loading.stop() - Di1 = TextDialog(client, url, text_content) - Di1.render() - elif command == "shutdown now": - ssh.stop_server() - -ssh.run(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'private_key.pem')) \ No newline at end of file diff --git a/src/PyserSSH/extensions/XHandler.py b/src/PyserSSH/extensions/XHandler.py index 34e82ac..5e75117 100644 --- a/src/PyserSSH/extensions/XHandler.py +++ b/src/PyserSSH/extensions/XHandler.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -30,16 +30,19 @@ import shlex from ..interactive import Send +def are_permissions_met(permission_list, permission_require): + return set(permission_require).issubset(set(permission_list)) + class XHandler: def __init__(self, enablehelp=True, showusageonworng=True): self.handlers = {} self.categories = {} self.enablehelp = enablehelp self.showusageonworng = showusageonworng - + self.serverself = None self.commandnotfound = None - def command(self, category=None, name=None, aliases=None): + def command(self, category=None, name=None, aliases=None, permissions: list = None): def decorator(func): nonlocal name, category if name is None: @@ -48,21 +51,32 @@ class XHandler: command_description = func.__doc__ # Read the docstring parameters = inspect.signature(func).parameters command_args = [] + has_args = False + has_kwargs = False + for param in list(parameters.values())[1:]: # Exclude first parameter (client) - if param.default != inspect.Parameter.empty: # Check if parameter has default value + if param.kind == inspect.Parameter.VAR_POSITIONAL: + has_args = True + elif param.kind == inspect.Parameter.VAR_KEYWORD: + has_kwargs = True + elif param.default != inspect.Parameter.empty: # Check if parameter has default value if param.annotation == bool: - command_args.append(f"-{param.name}") + command_args.append(f"--{param.name}") else: command_args.append((f"{param.name}", param.default)) else: command_args.append(param.name) + if category is None: category = 'No Category' if category not in self.categories: self.categories[category] = {} self.categories[category][command_name] = { 'description': command_description.strip() if command_description else "", - 'args': command_args + 'args': command_args, + "permissions": permissions, + 'has_args': has_args, + 'has_kwargs': has_kwargs } self.handlers[command_name] = func if aliases: @@ -76,6 +90,7 @@ class XHandler: tokens = shlex.split(command_string) command_name = tokens[0] args = tokens[1:] + if command_name == "help" and self.enablehelp: if args: Send(client, self.get_help_command_info(args[0])) @@ -85,58 +100,68 @@ class XHandler: else: if command_name in self.handlers: 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')): + Send(client, f"Permission denied. You do not have permission to execute '{command_name}'.") + return + command_args = inspect.signature(command_func).parameters - if len(args) % 2 != 0 and not args[0].startswith("--"): - if self.showusageonworng: - Send(client, self.get_help_command_info(command_name)) - else: - Send(client, f"Invalid number of arguments for command '{command_name}'.") - return - # Parse arguments final_args = {} - for i in range(0, len(args), 2): - if args[i].startswith("--"): - arg_name = args[i].lstrip('--') + final_kwargs = {} + i = 0 + + while i < len(args): + arg = args[i] + if arg.startswith('-'): + arg_name = arg.lstrip('-') if arg_name not in command_args: if self.showusageonworng: Send(client, self.get_help_command_info(command_name)) - else: - Send(client, f"Invalid flag '{arg_name}' for command '{command_name}'.") + Send(client, f"Invalid flag '{arg_name}' for command '{command_name}'.") return - try: - args[i + 1] - except: - pass + if command_args[arg_name].annotation == bool: + final_args[arg_name] = True + i += 1 else: - if self.showusageonworng: - Send(client, self.get_help_command_info(command_name)) + if i + 1 < len(args): + final_args[arg_name] = args[i + 1] + i += 2 else: - Send(client, f"value '{args[i + 1]}' not available for '{arg_name}' flag for command '{command_name}'.") - return - final_args[arg_name] = True + if self.showusageonworng: + Send(client, self.get_help_command_info(command_name)) + Send(client, f"Missing value for flag '{arg_name}' for command '{command_name}'.") + return else: - arg_name = args[i].lstrip('-') - if arg_name not in command_args: - if self.showusageonworng: - Send(client, self.get_help_command_info(command_name)) + if command_info['has_args']: + final_args.setdefault('args', []).append(arg) + elif command_info['has_kwargs']: + final_kwargs[arg] = args[i + 1] if i + 1 < len(args) else None + i += 1 + else: + if len(final_args) + 1 < len(command_args): + param = list(command_args.values())[len(final_args) + 1] + final_args[param.name] = arg else: - Send(client, f"Invalid argument '{arg_name}' for command '{command_name}'.") - return - arg_value = args[i + 1] - final_args[arg_name] = arg_value - # Match parsed arguments to function parameters - final_args_list = [] + if self.showusageonworng: + Send(client, self.get_help_command_info(command_name)) + Send(client, f"Unexpected argument '{arg}' for command '{command_name}'.") + return + i += 1 + + # Check for required positional arguments for param in list(command_args.values())[1:]: # Skip client argument - if param.name in final_args: - final_args_list.append(final_args[param.name]) - elif param.default != inspect.Parameter.empty: - final_args_list.append(param.default) - else: + if param.name not in final_args and param.default == inspect.Parameter.empty: if self.showusageonworng: Send(client, self.get_help_command_info(command_name)) - else: - Send(client, f"Missing required argument '{param.name}' for command '{command_name}'") + Send(client, f"Missing required argument '{param.name}' for command '{command_name}'") return + + final_args_list = [final_args.get(param.name, param.default) for param in list(command_args.values())[1:]] + + if command_info['has_kwargs']: + final_args_list.append(final_kwargs) + return command_func(client, *final_args_list) else: if self.commandnotfound: @@ -165,7 +190,10 @@ class XHandler: 'name': command_name, 'description': found_command['description'].strip() if found_command['description'] else "", 'args': found_command['args'], - 'category': category + 'category': category, + 'permissions': found_command['permissions'], + 'has_args': found_command['has_args'], + 'has_kwargs': found_command['has_kwargs'] } def get_help_command_info(self, command): @@ -185,6 +213,10 @@ class XHandler: help_message += f" [-{arg[0]} {arg[1]}]" else: help_message += f" <{arg}>" + if command_info['has_args']: + help_message += " [...]" + if command_info['has_kwargs']: + help_message += " [--=...]" return help_message def get_help_message(self): @@ -202,4 +234,5 @@ class XHandler: all_commands = {} for category, commands in self.categories.items(): all_commands[category] = commands - return all_commands \ No newline at end of file + return all_commands + diff --git a/src/PyserSSH/extensions/__init__.py b/src/PyserSSH/extensions/__init__.py index a30a409..d8d2ca3 100644 --- a/src/PyserSSH/extensions/__init__.py +++ b/src/PyserSSH/extensions/__init__.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License diff --git a/src/PyserSSH/extensions/dialog.py b/src/PyserSSH/extensions/dialog.py index 61049bf..e6372af 100644 --- a/src/PyserSSH/extensions/dialog.py +++ b/src/PyserSSH/extensions/dialog.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -31,7 +31,7 @@ from ..interactive import Clear, Send, wait_inputkey from ..system.sysfunc import text_centered_screen class TextDialog: - def __init__(self, client, title="", content=""): + def __init__(self, client, content="", title=""): self.client = client self.windowsize = client["windowsize"] diff --git a/src/PyserSSH/extensions/moredisplay.py b/src/PyserSSH/extensions/moredisplay.py index 7339117..b9238e0 100644 --- a/src/PyserSSH/extensions/moredisplay.py +++ b/src/PyserSSH/extensions/moredisplay.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -24,68 +24,35 @@ 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 time + +from ..interactive import Send def clickable_url(url, link_text=""): return f"\033]8;;{url}\033\\{link_text}\033]8;;\033\\" -class BasicTextFormatter: - 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" - } +def Send_karaoke_effect(client, text, delay=0.1, ln=True): + printed_text = "" + for i, char in enumerate(text): + # Print already printed text normally + Send(client, printed_text + char, ln=False) - @staticmethod - def format_text(text, color=None, color_level=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) + # Calculate not yet printed text to dim + not_printed_text = text[i + 1:] + dimmed_text = ''.join([f"\033[2m{char}\033[0m" for char in not_printed_text]) - if color in BasicTextFormatter.TEXT_COLORS: - if color_level in BasicTextFormatter.TEXT_COLOR_LEVELS: - color_code = BasicTextFormatter.TEXT_COLORS[color] - color_format = BasicTextFormatter.TEXT_COLOR_LEVELS[color_level].format(color_code) - formatted_text += color_format - else: - formatted_text += BasicTextFormatter.TEXT_COLORS[color] + # Print dimmed text + Send(client, dimmed_text, ln=False) - if background in BasicTextFormatter.BACKGROUND_COLORS: - formatted_text += BasicTextFormatter.BACKGROUND_COLORS[background] + # Wait before printing the next character + time.sleep(delay) - if attributes in BasicTextFormatter.TEXT_ATTRIBUTES: - formatted_text += BasicTextFormatter.TEXT_ATTRIBUTES[attributes] + # Clear the line for the next iteration + Send(client, '\r' ,ln=False) - if target_text == "": - formatted_text += text + BasicTextFormatter.RESET - else: - formatted_text += text[:start_index] + text[start_index:end_index] + BasicTextFormatter.RESET + text[end_index:] + # Prepare the updated printed_text for the next iteration + printed_text += char + + if ln: + Send(client, "") # new line - return formatted_text diff --git a/src/PyserSSH/demo/__init__.py b/src/PyserSSH/extensions/moreinteractive.py similarity index 74% rename from src/PyserSSH/demo/__init__.py rename to src/PyserSSH/extensions/moreinteractive.py index a30a409..04be378 100644 --- a/src/PyserSSH/demo/__init__.py +++ b/src/PyserSSH/extensions/moreinteractive.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -25,14 +25,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -""" -note +from ..interactive import Send -ansi cursor arrow -up - \x1b[A -down - \x1b[B -left - \x1b[D -right - \x1b[C +def ShowCursor(client, show=True): + if show: + Send(client, "\033[?25h", ln=False) + else: + Send(client, "\033[?25l", ln=False) -https://en.wikipedia.org/wiki/ANSI_escape_code -""" \ No newline at end of file +def SendBell(client): + Send(client, "\x07", ln=False) \ No newline at end of file diff --git a/src/PyserSSH/extensions/processbar.py b/src/PyserSSH/extensions/processbar.py index 2b592f2..f3571e4 100644 --- a/src/PyserSSH/extensions/processbar.py +++ b/src/PyserSSH/extensions/processbar.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -24,7 +24,7 @@ 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. """ -# this file is from damp11113-library +# this file is from DPSoftware Foundation-library from itertools import cycle import math @@ -37,10 +37,97 @@ from ..system.sysfunc import replace_enter_with_crlf def Print(channel, string, start="", end="\n"): channel.send(replace_enter_with_crlf(start + string + end)) -try: - from damp11113.utils import get_size_unit2, center_string, TextFormatter, insert_string -except: - raise ModuleNotFoundError("This extension is require damp11113-library") +def get_size_unit2(number, unitp, persec=True, unitsize=1024, decimal=True, space=" "): + for unit in ['', 'K', 'M', 'G', 'T', 'P']: + if number < unitsize: + if decimal: + num = f"{number:.2f}" + else: + num = int(number) + + if persec: + return f"{num}{space}{unit}{unitp}/s" + else: + return f"{num}{space}{unit}{unitp}" + number /= unitsize + +def center_string(main_string, replacement_string): + # Find the center index of the main string + center_index = len(main_string) // 2 + + # Calculate the start and end indices for replacing + start_index = center_index - len(replacement_string) // 2 + end_index = start_index + len(replacement_string) + + # Replace the substring at the center + new_string = main_string[:start_index] + replacement_string + main_string[end_index:] + + return new_string + +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(text, color=None, color_level=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 in TextFormatter.TEXT_COLORS: + if color_level in TextFormatter.TEXT_COLOR_LEVELS: + color_code = TextFormatter.TEXT_COLORS[color] + color_format = TextFormatter.TEXT_COLOR_LEVELS[color_level].format(color_code) + formatted_text += color_format + else: + formatted_text += TextFormatter.TEXT_COLORS[color] + + if background in TextFormatter.BACKGROUND_COLORS: + formatted_text += TextFormatter.BACKGROUND_COLORS[background] + + 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 + +def insert_string(base, inserted, position=0): + return base[:position] + inserted + base[position + len(inserted):] steps1 = ['[ ]', '[- ]', '[-- ]', '[---]', '[ --]', '[ -]'] steps2 = ['[ ]', '[- ]', '[ - ]', '[ -]'] diff --git a/src/PyserSSH/extensions/ptop.py b/src/PyserSSH/extensions/ptop.py deleted file mode 100644 index d45232f..0000000 --- a/src/PyserSSH/extensions/ptop.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) - -Visit https://github.com/damp11113/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. -""" - -class PTOP: - def __init__(self, client, interval=1): - pass # working \ No newline at end of file diff --git a/src/PyserSSH/extensions/remodesk.py b/src/PyserSSH/extensions/remodesk.py new file mode 100644 index 0000000..8947f38 --- /dev/null +++ b/src/PyserSSH/extensions/remodesk.py @@ -0,0 +1,294 @@ +""" +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 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 socket +import threading +import brotli +import numpy as np +import cv2 +from PIL import ImageGrab +import struct +import queue +import pickle +import mouse +import keyboard +import logging + +from ..system.clientype import Client + +logger = logging.getLogger("RemoDeskSSH") + +class Protocol: + def __init__(self, server): + self.listclient = [] + self.first = True + self.running = False + self.server = server + self.buffer = queue.Queue(maxsize=10) + + def _handle_client(self): + try: + while self.running: + data2send = self.buffer.get() + + for iclient in self.listclient: + try: + iclient[2].sendall(data2send) + except Exception as e: + iclient[2].close() + self.listclient.remove(iclient) + + if not self.listclient: + self.running = False + self.first = True + logger.info("No clients connected. Server is standby") + break + + except socket.error: + pass + except Exception as e: + logger.error(f"Error in handle_client: {e}") + + def _handle_client_commands(self, client, id): + try: + while True: + client_socket = client.get_subchannel(id) + + try: + # Receive the length of the data + data_length = self._receive_exact(client_socket, 4) + if not data_length: + break + + commandmetadata = struct.unpack('!I', data_length) + command_data = self._receive_exact(client_socket, commandmetadata[0]) + command = pickle.loads(command_data) + + if command: + self.handle_commands(command, client) + + except socket.error: + break + except Exception as e: + logger.error(f"Error in handle_client_commands: {e}") + + def handle_commands(self, command, client): + pass + + def _receive_exact(self, socket, n): + """Helper function to receive exactly n bytes.""" + data = b'' + while len(data) < n: + packet = socket.recv(n - len(data)) + if not packet: + return None + data += packet + return data + + def init(self, client): + pass + + def handle_new_client(self, client: Client, directchannel=None): + if directchannel: + id = directchannel.get_id() + channel = directchannel + else: + logger.info("waiting remote channel") + id, channel = client.open_new_subchannel(5) + if id == None or channel == None: + logger.info("client is not connect in 5 sec") + return + + self.listclient.append([client, id, channel]) + + if self.first: + self.running = True + handle_client_thread = threading.Thread(target=self._handle_client, daemon=True) + handle_client_thread.start() + + self.init(client) + + self.first = False + + command_thread = threading.Thread(target=self._handle_client_commands, args=(client, id), daemon=True) + command_thread.start() + +class RemoDesk(Protocol): + def __init__(self, server=None, quality=50, compression=50, format="jpeg", resolution: set[int, int] = None, activity_threshold=None, second_compress=True): + """ + Args: + server: ssh server + quality: quality of remote + compression: percent of compression 0-100 % + format: jpeg, webp, avif + resolution: resolution of remote + """ + + super().__init__(server) + + self.quality = quality + self.compression = compression + self.format = format + self.resolution = resolution + self.threshold = activity_threshold + self.compress2 = second_compress + self.screensize = () + self.previous_frame = None + + def _capture_screen(self): + try: + screenshot = ImageGrab.grab() + self.screensize = screenshot.size + img_np = np.array(screenshot) + img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) + return img_bgr + except: + return b"" + + def _detect_activity(self, current_frame): + if self.threshold: + if self.previous_frame is None: + self.previous_frame = current_frame + return False # No previous frame to compare to + + # Compute the absolute difference between the current frame and the previous frame + diff = cv2.absdiff(current_frame, self.previous_frame) + + # Convert the difference to grayscale + gray_diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY) + + # Apply a threshold to get a binary image + _, thresh = cv2.threshold(gray_diff, self.threshold, 255, cv2.THRESH_BINARY) + + # Calculate the number of non-zero pixels in the thresholded image + non_zero_count = np.count_nonzero(thresh) + + # Update the previous frame + self.previous_frame = current_frame + + # If there are enough non-zero pixels, we consider it as activity + return non_zero_count > 500 # You can adjust the threshold as needed + else: + return True + + def _imagenc(self, image): + if self.format == "webp": + retval, buffer = cv2.imencode('.webp', image, [int(cv2.IMWRITE_WEBP_QUALITY), self.quality]) + elif self.format == "jpeg": + retval, buffer = cv2.imencode('.jpeg', image, [int(cv2.IMWRITE_JPEG_QUALITY), self.quality]) + elif self.format == "avif": + retval, buffer = cv2.imencode('.avif', image, [int(cv2.IMWRITE_AVIF_QUALITY), self.quality]) + + else: + raise TypeError(f"{self.format} is not supported") + + if not retval: + raise ValueError("image encoding failed.") + + return np.array(buffer).tobytes() + + def _translate_coordinates(self, x, y): + if self.resolution: + translated_x = int(x * (self.screensize[0] / self.resolution[0])) + translated_y = int(y * (self.screensize[1] / self.resolution[1])) + else: + translated_x = int(x * (self.screensize[0] / 1920)) + translated_y = int(y * (self.screensize[1] / 1090)) + return translated_x, translated_y + + def _convert_quality(self, quality): + brotli_quality = int(quality / 100 * 11) + lgwin = int(10 + (quality / 100 * (24 - 10))) + + return brotli_quality, lgwin + + def _capture(self): + while self.running: + screen_image = self._capture_screen() + + if self._detect_activity(screen_image): + if self.resolution: + screen_image = cv2.resize(screen_image, self.resolution, interpolation=cv2.INTER_NEAREST) + else: + self.resolution = self.screensize + + data = self._imagenc(screen_image) + + if self.compress2: + bquality, lgwin = self._convert_quality(self.compression) + data = brotli.compress(data, quality=bquality, lgwin=lgwin) + + data_length = struct.pack('!III', len(data), self.resolution[0], self.resolution[1]) + data2send = data_length + data + + print(f"Sending data length: {len(data2send)}") + self.buffer.put(data2send) + + def handle_commands(self, command, client): + action = command["action"] + data = command["data"] + + if action == "move_mouse": + x, y = data["x"], data["y"] + rx, ry = self._translate_coordinates(x, y) + mouse.move(rx, ry) + + elif action == "click_mouse": + button = data["button"] + state = data["state"] + + if button == 1: + if state == "down": + mouse.press() + else: + mouse.release() + elif button == 2: + if state == "down": + mouse.press(mouse.MIDDLE) + else: + mouse.release(mouse.MIDDLE) + elif button == 3: + if state == "down": + mouse.press(mouse.RIGHT) + else: + mouse.release(mouse.RIGHT) + # elif button == 4: + # mouse.wheel() + # elif button == 5: + # mouse.wheel(-1) + elif action == "keyboard": + key = data["key"] + state = data["state"] + + if state == "down": + keyboard.press(key) + else: + keyboard.release(key) + + def init(self, client): + capture_thread = threading.Thread(target=self._capture, daemon=True) + capture_thread.start() \ No newline at end of file diff --git a/src/PyserSSH/extensions/serverutils.py b/src/PyserSSH/extensions/serverutils.py new file mode 100644 index 0000000..882a355 --- /dev/null +++ b/src/PyserSSH/extensions/serverutils.py @@ -0,0 +1,102 @@ +""" +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 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 logging + +from ..interactive import Send + +logger = logging.getLogger("PyserSSH") + +def kickbyusername(server, username, reason=None): + for peername, client_handler in list(server.client_handlers.items()): + if client_handler["current_user"] == username: + channel = client_handler.get("channel") + server._handle_event("disconnected", channel.getpeername(), server.client_handlers[channel.getpeername()]["current_user"]) + if reason is None: + if channel: + channel.close() + logger.info(f"User '{username}' has been kicked.") + else: + if channel: + Send(channel, f"You have been disconnected for {reason}") + channel.close() + logger.info(f"User '{username}' has been kicked by reason {reason}.") + +def kickbypeername(server, peername, reason=None): + client_handler = server.client_handlers.get(peername) + if client_handler: + channel = client_handler.get("channel") + server._handle_event("disconnected", channel.getpeername(), server.client_handlers[channel.getpeername()]["current_user"]) + if reason is None: + if channel: + channel.close() + logger.info(f"peername '{peername}' has been kicked.") + else: + if channel: + Send(channel, f"You have been disconnected for {reason}") + channel.close() + logger.info(f"peername '{peername}' has been kicked by reason {reason}.") + +def kickall(server, reason=None): + 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"]) + if reason is None: + if channel: + channel.close() + else: + if channel: + Send(channel, f"You have been disconnected for {reason}") + channel.close() + if reason is None: + server.client_handlers.clear() + logger.info("All users have been kicked.") + else: + logger.info(f"All users have been kicked by reason {reason}.") + +def broadcast(server, message): + for client_handler in server.client_handlers.values(): + channel = client_handler.get("channel") + if channel: + try: + # Send the message to the client + Send(channel, message) + except Exception as e: + logger.error(f"Error occurred while broadcasting message: {e}") + +def sendto(server, username, message): + for client_handler in server.client_handlers.values(): + if client_handler.get("current_user") == username: + channel = client_handler.get("channel") + if channel: + try: + # Send the message to the specific client + Send(channel, message) + except Exception as e: + logger.error(f"Error occurred while sending message to {username}: {e}") + break + else: + logger.warning(f"User '{username}' not found.") diff --git a/src/PyserSSH/interactive.py b/src/PyserSSH/interactive.py index 0bad91a..b76c377 100644 --- a/src/PyserSSH/interactive.py +++ b/src/PyserSSH/interactive.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -29,13 +29,42 @@ import socket from .system.sysfunc import replace_enter_with_crlf -def Send(client, string, ln=True): - channel = client["channel"] +def Send(client, string, ln=True, directchannel=False): + if directchannel: + channel = client + else: + channel = client["channel"] + if ln: channel.send(replace_enter_with_crlf(str(string) + "\n")) else: channel.send(replace_enter_with_crlf(str(string))) +def NewSend(client, *astring, ln=True, end=b'\n', sep=b' ', directchannel=False): + if directchannel: + channel = client + else: + channel = client["channel"] + + if ln: + if not b'\n' in end: + end += b'\n' + else: + # Ensure that `end` does not contain `b'\n'` if `ln` is False + end = end.replace(b'\n', b'') + + # Prepare the strings to be sent + if astring: + for i, s in enumerate(astring): + # Convert `s` to bytes if it's a string + if isinstance(s, str): + s = s.encode('utf-8') + # Use a hypothetical `replace_enter_with_crlf` function if needed + channel.send(replace_enter_with_crlf(s)) + if i != len(astring) - 1: + channel.send(sep) + channel.send(end) + def Clear(client, oldclear=False, keep=False): sx, sy = client["windowsize"]["width"], client["windowsize"]["height"] @@ -123,7 +152,7 @@ def wait_input(client, prompt="", defaultvalue=None, cursor_scroll=False, echo=T else: return output -def wait_inputkey(client, prompt="", raw=False, timeout=0): +def wait_inputkey(client, prompt="", raw=True, timeout=0): channel = client["channel"] if prompt != "": @@ -150,7 +179,8 @@ def wait_inputkey(client, prompt="", raw=False, timeout=0): except socket.timeout: channel.setblocking(False) channel.settimeout(None) - channel.send("\r\n") + if prompt != "": + channel.send("\r\n") return None except Exception: channel.setblocking(False) @@ -158,6 +188,48 @@ def wait_inputkey(client, prompt="", raw=False, timeout=0): channel.send("\r\n") raise +def wait_inputmouse(client, timeout=0): + channel = client["channel"] + Send(client, "\033[?1000h", ln=False) + + if timeout != 0: + channel.settimeout(timeout) + + try: + byte = channel.recv(10) + + if not byte or byte == b'\x04': + raise EOFError() + + if byte.startswith(b'\x1b[M'): + # Parse mouse event + if len(byte) < 6 or not byte.startswith(b'\x1b[M'): + Send(client, "\033[?1000l", ln=False) + return None, None, None + + # Extract button, x, y from the sequence + button = byte[3] - 32 + x = byte[4] - 32 + y = byte[5] - 32 + + Send(client, "\033[?1000l", ln=False) + return button, x, y + else: + Send(client, "\033[?1000l", ln=False) + return byte, None, None + + except socket.timeout: + channel.setblocking(False) + channel.settimeout(None) + channel.send("\r\n") + Send(client, "\033[?1000l", ln=False) + return None, None, None + except Exception: + channel.setblocking(False) + channel.settimeout(None) + channel.send("\r\n") + raise + def wait_choose(client, choose, prompt="", timeout=0): channel = client["channel"] @@ -176,18 +248,18 @@ def wait_choose(client, choose, prompt="", timeout=0): exported = " ".join(tempchooselist) if prompt.strip() == "": - Send(channel, f'\r{exported}', ln=False) + Send(client, f'\r{exported}', ln=False) else: - Send(channel, f'\r{prompt}{exported}', ln=False) + Send(client, f'\r{prompt}{exported}', ln=False) - keyinput = wait_inputkey(channel, raw=True) + keyinput = wait_inputkey(client, raw=True) if keyinput == b'\r': # Enter key - Send(channel, "\033[K") + Send(client, "\033[K") return chooseindex elif keyinput == b'\x03': # ' ctrl+c' key for cancel - Send(channel, "\033[K") - return None + Send(client, "\033[K") + return 0 elif keyinput == b'\x1b[D': # Up arrow key chooseindex -= 1 if chooseindex < 0: diff --git a/src/PyserSSH/server.py b/src/PyserSSH/server.py index 530db1d..ec2cfe9 100644 --- a/src/PyserSSH/server.py +++ b/src/PyserSSH/server.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -25,18 +25,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import os import time import paramiko import threading from functools import wraps import logging +import socket +import random +import traceback from .system.SFTP import SSHSFTPServer +from .system.sysfunc import replace_enter_with_crlf from .system.interface import Sinterface -from .interactive import * from .system.inputsystem import expect -from .system.info import __version__ +from .system.info import __version__, system_banner +from .system.clientype import Client as Clientype # paramiko.sftp_file.SFTPFile.MAX_REQUEST_SIZE = pow(2, 22) @@ -45,16 +48,18 @@ sftpclient = ["WinSCP", "Xplore"] logger = logging.getLogger("PyserSSH") class Server: - def __init__(self, accounts, system_message=True, disable_scroll_with_arrow=True, sftp=False, sftproot=os.getcwd(), system_commands=True, compression=True, usexternalauth=False, history=True, inputsystem=True, XHandler=None, title=f"PyserSSH v{__version__}", inspeed=32768): + 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): """ - A simple SSH server + 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) + sftp set to True to enable SFTP server + system_commands set to False to disable system commmands + compression set to False to disable SSH compression + enable_remote_status set to True to enable mobaxterm remote monitor (Beta) """ - self._event_handlers = {} self.sysmess = system_message - self.client_handlers = {} # Dictionary to store event handlers for each client self.accounts = accounts self.disable_scroll_with_arrow = disable_scroll_with_arrow - self.sftproot = sftproot self.sftpena = sftp self.enasyscom = system_commands self.compressena = compression @@ -64,10 +69,19 @@ class Server: self.XHandler = XHandler self.title = title self.inspeed = inspeed + self.enaloginbanner = enable_preauth_banner + self.enasysexec = enable_exec_system_command + self.enaremostatus = enable_remote_status + self.inputsysecho = inputsystem_echo + if self.XHandler != None: + self.XHandler.serverself = self + + self._event_handlers = {} + self.client_handlers = {} # Dictionary to store event handlers for each client self.__processmode = None self.__serverisrunning = False - self.__server_stopped = threading.Event() # Event to signal server stop + self.__daemon = False if self.enasyscom: print("\033[33m!!Warning!! System commands is enable! \033[0m") @@ -75,40 +89,41 @@ class Server: def on_user(self, event_name): def decorator(func): @wraps(func) - def wrapper(channel, *args, **kwargs): + def wrapper(client, *args, **kwargs): # Ignore the third argument filtered_args = args[:2] + args[3:] - return func(channel, *filtered_args, **kwargs) + return func(client, *filtered_args, **kwargs) self._event_handlers[event_name] = wrapper return wrapper return decorator - def handle_client_disconnection(self, peername, current_user): - if peername in self.client_handlers: - del self.client_handlers[peername] - logger.info(f"User {current_user} disconnected") + def handle_client_disconnection(self, handler, chandlers): + if not chandlers["channel"].get_transport().is_active(): + if handler: + handler(chandlers) + del self.client_handlers[chandlers["peername"]] def _handle_event(self, event_name, *args, **kwargs): handler = self._event_handlers.get(event_name) - if handler: - handler(*args, **kwargs) - if event_name == "disconnected": - self.handle_client_disconnection(*args, **kwargs) + if event_name == "error" and isinstance(args[0], Clientype): + args[0].last_error = traceback.format_exc() + + if event_name == "disconnected": + self.handle_client_disconnection(handler, *args, **kwargs) + elif handler: + return handler(*args, **kwargs) + + def handle_client(self, socketchannel, addr): + self._handle_event("pressh", socketchannel) + + try: + bh_session = paramiko.Transport(socketchannel) + except OSError: + return - def handle_client(self, client, addr): - bh_session = paramiko.Transport(client) bh_session.add_server_key(self.private_key) - if self.sftpena: - SSHSFTPServer.ROOT = self.sftproot - SSHSFTPServer.ACCOUNT = self.accounts - SSHSFTPServer.CLIENTHANDELES = self.client_handlers - bh_session.set_subsystem_handler('sftp', paramiko.SFTPServer, SSHSFTPServer) - - if self.compressena: - bh_session.use_compression(True) - else: - bh_session.use_compression(False) + bh_session.use_compression(self.compressena) bh_session.default_window_size = 2147483647 bh_session.packetizer.REKEY_BYTES = pow(2, 40) @@ -117,102 +132,154 @@ class Server: bh_session.default_max_packet_size = self.inspeed server = Sinterface(self) - bh_session.start_server(server=server) + try: + bh_session.start_server(server=server) + except: + return logger.info(bh_session.remote_version) channel = bh_session.accept() + if self.sftpena: + bh_session.set_subsystem_handler('sftp', paramiko.SFTPServer, SSHSFTPServer, channel, self.accounts, self.client_handlers) + + if not bh_session.is_authenticated(): + logger.warning("user not authenticated") + bh_session.close() + return + if channel is None: logger.warning("no channel") + bh_session.close() + return try: logger.info("user authenticated") - peername = channel.getpeername() + peername = bh_session.getpeername() if peername not in self.client_handlers: # Create a new event handler for this client if it doesn't exist - self.client_handlers[peername] = { - "event_handlers": {}, - "current_user": None, - "channel": channel, # Associate the channel with the client handler, - "last_activity_time": None, - "connecttype": None, - "last_login_time": None, - "windowsize": {}, - "x11": {}, - "prompt": None, - "inputbuffer": None, - "peername": peername - } + self.client_handlers[peername] = Clientype(channel, bh_session, peername) + client_handler = self.client_handlers[peername] - client_handler["current_user"] = server.current_user + client_handler["current_user"] = bh_session.get_username() client_handler["channel"] = channel # Update the channel attribute for the client handler + client_handler["transport"] = bh_session # Update the channel attribute for the client handler client_handler["last_activity_time"] = time.time() client_handler["last_login_time"] = time.time() - client_handler["prompt"] = self.accounts.get_prompt(server.current_user) + client_handler["prompt"] = self.accounts.get_prompt(bh_session.get_username()) + client_handler["session_id"] = random.randint(10000, 99999) + int(time.time() * 1000) self.accounts.set_user_last_login(self.client_handlers[channel.getpeername()]["current_user"], peername[0]) + logger.info("saved user data to client handlers") + #if not any(bh_session.remote_version.split("-")[2].startswith(prefix) for prefix in sftpclient): - if not channel.out_window_size == bh_session.default_window_size: - while self.client_handlers[channel.getpeername()]["windowsize"] == {}: - pass + if int(channel.out_window_size) != int(bh_session.default_window_size): + logger.info("user is ssh") + #timeout for waiting 10 sec + for i in range(100): + if self.client_handlers[channel.getpeername()]["windowsize"]: + break + time.sleep(0.1) + + if self.client_handlers[channel.getpeername()]["windowsize"] == {}: + logger.info("timeout for waiting window size in 10 sec") + self.client_handlers[channel.getpeername()]["windowsize"] = { + "width": 80, + "height": 24, + "pixelwidth": 0, + "pixelheight": 0 + } + + try: + self._handle_event("pre-shell", self.client_handlers[channel.getpeername()]) + except Exception as e: + self._handle_event("error", self.client_handlers[channel.getpeername()], e) + + while self.client_handlers[channel.getpeername()]["isexeccommandrunning"]: + time.sleep(0.1) userbanner = self.accounts.get_banner(self.client_handlers[channel.getpeername()]["current_user"]) - if self.sysmess or userbanner != None: - channel.send(f"\033]0;{self.title}\007".encode()) - channel.sendall(replace_enter_with_crlf(userbanner)) - channel.sendall(replace_enter_with_crlf("\n")) + if self.accounts.get_user_enable_inputsystem_echo(self.client_handlers[channel.getpeername()]["current_user"]) and self.inputsysecho: + echo = True + else: + echo = False + + if echo: + if self.title.strip() != "": + channel.send(f"\033]0;{self.title}\007".encode()) + + if self.sysmess or userbanner != None: + if userbanner is None and self.sysmess: + channel.sendall(replace_enter_with_crlf(system_banner)) + elif userbanner != None and self.sysmess: + channel.sendall(replace_enter_with_crlf(system_banner)) + channel.sendall(replace_enter_with_crlf(userbanner)) + elif userbanner != None and not self.sysmess: + channel.sendall(replace_enter_with_crlf(userbanner)) + + channel.sendall(replace_enter_with_crlf("\n")) + + client_handler["connecttype"] = "ssh" try: self._handle_event("connect", self.client_handlers[channel.getpeername()]) except Exception as e: self._handle_event("error", self.client_handlers[channel.getpeername()], e) - client_handler["connecttype"] = "ssh" - if self.enainputsystem: + if self.enainputsystem and self.accounts.get_user_enable_inputsystem(self.client_handlers[channel.getpeername()]["current_user"]): try: if self.accounts.get_user_timeout(self.client_handlers[channel.getpeername()]["current_user"]) != None: channel.setblocking(False) channel.settimeout(self.accounts.get_user_timeout(self.client_handlers[channel.getpeername()]["current_user"])) - channel.send(replace_enter_with_crlf(self.client_handlers[channel.getpeername()]["prompt"] + " ").encode('utf-8')) + if echo: + channel.send(replace_enter_with_crlf(self.client_handlers[channel.getpeername()]["prompt"] + " ")) + while True: - expect(self, self.client_handlers[channel.getpeername()]) + expect(self, self.client_handlers[channel.getpeername()], echo) except KeyboardInterrupt: - self._handle_event("disconnected", self.client_handlers[peername]["current_user"]) + self._handle_event("disconnected", self.client_handlers[peername]) channel.close() bh_session.close() except Exception as e: - self._handle_event("syserror", client_handler, e) + self._handle_event("error", client_handler, e) logger.error(e) finally: - self._handle_event("disconnected", self.client_handlers[peername]["current_user"]) + self._handle_event("disconnected", self.client_handlers[peername]) channel.close() else: if self.sftpena: + logger.info("user is sftp") if self.accounts.get_user_sftp_allow(self.client_handlers[channel.getpeername()]["current_user"]): client_handler["connecttype"] = "sftp" self._handle_event("connectsftp", self.client_handlers[channel.getpeername()]) + while bh_session.is_active(): + time.sleep(0.1) + + self._handle_event("disconnected", self.client_handlers[peername]) + else: - self._handle_event("disconnected", self.client_handlers[peername]["current_user"]) + self._handle_event("disconnected", self.client_handlers[peername]) channel.close() else: - self._handle_event("disconnected", self.client_handlers[peername]["current_user"]) + self._handle_event("disconnected", self.client_handlers[peername]) channel.close() except: - pass + bh_session.close() def stop_server(self): logger.info("Stopping the server...") try: for client_handler in self.client_handlers.values(): - channel = client_handler.get("channel") + channel = client_handler.channel if channel: channel.close() - self.__serverisrunning = True + self.__serverisrunning = False self.server.close() + logger.info("Server stopped.") except Exception as e: logger.error(f"Error occurred while stopping the server: {e}") @@ -223,20 +290,29 @@ class Server: while self.__serverisrunning: client, addr = self.server.accept() if self.__processmode == "thread": - client_thread = threading.Thread(target=self.handle_client, args=(client, addr)) + 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() except Exception as e: logger.error(e) - def run(self, private_key_path, host="0.0.0.0", port=2222, mode="thread", maxuser=0, daemon=False): - """mode: single, thread""" + 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 + """ 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)) - self.private_key = paramiko.RSAKey(filename=private_key_path) + + if private_key_path != None: + self.private_key = paramiko.RSAKey(filename=private_key_path) + else: + raise ValueError("No private key") + if maxuser == 0: self.server.listen() else: @@ -244,81 +320,8 @@ class Server: self.__processmode = mode.lower() self.__serverisrunning = True + self.__daemon = daemon - client_thread = threading.Thread(target=self._start_listening_thread) - client_thread.daemon = daemon + client_thread = threading.Thread(target=self._start_listening_thread, daemon=self.__daemon) client_thread.start() - def kickbyusername(self, username, reason=None): - for peername, client_handler in list(self.client_handlers.items()): - if client_handler["current_user"] == username: - channel = client_handler.get("channel") - self._handle_event("disconnected", channel.getpeername(), self.client_handlers[channel.getpeername()]["current_user"]) - if reason is None: - if channel: - channel.close() - logger.info(f"User '{username}' has been kicked.") - else: - if channel: - Send(channel, f"You have been disconnected for {reason}") - channel.close() - logger.info(f"User '{username}' has been kicked by reason {reason}.") - - def kickbypeername(self, peername, reason=None): - client_handler = self.client_handlers.get(peername) - if client_handler: - channel = client_handler.get("channel") - self._handle_event("disconnected", channel.getpeername(), self.client_handlers[channel.getpeername()]["current_user"]) - if reason is None: - if channel: - channel.close() - logger.info(f"peername '{peername}' has been kicked.") - else: - if channel: - Send(channel, f"You have been disconnected for {reason}") - channel.close() - logger.info(f"peername '{peername}' has been kicked by reason {reason}.") - - def kickall(self, reason=None): - for peername, client_handler in self.client_handlers.items(): - channel = client_handler.get("channel") - self._handle_event("disconnected", channel.getpeername(), self.client_handlers[channel.getpeername()]["current_user"]) - - if reason is None: - if channel: - channel.close() - else: - if channel: - Send(channel, f"You have been disconnected for {reason}") - channel.close() - - if reason is None: - self.client_handlers.clear() - logger.info("All users have been kicked.") - else: - logger.info(f"All users have been kicked by reason {reason}.") - - def broadcast(self, message): - for client_handler in self.client_handlers.values(): - channel = client_handler.get("channel") - if channel: - try: - # Send the message to the client - Send(channel, message) - except Exception as e: - logger.error(f"Error occurred while broadcasting message: {e}") - - def sendto(self, username, message): - for client_handler in self.client_handlers.values(): - if client_handler.get("current_user") == username: - channel = client_handler.get("channel") - - if channel: - try: - # Send the message to the specific client - Send(channel, message) - except Exception as e: - logger.error(f"Error occurred while sending message to {username}: {e}") - break - else: - logger.warning(f"User '{username}' not found.") diff --git a/src/PyserSSH/system/SFTP.py b/src/PyserSSH/system/SFTP.py index d3143af..2e778b6 100644 --- a/src/PyserSSH/system/SFTP.py +++ b/src/PyserSSH/system/SFTP.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -40,17 +40,20 @@ class SSHSFTPHandle(paramiko.SFTPHandle): # use the stored filename try: paramiko.SFTPServer.set_file_attr(self.filename, attr) - return paramiko.SFTP_OK + return paramiko.sftp.SFTP_OK except OSError as e: return paramiko.SFTPServer.convert_errno(e.errno) class SSHSFTPServer(paramiko.SFTPServerInterface): - ROOT = None - ACCOUNT = None - CLIENTHANDELES = None + def __init__(self, server: paramiko.ServerInterface, *args, **kwargs): + super().__init__(server) + self.channel = args[0] + self.account = args[1] + self.clientH = args[2] def _realpath(self, path): - return self.ROOT + self.canonicalize(path) + root = self.account.get_user_sftp_root_path(self.clientH[self.channel.getpeername()]["current_user"]) + return root + self.canonicalize(path) def list_folder(self, path): path = self._realpath(path) @@ -80,6 +83,12 @@ class SSHSFTPServer(paramiko.SFTPServerInterface): return paramiko.SFTPServer.convert_errno(e.errno) def open(self, path, flags, attr): + # check if write request + is_write = (flags & os.O_WRONLY or flags & os.O_RDWR) + (flags & os.O_CREAT) != 0 + + if self.account.get_user_sftp_readonly(self.clientH[self.channel.getpeername()]["current_user"]) and is_write: + return paramiko.sftp.SFTP_PERMISSION_DENIED + path = self._realpath(path) try: binary_flag = getattr(os, 'O_BINARY', 0) @@ -120,23 +129,32 @@ class SSHSFTPServer(paramiko.SFTPServerInterface): return fobj def remove(self, path): + if self.account.get_user_sftp_readonly(self.clientH[self.channel.getpeername()]["current_user"]): + return paramiko.sftp.SFTP_PERMISSION_DENIED + path = self._realpath(path) try: os.remove(path) except OSError as e: return paramiko.SFTPServer.convert_errno(e.errno) - return paramiko.SFTP_OK + return paramiko.sftp.SFTP_OK def rename(self, oldpath, newpath): + if self.account.get_user_sftp_readonly(self.clientH[self.channel.getpeername()]["current_user"]): + return paramiko.sftp.SFTP_PERMISSION_DENIED + oldpath = self._realpath(oldpath) newpath = self._realpath(newpath) try: os.rename(oldpath, newpath) except OSError as e: return paramiko.SFTPServer.convert_errno(e.errno) - return paramiko.SFTP_OK + return paramiko.sftp.SFTP_OK def mkdir(self, path, attr): + if self.account.get_user_sftp_readonly(self.clientH[self.channel.getpeername()]["current_user"]): + return paramiko.sftp.SFTP_PERMISSION_DENIED + path = self._realpath(path) try: os.mkdir(path) @@ -144,45 +162,58 @@ class SSHSFTPServer(paramiko.SFTPServerInterface): paramiko.SFTPServer.set_file_attr(path, attr) except OSError as e: return paramiko.SFTPServer.convert_errno(e.errno) - return paramiko.SFTP_OK + return paramiko.sftp.SFTP_OK def rmdir(self, path): + if self.account.get_user_sftp_readonly(self.clientH[self.channel.getpeername()]["current_user"]): + return paramiko.sftp.SFTP_PERMISSION_DENIED + path = self._realpath(path) try: os.rmdir(path) except OSError as e: return paramiko.SFTPServer.convert_errno(e.errno) - return paramiko.SFTP_OK + return paramiko.sftp.SFTP_OK def chattr(self, path, attr): + if self.account.get_user_sftp_readonly(self.clientH[self.channel.getpeername()]["current_user"]): + return paramiko.sftp.SFTP_PERMISSION_DENIED + path = self._realpath(path) try: paramiko.SFTPServer.set_file_attr(path, attr) except OSError as e: return paramiko.SFTPServer.convert_errno(e.errno) - return paramiko.SFTP_OK + return paramiko.sftp.SFTP_OK def symlink(self, target_path, path): + if self.account.get_user_sftp_readonly(self.clientH[self.channel.getpeername()]["current_user"]): + return paramiko.sftp.SFTP_PERMISSION_DENIED + + root = self.account.get_user_sftp_root_path(self.clientH[self.channel.getpeername()]["current_user"]) + path = self._realpath(path) if (len(target_path) > 0) and (target_path[0] == '/'): # absolute symlink - target_path = os.path.join(self.ROOT, target_path[1:]) + target_path = os.path.join(root, target_path[1:]) if target_path[:2] == '//': # bug in os.path.join target_path = target_path[1:] else: # compute relative to path abspath = os.path.join(os.path.dirname(path), target_path) - if abspath[:len(self.ROOT)] != self.ROOT: + if abspath[:len(root)] != root: # this symlink isn't going to work anyway -- just break it immediately target_path = '' try: os.symlink(target_path, path) except OSError as e: return paramiko.SFTPServer.convert_errno(e.errno) - return paramiko.SFTP_OK + return paramiko.sftp.SFTP_OK def readlink(self, path): + root = self.account.get_user_sftp_root_path(self.clientH[self.channel.getpeername()]["current_user"]) + path = self._realpath(path) try: symlink = os.readlink(path) @@ -190,8 +221,8 @@ class SSHSFTPServer(paramiko.SFTPServerInterface): return paramiko.SFTPServer.convert_errno(e.errno) if os.path.isabs(symlink): - if symlink[:len(self.ROOT)] == self.ROOT: - symlink = symlink[len(self.ROOT):] + if symlink[:len(root)] == root: + symlink = symlink[len(root):] if (len(symlink) == 0) or (symlink[0] != '/'): symlink = '/' + symlink else: diff --git a/src/PyserSSH/system/__init__.py b/src/PyserSSH/system/__init__.py index a30a409..d8d2ca3 100644 --- a/src/PyserSSH/system/__init__.py +++ b/src/PyserSSH/system/__init__.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License diff --git a/src/PyserSSH/system/clientype.py b/src/PyserSSH/system/clientype.py new file mode 100644 index 0000000..5f434ed --- /dev/null +++ b/src/PyserSSH/system/clientype.py @@ -0,0 +1,143 @@ +""" +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 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 time +from paramiko.transport import Transport +from paramiko.channel import Channel + +class Client: + def __init__(self, channel, transport, peername): + self.current_user = None + self.transport: Transport = transport + self.channel: Channel = channel + self.subchannel = {} + self.connecttype = None + self.last_activity_time = None + self.last_login_time = None + self.windowsize = {} + self.x11 = {} + self.prompt = None + self.inputbuffer = None + self.peername = peername + self.auth_method = self.transport.auth_handler.auth_method + self.session_id = None + self.terminal_type = None + self.env_variables = {} + self.last_error = None + self.last_command = None + self.isexeccommandrunning = False + + def get_id(self): + return self.session_id + + def get_name(self): + return self.current_user + + def get_peername(self): + return self.current_user + + def get_prompt(self): + return self.prompt + + def get_channel(self): + return self.channel + + def get_prompt_buffer(self): + return str(self.inputbuffer) + + def get_terminal_size(self): + return self.windowsize["width"], self.windowsize["height"] + + def get_connection_type(self): + return self.connecttype + + def get_auth_with(self): + return self.auth_method + + def get_session_duration(self): + return time.time() - self.last_login_time + + def get_environment(self, variable): + return self.env_variables[variable] + + def get_last_error(self): + return self.last_error + + def get_last_command(self): + return self.last_command + + def set_name(self, name): + self.current_user = name + + def set_prompt(self, prompt): + self.prompt = prompt + + def set_environment(self, variable, value): + self.env_variables[variable] = value + + def open_new_subchannel(self, timeout=None): + try: + channel = self.transport.accept(timeout) + id = channel.get_id() + except: + return None, None + + self.subchannel[id] = channel + return id, channel + + def get_subchannel(self, id): + return self.subchannel[id] + + def switch_user(self, user): + self.current_user = user + self.transport.auth_handler.username = user + + def close_subchannel(self, id): + self.subchannel[id].close() + + def close(self): + self.channel.close() + + # for backward compatibility only + def __getitem__(self, key): + return getattr(self, key) + + def __setitem__(self, key, value): + setattr(self, key, value) + + 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 diff --git a/src/PyserSSH/system/info.py b/src/PyserSSH/system/info.py index f973eaf..fca0e87 100644 --- a/src/PyserSSH/system/info.py +++ b/src/PyserSSH/system/info.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -24,11 +24,34 @@ 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 re -__version__ = "4.4" +__version__ = "5.0" system_banner = ( - f"\033[36mPyserSSH V{__version__} \033[0m\n" + 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" + #"\033[35mUse Putty and WinSCP (SFTP) for best experience \033[0m" ) + +def Flag_TH(returnlist=False): + Flags = [ + "\n", + f"\033[31m ======= == == ====== ======= ====== ====== ====== == == \033[0m\n", + f"\033[37m == == == == == === == == == == == == \033[0m\n", + f"\033[34m ======= ==== ======= ======= ====== ======= ======= ======== \033[0m\n", + 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", + "\n" + ] + + if returnlist: + return Flags + else: + exporttext = "" + + for line in Flags: + exporttext += line + return exporttext \ No newline at end of file diff --git a/src/PyserSSH/system/inputsystem.py b/src/PyserSSH/system/inputsystem.py index 66cf857..ddb4d64 100644 --- a/src/PyserSSH/system/inputsystem.py +++ b/src/PyserSSH/system/inputsystem.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -27,8 +27,6 @@ SOFTWARE. import socket import time import logging -import shlex -import traceback from .sysfunc import replace_enter_with_crlf from .syscom import systemcommand @@ -53,7 +51,7 @@ def expect(self, client, echo=True): chan.close() raise EOFError() - self._handle_event("onrawtype", self.client_handlers[chan.getpeername()], byte) + self._handle_event("rawtype", self.client_handlers[chan.getpeername()], byte) self.client_handlers[chan.getpeername()]["last_activity_time"] = time.time() @@ -146,7 +144,7 @@ def expect(self, client, echo=True): else: history_index_position = -1 - self._handle_event("ontype", self.client_handlers[chan.getpeername()], byte) + self._handle_event("type", self.client_handlers[chan.getpeername()], byte) if echo: if outindexall != cursor_position: chan.sendall(b" ") @@ -170,6 +168,7 @@ def expect(self, client, echo=True): if self.history and command.strip() != "" and self.accounts.get_lastcommand(client["current_user"]) != command: self.accounts.add_history(client["current_user"], command) + client["last_command"] = command if command.strip() != "": if self.accounts.get_user_timeout(self.client_handlers[chan.getpeername()]["current_user"]) != None: @@ -194,11 +193,11 @@ def expect(self, client, echo=True): except Exception as e: self._handle_event("error", client, e) - - try: - chan.send(replace_enter_with_crlf(client["prompt"] + " ").encode('utf-8')) - except: - logger.error("Send error") + if echo: + try: + chan.send(replace_enter_with_crlf(client["prompt"] + " ")) + except: + logger.error("Send error") chan.setblocking(False) chan.settimeout(None) @@ -206,14 +205,15 @@ def expect(self, client, echo=True): if self.accounts.get_user_timeout(self.client_handlers[chan.getpeername()]["current_user"]) != None: chan.setblocking(False) chan.settimeout(self.accounts.get_user_timeout(self.client_handlers[chan.getpeername()]["current_user"])) - + except socket.error: + pass except Exception as e: logger.error(str(e)) finally: try: if not byte: logger.info(f"{peername} is disconnected") - self._handle_event("disconnected", self.client_handlers[peername]["current_user"]) + self._handle_event("disconnected", self.client_handlers[peername]) except: - logger.info(f"{peername} is disconnected by timeout") - self._handle_event("timeout", self.client_handlers[peername]["current_user"]) \ No newline at end of file + logger.info(f"{peername} is disconnected") + self._handle_event("disconnected", self.client_handlers[peername]) \ No newline at end of file diff --git a/src/PyserSSH/system/interface.py b/src/PyserSSH/system/interface.py index c789c6d..19a84e6 100644 --- a/src/PyserSSH/system/interface.py +++ b/src/PyserSSH/system/interface.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -24,35 +24,192 @@ 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 time import paramiko +import ast + +from .syscom import systemcommand +from .remotestatus import startremotestatus + +def parse_exec_request(command_string): + try: + # Remove the leading 'b' and convert bytes to string + command_string = command_string.decode('utf-8') + + # Split the string into precommand and env parts + try: + parts = command_string.split(', ') + except: + parts = command_string.split(',') + + precommand_str = None + env_str = None + user_str = None + + for part in parts: + if part.startswith('precommand='): + precommand_str = part.split('=', 1)[1].strip() + elif part.startswith('env='): + env_str = part.split('=', 1)[1].strip() + elif part.startswith('user='): + user_str = part.split('=', 1)[1].strip() + + # Parse precommand using ast.literal_eval if present + precommand = ast.literal_eval(precommand_str) if precommand_str else None + + # Parse env using ast.literal_eval if present + env = ast.literal_eval(env_str) if env_str else None + + user = ast.literal_eval(user_str) if user_str else None + + return precommand, env, user + + except (ValueError, SyntaxError, TypeError) as e: + # Handle parsing errors here + print(f"Error parsing SSH command string: {e}") + return None, None, None + +def parse_exec_request_kwargs(command_string): + try: + # Remove the leading 'b' and convert bytes to string + command_string = command_string.decode('utf-8') + + # Split the string into key-value pairs + try: + parts = command_string.split(', ') + except: + parts = command_string.split(',') + + kwargs = {} + + for part in parts: + if '=' in part: + key, value = part.split('=', 1) + key = key.strip() + try: + value = ast.literal_eval(value.strip()) + except (ValueError, SyntaxError): + # If literal_eval fails, treat value as string + value = value.strip() + kwargs[key] = value + + return kwargs + + except (ValueError, SyntaxError, TypeError) as e: + # Handle parsing errors here + print(f"Error parsing command kwargs: {e}") + return {} class Sinterface(paramiko.ServerInterface): def __init__(self, serverself): - self.current_user = None self.serverself = serverself - def check_channel_request(self, kind, chanid): + def check_channel_request(self, kind, channel_id): if kind == 'session': return paramiko.OPEN_SUCCEEDED return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + def get_allowed_auths(self, username): + return self.serverself.accounts.get_allowed_auths(username) + def check_auth_password(self, username, password): data = { "username": username, "password": password, + "auth_type": "password" } if self.serverself.accounts.validate_credentials(username, password) and not self.serverself.usexternalauth: - self.current_user = username # Store the current user upon successful authentication return paramiko.AUTH_SUCCESSFUL else: if self.serverself._handle_event("auth", data): - self.current_user = username # Store the current user upon successful authentication return paramiko.AUTH_SUCCESSFUL else: return paramiko.AUTH_FAILED + def check_auth_none(self, username): + data = { + "username": username, + "auth_type": "none" + } + + if self.serverself.accounts.validate_credentials(username) and not self.serverself.usexternalauth: + return paramiko.AUTH_SUCCESSFUL + else: + if self.serverself._handle_event("auth", data): + return paramiko.AUTH_SUCCESSFUL + else: + return paramiko.AUTH_FAILED + + def check_auth_publickey(self, username, key): + data = { + "username": username, + "public_key": key, + "auth_type": "key" + } + + if self.serverself.accounts.validate_credentials(username, public_key=key) and not self.serverself.usexternalauth: + return paramiko.AUTH_SUCCESSFUL + else: + if self.serverself._handle_event("auth", data): + return paramiko.AUTH_SUCCESSFUL + else: + return paramiko.AUTH_FAILED + + def get_banner(self): + if self.serverself.enaloginbanner: + try: + banner, lang = self.serverself._handle_event("authbanner", None) + return banner, lang + except: + return "", "" + else: + return "", "" + + def check_channel_exec_request(self, channel, execommand): + if b"##Moba##" in execommand and self.serverself.enaremostatus: + startremotestatus(self.serverself, channel) + + client = self.serverself.client_handlers[channel.getpeername()] + + if self.serverself.enasysexec: + precommand, env, user = parse_exec_request(execommand) + + if env != None: + client.env_variables = env + + if user != None: + self.serverself._handle_event("exec", client, user) + + if precommand != None: + client.isexeccommandrunning = True + try: + if self.serverself.enasyscom: + sct = systemcommand(client, precommand) + else: + sct = False + + if not sct: + if self.serverself.XHandler != None: + self.serverself._handle_event("beforexhandler", client, precommand) + + self.serverself.XHandler.call(client, precommand) + + self.serverself._handle_event("afterxhandler", client, precommand) + else: + self.serverself._handle_event("command", client, precommand) + except Exception as e: + self.serverself._handle_event("error", client, e) + + client.isexeccommandrunning = False + else: + kwargs = parse_exec_request_kwargs(execommand) + + self.serverself._handle_event("exec", client, **kwargs) + + + return True + def check_channel_pty_request(self, channel, term, width, height, pixelwidth, pixelheight, modes): data = { "term": term, @@ -69,7 +226,9 @@ class Sinterface(paramiko.ServerInterface): "pixelheight": pixelheight, } try: + time.sleep(0.01) # fix waiting windowsize self.serverself.client_handlers[channel.getpeername()]["windowsize"] = data2 + self.serverself.client_handlers[channel.getpeername()]["terminal_type"] = term self.serverself._handle_event("connectpty", self.serverself.client_handlers[channel.getpeername()], data) except: pass @@ -102,4 +261,4 @@ class Sinterface(paramiko.ServerInterface): "pixelheight": pixelheight } self.serverself.client_handlers[channel.getpeername()]["windowsize"] = data - self.serverself._handle_event("resized", self.serverself.client_handlers[channel.getpeername()], data) + self.serverself._handle_event("resized", self.serverself.client_handlers[channel.getpeername()], data) \ No newline at end of file diff --git a/src/PyserSSH/system/remotestatus.py b/src/PyserSSH/system/remotestatus.py new file mode 100644 index 0000000..1cb08ba --- /dev/null +++ b/src/PyserSSH/system/remotestatus.py @@ -0,0 +1,271 @@ +""" +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 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 logging +import os +import socket +import threading +import time +import sys +import psutil +from datetime import datetime +import platform + +from ..interactive import Send +from .info import __version__ + +if platform.system() == "Windows": + import ctypes + +logger = logging.getLogger("PyserSSH") + +class LASTINPUTINFO(ctypes.Structure): + _fields_ = [ + ('cbSize', ctypes.c_uint), + ('dwTime', ctypes.c_uint), + ] + +def get_idle_time(): + if platform.system() == "Windows": + lastInputInfo = LASTINPUTINFO() + lastInputInfo.cbSize = ctypes.sizeof(lastInputInfo) + ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lastInputInfo)) + millis = ctypes.windll.kernel32.GetTickCount() - lastInputInfo.dwTime + return millis / 1000.0 + elif platform.system() == "Linux": + with open('/proc/stat') as f: + for line in f: + if line.startswith('btime'): + boot_time = float(line.split()[1]) + break + + with open('/proc/uptime') as f: + uptime_seconds = float(f.readline().split()[0]) + idle_time_seconds = uptime_seconds - (time.time() - boot_time) + + return idle_time_seconds + else: + return time.time() - psutil.boot_time() + +def get_system_uptime(): + if platform.system() == "Windows": + kernel32 = ctypes.windll.kernel32 + uptime = kernel32.GetTickCount64() / 1000.0 + return uptime + elif platform.system() == "Linux": + with open('/proc/uptime') as f: + uptime_seconds = float(f.readline().split()[0]) + + return uptime_seconds + else: + return 0 + +def get_folder_size(folder_path): + total_size = 0 + for dirpath, _, filenames in os.walk(folder_path): + for f in filenames: + fp = os.path.join(dirpath, f) + total_size += os.path.getsize(fp) + return total_size + +def get_folder_usage(folder_path, limit_size): + folder_size = get_folder_size(folder_path) + used_size = folder_size + free_size = limit_size - folder_size + percent_used = (folder_size / limit_size) * 100 if limit_size > 0 else 0 + return used_size, free_size, limit_size, percent_used + +librarypath = os.path.abspath(__file__).replace("\\", "/").split("/system/remotestatus.py")[0] + +def remotestatus(serverself, channel, oneloop=False): + try: + while True: + # Get RAM information + mem = psutil.virtual_memory() + + ramoutput = f"""\ +==> /proc/meminfo <== +MemTotal: {mem.total // 1024} kB +MemFree: {mem.free // 1024} kB +MemAvailable: {mem.available // 1024} kB +Buffers: 0 kB +Cached: 0 kB +SwapCached: 0 kB +Active: 0 kB +Inactive: 0 kB""" + + cpu_data = [] + + #currentprocess = psutil.Process().cpu_times() + + #cpu_data.append(["cpu", int(currentprocess.user), 0, int(currentprocess.system), 0, 0, 0, 0]) + + #if platform.system() == "Linux": + io_counters = psutil.disk_io_counters(perdisk=False) + io_wait_time = io_counters.read_time + io_counters.write_time + for idx, cpu_time in enumerate(psutil.cpu_times(True), start=-1): + if idx == -1: + cpu_data.append(["cpu", int(cpu_time.user), 0, int(cpu_time.system), int(cpu_time.idle), io_wait_time, int(cpu_time.interrupt), 0, 0, 0, 0]) + else: + cpu_data.append( + [f"cpu{idx}", int(cpu_time.user), 0, int(cpu_time.system), int(cpu_time.idle), io_wait_time, int(cpu_time.interrupt), 0, 0, 0, 0]) + + # Calculate maximum widths for formatting (optional) + max_widths = [max(len(str(row[i])) for row in cpu_data) for i in range(len(cpu_data[0]))] + + disk_data = [ + ["Filesystem", "1K-blocks", "Used", "Available", "Use%", "Mounted on"], + ] + + for disk in psutil.disk_partitions(True): + usage = psutil.disk_usage(disk.device) + mountpoint = disk.mountpoint + + if mountpoint == "C:\\": + mountpoint = "/" + + disk_data.append( + [disk.device.replace('\\', '/'), usage.total // 1024, usage.used // 1024, usage.free // 1024, f"{int(usage.percent)}%", + mountpoint]) + + libused, libfree, libtotal, libpercent = get_folder_usage(librarypath, 1024*1024) + + disk_data.append(["/dev/pyserssh", libtotal // 1024, libused // 1024, libfree // 1024, f"{int(libpercent)}%", "/python/pyserssh"]) + + max_widths3 = [max(len(str(row[i])) for row in disk_data) for i in range(len(disk_data[0]))] + + """ + network_data = [ + ["Inter-|", " Receive", "", "", "", "", "", "", " |", " Transmit" "", "", "", "", "", "", "", ""], + [" face |", "bytes", "packets", "errs", "drop", "fifo", "frame", "compressed", "multicast|", "bytes", "packets", + "errs", "drop", "fifo", "colls", "carrier", "compressed"] + ] + + for interface, stats in psutil.net_io_counters(pernic=True).items(): + network_data.append( + [f"{interface}:", stats.bytes_recv, stats.packets_recv, stats.errin, stats.dropin, 0, 0, 0, 0, + stats.bytes_sent, stats.packets_sent, stats.errout, stats.dropout, 0, 0, 0, 0]) + + max_widths2 = [max(len(str(row[i])) for row in network_data) for i in range(len(network_data[0]))] + + protocol_names = { + (socket.AF_INET, socket.SOCK_STREAM): 'tcp', + (socket.AF_INET, socket.SOCK_DGRAM): 'udp', + (socket.AF_INET6, socket.SOCK_STREAM): 'tcp6', + (socket.AF_INET6, socket.SOCK_DGRAM): 'udp6', + } + + netstat_data = [ + ["Proto", "Recv-Q", "Send-Q", "Local Address", "Foreign Address", "State", "PID/Program name"], + ] + + for conn in psutil.net_connections("all"): + if conn.status in ['TIME_WAIT', 'CLOSING', "NONE"]: + continue + + laddr_ip, laddr_port = conn.laddr if conn.laddr else ('', '') + raddr_ip, raddr_port = conn.raddr if conn.raddr else ('', '') + + protocol = protocol_names.get((conn.family, conn.type), 'Unknown') + + try: + process = psutil.Process(conn.pid) + processname = f"{conn.pid}/{process.name()}" + except psutil.NoSuchProcess: + processname = conn.pid + + netstat_data.append( + [protocol, 0, 0, f"{laddr_ip}:{laddr_port}", f"{raddr_ip}:{raddr_port}", conn.status, processname]) + + max_widths4 = [max(len(str(row[i])) for row in netstat_data) for i in range(len(netstat_data[0]))] + """ + + who_data = [] + + for idx, client in enumerate(serverself.client_handlers.values()): + last_login_date = datetime.utcfromtimestamp(client.last_login_time).strftime('%Y-%m-%d %H:%M') + who_data.append([client.current_user, f"pty/{idx}", last_login_date, f"({client.peername[0]})"]) + + max_widths5 = [max(len(str(row[i])) for row in who_data) for i in range(len(who_data[0]))] + + Send(channel, ramoutput, directchannel=True) + Send(channel, "", directchannel=True) + + # only support for CPU status current python process + Send(channel, "==> /proc/stat <==", directchannel=True) + for row in cpu_data: + Send(channel, " ".join("{:<{width}}".format(item, width=max_widths[i]) for i, item in enumerate(row)), directchannel=True) + + 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, "", directchannel=True) + Send(channel, "==> /proc/uptime <==", directchannel=True) + Send(channel, f"{get_system_uptime()} {get_idle_time()}", directchannel=True) + + Send(channel, "", directchannel=True) + Send(channel, "==> /proc/sys/kernel/hostname <==", directchannel=True) + Send(channel, platform.node(), directchannel=True) + + # fixing later for network status + #Send(channel, "", directchannel=True) + #Send(channel, "==> /proc/net/dev <==", directchannel=True) + #for row in network_data: + # Send(channel, " ".join("{:<{width}}".format(item, width=max_widths2[i]) for i, item in enumerate(row)), directchannel=True) + + Send(channel, "", directchannel=True) + Send(channel, "==> /proc/df <==", directchannel=True) + for row in disk_data: + Send(channel, " ".join("{:<{width}}".format(item, width=max_widths3[i]) for i, item in enumerate(row)), directchannel=True) + + # fixing later for network status + #Send(channel, "", directchannel=True) + #Send(channel, "==> /proc/netstat <==", directchannel=True) + #for row in netstat_data: + # Send(channel, " ".join("{:<{width}}".format(item, width=max_widths4[i]) for i, item in enumerate(row)), directchannel=True) + + Send(channel, "", directchannel=True) + Send(channel, "==> /proc/who <==", directchannel=True) + for row in who_data: + Send(channel, " ".join("{:<{width}}".format(item, width=max_widths5[i]) for i, item in enumerate(row)), directchannel=True) + + Send(channel, "", directchannel=True) + Send(channel, "==> /proc/end <==", directchannel=True) + Send(channel, "##Moba##", directchannel=True) + + if oneloop: + break + + time.sleep(1) + except socket.error: + pass + except Exception as e: + logger.error(e) + +def startremotestatus(serverself, channel): + t = threading.Thread(target=remotestatus, args=(serverself, channel), daemon=True) + t.start() \ No newline at end of file diff --git a/src/PyserSSH/system/syscom.py b/src/PyserSSH/system/syscom.py index 1eddab1..74f778c 100644 --- a/src/PyserSSH/system/syscom.py +++ b/src/PyserSSH/system/syscom.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License diff --git a/src/PyserSSH/system/sysfunc.py b/src/PyserSSH/system/sysfunc.py index c1b6999..9293ce9 100644 --- a/src/PyserSSH/system/sysfunc.py +++ b/src/PyserSSH/system/sysfunc.py @@ -1,8 +1,8 @@ """ -PyserSSH - A Scriptable SSH server. For more info visit https://github.com/damp11113/PyserSSH -Copyright (C) 2023-2024 damp11113 (MIT) +PyserSSH - A Scriptable SSH server. For more info visit https://github.com/DPSoftware-Foundation/PyserSSH +Copyright (C) 2023-2024 DPSoftware Foundation (MIT) -Visit https://github.com/damp11113/PyserSSH +Visit https://github.com/DPSoftware-Foundation/PyserSSH MIT License @@ -26,10 +26,20 @@ SOFTWARE. """ def replace_enter_with_crlf(input_string): - if '\n' in input_string: + if isinstance(input_string, str): + # Replace '\n' with '\r\n' in the string input_string = input_string.replace('\n', '\r\n') - return input_string - + # Encode the string to bytes + return input_string.encode() + elif isinstance(input_string, bytes): + # Decode bytes to string + decoded_string = input_string.decode() + # 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") def text_centered_screen(text, screen_width, screen_height, spacecharacter=" "): screen = []