diff --git a/packages/sshnpdpy/lib/socket_connector.py b/packages/sshnpdpy/lib/socket_connector.py new file mode 100644 index 000000000..8e6b485c8 --- /dev/null +++ b/packages/sshnpdpy/lib/socket_connector.py @@ -0,0 +1,67 @@ +import socket, logging, threading, errno, select +from time import sleep +class SocketConnector: + _logger = logging.getLogger("sshrv | socket_connector") + def __init__(self, server1_ip, server1_port, server2_ip, server2_port, reuse_port = False, verbose = False): + self._logger.setLevel(logging.INFO) + self._logger.addHandler(logging.StreamHandler()) + if verbose: + self._logger.setLevel(logging.DEBUG) + + # Create sockets for both servers + self.socketA = socket.create_connection((server1_ip, server1_port)) + self.socketB = socket.create_connection((server2_ip, server2_port)) + self.socketA.setblocking(0) + self.socketB.setblocking(0) + self._logger.info("Sockets connected.") + self._logger.debug(f"Created sockets for {server1_ip}:{server1_port} and {server2_ip}:{server2_port}") + self.server1_ip = server1_ip + self.server1_port = server1_port + self.server2_ip = server2_ip + self.server2_port = server2_port + + if reuse_port: + self.socketA.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.socketA.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + + + def connect(self): + sockets_to_monitor = [self.socketA, self.socketB] + timeout = 0 + try: + while True: + for sock in sockets_to_monitor: + try: + data = sock.recv(1024) + if not data and timeout > 100: + print("Connection closed.") + sockets_to_monitor.remove(sock) + sock.close() + elif not data: + timeout += 1 + sleep(0.1) + if data == b'': + continue + else: + # Forward data to the other server + if sock is self.socketA: + self._logger.debug("SEND A -> B : " + str(data)) + self.socketB.send(data) + elif sock is self.socketB: + self._logger.debug("RECV B -> A : " + str(data)) + self.socketA.send(data) + timeout = 0 + + except socket.error as e: + if e.errno == errno.EWOULDBLOCK: + pass # No data available, continue + else: + raise + except Exception as e: + raise(e) + + + def close(self): + self.socketA.close() + self.socketB.close() + \ No newline at end of file diff --git a/packages/sshnpdpy/lib/sshnpdclient.py b/packages/sshnpdpy/lib/sshnpdclient.py index 5ba70e14f..854d46d19 100644 --- a/packages/sshnpdpy/lib/sshnpdclient.py +++ b/packages/sshnpdpy/lib/sshnpdclient.py @@ -1,7 +1,5 @@ +import os, threading, getpass, json, logging, subprocess from io import StringIO -import logging -import os, threading, getpass -import subprocess from queue import Empty, Queue from time import sleep from uuid import uuid4 @@ -9,34 +7,40 @@ from paramiko import SSHClient, SSHException, WarningPolicy from paramiko.ed25519key import Ed25519Key - from socket import socket from select import select from at_client import AtClient from at_client.common import AtSign -from at_client.util import EncryptionUtil +from at_client.util import EncryptionUtil, KeysUtil from at_client.common.keys import AtKey, Metadata, SharedKey from at_client.connections.notification.atevents import AtEvent, AtEventType +from .sshrv import SSHRV + class SSHNPDClient: #Current opened threads threads = [] - def __init__(self, atsign, manager_atsign, device, username=None, verbose=False ): + def __init__(self, atsign, manager_atsign, device, username=None, verbose=False, expecting_ssh_keys = False): #Threading Stuff self.closing = Event() + self.closing.clear() #AtClient Stuff self.atsign = atsign self.manager_atsign = manager_atsign self.device = device self.username = username + self.device_namespace = f".{device}.sshnp" self.at_client = AtClient(AtSign(atsign), queue=Queue(maxsize=20), verbose=verbose) + + #SSH Stuff self.ssh_client = None - self.device_namespace = f".{device}.sshnp" self.authenticated = False + self.rv = None + self.expecting_ssh_keys = expecting_ssh_keys #Logger self.logger = logging.getLogger("sshnpd") @@ -57,22 +61,25 @@ def start(self): if self.username: self._set_username() threading.Thread(target=self.at_client.start_monitor, args=(self.device_namespace,)).start() + threading.Thread(target=self._handle_notifications, args=(self.at_client.queue,), daemon=True).start() event_thread = threading.Thread(target=self._handle_events, args=(self.at_client.queue,)) event_thread.start() SSHNPDClient.threads.append(event_thread) - self._handle_notifications(self.at_client.queue, self.sshnp_callback) def is_alive(self): if not self.authenticated: return True elif len(SSHNPDClient.threads) >= 2 and self.ssh_client.get_transport().is_active(): return True + elif self.rv.is_alive(): + return True else: return False def join(self): self.closing.set() - self.ssh_client.close() + if self.ssh_client: + self.ssh_client.close() self.at_client.stop_monitor() for thread in SSHNPDClient.threads: thread.join() @@ -85,28 +92,28 @@ def _set_username(self): "username", AtSign(self.atsign), AtSign(self.manager_atsign)) self.at_client.put(username_key, username) self.username = username - - - def _handle_notifications(self, queue: Queue, callback): + + def _handle_notifications(self, queue: Queue): private_key = "" - sshPublicKey = "" + ssh_public_key_received = True if not self.expecting_ssh_keys else (False if "" else True) #sorry for making this so cursed ssh_notification_recieved = False - while not ssh_notification_recieved or sshPublicKey == "" or private_key == "": + + while not self.closing.is_set(): try: at_event = queue.get(block=False) event_type = at_event.event_type event_data = at_event.event_data - - # TODO: There's defintely a better way to do this if event_type == AtEventType.UPDATE_NOTIFICATION: queue.put(at_event) sleep(1) if event_type != AtEventType.DECRYPTED_UPDATE_NOTIFICATION: continue + key = event_data["key"].split(":")[1].split(".")[0] decrypted_value = str(event_data["decryptedValue"]) + if key == "privatekey": self.logger.debug( f'private key received from ${event_data["from"]} notification id : ${event_data["id"]}') @@ -114,21 +121,10 @@ def _handle_notifications(self, queue: Queue, callback): continue if key == "sshpublickey": - self.logger.debug( - f'ssh Public Key received from ${event_data["from"]} notification id : ${event_data["id"]}') - sshPublicKey = decrypted_value - # // Check to see if the ssh Publickey is already in the file if not append to the ~/.ssh/authorized_keys file - writeKey = False - with open(f"{self.ssh_path}/authorized_hosts", "r") as read: - filedata = read.read() - if sshPublicKey not in filedata: - writeKey = True - with open(f"{self.ssh_path}/authorized_hosts", "w") as write: - if writeKey: - write.write(f"\n{sshPublicKey}") - self.logger.debug("key written") + self.logger.debug(f'ssh Public Key received from ${event_data["from"]} notification id : ${event_data["id"]}') + self._handle_ssh_public_key(decrypted_value) continue - + #reverse ssh if key == "sshd": self.logger.debug( f'ssh callback requested from {event_data["from"]} notification id : {event_data["id"]}') @@ -136,18 +132,33 @@ def _handle_notifications(self, queue: Queue, callback): callbackArgs = [ at_event, private_key, + False + ] + try: + threading.Thread(target=self.sshnp_callback, args=(callbackArgs)).start() + except Exception as e: + raise e + + #direct ssh + if key == 'ssh_request': + self.logger.debug( + f'ssh callback requested from {event_data["from"]} notification id : {event_data["id"]}') + callbackArgs = [ + at_event, + "", + True ] - + try: + threading.Thread(target=self.sshnp_callback, args=(callbackArgs)).start() + except Exception as e: + raise e except Empty: pass - try: - callback(*callbackArgs) - except Exception as e: - raise e + #Running in a thread def _handle_events(self, queue: Queue): - while self.closing: + while not self.closing.is_set(): try: at_event = queue.get(block=False) event_type = at_event.event_type @@ -163,62 +174,63 @@ def _handle_events(self, queue: Queue): self.at_client.handle_event(queue, at_event) except Empty: pass + + + def _direct_ssh(self, hostname, port, sessionId): + sshrv = SSHRV(hostname, port) + sshrv.run() + self.rv = sshrv + self.logger.info("sshrv started @ " + hostname + " on port " + str(port)) + (public_key, private_key)= self._generate_ssh_keys(sessionId) + private_key = private_key.replace("\n", "\\n") + self._handle_ssh_public_key(public_key) + data = f'{{"status":"connected","sessionId":"{sessionId}","ephemeralPrivateKey":"{private_key}"}}' + signature = EncryptionUtil.sign_sha256_rsa(data, self.at_client.keys[KeysUtil.encryption_private_key_name]) + envelope = f'{{"payload":{data},"signature":"{signature}","hashingAlgo":"sha256","signingAlgo":"rsa2048"}}' + return envelope + + def sshnp_callback( + self, + event: AtEvent, + private_key="", + direct=False, + ): + uuid = event.event_data["id"] + if direct: + ssh_list = json.loads(event.event_data["decryptedValue"])['payload'] + else: + ssh_list = event.event_data["decryptedValue"].split(" ") + iv_nonce = EncryptionUtil.generate_iv_nonce() + metadata = Metadata( + ttl=10000, + ttr=-1, + iv_nonce=iv_nonce, + ) + at_key = AtKey(f"{uuid}.{self.device}", self.atsign) + at_key.shared_with = AtSign(self.manager_atsign) + at_key.metadata = metadata + at_key.namespace = self.device_namespace + if len(ssh_list) == 5 or direct: + uuid = ssh_list['sessionId'] if direct else ssh_list[4] + at_key = AtKey(f"{uuid}", self.atsign) + at_key.shared_with = AtSign(self.manager_atsign) + at_key.metadata = metadata + at_key.namespace =self.device_namespace + ssh_response = None + + if direct: + ssh_response = self._direct_ssh(ssh_list['host'], ssh_list['port'], ssh_list['sessionId']) + else: + ssh_response = self._reverse_ssh_client(ssh_list, private_key) + + if ssh_response: + notify_response = self.at_client.notify(at_key, ssh_response, session_id=uuid) + self.logger.info("sent ssh notification to " + at_key.shared_with.to_string() + "with id:" + uuid) + self.authenticated = True + if direct: + self._ephemeral_cleanup(uuid) - def _reverse_ssh_exec(self, ssh_list: list, private_key, sessionID): - local_port = ssh_list[0] - port = ssh_list[1] - username = ssh_list[2] - hostname = ssh_list[3] - filename = f"/tmp/.{uuid4()}" - exitCode = 0 - if not private_key.endswith("\n"): - private_key += "\n" - with open(filename, "w") as file: - file.write(private_key) - subprocess.run(["chmod", "go-rwx", filename]) - args = [ - "ssh", - f"{username}@{hostname}", - "-p", - port, - "-i", - filename, - "-R", - f"{local_port}:localhost:22", - "-t", - "-o", - "StrictHostKeyChecking=accept-new", - "-o", - "IdentitiesOnly=yes", - "-o", - "BatchMode=yes", - "-o", - "ExitOnForwardFailure=yes", - "-v", - ] - try: - process = subprocess.Popen( - args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True - ) - stdout, stderr = process.communicate() - exitCode = process.returncode - if exitCode != 0: - print("ssh session failed for " + username) - else: - print( - "ssh session started for " - + username - + "@" - + hostname - + " on port " - + port - ) - os.remove(filename) - except Exception as e: - print(e) - - return exitCode == 0 #Running in a thread def _forward_socket_handler(self, chan, dest): @@ -233,7 +245,7 @@ def _forward_socket_handler(self, chan, dest): f"Connected! Tunnel open {chan.origin_addr} -> {chan.getpeername()} -> {dest}" ) - while self.closing: + while not self.closing.is_set(): r, w, x = select([sock, chan], [], []) if sock in r: data = sock.recv(1024) @@ -251,7 +263,7 @@ def _forward_socket_handler(self, chan, dest): #running in a threads def _forward_socket(self, tp, dest): - while self.closing: + while not self.closing.is_set(): chan = tp.accept(1000) if chan is None: continue @@ -270,17 +282,22 @@ def _reverse_ssh_client(self, ssh_list: list, private_key: str): port = ssh_list[1] username = ssh_list[2] hostname = ssh_list[3] + if "\\" in username: + username = username.split("/")[-1] self.logger.info("ssh session started for " + username + " @ " + hostname + " on port " + port) - ssh_client = SSHClient() - ssh_client.load_system_host_keys(f"{self.ssh_path}/known_hosts") - ssh_client.set_missing_host_key_policy(WarningPolicy()) - file_like = StringIO(private_key) - paramiko_log = logging.getLogger("paramiko.transport") - paramiko_log.setLevel(self.logger.level) - paramiko_log.addHandler(logging.StreamHandler()) + if self.ssh_client == None: + ssh_client = SSHClient() + ssh_client.load_system_host_keys(f"{self.ssh_path}/known_hosts") + ssh_client.set_missing_host_key_policy(WarningPolicy()) + file_like = StringIO(private_key) + paramiko_log = logging.getLogger("paramiko.transport") + paramiko_log.setLevel(self.logger.level) + paramiko_log.addHandler(logging.StreamHandler()) + self.ssh_client = ssh_client + try: pkey = Ed25519Key.from_private_key(file_obj=file_like) - ssh_client.connect( + self.ssh_client.connect( hostname=hostname, port=port, username=username, @@ -289,7 +306,7 @@ def _reverse_ssh_client(self, ssh_list: list, private_key: str): timeout=10, disabled_algorithms={"pubkeys": ["rsa-sha2-512", "rsa-sha2-256"]}, ) - tp = ssh_client.get_transport() + tp = self.ssh_client.get_transport() self.logger.info("Forwarding port " + local_port + " to " + hostname + ":" + port) tp.request_port_forward("", int(local_port)) thread = threading.Thread( @@ -305,38 +322,61 @@ def _reverse_ssh_client(self, ssh_list: list, private_key: str): raise(f'SSHError (Make sure you do not have another sshnpd running): $e') except Exception as e: raise(e) - self.ssh_client = ssh_client - return True - + + return "connected" - def sshnp_callback( - self, - event: AtEvent, - private_key, - ): - uuid = event.event_data["id"] - ssh_list = event.event_data["decryptedValue"].split(" ") - iv_nonce = EncryptionUtil.generate_iv_nonce() - metadata = Metadata( - ttl=10000, - ttr=-1, - iv_nonce=iv_nonce, + def _generate_ssh_keys(self, session_id): + # Generate SSH Keys + self.logger.info("Generating SSH Keys") + if not os.path.exists(f"{self.ssh_path}/tmp/"): + os.makedirs(f"{self.ssh_path}/tmp/") + + ssh_keygen = subprocess.Popen( + ["ssh-keygen", "-t", "ed25519", "-a", "100", "-f", f"{session_id}_sshnp", "-q", "-N", ""], + cwd=f'{self.ssh_path}/tmp/', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ) - at_key = AtKey(f"{uuid}.", self.atsign) - at_key.shared_with = AtSign(self.manager_atsign) - at_key.metadata = metadata - at_key.namespace = self.device_namespace - if len(ssh_list) == 5: - uuid = ssh_list[4] - at_key = AtKey(f"{uuid}", self.atsign) - at_key.shared_with = AtSign(self.manager_atsign) - at_key.metadata = metadata - at_key.namespace =self.device_namespace - ssh_auth = self._reverse_ssh_client(ssh_list, private_key) + stdout, stderr = ssh_keygen.communicate() + if ssh_keygen.returncode != 0: + self.logger.error("SSH Key generation failed") + self.logger.error(stderr.decode("utf-8")) + return False - if ssh_auth: - response = self.at_client.notify(at_key, "connected") - self.logger.info("sent ssh notification to " + at_key.shared_with.to_string()) - self.authenticated = True - \ No newline at end of file + self.logger.info("SSH Keys Generated") + ssh_public_key = "" + ssh_private_key = "" + try: + with open(f"{self.ssh_path}/tmp/{session_id}_sshnp.pub", 'r') as public_key_file: + ssh_public_key = public_key_file.read() + + with open(f"{self.ssh_path}/tmp/{session_id}_sshnp", 'r') as private_key_file: + ssh_private_key = private_key_file.read() + + except Exception as e: + self.logger.error(e) + return False + + return (ssh_public_key, ssh_private_key) + + def _ephemeral_cleanup(self, session_id): + try: + os.remove(f"{self.ssh_path}/tmp/{session_id}_sshnp.pub") + os.remove(f"{self.ssh_path}/tmp/{session_id}_sshnp") + self.logger.info("ephemeral ssh keys cleaned up") + except Exception as e: + self.logger.error(e) + + def _handle_ssh_public_key(self, ssh_public_key): + # // Check to see if the ssh Publickey is already in the file if not append to the ~/.ssh/authorized_keys file + writeKey = False + filedata = "" + with open(f"{self.ssh_path}/authorized_keys", "r") as read: + filedata = read.read() + if ssh_public_key not in filedata: + writeKey = True + if writeKey: + with open(f"{self.ssh_path}/authorized_keys", "w") as write: + write.write(f"{filedata}\n{ssh_public_key}") + self.logger.debug("key written" ) \ No newline at end of file diff --git a/packages/sshnpdpy/lib/sshrv.py b/packages/sshnpdpy/lib/sshrv.py new file mode 100644 index 000000000..24754b081 --- /dev/null +++ b/packages/sshnpdpy/lib/sshrv.py @@ -0,0 +1,34 @@ +import logging +import socket +import threading + +from .socket_connector import SocketConnector + + +class SSHRV: + def __init__(self, destination, port, local_port = 22, verbose = False): + self.logger = logging.getLogger("sshrv") + self.host = "" + self.destination = destination + self.local_ssh_port = local_port + self.streaming_port = port + self.socket_connector = None + self.verbose = verbose + + + def run(self): + try: + self.host = socket.gethostbyname(socket.gethostname()) + socket_connector = SocketConnector(self.host, self.local_ssh_port, self.destination, self.streaming_port, reuse_port=True, verbose=self.verbose) + t1 = threading.Thread(target=socket_connector.connect) + t1.start() + self.socket_connector = t1 + return True + + except Exception as e: + logging.error("SSHRV Error: " + str(e)) + + def is_alive(self): + return self.socket_connector.is_alive() + + \ No newline at end of file diff --git a/packages/sshnpdpy/sshnpd.py b/packages/sshnpdpy/sshnpd.py index e995542ec..ef91ecc20 100755 --- a/packages/sshnpdpy/sshnpd.py +++ b/packages/sshnpdpy/sshnpd.py @@ -13,11 +13,12 @@ def main(): optional = parser.add_argument_group('optional arguments') optional.add_argument("-u", action='store_true', dest="username", help="Username", default="default") optional.add_argument("-v", action='store_true', dest="verbose", help="Verbose") + optional.add_argument("-s", action="store_true", dest="expecting_ssh_keys", help="SSH Keypair, use this if you want to use your own ssh keypair") args = parser.parse_args() - sshnpd = SSHNPDClient(args.atsign, args.manager_atsign, args.device, args.username, args.verbose) + sshnpd = SSHNPDClient(args.atsign, args.manager_atsign, args.device, args.username, args.verbose, args.expecting_ssh_keys) try: sshnpd.start()