#
#       Copyright 2011 Liftoff Software Corporation
#
# TODO: See if we can spin off termio.py into its own little program that sits between Gate One and ssh_connect.py.  That way we can take advantage of multiple cores/processors (for terminal-to-HTML processing).  There's no reason why we can't write something that does what dtach does.  Just need to redirect the fd of self.cmd to a unix domain socket and os.setsid() somewhere after forking (twice maybe?).
# TODO: Make the environment variables used before launching self.cmd configurable
# Meta
__version__ = '0.9'
__license__ = "AGPLv3 or Proprietary (see LICENSE.txt)"
__version_info__ = (0, 9)
__author__ = 'Dan McDougall <daniel.mcdougall@liftoffsoftware.com>'
__doc__ = """\
About termio
============
This module provides a Multiplex class that can perform the following:
 * Fork a child process that opens a given terminal program.
 * Read and write data to and from the child process.
 * Log the output of the child process to a file and/or syslog.
The Multiplex class is meant to be used in conjunction with a running Tornado
IOLoop instance.  It can be instantiated from within your Tornado application
like so::
    multiplexer = termio.Multiplex(
        'nethack',
        tmpdir='/tmp',
        log_path='/var/log/myapp',
        user='bsmith@CORP',
        term_num=1,
        syslog=True
    )
Then *multiplexer* can create and launch a new controlling terminal (tty)
running the given command (e.g. 'nethack')::
    env = {
        'PATH': os.environ['PATH'],
        'MYVAR': 'foo'
    }
    fd = multiplexer.create(80, 24, env=env)
    # The fd is returned from create() in case you want more low-level control.
Input and output from the controlled program is asynchronous and gets handled
via IOLoop.  It will automatically write all output from the terminal program to
an instance of self.terminal_emulator (which defaults to Gate One's
terminal.Terminal).  So if you want to perform an action whenever the running
terminal application has output (like, say, sending a message to a client)
you'll need to attach a callback::
    def screen_update():
        'Called when new output is ready to send to the client'
        output = multiplexer.dumplines()
        socket_or_something.write(output)
    multiplexer.callbacks[multiplexer.CALLBACK_UPDATE] = screen_update
In this example, screen_update() will write() the output of
multiplexer.dumplines() to *socket_or_something* whenever the terminal program
has some sort of output.  You can also make calls directly to the terminal
emulator (if you're using a custom one)::
    def screen_update():
        output = multiplexer.term.my_custom_func()
        whatever.write(output)
Writing characters to the controlled terminal application is pretty
straightforward::
    multiplexer.proc_write('some text')
Typically you'd pass in keystrokes or commands from your application to the
underlying program this way and the screen/terminal emulator would get updated
automatically.  If using Gate One's Terminal() you can also attach callbacks
to perform further actions when more specific situations are encountered (e.g.
when the window title is set via that respective escape sequence)::
    def set_title():
        'Hypothetical title-setting function'
        print("Window title was just set to: %s" % multiplexer.term.title)
    multiplexer.term.callbacks[multiplexer.CALLBACK_TITLE] = set_title
Module Functions and Classes
============================
"""
# Stdlib imports
import signal, threading, fcntl, os, pty, re, sys, time, termios, struct
import io, codecs, gzip, syslog
from tornado import ioloop
from functools import partial
from itertools import izip
import logging
from subprocess import Popen
# Import our own stuff
from utils import noop
# Globals
SEPARATOR = u"\U000f0f0f" # The character used to separate frames in the log
# NOTE: That unicode character was carefully selected from only the finest
# of the PUA.  I hereby dub thee, "U+F0F0F0, The Separator."
# Helper functions
def handle_special(e):
[docs]    """
    Used in conjunction with codecs.register_error, will replace special ascii
    characters such as 0xDA and 0xc4 (which are used by ncurses) with their
    Unicode equivalents.
    """
    # TODO: Fill this out with *all* the ascii characters >127 from
    #       http://www.ascii-code.com/
    specials = {
        # NOTE: When $TERM is set to "Linux" these end up getting used by things
        #       like ncurses-based apps.  In other words, it makes a whole lot
        #       of ugly look pretty again.
        0xda: u'┌', # ACS_ULCORNER
        0xc0: u'└', # ACS_LLCORNER
        0xbf: u'┐', # ACS_URCORNER
        0xd9: u'┘', # ACS_LRCORNER
        0xb4: u'├', # ACS_RTEE
        0xc3: u'┤', # ACS_LTEE
        0xc1: u'┴', # ACS_BTEE
        0xc2: u'┬', # ACS_TTEE
        0xc4: u'─', # ACS_HLINE
        0xb3: u'│', # ACS_VLINE
        0xc5: u'┼', # ACS_PLUS
        0x2d: u'', # ACS_S1
        0x5f: u'', # ACS_S9
        0xc5: u'◆', # ACS_DIAMOND
        0xb2: u'▒', # ACS_CKBOARD
        0xf8: u'°', # ACS_DEGREE
        0xf1: u'±', # ACS_PLMINUS
        0xf9: u'•', # ACS_BULLET
        0x3c: u'←', # ACS_LARROW
        0x3e: u'→', # ACS_RARROW
        0x76: u'↓', # ACS_DARROW
        0x5e: u'↑', # ACS_UARROW
        0xb0: u'⊞', # ACS_BOARD
        0x0f: u'⨂', # ACS_LANTERN
        0xdb: u'█', # ACS_BLOCK
        0x9d: u'Ø', # Upper-case slashed zero (157)--using same as empty set
        0xd8: u'Ø', # Empty set (216)
# Note to self:  Why did I bother with these overly descriptive comments?  Ugh
# I've been staring at obscure symbols far too much lately ⨀_⨀
        0xc7: u'Ç', # Latin capital letter C with cedilla
        0xeb: u'ë', # Latin small letter e with diaeresis
        0x99: u'™', # Trademark sign--in case you didn't know!
        0xff: u'ÿ', # Latin small letter y with diaeresis⬅So THATS what that is
        0xa8: u'¨', # Spacing diaeresis - umlaut
        0xec: u'ì', # Latin small letter i with grave... concern.
        0xca: u'Ê', # Latin capital letter E with circumflex
        0x83: u'ƒ', # Latin small letter f with hook
    }
    # I left this in its odd state so I could differentiate between the two
    # in the future.
    if isinstance(e, (UnicodeEncodeError, UnicodeTranslateError)):
        s = [u'%s' % specials[ord(c)] for c in e.object[e.start:e.end]]
        return ''.join(s), e.end
    else:
        s = [u'%s' % specials[ord(c)] for c in e.object[e.start:e.end]]
        return ''.join(s), e.end
codecs.register_error('handle_special', handle_special) 
# Classes
class Multiplex:
[docs]    """
    The Multiplex class takes care of forking a child process and provides
    methods for reading/writing to it.  It also creates an instance of
    tornado.ioloop.IOLoop that listens for events on the spawned terminal
    application and updates self.proc[fd]['term'] with any changes.
    """
    CALLBACK_UPDATE = 1 # Screen update
    CALLBACK_EXIT = 2   # When the underlying program exits
    def __init__(self,
            cmd=None,
            terminal_emulator=None, # Defaults to Gate One's terminal.Terminal
            cps=5000,
            tmpdir="/tmp",
            log_path=None,
            user=None, # Only used by syslog output (to differentiate who's who)
            term_num=None, # Also only for syslog output for the same reason
            syslog=False,
            syslog_facility=syslog.LOG_DAEMON):
        # NOTE: Commented this out because Death apparently moves too swiftly!
        # Elect for automatic child reaping (may Death take them kindly!)
        #signal.signal(signal.SIGCHLD, signal.SIG_IGN)
        self.cmd = cmd
        if not terminal_emulator:
            # Why do this?  So you could use/write your own specialty emulator.
            # Whatever you use it just has to accept 'rows' and 'cols' as
            # keyword arguments in __init__()
            from terminal import Terminal # Dynamic import to cut down on waste
            self.terminal_emulator = Terminal
        else:
            self.terminal_emulator = terminal_emulator
        self.cps = cps # Characters per second where the rate limiter kicks in
        self.tmpdir = tmpdir # Where to store things like pid files
        self.log_path = log_path # Logs of the terminal output wind up here
        self.syslog = syslog # See "if self.syslog:" below
        self.syslog_facility = syslog_facility # Ditto
        self.lock = threading.RLock() # Used by the @synchronized decorator
        self.io_loop = ioloop.IOLoop.instance() # Monitors child for activity
        self.alive = True # Provides a quick way to see if we're still kickin'
        # These three variables are used by the rate limiting function:
        self.ratelimit = time.time()
        self.skip = False
        self.ratelimiter_engaged = False
        # Setup our callbacks
        self.callbacks = { # Defaults do nothing which saves some conditionals
            self.CALLBACK_UPDATE: noop,
            self.CALLBACK_EXIT: noop,
        }
        # Configure syslog logging
        self.user = user
        self.term_num = term_num
        self.syslog_buffer = ''
        if self.syslog: # Dynamic imports again because I'm freaky and frugal
            import syslog
            # Sets up syslog messages to show up like this:
            #   Sep 28 19:45:02 <hostname> gateone: <log message>
            syslog.openlog('gateone', 0, syslog_facility)
    def create(self, rows=24, cols=80, env=None):
[docs]        """
        Creates a new virtual terminal (tty) and executes self.cmd within it.
        Also sets up our read/write callback and attaches them to Tornado's
        IOLoop.
        *cols*
            The number of columns to emulate on the virtual terminal (width)
        *rows*
            The number of rows to emulate (height).
        *env*
            A dictionary of environment variables to set when executing self.cmd.
        """
        pid, fd = pty.fork()
        if pid == 0: # We're inside the child process
            try: # Enumerate our file descriptors
                fd_list = [int(i) for i in os.listdir('/proc/self/fd')]
            except OSError:
                fd_list = xrange(256)
    # Close all file descriptors other than stdin, stdout, and stderr (0, 1, 2)
            for i in [i for i in fd_list if i > 2]:
                try:
                    os.close(i)
                except OSError:
                    pass
            if not env:
                env = {}
            env["COLUMNS"] = str(cols)
            env["LINES"] = str(rows)
            env["TERM"] = "xterm" # TODO: This needs to be configurable on-the-fly
            #env["PATH"] = os.environ['PATH']
            #env["LANG"] = os.environ['LANG']
            p = Popen(self.cmd, env=env, shell=True)
            p.wait()
            # This exit() ensures IOLoop doesn't freak out about a missing fd
            sys.exit(0)
        else: # We're inside this Python script
            fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
            # These two lines set the size of the terminal window:
            s = struct.pack("HHHH", rows, cols, 0, 0)
            fcntl.ioctl(fd, termios.TIOCSWINSZ, s)
            self.fd = fd
            self.pid = pid
            self.term = self.terminal_emulator(rows=rows, cols=cols)
            self.time = time.time()
            # NOTE: io.open has a memory leak in Python 2.6 and below so we only
            #       use it for Python 2.7+.  Why use it at all?  It's faster,
            #       more memory efficient, and not deprecated.
            if sys.version_info[0] == 2 and sys.version_info[1] >= 7:
                self.reader =  io.open(
                    self.fd,
                    'rt',
                    buffering=1024,
                    newline="",
                    encoding='UTF-8', # TODO: Make this configurable
                    closefd=False,
                    errors='handle_special'
                )
            else:
            # For Python <= 2.6
                self.reader = os.fdopen(self.fd, 'r')
            # Tell our IOLoop instance to start watching the child
            self.io_loop.add_handler(
                self.fd, self.proc_read, self.io_loop.READ)
            self.prev_output = [None for a in xrange(rows-1)]
            return fd
    def die(self): 
[docs]        """
        Sets self.alive to False
        NOTE: This is actually important as it allows controlling processes to
        see if the multiplexer is still alive or not (so they don't have to
        enumerate the process table looking for a particular pid).
        """
        self.alive = False
    def resize(self, rows, cols): 
[docs]        """
        Resizes the child process's terminal window to *rows* and *cols*
        """
        self.term.resize(rows, cols)
        s = struct.pack("HHHH", rows, cols, 0, 0)
        fcntl.ioctl(self.fd, termios.TIOCSWINSZ, s)
    def redraw(self): 
[docs]        """
        Tells the running terminal program to redraw the screen by executing
        a window resize event (using its current dimensions) and writing a
        ctrl-l.
        """
        s = struct.pack("HHHH", self.term.rows, self.term.cols, 0, 0)
        fcntl.ioctl(self.fd, termios.TIOCSWINSZ, s)
        self.proc_write(u'\x0c') # ctrl-l
        # WINCH can mess things up quite a bit so I've disabled it.
        # Leaving this here in case it ever becomes configurable (reference)
        #os.kill(self.proc[fd]['pid'], signal.SIGWINCH)
    def proc_kill(self): 
[docs]        """
        Kill the child process associated with the given file descriptor (fd).
        NOTE: If dtach is being used this only kills the dtach process.
        """
        try:
            self.io_loop.remove_handler(self.fd)
        except KeyError:
            print("Error trying to remove fd: %s" % self.fd)
        try:
            os.kill(self.pid, signal.SIGTERM)
            os.wait()
        except (IOError, OSError):
            # Lots of trivial reasons why we could get these
            pass
    def term_write(self, chars): 
[docs]        """
        Writes *chars* to self.term and also takes care of logging to
        *self.log_path* (if set) and/or syslog (if *self.syslog* is True).
        NOTE: This kind of logging doesn't capture user keystrokes.  This is
        intentional as we don't want passwords winding up in the logs.
        """
        self.term.write(chars)
        # Write to the log too (if configured)
        if self.log_path:
            now = int(round(time.time() * 1000))
            # NOTE: I'm using an obscure unicode symbol in order to avoid
            # conflicts.  We need to do our best to ensure that we can
            # differentiate between terminal output and our log format...
            # This should do the trick because it is highly unlikely that
            # someone would be displaying this obscure unicode symbol on an
            # actual terminal unless they were using Gate One to view a
            # Gate One log file in vim or something =)
            # \U000f0f0f == U+F0F0F (Private Use Symbol)
            output = u"%s:%s\U000f0f0f" % (now, chars)
            log = gzip.open(self.log_path, mode='a')
            log.write(output.encode("utf-8"))
            log.close()
        # NOTE: Gate One's log format is special in that it can be used for both
        # playing back recorded sessions *or* generating syslog-like output.
        if self.syslog:
            # Try and keep it as line-line as possible so we don't end up with
            # a log line per character.
            if '\n' in chars:
                for line in chars.splitlines():
                    if self.syslog_buffer:
                        line = self.syslog_buffer + line
                        self.syslog_buffer = ''
                    syslog.syslog("%s %s: %s" % (
                        self.user, self.term_num, line))
            else:
                self.syslog_buffer += chars
    def proc_read(self, fd, event): 
[docs]        """
        Read in the output of the process associated with *fd* and write it to
        self.term.
        This method will also keep an eye on the output rate of the underlying
        terminal application.  If it goes to high (which would gobble up CPU) it
        will engage a rate limiter.  So if someone thinks it would be funny to
        run 'top' with a refresh rate of 0.01 they'll really only be getting
        updates every ~2 seconds (and it won't bog down the server =).
        NOTE: This method is not meant to be called directly...  The IOLoop
        should be the one calling it when it detects an io_loop.READ event.
        """
        self.lock.acquire()
        if event == self.io_loop.READ:
            try:
                updated = self.reader.read(65536)
                #updated = os.read(fd, 65536) # A lot slower than reader, why?
                ratelimit = self.ratelimit
                now = time.time()
                timediff = now - self.time
                rate_timediff = now - ratelimit
                characters = len(updated)
                cps = characters/timediff # Assumes 7 bits per char (ASCII)
                # The conditionals below drop the output of our fd if it's coming
                # in too fast.  Essentially, it is a rate limiter to prevent
                # really noisy/fast output console apps (say, 'top' with a
                # refresh rate of 0.01) from causing this application to
                # gobble up all the system CPU trying to process the input.
                # Think of it like mplayer's "-framedrop" option that keeps
                # your video playing at the proper rate if the CPU runs out of
                # power to process video frames.
                # Only consider dropping if the rate is faster than self.cps:
                if cps > self.cps:
                    # Don't start cutting frames unless this is a constant thing
                    if rate_timediff > 5:
                        # TODO: Have this flash a message on the screen
                        #       indicating the rate limiter has been engaged.
                        self.ratelimiter_engaged = True
                        check = divmod(now - ratelimit, 2)[0]
                        # Update once every other second or so
                        if check % 2 == 0 and not self.skip:
                            self.term_write(updated)
                            self.skip = True
                            self.callbacks[self.CALLBACK_UPDATE]()
                        elif self.skip:
                            self.skip = False
                    else:
                        self.term_write(updated)
                        self.callbacks[self.CALLBACK_UPDATE]()
                    # NOTE: This can result in odd output with too-fast apps
                else:
                    self.term_write(updated)
                if now - ratelimit > 1:
                    # Reset the rate limiter
                    self.ratelimit = time.time()
                self.callbacks[self.CALLBACK_UPDATE]()
                self.time = time.time()
            except KeyError as e:
                # Should just be an exception from handle_special()
                logging.debug("KeyError in proc_read(): %s" % e) # So we know
            except (IOError, OSError) as e:
                logging.error("Got exception in proc_read: %s" % `e`)
                self.die()
                self.proc_kill()
            except Exception as e:
                import traceback
                logging.error("Got BIZARRO exception in proc_read (WTF?): %s" % `e`)
                traceback.print_exc(file=sys.stdout)
                self.die()
                self.proc_kill()
        else: # Child died
            logging.debug("Apparently fd %s just died." % self.fd)
            self.die()
            self.proc_kill()
            self.callbacks[self.CALLBACK_EXIT]()
        self.lock.release()
    def proc_write(self, chars): 
[docs]        """
        Writes *chars* to the terminal process running on *fd*.
        """
        self.lock.acquire()
        try:
            # By creating a new writer with every execution of this function we
            # can avoid the memory leak in earlier versions of Python.  It is
            # slightly slower but, hey, now you have an excuse to upgrade!
            writer = io.open(
                self.fd,
                'wt',
                buffering=1024,
                newline="",
                encoding='UTF-8',
                closefd=False
            )
            writer.write(chars)
            writer.flush()
            #os.write(self.fd, s) # This doesn't leak but it also doesn't support unicode
        except (IOError, OSError):
            self.die()
            self.proc_kill()
        except Exception as e:
            logging.error("proc_write() exception: %s" % e)
        self.lock.release()
    def dumplines(self): 
[docs]        """
        Returns the terminal text lines (a list of lines, to be specific) and
        its scrollback buffer (which is also a list of lines) as a tuple,
        (scrollback, text).  If a line hasn't changed since the last dump then
        it will be replaced with an empty string (in the terminal text lines).
        """
        self.lock.acquire()
        try:
            output = []
            scrollback, html = self.term.dump_html()
            for line1, line2 in izip(self.prev_output, html):
                if line1 != line2:
                    output.append(line2)
                else:
                    output.append('')
            self.prev_output = html
            return (scrollback, output)
        except KeyError:
            return (None, None)
        self.lock.release()