# -*- coding: utf-8 -*-
#
# Copyright 2013 Liftoff Software Corporation
#
from __future__ import unicode_literals
__doc__ = """\
A Gate One Application (`GOApplication`) that provides a terminal emulator.
"""
# Meta
__version__ = '1.2'
__license__ = "AGPLv3 or Proprietary (see LICENSE.txt)"
__version_info__ = (1, 2)
__author__ = 'Dan McDougall <daniel.mcdougall@liftoffsoftware.com>'
# Standard library imports
import os, sys, time, io, atexit, logging
from datetime import datetime, timedelta
from functools import partial
# Pseudo stdlib
from pkg_resources import resource_filename, resource_listdir, resource_string
# Gate One imports
from gateone import GATEONE_DIR, SESSIONS
from gateone.core.server import StaticHandler, BaseHandler, GOApplication
from gateone.core.server import ApplicationWebSocket
from gateone.auth.authorization import require, authenticated
from gateone.auth.authorization import applicable_policies, policies
from gateone.core.configuration import get_settings, RUDict
from gateone.core.utils import cmd_var_swap, json_encode, generate_session_id
from gateone.core.utils import mkdir_p, entry_point_files
from gateone.core.utils import process_opt_esc_sequence, bind, MimeTypeFail
from gateone.core.utils import short_hash, create_data_uri, which
from gateone.core.locale import get_translation
from gateone.core.log import go_logger, string_to_syslog_facility
from gateone.applications.terminal.logviewer import main as logviewer_main
from gateone.applications.terminal.policy import terminal_policies
# 3rd party imports
from tornado.escape import json_decode
from tornado.options import options, define, Error
# Globals
REGISTERED_HANDLERS = [] # So we don't accidentally re-add handlers
web_handlers = [] # Assigned in init()
# Localization support
_ = get_translation()
# Terminal-specific command line options. These become options you can pass to
# gateone.py (e.g. --session_logging)
if not hasattr(options, 'session_logging'):
define(
"session_logging",
default=True,
group='terminal',
help=_("If enabled, logs of user sessions will be saved in "
"<user_dir>/<user>/logs. Default: Enabled")
)
define( # This is an easy way to support cetralized logging
"syslog_session_logging",
default=False,
group='terminal',
help=_("If enabled, logs of user sessions will be written to syslog.")
)
define(
"dtach",
default=True,
group='terminal',
help=_("Wrap terminals with dtach. Allows sessions to be resumed even "
"if Gate One is stopped and started (which is a sweet feature).")
)
define(
"kill",
default=False,
group='terminal',
help=_("Kill any running Gate One terminal processes including dtach'd "
"processes.")
)
[docs]def kill_session(session, kill_dtach=False):
"""
Terminates all the terminal processes associated with *session*. If
*kill_dtach* is True, the dtach processes associated with the session will
also be killed.
.. note::
This function gets appended to the
`SESSIONS[session]["kill_session_callbacks"]` list inside of
:meth:`TerminalApplication.authenticate`.
"""
term_log = go_logger("gateone.terminal")
term_log.debug('kill_session(%s)' % session)
if kill_dtach:
from gateone.core.utils import kill_dtached_proc
for location, apps in list(SESSIONS[session]['locations'].items()):
loc = SESSIONS[session]['locations'][location]['terminal']
terms = apps['terminal']
for term in terms:
if isinstance(term, int):
if loc[term]['multiplex'].isalive():
loc[term]['multiplex'].terminate()
if kill_dtach:
kill_dtached_proc(session, location, term)
[docs]def timeout_session(session):
"""
Attached to Gate One's 'timeout_callbacks'; kills the given session.
If 'dtach' support is enabled the dtach processes associated with the
session will also be killed.
"""
kill_session(session, kill_dtach=True)
@atexit.register
def quit():
from gateone.core.utils import killall
try:
commands = options.parse_command_line()
except Error: # options.Error
return # Bad command line options provided--let the parent handle it
if commands or options.help:
# Don't call killall() if the user is invoking gateone --help or a
# CLI command like 'broadcast' or 'termlog'
return
if not options.dtach:
# If we're not using dtach play it safe by cleaning up any leftover
# processes. When passwords are used with the ssh_conenct.py script
# it runs os.setsid() on the child process which means it won't die
# when Gate One is closed. This is primarily to handle that
# specific situation.
killall(options.session_dir, options.pid_file)
# NOTE: THE BELOW IS A WORK IN PROGRESS
[docs]class SharedTermHandler(BaseHandler):
"""
Renders shared.html which allows an anonymous user to view a shared
terminal.
"""
def get(self, share_id):
hostname = os.uname()[1]
prefs = self.get_argument("prefs", None)
gateone_js = "%sstatic/gateone.js" % self.settings['url_prefix']
minified_js_abspath = resource_filename(
'gateone', '/static/gateone.min.js')
# Use the minified version if it exists
if os.path.exists(minified_js_abspath):
gateone_js = "%sstatic/gateone.min.js" % self.settings['url_prefix']
index_path = resource_filename(
'gateone.applications.terminal', '/templates/share.html')
self.render(
index_path,
share_id=share_id,
hostname=hostname,
gateone_js=gateone_js,
url_prefix=self.settings['url_prefix'],
prefs=prefs
)
[docs]class TermStaticFiles(StaticHandler):
"""
Serves static files in the `gateone/applications/terminal/static` directory.
.. note::
This is configured via the `web_handlers` global (a feature inherent to
Gate One applications).
"""
pass
[docs]class TerminalApplication(GOApplication):
"""
A Gate One Application (`GOApplication`) that handles creating and
controlling terminal applications running on the Gate One server.
"""
info = {
'name': "Terminal",
'version': __version__,
'description': (
"Open terminals running any number of configured applications."),
'dependencies': ['terminal.js', 'terminal_input.js']
}
name = "Terminal" # A user-friendly name that will be displayed to the user
def __init__(self, ws):
logging.debug("TerminalApplication.__init__(%s)" % ws)
self.policy = {} # Gets set in authenticate() below
self.terms = {}
self.loc_terms = {}
# So we can keep track and avoid sending unnecessary messages:
self.titles = {}
self.em_dimensions = None
self.race_check = False
self.log_metadata = {'application': 'terminal'}
GOApplication.__init__(self, ws)
[docs] def initialize(self):
"""
Called when the WebSocket is instantiated, sets up our WebSocket
actions, security policies, and attaches all of our plugin hooks/events.
"""
self.log_metadata = {
'application': 'terminal',
'ip_address': self.ws.request.remote_ip,
'location': self.ws.location
}
self.term_log = go_logger("gateone.terminal")
self.term_log.debug("TerminalApplication.initialize()")
# Register our security policy function
self.ws.security.update({'terminal': terminal_policies})
# Register our WebSocket actions
self.ws.actions.update({
'terminal:new_terminal': self.new_terminal,
'terminal:set_terminal': self.set_terminal,
'terminal:move_terminal': self.move_terminal,
'terminal:swap_terminals': self.swap_terminals,
'terminal:kill_terminal': self.kill_terminal,
'c': self.char_handler, # Just 'c' to keep the bandwidth down
'terminal:write_chars': self.write_chars,
'terminal:refresh': self.refresh_screen,
'terminal:full_refresh': self.full_refresh,
'terminal:resize': self.resize,
'terminal:get_bell': self.get_bell,
'terminal:manual_title': self.manual_title,
'terminal:reset_terminal': self.reset_terminal,
'terminal:get_webworker': self.get_webworker,
'terminal:get_font': self.get_font,
'terminal:get_colors': self.get_colors,
'terminal:set_encoding': self.set_term_encoding,
'terminal:set_keyboard_mode': self.set_term_keyboard_mode,
'terminal:get_locations': self.get_locations,
'terminal:get_terminals': self.terminals,
'terminal:get_client_files': self.send_client_files,
'terminal:permissions': self.permissions,
'terminal:new_share_id': self.new_share_id,
'terminal:share_user_list': self.share_user_list,
'terminal:enumerate_commands': self.enumerate_commands,
'terminal:enumerate_fonts': self.enumerate_fonts,
'terminal:enumerate_colors': self.enumerate_colors,
'terminal:list_shared_terminals': self.list_shared_terminals,
'terminal:attach_shared_terminal': self.attach_shared_terminal,
'terminal:detach_shared_terminal': self.detach_shared_terminal,
'terminal:start_capture': self.start_capture,
'terminal:stop_capture': self.stop_capture,
'terminal:debug_terminal': self.debug_terminal
})
if 'terminal' not in self.ws.persist:
self.ws.persist['terminal'] = {}
# Initialize plugins (every time a connection is established so we can
# load new plugins with a simple page reload)
enabled_plugins = self.ws.prefs['*']['terminal'].get(
'enabled_plugins', [])
self.plugins = entry_point_files('go_terminal_plugins', enabled_plugins)
plugin_list = set()
for plugin in list(
self.plugins['py'].keys() +
self.plugins['js'].keys() +
self.plugins['css'].keys()):
if '.' in plugin:
plugin = plugin.split('.')[-1]
plugin_list.add(plugin)
plugin_list = sorted(plugin_list) # So there's consistent ordering
self.term_log.info(_(
"Active Terminal Plugins: %s" % ", ".join(plugin_list)))
# Setup some events
terminals_func = partial(self.terminals, self)
self.ws.on("go:set_location", terminals_func)
# Attach plugin hooks
self.plugin_hooks = {}
# TODO: Keep track of plugins and hooks to determine when they've
# changed so we can tell clients to pull updates and whatnot
for name, plugin in self.plugins['py'].items():
try:
if hasattr(plugin, 'hooks'):
self.plugin_hooks.update({plugin.__name__: plugin.hooks})
if hasattr(plugin, 'initialize'):
plugin.initialize(self)
except AttributeError as e:
if options.logging.lower() == 'debug':
self.term_log.error(
_("Got exception trying to initialize the {0} plugin:"
).format(plugin))
self.term_log.error(e)
import traceback
traceback.print_exc(file=sys.stdout)
pass # No hooks--probably just a supporting .py file.
# Hook up the hooks
# NOTE: Most of these will soon be replaced with on() and off() events
# and maybe some functions related to initialization.
self.plugin_esc_handlers = {}
self.plugin_auth_hooks = []
self.plugin_command_hooks = []
self.plugin_log_metadata_hooks = []
self.plugin_new_multiplex_hooks = []
self.plugin_new_term_hooks = {}
self.plugin_env_hooks = {}
for plugin_name, hooks in self.plugin_hooks.items():
plugin_name = plugin_name.split('.')[-1]
if 'WebSocket' in hooks:
# Apply the plugin's WebSocket actions
for ws_command, func in hooks['WebSocket'].items():
self.ws.actions.update({ws_command: bind(func, self)})
if 'Escape' in hooks:
# Apply the plugin's Escape handler
self.on(
"terminal:opt_esc_handler:%s" %
plugin_name, bind(hooks['Escape'], self))
if 'Command' in hooks:
# Apply the plugin's 'Command' hooks (called by new_multiplex)
if isinstance(hooks['Command'], (list, tuple)):
self.plugin_command_hooks.extend(hooks['Command'])
else:
self.plugin_command_hooks.append(hooks['Command'])
if 'Metadata' in hooks:
# Apply the plugin's 'Metadata' hooks (called by new_multiplex)
if isinstance(hooks['Metadata'], (list, tuple)):
self.plugin_log_metadata_hooks.extend(hooks['Metadata'])
else:
self.plugin_log_metadata_hooks.append(hooks['Metadata'])
if 'Multiplex' in hooks:
# Apply the plugin's Multiplex hooks (called by new_multiplex)
if isinstance(hooks['Multiplex'], (list, tuple)):
self.plugin_new_multiplex_hooks.extend(hooks['Multiplex'])
else:
self.plugin_new_multiplex_hooks.append(hooks['Multiplex'])
if 'TermInstance' in hooks:
# Apply the plugin's TermInstance hooks (called by new_terminal)
if isinstance(hooks['TermInstance'], (list, tuple)):
self.plugin_new_term_hooks.extend(hooks['TermInstance'])
else:
self.plugin_new_term_hooks.append(hooks['TermInstance'])
if 'Environment' in hooks:
self.plugin_env_hooks.update(hooks['Environment'])
if 'Events' in hooks:
for event, callback in hooks['Events'].items():
self.on(event, bind(callback, self))
[docs] def open(self):
"""
This gets called at the end of :meth:`ApplicationWebSocket.open` when
the WebSocket is opened.
"""
self.term_log.debug('TerminalApplication.open()')
self.callback_id = "%s;%s;%s" % (
self.ws.client_id, self.request.host, self.request.remote_ip)
self.trigger("terminal:open")
[docs] def send_client_files(self):
"""
Sends the client our standard CSS and JS.
"""
# Render and send the client our terminal.css
terminal_css = resource_filename(
'gateone.applications.terminal', '/templates/terminal.css')
self.render_and_send_css(terminal_css, element_id="terminal.css")
# Send the client our JavaScript files
js_files = resource_listdir('gateone.applications.terminal', '/static/')
js_files.sort()
for fname in js_files:
if fname.endswith('.js'):
js_file_path = resource_filename(
'gateone.applications.terminal', '/static/%s' % fname)
if fname == 'terminal.js':
self.ws.send_js(js_file_path, requires=["terminal.css"])
elif fname == 'terminal_input.js':
self.ws.send_js(js_file_path, requires="terminal.js")
else:
self.ws.send_js(js_file_path, requires='terminal_input.js')
self.ws.send_plugin_static_files(
'go_terminal_plugins',
requires=["terminal_input.js"])
# Send the client the 256-color style information and our printing CSS
self.send_256_colors()
self.send_print_stylesheet()
[docs] def authenticate(self):
"""
This gets called immediately after the user is authenticated
successfully at the end of :meth:`ApplicationWebSocket.authenticate`.
Sends all plugin JavaScript files to the client and triggers the
'terminal:authenticate' event.
"""
self.term_log.debug('TerminalApplication.authenticate()')
self.log_metadata = {
'application': 'terminal',
'upn': self.current_user['upn'],
'ip_address': self.ws.request.remote_ip,
'location': self.ws.location
}
self.term_log = go_logger("gateone.terminal", **self.log_metadata)
# Get our user-specific settings/policies for quick reference
self.policy = applicable_policies(
'terminal', self.current_user, self.ws.prefs)
# NOTE: If you want to be able to check policies on-the-fly without
# requiring the user reload the page when a change is made make sure
# call applicable_policies() on your own using self.ws.prefs every time
# you want to check them. This will ensure it's always up-to-date.
# NOTE: applicable_policies() is memoized so calling it over and over
# again shouldn't slow anything down.
# Start by determining if the user can even login to the terminal app
if 'allow' in self.policy:
if not self.policy['allow']:
# User is not allowed to access the terminal application. Don't
# bother sending them any static files and whatnot.
self.term_log.debug(_(
"User is not allowed to use the Terminal application. "
"Skipping post-authentication functions."))
return
self.send_client_files()
sess = SESSIONS[self.ws.session]
# Create a place to store app-specific stuff related to this session
# (but not necessarily this 'location')
if "terminal" not in sess:
sess['terminal'] = {}
# When Gate One exits...
if kill_session not in sess["kill_session_callbacks"]:
sess["kill_session_callbacks"].append(kill_session)
# When a session actually times out (kill dtach'd processes too)...
if timeout_session not in sess["timeout_callbacks"]:
sess["timeout_callbacks"].append(timeout_session)
# Set the sub-applications list to our commands
commands = list(self.policy['commands'].keys())
sub_apps = []
for command in commands:
if isinstance(self.policy['commands'][command], dict):
sub_app = self.policy['commands'][command].copy()
del sub_app['command'] # Don't want clients to know this
sub_app['name'] = command # Let them have the short name
if 'icon' in sub_app:
if sub_app['icon'].startswith(os.path.sep):
# This is a path to the icon instead of the actual
# icon (has to be SVG, after all). Replace it with
# the actual icon data (should start with <svg>)
if os.path.exists(sub_app['icon']):
with io.open(
sub_app['icon'], encoding='utf-8') as f:
sub_app['icon'] = f.read()
else:
self.term_log.error(_(
"Path to icon ({icon}) for command, "
"'{cmd}' could not be found.").format(
cmd=sub_app['name'],
icon=sub_app['icon']))
del sub_app['icon']
else:
sub_app = {'name': command}
if 'icon' not in sub_app:
# Use the generic one
icon_path = resource_filename(
'gateone.applications.terminal',
'/templates/command_icon.svg')
sub_app_icon = resource_string(
'gateone.applications.terminal',
'/templates/command_icon.svg').decode('utf-8')
sub_app['icon'] = sub_app_icon.format(cmd=sub_app['name'])
sub_apps.append(sub_app)
self.info['sub_applications'] = sorted(
sub_apps, key=lambda k: k['name'])
# NOTE: The user will often be authenticated before terminal.js is
# loaded. This means that self.terminals() will be ignored in most
# cases (only when the connection lost and re-connected without a page
# reload). For this reason GateOne.Terminal.init() calls
# getOpenTerminals().
self.terminals() # Tell the client about open terminals
self.list_shared_terminals() # Also tell them about any shared terms
self.trigger("terminal:authenticate")
[docs] def on_close(self):
"""
Removes all attached callbacks and triggers the `terminal:on_close`
event.
"""
# Remove all attached callbacks so we're not wasting memory/CPU on
# disconnected clients
if not hasattr(self.ws, 'location'):
return # Connection closed before authentication completed
if not self.ws.session: # Broadcast terminal
self.trigger("terminal:on_close")
return
session_locs = SESSIONS[self.ws.session]['locations']
if self.ws.location in session_locs and hasattr(self, 'loc_terms'):
for term in self.loc_terms:
if isinstance(term, int):
term_obj = self.loc_terms[term]
try:
multiplex = term_obj['multiplex']
multiplex.remove_all_callbacks(self.callback_id)
client_dict = term_obj[self.ws.client_id]
term_emulator = multiplex.term
term_emulator.remove_all_callbacks(self.callback_id)
# Remove anything associated with the client_id
multiplex.io_loop.remove_timeout(
client_dict['refresh_timeout'])
del self.loc_terms[term][self.ws.client_id]
except (AttributeError, KeyError):
# User never completed opening a terminal so
# self.callback_id is missing. Nothing to worry about
if self.ws.client_id in term_obj:
del term_obj[self.ws.client_id]
self.trigger("terminal:on_close")
@require(authenticated(), policies('terminal'))
def enumerate_commands(self):
"""
Tell the client which 'commands' (from settings/policy) that are
available via the `terminal:commands_list` WebSocket action.
"""
# Get the current settings in case they've changed:
policy = applicable_policies(
'terminal', self.current_user, self.ws.prefs)
commands = list(policy.get('commands', {}).keys())
if not commands:
self.term_log.error(_("You're missing the 'commands' setting!"))
return
message = {'terminal:commands_list': {'commands': commands}}
self.write_message(message)
[docs] def enumerate_fonts(self):
"""
Returns a JSON-encoded object containing the installed fonts.
"""
from .woff_info import woff_info
fonts = resource_listdir(
'gateone.applications.terminal', '/static/fonts')
font_list = []
for font in fonts:
if not font.endswith('.woff'):
continue
font_path = resource_filename(
'gateone.applications.terminal', '/static/fonts/%s' % font)
font_info = woff_info(font_path)
if "Font Family" not in font_info:
self.ws.logger.error(_(
"Bad font in fonts dir (missing Font Family in name "
"table): %s" % font))
continue # Bad font
if font_info["Font Family"] not in font_list:
font_list.append(font_info["Font Family"])
message = {'terminal:fonts_list': {'fonts': font_list}}
self.write_message(message)
@require(policies('terminal'))
def get_font(self, settings):
"""
Attached to the `terminal:get_font` WebSocket action; sends the client
CSS that includes a complete set of fonts associated with
*settings["font_family"]*. Optionally, the following additional
*settings* may be provided:
:font_size:
Assigns the 'font-size' property according to the given value.
"""
font_family = settings['font_family']
font_size = settings.get('font_size', '90%')
filename = 'font.css'
font_css_path = resource_filename(
'gateone.applications.terminal', '/templates/%s' % filename)
if font_family == 'monospace':
# User wants the browser to control the font; real simple:
rendered_path = self.render_style(
font_css_path,
force=True,
font_family=font_family,
font_size=font_size)
self.send_css(
rendered_path, element_id="terminal_font", filename=filename)
return
from .woff_info import woff_info
fonts = resource_listdir(
'gateone.applications.terminal', '/static/fonts')
woffs = {}
for font in fonts:
if not font.endswith('.woff'):
continue
font_path = resource_filename(
'gateone.applications.terminal', '/static/fonts/%s' % font)
font_info = woff_info(font_path)
if "Font Family" not in font_info:
self.ws.logger.error(_(
"Bad font in fonts dir (missing Font Family in name "
"table): %s" % font))
continue # Bad font
if font_info["Font Family"] == font_family:
font_dict = {
"subfamily": font_info["Font Subfamily"],
"font_style": "normal", # Overwritten below (if warranted)
"font_weight": "normal", # Ditto
"locals": "",
"url": (
"{server_url}terminal/static/fonts/{font}".format(
server_url=self.ws.base_url,
font=font)
)
}
if "Full Name" in font_info:
font_dict["locals"] += (
"local('{0}')".format(font_info["Full Name"]))
if "Postscript Name" in font_info:
font_dict["locals"] += (
", local('{0}')".format(font_info["Postscript Name"]))
if 'italic' in font_info["Font Subfamily"].lower():
font_dict["font_style"] = "italic"
if 'oblique' in font_info["Font Subfamily"].lower():
font_dict["font_style"] = "oblique"
if 'bold' in font_info["Font Subfamily"].lower():
font_dict["font_weight"] = "bold"
woffs.update({font: font_dict})
# NOTE: Not using render_and_send_css() because the source CSS file will
# never change but the output will.
rendered_path = self.render_style(
font_css_path,
force=True,
woffs=woffs,
font_family=font_family,
font_size=font_size)
self.send_css(
rendered_path, element_id="terminal_font", filename=filename)
[docs] def enumerate_colors(self):
"""
Returns a JSON-encoded object containing the installed text color
schemes.
"""
colors = resource_listdir(
'gateone.applications.terminal', '/templates/term_colors')
colors = [a for a in colors if a.endswith('.css')]
colors = [a.replace('.css', '') for a in colors]
message = {'terminal:colors_list': {'colors': colors}}
self.write_message(message)
[docs] def save_term_settings(self, term, settings):
"""
Saves whatever *settings* (must be JSON-encodable) are provided in the
user's session directory; associated with the given *term*.
The `restore_term_settings` function can be used to restore the provided
settings.
.. note:: This method is primarily to aid dtach support.
"""
self.term_log.debug("save_term_settings(%s, %s)" % (term, settings))
from .term_utils import save_term_settings as _save
term = str(term) # JSON wants strings as keys
def saved(result): # NOTE: result will always be None
"""
Called when we're done JSON-decoding and re-encoding the given
settings. Just triggers the `terminal:save_term_settings` event.
"""
self.trigger("terminal:save_term_settings", term, settings)
# Why bother with an async call for something so small? Well, we can't
# be sure it will *always* be a tiny amount of data. What if some app
# embedding Gate One wants to pass in some huge amount of metadata when
# they open new terminals? Don't want to block while the read, JSON
# decode, JSON encode, and write operations take place.
# Also note that this function gets called whenever a new terminal is
# opened or resumed. So if you have 100 users each with a dozen or so
# terminals it could slow things down quite a bit in the event that a
# number of users lose connectivity and reconnect at once (or the server
# is restarted with dtach support enabled).
self.cpu_async.call_singleton( # Singleton since we're writing async
_save,
'save_term_settings_%s' % self.ws.session,
term,
self.ws.location,
self.ws.session,
settings,
callback=saved)
[docs] def restore_term_settings(self, term):
"""
Reads the settings associated with the given *term* that are stored in
the user's session directory and applies them to
``self.loc_terms[term]``
"""
term = str(term) # JSON wants strings as keys
self.term_log.debug("restore_term_settings(%s)" % term)
from .term_utils import restore_term_settings as _restore
def restore(settings):
"""
Saves the *settings* returned by :func:`restore_term_settings`
in `self.loc_terms[term]` and triggers the
`terminal:restore_term_settings` event.
"""
if self.ws.location in settings:
if term in settings[self.ws.location]:
termNum = int(term)
self.loc_terms[termNum].update(
settings[self.ws.location][term])
# The terminal title needs some special love
self.loc_terms[termNum]['multiplex'].term.title = (
self.loc_terms[termNum]['title'])
self.trigger("terminal:restore_term_settings", term, settings)
future = self.cpu_async.call(
_restore,
self.ws.location,
self.ws.session,
memoize=False,
callback=restore)
return future
[docs] def clear_term_settings(self, term):
"""
Removes any settings associated with the given *term* in the user's
term_settings.json file (in their session directory).
"""
term = str(term)
self.term_log.debug("clear_term_settings(%s)" % term)
term_settings = RUDict()
term_settings[self.ws.location] = {term: {}}
session_dir = options.session_dir
session_dir = os.path.join(session_dir, self.ws.session)
settings_path = os.path.join(session_dir, 'term_settings.json')
if not os.path.exists(settings_path):
return # Nothing to do
# First we read in the existing settings and then update them.
if os.path.exists(settings_path):
with io.open(settings_path, encoding='utf-8') as f:
term_settings.update(json_decode(f.read()))
del term_settings[self.ws.location][term]
with io.open(settings_path, 'w', encoding='utf-8') as f:
f.write(json_encode(term_settings))
self.trigger("terminal:clear_term_settings", term)
@require(authenticated(), policies('terminal'))
def terminals(self, *args, **kwargs):
"""
Sends a list of the current open terminals to the client using the
`terminal:terminals` WebSocket action.
"""
# Note: *args and **kwargs are present so we can attach this to a go:
# event and just ignore the provided arguments.
self.term_log.debug('terminals()')
terminals = {}
# Create an application-specific storage space in the locations dict
if 'terminal' not in self.ws.locations[self.ws.location]:
self.ws.locations[self.ws.location]['terminal'] = {}
# Quick reference for our terminals in the current location:
if not self.ws.location:
return # WebSocket disconnected or not-yet-authenticated
self.loc_terms = self.ws.locations[self.ws.location]['terminal']
for term in list(self.loc_terms.keys()):
if isinstance(term, int): # Only terminals are integers in the dict
terminals.update({
term: {
'metadata': self.loc_terms[term]['metadata'],
'command': self.loc_terms[term]['command'],
'title': self.loc_terms[term]['title']
}})
share_id = self.loc_terms[term].get('share_id', None)
if share_id:
terminals[term].update({'share_id': share_id})
if not self.ws.session:
return # Just a broadcast terminal viewer
# Check for any dtach'd terminals we might have missed
if options.dtach and which('dtach'):
from .term_utils import restore_term_settings
term_settings = restore_term_settings(
self.ws.location, self.ws.session)
session_dir = options.session_dir
session_dir = os.path.join(session_dir, self.ws.session)
if not os.path.exists(session_dir):
mkdir_p(session_dir)
os.chmod(session_dir, 0o770)
for item in os.listdir(session_dir):
if item.startswith('dtach_'):
split = item.split('_')
location = split[1]
if location == self.ws.location:
term = int(split[2])
if term not in terminals:
if self.ws.location not in term_settings:
continue
# NOTE: str() below because the dict comes from JSON
if str(term) not in term_settings[self.ws.location]:
continue
data = term_settings[self.ws.location][str(term)]
metadata = data.get('metadata', {})
command = data.get('command', None)
title = data.get('title', 'Gate One')
terminals.update({term: {
'metadata': metadata,
'command': command,
'title': title
}})
self.trigger('terminal:terminals', terminals)
message = {'terminal:terminals': terminals}
self.write_message(json_encode(message))
[docs] def term_ended(self, term):
"""
Sends the 'term_ended' message to the client letting it know that the
given *term* is no more.
"""
metadata = {"term": term}
if term in self.loc_terms:
metadata["command"] = self.loc_terms[term].get("command", None)
self.term_log.info(
"Terminal Closed: %s" % term, metadata=metadata)
message = {'terminal:term_ended': term}
if term in self.loc_terms:
timediff = datetime.now() - self.loc_terms[term]['created']
if self.race_check:
race_check_timediff = datetime.now() - self.race_check
if race_check_timediff < timedelta(milliseconds=500):
# Definitely a race condition (command is failing to run).
# Add a delay
self.add_timeout("5s", partial(self.term_ended, term))
self.race_check = False
self.ws.send_message(_(
"Warning: Terminals are closing too fast. If you see "
"this message multiple times it is likely that the "
"configured command is failing to execute. Please "
"check your server settings."
))
cmd = self.loc_terms[term]['multiplex'].cmd
self.term_log.warning(_(
"Terminals are closing too quickly after being opened "
"(command: %s). Please check your 'commands' (usually "
"in settings/50terminal.conf)." % repr(cmd)))
return
elif timediff < timedelta(seconds=1):
# Potential race condition
# Alow the first one to go through immediately
self.race_check = datetime.now()
try:
self.write_message(json_encode(message))
except AttributeError:
# Because this function can be called after a timeout it is possible
# that the client will have disconnected in the mean time resulting
# in this exception. Not a problem; ignore.
return
self.trigger("terminal:term_ended", term)
[docs] def add_terminal_callbacks(self, term, multiplex, callback_id):
"""
Sets up all the callbacks associated with the given *term*, *multiplex*
instance and *callback_id*.
"""
import terminal
refresh = partial(self.refresh_screen, term)
multiplex.add_callback(multiplex.CALLBACK_UPDATE, refresh, callback_id)
ended = partial(self.term_ended, term)
multiplex.add_callback(multiplex.CALLBACK_EXIT, ended, callback_id)
# Setup the terminal emulator callbacks
term_emulator = multiplex.term
set_title = partial(self.set_title, term)
term_emulator.add_callback(
terminal.CALLBACK_TITLE, set_title, callback_id)
#set_title() # Set initial title
bell = partial(self.bell, term)
term_emulator.add_callback(
terminal.CALLBACK_BELL, bell, callback_id)
opt_esc_handler = partial(self.opt_esc_handler, term, multiplex)
term_emulator.add_callback(
terminal.CALLBACK_OPT, opt_esc_handler, callback_id)
mode_handler = partial(self.mode_handler, term)
term_emulator.add_callback(
terminal.CALLBACK_MODE, mode_handler, callback_id)
reset_term = partial(self.reset_client_terminal, term)
term_emulator.add_callback(
terminal.CALLBACK_RESET, reset_term, callback_id)
dsr = partial(self.dsr, term)
term_emulator.add_callback(
terminal.CALLBACK_DSR, dsr, callback_id)
term_emulator.add_callback(
terminal.CALLBACK_MESSAGE, self.ws.send_message, callback_id)
# Call any registered plugin Terminal hooks
self.trigger(
"terminal:add_terminal_callbacks", term, multiplex, callback_id)
[docs] def remove_terminal_callbacks(self, multiplex, callback_id):
"""
Removes all the Multiplex and terminal emulator callbacks attached to
the given *multiplex* instance and *callback_id*.
"""
import terminal
multiplex.remove_callback(multiplex.CALLBACK_UPDATE, callback_id)
multiplex.remove_callback(multiplex.CALLBACK_EXIT, callback_id)
term_emulator = multiplex.term
term_emulator.remove_callback(terminal.CALLBACK_TITLE, callback_id)
term_emulator.remove_callback(
terminal.CALLBACK_MESSAGE, callback_id)
term_emulator.remove_callback(terminal.CALLBACK_DSR, callback_id)
term_emulator.remove_callback(terminal.CALLBACK_RESET, callback_id)
term_emulator.remove_callback(terminal.CALLBACK_MODE, callback_id)
term_emulator.remove_callback(terminal.CALLBACK_OPT, callback_id)
term_emulator.remove_callback(terminal.CALLBACK_BELL, callback_id)
[docs] def new_multiplex(self,
cmd, term_id, logging=True, encoding='utf-8', debug=False):
"""
Returns a new instance of :py:class:`termio.Multiplex` with the proper
global and client-specific settings.
:cmd:
The command to execute inside of Multiplex.
:term_id:
The terminal to associate with this Multiplex or a descriptive
identifier (it's only used for logging purposes).
:logging:
If ``False``, logging will be disabled for this instance of
Multiplex (even if it would otherwise be enabled).
:encoding:
The default encoding that will be used when reading or writing
to the Multiplex instance.
:debug:
If ``True``, will enable debugging on the created Multiplex
instance.
"""
import termio
cls = TerminalApplication
policies = applicable_policies(
'terminal', self.current_user, self.ws.prefs)
shell_command = policies.get('shell_command', None)
enabled_filetypes = policies.get('enabled_filetypes', 'all')
use_shell = policies.get('use_shell', True)
user_dir = self.settings['user_dir']
try:
user = self.current_user['upn']
except:
# No auth, use ANONYMOUS (% is there to prevent conflicts)
user = r'ANONYMOUS' # Don't get on this guy's bad side
session_dir = options.session_dir
session_dir = os.path.join(session_dir, self.ws.session)
log_path = None
syslog_logging = False
if logging:
syslog_logging = policies['syslog_session_logging']
if policies['session_logging']:
log_dir = os.path.join(user_dir, user)
log_dir = os.path.join(log_dir, 'logs')
# Create the log dir if not already present
if not os.path.exists(log_dir):
mkdir_p(log_dir)
log_suffix = "-{0}.golog".format(
self.current_user.get('ip_address', "0.0.0.0"))
log_name = datetime.now().strftime(
'%Y%m%d%H%M%S%f') + log_suffix
log_path = os.path.join(log_dir, log_name)
facility = string_to_syslog_facility(self.settings['syslog_facility'])
# This allows plugins to transform the command however they like
if self.plugin_command_hooks:
for func in self.plugin_command_hooks:
cmd = func(self, cmd, term=term_id)
additional_log_metadata = {
'ip_address': self.current_user.get('ip_address', "0.0.0.0")
}
# This allows plugins to add their own metadata to .golog files:
if self.plugin_log_metadata_hooks:
for func in self.plugin_log_metadata_hooks:
metadata = func(self, term=term_id)
additional_log_metadata.update(metadata)
terminal_emulator_kwargs = {}
if enabled_filetypes != 'all':
# Only need to bother if it is something other than the default
terminal_emulator_kwargs = {'enabled_filetypes': enabled_filetypes}
m = termio.Multiplex(
cmd,
terminal_emulator_kwargs=terminal_emulator_kwargs,
log_path=log_path,
user=user,
term_id=term_id,
debug=debug,
syslog=syslog_logging,
syslog_facility=facility,
additional_metadata=additional_log_metadata,
encoding=encoding
)
if use_shell:
m.use_shell = True # This is the default anyway
if shell_command:
m.shell_command = shell_command
else:
m.use_shell = False
if self.plugin_new_multiplex_hooks:
for func in self.plugin_new_multiplex_hooks:
func(self, m)
self.trigger("terminal:new_multiplex", m)
return m
[docs] def highest_term_num(self, location=None):
"""
Returns the highest terminal number at the given *location* (so we can
figure out what's next). If *location* is omitted, uses
`self.ws.location`.
"""
if not self.ws.session:
return 1 # Broadcast terminal viewer
if not location:
location = self.ws.location
loc = SESSIONS[self.ws.session]['locations'][location]['terminal']
highest = 0
for term in list(loc.keys()):
if isinstance(term, int):
if term > highest:
highest = term
return highest
@require(authenticated(), policies('terminal'))
def new_terminal(self, settings):
"""
Starts up a new terminal associated with the user's session using
*settings* as the parameters. If a terminal already exists with the
same number as *settings[term]*, self.set_terminal() will be called
instead of starting a new terminal (so clients can resume their session
without having to worry about figuring out if a new terminal already
exists or not).
"""
term = int(settings.get('term', self.highest_term_num() + 1))
# TODO: Make these specific to each terminal:
rows = settings.get('rows', 24)
if not isinstance(rows, int):
rows = 24
cols = settings.get('columns', 80)
if not isinstance(cols, int):
cols = 80
if rows < 2 or cols < 2: # Something went wrong calculating term size
# Fall back to a standard default
rows = 24
cols = 80
default_env = {"TERM": 'xterm-256color'} # Only one default
policy = applicable_policies(
'terminal', self.current_user, self.ws.prefs)
environment_vars = policy.get('environment_vars', default_env)
default_encoding = policy.get('default_encoding', 'utf-8')
encoding = settings.get('encoding', default_encoding)
if not encoding: # Was passed as None or 'null'
encoding = default_encoding
term_metadata = settings.get('metadata', {})
settings_dir = self.settings['settings_dir']
user_session_dir = os.path.join(options.session_dir, self.ws.session)
# NOTE: 'command' here is actually just the short name of the command.
# ...which maps to what's configured the 'commands' part of your
# terminal settings.
if 'command' in settings and settings['command']:
command = settings['command']
else:
try:
command = policy['default_command']
except KeyError:
self.term_log.error(_(
"You are missing a 'default_command' in your terminal "
"settings (usually 50terminal.conf in %s)"
% settings_dir))
return
cmd_dtach_enabled = True
# Get the full command
try:
full_command = policy['commands'][command]
except KeyError:
# The given command isn't an option
self.term_log.error(_(
"%s: Attempted to execute invalid command (%s)." % (
self.current_user['upn'], command)))
self.ws.send_message(_("Terminal: Invalid command: %s" % command))
self.term_ended(term)
return
if isinstance(full_command, dict): # Extended command definition
# This lets you disable dtach on a per-command basis:
cmd_dtach_enabled = full_command.get('dtach', True)
full_command = full_command['command']
# Make a nice, useful logging line with extra metadata
log_metadata = {
"rows": settings["rows"],
"columns": settings["columns"],
"term": term,
"command": command
}
self.term_log.info("New Terminal: %s" % term, metadata=log_metadata)
# Now remove the new-term-specific metadata
if 'em_dimensions' in settings:
self.em_dimensions = {
'height': settings['em_dimensions']['h'],
'width': settings['em_dimensions']['w']
}
user_dir = self.settings['user_dir']
if term not in self.loc_terms:
# Setup the requisite dict
self.loc_terms[term] = {
'last_activity': datetime.now(),
'title': 'Gate One',
'command': command,
'manual_title': False,
'metadata': term_metadata, # Any extra info the client gave us
# This is needed by the terminal sharing policies:
'user': self.current_user # So we can determine the owner
}
term_obj = self.loc_terms[term]
if self.ws.client_id not in term_obj:
term_obj[self.ws.client_id] = {
# Used by refresh_screen()
'refresh_timeout': None
}
if 'multiplex' not in term_obj:
# Start up a new terminal
term_obj['created'] = datetime.now()
# NOTE: Not doing anything with 'created'... yet!
now = int(round(time.time() * 1000))
try:
user = self.current_user['upn']
except:
# No auth, use ANONYMOUS (% is there to prevent conflicts)
user = 'ANONYMOUS' # Don't get on this guy's bad side
cmd = cmd_var_swap(full_command, # Swap out variables like %USER%
gateone_dir=GATEONE_DIR,
session=self.ws.session, # with their real-world values.
session_dir=options.session_dir,
session_hash=short_hash(self.ws.session),
userdir=user_dir,
user=user,
time=now
)
# Now swap out any variables like $PATH, $HOME, $USER, etc
cmd = os.path.expandvars(cmd)
resumed_dtach = False
# Create the user's session dir if not already present
if not os.path.exists(user_session_dir):
mkdir_p(user_session_dir)
os.chmod(user_session_dir, 0o770)
if options.dtach and which('dtach') and cmd_dtach_enabled:
# Wrap in dtach (love this tool!)
dtach_path = "{session_dir}/dtach_{location}_{term}".format(
session_dir=user_session_dir,
location=self.ws.location,
term=term)
if os.path.exists(dtach_path):
# Using 'none' for the refresh because termio
# likes to manage things like that on his own...
cmd = "dtach -a %s -E -z -r none" % dtach_path
resumed_dtach = True
else: # No existing dtach session... Make a new one
cmd = "dtach -c %s -E -z -r none %s" % (dtach_path, cmd)
self.term_log.debug(_("new_terminal cmd: %s" % repr(cmd)))
m = term_obj['multiplex'] = self.new_multiplex(
cmd, term, encoding=encoding)
# Set some environment variables so the programs we execute can use
# them (very handy). Allows for "tight integration" and "synergy"!
env = {
'GO_DIR': GATEONE_DIR,
'GO_SETTINGS_DIR': settings_dir,
'GO_USER_DIR': user_dir,
'GO_USER': user,
'GO_TERM': str(term),
'GO_LOCATION': self.ws.location,
'GO_SESSION': self.ws.session,
'GO_SESSION_DIR': options.session_dir,
'GO_USER_SESSION_DIR': user_session_dir,
}
env.update(os.environ) # Add the defaults for this system
env.update(environment_vars) # Apply policy-based environment
if self.plugin_env_hooks:
# This allows plugins to add/override environment variables
env.update(self.plugin_env_hooks)
m.spawn(rows, cols, env=env, em_dimensions=self.em_dimensions)
# Give the terminal emulator a path to store temporary files
m.term.temppath = os.path.join(user_session_dir, 'downloads')
if not os.path.exists(m.term.temppath):
os.mkdir(m.term.temppath)
# Tell it how to serve them up (origin ensures correct link)
m.term.linkpath = "{server_url}downloads".format(
server_url=self.ws.base_url)
# Make sure it can generate pretty icons for file downloads
m.term.icondir = resource_filename('gateone', '/static/icons')
if resumed_dtach:
# Send an extra Ctrl-L to refresh the screen and fix the sizing
# after it has been reattached.
m.write('\x0c')
else:
# Terminal already exists
m = term_obj['multiplex']
if m.isalive():
# It's ALIVE!!!
if term_obj['user'] == self.current_user:
m.resize(
rows, cols,
ctrl_l=False,
em_dimensions=self.em_dimensions)
message = {'terminal:term_exists': {'term': term}}
self.write_message(json_encode(message))
# This resets the screen diff
m.prev_output[self.ws.client_id] = [None] * rows
else:
# Tell the client this terminal is no more
self.term_ended(term)
return
# Setup callbacks so that everything gets called when it should
self.add_terminal_callbacks(
term, term_obj['multiplex'], self.callback_id)
# NOTE: refresh_screen will also take care of cleaning things up if
# term_obj['multiplex'].isalive() is False
self.refresh_screen(term, True) # Send a fresh screen to the client
self.current_term = term
# Restore expanded modes
for mode, setting in m.term.expanded_modes.items():
self.mode_handler(term, mode, setting)
if self.settings['logging'] == 'debug':
self.ws.send_message(_(
"WARNING: Logging is set to DEBUG. All keystrokes will be "
"logged!"))
self.send_term_encoding(term, encoding)
if self.loc_terms[term]['multiplex'].cmd.startswith('dtach -a'):
# This dtach session was resumed; restore terminal settings
m_term = term_obj['multiplex'].term
future = self.restore_term_settings(term)
self.io_loop.add_future(
future, lambda f: self.set_title(term, force=True, save=False))
# The multiplex instance needs the title set by hand (it's special)
self.io_loop.add_future(
future, lambda f: m_term.set_title(
self.loc_terms[term]['title']))
self.trigger("terminal:new_terminal", term)
# Calling save_term_settings() after the event is fired so that plugins
# can modify the metadata before it gets saved.
self.save_term_settings(
term, {'command': command,
'metadata': self.loc_terms[term]['metadata']})
@require(authenticated(), policies('terminal'))
def set_term_encoding(self, settings):
"""
Sets the encoding for the given *settings['term']* to
*settings['encoding']*.
"""
term = int(settings['term'])
encoding = settings['encoding']
try:
" ".encode(encoding)
except LookupError:
# Invalid encoding
self.ws.send_message(_(
"Invalid encoding. For a list of valid encodings see:<br>"
"<a href='http://docs.python.org/2/library/codecs.html#standard-encodings'"
" target='new'>Standard Encodings</a>"
))
return
term_obj = self.loc_terms[term]
m = term_obj['multiplex']
m.set_encoding(encoding)
# Make sure the client is aware that the change was successful
[docs] def send_term_encoding(self, term, encoding):
"""
Sends a message to the client indicating the *encoding* of *term* (in
the event that a terminal is reattached or when sharing a terminal).
"""
message = {'terminal:encoding': {'term': term, 'encoding': encoding}}
self.write_message(message)
@require(authenticated(), policies('terminal'))
def set_term_keyboard_mode(self, settings):
"""
Sets the keyboard mode (e.g. 'sco') for the given *settings['term']* to
*settings['mode']*. This is only so we can inform the client of the
mode when a terminal is re-attached (the serer-side stuff doesn't use
keyboard modes).
"""
valid_modes = ['default', 'sco', 'xterm', 'linux']
term = int(settings['term'])
mode = settings['mode']
if mode not in valid_modes:
self.ws.send_message(_(
"Invalid keyboard mode. Must be one of: %s"
% ", ".join(valid_modes)))
return
term_obj = self.loc_terms[term]
term_obj['keyboard_mode'] = mode
[docs] def send_term_keyboard_mode(self, term, mode):
"""
Sends a message to the client indicating the *mode* of *term* (in
the event that a terminal is reattached or when sharing a terminal).
"""
message = {'terminal:keyboard_mode': {'term': term, 'mode': mode}}
self.write_message(message)
@require(authenticated(), policies('terminal'))
def start_capture(self, term=None):
"""
Starts capturing output for the terminal given via *term*.
The output will be saved to a temporary file and delivered to the client
when `TerminalApplication.stop_capture` is called.
If no *term* is given the currently-selected terminal will be used.
"""
self.term_log.debug("start_capture(%s)" % repr(term))
from tempfile import NamedTemporaryFile
from .term_utils import capture_stream
if not term:
term = self.current_term
# Make a temporary file to save the terminal's output
capture_file = NamedTemporaryFile(prefix="go_term_cap", delete=False)
capture_path = capture_file.name
# Don't need the object anymore since we'll be using io.open():
capture_file.close() # Will get deleted in stop_capture()
capture_func = partial(capture_stream, self)
term_obj = self.loc_terms[term]
term_obj["capture"] = {
"output": io.open(capture_path, 'a', encoding="utf-8"),
"capture_func": capture_func # So we can call self.off() with it
}
self.on("terminal:refresh_screen", capture_func)
[docs] def stop_capture(self, term):
"""
Stops capturing output for the given *term* by closing the open file
object and deleting the "capture" dict from the current instance of
`TerminalApplication.loc_terms[term]`. The captured data will be sent
to the client via the 'terminal:captured_data' WebSocket action which
will included a dict like so::
{"term": 1, "data": "$ ls\nfile1 file2\n$ " }
"""
self.term_log.debug("stop_capture(%s)" % term)
if term not in self.loc_terms:
return # Nothing to do
term_obj = self.loc_terms[term]
if 'capture' not in term_obj:
return # Nothing to do
capture = term_obj["capture"]["output"]
capture_path = capture.name
capture.flush()
capture.close()
capture_func = term_obj["capture"]["capture_func"]
self.off("terminal:refresh_screen", capture_func)
capture_data = open(capture_path, 'rb').read()
capture_dict = {
'term': term,
'data': capture_data
}
# Cleanup
os.remove(capture_path)
del term_obj["capture"]
message = {'terminal:captured_data': capture_dict}
self.write_message(message)
@require(authenticated(), policies('terminal'))
def swap_terminals(self, settings):
"""
Swaps the numbers of *settings['term1']* and *settings['term2']*.
"""
term1 = int(settings.get('term1', 0))
term2 = int(settings.get('term2', 0))
if not term1 or not term2:
return # Nothing to do
missing_msg = _("Error: Terminal {term} does not exist.")
if term1 not in self.loc_terms:
self.ws.send_message(missing_msg.format(term=term1))
return
if term2 not in self.loc_terms:
self.ws.send_message(missing_msg.format(term=term2))
return
term1_dict = self.loc_terms.pop(term1)
term2_dict = self.loc_terms.pop(term2)
self.remove_terminal_callbacks(
term1_dict['multiplex'], self.callback_id)
self.remove_terminal_callbacks(
term2_dict['multiplex'], self.callback_id)
self.loc_terms.update({term1: term2_dict})
self.loc_terms.update({term2: term1_dict})
self.add_terminal_callbacks(
term1, term2_dict['multiplex'], self.callback_id)
self.add_terminal_callbacks(
term2, term1_dict['multiplex'], self.callback_id)
self.trigger("terminal:swap_terminals", term1, term2)
@require(authenticated(), policies('terminal'))
def move_terminal(self, settings):
"""
Attached to the `terminal:move_terminal` WebSocket action. Moves
*settings['term']* (terminal number) to
``SESSIONS[self.ws.session][[*settings['location']*]['terminal']``. In
other words, it moves the given terminal to the given location in the
*SESSIONS* dict.
If the given location dict doesn't exist (yet) it will be created.
"""
self.term_log.debug("move_terminal(%s)" % settings)
new_location_exists = True
term = existing_term = int(settings['term'])
new_location = settings['location']
if term not in self.loc_terms:
self.ws.send_message(_(
"Error: Terminal {term} does not exist at the current location"
" ({location})".format(term=term, location=self.ws.location)))
return
existing_term_obj = self.loc_terms[term]
if new_location not in self.ws.locations:
term = 1 # Starting anew in the new location
self.ws.locations[new_location] = {}
self.ws.locations[new_location]['terminal'] = {
term: existing_term_obj
}
new_location_exists = False
else:
existing_terms = [
a for a in self.ws.locations[
new_location]['terminal'].keys()
if isinstance(a, int)]
existing_terms.sort()
new_term_num = 1
if existing_terms:
new_term_num = existing_terms[-1] + 1
self.ws.locations[new_location][
'terminal'][new_term_num] = existing_term_obj
multiplex = existing_term_obj['multiplex']
# Remove the existing object's callbacks so we don't end up sending
# things like screen updates to the wrong place.
try:
self.remove_terminal_callbacks(multiplex, self.callback_id)
except KeyError:
pass # Already removed callbacks--no biggie
em_dimensions = {
'h': multiplex.em_dimensions['height'],
'w': multiplex.em_dimensions['width']
}
if new_location_exists:
# Already an open window using this 'location'... Tell it to open
# a new terminal for the user.
new_location_instance = None
# Find the ApplicationWebSocket instance using the given 'location':
for instance in self.ws.instances:
if instance.location == new_location:
ws_instance = instance
break
# Find the TerminalApplication inside the ws_instance:
for app in ws_instance.apps:
if isinstance(app, TerminalApplication):
new_location_instance = app
new_location_instance.new_terminal({
'term': new_term_num,
'rows': multiplex.rows,
'columns': multiplex.cols,
'em_dimensions': em_dimensions
})
ws_instance.send_message(_(
"Incoming terminal from location: %s" % self.ws.location))
#else:
# Make sure the new location dict is setup properly
#self.add_terminal_callbacks(term, multiplex, callback_id)
# Remove old location:
del self.loc_terms[existing_term]
details = {
'term': term,
'location': new_location
}
message = { # Closes the term in the current window/tab
'terminal:term_moved': details,
}
self.write_message(message)
self.trigger("terminal:move_terminal", details)
@require(authenticated(), policies('terminal'))
def kill_terminal(self, term):
"""
Kills *term* and any associated processes.
"""
term = int(term)
if term not in self.loc_terms:
return # Nothing to do
metadata = {
"term": term,
"command": self.loc_terms[term]["command"]
}
self.term_log.info(
"Terminal Killed: %s" % term, metadata=metadata)
multiplex = self.loc_terms[term]['multiplex']
# Remove the EXIT callback so the terminal doesn't restart itself
multiplex.remove_callback(multiplex.CALLBACK_EXIT, self.callback_id)
try:
if options.dtach: # dtach needs special love
from gateone.core.utils import kill_dtached_proc
kill_dtached_proc(self.ws.session, self.ws.location, term)
if multiplex.isalive():
multiplex.terminate()
except KeyError:
pass # The EVIL termio has killed my child! Wait, that's good...
# Because now I don't have to worry about it!
finally:
del self.loc_terms[term]
self.clear_term_settings(term)
self.trigger("terminal:kill_terminal", term)
[docs] def set_terminal(self, term):
"""
Sets `self.current_term = *term*` so we can determine where to send
keystrokes.
"""
try:
self.current_term = int(term)
self.trigger("terminal:set_terminal", term)
except TypeError:
pass # Bad term given
[docs] def reset_client_terminal(self, term):
"""
Tells the client to reset the terminal (clear the screen and remove
scrollback).
"""
message = {'terminal:reset_client_terminal': term}
self.write_message(json_encode(message))
self.trigger("terminal:reset_client_terminal", term)
@require(authenticated(), policies('terminal'))
def reset_terminal(self, term):
"""
Performs the equivalent of the 'reset' command which resets the terminal
emulator (among other things) to return the terminal to a sane state in
the event that something went wrong (bad escape sequence).
"""
self.term_log.debug('reset_terminal(%s)' % term)
term = int(term)
# This re-creates all the tabstops:
tabs = '\x1bH ' * 22
reset_sequence = (
'\r\x1b[3g %sr\x1bc\x1b[!p\x1b[?3;4l\x1b[4l\x1b>\r' % tabs)
multiplex = self.loc_terms[term]['multiplex']
multiplex.term.write(reset_sequence)
multiplex.write('\x0c') # ctrl-l
self.full_refresh(term)
self.trigger("terminal:reset_terminal", term)
[docs] def set_title(self, term, force=False, save=True):
"""
Sends a message to the client telling it to set the window title of
*term* to whatever comes out of::
self.loc_terms[term]['multiplex'].term.get_title() # Whew! Say that three times fast!
Example message::
{'set_title': {'term': 1, 'title': "user@host"}}
If *force* resolves to True the title will be sent to the cleint even if
it matches the previously-set title.
if *save* is ``True`` (the default) the title will be saved via the
`TerminalApplication.save_term_settings` function so that it may be
restored later (in the event of a server restart--if you've got dtach
support enabled).
.. note:: Why the complexity on something as simple as setting the title? Many prompts set the title. This means we'd be sending a 'title' message to the client with nearly every screen update which is a pointless waste of bandwidth if the title hasn't changed.
"""
self.term_log.debug("set_title(%s, %s, %s)" % (term, force, save))
term = int(term)
term_obj = self.loc_terms[term]
if term_obj['manual_title']:
if force:
title = term_obj['title']
title_message = {
'terminal:set_title': {'term': term, 'title': title}}
self.write_message(json_encode(title_message))
return
title = term_obj['multiplex'].term.get_title()
# Only send a title update if it actually changed
if title != term_obj['title'] or force:
term_obj['title'] = title
title_message = {
'terminal:set_title': {'term': term, 'title': title}}
self.write_message(json_encode(title_message))
# Save it in case we're restarted (only matters for dtach)
if save:
self.save_term_settings(term, {'title': title})
self.trigger("terminal:set_title", term, title)
@require(authenticated(), policies('terminal'))
def manual_title(self, settings):
"""
Sets the title of *settings['term']* to *settings['title']*. Differs
from :func:`set_title` in that this is an action that gets called by the
client when the user sets a terminal title manually.
"""
self.term_log.debug("manual_title: %s" % settings)
term = int(settings['term'])
title = settings['title']
term_obj = self.loc_terms[term]
if not title:
title = term_obj['multiplex'].term.get_title()
term_obj['manual_title'] = False
else:
term_obj['manual_title'] = True
term_obj['title'] = title
title_message = {'terminal:set_title': {'term': term, 'title': title}}
self.write_message(json_encode(title_message))
# Save it in case we're restarted (only matters for dtach)
self.save_term_settings(term, {'title': title})
self.trigger("terminal:manual_title", title)
[docs] def bell(self, term):
"""
Sends a message to the client indicating that a bell was encountered in
the given terminal (*term*). Example message::
{'bell': {'term': 1}}
"""
bell_message = {'terminal:bell': {'term': term}}
self.write_message(json_encode(bell_message))
self.trigger("terminal:bell", term)
[docs] def mode_handler(self, term, setting, boolean):
"""
Handles mode settings that require an action on the client by pasing it
a message like::
{
'terminal:set_mode': {
'mode': setting,
'bool': True,
'term': term
}
}
"""
self.term_log.debug(
"mode_handler() term: %s, setting: %s, boolean: %s" %
(term, setting, boolean))
term_obj = self.loc_terms[term]
# So we can restore it:
term_obj['application_mode'] = boolean
if boolean:
# Tell client to set this mode
mode_message = {'terminal:set_mode': {
'mode': setting,
'bool': True,
'term': term
}}
self.write_message(json_encode(mode_message))
else:
# Tell client to reset this mode
mode_message = {'terminal:set_mode': {
'mode': setting,
'bool': False,
'term': term
}}
self.write_message(json_encode(mode_message))
self.trigger("terminal:mode_handler", term, setting, boolean)
[docs] def dsr(self, term, response):
"""
Handles Device Status Report (DSR) calls from the underlying program
that get caught by the terminal emulator. *response* is what the
terminal emulator returns from the CALLBACK_DSR callback.
.. note:: This also handles the CSI DSR sequence.
"""
m = self.loc_terms[term]['multiplex']
m.write(response)
[docs] def _send_refresh(self, term, full=False):
"""Sends a screen update to the client."""
try:
term_obj = self.loc_terms[term]
term_obj['last_activity'] = datetime.now()
except KeyError:
# This can happen if the user disconnected in the middle of a screen
# update or if the terminal was closed really quickly before the
# Tornado framework got a chance to call this function. Nothing to
# be concerned about.
return # Ignore
multiplex = term_obj['multiplex']
scrollback, screen = multiplex.dump_html(
full=full, client_id=self.ws.client_id)
if [a for a in screen if a]: # Checking for non-empty lines here
output_dict = {
'terminal:termupdate': {
'term': term,
'scrollback': scrollback,
'screen' : screen,
'ratelimiter': multiplex.ratelimiter_engaged
}
}
try:
self.write_message(json_encode(output_dict))
except IOError: # Socket was just closed, no biggie
self.term_log.info(
_("WebSocket closed (%s)") % self.current_user['upn'])
multiplex = term_obj['multiplex']
multiplex.remove_callback( # Stop trying to write
multiplex.CALLBACK_UPDATE, self.callback_id)
[docs] def refresh_screen(self, term, full=False, stream=None):
"""
Writes the state of the given terminal's screen and scrollback buffer to
the client using `_send_refresh()`. Also ensures that screen updates
don't get sent too fast to the client by instituting a rate limiter that
also forces a refresh every 150ms. This keeps things smooth on the
client side and also reduces the bandwidth used by the application (CPU
too).
If *full*, send the whole screen (not just the difference).
The *stream* argument is meant to contain the raw character stream that
resulted in the terminal screen being updated. It is only used to pass
the data through to the 'terminal:refresh_screen' event. This event is
and that raw data is used by the `TerminalApplication.start_capture` and
`TerminalApplication.stop_capture` methods.
"""
# Commented this out because it was getting annoying.
# Note to self: add more levels of debugging beyond just "debug".
#self.term_log.debug(
#"refresh_screen (full=%s) on %s" % (full, self.callback_id))
if term:
term = int(term)
else:
return # This just prevents an exception when the cookie is invalid
term_obj = self.loc_terms[term]
try:
msec = timedelta(milliseconds=50) # Keeps things smooth
# In testing, 150 milliseconds was about as low as I could go and
# still remain practical.
force_refresh_threshold = timedelta(milliseconds=150)
last_activity = term_obj['last_activity']
timediff = datetime.now() - last_activity
# Because users can be connected to their session from more than one
# browser/computer we differentiate between refresh timeouts by
# tying the timeout to the client_id.
client_dict = term_obj[self.ws.client_id]
multiplex = term_obj['multiplex']
refresh = partial(self._send_refresh, term, full)
# We impose a rate limit of max one screen update every 50ms by
# wrapping the call to _send_refresh() in an IOLoop timeout that
# gets cancelled and replaced if screen updates come in faster than
# once every 50ms. If screen updates are consistently faster than
# that (e.g. a key is held down) we also force sending the screen
# to the client every 150ms. This ensures that no matter how fast
# screen updates are happening the user will get at least one
# update every 150ms. It works out quite nice, actually.
if client_dict['refresh_timeout']:
multiplex.io_loop.remove_timeout(client_dict['refresh_timeout'])
if timediff > force_refresh_threshold:
refresh()
else:
client_dict['refresh_timeout'] = multiplex.io_loop.add_timeout(
msec, refresh)
except KeyError as e: # Session died (i.e. command ended).
self.term_log.debug(_("KeyError in refresh_screen: %s" % e))
self.trigger("terminal:refresh_screen", term, stream=stream)
[docs] def full_refresh(self, term):
"""Calls `self.refresh_screen(*term*, full=True)`"""
try:
term = int(term)
except ValueError:
self.term_log.debug(_(
"Invalid terminal number given to full_refresh(): %s" % term))
self.refresh_screen(term, full=True)
self.trigger("terminal:full_refresh", term)
@require(authenticated(), policies('terminal'))
def resize(self, resize_obj):
"""
Resize the terminal window to the rows/columns specified in *resize_obj*
Example *resize_obj*::
{'rows': 24, 'columns': 80}
"""
term = None
if 'term' in resize_obj:
try:
term = int(resize_obj['term'])
except ValueError:
return # Got bad value, skip this resize
self.term_log.info("Resizing Terminal: %s" % term, metadata=resize_obj)
rows = resize_obj['rows']
cols = resize_obj['columns']
self.em_dimensions = {
'height': resize_obj['em_dimensions']['h'],
'width': resize_obj['em_dimensions']['w']
}
ctrl_l = False
if 'ctrl_l' in resize_obj:
ctrl_l = resize_obj['ctrl_l']
if rows < 2 or cols < 2:
# Fall back to a standard default:
rows = 24
cols = 80
# If the user already has a running session, set the new terminal size:
try:
if term:
m = self.loc_terms[term]['multiplex']
m.resize(
rows,
cols,
em_dimensions=self.em_dimensions,
ctrl_l=ctrl_l
)
else: # Resize them all
for term in list(self.loc_terms.keys()):
if isinstance(term, int): # Skip the TidyThread
self.loc_terms[term]['multiplex'].resize(
rows,
cols,
em_dimensions=self.em_dimensions,
ctrl_l=ctrl_l
)
except KeyError: # Session doesn't exist yet, no biggie
pass
self.write_message(
{"terminal:resize": {"term": term, "rows": rows, "columns": cols}})
self.trigger("terminal:resize", term)
@require(authenticated(), policies('terminal'))
def char_handler(self, chars, term=None):
"""
Writes *chars* (string) to *term*. If *term* is not provided the
characters will be sent to the currently-selected terminal.
"""
self.term_log.debug("char_handler(%s, %s)" % (repr(chars), repr(term)))
if not term:
term = self.current_term
term = int(term) # Just in case it was sent as a string
if self.ws.session in SESSIONS and term in self.loc_terms:
multiplex = self.loc_terms[term]['multiplex']
if multiplex.isalive():
multiplex.write(chars)
# Handle (gracefully) the situation where a capture is stopped
if '\x03' in chars:
if not multiplex.term.capture:
return # Nothing to do
# Make sure the call to abort_capture() comes *after* the
# underlying program has itself caught the SIGINT (Ctrl-C)
multiplex.io_loop.add_timeout(
timedelta(milliseconds=1000),
multiplex.term.abort_capture)
# Also make sure the client gets a screen update
refresh = partial(self.refresh_screen, term)
multiplex.io_loop.add_timeout(
timedelta(milliseconds=1050), refresh)
@require(authenticated(), policies('terminal'))
def write_chars(self, message):
"""
Writes *message['chars']* to *message['term']*. If *message['term']*
is not present, *self.current_term* will be used.
"""
#self.term_log.debug('write_chars(%s)' % message)
if 'chars' not in message:
return # Invalid message
if 'term' not in message:
message['term'] = self.current_term
try:
self.char_handler(message['chars'], message['term'])
except Exception as e:
# Term is closed or invalid
self.term_log.error(_(
"Got exception trying to write_chars() to terminal %s"
% message['term']))
self.term_log.error(str(e))
import traceback
traceback.print_exc(file=sys.stdout)
[docs] def opt_esc_handler(self, term, multiplex, chars):
"""
Calls whatever function is attached to the
'terminal:opt_esc_handler:<name>' event; passing it the *text* (second
item in the tuple) that is returned by
:func:`utils.process_opt_esc_sequence`. Such functions are usually
attached via the 'Escape' plugin hook but may also be registered via
the usual event method, :meth`self.on`::
self.on('terminal:opt_esc_handler:somename', some_function)
The above example would result in :func:`some_function` being called
whenever a matching optional escape sequence handler is encountered.
For example:
.. ansi-block::
$ echo -e "\\033]_;somename|Text passed to some_function()\\007"
Which would result in :func:`some_function` being called like so::
some_function(
self, "Text passed to some_function()", term, multiplex)
In the above example, *term* will be the terminal number that emitted
the event and *multiplex* will be the `termio.Multiplex` instance that
controls the terminal.
"""
self.term_log.debug("opt_esc_handler(%s)" % repr(chars))
plugin_name, text = process_opt_esc_sequence(chars)
if plugin_name:
try:
event = "terminal:opt_esc_handler:%s" % plugin_name
self.trigger(event, text, term=term, multiplex=multiplex)
except Exception as e:
self.term_log.error(_(
"Got exception trying to execute plugin's optional ESC "
"sequence handler..."))
self.term_log.error(str(e))
import traceback
traceback.print_exc(file=sys.stdout)
[docs] def get_bell(self):
"""
Sends the bell sound data to the client in in the form of a data::URI.
"""
bell_path = resource_filename(
'gateone.applications.terminal', '/static/bell.ogg')
try:
bell_data_uri = create_data_uri(bell_path)
except (IOError, MimeTypeFail): # There's always the fallback
self.term_log.error(_("Could not load bell: %s") % bell_path)
bell_data_uri = resource_string(
'gateone.applications.terminal', '/static/fallback_bell.txt')
mimetype = bell_data_uri.split(';')[0].split(':')[1]
message = {
'terminal:load_bell': {
'data_uri': bell_data_uri, 'mimetype': mimetype
}
}
self.write_message(json_encode(message))
[docs] def get_webworker(self):
"""
Sends the text of our term_ww.js to the client in order to get
around the limitations of loading remote Web Worker URLs (for embedding
Gate One into other apps).
"""
go_process = resource_string(
'gateone.applications.terminal', '/static/webworkers/term_ww.js')
message = {'terminal:load_webworker': go_process.decode('utf-8')}
self.write_message(json_encode(message))
[docs] def get_colors(self, settings):
"""
Sends the text color stylesheet matching the properties specified in
*settings* to the client. *settings* must contain the following:
:colors: The name of the CSS text color scheme to be retrieved.
"""
self.term_log.debug('get_colors(%s)' % settings)
send_css = self.ws.prefs['*']['gateone'].get('send_css', True)
if not send_css:
if not hasattr('logged_css_message', self):
self.term_log.info(_(
"send_css is false; will not send JavaScript."))
# So we don't repeat this message a zillion times in the logs:
self.logged_css_message = True
return
colors_filename = "%s.css" % settings["colors"]
colors_path = resource_filename(
'gateone.applications.terminal',
'/templates/term_colors/%s' % colors_filename)
filename = "term_colors.css" # Make sure it's the same every time
self.render_and_send_css(colors_path,
element_id="text_colors", filename=filename)
@require(policies('terminal'))
def get_locations(self):
"""
Attached to the `terminal:get_locations` WebSocket action. Sends a
message to the client (via the `terminal:term_locations` WebSocket
action) listing all 'locations' where terminals reside.
.. note::
Typically the location mechanism is used to open terminals in
different windows/tabs.
"""
term_locations = {}
for location, obj in self.ws.locations.items():
terms = obj.get('terminal', None)
if terms:
term_locations[location] = terms.keys()
message = {'terminal:term_locations': term_locations}
self.write_message(json_encode(message))
self.trigger("terminal:term_locations", term_locations)
# Terminal sharing TODO (not in any particular order or priority):
# * GUI elements that allow a user to share a terminal:
# DONE Share this terminal:
# DONE - Allow anyone with the right URL to view (requires authorization-on-connect).
# DONE - Allow only authenticated users.
# DONE - Allow only specified users.
# - Sharing controls widget (pause/resume sharing, primarily).
# - Chat widget (or similar--maybe with audio/video via WebRTC).
# - A mechanism to invite people (send an email/alert).
# - A mechanism to approve inbound viewers (for "allow AUTHENTICATED" situations).
# * A server-side API to control sharing:
# DONE - Share X with authorization options (allow anon w/URL and/or password, authenticated users, or a specific list)
# DONE - Stop sharing terminal X.
# - Pause sharing of terminal X (So it can be resumed without having to change the viewers/write list).
# DONE - Generate sharing URL for terminal X.
# - Send invitation to view terminal X. Connected user(s), email, and possibly other mechanisms (Jabber/Google Talk, SMS, etc)
# - Approve inbound viewer.
# DONE - Allow viewer(s) to control terminal X.
# - A completely separate chat/communications API.
# DONE - List shared terminals.
# DONE - Must integrate policy support for @require(policies('terminal'))
# * A client-side API to control sharing:
# DONE - Notify user of connected viewers.
# - Notify user of access/control grants.
# - Control playback history via server-side events (in case a viewer wants to point something out that just happened).
# * DONE - A RequestHandler to handle anonymous connections to shared terminals. Needs to serve up something specific (not index.html).
# * DONE - A mechanism to generate anonymous sharing URLs.
# * A way for users to communicate with each other (chat, audio, video).
# * DONE - A mechansim for password-protecting shared terminals.
# * Logic to detect the optimum terminal size for all viewers.
# * DONE - A data structure of some sort to keep track of shared terminals and who is currently connected to them.
# * A way to view multiple shared terminals on a single page with the option to break them out into individual windows/tabs.
@require(authenticated(), policies('terminal'))
def permissions(self, settings):
"""
Attached to the `terminal:permissions` WebSocket action; controls the
sharing permissions on a given *settings['term']*. Specifically, who
may view or write to a given terminal.
The *settings* dict **must** contain the following::
{
'term': <terminal number>,
'read': <"ANONYMOUS", "AUTHENTICATED", or a list of UPNs>
}
Optionally, the *settings* dict may also contain the following::
{
'broadcast': <True/False>, # Default: False
'password': <string>, # Default: No password
'write': <"ANONYMOUS", "AUTHENTICATED", or a list of UPNs>
# If "write" is omitted the terminal will be shared read-only
}
If *broadcast* is True, anyone will be able to connect to the shared
terminal without a password. A URL where users can access the shared
terminal will be automatically generated.
If a *password* is provided, the given password will be required before
users may connect to the shared terminal.
Example WebSocket command to share a terminal:
.. code-block:: javascript
settings = {
"term": 1,
"read": "AUTHENTICATED",
"password": "foo" // Omit if no password is required
}
GateOne.ws.send(JSON.stringify({"terminal:permissions": settings}));
.. note::
If the server is configured with `"auth": "none"` and
*settings['read']* is "AUTHENTICATED" all users will be able to view
the shared terminal without having to enter a password.
"""
self.term_log.debug("permissions(%s)" % settings)
from gateone.core.utils import random_words
share_dict = {}
term = int(settings.get('term', self.current_term))
# Share permissions get stored in the PERSIST global
if 'shared' not in self.ws.persist['terminal']:
self.ws.persist['terminal']['shared'] = {}
shared_terms = self.ws.persist['terminal']['shared']
term_obj = self.loc_terms.get(term, None)
if not term_obj:
return # Terminal does not exist (anymore)
read = settings.get('read', []) # List of who to share with
if not isinstance(read, (list, tuple)):
read = [read] # Must be a list even if only one permission
write = settings.get('write', []) # Who can write (implies read access)
if not isinstance(write, (list, tuple)):
write = [write]
password = settings.get('password', None)
# "broadcast" mode allows anonymous access without a password
broadcast_url_template = "{base_url}terminal/shared/{share_id}"
broadcast = settings.get('broadcast', False)
for share_id, val in shared_terms.items():
if val['term_obj'] == term_obj:
# Save the original read permissions for access check/revoke
orig_read = shared_terms[share_id]['read']
# Update existing permissions
shared_terms[share_id]['read'] = read
shared_terms[share_id]['write'] = write
shared_terms[share_id]['password'] = password
if broadcast == True: # Generate a new broadcast URL
broadcast = broadcast_url_template.format(
base_url=self.ws.base_url,
share_id=share_id)
shared_terms[share_id]['broadcast'] = broadcast
# Perform an access check and revoke access for existing viewers
# if they have been removed from the 'read' list
for upn in orig_read:
if upn not in shared_terms[share_id]['read']:
self.remove_viewer(term, upn)
# Check if nothing is shared anymore so we can remove it
if not read and not write and not broadcast:
self.remove_viewer(term) # Remove all viewers
del self.ws.persist['terminal']['shared'][share_id]
self.get_permissions(term)
self.notify_permissions()
return
if not read and not write and not broadcast:
return # Nothing to do
share_id = '-'.join(random_words(2))
if broadcast == True: # Generate a broadcast URL
broadcast = broadcast_url_template.format(
base_url=self.ws.base_url,
share_id=share_id)
share_dict.update({
'user': self.current_user,
'term': term,
'term_obj': term_obj,
'read': read,
'write': write, # Populated on-demand by the sharing user
'broadcast': broadcast,
'password': settings.get('password', None),
'viewers': []
})
shared_terms[share_id] = share_dict
term_obj['share_id'] = share_id # So we can quickly tell it's shared
# Make a note of this shared terminal and its permissions in the logs
self.term_log.info(
_("{upn} updated sharing permissions on terminal {term} ({title}))")
.format(
upn=self.current_user['upn'],
term=term,
title=term_obj['title']),
metadata={'permissions': settings, 'share_id': share_id})
self.trigger("terminal:permissions", settings)
# Send the client the permissions information now that it's changed
self.get_permissions(term)
self.notify_permissions()
[docs] def remove_viewer(self, term, upn=None):
"""
Disconnects all callbacks attached to the given *term* for the given
*upn* and notifies that user that the terminal is no longer shared (so
it can be shown to be disconnected at the client).
If *upn* is `None` all users (broadcast viewers included) will have the
given *term* disconnected.
"""
cls = ApplicationWebSocket
term_obj = self.loc_terms[term]
share_id = term_obj['share_id']
shared_terms = self.ws.persist['terminal']['shared']
share_obj = shared_terms[share_id]
term_app_instance = None
def disconnect(term_instance, term):
message = {'terminal:share_disconnected': {'term': term}}
#self.write_message(json_encode(message))
if user['upn'] == 'ANONYMOUS':
cls._deliver(message, session=user['session'])
else:
cls._deliver(message, upn=user['upn'])
for instance in cls.instances:
try:
user = instance.current_user
except AttributeError:
continue
if upn and user.get('upn', None) != upn:
continue
if share_obj['user'] == user:
continue # Don't need to "remove" the owner
for app in instance.apps:
if isinstance(app, TerminalApplication):
# This is that user's instance of the Terminal app
term_app_instance = app
break
for u_term_obj in list(term_app_instance.loc_terms.values()):
if term_obj == u_term_obj:
multiplex = u_term_obj['multiplex']
self.remove_terminal_callbacks(
multiplex, term_app_instance.callback_id)
del term_app_instance.loc_terms[term]
term_app_instance.clear_term_settings(term)
term_app_instance.term_ended(term)
for i, viewer in enumerate(list(share_obj['viewers'])):
if viewer['upn'] == user['upn']:
share_obj['viewers'].pop(i)
break
if upn and user.get('upn', None) == upn:
break
if not term_app_instance:
return # User is no longer viewing the terminal
[docs] def notify_permissions(self):
"""
Sends clients the list of shared terminals if they have been granted
access to any shared terminal.
.. note::
Normally this only gets called from
`~TerminalApplication.permissions` after something changed.
"""
self.term_log.debug("notify_permissions()")
cls = ApplicationWebSocket
users = cls._list_connected_users()
shared_terms = self.ws.persist['terminal']['shared']
def send_message(user):
out_dict = self._shared_terminals_dict(user=user)
message = {'terminal:shared_terminals': {'terminals': out_dict}}
if user['upn'] == 'ANONYMOUS':
cls._deliver(message, session=user['session'])
else:
cls._deliver(message, upn=user['upn'])
for user in users:
upn = user.get('upn', None)
if not upn:
continue
for share_id, share_dict in shared_terms.items():
try:
if share_dict['user'] == user: # Owner
send_message(user)
break
if 'AUTHENTICATED' in share_dict['read']:
send_message(user)
break
if upn in share_dict['read']:
send_message(user)
break
except AttributeError:
pass # User disconnected in the middle of this operation
@require(authenticated(), policies('terminal'))
def new_share_id(self, settings):
"""
Generates a new pair of words to act as the share/broadcast ID for a
given *settings['term']*. If a 'term' is not provided the currently
selected terminal will be used.
Optionally, *settings['share_id'] may be provied to explicitly set it to
the given value.
.. note:: The terminal must already be shared with broadcast enabled.
"""
from gateone.core.utils import random_words
if 'term' not in settings:
return # Invalid
if 'shared' not in self.ws.persist['terminal']:
return # Nothing to do
term = int(settings.get('term', self.current_term))
random_share_id = '-'.join(random_words(2))
new_share_id = settings.get('share_id', random_share_id)
shared_terms = self.ws.persist['terminal']['shared']
term_obj = self.loc_terms[term]
broadcast_url_template = "{base_url}terminal/shared/{share_id}"
old_share_id = None
if new_share_id in shared_terms: # Already exists
self.write_message(
_("Share ID '%s' is already in use") % new_share_id)
return
for share_id, val in list(shared_terms.items()):
if val['term_obj'] == term_obj:
old_share_id = share_id
broadcast = broadcast_url_template.format(
base_url=self.ws.base_url,
share_id=new_share_id)
shared_terms[new_share_id] = shared_terms[share_id]
shared_terms[new_share_id]['broadcast'] = broadcast
del shared_terms[share_id]
self.get_permissions(term)
self.term_log.info(
_("{upn} changed share ID of terminal {term} from '{old}'' to "
"'{new}'").format(
upn=self.current_user['upn'],
term=term,
old=old_share_id,
new=new_share_id))
@require(authenticated(), policies('terminal'))
def get_permissions(self, term):
"""
Sends the client an object representing the permissions of the given
*term*. Example JavaScript:
.. code-block:: javascript
GateOne.ws.send(JSON.stringify({
"terminal:get_permissions": 1
}));
"""
if 'shared' not in self.ws.persist['terminal']:
error_msg = _("Error: Invalid share ID.")
self.ws.send_message(error_msg)
return
out_dict = {'result': 'Success'}
term_obj = self.loc_terms.get(term, None)
if not term_obj:
return # Term doesn't exist
shared_terms = self.ws.persist['terminal']['shared']
for share_id, share_dict in shared_terms.items():
if share_dict['term_obj'] == term_obj:
out_dict['write'] = share_dict['write']
out_dict['read'] = share_dict['read']
out_dict['share_id'] = share_id
break
message = {'terminal:sharing_permissions': out_dict}
self.write_message(json_encode(message))
self.trigger("terminal:get_sharing_permissions", term)
@require(authenticated(), policies('terminal'))
def share_user_list(self, share_id):
"""
Sends the client a dict of users that are currently viewing the terminal
associated with *share_id* using the 'terminal:share_user_list'
WebSocket action. The output will indicate which users have write
access. Example JavaScript:
.. code-block:: javascript
var shareID = "notification-chicken";
GateOne.ws.send(JSON.stringify({
"terminal:share_user_list": shareID
}));
"""
out_dict = {'viewers': [], 'write': []}
message = {'terminal:share_user_list': out_dict}
try:
share_obj = self.ws.persist['terminal']['shared'][share_id]
except KeyError:
error_msg = _("No terminal associated with the given share_id.")
message = {'go:notice': error_msg}
self.write_message(message)
return
if 'viewers' in share_obj:
for user in share_obj['viewers']:
# Only let the client know about the UPN and IP Address
user_dict = {
'upn': user['upn'],
'ip_address': user['ip_address']
}
if 'email' in user:
user_dict.update({'email': user['email']})
out_dict['viewers'].append(user_dict)
if isinstance(share_obj['write'], list):
for allowed in share_obj['write']:
out_dict['write'].append(allowed)
else:
out_dict['write'] = share_obj['write']
self.write_message(message)
self.trigger("terminal:share_user_list", share_id)
[docs] def _shared_terminals_dict(self, user=None):
"""
Returns a dict containing information about all shared terminals that
the given *user* has access to. If no *user* is given
`self.current_user` will be used.
"""
out_dict = {}
if not user:
user = self.current_user
shared_terms = self.ws.persist['terminal'].get('shared', {})
for share_id, share_dict in shared_terms.items():
owner = False
auth_or_anon = False
explicit_user = False
for read_perm in share_dict['read']:
if read_perm in ['AUTHENTICATED', 'ANONYMOUS']:
auth_or_anon = True
if user['upn'] in share_dict['read']:
explicit_user = True
if share_dict['user']['upn'] == user['upn']:
owner = True
if owner or auth_or_anon or explicit_user:
password = share_dict.get('password', False)
if password == None:
password = False # Looks better at the client this way
elif password and not owner: # This would be a string
password = True # Don't want to reveal it to the client!
broadcast = share_dict.get('broadcast', False)
out_dict[share_id] = {
'owner': share_dict['user']['upn'],
'term': share_dict['term'], # Only useful for the owner
'title': share_dict['term_obj']['title'],
'read': share_dict['read'],
'write': share_dict['write'],
'viewers': share_dict['viewers'],
'password_protected': password,
'broadcast': broadcast
}
return out_dict
@require(authenticated(), policies('terminal'))
def list_shared_terminals(self):
"""
Returns a message to the client listing all the shared terminals they
may access. Example JavaScript:
.. code-block:: javascript
GateOne.ws.send(JSON.stringify({
"terminal:list_shared_terminals": null
}));
The client will be sent the list of shared terminals via the
`terminal:shared_terminals` WebSocket action.
"""
out_dict = self._shared_terminals_dict()
message = {'terminal:shared_terminals': {'terminals': out_dict}}
self.write_message(json_encode(message))
self.trigger("terminal:list_shared_terminals")
# NOTE: This doesn't require authenticated() so anonymous sharing can work
@require(policies('terminal'))
def attach_shared_terminal(self, settings):
"""
Attaches callbacks for the terminals associated with
*settings['share_id']* if the user is authorized to view the share or if
the given *settings['password']* is correct (if shared anonymously).
To attach to a shared terminal from the client:
.. code-block:: javascript
settings = {
"share_id": "ZWVjNGRiZTA0OTllNDJiODkwOGZjNDA2ZWNkNGU4Y2UwM",
"password": "password here",
"metadata": {"optional metadata": "would go here"}
}
GateOne.ws.send(JSON.stringify({
"terminal:attach_shared_terminal": settings
}));
.. note::
Providing a password is only necessary if the shared terminal
requires it.
"""
cls = ApplicationWebSocket
self.term_log.debug("attach_shared_terminal(%s)" % settings)
if 'share_id' not in settings:
self.term_log.error(_("Invalid share_id."))
return
shared_terms = self.ws.persist['terminal'].get('shared', {})
password = settings.get('password', None)
share_obj = None
for share_id, share_dict in shared_terms.items():
if share_id == settings['share_id']:
share_obj = share_dict
break # This is the share_dict we want
if not share_obj:
self.ws.send_message(_("Requested shared terminal does not exist."))
return
if not share_obj['broadcast']:
if 'AUTHENTICATED' not in share_obj['read']:
if self.current_user['upn'] not in share_obj['read']:
self.ws.send_message(_(
"You are not authorized to view this terminal"))
return
if share_obj['password'] and password != share_obj['password']:
self.ws.send_message(_("Invalid password."))
return
term = self.highest_term_num() + 1
term_obj = share_obj['term_obj']
# Add this terminal to our existing SESSION
self.loc_terms[term] = term_obj
# We're basically making a new terminal for this client that happens to
# have been started by someone else.
multiplex = term_obj['multiplex']
if self.ws.client_id not in term_obj:
term_obj[self.ws.client_id] = {
# Used by refresh_screen()
'refresh_timeout': None
}
if multiplex.isalive():
message = {
'terminal:term_exists': {
'term': term, 'share_id': settings['share_id']
}
}
self.write_message(json_encode(message))
# This resets the screen diff
multiplex.prev_output[self.ws.client_id] = [
None for a in range(multiplex.rows-1)]
# Setup callbacks so that everything gets called when it should
self.add_terminal_callbacks(
term, term_obj['multiplex'], self.callback_id)
# NOTE: refresh_screen will also take care of cleaning things up if
# term_obj['multiplex'].isalive() is False
self.refresh_screen(term, True) # Send a fresh screen to the client
self.current_term = term
# Restore expanded modes (at the client)
for mode, setting in multiplex.term.expanded_modes.items():
self.mode_handler(term, mode, setting)
# Tell the client about this terminal's title
self.set_title(term, force=True, save=False)
# TODO: Get this performing lookups in an attribute repository
metadata = settings.get('metadata', {})
if not metadata:
metadata = {} # In case it's null/None
email = metadata.get('email', None)
upn = metadata.get('upn', email)
broadcast_viewer = True
if self.current_user:
upn = self.current_user['upn']
broadcast_viewer = False
# Add this user to the list of viewers
current_viewer = self.current_user
if not current_viewer: # Anonymous broadcast viewer
current_viewer = {
'upn': upn,
'email': email,
'ip_address': self.ws.request.remote_ip,
'broadcast': True,
'client_id': self.ws.client_id
}
viewer_dict = { # Limit it so we don't give away sensitive info
'upn': current_viewer['upn'],
'email': current_viewer.get('email', email),
'ip_address': current_viewer['ip_address'],
'broadcast': current_viewer.get('broadcast', False),
'client_id': self.ws.client_id
}
if 'viewers' not in share_obj:
share_obj['viewers'] = [viewer_dict]
else:
share_obj['viewers'].append(viewer_dict)
# Make a note of this connection in the logs
self.term_log.info(
_("{upn} connected to terminal shared by {owner}").format(
upn=upn,
owner=share_obj['user']['upn']),
metadata=current_viewer)
out_dict = self._shared_terminals_dict(user=share_obj['user'])
message = {'terminal:shared_terminals': {'terminals': out_dict}}
self.write_message(json_encode(message))
# Notify the owner of the terminal that this user is now viewing:
notice = _("%s (%s) is now viewing terminal %s" % (
current_viewer['upn'],
current_viewer['ip_address'],
share_obj['term']))
if upn == 'ANONYMOUS':
self.ws.send_message(notice, session=share_obj['user']['session'])
# Also send them an updated shared_terminals list:
cls._deliver(message, session=share_obj['user']['session'])
else:
self.ws.send_message(notice, upn=share_obj['user']['upn'])
cls._deliver(message, upn=share_obj['user']['upn'])
def remove_callbacks():
try:
self.remove_terminal_callbacks(multiplex, self.callback_id)
except KeyError:
pass # Already removed callbacks--no biggie
if broadcast_viewer:
detach = partial(self.detach_shared_terminal, {'term': term})
self.on('terminal:on_close', detach)
else: # This lets regular users resume
self.on('terminal:on_close', remove_callbacks)
self.trigger("terminal:attach_shared_terminal", term)
@require(policies('terminal'))
def detach_shared_terminal(self, settings):
"""
Stops watching the terminal specified via *settings['term']*.
"""
self.term_log.debug("detach_shared_terminal(%s)" % settings)
term = settings.get('term', None)
if not term:
return # bad settings
term = int(term)
if term not in self.loc_terms:
return # Already detached
term_obj = self.loc_terms[term]
multiplex = term_obj['multiplex']
shared_terms = self.ws.persist['terminal'].get('shared', {})
if not shared_terms:
return # Nothing to do
share_obj = []
for share_id, share_dict in shared_terms.items():
if term_obj == share_dict['term_obj']:
share_obj = share_dict
break # This is the share dict we want
# Remove ourselves from the list of viewers for this terminal
for viewer in list(share_dict['viewers']):
if viewer['client_id'] == self.ws.client_id:
share_obj['viewers'].remove(viewer)
try:
self.remove_terminal_callbacks(multiplex, self.callback_id)
del self.loc_terms[term]
if self.ws.session:
self.clear_term_settings(term)
except KeyError:
pass # Already removed callbacks--no biggie
finally:
self.notify_permissions()
[docs] def render_256_colors(self):
"""
Renders the CSS for 256 color support and saves the result as
'256_colors.css' in Gate One's configured `cache_dir`. If that file
already exists and has not been modified since the last time it was
generated rendering will be skipped.
Returns the path to that file as a string.
"""
# NOTE: Why generate this every time? Presumably these colors can be
# changed on-the-fly by terminal programs. That functionality
# has yet to be implemented but this function will enable use to
# eventually do that.
# Use the get_settings() function to import our 256 colors (convenient)
cache_dir = self.ws.settings['cache_dir']
cached_256_colors = os.path.join(cache_dir, '256_colors.css')
if os.path.exists(cached_256_colors):
return cached_256_colors
colors_json_path = resource_filename(
'gateone.applications.terminal', '/static/256colors.json')
color_map = get_settings(colors_json_path, add_default=False)
# Setup our 256-color support CSS:
colors_256 = ""
for i in xrange(256):
i = str(i)
fg = "#%s span.✈fx%s {color: #%s;}" % (
self.ws.container, i, color_map[i])
bg = "#%s span.✈bx%s {background-color: #%s;} " % (
self.ws.container, i, color_map[i])
fg_rev =(
"#%s span.✈reverse.✈fx%s {background-color: #%s; color: "
"inherit;}" % (self.ws.container, i, color_map[i]))
bg_rev =(
"#%s span.✈reverse.✈bx%s {color: #%s; background-color: "
"inherit;} " % (self.ws.container, i, color_map[i]))
colors_256 += "%s %s %s %s\n" % (fg, bg, fg_rev, bg_rev)
with io.open(cached_256_colors, 'w', encoding="utf-8") as f:
f.write(colors_256)
# send_css() will take care of minifiying and caching further
return cached_256_colors
[docs] def send_256_colors(self):
"""
Sends the client the CSS to handle 256 color support.
"""
self.ws.send_css(self.render_256_colors())
[docs] def send_print_stylesheet(self):
"""
Sends the 'templates/printing/printing.css' stylesheet to the client
using `ApplicationWebSocket.ws.send_css` with the "media" set to
"print".
"""
print_css_path = resource_filename(
'gateone.applications.terminal', '/templates/printing/printing.css')
self.render_and_send_css(
print_css_path, element_id="terminal_print_css", media="print")
@require(authenticated())
def debug_terminal(self, term):
"""
Prints the terminal's screen and renditions to stdout so they can be
examined more closely.
.. note:: Can only be called from a JavaScript console like so...
.. code-block:: javascript
GateOne.ws.send(JSON.stringify({'terminal:debug_terminal': *term*}));
"""
m = self.loc_terms[term]['multiplex']
term_obj = m.term
screen = term_obj.screen
renditions = term_obj.renditions
for i, line in enumerate(screen):
# This gets rid of images:
line = [a for a in line if len(a) == 1]
logging.info("%s:%s" % (i, "".join(line)))
logging.info(renditions[i])
try:
from pympler import asizeof
logging.info("screen size: %s" % asizeof.asizeof(screen))
logging.info("renditions size: %s" % asizeof.asizeof(renditions))
logging.info(
"Total term object size: %s" % asizeof.asizeof(term_obj))
except ImportError:
pass # No biggie
self.ws.debug() # Do regular debugging as well
[docs]def apply_cli_overrides(settings):
"""
Updates *settings* in-place with values given on the command line and
updates the `options` global with the values from *settings* if not provided
on the command line.
"""
# Figure out which options are being overridden on the command line
arguments = []
terminal_options = ('dtach', 'syslog_session_logging', 'session_logging')
for arg in list(sys.argv)[1:]:
if not arg.startswith('-'):
break
else:
arguments.append(arg.lstrip('-').split('=', 1)[0])
for argument in arguments:
if argument not in terminal_options:
continue
if argument in options:
settings[argument] = options[argument]
for key, value in settings.items():
if key in options:
if str == bytes: # Python 2
if isinstance(value, unicode):
# For whatever reason Tornado doesn't like unicode values
# for its own settings unless you're using Python 3...
value = str(value)
setattr(options, key, value)
[docs]def init(settings):
"""
Checks to make sure 50terminal.conf is created if terminal-specific settings
are not found in the settings directory.
Also checks to make sure that the logviewer.py script is executable.
"""
term_log = go_logger("gateone.terminal")
logviewer_path = resource_filename(
'gateone.applications.terminal', '/logviewer.py')
import stat
st = os.stat(logviewer_path)
if not bool(st.st_mode & stat.S_IXOTH):
try:
os.chmod(logviewer_path, 0o755)
except OSError:
# We don't have permission to change it. Not a big deal.
pass
terminal_options = ( # These are now terminal-app-specific setttings
'command', 'dtach', 'session_logging', 'syslog_session_logging'
)
if os.path.exists(options.config):
# Get the old settings from the old config file and use them to generate
# a new 50terminal.conf
if 'terminal' not in settings['*']:
settings['*']['terminal'] = {}
with io.open(options.config, encoding='utf-8') as f:
for line in f:
if line.startswith('#'):
continue
key = line.split('=', 1)[0].strip()
value = eval(line.split('=', 1)[1].strip())
if key not in terminal_options:
continue
if key == 'command':
# Fix the path to ssh_connect.py if present
if 'ssh_connect.py' in value:
value = value.replace(
'/plugins/', '/applications/terminal/plugins/')
# Also fix the path to the known_hosts file
if '/ssh/known_hosts' in value:
value = value.replace(
'/ssh/known_hosts', '/.ssh/known_hosts')
key = 'commands' # Convert to new name
value = {'SSH': value}
settings['*']['terminal'].update({key: value})
required_settings = ('commands', 'default_command', 'session_logging')
term_settings = settings['*'].get('terminal', {})
generate_terminal_config = False
for setting in required_settings:
if setting not in term_settings:
generate_terminal_config = True
if not term_settings or generate_terminal_config:
# Create some defaults and save the config as 50terminal.conf
settings_path = options.settings_dir
terminal_conf_path = os.path.join(settings_path, '50terminal.conf')
if not os.path.exists(terminal_conf_path):
from gateone.core.configuration import settings_template
template_path = resource_filename(
'gateone.applications.terminal',
'/templates/settings/50terminal.conf')
settings['*']['terminal'] = {}
# Update the settings with defaults
ssh_connect_path = resource_filename(
'gateone.applications.terminal',
'/plugins/ssh/scripts/ssh_connect.py')
default_command = (
"{0} -S "
r"'%SESSION_DIR%/%SESSION%/%SHORT_SOCKET%' --sshfp "
r"-a '-oUserKnownHostsFile=\"%USERDIR%/%USER%/.ssh/known_hosts\"'"
).format(ssh_connect_path)
settings['*']['terminal'].update({
'dtach': True,
'session_logging': True,
'syslog_session_logging': False,
'commands': {
'SSH': {
"command": default_command,
"description": "Connect to hosts via SSH."
}
},
'default_command': 'SSH',
'environment_vars': {
'TERM': 'xterm-256color'
},
'enabled_filetypes': 'all'
})
new_term_settings = settings_template(
template_path, settings=settings['*']['terminal'])
with io.open(terminal_conf_path, 'w', encoding='utf-8') as s:
s.write(_(
"// This is Gate One's Terminal application settings "
"file.\n"))
s.write(new_term_settings)
term_settings = settings['*']['terminal']
if options.kill:
from gateone.core.utils import killall
go_settings = settings['*']['gateone']
# Kill all running dtach sessions (associated with Gate One anyway)
killall(go_settings['session_dir'], go_settings['pid_file'])
# Cleanup the session_dir (it is supposed to only contain temp stuff)
import shutil
shutil.rmtree(go_settings['session_dir'], ignore_errors=True)
sys.exit(0)
if not which('dtach'):
term_log.warning(
_("dtach command not found. dtach support has been disabled."))
apply_cli_overrides(term_settings)
# Fix the path to known_hosts if using the old default command
for name, command in term_settings['commands'].items():
if '\"%USERDIR%/%USER%/ssh/known_hosts\"' in command:
term_log.warning(_(
"The default path to known_hosts has been changed. Please "
"update your settings to use '/.ssh/known_hosts' instead of "
"'/ssh/known_hosts'. Applying a termporary fix..."))
term_settings['commands'][name] = command.replace('/ssh/', '/.ssh/')
# Initialize plugins so we can add their 'Web' handlers
enabled_plugins = settings['*']['terminal'].get('enabled_plugins', [])
plugins = entry_point_files('go_terminal_plugins', enabled_plugins)
# Attach plugin hooks
plugin_hooks = {}
for name, plugin in plugins['py'].items():
try:
plugin_hooks.update({plugin.__name__: plugin.hooks})
except AttributeError:
pass # No hooks, no problem
# Add static handlers for all the JS plugins (primarily for source maps)
url_prefix = settings['*']['gateone']['url_prefix']
plugins = set(
plugins['py'].keys() + plugins['js'].keys() + plugins['css'].keys())
for plugin in plugins:
name = plugin.split('.')[-1]
plugin_static_url = r"{prefix}terminal/{name}/static/(.*)".format(
prefix=url_prefix, name=name)
handler = (plugin_static_url, StaticHandler, {
"path": '/static/',
'use_pkg': plugin
})
if handler not in REGISTERED_HANDLERS:
REGISTERED_HANDLERS.append(handler)
web_handlers.append(handler)
# Hook up the 'Web' handlers so those URLs are immediately available
for hooks in plugin_hooks.values():
if 'Web' in hooks:
for handler in hooks['Web']:
if not handler[0].startswith(url_prefix): # Fix it
handler = (url_prefix + handler[0].lstrip('/'), handler[1])
if handler in REGISTERED_HANDLERS:
continue # Already registered this one
else:
REGISTERED_HANDLERS.append(handler)
web_handlers.append(handler)
# Tell Gate One which classes are applications
apps = [TerminalApplication]
# Tell Gate One about our terminal-specific static file handler
web_handlers.append((
r'terminal/static/(.*)',
TermStaticFiles,
{"path": resource_filename('gateone.applications.terminal', '/static')}
))
web_handlers.append((r'terminal/shared/(.*)', SharedTermHandler))
# Command line argument commands
commands = {
'termlog': {
'function': logviewer_main,
'description': _("View terminal session logs.")
}
}