# -*- coding: utf-8 -*-
#
# Copyright 2011 Liftoff Software Corporation
#
# TODO: Complete this docstring...
__doc__ = """\
ssh.py - A plugin for Gate One that adds additional SSH-specific features.
Hooks
-----
This Python plugin file implements the following hooks::
hooks = {
'WebSocket': {
'terminal:ssh_get_known_hosts': get_known_hosts,
'terminal:ssh_save_known_hosts': save_known_hosts,
'terminal:ssh_get_connect_string': get_connect_string,
'terminal:ssh_execute_command': ws_exec_command,
'terminal:ssh_get_identities': get_identities,
'terminal:ssh_get_public_key': get_public_key,
'terminal:ssh_get_private_key': get_private_key,
'terminal:ssh_get_host_fingerprint': get_host_fingerprint,
'terminal:ssh_gen_new_keypair': generate_new_keypair,
'terminal:ssh_store_id_file': store_id_file,
'terminal:ssh_delete_identity': delete_identity,
'terminal:ssh_set_default_identities': set_default_identities
},
'Escape': opt_esc_handler,
'Events': {
'terminal:authenticate': send_css_template,
'terminal:authenticate': create_user_ssh_dir
}
}
Docstrings
----------
"""
# Meta
__version__ = '1.1'
__version_info__ = (1, 1)
__license__ = "GNU AGPLv3 or Proprietary (see LICENSE.txt)"
__author__ = 'Dan McDougall <daniel.mcdougall@liftoffsoftware.com>'
# Python stdlib
import os, re, io
from datetime import datetime, timedelta
from functools import partial
# Our stuff
from gateone.core.server import BaseHandler
from gateone.core.utils import mkdir_p, shell_command, which
from gateone.core.utils import noop, bind
from gateone.core.locale import get_translation
from gateone.core.log import go_logger
_ = get_translation()
# Tornado stuff
import tornado.web
import tornado.ioloop
# Globals
ssh_log = go_logger("gateone.terminal.ssh", plugin='ssh')
OPENSSH_VERSION = None
DROPBEAR_VERSION = None
PLUGIN_PATH = os.path.split(__file__)[0] # Path to this plugin's directory
OPEN_SUBCHANNELS = {}
SUBCHANNEL_TIMEOUT = timedelta(minutes=5) # How long to wait before auto-closing
READY_STRING = "GATEONE_SSH_EXEC_CMD_CHANNEL_READY"
READY_MATCH = re.compile("^%s$" % READY_STRING, re.MULTILINE)
OUTPUT_MATCH = re.compile(
"^{rs}.+^{rs}$".format(rs=READY_STRING), re.MULTILINE|re.DOTALL)
VALID_PRIVATE_KEY = valid = re.compile(
r'^-----BEGIN [A-Z]+ PRIVATE KEY-----.*-----END [A-Z]+ PRIVATE KEY-----$',
re.MULTILINE|re.DOTALL)
TIMER = None # Used to store temporary, cancellable timeouts
# TODO: make execute_command() a user-configurable option... So it will automatically run whatever command(s) the user likes whenever they connect to a given server. Differentiate between when they connect and when they start up a master or slave channel.
# Exceptions
[docs]class SSHMultiplexingException(Exception):
"""
Called when there's a failure trying to open a sub-shell via OpenSSH's
`Master mode <http://en.wikibooks.org/wiki/OpenSSH/Cookbook/Multiplexing>`_
multiplexing capability.
"""
pass
[docs]class SSHExecutionException(Exception):
"""
Called when there's an error trying to execute a command in the slave.
"""
pass
[docs]class SSHKeygenException(Exception):
"""
Called when there's an error trying to generate a public/private keypair.
"""
pass
[docs]class SSHKeypairException(Exception):
"""
Called when there's an error trying to save public/private keypair or
certificate.
"""
pass
[docs]class SSHPassphraseException(Exception):
"""
Called when we try to generate/decode something that requires a passphrase
but no passphrase was given.
"""
pass
[docs]def get_ssh_dir(self):
"""
Given a :class:`gateone.TerminalWebSocket` (*self*) instance, return the
current user's ssh directory
.. note:: If the user's ssh directory doesn't start with a . (dot) it will be renamed.
"""
user = self.current_user['upn']
users_dir = os.path.join(self.ws.settings['user_dir'], user) # "User's dir"
old_ssh_dir = os.path.join(users_dir, 'ssh')
users_ssh_dir = os.path.join(users_dir, '.ssh')
if os.path.exists(old_ssh_dir):
if not os.path.exists(users_ssh_dir):
self.ssh_log.info(_(
"Renaming %s's 'ssh' directory to '.ssh'." % user))
os.rename(old_ssh_dir, users_ssh_dir)
else:
self.ssh_log.warning(_(
"Both an 'ssh' and '.ssh' directory exist for user %s. "
"Using the .ssh directory." % user))
return users_ssh_dir
[docs]def open_sub_channel(self, term):
"""
Opens a sub-channel of communication by executing a new shell on the SSH
server using OpenSSH's `Master mode <http://en.wikibooks.org/wiki/OpenSSH/Cookbook/Multiplexing>`_
capability (it spawns a new slave) and returns the resulting
:class:`termio.Multiplex` instance. If a slave has already been opened for
this purpose it will re-use the existing channel.
"""
term = int(term)
global OPEN_SUBCHANNELS
if term in OPEN_SUBCHANNELS and OPEN_SUBCHANNELS[term].isalive():
# Use existing sub-channel (much faster this way)
return OPEN_SUBCHANNELS[term]
self.ssh_log.info("Opening SSH sub-channel", metadata={'term': term})
# NOTE: When connecting a slave via ssh you can't tell it to execute a
# command like you normally can (e.g. 'ssh user@host <some command>'). This
# is why we're using the termio.Multiplex.expect() functionality below...
session = self.ws.session
session_dir = self.ws.settings['session_dir']
session_path = os.path.join(session_dir, session)
if not session_path:
raise SSHMultiplexingException(_(
"SSH Plugin: Unable to open slave sub-channel."))
socket_path = self.loc_terms[term]['ssh_socket']
# Interesting: When using an existing socket you don't need to give it all
# the same options as you used to open it but you still need to give it
# *something* in place of the hostname or it will report a syntax error and
# print out the help. So that's why I've put 'go_ssh_remote_cmd' below.
# ...but I could have just used 'foo' :)
if not socket_path:
raise SSHMultiplexingException(_(
"SSH Plugin: Unable to open slave sub-channel."))
users_ssh_dir = get_ssh_dir(self)
ssh_config_path = os.path.join(users_ssh_dir, 'config')
if not os.path.exists(ssh_config_path):
# Create it (an empty one so ssh doesn't error out)
with open(ssh_config_path, 'w') as f:
f.write('\n')
# Hopefully 'go_ssh_remote_cmd' will be a clear enough indication of
# what is going on by anyone that has to review the logs...
ssh = which('ssh')
ssh_command = "%s -x -S'%s' -F'%s' go_ssh_remote_cmd" % (
ssh, socket_path, ssh_config_path)
OPEN_SUBCHANNELS[term] = m = self.new_multiplex(
ssh_command, "%s (sub)" % term)
# Using huge numbers here so we don't miss much (if anything) if the user
# executes something like "ps -ef".
m.spawn(rows=100, cols=200) # Hopefully 100/200 lines/cols is enough
# ...if it isn't, well, that's not really what this is for :)
# Set the term title so it gets a proper name in the logs
m.writeline(u'echo -e "\\033]0;Term %s sub-channel\\007"' % term)
return m
[docs]def wait_for_prompt(term, cmd, errorback, callback, m_instance, matched):
"""
Called by :func:`termio.Multiplex.expect` inside of :func:`execute_command`,
clears the screen and executes *cmd*. Also, sets an
:func:`~termio.Multiplex.expect` to call :func:`get_cmd_output` when the
end of the command output is detected.
"""
ssh_log.debug('wait_for_prompt()')
m_instance.term.clear_screen() # Makes capturing just what we need easier
getoutput = partial(get_cmd_output, term, errorback, callback)
m_instance.expect(OUTPUT_MATCH,
getoutput, errorback=errorback, preprocess=False, timeout=10)
# Run our command immediately followed by our separation/ready string
m_instance.writeline((
u'echo -e "{rs}"; ' # Echo the first READY_STRING
u'{cmd}; ' # Execute the command in question
u'echo -e "{rs}"' # READY_STRING again so we can capture the between
).format(rs=READY_STRING, cmd=cmd))
[docs]def get_cmd_output(term, errorback, callback, m_instance, matched):
"""
Captures the output of the command executed inside of
:func:`wait_for_prompt` and calls *callback* if it isn't `None`.
"""
ssh_log.debug('get_cmd_output()')
cmd_out = [a.rstrip() for a in m_instance.dump() if a.rstrip()]
capture = False
out = []
for line in cmd_out:
if not capture and line.startswith(READY_STRING):
capture = True
elif capture:
if READY_STRING in line:
break
out.append(line)
# This is just a silly trick to get the shell timing out/terminating itself
# after a timout (so we don't keep the sub-channel open forever). It is
# easier than starting a timeout thread, timer, IOLoop.add_timeout(), etc
# (I tried all those and it seemed to result in m_instance never getting
# cleaned up properly by the garbage collector--it would leak memory)
m_instance.unexpect() # Clear out any existing patterns (i.e. keepalive ;)
m_instance.expect( # Add our inactivity timeout
"^SUB-CHANNEL INACTIVITY TIMEOUT$", # ^ and $ to prevent accidents ;)
noop, # Don't need to do anything since this should never match
errorback=timeout_sub_channel,
preprocess=False,
timeout=SUBCHANNEL_TIMEOUT)
m_instance.scheduler.start() # To ensure the timeout occurs
cmd_out = "\n".join(out)
if callback:
callback(cmd_out, None)
[docs]def terminate_sub_channel(m_instance):
"""
Calls `m_instance.terminate()` and deletes it from `OPEN_SUBCHANNELS`.
"""
ssh_log.info(
"Closing SSH sub-channel", metadata={'term': repr(m_instance.term_id)})
global OPEN_SUBCHANNELS
m_instance.terminate()
# Find the Multiplex object inside of OPEN_SUBCHANNELS and remove it
for key, value in list(OPEN_SUBCHANNELS.items()):
# This will be something like: {1: <Multiplex instance>}
if hash(value) == hash(m_instance):
# This is necessary so the interpreter can properly collect garbage:
del OPEN_SUBCHANNELS[key]
[docs]def timeout_sub_channel(m_instance):
"""
Called when the sub-channel times out by way of an
:class:`termio.Multiplex.expect` pattern that should never match anything.
"""
ssh_log.info(
_("SSH sub-channel closed due to inactivity."),
metadata={'term': repr(m_instance.term_id)})
terminate_sub_channel(m_instance)
[docs]def got_error(self, m_instance, match=None, term=None, cmd=None):
"""
Called if :func:`execute_command` encounters a problem/timeout.
*match* is here in case we want to use it for a positive match of an error.
"""
self.ssh_log.error(_(
"%s: Got an error trying to capture output inside of "
"execute_command() running: %s" % (m_instance.user, m_instance.cmd)))
self.ssh_log.debug("output before error: %s" % m_instance.dump())
terminate_sub_channel(m_instance)
if self:
message = {
'terminal:sshjs_cmd_output': {
'cmd': cmd,
'term': term,
'output': None,
'result': _(
'Error: Timeout exceeded or command failed to execute.')
}
}
self.write_message(message)
[docs]def execute_command(self, term, cmd, callback=None):
"""
Execute the given command (*cmd*) on the given *term* using the existing
SSH tunnel (taking advantage of `Master mode <http://en.wikibooks.org/wiki/OpenSSH/Cookbook/Multiplexing>`_)
and call *callback* with the output of said command and the current
:class:`termio.Multiplex` instance as arguments like so::
callback(output, m_instance)
If *callback* is not provided then the command will be executed and any
output will be ignored.
.. note:: This will not result in a new terminal being opened on the client--it simply executes a command and returns the result using the existing SSH tunnel.
"""
self.ssh_log.info(
"Executing command on SSH sub-channel: %s" % cmd,
metadata={'term': term, 'command': cmd})
try:
m = open_sub_channel(self, term)
except SSHMultiplexingException as e:
self.ssh_log.error(_(
"%s: Got an error trying to open sub-channel on term %s..." %
(self.current_user['upn'], term)))
# Try to send an error response to the client
message = {
'terminal:sshjs_cmd_output': {
'term': term,
'cmd': cmd,
'output': None,
'result': str(e)
}
}
try:
self.write_message(message)
except: # This is really just a last-ditch thing
pass
return
# NOTE: We can assume the IOLoop is started and automatically calling read()
m.unexpect() # Clear out any existing patterns (if existing sub-channel)
m.term.clear_screen() # Clear the screen so nothing mucks up our regexes
# Check to make sure we've got a proper prompt by executing an echo
# statement and waiting for it to complete. This is more reliable than
# using a regular expression to match a shell prompt (which could be set
# to anything). It also gives us a clear indication as to where the command
# output begins and ends.
errorback = partial(got_error, self, term=term, cmd=cmd)
wait = partial(wait_for_prompt, term, cmd, errorback, callback)
m.expect(READY_MATCH,
callback=wait, errorback=errorback, preprocess=False, timeout=10)
self.ssh_log.debug("Waiting for READY_MATCH inside execute_command()")
m.writeline(u'echo -e "\\n%s"' % READY_STRING)
[docs]def send_result(self, term, cmd, output, m_instance):
"""
Called by :func:`ws_exec_command` when the output of the executed command
has been captured successfully. Writes a message to the client with the
command's output and some relevant metadata.
"""
message = {
'terminal:sshjs_cmd_output': {
'term': term,
'cmd': cmd,
'output': output,
'result': 'Success'
}
}
self.write_message(message)
[docs]def ws_exec_command(self, settings):
"""
Takes the necessary variables from *settings* and calls :func:`execute_command`.
*settings* should be a dict that contains a 'term' and a 'cmd' to execute.
.. tip:: This function can be used to quickly execute a command and return its result from the client over an existing SSH connection without requiring the user to enter their password! See execRemoteCmd() in ssh.js.
"""
term = settings['term']
cmd = settings['cmd']
send = partial(send_result, self, term, cmd)
try:
execute_command(self, term, cmd, send)
except SSHExecutionException as e:
message = {
'terminal:sshjs_cmd_output': {
'term': term,
'cmd': cmd,
'output': None,
'result': 'Error: %s' % e
}
}
self.write_message(message)
# Handlers
#class KnownHostsHandler(BaseHandler):
#"""
#This handler allows the client to view, edit, and upload the known_hosts
#file associated with their user account.
#"""
#@tornado.web.authenticated
#def get(self):
#"""
#Determine what the user is asking for and call the appropriate method.
#""" # NOTE: Just dealing with known_hosts for now but keys are next
#get_kh = self.get_argument('known_hosts', None)
#if get_kh:
#self._return_known_hosts()
#@tornado.web.authenticated
#def post(self):
#"""
#Determine what the user is updating by checking the given arguments and
#proceed with the update.
#"""
#known_hosts = self.get_argument('known_hosts', None)
#if known_hosts:
#kh = self.request.body
#self._save_known_hosts(kh)
#def _return_known_hosts(self):
#"""Returns the user's known_hosts file in text/plain format."""
#user = self.current_user['upn']
#ssh_log.debug("known_hosts requested by %s" % user)
#users_dir = os.path.join(self.settings['user_dir'], user) # "User's dir"
#users_ssh_dir = os.path.join(users_dir, '.ssh')
#kh_path = os.path.join(users_ssh_dir, 'known_hosts')
#known_hosts = ""
#if os.path.exists(kh_path):
#known_hosts = open(kh_path).read()
#self.set_header ('Content-Type', 'text/plain')
#self.write(known_hosts)
#def _save_known_hosts(self, known_hosts):
#"""Save the given *known_hosts* file."""
#print("known_hosts: %s" % known_hosts)
#user = self.current_user['upn']
#users_dir = os.path.join(self.settings['user_dir'], user) # "User's dir"
#users_ssh_dir = os.path.join(users_dir, '.ssh')
#if not os.path.isdir(users_ssh_dir): # Make .ssh dir if not present
#mkdir_p(users_ssh_dir)
#os.chmod(users_ssh_dir, 0o700)
#kh_path = os.path.join(users_ssh_dir, 'known_hosts')
## Letting Tornado's exception handler deal with errors here
#with io.open(kh_path, 'wb') as f:
#print("Writing known hosts: %s" % kh_path)
#f.write(known_hosts)
#self.write("success")
"""
WebSocket Commands
------------------
"""
# WebSocket commands (not the same as handlers)
[docs]def get_known_hosts(self):
"""
Attached to the (server-side) `terminal:ssh_get_known_hosts` WebSocket
action; returns the current user's known_hosts file.
"""
user = self.current_user['upn']
ssh_log.debug("known_hosts requested by %s" % user)
users_dir = os.path.join(self.settings['user_dir'], user) # "User's dir"
users_ssh_dir = os.path.join(users_dir, '.ssh')
kh_path = os.path.join(users_ssh_dir, 'known_hosts')
known_hosts = ""
if os.path.exists(kh_path):
known_hosts = open(kh_path).read()
message = {
'terminal:sshjs_known_hosts': {
'known_hosts': known_hosts
}
}
self.write_message(message)
[docs]def save_known_hosts(self, known_hosts):
"""
Attached to the (server-side) `terminal:ssh_save_known_hosts` WebSocket
action; saves the given *known_hosts* (string) to the user's known_hosts
file.
"""
user = self.current_user['upn']
ssh_log.debug("known_hosts updated by %s" % user)
users_dir = os.path.join(self.settings['user_dir'], user) # "User's dir"
users_ssh_dir = os.path.join(users_dir, '.ssh')
if not os.path.isdir(users_ssh_dir): # Make .ssh dir if not present
mkdir_p(users_ssh_dir)
os.chmod(users_ssh_dir, 0o700)
kh_path = os.path.join(users_ssh_dir, 'known_hosts')
try:
with io.open(kh_path, 'wb') as f:
f.write(known_hosts)
except Exception as e:
error_msg = _("Exception trying to save known_hosts file: %s" % e)
ssh_log.error(error_msg)
self.write_message(_(
"An error was encountered trying to save the known_hosts file. "
"See server logs for details."))
[docs]def get_connect_string(self, term):
"""
Attached to the (server-side) `terminal:ssh_get_connect_string` WebSocket
action; writes the connection string associated with *term* to the WebSocket
like so::
{'terminal:sshjs_reconnect': {*term*: <connection string>}}
In ssh.js we attach a WebSocket action to 'terminal:sshjs_reconnect'
that assigns the connection string sent by this function to
`GateOne.Terminal.terminals[*term*]['sshConnectString']`.
"""
# This is the first function that normally gets called when a user uses SSH
# so it's a good time to update the logger with extra metadata
self.ssh_log = go_logger(
"gateone.terminal.ssh", plugin='ssh', **self.log_metadata)
term = int(term)
if term not in self.loc_terms:
return # Nothing to do (already closed)
self.ssh_log.debug("get_connect_string()", metadata={'term': term})
connect_string = self.loc_terms[term].get('ssh_connect_string', None)
if connect_string:
message = {
'terminal:sshjs_reconnect': {
'term': term,
'connect_string': connect_string
}
}
self.write_message(message)
[docs]def get_key(self, name, public):
"""
Returns the private SSH key associated with *name* to the client. If
*public* is `True`, returns the public key to the client.
"""
if not isinstance(name, (str, unicode)):
error_msg = _(
'SSH Plugin Error: Invalid name given, %s' % repr(name))
message = {'go:save_file': {'result': error_msg}}
self.write_message(message)
return
if public and not name.endswith('.pub'):
name += '.pub'
out_dict = {
'result': None, # Yet
'filename': name,
'data': None,
'mimetype': 'text/plain'
}
users_ssh_dir = get_ssh_dir(self)
key_path = os.path.join(users_ssh_dir, name)
if os.path.exists(key_path):
with open(key_path) as f:
out_dict['data'] = f.read()
out_dict['result'] = 'Success'
else:
out_dict['result'] = _(
'SSH Plugin Error: Public key not found at %s' % key_path)
message = {'go:save_file': out_dict}
self.write_message(message)
return out_dict
[docs]def get_public_key(self, name):
"""
Returns the user's public key file named *name*.
"""
get_key(self, name, True)
[docs]def get_private_key(self, name):
"""
Returns the user's private key file named *name*.
"""
get_key(self, name, False)
[docs]def get_host_fingerprint(self, settings):
"""
Returns a the hash of the given host's public key by making a remote
connection to the server (not just by looking at known_hosts).
"""
out_dict = {}
if 'port' not in settings:
port = 22
else:
port = settings['port']
if 'host' not in settings:
out_dict['result'] = _("Error: You must supply a 'host'.")
message = {'terminal:sshjs_display_fingerprint': out_dict}
self.write_message(message)
else:
host = settings['host']
self.ssh_log.debug(
"get_host_fingerprint(%s:%s)" % (host, port),
metadata={'host': host, 'port': port})
out_dict.update({
'result': 'Success',
'host': host,
'fingerprint': None
})
ssh = which('ssh')
command = "%s -p %s -oUserKnownHostsFile=none -F. %s" % (ssh, port, host)
m = self.new_multiplex(
command,
'get_host_key',
logging=False) # Logging is false so we don't make tons of silly logs
def grab_fingerprint(m_instance, match):
out_dict['fingerprint'] = match.splitlines()[-1][:-1]
m_instance.terminate()
message = {'terminal:sshjs_display_fingerprint': out_dict}
self.write_message(message)
del m_instance
def errorback(m_instance):
leftovers = [a.rstrip() for a in m_instance.dump() if a.strip()]
out_dict['result'] = _(
"Error: Could not determine the fingerprint of %s:%s... '%s'"
% (host, port, "\n".join(leftovers)))
m_instance.terminate() # Don't leave stuff hanging around!
message = {'terminal:sshjs_display_fingerprint': out_dict}
self.write_message(message)
del m_instance
# "The authenticity of host 'localhost (127.0.0.1)' can't be established.\r\nECDSA key fingerprint is 83:f5:b1:f1:d3:8c:b8:fe:d3:be:e5:dd:95:a5:ba:73.\r\nAre you sure you want to continue connecting (yes/no)? "
m.expect('\n.+fingerprint .+\n',
grab_fingerprint, errorback=errorback, preprocess=False)
m.spawn()
# OpenSSH output example:
# ECDSA key fingerprint is 28:46:86:3a:c6:f9:63:b8:90:e1:09:69:f2:1d:c8:ce.
# Dropbear output example:
# (fingerprint md5 fa:a1:5b:4f:e5:ab:fe:e6:1f:1f:74:20:d7:35:67:c2)
[docs]def generate_new_keypair(self, settings):
"""
Calls :func:`openssh_generate_new_keypair` or
:func:`dropbear_generate_new_keypair` depending on what's available on the
system.
"""
self.ssh_log.debug('generate_new_keypair()')
users_ssh_dir = get_ssh_dir(self)
name = 'id_ecdsa'
keytype = None
bits = None
passphrase = ''
comment = ''
if 'name' in settings:
name = settings['name']
if 'keytype' in settings:
keytype = settings['keytype']
if 'bits' in settings:
bits = settings['bits']
if 'passphrase' in settings:
passphrase = settings['passphrase']
if 'comment' in settings:
comment = settings['comment']
log_metadata = {
'name': name,
'keytype': keytype,
'bits': bits,
'comment': comment
}
self.ssh_log.info("Generating new SSH keypair", metadata=log_metadata)
if which('ssh-keygen'): # Prefer OpenSSH
openssh_generate_new_keypair(
self,
name, # Name to use when generating the keypair
users_ssh_dir, # Path to save it
keytype=keytype,
passphrase=passphrase,
bits=bits,
comment=comment
)
elif which('dropbearkey'):
dropbear_generate_new_keypair(self,
name, # Name to use when generating the keypair
users_ssh_dir, # Path to save it
keytype=keytype,
passphrase=passphrase,
bits=bits,
comment=comment)
def errorback(self, m_instance):
self.ssh_log.error(_("Keypair generation failed."))
print(m_instance.dump())
m_instance.terminate()
message = {
'terminal:sshjs_keygen_complete': {
'result': _("There was a problem generating SSH keys: %s"
% m_instance.dump()),
}
}
self.write_message(message)
[docs]def overwrite(m_instance, match):
"""
Called if we get asked to overwrite an existing keypair.
"""
ssh_log.debug('overwrite()')
m_instance.writeline('y')
def enter_passphrase(passphrase, m_instance, match):
ssh_log.debug("entering passphrase...")
m_instance.writeline('%s' % passphrase)
def finished(self, m_instance, fingerprint):
self.ssh_log.info(
_("Keypair generation complete"), metadata={'fingerprint': fingerprint})
message = {
'terminal:sshjs_keygen_complete': {
'result': 'Success',
'fingerprint': fingerprint
}
}
m_instance.terminate()
self.write_message(message)
[docs]def openssh_generate_new_keypair(self, name, path,
keytype=None, passphrase="", bits=None, comment=""):
"""
Generates a new private and public key pair--stored in the user's directory
using the given *name* and other optional parameters (using OpenSSH).
If *keytype* is given, it must be one of "ecdsa", "rsa" or "dsa" (case
insensitive). If *keytype* is "rsa" or "ecdsa", *bits* may be specified to
specify the size of the key.
.. note:: Defaults to generating a 521-byte ecdsa key if OpenSSH is version 5.7+. Otherwise a 2048-bit rsa key will be used.
"""
self.ssh_log.debug('openssh_generate_new_keypair()')
openssh_version = shell_command('ssh -V')[1]
ssh_major_version = int(
openssh_version.split()[0].split('_')[1].split('.')[0])
key_path = os.path.join(path, name)
ssh_minor_version = int(
openssh_version.split()[0].split('_')[1].split('.')[1][0])
ssh_version = "%s.%s" % (ssh_major_version, ssh_minor_version)
ssh_version = float(ssh_version)
if not keytype:
if ssh_version >= 5.7:
keytype = "ecdsa"
else:
keytype = "rsa"
else:
keytype = keytype.lower()
if not bits and keytype == "ecdsa":
bits = 521 # Not a typo: five-hundred-twenty-one bits
elif not bits and keytype == "rsa":
bits = 2048
if not passphrase: # This just makes sure False and None end up as ''
passphrase = ''
hostname = os.uname()[1]
if not comment:
now = datetime.now().isoformat()
comment = "Generated by Gate One on %s %s" % (hostname, now)
ssh_keygen_path = which('ssh-keygen')
command = (
"%s " # Path to ssh-keygen
"-b %s " # bits
"-t %s " # keytype
"-C '%s' " # comment
"-f '%s'" # Key path
% (ssh_keygen_path, bits, keytype, comment, key_path)
)
self.ssh_log.debug("Keygen command: %s" % command)
m = self.new_multiplex(command, "gen_ssh_keypair")
call_errorback = partial(errorback, self)
m.expect('^Overwrite.*',
overwrite, optional=True, preprocess=False, timeout=10)
passphrase_handler = partial(enter_passphrase, passphrase)
m.expect('^Enter passphrase',
passphrase_handler,
errorback=call_errorback,
preprocess=False,
timeout=10)
m.expect('^Enter same passphrase again',
passphrase_handler,
errorback=call_errorback,
preprocess=False,
timeout=10)
finalize = partial(finished, self)
# The regex below captures the md5 fingerprint which tells us the
# operation was successful.
m.expect(
'(([0-9a-f][0-9a-f]\:){15}[0-9a-f][0-9a-f])',
finalize,
errorback=call_errorback,
preprocess=False,
timeout=15 # Key generation can take a little while
)
m.spawn()
[docs]def dropbear_generate_new_keypair(self, name, path,
keytype=None, passphrase="", bits=None, comment=""):
"""
.. note:: Not implemented yet
"""
pass
[docs]def openssh_generate_public_key(self, path, passphrase=None, settings=None):
"""
Generates a public key from the given private key at *path*. If a
*passphrase* is provided, it will be used to generate the public key (if
necessary).
"""
self.ssh_log.debug('openssh_generate_public_key()')
ssh_keygen_path = which('ssh-keygen')
pubkey_path = "%s.pub" % path
command = (
"%s " # Path to ssh-keygen
"-f '%s' " # Key path
"-y " # Output public key to stdout
"2>&1 " # Redirect stderr to stdout so we can catch failures
"> '%s'" # Redirect stdout to the public key path
% (ssh_keygen_path, path, pubkey_path)
)
import termio
m = termio.Multiplex(command)
def request_passphrase(*args, **kwargs):
"Called if this key requires a passphrase. Ask the client to provide"
message = {'terminal:sshjs_ask_passphrase': settings}
self.write_message(message)
def bad_passphrase(m_instance, match):
"Called if the user entered a bad passphrase"
settings['bad'] = True
request_passphrase()
if passphrase:
m.expect('^Enter passphrase',
"%s\n" % passphrase, optional=True, preprocess=False, timeout=5)
m.expect('^load failed',
bad_passphrase, optional=True, preprocess=False, timeout=5)
elif settings:
m.expect('^Enter passphrase',
request_passphrase, optional=True, preprocess=False, timeout=5)
def atexit(child, exitstatus):
"Raises an SSHKeygenException if the *exitstatus* isn't 0"
if exitstatus != 0:
print(m.dump)
raise SSHKeygenException(_(
"Error generating public key from private key at %s" % path))
m.spawn(exitfunc=atexit)
#exitstatus, output = shell_command(command)
#if exitstatus != 0:
#raise SSHKeygenException(_(
#"Error generating public key from private key at %s" % path))
# ssh-kegen example for reference:
#$ ssh-keygen -b 521 -t ecdsa "Testing" -f /tmp/testkey
#Generating public/private ecdsa key pair.
#Enter passphrase (empty for no passphrase):
#Enter same passphrase again:
#Your identification has been saved in /tmp/testkey.
#Your public key has been saved in /tmp/testkey.pub.
#The key fingerprint is:
#6b:13:4b:5d:80:bd:21:70:33:f5:b9:15:78:75:08:9a Testing
#The key's randomart image is:
#+--[ECDSA 521]---+
#| ..++o .o.oo|
#| .ooo=..o..|
#| .Eo+.. |
#| ... o |
#| S . . |
#| . + |
#| = |
#| . . |
#| |
#+-----------------+
# dropbearkey example for reference:
#root@router:~# dropbearkey -t dss -f /tmp/testing
#Will output 1024 bit dss secret key to '/tmp/testing'
#Generating key, this may take a while...
#Public key portion is:
#ssh-dss AAAAB3NzaC1kc3MAAACBAJ5jU4izsZtJKEojw7gIcc6e3U4X6OENN6081YxSAanfTbKjR0V3Ho6aui2z8o039BVH4S5cVD51vEEvDjirKStM2aMvdrVZkjGH1iOMWY4MQrCl4EqMr7rWikeiZJN6BJ+xmPBUyZuicVDFkBwqC+dKgxml0RTpa7TYBWvp403XAAAAFQDg6vb3afaKM9+DvBW7I4xPxF8a8QAAAIEAjcNHYFrqcWK9lSsw2Oy+w1PEWQuxvWydXXk3MQyiZ/PYaeU/138iCB2pW1fgCksx5CHF8dgtQ7AsFv32gBlxuDgX3EYtPYR0wGJqyU7w9+qaq1T02zmDfW4k2WDfMNz+QWFYHuKzC/aeuEC0BRTLyPVQMHLNAd/F5beCqlIPRPcAAACAfUy1+yNgK2svox6aJRqtpxbMSPDRNTRMAjeTkCeLopesZFYbPvms2c19WkIk2qD9aw3gIxsR4wO+kkvI4BtOs8dXQWS+bc+svJbIYOqmPFo89BJHfbP9wvMhfTlp1uH9LxAG6ZiHHz5fseUgTrwYkSw1beUprikxlca8lQm5v7g= root@RouterStationPro
#Fingerprint: md5 c6:f9:f2:95:b8:40:ac:f3:53:f1:39:e9:57:a0:58:18
# TODO: Get this validating uploaded keys.
[docs]def store_id_file(self, settings):
"""
Stores the given *settings['private']* and/or *settings['public']* keypair
in the user's ssh directory as *settings['name']* and/or
*settings['name']*.pub, respectively. Either file can be saved independent
of each other (in case this function needs to be called multiple times to
save each respective file).
Also, a *settings['certificate']* may be provided to be saved along
with the private and public keys. It will be saved as
*settings['name']*-cert.pub.
.. note::
I've found the following website helpful in understanding how to use
OpenSSH with SSL certificates:
http://blog.habets.pp.se/2011/07/OpenSSH-certificates
.. tip::
Using signed-by-a-CA certificates is very handy because allows you to
revoke the user's SSH key(s). e.g. If they left the company.
"""
self.ssh_log.debug('store_id_file()')
out_dict = {'result': 'Success'}
global TIMER
try:
name = settings.get('name', None)
if not name:
raise SSHKeypairException(_("You must specify a valid *name*."))
name = os.path.splitext(name)[0] # Remove .txt, .pub, etc
private = settings.get('private', None)
public = settings.get('public', None)
certificate = settings.get('certificate', None)
passphrase = settings.get('passphrase', None)
if not private and not public and not certificate:
raise SSHKeypairException(_("No files were given to save!"))
users_ssh_dir = get_ssh_dir(self)
private_key_path = os.path.join(users_ssh_dir, name)
# Strip any .pub or .txt from the end of the public key
if name.endswith('.pub'):
public_key_name = name # Get rid of the extra .pub
public_key_name = name + '.pub'
public_key_path = os.path.join(users_ssh_dir, public_key_name)
certificate_name = name + '-cert.pub'
if name.endswith('-cert.pub'):
certificate_name = name # Don't need an extra -cert.pub at the end
certificate_path = os.path.join(users_ssh_dir, certificate_name)
if private:
# Fix Windows newlines
private = private.replace('\r\n', '\n')
if VALID_PRIVATE_KEY.match(private):
with open(private_key_path, 'w') as f:
f.write(private)
# Without this you get a warning:
os.chmod(private_key_path, 0o600)
else:
self.write_message({'go:notice': _(
"ERROR: Private key is not valid.")})
return
if public:
# Fix Windows newlines
public = public.replace('\r\n', '\n')
with open(public_key_path, 'w') as f:
f.write(public)
# Now remove the timer that will generate the public key from the
# private key if it is set.
if TIMER:
self.ssh_log.debug(_(
"Got public key, cancelling public key generation timer."))
io_loop = tornado.ioloop.IOLoop.current()
io_loop.remove_timeout(TIMER)
TIMER = None
get_ids = partial(get_identities, self, None)
io_loop.add_timeout(timedelta(seconds=2), get_ids)
elif private: # No biggie, generate one
# Only generate a new public key if one isn't uploaded within 2
# seconds (should be plenty of time since they're typically sent
# simultaneously but inside different WebSocket messages).
self.ssh_log.debug(_(
"Only received a private key. Setting timeout to generate the "
"public key if not received within 3 seconds."))
io_loop = tornado.ioloop.IOLoop.current()
deadline = timedelta(seconds=2)
def generate_public_key(): # I love closures
openssh_generate_public_key(self,
private_key_path, passphrase, settings=settings)
get_ids = partial(get_identities, self, None)
io_loop.add_timeout(timedelta(seconds=2), get_ids)
# This gets removed if the public key is uploaded
TIMER = io_loop.add_timeout(deadline, generate_public_key)
if certificate:
# Fix Windows newlines
certificate = certificate.replace('\r\n', '\n')
with open(certificate_path, 'w') as f:
f.write(certificate)
except Exception as e:
out_dict['result'] = _("Error saving keys: %s" % e)
message = {
'terminal:sshjs_save_id_complete': out_dict
}
self.write_message(message)
[docs]def delete_identity(self, name):
"""
Removes the identity associated with *name*. For example if *name* is
'testkey', 'testkey' and 'testkey.pub' would be removed from the user's
ssh directory (and 'testkey-cert.pub' if present).
"""
self.ssh_log.info(
'Deleting SSH identity: %s' % name, metadata={'name': name})
out_dict = {'result': 'Success'}
users_ssh_dir = get_ssh_dir(self)
private_key_path = os.path.join(users_ssh_dir, name)
public_key_path = os.path.join(users_ssh_dir, name+'.pub')
certificate_path = os.path.join(users_ssh_dir, name+'-cert.pub')
try:
if os.path.exists(private_key_path):
os.remove(private_key_path)
if os.path.exists(public_key_path):
os.remove(public_key_path)
if os.path.exists(certificate_path):
os.remove(certificate_path)
except Exception as e:
out_dict['result'] = _("Error deleting keypair: %s" % e)
message = {
'terminal:sshjs_delete_identity_complete': out_dict
}
self.write_message(message)
[docs]def get_identities(self, *anything):
"""
Sends a message to the client with a list of the identities stored on the
server for the current user.
*anything* is just there because the client needs to send *something* along
with the 'action'.
"""
self.ssh_log.debug('get_identities()')
out_dict = {'result': 'Success'}
users_ssh_dir = get_ssh_dir(self)
out_dict['identities'] = []
ssh_keygen_path = which('ssh-keygen')
# TODO: Switch this from using ssh-keygen to determine the keytype to using the string inside the public key.
try:
if os.path.exists(users_ssh_dir):
ssh_files = os.listdir(users_ssh_dir)
for f in ssh_files:
if f.endswith('.pub'):
# Double-check there's also a private key...
identity = f[:-4] # Will be the same name minus '.pub'
if identity in ssh_files:
id_path = os.path.join(users_ssh_dir, identity)
pub_key_path = os.path.join(users_ssh_dir, f)
public_key_contents = open(pub_key_path).read()
comment = ' '.join(public_key_contents.split(' ')[2:])
if public_key_contents.startswith('ecdsa'):
keytype = 'ECDSA'
elif public_key_contents.startswith('ssh-dss'):
keytype = 'DSA'
elif public_key_contents.startswith('ssh-rsa'):
keytype = 'RSA'
else:
keytype = 'Unknown'
keygen_cmd = "'%s' -vlf '%s'" % (
ssh_keygen_path, id_path)
retcode, key_info = shell_command(keygen_cmd)
# This will just wind up as an empty string if the
# version of ssh doesn't support randomart:
randomart = '\n'.join(key_info.splitlines()[1:])
bits = key_info.split()[0]
fingerprint = key_info.split()[1]
retcode, bubblebabble = shell_command(
"'%s' -Bf '%s'" % (ssh_keygen_path, id_path))
bubblebabble = bubblebabble.split()[1]
certinfo = ''
cert_path = "'%s-cert.pub'" % id_path
if os.path.exists(cert_path):
retcode, certinfo = shell_command(
"'%s' -Lf '%s'" % (ssh_keygen_path, cert_path))
certinfo = ' '.join(certinfo.split(' ')[1:])
fixed_certinfo = ''
for i, line in enumerate(certinfo.splitlines()):
if i == 0:
line = line.lstrip()
fixed_certinfo += line.replace(' ', ' ')
fixed_certinfo += '\n'
id_obj = {
'name': identity,
'public': public_key_contents,
'keytype': keytype,
'bubblebabble': bubblebabble,
'fingerprint': fingerprint,
'randomart': randomart,
'certinfo': fixed_certinfo,
'bits': bits,
'comment': comment.rstrip(),
}
out_dict['identities'].append(id_obj)
# Figure out which identities are defaults
default_ids = []
default_ids_exists = False
users_ssh_dir = get_ssh_dir(self)
default_ids_path = os.path.join(users_ssh_dir, '.default_ids')
if os.path.exists(default_ids_path):
default_ids_exists = True
with open(default_ids_path) as f:
default_ids = f.read().splitlines() # Why not readlines()? \n
# Convert any absolute paths inside default_ids to just the short names
default_ids = [os.path.split(a)[1] for a in default_ids]
if default_ids_exists:
for i, id_obj in enumerate(out_dict['identities']):
if id_obj['name'] in default_ids:
out_dict['identities'][i]['default'] = True
else:
out_dict['identities'][i]['default'] = False
except Exception as e:
error_msg = _("Error getting identities: %s" % e)
self.ssh_log.error(error_msg)
out_dict['result'] = error_msg
message = {
'terminal:sshjs_identities_list': out_dict
}
self.write_message(message)
[docs]def set_default_identities(self, identities):
"""
Given a list of *identities*, mark them as defaults to use in all outbound
SSH connections by writing them to `<user's ssh dir>/.default_ids`. If
*identities* is empty, no identities will be used in outbound connections.
.. note::
Whenever this function is called it will overwrite whatever is in
`.default_ids`.
"""
if isinstance(identities, list): # Ignore anything else
users_ssh_dir = get_ssh_dir(self)
default_ids_path = os.path.join(users_ssh_dir, '.default_ids')
with open(default_ids_path, 'w') as f:
f.write('\n'.join(identities) + '\n') # Need that trailing newline
[docs]def set_ssh_socket(self, term, path):
"""
Given a *term* and *path*, sets
``self.loc_terms[term]['ssh_socket'] = path``.
"""
self.ssh_log.debug("set_ssh_socket(): %s, %s" % (term, path))
term = int(term)
if term in self.loc_terms:
self.loc_terms[term]['ssh_socket'] = path
self.save_term_settings(term, {'ssh_socket': path})
[docs]def set_ssh_connect_string(self, term, connect_string):
"""
Given a *term* and *connect_string*, sets
``self.loc_terms[term]['ssh_connect_string'] = connect_string``.
"""
self.ssh_log.debug(
'set_ssh_connect_string: %s, %s' % (term, connect_string))
term = int(term)
if term in self.loc_terms:
self.loc_terms[term]['ssh_connect_string'] = connect_string
self.save_term_settings(term, {'ssh_connect_string': connect_string})
message = {'terminal:sshjs_connect': connect_string}
self.write_message(message)
# Special optional escape sequence handler (see docs on how it works)
[docs]def opt_esc_handler(self, text, term=None, multiplex=None):
"""
Handles text passed from the special optional escape sequance handler. We
use it to tell ssh.js what the SSH connection string is so it can use that
information to duplicate sessions (if the user so desires). For reference,
the specific string which will call this function from a terminal app is::
\\x1b]_;ssh|<whatever>\\x07
.. seealso::
:class:`gateone.TerminalWebSocket.esc_opt_handler` and
:func:`terminal.Terminal._opt_handler`
"""
supported_assignments = {
'ssh_socket': set_ssh_socket,
'connect_string': set_ssh_connect_string
}
if text.startswith('set;'):
values = text.split(';')
assignment = values[1]
data = values[2:]
func = supported_assignments.get(assignment, None)
if func:
func(self, term, *data)
return
[docs]def create_user_ssh_dir(self):
"""
To be called by the 'Auth' hook that gets called after the user is done
authenticating, ensures that the `<user's dir>/ssh` directory exists.
"""
self.ssh_log.debug("create_user_ssh_dir()")
user = self.current_user['upn']
users_dir = os.path.join(self.ws.settings['user_dir'], user) # "User's dir"
ssh_dir = os.path.join(users_dir, '.ssh')
try:
mkdir_p(ssh_dir)
except OSError as e:
self.ssh_log.error(_("Error creating user's ssh directory: %s\n" % e))
[docs]def send_ssh_css_template(self):
"""
Sends our ssh.css template to the client using the 'load_style'
WebSocket action. The rendered template will be saved in Gate One's
'cache_dir'.
"""
css_path = os.path.join(PLUGIN_PATH, 'templates', 'ssh.css')
self.render_and_send_css(css_path)
[docs]def initialize(self):
"""
Called inside of :meth:`TerminalApplication.initialize` shortly after the
WebSocket is instantiated. Attaches our two `terminal:authenticate` events
(to create the user's .ssh dir and send our CSS template) and ensures that
the ssh_connect.py script is executable.
"""
ssh_connect_path = os.path.join(PLUGIN_PATH, 'scripts', 'ssh_connect.py')
if os.path.exists(ssh_connect_path):
import stat
st = os.stat(ssh_connect_path)
if not bool(st.st_mode & stat.S_IXOTH):
try:
os.chmod(ssh_connect_path, 0o755)
except OSError:
ssh_log.error(_(
"Could not set %s as executable. You will need to 'chmod "
"a+x' that script manually.") % ssh_connect_path)
user_msg = _(
"Error loading SSH plugin: The ssh_connect.py script is "
"not executable. See the logs for more details.")
send_msg = partial(self.ws.send_message, user_msg)
events = ["terminal:authenticate", "terminal:new_terminal"]
self.on(events, send_msg)
self.ssh_log = go_logger("gateone.terminal.ssh", plugin='ssh')
# NOTE: Why not use the 'Events' hook for these? You can't attach two
# functions to the same event via that mechanism because it's a dict
# (one would override the other).
# An alternative would be to write a single function say, on_auth() that
# calls both of these functions then assign it to 'terminal:authenticate' in
# the 'Events' hook. I think this way is better since it is more explicit.
self.on('terminal:authenticate', bind(send_ssh_css_template, self))
self.on('terminal:authenticate', bind(create_user_ssh_dir, self))
hooks = {
#'Web': [(r"/ssh", KnownHostsHandler)],
'WebSocket': {
'terminal:ssh_get_known_hosts': get_known_hosts,
'terminal:ssh_save_known_hosts': save_known_hosts,
'terminal:ssh_get_connect_string': get_connect_string,
'terminal:ssh_execute_command': ws_exec_command,
'terminal:ssh_get_identities': get_identities,
'terminal:ssh_get_public_key': get_public_key,
'terminal:ssh_get_private_key': get_private_key,
'terminal:ssh_get_host_fingerprint': get_host_fingerprint,
'terminal:ssh_gen_new_keypair': generate_new_keypair,
'terminal:ssh_store_id_file': store_id_file,
'terminal:ssh_delete_identity': delete_identity,
'terminal:ssh_set_default_identities': set_default_identities,
},
'Escape': opt_esc_handler,
}
# Certificate information (as output by ssh-keygen) for reference:
#
#$ ssh-keygen -Lf id_rsa-cert.pub
#id_rsa-cert.pub:
#Type: ssh-rsa-cert-v01@openssh.com user certificate
#Public key: RSA-CERT 80:57:2c:18:f9:86:ab:8b:64:27:db:6f:5e:03:3f:d9
#Signing CA: RSA 86:25:b0:73:67:0f:51:2e:a7:96:63:08:fb:d6:69:94
#Key ID: "user_riskable"
#Serial: 0
#Valid: from 2012-01-08T13:38:00 to 2013-01-06T13:39:27
#Principals:
#riskable
#Critical Options: (none)
#Extensions:
#permit-agent-forwarding
#permit-port-forwarding
#permit-pty
#permit-user-rc
#permit-X11-forwarding
# NOTE: *message['identities'][0]* - An associative array conaining the key's metadata. For example:
#
#{
#'name': "testid",
#'public': "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBAChqFprVjC0MKe3qpjjc+WdANOHMgcUl46dJxZ+s5soBTkO6thcJDAbFb36lg3YyzZi/PtDJV5CPp8Mv1SUXUYBqgFZJFBqWwkB0O1ohjtEVzC8+ybrY+hP0zLqykglhOi+6W66HgFwjJGn56uGE7s8UpnSRKtqGq2USyme5gopYlytTw== Generated by Gate One on somehost",
#'keytype': "ecdsa",
#'bubblebabble': "xevol-budez-difod-zumif-zofos-vezis-rilep-febel-tufok-lugud-dyxex",
#'fingerprint': "0e:69:0a:9e:2e:26:2b:91:23:3d:95:4b:65:31:a9:6f",
#'randomart': "+--[ECDSA 521]---+\n| oo |\n| +. |\n| = |\n| = . |\n| o.o o+ S |\n|=.oo.oEo |\n|.oo... . |\n|+o |\n|=o. |\n+-----------------+",
#'certinfo': "",
#'bits': 521,
#'comment': "Generated by Gate One on somehost",
#}