#
# Copyright 2011 Liftoff Software Corporation
#
# For license information see LICENSE.txt
# 1.0 TODO:
# * DOCUMENTATION!
# * Write a setup.py' with init scripts to stop/start/restart Gate One safely. Also make sure that .deb and .rpm packages safely restart Gate One without impacting running sessions. The setup.py should also attempt to minify the .css and .js files.
# Meta
__version__ = '0.9'
__license__ = "AGPLv3 or Proprietary (see LICENSE.txt)"
__version_info__ = (0, 9)
__author__ = 'Dan McDougall <daniel.mcdougall@liftoffsoftware.com>'
# NOTE: Docstring includes reStructuredText markup for use with Sphinx.
__doc__ = '''\
.. _gateone.py:
Gate One
========
Gate One is a web-based terminal emulator written in Python using the Tornado
web framework. This module runs the primary daemon process and acts as a
central controller for all running terminals and terminal programs. It supports
numerous configuration options and can also be called with the --kill switch
to kill all running terminal programs (if using dtach--otherwise they die on
their own when gateone.py is stopped).
Dependencies
------------
Gate One requires Python 2.6+ but runs best with Python 2.7+. It also depends
on the following 3rd party Python modules:
* `Tornado <http://www.tornadoweb.org/>`_ 2.1+ - Non-blocking web server framework that powers FriendFeed.
The following modules are optional and can provide Gate One with additional
functionality:
* `pyOpenSSL <https://launchpad.net/pyopenssl>`_ 0.10+ - OpenSSL wrapper for Python. Only used to generate self-signed SSL keys and certificates.
* `kerberos <http://pypi.python.org/pypi/kerberos>`_ 1.0+ - A high-level Kerberos interface for Python. Only necessary if you plan to use the Kerberos authentication module.
On most platforms both the required and optional modules can be installed via one of these commands:
.. ansi-block::
\x1b[1;34muser\x1b[0m@modern-host\x1b[1;34m:~ $\x1b[0m sudo pip install tornado pyopenssl kerberos
...or:
.. ansi-block::
\x1b[1;34muser\x1b[0m@legacy-host\x1b[1;34m:~ $\x1b[0m sudo easy_install tornado pyopenssl kerberos
.. note:: The use of pip is recommended. See http://www.pip-installer.org/en/latest/installing.html if you don't have it.
Settings
--------
All of Gate One's configurable options can be controlled either via command line
switches or by settings in the server.conf file (they match up 1-to-1). If no
server.conf exists one will be created using defaults (i.e. when Gate One is run
for the first time). Settings in the server.conf file use the following format::
<setting> = <value>
Here's an example::
address = "0.0.0.0" # Strings are surrounded by quotes
port = 443 # Numbers don't need quotes
There are a few important differences between the configuration file and
command line switches in regards to boolean values (True/False). A switch such
as --debug evaluates to "debug = True" and this is exactly how it would be
configured in server.conf::
debug = True # Booleans don't need quotes either
.. note:: server.conf is case sensitive for "True", "False" and "None".
Running gateone.py with the --help switch will print the usage information as
well as descriptions of what each configurable option does:
.. ansi-block::
\x1b[1;31mroot\x1b[0m@host\x1b[1;34m:~ $\x1b[0m ./gateone.py --help
Usage: ./gateone.py [OPTIONS]
Options:
--help show this help information
--log_file_max_size max size of log files before rollover
--log_file_num_backups number of log files to keep
--log_file_prefix=PATH Path prefix for log files. Note that if you are running multiple tornado processes, log_file_prefix must be different for each of them (e.g. include the port number)
--log_to_stderr Send log output to stderr (colorized if possible). By default use stderr if --log_file_prefix is not set and no other logging is configured.
--logging=info|warning|error|none Set the Python log level. If 'none', tornado won't touch the logging configuration.
--address Run on the given address.
--auth Authentication method to use. Valid options are: none, kerberos, google
--certificate Path to the SSL certificate. Will be auto-generated if none is provided.
--command Run the given command when a user connects (e.g. 'nethack').
--cookie_secret Use the given 45-character string for cookie encryption.
--debug Enable debugging features such as auto-restarting when files are modified.
--disable_ssl If enabled, Gate One will run without SSL (generally not a good idea).
--dtach Wrap terminals with dtach. Allows sessions to be resumed even if Gate One is stopped and started (which is a sweet feature =).
--embedded Run Gate One in Embedded Mode (no toolbar, only one terminal allowed, etc. See docs).
--keyfile Path to the SSL keyfile. Will be auto-generated if none is provided.
--kill Kill any running Gate One terminal processes including dtach'd processes.
--port Run on the given port.
--session_dir Path to the location where session information will be stored.
--session_logging If enabled, logs of user sessions will be saved in <user_dir>/logs. Default: Enabled
--session_timeout Amount of time that a session should be kept alive after the client has logged out. Accepts <num>X where X could be one of s, m, h, or d for seconds, minutes, hours, and days. Default is '5d' (5 days).
--sso_realm Kerberos REALM (aka DOMAIN) to use when authenticating clients. Only relevant if Kerberos authentication is enabled.
--sso_service Kerberos service (aka application) to use. Defaults to HTTP. Only relevant if Kerberos authentication is enabled.
--syslog_facility Syslog facility to use when logging to syslog (if syslog_session_logging is enabled). Must be one of: auth, cron, daemon, kern, local0, local1, local2, local3, local4, local5, local6, local7, lpr, mail, news, syslog, user, uucp. Default: daemon
--syslog_session_logging If enabled, logs of user sessions will be written to syslog.
--user_dir Path to the location where user files will be stored.
.. note:: Some of these options (e.g. log_file_prefix) are inherent to the Tornado framework. You won't find them anywhere in gateone.py.
File Paths
----------
Gate One stores its files, temporary session information, and persistent user
data in the following locations (Note: Many of these are configurable):
================= ==================================================================================
File/Directory Description
================= ==================================================================================
gateone.py Gate One's primary executable/script. Also, the file containing this documentation
auth.py Authentication classes
logviewer.py A utility to view Gate One session logs
server.conf Gate One's configuration file
sso.py A Kerberos Single Sign-on module for Tornado (used by auth.py)
terminal.py A Pure Python terminal emulator module
termio.py Terminal input/output control module
utils.py Various supporting functions
docs/ Gate One documentation
static/ Non-dynamic files that get served to clients (e.g. gateone.js, gateone.css, etc)
templates/ Tornado template files such as index.html
tests/ Gate One-specific automated unit/acceptance tests
plugins/ Plugins go here in the form of ./plugins/<plugin name>/<plugin files|directories>
users/ Persistent user data in the form of ./users/<username>/<user-specific files>
users/<user>/logs This is where session logs get stored if session_logging is set.
/tmp/gateone Temporary session data in the form of /tmp/gateone/<session ID>/<files>
================= ==================================================================================
Running
-------
Executing Gate One is as simple as:
.. ansi-block::
\x1b[1;31mroot\x1b[0m@host\x1b[1;34m:~ $\x1b[0m ./gateone.py
NOTE: By default Gate One will run on port 443 which requires root on most
systems. Use --port=<something greater than 1024> for non-root users.
Plugins
-------
Gate One includes support for any combination of the following types of plugins:
* Python
* JavaScript
* CSS
Python plugins can integrate with Gate One in three ways:
* Adding or overriding tornado.web.RequestHandlers (with a given regex).
* Adding or overriding methods (aka "commands") in TerminalWebSocket.
* Adding special plugin-specific escape sequence handlers (see the plugin development documentation for details on what/how these are/work).
JavaScript plugins will be added to the <body> tag of Gate One's base index.html
template like so:
.. code-block:: html
<!-- Begin JS files from plugins -->
{% for jsplugin in jsplugins %}
<script type="text/javascript" src="{{jsplugin}}"></script>
{% end %}
<!-- End JS files from plugins -->
CSS plugins are similar to JavaScript but instead of being appended to the
<body> they are added to the <head>:
.. code-block:: html
<!-- Begin CSS files from plugins -->
{% for cssplugin in cssplugins %}
<link rel="stylesheet" href="{{cssplugin}}" type="text/css" media="screen" />
{% end %}
<!-- End CSS files from plugins -->
There are also hooks throughout Gate One's code for plugins to add or override
Gate One's functionality. Documentation on how to write plugins can be found in
the Plugin Development docs. From the perspective of gateone.py, it performs
the following tasks in relation to plugins:
* Imports Python plugins and connects their hooks.
* Creates symbolic links inside ./static/ that point to each plugin's respective /static/ directory and serves them to clients.
* Serves the index.html that includes plugins' respective .js and .css files.
Class Docstrings
================
'''
# Standard library modules
import os
import sys
import logging
import threading
import time
from functools import partial
from datetime import datetime, timedelta
from platform import uname
# Our own modules
import termio
from auth import NullAuthHandler, KerberosAuthHandler, GoogleAuthHandler
from utils import noop, str2bool, generate_session_id, cmd_var_swap, mkdir_p
from utils import gen_self_signed_ssl, killall, get_plugins, load_plugins
from utils import create_plugin_static_links, merge_handlers, none_fix
from utils import convert_to_timedelta, kill_dtached_proc
from utils import process_opt_esc_sequence, create_data_uri
from utils import FACILITIES, string_to_syslog_facility
# Tornado modules (yeah, we use all this stuff)
import tornado.httpserver
import tornado.ioloop
import tornado.options
import tornado.web
import tornado.auth
import tornado.template
from tornado.websocket import WebSocketHandler
from tornado.escape import json_encode, json_decode
from tornado.options import define, options
# Globals
SESSIONS = {} # We store the crux of most session info here
CMD = None # Will be overwritten by options.command
TIMEOUT = timedelta(days=5) # Gets overridden by options.session_timeout
GATEONE_DIR = os.path.dirname(os.path.abspath(__file__))
PLUGINS = get_plugins(GATEONE_DIR + '/plugins')
PLUGIN_WS_CMDS = {} # Gives plugins the ability to extend/enhance TerminalWebSocket
PLUGIN_HOOKS = {} # Gives plugins the ability to hook into various things.
# Gate One registers a handler for for terminal.py's CALLBACK_OPT special escape
# sequence callback. Whenever this escape sequence is encountered, Gate One
# will parse the sequence's contained characters looking for the following
# format:
# <plugin name>|<whatever>
# The <whatever> part will be passed to any plugin matching <plugin name> if the
# plugin has 'Escape': <function> registered in its hooks.
PLUGIN_ESC_HANDLERS = {}
# Classes
class BaseHandler(tornado.web.RequestHandler):
"""
[docs] A base handler that all Gate One RequestHandlers will inherit methods from.
"""
# Right now it's just the one...
def get_current_user(self):
"""Tornado standard method--implemented our way."""
[docs] user_json = self.get_secure_cookie("user")
if not user_json: return None
return tornado.escape.json_decode(user_json)
class MainHandler(BaseHandler):
"""
[docs] Renders index.html which loads Gate One.
Will include the minified version of gateone.js if available as
gateone.min.js.
Will encode GATEONE_DIR/static/bell.ogg as a data:URI and put it as the
<source> of the <audio> tag inside the index.html template. Gate One
administrators can replace bell.ogg with whatever they like but the file
size should be less than 32k when encoded to Base64.
"""
# TODO: Add the ability for users to define their own individual bells.
@tornado.web.authenticated
def get(self):
hostname = uname()[1]
gateone_js = "/static/gateone.js"
minified_js_abspath = "%s/static/gateone.min.js" % GATEONE_DIR
bell = "%s/static/bell.ogg" % GATEONE_DIR
bell_data_uri = create_data_uri(bell)
# Use the minified version if it exists
if os.path.exists(minified_js_abspath):
gateone_js = "/static/gateone.min.js"
self.render(
"templates/index.html",
hostname=hostname,
gateone_js=gateone_js,
jsplugins=PLUGINS['js'],
cssplugins=PLUGINS['css'],
bell_data_uri=bell_data_uri
)
class StyleHandler(BaseHandler):
"""
[docs] Serves up our CSS templates (e.g. the 'black' or 'white' schemes)
"""
# Hey, if unauthenticated clients want this they can have it!
#@tornado.web.authenticated
def get(self):
container = self.get_argument("container")
prefix = self.get_argument("prefix")
scheme = self.get_argument("scheme", None)
self.set_header ('Content-Type', 'text/css')
self.render(
"templates/css_%s.css" % scheme, container=container, prefix=prefix)
class TerminalWebSocket(WebSocketHandler):
def __init__(self, application, request):
WebSocketHandler.__init__(self, application, request)
self.commands = {
'ping': self.pong,
'authenticate': self.authenticate,
'new_terminal': self.new_terminal,
'set_terminal': self.set_terminal,
'kill_terminal': self.kill_terminal,
'c': self.char_handler, # Just 'c' to keep the bandwidth down
'refresh': self.refresh_screen,
#'refresh_test': self.refresh_screen_testing,
'resize': self.resize,
'debug_terminal': self.debug_terminal
}
self.terms = {}
# So we can keep track and avoid sending unnecessary messages:
self.titles = {}
def get_current_user(self):
"""Identical to the function of the same name in MainHandler."""
user_json = self.get_secure_cookie("user")
if not user_json: return None
return tornado.escape.json_decode(user_json)
def open(self):
"""Called when a new WebSocket is opened."""
logging.info(
"WebSocket opened (%s)" % self.get_current_user()['go_upn'])
def on_message(self, message):
"""Called when we receive a message from the client."""
# This is super useful when debugging:
logging.debug("message: %s" % repr(message))
message_obj = None
try:
message_obj = json_decode(message) # JSON FTW!
if not isinstance(message_obj, dict):
self.write_message("'Error: Message bust be a JSON dict.'")
except ValueError: # We didn't get JSON
self.write_message("'Error: We only accept JSON here.'")
if message_obj:
for key, value in message_obj.items():
try: # Plugins first so they can override behavior if they wish
PLUGIN_WS_CMDS[key](value, tws=self) # tws==TerminalWebSocket
except KeyError:
try:
self.commands[key](value)
except KeyError:
pass # Ignore commands we don't understand
def on_close(self):
"""
Called when the client terminates the connection.
NOTE: Normally self.refresh_screen() catches the disconnect first and
this won't be called.
"""
logging.info(
"WebSocket closed (%s)" % self.get_current_user()['go_upn'])
def pong(self, timestamp):
"""
Responds to a client 'ping' request... Just returns the given
timestamp back to the client so it can measure round-trip time.
"""
message = {'pong': timestamp}
self.write_message(json_encode(message))
# TODO: Change this to encrypt the session ID so that it is stored in encrypted form on the client end. Just like we do with cookies but for use with localStorage. The encrypted value should actually be a JSON dict with a uniqe, random ID included to ensure that the encrypted data changes every time it is created (even though the session ID might not).
def authenticate(self, settings):
"""
Authenticates the client using the given session (which should be
settings['session']) and returns a list of all running terminals (if
any). If no session is given (null) a new one will be created.
"""
logging.debug("authenticate(): %s" % settings)
# Make sure the client is authenticated if authentication is enabled
if self.settings['auth']:
try:
user = self.get_current_user()['go_upn']
if user == '%anonymous':
logging.error("Unauthenticated WebSocket attempt.")
# In case this is a legitimate client that simply lost its
# cookie, tell it to re-auth by calling the appropriate
# action on the other side.
message = {'reauthenticate': True}
self.write_message(json_encode(message))
self.close() # Close the WebSocket
except KeyError:
# Force them to authenticate
message = {'reauthenticate': True}
self.write_message(json_encode(message))
self.close() # Close the WebSocket
else:
# Double-check there isn't a user set in the cookie (i.e. we have
# recently changed Gate One's settings). If there is, force it
# back to %anonymous.
user = self.get_current_user()['go_upn']
if user != '%anonymous':
message = {'reauthenticate': True}
self.write_message(json_encode(message))
self.close() # Close the WebSocket
if 'session' in settings.keys():
# Try to use the cookie session first
try:
self.session = self.get_current_user()['go_session']
except:
# This generates a random 45-character string:
self.session = generate_session_id()
# This check is to make sure there's no existing session so we don't
# accidentally clobber it.
if self.session not in SESSIONS:
# Old session is no good, start a new one:
SESSIONS[self.session] = {}
terminals = []
for term in SESSIONS[self.session].keys():
if isinstance(term, int): # This skips the TidyThread...
terminals.append(term) # Only terminals are integers in the dict
# Check for any dtach'd terminals we might have missed
if self.settings['dtach']:
session_dir = self.settings['session_dir']
session_dir = session_dir + "/" + self.session
if not os.path.exists(session_dir):
mkdir_p(session_dir)
os.chmod(session_dir, 0700)
for item in os.listdir(session_dir):
if item.startswith('dtach:'):
term = int(item.split(':')[1])
if term not in terminals:
terminals.append(term)
terminals.sort() # Put them in order so folks don't get confused
message = {'terminals': terminals}
# TODO: Add a hook here for plugins to send their own messages when a
# given terminal is reconnected.
self.write_message(json_encode(message))
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).
"""
logging.debug("%s new_terminal(): %s" % (
self.get_current_user()['go_upn'], settings))
self.current_term = term = settings['term']
self.rows = rows = settings['rows']
self.cols = cols = settings['cols']
user_dir = self.settings['user_dir']
if term not in SESSIONS[self.session]:
# Setup the requisite dict
SESSIONS[self.session][term] = {}
if 'multiplex' not in SESSIONS[self.session][term]:
# Start up a new terminal
SESSIONS[self.session][term]['created'] = datetime.now()
# NOTE: Not doing anything with 'created'... yet!
now = int(round(time.time() * 1000))
try:
user = self.get_current_user()['go_upn']
except:
# No auth, use %anonymous (% is there to prevent conflicts)
user = r'%anonymous' # Don't get on this guy's bad side
cmd = cmd_var_swap(CMD, # Swap out variables like %USER% in CMD
session=self.session, # with their real-world values.
user_dir=user_dir,
user=user,
time=now
)
resumed_dtach = False
session_dir = self.settings['session_dir']
session_dir = session_dir + "/" + self.session
# Create the session dir if not already present
if not os.path.exists(session_dir):
mkdir_p(session_dir)
os.chmod(session_dir, 0700)
if self.settings['dtach']: # Wrap in dtach (love this tool!)
dtach_path = "%s/dtach:%s" % (session_dir, term)
if os.path.exists(dtach_path):
# Using 'none' for the refresh because the EVIL 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)
log_path = None
if self.settings['session_logging']:
log_dir = "%s/%s/logs" % (user_dir, user)
# Create the log dir if not already present
if not os.path.exists(log_dir):
mkdir_p(log_dir)
log_path = "%s/%s" % (
log_dir, datetime.now().strftime('%Y%m%d%H%M%S%f.golog'))
facility = string_to_syslog_facility(
self.settings['syslog_facility'])
SESSIONS[self.session][term]['multiplex'] = termio.Multiplex(
cmd,
tmpdir=session_dir,
log_path=log_path,
user=user,
term_num=term,
syslog=self.settings['syslog_session_logging'],
syslog_facility=facility
)
# Set some environment variables so the programs we execute can use
# them (very handy). Allows for "tight integration" and "synergy"!
env = {
'GO_TERM': str(term),
'GO_SESSION': self.session,
'GO_SESSION_DIR': session_dir,
}
fd = SESSIONS[self.session][term]['multiplex'].create(
rows, cols, env=env)
refresh = partial(self.refresh_screen, term)
SESSIONS[self.session][term][ # 1 is CALLBACK_UPDATE
'multiplex'].callbacks[1] = refresh
restart = partial(self.new_terminal, settings)
SESSIONS[self.session][term][ # 2 is CALLBACK_EXIT
'multiplex'].callbacks[2] = restart
termio_write = SESSIONS[ # Write responses directly to the prog
self.session][term]['multiplex'].proc_write
SESSIONS[self.session][term][ # 5 is CALLBACK_DSR
'multiplex'].term.callbacks[5] = termio_write
set_title = partial(self.set_title, term)
SESSIONS[self.session][term][ # 6 is CALLBACK_TITLE
'multiplex'].term.callbacks[6] = set_title
set_title() # Set initial title
bell = partial(self.bell, term)
SESSIONS[self.session][term][ # 7 is CALLBACK_BELL
'multiplex'].term.callbacks[7] = bell
SESSIONS[self.session][term][ # 8 is CALLBACK_OPT
'multiplex'].term.callbacks[8] = self.esc_opt_handler
mode_handler = partial(self.mode_handler, term)
SESSIONS[self.session][term][ # 9 is CALLBACK_MODE
'multiplex'].term.callbacks[9] = mode_handler
if self.settings['dtach']: # dtach sessions need a little extra love
SESSIONS[self.session][term]['multiplex'].redraw()
else:
# Terminal already exists
if SESSIONS[self.session][term]['multiplex'].alive: # It's ALIVE!
message = {'term_exists': term}
self.write_message(json_encode(message))
# This resets the diff
SESSIONS[self.session][term]['multiplex'].prev_output = [
None for a in xrange(rows-1)]
# TODO: Right here we need to change how the callbacks are handled so we can have multiple screen update callbacks for the same session (so a user could have two browsers open to the same session). Not exactly a common use case but you never know!
restart = partial(self.new_terminal, settings)
SESSIONS[self.session][term][ # 2 is CALLBACK_EXIT
'multiplex'].callbacks[2] = restart
refresh = partial(self.refresh_screen, term)
SESSIONS[self.session][term][ # 1 is CALLBACK_UPDATE
'multiplex'].callbacks[1] = refresh
set_title = partial(self.set_title, term)
termio_write = SESSIONS[ # Write responses directly to the prog
self.session][term]['multiplex'].proc_write
SESSIONS[self.session][term][ # 5 is CALLBACK_DSR
'multiplex'].term.callbacks[5] = termio_write
SESSIONS[self.session][term][ # 6 is CALLBACK_TITLE
'multiplex'].term.callbacks[6] = set_title
set_title() # Set the title
bell = partial(self.bell, term)
SESSIONS[self.session][term][ # 7 is CALLBACK_BELL
'multiplex'].term.callbacks[7] = bell
SESSIONS[self.session][term][ # 8 is CALLBACK_OPT
'multiplex'].term.callbacks[8] = self.esc_opt_handler
mode_handler = partial(self.mode_handler, term)
SESSIONS[self.session][term][ # 9 is CALLBACK_MODE
'multiplex'].term.callbacks[9] = mode_handler
self.refresh_screen(term) # Send a fresh screen to the client
# NOTE: refresh_screen will also take care of cleaning things up if
# SESSIONS[self.session][term]['multiplex'].alive is False
if 'tidy_thread' not in SESSIONS[self.session]:
# Start the keepalive thread so the session will time out if the
# user disconnects for like a week (by default anyway =)
SESSIONS[self.session]['tidy_thread'] = TidyThread(self.session)
SESSIONS[self.session]['tidy_thread'].start()
def kill_terminal(self, term):
"""Kills *term* and any associated processes"""
#print("killing terminal: %s" % term)
term = int(term)
try:
SESSIONS[self.session][term]['multiplex'].die()
SESSIONS[self.session][term]['multiplex'].proc_kill()
if self.settings['dtach']:
kill_dtached_proc(self.session, term)
del SESSIONS[self.session][term]
except KeyError as e:
pass # The EVIL termio has killed my child! Wait, that's good...
# Because now I don't have to worry about it!
def set_terminal(self, term):
"""Sets self.current_term = *term*"""
self.current_term = term
def set_title(self, term):
"""
Sends a message to the client telling it to set the window title of
*term* to...
SESSIONS[self.session][term]['multiplex'].proc[fd]['term'].title.
Example output:
{'set_title': {'term': 1, 'title': "user@host"}}
"""
#print("Got set_title on term: %s" % term)
title = SESSIONS[self.session][term]['multiplex'].term.title
# Only send a title update if it actually changed
if term not in self.titles: # There's a first time for everything
self.titles[term] = ""
if title != self.titles[term]:
self.titles[term] = title
title_message = {'set_title': {'term': term, 'title': title}}
self.write_message(json_encode(title_message))
def bell(self, term):
"""
Sends a message to the client indicating that a bell was encountered in
the given terminal (*term*). Example output:
{'bell': {'term': 1}}
"""
bell_message = {'bell': {'term': term}}
self.write_message(json_encode(bell_message))
def mode_handler(self, term, setting, boolean):
"""Handles mode settings that require an action on the client."""
if setting in ['1']: # Only support this mode right now
if boolean:
# Tell client to enable application cursor mode
mode_message = {'set_mode': {
'mode': setting,
'boolean': True,
'term': term
}}
self.write_message(json_encode(mode_message))
else:
# Tell client to disable application cursor mode
mode_message = {'set_mode': {
'mode': setting,
'boolean': False,
'term': term
}}
self.write_message(json_encode(mode_message))
def refresh_screen(self, term):
"""Returns the whole terminal screen."""
try:
SESSIONS[self.session]['tidy_thread'].keepalive(datetime.now())
scrollback, screen = SESSIONS[
self.session][term]['multiplex'].dumplines()
except KeyError: # Session died (i.e. command ended).
scrollback, screen = None, None
if screen:
multiplexer = SESSIONS[self.session][term]['multiplex']
output_dict = {
'termupdate': {
'term': term,
'scrollback': scrollback,
'screen' : screen,
'ratelimiter': multiplexer.ratelimiter_engaged
}
}
try:
self.write_message(json_encode(output_dict))
except IOError: # Socket was just closed, no biggie
logging.info(
"WebSocket closed (%s)" % self.get_current_user()['go_upn'])
SESSIONS[self.session][term][ # 1 is CALLBACK_UPDATE
'multiplex'].callbacks[1] = noop # Stop trying to write
def resize(self, resize_obj):
"""
Resize the terminal window to the rows/cols specified in *resize_obj*
Example *resize_obj*:
{'rows': 24, 'cols': 80}
"""
self.rows = resize_obj['rows']
self.cols = resize_obj['cols']
term = resize_obj['term']
if self.rows < 2 or self.cols < 2: # 0 or negative numbers will crash
# Fall back to a standard default:
self.rows = 24
self.cols = 80
# If the user already has a running session, set the new terminal size:
try: # TODO: Make this only resize a given terminal. Let the client handle repeating the resize command for each.
for term in SESSIONS[self.session].keys():
if isinstance(term, int): # Skip the TidyThread
SESSIONS[self.session][term]['multiplex'].resize(
self.rows,
self.cols
)
except KeyError: # Session doesn't exist yet, no biggie
pass
def char_handler(self, chars):
"""Writes *chars* (string) to the currently-selected terminal"""
term = self.current_term
session = self.session
if session in SESSIONS:
if SESSIONS[session][term]['multiplex'].alive:
if chars:
SESSIONS[ # Force an update
session][term]['multiplex'].ratelimit = time.time()
SESSIONS[session][term]['multiplex'].proc_write(chars)
def esc_opt_handler(self, chars):
"""
Executes whatever function is registered matching the tuple returned by
process_opt_esc_sequence().
"""
plugin_name, text = process_opt_esc_sequence(chars)
if plugin_name:
try:
PLUGIN_ESC_HANDLERS[plugin_name](text, tws=self)
except Exception as e:
print("Got exception trying to execute plugin's optional ESC "
"sequence handler...")
print(e)
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:
GateOne.ws.send(JSON.stringify({'debug_terminal': *term*}));
"""
screen = SESSIONS[self.session][term]['multiplex'].term.screen
renditions = SESSIONS[self.session][term]['multiplex'].term.renditions
for i, line in enumerate(screen):
print("%s:%s" % (i, "".join(line)))
print(renditions[i])
class RecordingHandler(BaseHandler):
"""
[docs] Handles uploads of session recordings and returns them to the client in a
self-contained HTML file that will auto-start playback.
NOTE: The real crux of the code that handles this is in the template.
"""
def post(self):
recording = self.get_argument("recording")
container = self.get_argument("container")
prefix = self.get_argument("prefix")
scheme = self.get_argument("scheme")
css_file = open('templates/css_%s.css' % scheme).read()
css = tornado.template.Template(css_file)
self.render(
"templates/self_contained_recording.html",
recording=recording,
container=container,
prefix=prefix,
css=css.generate(container=container, prefix=prefix)
)
class OpenLogHandler(BaseHandler):
"""
[docs] Handles uploads of user logs and returns them to the client as a basic HTML
page. Essentially, this works around the limitation of an HTML page being
unable to save itself =).
"""
def post(self):
log = self.get_argument("log")
container = self.get_argument("container")
prefix = self.get_argument("prefix")
scheme = self.get_argument("scheme")
css_file = open('templates/css_%s.css' % scheme).read()
css = tornado.template.Template(css_file)
self.render(
"templates/user_log.html",
log=log,
container=container,
prefix=prefix,
css=css.generate(container=container, prefix=prefix)
)
class TidyThread(threading.Thread):
"""
[docs] Kills a user's termio session if the client hasn't updated the keepalive
within *TIMEOUT* (global). Also, tidies up sessions, logs, and whatnot based
on Gate One's settings (when the time is right).
NOTE: This is necessary to prevent shells from running eternally in the
background.
*session* - 45-character string containing the user's session ID
"""
# TODO: Get this cleaning up logs according to the user's settings
# TODO: Add the aforementioned log cleanup settings :)
def __init__(self, session):
threading.Thread.__init__(
self,
name="TidyThread-%s" % session
)
self.last_keepalive = datetime.now()
self.session = session
self.quitting = False
def keepalive(self, datetime_obj=None):
"""
[docs] Resets the keepalive timer. Typically called when the user performs a new action.
*datetime_obj* - A datetime object that will be used to measure *TIMEOUT* against. Will end up defaulting to datetime.now() (if None) which is what you'd want 99% of the time.
"""
if datetime_obj:
self.last_keepalive = datetime_obj
else:
self.last_keepalive = datetime.now()
def quit(self):
self.quitting = True
def run(self):
while not self.quitting:
try:
session = self.session
if datetime.now() > self.last_keepalive + TIMEOUT:
logging.info(
"{session} timeout.".format(
session=session
)
)
self.quitting = True
# This loops through all the open terminals checking if each is alive
all_dead = True
for term in SESSIONS[session].keys():
try:
if SESSIONS[session][term]['multiplex'].alive:
all_dead = False
except TypeError: # Ignore TidyThread object
pass
if all_dead:
self.quitting = True
# Keep this low or it will take that long for the process to end
# when it receives a SIGTERM or Ctrl-c
time.sleep(2)
except Exception as e:
logging.info(
"Exception encountered: {exception}".format(exception=e)
)
self.quitting = True
logging.info(
"{session} received quit()... "
"Killing termio session.".format(session=self.session)
)
# Clean up:
for term in SESSIONS[session].keys():
try:
SESSIONS[session][term]['multiplex'].die()
SESSIONS[session][term]['multiplex'].proc_kill()
except TypeError: # Ignore the TidyThread (i.e. ourselves)
pass
except KeyError: # Already killed... Great!
pass
del SESSIONS[session]
class Application(tornado.web.Application):
def __init__(self, settings):
"""
Setup our Tornado application... Everything in *settings* will wind up
in the Tornado settings dict so as to be accessible under self.settings.
"""
global PLUGIN_WS_CMDS
global PLUGIN_HOOKS
global PLUGIN_ESC_HANDLERS
# Base settings for our Tornado app
tornado_settings = dict(
cookie_secret=settings['cookie_secret'],
static_path=os.path.join(GATEONE_DIR, "static"),
gzip=True,
login_url="/auth"
)
# Make sure all the provided settings wind up in self.settings
for k, v in settings.items():
tornado_settings[k] = v
# Setup the configured authentication type
AuthHandler = NullAuthHandler # Default
if 'auth' in settings and settings['auth']:
if settings['auth'] == 'kerberos' and KerberosAuthHandler:
AuthHandler = KerberosAuthHandler
tornado_settings['sso_realm'] = settings["sso_realm"]
tornado_settings['sso_service'] = settings["sso_service"]
elif settings['auth'] == 'google':
AuthHandler = GoogleAuthHandler
logging.info("Using %s authentication" % settings['auth'])
else:
logging.info("No authentication method configure. All users will be"
" %anonymous")
# Setup our URL handlers
handlers = [
(r"/", MainHandler),
(r"/ws", TerminalWebSocket),
(r"/auth", AuthHandler),
(r"/style", StyleHandler),
(r"/recording", RecordingHandler),
(r"/openlog", OpenLogHandler),
(r"/docs/(.*)", tornado.web.StaticFileHandler, {
"path": GATEONE_DIR + '/docs/build/html/',
"default_filename": "index.html"
})
]
# Load plugins and grab their hooks
imported = load_plugins(PLUGINS['py'])
for plugin in imported:
try:
PLUGIN_HOOKS.update({plugin.__name__: plugin.hooks})
except AttributeError:
pass # No hooks--probably just a supporting .py file.
# Connect the hooks
for plugin_name, hooks in PLUGIN_HOOKS.items():
if 'Web' in hooks:
# Apply the plugin's Web handlers
handlers.extend(hooks['Web'])
if 'WebSocket' in hooks:
# Apply the plugin's WebSocket commands
PLUGIN_WS_CMDS.update(hooks['WebSocket'])
if 'Escape' in hooks:
# Apply the plugin's Escape handler
PLUGIN_ESC_HANDLERS.update({plugin_name: hooks['Escape']})
# This removes duplicate handlers for the same regex, allowing plugins
# to override defaults:
handlers = merge_handlers(handlers)
# Include JS-only and CSS-only plugins (for logging purposes)
js_plugins = [a.split('/')[2] for a in PLUGINS['js']]
css_plugins = [a.split('/')[2] for a in PLUGINS['css']]
plugin_list = list(set(PLUGINS['py'] + js_plugins + css_plugins))
plugin_list.sort() # So there's consistent ordering
logging.info("Loaded plugins: %s" % ", ".join(plugin_list))
tornado.web.Application.__init__(self, handlers, **tornado_settings)
def main():
# Simplify the auth option help message
auths = "none, google"
if KerberosAuthHandler:
auths += ", kerberos"
# Simplify the syslog_facility option help message
facilities = FACILITIES.keys()
facilities.sort()
define(
"debug",
default=False,
help="Enable debugging features such as auto-restarting when files are "
"modified."
)
define("cookie_secret", # 45 chars is, "Good enough for me" (cookie joke =)
default=None,
help="Use the given 45-character string for cookie encryption.",
type=str
)
define("command",
default=GATEONE_DIR + "plugins/ssh/scripts/ssh_connect.py",
help="Run the given command when a user connects (e.g. 'nethack').",
type=str
)
define("address",
default="0.0.0.0",
help="Run on the given address.",
type=str)
define("port", default=443, help="Run on the given port.", type=int)
# Please only use this if Gate One is running behind something with SSL:
define(
"disable_ssl",
default=False,
help="If enabled, Gate One will run without SSL (generally not a good "
"idea)."
)
define(
"certificate",
default="certificate.pem",
help="Path to the SSL certificate. Will be auto-generated if none is"
" provided.",
type=str
)
define(
"keyfile",
default="keyfile.pem",
help="Path to the SSL keyfile. Will be auto-generated if none is"
" provided.",
type=str
)
define(
"user_dir",
default=GATEONE_DIR + "/users",
help="Path to the location where user files will be stored.",
type=str
)
define(
"session_dir",
default="/tmp/gateone",
help="Path to the location where session information will be stored.",
type=str
)
define(
"session_logging",
default=True,
help="If enabled, logs of user sessions will be saved in "
"<user_dir>/logs. Default: Enabled"
)
define( # This is an easy way to support cetralized logging
"syslog_session_logging",
default=False,
help="If enabled, logs of user sessions will be written to syslog."
)
define(
"syslog_facility",
default="daemon",
help="Syslog facility to use when logging to syslog (if "
"syslog_session_logging is enabled). Must be one of: %s."
" Default: daemon" % ", ".join(facilities),
type=str
)
define(
"session_timeout",
default="5d",
help="Amount of time that a session should be kept alive after the "
"client has logged out. Accepts <num>X where X could be one of s, m, h"
", or d for seconds, minutes, hours, and days. Default is '5d' (5 days"
").",
type=str
)
define(
"auth",
default=None,
help="Authentication method to use. Valid options are: %s" % auths,
type=str
)
define(
"sso_realm",
default=None,
help="Kerberos REALM (aka DOMAIN) to use when authenticating clients."
" Only relevant if Kerberos authentication is enabled.",
type=str
)
define(
"sso_service",
default='HTTP',
help="Kerberos service (aka application) to use. Defaults to HTTP. "
"Only relevant if Kerberos authentication is enabled.",
type=str
)
define(
"embedded",
default=False,
help="Run Gate One in Embedded Mode (no toolbar, only one terminal "
"allowed, etc. See docs)."
)
define(
"dtach",
default=True,
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,
help="Kill any running Gate One terminal processes including dtach'd "
"processes."
)
# TODO: Give plugins the ability to add their own define()s
# TODO: Use the arguments passed to gateone.py to generate server.conf if it
# isn't already present.
if os.path.exists(GATEONE_DIR + "/server.conf"):
tornado.options.parse_config_file(GATEONE_DIR + "/server.conf")
else: # Generate a default server.conf with a random cookie secret
logging.info("No server.conf found. A new one will be generated using "
"defaults.")
if not os.path.exists(options.user_dir): # Make our user_dir
mkdir_p(options.user_dir)
os.chmod(options.user_dir, 0700)
if not os.path.exists(options.session_dir): # Make our session_dir
mkdir_p(options.session_dir)
os.chmod(options.session_dir, 0700)
config_defaults = {
'debug': False,
'cookie_secret': generate_session_id(), # Works for so many things!
'port': 443,
'address': '0.0.0.0', # All addresses
'embedded': False,
'auth': None,
'dtach': True,
# NOTE: The next four options are specific to the Tornado framework
'log_file_max_size': 100 * 1024 * 1024, # 100MB
'log_file_num_backups': 10, # 1GB total max
'log_file_prefix': '/var/log/gateone/webserver.log',
'logging': 'info', # One of: info, warning, error, none
'user_dir': options.user_dir,
'session_dir': options.session_dir,
'session_logging': options.session_logging,
'syslog_session_logging': options.syslog_session_logging,
'syslog_facility': options.syslog_facility,
'session_timeout': options.session_timeout,
'keyfile': GATEONE_DIR + "/keyfile.pem",
'certificate': GATEONE_DIR + "/certificate.pem",
'command': (
GATEONE_DIR + "/plugins/ssh/scripts/ssh_connect.py -S "
r"'/tmp/gateone/%SESSION%/%r@%h:%p' -a "
"'-oUserKnownHostsFile=%USERDIR%/%USER%/known_hosts'"
),
'sso_realm': 'EXAMPLE.COM',
'sso_service': 'HTTP'
}
config = open(GATEONE_DIR + "/server.conf", "w")
for key, value in config_defaults.items():
if isinstance(value, basestring):
config.write('%s = "%s"\n' % (key, value))
else:
config.write('%s = %s\n' % (key, value))
config.close()
tornado.options.parse_config_file(GATEONE_DIR + "/server.conf")
# Create the log dir if not already present (NOTE: Assumes we're root)
log_dir = os.path.split(options.log_file_prefix)[0]
if not os.path.exists(log_dir):
try:
mkdir_p(log_dir)
except OSError:
print("\x1b[1;31mERROR:\x1b[0m Could not create %s for "
"log_file_prefix: %s" % (log_dir, options.log_file_prefix))
print("You probably want to change this option, run Gate One as "
"root, or create that directory and give the proper user "
"ownership of it.")
sys.exit(1)
tornado.options.parse_command_line()
if options.kill:
# Kill all running dtach sessions (associated with Gate One anyway)
killall(options.session_dir)
sys.exit(0)
# Set our CMD variable to tell the multiplexer which command to execute
global CMD
CMD = options.command
# Set our global session timeout
global TIMEOUT
TIMEOUT = convert_to_timedelta(options.session_timeout)
# Define our Application settings
app_settings = {
'gateone_dir': GATEONE_DIR, # Only here so plugins can reference it
'debug': options.debug,
'cookie_secret': options.cookie_secret,
'auth': none_fix(options.auth),
'embedded': str2bool(options.embedded),
'user_dir': options.user_dir,
'session_dir': options.session_dir,
'session_logging': options.session_logging,
'syslog_session_logging': options.syslog_session_logging,
'syslog_facility': options.syslog_facility,
'dtach': options.dtach,
'sso_realm': options.sso_realm,
'sso_service': options.sso_service
}
# Check to make sure we have a certificate and keyfile and generate fresh
# ones if not.
if not os.path.exists(options.keyfile):
logging.info("No SSL private key found. One will be generated.")
gen_self_signed_ssl()
if not os.path.exists(options.certificate):
logging.info("No SSL certificate found. One will be generated.")
gen_self_signed_ssl()
logging.info(
"Listening on https://{address}:{port}/".format(
address=options.address, port=options.port
)
)
# Setup static file links for plugins (if any)
static_dir = os.path.join(GATEONE_DIR, "static")
plugin_dir = os.path.join(GATEONE_DIR, "plugins")
create_plugin_static_links(static_dir, plugin_dir)
# Instantiate our Tornado web server
ssl_options = {
"certfile": os.path.join(os.getcwd(), "certificate.pem"),
"keyfile": os.path.join(os.getcwd(), "keyfile.pem"),
}
if options.disable_ssl:
ssl_options = None
http_server = tornado.httpserver.HTTPServer(
Application(settings=app_settings), ssl_options=ssl_options)
try: # Start your engines!
http_server.listen(options.port, options.address)
tornado.ioloop.IOLoop.instance().start()
except KeyboardInterrupt: # ctrl-c
logging.info("Caught KeyboardInterrupt. Killing sessions...")
for t in threading.enumerate():
if t.getName().startswith('TidyThread'):
t.quit()
if __name__ == "__main__":
main()