# -*- coding: utf-8 -*-
#
# Copyright 2013 Liftoff Software Corporation
#
__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
from datetime import datetime, timedelta
from functools import partial
# Gate One imports
from gateone import GATEONE_DIR, SESSIONS
from gateone.core.server import StaticHandler, BaseHandler, GOApplication
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
from gateone.core.utils import mkdir_p, get_plugins
from gateone.core.utils import process_opt_esc_sequence, bind, MimeTypeFail
from gateone.core.utils import which
from gateone.core.utils import short_hash, load_modules, create_data_uri
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 .logviewer import main as logviewer_main
# 3rd party imports
from tornado.escape import json_decode
from tornado.options import options, define
# Globals
APPLICATION_PATH = os.path.split(__file__)[0] # Path to our application
REGISTERED_HANDLERS = [] # So we don't accidentally re-add handlers
web_handlers = [] # Assigned in init()
term_log = go_logger("gateone.terminal")
# 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.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
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)
[docs]def policy_new_terminal(cls, policy):
"""
Called by :func:`terminal_policies`, returns True if the user is
authorized to execute :func:`new_terminal` and applies any configured
restrictions (e.g. max_dimensions). Specifically, checks to make sure the
user is not in violation of their applicable policies (e.g. max_terms).
"""
instance = cls.instance
session = instance.ws.session
try:
term = cls.f_args[0]['term']
except (KeyError, IndexError):
# new_terminal got bad *settings*. Deny
return False
user = instance.current_user
open_terminals = 0
locations = SESSIONS[session]['locations']
if term in instance.loc_terms:
# Terminal already exists (reattaching) or was shared by someone else
return True
for loc in locations.values():
for t, term_obj in loc['terminal'].items():
if t in instance.loc_terms:
if user == term_obj['user']:
# Terms shared by others don't count
if user['upn'] == 'ANONYMOUS':
# ANONYMOUS users are all the same so we have to use
# the session ID
if session == term_obj['user']['session']:
open_terminals += 1
else:
open_terminals += 1
# Start by determining the limits
max_terms = 0 # No limit
if 'max_terms' in policy:
max_terms = policy['max_terms']
max_cols = 0
max_rows = 0
if 'max_dimensions' in policy:
max_cols = policy['max_dimensions']['columns']
max_rows = policy['max_dimensions']['rows']
if max_terms:
if open_terminals >= max_terms:
term_log.error(_(
"%s denied opening new terminal. The 'max_terms' policy limit "
"(%s) has been reached for this user." % (
user['upn'], max_terms)))
# Let the client know this term is no more (after a timeout so the
# can complete its newTerminal stuff beforehand).
term_ended = partial(instance.term_ended, term)
instance.add_timeout("500", term_ended)
cls.error = _(
"Server policy dictates that you may only open %s terminal(s) "
% max_terms)
return False
if max_cols:
if int(cls.f_args['columns']) > max_cols:
cls.f_args['columns'] = max_cols # Reduce to max size
if max_rows:
if int(cls.f_args['rows']) > max_rows:
cls.f_args['rows'] = max_rows # Reduce to max size
return True
[docs]def policy_share_terminal(cls, policy):
"""
Called by :func:`terminal_policies`, returns True if the user is
authorized to execute :func:`share_terminal`.
"""
try:
cls.f_args[0]['term']
except (KeyError, IndexError):
# share_terminal got bad *settings*. Deny
return False
can_share = policy.get('share_terminals', True)
if not can_share:
return False
return True
[docs]def policy_char_handler(cls, policy):
"""
Called by :func:`terminal_policies`, returns True if the user is
authorized to write to the current (or specified) terminal.
"""
error_msg = _("You do not have permission to write to this terminal.")
cls.error = error_msg
instance = cls.instance
try:
term = cls.f_args[1]
except IndexError:
# char_handler didn't get 'term' as a non-keyword argument. Try kword:
try:
term = cls.f_kwargs['term']
except KeyError:
# No 'term' was given at all. Use current_term
term = instance.current_term
# Make sure the term is an int
term = int(term)
if term not in instance.loc_terms:
return True # Terminal was probably just closed
term_obj = instance.loc_terms[term]
user = instance.current_user
if user['upn'] == term_obj['user']['upn']:
# UPN match... Double-check ANONYMOUS
if user['upn'] == 'ANONYMOUS':
# All users will be ANONYMOUS so we need to check their session ID
if user['session'] == term_obj['user']['session']:
return True
# TODO: Think about adding an administrative lock feature here
else:
return True # Users can always write to their own terminals
if 'share_id' in term_obj:
# This is a shared terminal. Check if the user is in the 'write' list
shared = instance.ws.persist['terminal']['shared']
share_obj = shared[term_obj['share_id']]
if user['upn'] in share_obj['write']:
return True
elif share_obj['write'] in ['AUTHENTICATED', 'ANONYMOUS']:
return True
elif isinstance(share_obj['write'], list):
# Iterate and check each item
for allowed in share_obj['write']:
if allowed == user['upn']:
return True
elif allowed in ['AUTHENTICATED', 'ANONYMOUS']:
return True
# TODO: Handle regexes and lists of regexes here
return False
[docs]def terminal_policies(cls):
"""
This function gets registered under 'terminal' in the
:attr:`ApplicationWebSocket.security` dict and is called by the
:func:`require` decorator by way of the :class:`policies` sub-function. It
returns True or False depending on what is defined in the settings dir and
what function is being called.
This function will keep track of and place limmits on the following:
* The number of open terminals.
* How big each terminal may be.
* Who may view or write to a shared terminal.
If no 'terminal' policies are defined this function will always return True.
"""
instance = cls.instance # TerminalApplication instance
function = cls.function # Wrapped function
#f_args = cls.f_args # Wrapped function's arguments
#f_kwargs = cls.f_kwargs # Wrapped function's keyword arguments
policy_functions = {
'new_terminal': policy_new_terminal,
'share_terminal': policy_share_terminal,
'char_handler': policy_char_handler
}
user = instance.current_user
policy = applicable_policies('terminal', user, instance.ws.prefs)
if not policy: # Empty RUDict
return True # A world without limits!
# TODO: Move the "allow" logic into gateone.py or auth.py
# Start by determining if the user can even login to the terminal app
if 'allow' in policy:
if not policy['allow']:
term_log.error(_(
"%s denied access to the Terminal application by policy."
% user['upn']))
return False
if function.__name__ in policy_functions:
return policy_functions[function.__name__](cls, policy)
return True # Default to permissive if we made it this far
# 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):
hostname = os.uname()[1]
prefs = self.get_argument("prefs", None)
share_id = self.get_argument("share_id")
gateone_js = "%sstatic/gateone.js" % self.settings['url_prefix']
minified_js_abspath = os.path.join(GATEONE_DIR, 'static')
minified_js_abspath = os.path.join(
minified_js_abspath, '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']
template_path = os.path.join(APPLICATION_PATH, 'templates')
index_path = os.path.join(template_path, 'shared.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",
'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):
term_log.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
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.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:share_terminal': self.share_terminal,
'terminal:share_user_list': self.share_user_list,
'terminal:unshare_terminal': self.unshare_terminal,
'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:set_sharing_permissions': self.set_sharing_permissions,
'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 = get_plugins(
os.path.join(APPLICATION_PATH, 'plugins'), enabled_plugins)
py_plugins = []
for module_path in self.plugins['py']:
name = module_path.split('.')[0]
py_plugins.append(name)
js_plugins = []
for js_path in self.plugins['js']:
name = js_path.split(os.path.sep)[0]
js_plugins.append(name)
css_plugins = []
for css_path in css_plugins:
name = css_path.split(os.path.sep)[-2]
css_plugins.append(name)
plugin_list = list(set(py_plugins + js_plugins + css_plugins))
plugin_list.sort() # So there's consistent ordering
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
imported = load_modules(self.plugins['py'])
for plugin in imported:
try:
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:"))
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.
"""
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 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.
"""
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
# Render and send the client our terminal.css
templates_path = os.path.join(APPLICATION_PATH, 'templates')
terminal_css = os.path.join(templates_path, 'terminal.css')
self.render_and_send_css(terminal_css, element_id="terminal.css")
# Send the client our JavaScript files
static_dir = os.path.join(APPLICATION_PATH, 'static')
js_files = os.listdir(static_dir)
js_files.sort()
for fname in js_files:
if fname.endswith('.js'):
js_file_path = os.path.join(static_dir, 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(
os.path.join(APPLICATION_PATH, 'plugins'),
application="terminal",
requires=["terminal_input.js"])
# Send the client the 256-color style information and our printing CSS
self.send_256_colors()
self.send_print_stylesheet()
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 = os.path.join(templates_path, 'command_icon.svg')
with io.open(icon_path, encoding='utf-8') as f:
sub_app['icon'] = f.read().format(cmd=sub_app['name'])
sub_apps.append(sub_app)
self.info['sub_applications'] = sub_apps
self.info['sub_applications'].sort()
# 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.trigger("terminal:authenticate")
def on_close(self):
# 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
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_path = os.path.join(APPLICATION_PATH, 'static', 'fonts')
fonts = os.listdir(fonts_path)
font_list = []
for font in fonts:
if not font.endswith('.woff'):
continue
font_path = os.path.join(fonts_path, 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(authenticated(), 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%')
templates_path = templates_path = os.path.join(
APPLICATION_PATH, 'templates')
filename = 'font.css'
font_css_path = os.path.join(templates_path, 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_path = os.path.join(APPLICATION_PATH, 'static', 'fonts')
fonts = os.listdir(fonts_path)
woffs = {}
for font in fonts:
if not font.endswith('.woff'):
continue
font_path = os.path.join(fonts_path, 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_path = os.path.join(APPLICATION_PATH, 'templates', 'term_colors')
colors = os.listdir(colors_path)
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:
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'],
'title': self.loc_terms[term]['title']
}})
# 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', {})
title = data.get('title', 'Gate One')
terminals.update({term: {
'metadata': metadata,
'title': title
}})
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
policies = applicable_policies(
'terminal', self.current_user, self.ws.prefs)
shell_command = policies.get('shell_command', None)
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['ip_address'])
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)
additional_log_metadata = {
'ip_address': self.current_user.get('ip_address', "")
}
# 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)
additional_log_metadata.update(metadata)
m = termio.Multiplex(
cmd,
log_path=log_path,
user=user,
term_id=term_id,
debug=debug,
syslog=syslog_logging,
syslog_facility=facility,
syslog_host=self.settings['syslog_host'],
additional_metadata=additional_log_metadata,
encoding=encoding
)
if shell_command:
m.shell_command = shell_command
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 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['term'])
# TODO: Make these specific to each terminal:
rows = settings['rows']
cols = settings['columns']
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
# 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
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'):
# 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 = os.path.join(GATEONE_DIR, '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(u'\x0c')
else:
# Terminal already exists
m = term_obj['multiplex']
if m.isalive():
# It's ALIVE!!!
m.resize(
rows, cols, ctrl_l=False, em_dimensions=self.em_dimensions)
message = {'terminal:term_exists': 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, {'metadata': self.loc_terms[term]['metadata']})
@require(authenticated())
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())
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())
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.loc_terms.update({term1: term2_dict})
self.loc_terms.update({term2: term1_dict})
self.trigger("terminal:swap_terminals", term1, term2)
@require(authenticated())
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())
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)
@require(authenticated())
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())
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 = u'\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(u'\x0c') # ctrl-l
self.full_refresh(term)
self.trigger("terminal:reset_terminal", term)
@require(authenticated())
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())
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)
@require(authenticated())
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)
@require(authenticated())
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)
@require(authenticated())
def refresh_screen(self, term, full=False):
"""
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).
"""
# 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)
@require(authenticated())
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 u'\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)
@require(authenticated())
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 = os.path.join(APPLICATION_PATH, 'static')
bell_path = os.path.join(bell_path, '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)
fallback_path = os.path.join(
APPLICATION_PATH, 'static', 'fallback_bell.txt')
with io.open(fallback_path, encoding='utf-8') as f:
bell_data_uri = f.read()
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).
"""
static_url = os.path.join(APPLICATION_PATH, "static")
webworker_path = os.path.join(static_url, 'webworkers', 'term_ww.js')
with io.open(webworker_path, encoding='utf-8') as f:
go_process = f.read()
message = {'terminal:load_webworker': go_process}
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
templates_path = os.path.join(APPLICATION_PATH, 'templates')
term_colors_path = os.path.join(templates_path, 'term_colors')
colors_filename = "%s.css" % settings["colors"]
colors_path = os.path.join(term_colors_path, 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(authenticated(), 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:
# - Share this terminal:
# > Allow anyone with the right URL to view (requires authorization-on-connect).
# > Allow only authenticated users.
# > 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.
# * A server-side API to control sharing:
# DONE (mostly) - 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).
# - 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:
# - 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).
# * A RequestHandler to handle anonymous connections to shared terminals. Needs to serve up something specific (not index.html)
# * A mechanism to generate anonymous sharing URLs.
# * A way for users to communicate with each other (chat, audio, video).
# * A mechansim for password-protecting shared terminals.
# * Logic to detect the optimum terminal size for all viewers.
# * 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 share_terminal(self, settings):
"""
Shares the given *settings['term']* using the given *settings*. The
*settings* dict **must** contain the following::
{
'term': <terminal number>,
'read': <"ANONYMOUS", "AUTHENTICATED", a user.attr regex like "user.email=.*@liftoffsoftware.com" or a list thereof>,
}
Optionally, the *settings* dict may also contain the following::
{
'broadcast': <True/False>,
'password': <string>,
'write': <"ANONYMOUS", "AUTHENTICATED", a user.attr regex like "user.email=.*@liftoffsoftware.com", or a list thereof>
# If "write" is omitted the terminal will be shared read-only until write access is granted (on demand)
}
If *broadcast* is True, anyone will be able to connect to the shared
terminal without a password.
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:share_terminal": 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("share_terminal(%s)" % settings)
from gateone.core.utils import generate_session_id
out_dict = {'result': 'Success'}
share_dict = {}
term = settings.get('term', self.current_term)
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[term]
read = settings.get('read', 'AUTHENTICATED') # List of who to share with
if not isinstance(read, (list, tuple)):
read = [read]
write = settings.get('write', []) # Who can write (implies read access)
if not isinstance(write, (list, tuple)):
write = [write]
# "broadcast" mode allows anonymous access without a password
#broadcast = settings.get('broadcast', False)
# ANONYMOUS (auto-gen URL), user.attr=(regex), and "AUTHENTICATED"
out_dict.update({
'term': term,
'read': read,
'write': write,
#'broadcast': broadcast
})
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', False)
#if read == 'ANONYMOUS':
#if not broadcast:
## This situation *requires* a password
#password = settings.get('password', generate_session_id()[:8])
out_dict['password'] = password
share_dict['password'] = password
url_prefix = self.ws.settings['url_prefix']
for share_id, val in shared_terms.items():
if val['term_obj'] == term_obj:
if share_dict['read'] != shared_terms[share_id]['read']:
# User is merely changing the permissions
shared_terms[share_id]['read'] = share_dict['read']
return
if share_dict['write'] != shared_terms[share_id]['write']:
# User is merely changing the permissions
shared_terms[share_id]['write'] = share_dict['write']
return
self.ws.send_message(_("This terminal is already shared."))
return
share_id = generate_session_id()
url = "%sterminal/shared/%s" % (url_prefix, share_id)
share_dict['url'] = url
out_dict['url'] = url
out_dict['share_id'] = share_id
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 in the logs
self.term_log.info(_(
"%s shared terminal %s (%s)" % (
self.current_user['upn'], term, term_obj['title'])))
message = {'terminal:term_shared': out_dict}
self.write_message(json_encode(message))
self.trigger("terminal:share_terminal", settings)
@require(authenticated(), policies('terminal'))
def unshare_terminal(self, term):
"""
Stops sharing the given *term*. Example JavaScript:
.. code-block:: javascript
GateOne.ws.send(JSON.stringify({"terminal:unshare_terminal": 1}));
"""
out_dict = {'result': 'Success'}
term_obj = self.loc_terms[term]
shared_terms = self.ws.persist['terminal']['shared']
message = {'terminal:unshared_terminal': out_dict}
self.write_message(json_encode(message))
# TODO: Write logic here that kills the terminal of each viewer and sends them a message indicating that the sharing has ended.
message = {'terminal:term_ended': term}
# TODO: Per the above TODO, this needs to be changed to notify each user (connected to the terminal):
self.ws.send_message(message, upn=self.current_user['upn'])
for share_id, share_dict in shared_terms.items():
if share_dict['term_obj'] == term_obj:
del shared_terms[share_id]
break
del term_obj['share_id']
self.trigger("terminal:unshare_terminal", term)
@require(authenticated(), policies('terminal'))
def set_sharing_permissions(self, settings):
"""
Sets the sharing permissions on the given *settings['term']*. Requires
*settings['read']* and/or *settings['write']*. Example JavaScript:
.. code-block:: javascript
settings = {
"term": 1,
"read": "AUTHENTICATED",
"write": ['bob@somehost', 'joe@somehost']
}
GateOne.ws.send(JSON.stringify({
"terminal:set_sharing_permissions": settings
}));
"""
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 = settings['term']
term_obj = self.loc_terms[term]
shared_terms = self.ws.persist['terminal']['shared']
for share_id, share_dict in shared_terms.items():
if share_dict['term_obj'] == term_obj:
if 'read' in settings:
share_dict['read'] = settings['read']
if 'write' in settings:
share_dict['write'] = settings['write']
break
# TODO: Put some logic here that notifies users if their permissions changed.
message = {'terminal:sharing_permissions': out_dict}
self.write_message(json_encode(message))
self.trigger("terminal:set_sharing_permissions", settings)
@require(authenticated(), policies('terminal'))
def get_sharing_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_sharing_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[term]
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 = "YzUxNzNkNjliMDQ4NDU21DliM3EwZTAwODVhNGY5MjNhM";
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
out_dict['viewers'].append({
'upn': user['upn'],
'ip_address': user['ip_address']
})
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)
@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 = {'terminals': {}, 'result': 'Success'}
shared_terms = self.ws.persist['terminal'].get('shared', {})
for share_id, share_dict in shared_terms.items():
if share_dict['read'] in ['AUTHENTICATED', 'ANONYMOUS']:
password = share_dict.get('password', False)
if password: # This would be a string
password = True # Don't want to reveal it to the client!
broadcast = share_dict.get('broadcast', False)
out_dict['terminals'][share_id] = {
'upn': share_dict['user']['upn'],
'broadcast': broadcast
}
out_dict['terminals'][share_id]['password_required'] = password
message = {'terminal:shared_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"
}
GateOne.ws.send(JSON.stringify({
"terminal:attach_shared_terminal": settings
}));
.. note::
Providing a password is only necessary if the shared terminal
requires it.
"""
self.term_log.debug("attach_shared_terminal(%s)" % settings)
shared_terms = self.ws.persist['terminal']['shared']
if 'share_id' not in settings:
self.term_log.error(_("Invalid share_id."))
return
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 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}
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
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)
# Make a note of this connection in the logs
self.term_log.info(_(
"%s connected to terminal shared by %s " % (
self.current_user['upn'], term_obj['user']['upn'])))
# Add this user to the list of viewers
if 'viewers' not in share_obj:
share_obj['viewers'] = [self.current_user]
else:
share_obj['viewers'].append(self.current_user)
# Notify the owner of the terminal that this user is now viewing
message = _("%s (%s) is now viewing terminal %s" % (
self.current_user['upn'],
term_obj['user']['ip_address'],
share_obj['term']))
# TODO: Use something more specific to sharing than send_message(). Preferably something that opens up a widget that can also display which user is typing.
if self.current_user['upn'] == 'ANONYMOUS':
self.ws.send_message(message, session=term_obj['user']['session'])
else:
self.ws.send_message(message, upn=term_obj['user']['upn'])
self.trigger("terminal:attach_shared_terminal", term)
[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 = os.path.join(
APPLICATION_PATH, '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 = u"#%s span.✈fx%s {color: #%s;}" % (
self.ws.container, i, color_map[i])
bg = u"#%s span.✈bx%s {background-color: #%s;} " % (
self.ws.container, i, color_map[i])
fg_rev =(
u"#%s span.✈reverse.fx%s {background-color: #%s; color: "
u"inherit;}" % (self.ws.container, i, color_map[i]))
bg_rev =(
u"#%s span.✈reverse.bx%s {color: #%s; background-color: "
u"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 = os.path.join(
APPLICATION_PATH, '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]
print("%s:%s" % (i, "".join(line)))
print(renditions[i])
try:
from pympler import asizeof
print("screen size: %s" % asizeof.asizeof(screen))
print("renditions size: %s" % asizeof.asizeof(renditions))
print("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.
"""
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})
if 'terminal' not in settings['*']:
# 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
# TODO: Think about moving 50terminal.conf template into the
# terminal application's directory.
template_path = os.path.join(
GATEONE_DIR, 'templates', 'settings', '50terminal.conf')
settings['*']['terminal'] = {}
# Update the settings with defaults
default_command = (
GATEONE_DIR +
"/applications/terminal/plugins/ssh/scripts/ssh_connect.py -S "
r"'%SESSION_DIR%/%SESSION%/%SHORT_SOCKET%' --sshfp "
r"-a '-oUserKnownHostsFile=\"%USERDIR%/%USER%/.ssh/known_hosts\"'"
)
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'
}
})
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_path = os.path.join(APPLICATION_PATH, 'plugins')
plugins = get_plugins(plugins_path, enabled_plugins)
# Attach plugin hooks
plugin_hooks = {}
imported = load_modules(plugins['py'])
for plugin in imported:
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']
plugin_dirs = os.listdir(plugins_path)
# Remove anything that isn't a directory (just in case)
plugin_dirs = [
a for a in plugin_dirs
if os.path.isdir(os.path.join(plugins_path, a))
]
if not enabled_plugins: # Use all of them
enabled_plugins = plugin_dirs
for plugin_name in enabled_plugins:
plugin_static_url = r"{prefix}terminal/{name}/static/(.*)".format(
prefix=url_prefix, name=plugin_name)
static_path = os.path.join(
APPLICATION_PATH, 'plugins', plugin_name, 'static')
if os.path.exists(static_path):
handler = (
plugin_static_url, StaticHandler, {"path": static_path})
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 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": os.path.join(APPLICATION_PATH, 'static')}
))
# Command line argument commands
commands = {
'termlog': logviewer_main
}