mirror of
https://github.com/nchevsky/systemrescue-zfs.git
synced 2025-12-06 07:12:01 +01:00
364 lines
14 KiB
Plaintext
364 lines
14 KiB
Plaintext
|
|
#! /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}(?<!-)$", re.IGNORECASE)
|
||
|
|
if all(allowed.match(label) for label in labels):
|
||
|
|
return value
|
||
|
|
raise argparse.ArgumentTypeError("invalid hostname")
|
||
|
|
|
||
|
|
# automatically output full help in case of argument error
|
||
|
|
class ArgParserWithHelp(argparse.ArgumentParser):
|
||
|
|
def error(self, message):
|
||
|
|
sys.stderr.write('ERROR: %s\n' % message)
|
||
|
|
print()
|
||
|
|
self.print_help()
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
cliarg_parser = ArgParserWithHelp(description='Open a outbound SSH server connection (reverse SSH), primarily for remote support')
|
||
|
|
|
||
|
|
cliarg_parser.add_argument('-d',
|
||
|
|
'--debug',
|
||
|
|
action='store_true',
|
||
|
|
help='enable debug output')
|
||
|
|
|
||
|
|
cliarg_parser.add_argument('-b',
|
||
|
|
'--background',
|
||
|
|
action='store_true',
|
||
|
|
help='fork to background once the connection is established')
|
||
|
|
|
||
|
|
cliarg_parser.add_argument('-t',
|
||
|
|
'--tries',
|
||
|
|
type=check_unsigned,
|
||
|
|
default=0,
|
||
|
|
help='connection tries (0: endless, this is the default)')
|
||
|
|
|
||
|
|
cliarg_parser.add_argument('hostname',
|
||
|
|
metavar='hostname',
|
||
|
|
type=check_hostname_or_ip,
|
||
|
|
nargs=1,
|
||
|
|
help='hostname (or IP) to connect to')
|
||
|
|
|
||
|
|
cliarg_parser.add_argument('port',
|
||
|
|
metavar='port',
|
||
|
|
type=check_portno,
|
||
|
|
nargs=1,
|
||
|
|
help='TCP port number to connect to')
|
||
|
|
|
||
|
|
args = cliarg_parser.parse_args()
|
||
|
|
|
||
|
|
hostname = args.hostname[0]
|
||
|
|
port = args.port[0]
|
||
|
|
|
||
|
|
# make sure that the remote can log in at all
|
||
|
|
|
||
|
|
# first method: root password is set
|
||
|
|
root_pwd_set = False
|
||
|
|
root_pwd_re = re.compile(r"^root:\$[^:]+:")
|
||
|
|
|
||
|
|
with open("/etc/shadow") as shadow_file:
|
||
|
|
for line in shadow_file:
|
||
|
|
if (root_pwd_re.match(line)):
|
||
|
|
root_pwd_set = True
|
||
|
|
break
|
||
|
|
|
||
|
|
# second method: authorized_keys existing and not empty
|
||
|
|
auth_keys_path = "/root/.ssh/authorized_keys"
|
||
|
|
if not root_pwd_set and not ( os.path.exists(auth_keys_path) and os.path.getsize(auth_keys_path) > 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)
|