From 5443527a2d53f66a82a6f02a6a5fff00404b1745 Mon Sep 17 00:00:00 2001 From: Akashdeep Dhar Date: Feb 24 2021 08:45:44 +0000 Subject: Merge pull request #9 from t0xic0der/container-attachment-advanced Added console attachment to container on request --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dfb1afb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.8-alpine +ENV PYTHONUNBUFFERED=1 +WORKDIR /supervisor-driver-service +COPY requirements.txt requirements.txt +RUN apk add --no-cache docker gcc musl-dev linux-headers +RUN pip install -r requirements.txt +COPY . . +EXPOSE 8888 +EXPOSE 6969 diff --git a/base/term.py b/base/term.py deleted file mode 100644 index 262663e..0000000 --- a/base/term.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -########################################################################## -* -* Copyright © 2019-2021 Akashdeep Dhar -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program. If not, see . -* -########################################################################## -""" - -import tornado.ioloop -import tornado.web -from terminado import TermSocket, UniqueTermManager - - -def inptloop(termmngr): - loop = tornado.ioloop.IOLoop.instance() - try: - loop.start() - except KeyboardInterrupt: - print("\nShutting down on SIGINT") - finally: - termmngr.shutdown() - loop.close() - - -def mainterm(portqant): - termmngr = UniqueTermManager(shell_command=["sh"]) - handlers = [ - ( - r"/websocket", TermSocket, - { - 'term_manager': termmngr - } - ) - ] - mainobjc = tornado.web.Application(handlers) - print(" * TermSocket started on port " + portqant) - mainobjc.listen(portqant, "0.0.0.0") - inptloop(termmngr) diff --git a/dish/back.py b/dish/back.py index ef1efc0..899504d 100644 --- a/dish/back.py +++ b/dish/back.py @@ -62,6 +62,10 @@ class DockerContainerInformation: """ try: contobjc = self.clinobjc.containers.get(contiden) + try: + imejname = contobjc.image.tags[0] + except: + imejname = "UNAVAILABLE" dispdict = { "short_id": contobjc.short_id, "id": contobjc.id, @@ -71,7 +75,7 @@ class DockerContainerInformation: "ports": contobjc.ports, "status": contobjc.status, "image": { - "name": contobjc.image.tags[0], + "name": imejname, "short_id": contobjc.image.short_id } } @@ -138,9 +142,13 @@ class DockerImageInformation: imejlist = self.clinobjc.images.list(all=True) dispdict = {} for indx in imejlist: + try: + imejname = indx.tags[0] + except: + imejname = "UNAVAILABLE" dispdict[indx.short_id] = { "id": indx.id, - "name": indx.tags[0], + "name": imejname, } return dispdict @@ -150,10 +158,14 @@ class DockerImageInformation: """ try: imejobjc = self.clinobjc.images.get(imejiden) + try: + imejname = imejobjc.tags[0] + except: + imejname = "UNAVAILABLE" dispdict = { "short_id": imejobjc.short_id, "id": imejobjc.id, - "name": imejobjc.tags[0], + "name": imejname, "attrs": imejobjc.attrs, "labels": imejobjc.labels, "tags": imejobjc.tags diff --git a/dish/term.py b/dish/term.py new file mode 100644 index 0000000..c7d8d41 --- /dev/null +++ b/dish/term.py @@ -0,0 +1,100 @@ +""" +########################################################################## +* +* Copyright © 2019-2021 Akashdeep Dhar +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +* +########################################################################## +""" + +from tornado.ioloop import IOLoop +import tornado.web +from terminado import TermSocket, UniqueTermManager +import sys +from hashlib import sha256 + + +class AttachmentEndpoint(tornado.web.RequestHandler): + def set_default_headers(self): + self.set_header("Access-Control-Allow-Origin", "*") + + def get(self): + try: + contiden = self.get_argument("contiden") + comdexec = self.get_argument("comdexec") + self.write(addhandr(contiden, comdexec)) + except Exception as expt: + print("Console attachment failed! - " + str(expt)) + self.set_header("Access-Control-Allow-Origin", "*") + self.write({"retnmesg": "deny"}) + + +termmngr = UniqueTermManager(shell_command=["sh"]) + + +handlers = [ + ( + r"/websocket", TermSocket, + { + "term_manager": termmngr + } + ), + ( + r"/atchcons/", AttachmentEndpoint + ) +] + + +def mainterm(portqant): + try: + global mainobjc + mainobjc = tornado.web.Application(handlers) + print(" * TermSocket started on port " + portqant) + mainobjc.listen(portqant, "0.0.0.0") + IOLoop.instance().start() + except KeyboardInterrupt: + print("\nShutting down on SIGINT") + finally: + sys.exit() + + +def addhandr(contiden, comdexec): + try: + print(" * " + comdexec + " attached to " + contiden) + urlpatrn = sha256((contiden + comdexec).encode()).hexdigest() + comdexec = comdexec.split() + stndexec = ["docker", "exec", "-ti", contiden] + for indx in comdexec: + stndexec.append(indx) + mainobjc.add_handlers( + r".*", # match any host + [ + ( + r"/" + urlpatrn, TermSocket, + { + "term_manager": UniqueTermManager(shell_command=stndexec) + } + ) + ] + ) + return { + "retnmesg": "allow", + "urlpatrn": urlpatrn + } + except Exception as expt: + print(" * Failed to attach terminal" + "\n" + str(expt)) + return { + "retnmesg": "deny" + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6520b55 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.8" +services: + endpoint: + build: . + command: python3 falc.py -p 8888 -s 6969 + ports: + - "8888:8888" + - "6969:6969" + volumes: + - ".:/supervisor-driver-service" + - "/var/run/docker.sock:/var/run/docker.sock" diff --git a/falc.py b/falc.py index ce49bee..3dd7eb7 100644 --- a/falc.py +++ b/falc.py @@ -30,7 +30,7 @@ from base.frnt import ( ProcessHandlingEndpoint, StatisticalEndpoint, ) -from base.term import mainterm +from dish.term import mainterm from dish.frnt import ( ContainerInformationEndpoint, ImageInformationEndpoint, diff --git a/requirements.txt b/requirements.txt index 33f74ff..6fcd6c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,6 @@ psutil==5.8.0 ptyprocess==0.7.0 requests==2.25.1 six==1.15.0 -terminado==0.9.2 tornado==6.1 urllib3==1.26.3 websocket-client==0.57.0 diff --git a/terminado/__init__.py b/terminado/__init__.py new file mode 100644 index 0000000..48ec12a --- /dev/null +++ b/terminado/__init__.py @@ -0,0 +1,15 @@ +"""Terminals served to xterm.js using Tornado websockets""" + +# Copyright (c) Jupyter Development Team +# Copyright (c) 2014, Ramalingam Saravanan +# Distributed under the terms of the Simplified BSD License. + +from .websocket import TermSocket +from .management import (TermManagerBase, SingleTermManager, + UniqueTermManager, NamedTermManager) + +import logging +# Prevent a warning about no attached handlers in Python 2 +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +__version__ = '0.9.2' diff --git a/terminado/_static/terminado.js b/terminado/_static/terminado.js new file mode 100644 index 0000000..836e354 --- /dev/null +++ b/terminado/_static/terminado.js @@ -0,0 +1,39 @@ +// Copyright (c) Jupyter Development Team +// Copyright (c) 2014, Ramalingam Saravanan +// Distributed under the terms of the Simplified BSD License. + +function make_terminal(element, size, ws_url) { + var ws = new WebSocket(ws_url); + var term = new Terminal({ + cols: size.cols, + rows: size.rows, + screenKeys: true, + useStyle: true + }); + ws.onopen = function(event) { + ws.send(JSON.stringify(["set_size", size.rows, size.cols, + window.innerHeight, window.innerWidth])); + term.on('data', function(data) { + ws.send(JSON.stringify(['stdin', data])); + }); + + term.on('title', function(title) { + document.title = title; + }); + + term.open(element); + + ws.onmessage = function(event) { + json_msg = JSON.parse(event.data); + switch(json_msg[0]) { + case "stdout": + term.write(json_msg[1]); + break; + case "disconnect": + term.write("\r\n\r\n[Finished... Terminado]\r\n"); + break; + } + }; + }; + return {socket: ws, term: term}; +} diff --git a/terminado/management.py b/terminado/management.py new file mode 100644 index 0000000..be811fe --- /dev/null +++ b/terminado/management.py @@ -0,0 +1,362 @@ +"""Terminal management for exposing terminals to a web interface using Tornado. +""" +# Copyright (c) Jupyter Development Team +# Copyright (c) 2014, Ramalingam Saravanan +# Distributed under the terms of the Simplified BSD License. + +from __future__ import absolute_import, print_function + +import sys +if sys.version_info[0] < 3: + byte_code = ord +else: + def byte_code(x): return x + unicode = str + +from collections import deque +import itertools +import logging +import os +import signal + +try: + from ptyprocess import PtyProcessUnicode +except ImportError: + from winpty import PtyProcess as PtyProcessUnicode + +from tornado import gen +from tornado.ioloop import IOLoop + +ENV_PREFIX = "PYXTERM_" # Environment variable prefix + +DEFAULT_TERM_TYPE = "xterm" + + +class PtyWithClients(object): + def __init__(self, argv, env=[], cwd=None): + self.clients = [] + # If you start the process and then construct this object from it, + # output generated by the process prior to the object's creation + # is lost. Hence the change from 0.8.3. + # Buffer output until a client connects; then let the client + # drain the buffer. + # We keep the same read_buffer as before + self.read_buffer = deque([], maxlen=10) + self.preopen_buffer = deque([]) + self.ptyproc = PtyProcessUnicode.spawn(argv, env=env, cwd=cwd) + + def resize_to_smallest(self): + """Set the terminal size to that of the smallest client dimensions. + + A terminal not using the full space available is much nicer than a + terminal trying to use more than the available space, so we keep it + sized to the smallest client. + """ + minrows = mincols = 10001 + for client in self.clients: + rows, cols = client.size + if rows is not None and rows < minrows: + minrows = rows + if cols is not None and cols < mincols: + mincols = cols + + if minrows == 10001 or mincols == 10001: + return + + rows, cols = self.ptyproc.getwinsize() + if (rows, cols) != (minrows, mincols): + self.ptyproc.setwinsize(minrows, mincols) + + def kill(self, sig=signal.SIGTERM): + """Send a signal to the process in the pty""" + self.ptyproc.kill(sig) + + def killpg(self, sig=signal.SIGTERM): + """Send a signal to the process group of the process in the pty""" + if os.name == 'nt': + return self.ptyproc.kill(sig) + pgid = os.getpgid(self.ptyproc.pid) + os.killpg(pgid, sig) + + @gen.coroutine + def terminate(self, force=False): + '''This forces a child process to terminate. It starts nicely with + SIGHUP and SIGINT. If "force" is True then moves onto SIGKILL. This + returns True if the child was terminated. This returns False if the + child could not be terminated. ''' + if os.name == 'nt': + signals = [signal.SIGINT, signal.SIGTERM] + else: + signals = [signal.SIGHUP, signal.SIGCONT, signal.SIGINT, + signal.SIGTERM] + + loop = IOLoop.current() + def sleep(): return gen.sleep(self.ptyproc.delayafterterminate) + + if not self.ptyproc.isalive(): + raise gen.Return(True) + try: + for sig in signals: + self.kill(sig) + yield sleep() + if not self.ptyproc.isalive(): + raise gen.Return(True) + if force: + self.kill(signal.SIGKILL) + yield sleep() + if not self.ptyproc.isalive(): + raise gen.Return(True) + else: + raise gen.Return(False) + raise gen.Return(False) + except OSError: + # I think there are kernel timing issues that sometimes cause + # this to happen. I think isalive() reports True, but the + # process is dead to the kernel. + # Make one last attempt to see if the kernel is up to date. + yield sleep() + if not self.ptyproc.isalive(): + raise gen.Return(True) + else: + raise gen.Return(False) + + +def _update_removing(target, changes): + """Like dict.update(), but remove keys where the value is None. + """ + for k, v in changes.items(): + if v is None: + target.pop(k, None) + else: + target[k] = v + + +class TermManagerBase(object): + """Base class for a terminal manager.""" + + def __init__(self, shell_command, server_url="", term_settings={}, + extra_env=None, ioloop=None): + self.shell_command = shell_command + self.server_url = server_url + self.term_settings = term_settings + self.extra_env = extra_env + self.log = logging.getLogger(__name__) + + self.ptys_by_fd = {} + + if ioloop is not None: + self.ioloop = ioloop + else: + import tornado.ioloop + self.ioloop = tornado.ioloop.IOLoop.instance() + + def make_term_env(self, height=25, width=80, winheight=0, winwidth=0, **kwargs): + """Build the environment variables for the process in the terminal.""" + env = os.environ.copy() + env["TERM"] = self.term_settings.get("type", DEFAULT_TERM_TYPE) + dimensions = "%dx%d" % (width, height) + if winwidth and winheight: + dimensions += ";%dx%d" % (winwidth, winheight) + env[ENV_PREFIX+"DIMENSIONS"] = dimensions + env["COLUMNS"] = str(width) + env["LINES"] = str(height) + + if self.server_url: + env[ENV_PREFIX+"URL"] = self.server_url + + if self.extra_env: + _update_removing(env, self.extra_env) + + return env + + def new_terminal(self, **kwargs): + """Make a new terminal, return a :class:`PtyWithClients` instance.""" + options = self.term_settings.copy() + options['shell_command'] = self.shell_command + options.update(kwargs) + argv = options['shell_command'] + env = self.make_term_env(**options) + cwd = options.get('cwd', None) + return PtyWithClients(argv, env, cwd) + + def start_reading(self, ptywclients): + """Connect a terminal to the tornado event loop to read data from it.""" + fd = ptywclients.ptyproc.fd + self.ptys_by_fd[fd] = ptywclients + self.ioloop.add_handler(fd, self.pty_read, self.ioloop.READ) + + def on_eof(self, ptywclients): + """Called when the pty has closed. + """ + # Stop trying to read from that terminal + fd = ptywclients.ptyproc.fd + self.log.info("EOF on FD %d; stopping reading", fd) + del self.ptys_by_fd[fd] + self.ioloop.remove_handler(fd) + + # This closes the fd, and should result in the process being reaped. + ptywclients.ptyproc.close() + + def pty_read(self, fd, events=None): + """Called by the event loop when there is pty data ready to read.""" + ptywclients = self.ptys_by_fd[fd] + try: + s = ptywclients.ptyproc.read(65536) + client_list = ptywclients.clients + ptywclients.read_buffer.append(s) + if not client_list: + # No one to consume our output: buffer it. + ptywclients.preopen_buffer.append(s) + return + for client in ptywclients.clients: + client.on_pty_read(s) + except EOFError: + self.on_eof(ptywclients) + for client in ptywclients.clients: + client.on_pty_died() + + def get_terminal(self, url_component=None): + """Override in a subclass to give a terminal to a new websocket connection + + The :class:`TermSocket` handler works with zero or one URL components + (capturing groups in the URL spec regex). If it receives one, it is + passed as the ``url_component`` parameter; otherwise, this is None. + """ + raise NotImplementedError + + def client_disconnected(self, websocket): + """Override this to e.g. kill terminals on client disconnection. + """ + pass + + @gen.coroutine + def shutdown(self): + yield self.kill_all() + + @gen.coroutine + def kill_all(self): + futures = [] + for term in self.ptys_by_fd.values(): + futures.append(term.terminate(force=True)) + # wait for futures to finish + for f in futures: + yield f + + +class SingleTermManager(TermManagerBase): + """All connections to the websocket share a common terminal.""" + + def __init__(self, **kwargs): + super(SingleTermManager, self).__init__(**kwargs) + self.terminal = None + + def get_terminal(self, url_component=None): + if self.terminal is None: + self.terminal = self.new_terminal() + self.start_reading(self.terminal) + return self.terminal + + @gen.coroutine + def kill_all(self): + yield super(SingleTermManager, self).kill_all() + self.terminal = None + + +class MaxTerminalsReached(Exception): + def __init__(self, max_terminals): + self.max_terminals = max_terminals + + def __str__(self): + return "Cannot create more than %d terminals" % self.max_terminals + + +class UniqueTermManager(TermManagerBase): + """Give each websocket a unique terminal to use.""" + + def __init__(self, max_terminals=None, **kwargs): + super(UniqueTermManager, self).__init__(**kwargs) + self.max_terminals = max_terminals + + def get_terminal(self, url_component=None): + if self.max_terminals and len(self.ptys_by_fd) >= self.max_terminals: + raise MaxTerminalsReached(self.max_terminals) + + term = self.new_terminal() + self.start_reading(term) + return term + + def client_disconnected(self, websocket): + """Send terminal SIGHUP when client disconnects.""" + self.log.info("Websocket closed, sending SIGHUP to terminal.") + if websocket.terminal: + if os.name == 'nt': + websocket.terminal.kill() + # Immediately call the pty reader to process + # the eof and free up space + self.pty_read(websocket.terminal.ptyproc.fd) + return + websocket.terminal.killpg(signal.SIGHUP) + + +class NamedTermManager(TermManagerBase): + """Share terminals between websockets connected to the same endpoint. + """ + + def __init__(self, max_terminals=None, **kwargs): + super(NamedTermManager, self).__init__(**kwargs) + self.max_terminals = max_terminals + self.terminals = {} + + def get_terminal(self, term_name): + assert term_name is not None + + if term_name in self.terminals: + return self.terminals[term_name] + + if self.max_terminals and len(self.terminals) >= self.max_terminals: + raise MaxTerminalsReached(self.max_terminals) + + # Create new terminal + self.log.info("New terminal with specified name: %s", term_name) + term = self.new_terminal() + term.term_name = term_name + self.terminals[term_name] = term + self.start_reading(term) + return term + + name_template = "%d" + + def _next_available_name(self): + for n in itertools.count(start=1): + name = self.name_template % n + if name not in self.terminals: + return name + + def new_named_terminal(self, **kwargs): + name = self._next_available_name() + term = self.new_terminal(**kwargs) + self.log.info("New terminal with automatic name: %s", name) + term.term_name = name + self.terminals[name] = term + self.start_reading(term) + return name, term + + def kill(self, name, sig=signal.SIGTERM): + term = self.terminals[name] + term.kill(sig) # This should lead to an EOF + + @gen.coroutine + def terminate(self, name, force=False): + term = self.terminals[name] + yield term.terminate(force=force) + + def on_eof(self, ptywclients): + super(NamedTermManager, self).on_eof(ptywclients) + name = ptywclients.term_name + self.log.info("Terminal %s closed", name) + self.terminals.pop(name, None) + + @gen.coroutine + def kill_all(self): + yield super(NamedTermManager, self).kill_all() + self.terminals = {} diff --git a/terminado/tests/__init__.py b/terminado/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/terminado/tests/__init__.py diff --git a/terminado/tests/basic_test.py b/terminado/tests/basic_test.py new file mode 100644 index 0000000..e748e70 --- /dev/null +++ b/terminado/tests/basic_test.py @@ -0,0 +1,259 @@ +# basic_tests.py -- Basic unit tests for Terminado + +# Copyright (c) Jupyter Development Team +# Copyright (c) 2014, Ramalingam Saravanan +# Distributed under the terms of the Simplified BSD License. + +from __future__ import absolute_import, print_function + +import unittest +from terminado import * +import tornado +import tornado.httpserver +from tornado.httpclient import HTTPError +from tornado.ioloop import IOLoop +import tornado.testing +import datetime +import logging +import json +import os +import re +import signal + +# We must set the policy for python >=3.8, see https://www.tornadoweb.org/en/stable/#installation +# Snippet from https://github.com/tornadoweb/tornado/issues/2608#issuecomment-619524992 +import sys, asyncio +if sys.version_info[0]==3 and sys.version_info[1] >= 8 and sys.platform.startswith('win'): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +# +# The timeout we use to assume no more messages are coming +# from the sever. +# +DONE_TIMEOUT = 1.0 +os.environ['ASYNC_TEST_TIMEOUT'] = "20" # Global test case timeout + +MAX_TERMS = 3 # Testing thresholds + +class TestTermClient(object): + """Test connection to a terminal manager""" + def __init__(self, websocket): + self.ws = websocket + self.pending_read = None + + @tornado.gen.coroutine + def read_msg(self): + + # Because the Tornado Websocket client has no way to cancel + # a pending read, we have to keep track of them... + if self.pending_read is None: + self.pending_read = self.ws.read_message() + + response = yield self.pending_read + self.pending_read = None + if response: + response = json.loads(response) + raise tornado.gen.Return(response) + + @tornado.gen.coroutine + def read_all_msg(self, timeout=DONE_TIMEOUT): + """Read messages until read times out""" + msglist = [] + delta = datetime.timedelta(seconds=timeout) + while True: + try: + mf = self.read_msg() + msg = yield tornado.gen.with_timeout(delta, mf) + except tornado.gen.TimeoutError: + raise tornado.gen.Return(msglist) + + msglist.append(msg) + + def write_msg(self, msg): + self.ws.write_message(json.dumps(msg)) + + @tornado.gen.coroutine + def read_stdout(self, timeout=DONE_TIMEOUT): + """Read standard output until timeout read reached, + return stdout and any non-stdout msgs received.""" + msglist = yield self.read_all_msg(timeout) + stdout = "".join([msg[1] for msg in msglist if msg[0] == 'stdout']) + othermsg = [msg for msg in msglist if msg[0] != 'stdout'] + raise tornado.gen.Return((stdout, othermsg)) + + def write_stdin(self, data): + """Write to terminal stdin""" + self.write_msg(['stdin', data]) + + @tornado.gen.coroutine + def get_pid(self): + """Get process ID of terminal shell process""" + yield self.read_stdout() # Clear out any pending + self.write_stdin("echo $$\r") + (stdout, extra) = yield self.read_stdout() + if os.name == 'nt': + match = re.search(r'echo \$\$\x1b\[0K\r\n(\d+)', stdout) + pid = int(match.groups()[0]) + else: + pid = int(stdout.split('\n')[1]) + raise tornado.gen.Return(pid) + + def close(self): + self.ws.close() + +class TermTestCase(tornado.testing.AsyncHTTPTestCase): + + # Factory for TestTermClient, because it has to be a Tornado co-routine. + # See: https://github.com/tornadoweb/tornado/issues/1161 + @tornado.gen.coroutine + def get_term_client(self, path): + port = self.get_http_port() + url = 'ws://127.0.0.1:%d%s' % (port, path) + request = tornado.httpclient.HTTPRequest(url, + headers={'Origin' : 'http://127.0.0.1:%d' % port}) + + ws = yield tornado.websocket.websocket_connect(request) + raise tornado.gen.Return(TestTermClient(ws)) + + @tornado.gen.coroutine + def get_term_clients(self, paths): + tms = yield [self.get_term_client(path) for path in paths] + raise tornado.gen.Return(tms) + + @tornado.gen.coroutine + def get_pids(self, tm_list): + pids = [] + for tm in tm_list: # Must be sequential, in case terms are shared + pid = yield tm.get_pid() + pids.append(pid) + + raise tornado.gen.Return(pids) + + def tearDown(self): + self.named_tm.kill_all() + self.single_tm.kill_all() + self.unique_tm.kill_all() + super().tearDown() + + def get_app(self): + self.named_tm = NamedTermManager(shell_command=['bash'], + max_terminals=MAX_TERMS, + ioloop=self.io_loop) + self.single_tm = SingleTermManager(shell_command=['bash'], + ioloop=self.io_loop) + self.unique_tm = UniqueTermManager(shell_command=['bash'], + max_terminals=MAX_TERMS, + ioloop=self.io_loop) + + named_tm = self.named_tm + class NewTerminalHandler(tornado.web.RequestHandler): + """Create a new named terminal, return redirect""" + def get(self): + name, terminal = named_tm.new_named_terminal() + self.redirect("/named/" + name, permanent=False) + + return tornado.web.Application([ + (r"/new", NewTerminalHandler), + (r"/named/(\w+)", TermSocket, {'term_manager': self.named_tm}), + (r"/single", TermSocket, {'term_manager': self.single_tm}), + (r"/unique", TermSocket, {'term_manager': self.unique_tm}) + ], debug=True) + + test_urls = ('/named/term1', '/unique', '/single') + +class CommonTests(TermTestCase): + @tornado.testing.gen_test + def test_basic(self): + for url in self.test_urls: + tm = yield self.get_term_client(url) + response = yield tm.read_msg() + self.assertEqual(response, ['setup', {}]) + + # Check for initial shell prompt + response = yield tm.read_msg() + self.assertEqual(response[0], 'stdout') + self.assertGreater(len(response[1]), 0) + tm.close() + + @tornado.testing.gen_test + def test_basic_command(self): + for url in self.test_urls: + tm = yield self.get_term_client(url) + yield tm.read_all_msg() + tm.write_stdin("whoami\n") + (stdout, other) = yield tm.read_stdout() + if os.name == 'nt': + assert 'whoami' in stdout + else: + assert stdout.startswith('who') + assert other == [] + tm.close() + +class NamedTermTests(TermTestCase): + def test_new(self): + response = self.fetch("/new", follow_redirects=False) + self.assertEqual(response.code, 302) + url = response.headers["Location"] + + # Check that the new terminal was created + name = url.split('/')[2] + self.assertIn(name, self.named_tm.terminals) + + @tornado.testing.gen_test + def test_namespace(self): + names = ["/named/1"]*2 + ["/named/2"]*2 + tms = yield self.get_term_clients(names) + pids = yield self.get_pids(tms) + + self.assertEqual(pids[0], pids[1]) + self.assertEqual(pids[2], pids[3]) + self.assertNotEqual(pids[0], pids[3]) + + @tornado.testing.gen_test + def test_max_terminals(self): + urls = ["/named/%d" % i for i in range(MAX_TERMS+1)] + tms = yield self.get_term_clients(urls[:MAX_TERMS]) + pids = yield self.get_pids(tms) + + # MAX_TERMS+1 should fail + tm = yield self.get_term_client(urls[MAX_TERMS]) + msg = yield tm.read_msg() + self.assertEqual(msg, None) # Connection closed + +class SingleTermTests(TermTestCase): + @tornado.testing.gen_test + def test_single_process(self): + tms = yield self.get_term_clients(["/single", "/single"]) + pids = yield self.get_pids(tms) + self.assertEqual(pids[0], pids[1]) + +class UniqueTermTests(TermTestCase): + @tornado.testing.gen_test + def test_unique_processes(self): + tms = yield self.get_term_clients(["/unique", "/unique"]) + pids = yield self.get_pids(tms) + self.assertNotEqual(pids[0], pids[1]) + + @tornado.testing.gen_test + def test_max_terminals(self): + tms = yield self.get_term_clients(['/unique'] * MAX_TERMS) + pids = yield self.get_pids(tms) + self.assertEqual(len(set(pids)), MAX_TERMS) # All PIDs unique + + # MAX_TERMS+1 should fail + tm = yield self.get_term_client("/unique") + msg = yield tm.read_msg() + self.assertEqual(msg, None) # Connection closed + + # Close one + tms[0].close() + msg = yield tms[0].read_msg() # Closed + self.assertEquals(msg, None) + + # Should be able to open back up to MAX_TERMS + tm = yield self.get_term_client("/unique") + msg = yield tm.read_msg() + self.assertEquals(msg[0], 'setup') + +if __name__ == '__main__': + unittest.main() diff --git a/terminado/uimod_embed.js b/terminado/uimod_embed.js new file mode 100644 index 0000000..c517e13 --- /dev/null +++ b/terminado/uimod_embed.js @@ -0,0 +1,16 @@ +// Copyright (c) Jupyter Development Team +// Copyright (c) 2014, Ramalingam Saravanan +// Distributed under the terms of the Simplified BSD License. + +window.addEventListener('load', function () { + var containers = document.getElementsByClassName('terminado-container') + var container, rows, cols, protocol, ws_url; + for (var i = 0; i < containers.length; i++) { + container = containers[i]; + rows = parseInt(container.dataset.rows); + cols = parseInt(container.dataset.cols); + protocol = (window.location.protocol.indexOf("https") === 0) ? "wss" : "ws"; + ws_url = protocol+"://"+window.location.host+ container.dataset.wsUrl; + make_terminal(container, {rows: rows, cols: cols}, ws_url); + } +}, false); diff --git a/terminado/uimodule.py b/terminado/uimodule.py new file mode 100644 index 0000000..584259c --- /dev/null +++ b/terminado/uimodule.py @@ -0,0 +1,27 @@ +"""A Tornado UI module for a terminal backed by terminado. + +See the Tornado docs for information on UI modules: +http://www.tornadoweb.org/en/stable/guide/templates.html#ui-modules +""" +# Copyright (c) Jupyter Development Team +# Copyright (c) 2014, Ramalingam Saravanan +# Distributed under the terms of the Simplified BSD License. + +import os.path +import tornado.web + +class Terminal(tornado.web.UIModule): + def render(self, ws_url, cols=80, rows=25): + return ('
').format( + ws_url=ws_url, rows=rows, cols=cols) + + def javascript_files(self): + # TODO: Can we calculate these dynamically? + return ['/xstatic/termjs/term.js', '/static/terminado.js'] + + def embedded_javascript(self): + file = os.path.join(os.path.dirname(__file__), 'uimod_embed.js') + with open(file) as f: + return f.read() diff --git a/terminado/websocket.py b/terminado/websocket.py new file mode 100644 index 0000000..8330d5f --- /dev/null +++ b/terminado/websocket.py @@ -0,0 +1,115 @@ +"""Tornado websocket handler to serve a terminal interface. +""" +# Copyright (c) Jupyter Development Team +# Copyright (c) 2014, Ramalingam Saravanan +# Distributed under the terms of the Simplified BSD License. + +from __future__ import absolute_import, print_function + +# Python3-friendly imports +try: + from urllib.parse import urlparse +except ImportError: + from urlparse import urlparse + +import json +import logging + +import tornado.web +import tornado.websocket + + +def _cast_unicode(s): + if isinstance(s, bytes): + return s.decode('utf-8') + return s + + +class TermSocket(tornado.websocket.WebSocketHandler): + """Handler for a terminal websocket""" + + def initialize(self, term_manager): + self.term_manager = term_manager + self.term_name = "" + self.size = (None, None) + self.terminal = None + + self._logger = logging.getLogger(__name__) + + def check_origin(self, origin: str): + return True + + def origin_check(self, origin=None): + """Deprecated: backward-compat for terminado <= 0.5.""" + return self.check_origin(origin or self.request.headers.get('Origin')) + + def open(self, url_component=None): + """Websocket connection opened. + + Call our terminal manager to get a terminal, and connect to it as a + client. + """ + # Jupyter has a mixin to ping websockets and keep connections through + # proxies alive. Call super() to allow that to set up: + super(TermSocket, self).open(url_component) + + self._logger.info("TermSocket.open: %s", url_component) + + url_component = _cast_unicode(url_component) + self.term_name = url_component or 'tty' + self.terminal = self.term_manager.get_terminal(url_component) + self.terminal.clients.append(self) + self.send_json_message(["setup", {}]) + self._logger.info("TermSocket.open: Opened %s", self.term_name) + # Now drain the preopen buffer, if it exists. + buffered = "" + while True: + if not self.terminal.preopen_buffer: + break + s = self.terminal.preopen_buffer.popleft() + buffered += s + if buffered: + self.on_pty_read(buffered) + + def on_pty_read(self, text): + """Data read from pty; send to frontend""" + self.send_json_message(['stdout', text]) + + def send_json_message(self, content): + json_msg = json.dumps(content) + self.write_message(json_msg) + + def on_message(self, message): + """Handle incoming websocket message + + We send JSON arrays, where the first element is a string indicating + what kind of message this is. Data associated with the message follows. + """ + ##logging.info("TermSocket.on_message: %s - (%s) %s", self.term_name, type(message), len(message) if isinstance(message, bytes) else message[:250]) + command = json.loads(message) + msg_type = command[0] + + if msg_type == "stdin": + self.terminal.ptyproc.write(command[1]) + elif msg_type == "set_size": + self.size = command[1:3] + self.terminal.resize_to_smallest() + + def on_close(self): + """Handle websocket closing. + + Disconnect from our terminal, and tell the terminal manager we're + disconnecting. + """ + self._logger.info("Websocket closed") + if self.terminal: + self.terminal.clients.remove(self) + self.terminal.resize_to_smallest() + self.term_manager.client_disconnected(self) + + def on_pty_died(self): + """Terminal closed: tell the frontend, and close the socket. + """ + self.send_json_message(['disconnect', 1]) + self.close() + self.terminal = None