You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
webssh/main.py

386 lines
12 KiB

7 years ago
import io
7 years ago
import logging
import os.path
import socket
import threading
import traceback
7 years ago
import uuid
7 years ago
import weakref
7 years ago
import paramiko
import tornado.gen
import tornado.ioloop
7 years ago
import tornado.web
import tornado.websocket
from tornado.concurrent import Future
7 years ago
from tornado.ioloop import IOLoop
from tornado.iostream import _ERRNO_CONNRESET
7 years ago
from tornado.options import define, options, parse_command_line
from tornado.util import errno_from_exception
7 years ago
define('address', default='127.0.0.1', help='listen address')
define('port', default=8888, help='listen port', type=int)
define('debug', default=False, help='debug mode', type=bool)
define('policy', default='warning',
7 years ago
help='missing host key policy, reject|autoadd|warning')
7 years ago
define('period', default=10, help='seconds for periodic callback', type=int)
7 years ago
BUF_SIZE = 1024
DELAY = 3
workers = {}
class Worker(object):
def __init__(self, ssh, chan, dst_addr):
self.loop = IOLoop.current()
self.ssh = ssh
self.chan = chan
self.dst_addr = dst_addr
self.fd = chan.fileno()
self.id = str(id(self))
self.data_to_dst = []
self.handler = None
self.mode = IOLoop.READ
7 years ago
def __call__(self, fd, events):
if events & IOLoop.READ:
self.on_read()
if events & IOLoop.WRITE:
self.on_write()
if events & IOLoop.ERROR:
self.close()
def set_handler(self, handler):
if not self.handler:
self.handler = handler
def update_handler(self, mode):
if self.mode != mode:
self.loop.update_handler(self.fd, mode)
self.mode = mode
7 years ago
def on_read(self):
logging.debug('worker {} on read'.format(self.id))
try:
data = self.chan.recv(BUF_SIZE)
except (OSError, IOError) as e:
logging.error(e)
if errno_from_exception(e) in _ERRNO_CONNRESET:
self.close()
else:
logging.debug('"{}" from {}:{}'.format(data, *self.dst_addr))
if not data:
self.close()
return
logging.debug('"{}" to {}:{}'.format(data, *self.handler.src_addr))
try:
self.handler.write_message(data)
except tornado.websocket.WebSocketClosedError:
self.close()
7 years ago
def on_write(self):
logging.debug('worker {} on write'.format(self.id))
if not self.data_to_dst:
return
7 years ago
data = ''.join(self.data_to_dst)
logging.debug('"{}" to {}:{}'.format(data, *self.dst_addr))
7 years ago
try:
sent = self.chan.send(data)
except (OSError, IOError) as e:
7 years ago
logging.error(e)
if errno_from_exception(e) in _ERRNO_CONNRESET:
self.close()
else:
self.update_handler(IOLoop.WRITE)
7 years ago
else:
self.data_to_dst = []
7 years ago
data = data[sent:]
if data:
self.data_to_dst.append(data)
self.update_handler(IOLoop.WRITE)
else:
self.update_handler(IOLoop.READ)
7 years ago
def close(self):
logging.debug('Closing worker {}'.format(self.id))
if self.handler:
self.loop.remove_handler(self.fd)
self.handler.close()
self.chan.close()
self.ssh.close()
logging.info('Connection to {}:{} lost'.format(*self.dst_addr))
7 years ago
class MixinHandler(object):
7 years ago
def __init__(self, *args, **kwargs):
super(MixinHandler, self).__init__(*args, **kwargs)
def get_client_addr(self):
ip = self.request.headers.get('X-Real-Ip')
port = self.request.headers.get('X-Real-Port')
7 years ago
addr = None
if ip and port:
addr = (ip, int(port))
7 years ago
elif ip or port:
logging.warn('Wrong nginx configuration.')
return addr
class IndexHandler(MixinHandler, tornado.web.RequestHandler):
7 years ago
7 years ago
def get_privatekey(self):
try:
data = self.request.files.get('privatekey')[0]['body']
7 years ago
except TypeError:
return
return data.decode('utf-8')
def get_specific_pkey(self, pkeycls, privatekey, password):
logging.info('Trying {}'.format(pkeycls.__name__))
try:
pkey = pkeycls.from_private_key(io.StringIO(privatekey),
password=password)
except paramiko.PasswordRequiredException:
raise ValueError('Need password to decrypt the private key.')
except paramiko.SSHException:
7 years ago
pass
else:
return pkey
7 years ago
def get_pkey(self, privatekey, password):
password = password.encode('utf-8') if password else None
7 years ago
pkey = self.get_specific_pkey(paramiko.RSAKey, privatekey, password)\
or self.get_specific_pkey(paramiko.DSSKey, privatekey, password)\
or self.get_specific_pkey(paramiko.ECDSAKey, privatekey, password)\
or self.get_specific_pkey(paramiko.Ed25519Key, privatekey,
password)
if not pkey:
raise ValueError('Not a valid private key file or '
'wrong password for decrypting the private key.')
7 years ago
return pkey
def get_port(self):
value = self.get_value('port')
try:
port = int(value)
except ValueError:
port = 0
if 0 < port < 65536:
return port
7 years ago
raise ValueError('Invalid port {}'.format(value))
7 years ago
def get_value(self, name):
value = self.get_argument(name)
if not value:
7 years ago
raise ValueError('Empty {}'.format(name))
7 years ago
return value
def get_args(self):
hostname = self.get_value('hostname')
port = self.get_port()
username = self.get_value('username')
password = self.get_argument('password')
privatekey = self.get_privatekey()
pkey = self.get_pkey(privatekey, password) if privatekey else None
args = (hostname, port, username, password, pkey)
logging.debug(args)
return args
7 years ago
def get_client_addr(self):
return super(IndexHandler, self).get_client_addr() or self.request.\
connection.stream.socket.getpeername()
7 years ago
def ssh_connect(self):
ssh = paramiko.SSHClient()
ssh._system_host_keys = self.settings['system_host_keys']
ssh._host_keys = self.settings['host_keys']
ssh.set_missing_host_key_policy(self.settings['policy'])
7 years ago
7 years ago
args = self.get_args()
dst_addr = (args[0], args[1])
logging.info('Connecting to {}:{}'.format(*dst_addr))
7 years ago
try:
ssh.connect(*args, timeout=6)
except socket.error:
raise ValueError('Unable to connect to {}:{}'.format(*dst_addr))
except paramiko.BadAuthenticationType:
raise ValueError('Authentication failed.')
except paramiko.BadHostKeyException:
raise ValueError('Bad host key.')
7 years ago
7 years ago
chan = ssh.invoke_shell(term='xterm')
chan.setblocking(0)
worker = Worker(ssh, chan, dst_addr)
worker.src_addr = self.get_client_addr()
7 years ago
return worker
def ssh_connect_wrapped(self, future):
try:
worker = self.ssh_connect()
except Exception as exc:
7 years ago
logging.error(traceback.format_exc())
future.set_exception(exc)
else:
future.set_result(worker)
7 years ago
def get(self):
self.render('index.html')
@tornado.gen.coroutine
7 years ago
def post(self):
worker_id = None
status = None
future = Future()
t = threading.Thread(target=self.ssh_connect_wrapped, args=(future,))
t.setDaemon(True)
t.start()
7 years ago
7 years ago
try:
7 years ago
worker = yield future
except Exception as exc:
status = str(exc)
7 years ago
else:
worker_id = worker.id
workers[worker_id] = worker
IOLoop.current().call_later(DELAY, recycle, worker)
7 years ago
self.write(dict(id=worker_id, status=status))
class WsockHandler(MixinHandler, tornado.websocket.WebSocketHandler):
7 years ago
def __init__(self, *args, **kwargs):
self.loop = IOLoop.current()
self.worker_ref = None
7 years ago
super(WsockHandler, self).__init__(*args, **kwargs)
def get_client_addr(self):
return super(WsockHandler, self).get_client_addr() or self.stream.\
socket.getpeername()
7 years ago
def open(self):
7 years ago
self.src_addr = self.get_client_addr()
logging.info('Connected from {}:{}'.format(*self.src_addr))
7 years ago
worker = workers.get(self.get_argument('id'))
if worker and worker.src_addr[0] == self.src_addr[0]:
workers.pop(worker.id)
self.set_nodelay(True)
worker.set_handler(self)
self.worker_ref = weakref.ref(worker)
self.loop.add_handler(worker.fd, worker, IOLoop.READ)
else:
self.close()
7 years ago
def on_message(self, message):
logging.debug('"{}" from {}:{}'.format(message, *self.src_addr))
7 years ago
worker = self.worker_ref()
worker.data_to_dst.append(message)
worker.on_write()
def on_close(self):
logging.info('Disconnected from {}:{}'.format(*self.src_addr))
7 years ago
worker = self.worker_ref() if self.worker_ref else None
if worker:
worker.close()
def recycle(worker):
if worker.handler:
return
logging.debug('Recycling worker {}'.format(worker.id))
workers.pop(worker.id, None)
worker.close()
def get_host_keys(path):
if os.path.exists(path) and os.path.isfile(path):
return paramiko.hostkeys.HostKeys(filename=path)
7 years ago
return paramiko.hostkeys.HostKeys()
def save_host_keys(host_keys, filename):
length = len(host_keys)
if length != host_keys._last_len:
logging.info('Updating {}'.format(filename))
host_keys.save(filename)
host_keys._last_len = length
def get_policy_class(policy):
origin_policy = policy
policy = policy.lower()
if not policy.endswith('policy'):
policy += 'policy'
7 years ago
dic = {k.lower(): v for k, v in vars(paramiko.client).items() if type(v)
is type and issubclass(v, paramiko.client.MissingHostKeyPolicy)}
try:
cls = dic[policy]
except KeyError:
raise ValueError('Unknown policy {!r}'.format(origin_policy))
return cls
7 years ago
def get_application_settings():
base_dir = os.path.dirname(__file__)
filename = os.path.join(base_dir, 'known_hosts')
host_keys = get_host_keys(filename)
system_host_keys = get_host_keys(os.path.expanduser('~/.ssh/known_hosts'))
policy_class = get_policy_class(options.policy)
logging.info(policy_class.__name__)
if policy_class is paramiko.client.AutoAddPolicy:
7 years ago
host_keys.save(filename) # for permission test
host_keys._last_len = len(host_keys)
tornado.ioloop.PeriodicCallback(
lambda: save_host_keys(host_keys, filename),
options.period * 1000 # milliseconds
).start()
elif policy_class is paramiko.client.RejectPolicy:
if not host_keys and not system_host_keys:
raise ValueError('Empty known_hosts with reject policy?')
7 years ago
settings = dict(
template_path=os.path.join(base_dir, 'templates'),
static_path=os.path.join(base_dir, 'static'),
cookie_secret=uuid.uuid4().hex,
xsrf_cookies=True,
host_keys=host_keys,
system_host_keys=system_host_keys,
policy=policy_class(),
debug=options.debug
)
return settings
def main():
parse_command_line()
settings = get_application_settings()
7 years ago
handlers = [
(r'/', IndexHandler),
(r'/ws', WsockHandler)
]
app = tornado.web.Application(handlers, **settings)
app.listen(options.port, options.address)
logging.info('Listening on {}:{}'.format(options.address, options.port))
IOLoop.current().start()
if __name__ == '__main__':
main()