#! /usr/bin/env python3 # # reverse_ssh - Open a outbound SSH server connection (reverse SSH), primarily for remote support # # Author: Gerd v. Egidy # SPDX-License-Identifier: GPL-3.0-or-later # # Usually ssh connections are built using tcp from the ssh client to the server. # This can be difficult if the server is behind a NAT router or firewall. # reverse_ssh is run on the server system and creates an outbound tcp connection # to the ssh client. This outgoing tcp connection has a much better chance to pass # through the NAT router or firewall. # # Reversing the connection direction on client and server is done using socat # http://www.dest-unreach.org/socat/ # # Usage: # reverse_ssh [-h] [-d] [-b] [-t TRIES] hostname port # # positional arguments: # hostname hostname (or IP) to connect to # port TCP port number to connect to # # optional arguments: # -h, --help show this help message and exit # -d, --debug enable debug output # -b, --background fork to background once the connection is established # -t TRIES, --tries TRIES connection tries (0: endless, this is the default) # # Receiving reverse_ssh connections on the ssh client: # (the system with the ssh client must be accessible from the internet on $RECEIVEPORT) # export RECEIVEPORT=2222 # ssh -l root -o "ProxyCommand socat - TCP4-LISTEN:${RECEIVEPORT},reuseaddr" -o StrictHostKeyChecking=no none # # Receiving reverse_ssh connections with a bounce host: # (if the system with the ssh client is not directly reachable, a bounce host can be used) # (requires "GatewayPorts yes" in /etc/ssh/sshd_config on the bounce host) # export RECEIVEPORT=2222 # ssh -R ${RECEIVEPORT}:/tmp/reverse_ssh -N -f bouncehost.example.com # ssh -l root -o "ProxyCommand socat - UNIX-LISTEN:/tmp/reverse_ssh" -o StrictHostKeyChecking=no none # # For more information see # https://www.system-rescue.org/scripts/reverse_ssh/ # import argparse import os import sys import socket import re import subprocess import time import signal import syslog min_retry_seconds = 3 # raise an exception on SIGTERM, so that we can kill a running socat process def sigterm_handler(signum, frame): raise OSError("SIGTERM") signal.signal(signal.SIGTERM, sigterm_handler) def createDaemon(): """Detach a process from the controlling terminal and run it in the background as a daemon. taken from https://code.activestate.com/recipes/278731-creating-a-daemon-the-python-way/ Copyright Chad J. Schroeder, licensed under the Python Software Foundation License (PSF) """ # Fork a child process so the parent can exit. This returns control to # the command-line or shell. It also guarantees that the child will not # be a process group leader, since the child receives a new process ID # and inherits the parent's process group ID. This step is required # to insure that the next call to os.setsid is successful. pid = os.fork() if (pid == 0): # The first child. # To become the session leader of this new session and the process group # leader of the new process group, we call os.setsid(). The process is # also guaranteed not to have a controlling terminal. os.setsid() # Fork a second child and exit immediately to prevent zombies. This # causes the second child process to be orphaned, making the init # process responsible for its cleanup. And, since the first child is # a session leader without a controlling terminal, it's possible for # it to acquire one by opening a terminal in the future (System V- # based systems). This second fork guarantees that the child is no # longer a session leader, preventing the daemon from ever acquiring # a controlling terminal. pid = os.fork() # Fork a second child. if (pid == 0): # The second child. # Since the current working directory may be a mounted filesystem, we # avoid the issue of not being able to unmount the filesystem at # shutdown time by changing it to the root directory. os.chdir("/") # redirect stdin, stdout, stderr to /dev/null os.close(0) os.close(1) os.close(2) # This call to open is guaranteed to return the lowest file descriptor, # which will be 0 (stdin), since it was closed above. os.open("/dev/null", os.O_RDWR) # standard input (0) # Duplicate standard input to standard output and standard error. os.dup2(0, 1) # standard output (1) os.dup2(0, 2) # standard error (2) return(0) else: # exit() or _exit()? See below. os._exit(0) # Exit parent (the first child) of the second child. else: # exit() or _exit()? # _exit is like exit(), but it doesn't call any functions registered # with atexit (and on_exit) or any registered signal handlers. It also # closes any open file descriptors. Using exit() may cause all stdio # streams to be flushed twice and any temporary files may be unexpectedly # removed. It's therefore recommended that child branches of a fork() # and the parent branch(es) of a daemon use _exit(). os._exit(0) # Exit parent of the first child. def check_portno(value): ivalue = int(value) if ivalue <= 0 or ivalue > 65535: raise argparse.ArgumentTypeError("port number must be between 1 and 65535") return ivalue def check_unsigned(value): ivalue = int(value) if ivalue < 0: raise argparse.ArgumentTypeError("only positive integers allowed") return ivalue def check_hostname_or_ip(value): # check if it is a valid IPv6 try: socket.inet_pton(socket.AF_INET6, value) return value except Exception: # no IPv6, continue pass # check if it is a valid IPv4 try: socket.inet_pton(socket.AF_INET, value) return value except Exception: # no IPv4, continue pass # check if it is a valid dns hostname if value[-1] == ".": # strip exactly one dot from the right, if present value = value[:-1] if len(value) > 253: raise argparse.ArgumentTypeError("invalid hostname, too long") labels = value.split(".") # the TLD must be not all-numeric if re.match(r"[0-9]+$", labels[-1]): raise argparse.ArgumentTypeError("invalid hostname") allowed = re.compile(r"(?!-)[a-z0-9-]{1,63}(? 0 ): print("ERROR: neither a root password nor public key has been configured", file=sys.stderr) print("", file=sys.stderr) print("hint: use the \"passwd\" command to set a password", file=sys.stderr) sys.exit(2) # make sure that ssh is running s = socket.socket() s.settimeout(2) s.setblocking(True) result = s.connect_ex(("localhost", 22)) s.close() if result: print("ERROR: can't connect to ssh daemon on localhost", file=sys.stderr) print("", file=sys.stderr) print("hint: start sshd with the command \"systemctl start sshd.service\"", file=sys.stderr) sys.exit(3) # make sure that socat is installed if not os.path.exists("/usr/bin/socat") or not os.path.getsize("/usr/bin/socat") > 0: print("ERROR: socat not installed", file=sys.stderr) print("", file=sys.stderr) print("hint: install socat with the command \"pacman -Sy socat\"", file=sys.stderr) sys.exit(4) # parameter and system checks ok, proceed to connect syslog.openlog(logoption=syslog.LOG_PID, facility=syslog.LOG_USER) syslog.syslog(f"initiating connection to ssh client at {hostname}:{port}") socat_out_re = re.compile(r"^.+? socat\[[0-9]+\] ([A-Z]) (.+)") eof_msg_re = re.compile(r"^socket .* is at EOF") connected_msg_re = re.compile(r"^transferred [0-9]+ bytes from [0-9]+ to [0-9]+") # prepare retry variables if args.tries == 0: endless = True retry = 0 else: endless = False retry = args.tries once_connected = False try: # main retry loop while retry > 0 or endless: connected = False starttime = time.monotonic() # connect to the locally running ssh on port 22 first # then try the outbound connection to the given host and port # in case of success, the sockets are connected and the remote end can use a ssh client # in case of error, the socat process is terminated # (the retry of socat doesn't work predictable enough, depends very much on type of error) # always run it in high debug output mode because status info can only be extracted that way socat_process = subprocess.Popen(['/usr/bin/socat', '-d', '-d', '-d', 'TCP:localhost:22', f"TCP:{hostname}:{port},connect-timeout=15" ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) # loop through the lines written to stdout+stderr while socat is running while True: outline = socat_process.stdout.readline() if len(outline) == 0 and socat_process.poll() is not None: # process ended and we got all output break if len(outline) > 0: outline = outline.decode('utf-8') if args.debug: print(outline.strip()) splitline = socat_out_re.match(outline) if splitline: # we could split the debug output into date, process, severity and actual message severity = splitline.group(1) message = splitline.group(2) if severity == "E" or severity == "W": # some error or warning occured, show it print(message) syslog.syslog(message) if severity == "I" and connected_msg_re.match(message) and not connected: # the first "transferred nn bytes..." message denotes that we really have # a connection. But these messages are repeated, so just the first counts print("Connected") syslog.syslog(f"connected to {hostname}:{port}") connected = True once_connected = True # we have a connection, don't retry to establish one when this one is terminated retry = 0 endless = False if args.background: createDaemon() if severity == "N" and eof_msg_re.match(message) and connected: # the first "socket .* is at EOF" message denotes that the connection # was terminated. But these messages are repeated, so just the first counts print("Connection terminated") syslog.syslog("connection terminated") connected = False if retry > 0: retry -= 1 if retry > 0 or endless: # we will retry if time.monotonic() - starttime < min_retry_seconds: # don't hammer the remote in case of errors time.sleep(min_retry_seconds) except (KeyboardInterrupt, OSError): # we want a nicer message for Ctrl-c # also the SIGTERM call flow ends up here print() print("Aborted") syslog.syslog("Aborted") try: socat_process.kill() except Exception: # ignore if killing doesn't work, it could be that no process is running pass sys.exit(5) if once_connected: # everything ok sys.exit(0) else: # some error while trying to establish a connection sys.exit(6)