├── idp ├── __init__.py ├── Json.py └── IPA.py ├── test-requirements.txt ├── requirements.txt ├── aker_logo.png ├── aker.ini ├── .travis.yml ├── tox.ini ├── pyte ├── compat.py ├── __main__.py ├── __init__.py ├── control.py ├── modes.py ├── graphics.py ├── escape.py ├── charsets.py ├── streams.py └── screens.py ├── IdPFactory.py ├── hosts.json ├── .gitignore ├── popup.py ├── session.py ├── akerctl.py ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── README.md ├── aker.py ├── SSHClient.py ├── snoop.py ├── hosts.py ├── tui.py └── LICENSE.txt /idp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | configparser 2 | urwid 3 | paramiko 4 | redis 5 | wcwidth 6 | -------------------------------------------------------------------------------- /aker_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aker-gateway/Aker/HEAD/aker_logo.png -------------------------------------------------------------------------------- /aker.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | log_level = INFO 3 | ssh_port = 22 4 | 5 | # Identity Provider to determine the list of available hosts 6 | # options shipped are IPA, Json. Default is IPA 7 | idp = IPA 8 | hosts_file = /etc/aker/hosts.json 9 | 10 | # FreeIPA hostgroup name contatining Aker gateways 11 | # to be excluded from hosts presented to user 12 | gateway_group = gateways 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | 5 | notifications: 6 | email: false 7 | 8 | before_install: 9 | - pip install pep8 10 | - pip install misspellings 11 | - pip install nose 12 | 13 | script: 14 | # Run pep8 on all .py files in all subfolders 15 | # (I ignore "E402: module level import not at top of file" 16 | # because of use case sys.path.append('..'); import ) 17 | - find . -name \*.py -exec pep8 --ignore=E402,E501 {} + 18 | - find . -name '*.py' | misspellings -f - 19 | - nosetests 20 | 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 2.0 3 | envlist = py27,pep8 4 | skipsdist = True 5 | 6 | [testenv] 7 | passenv = CI TRAVIS TRAVIS_* 8 | deps = -r{toxinidir}/requirements.txt 9 | -r{toxinidir}/test-requirements.txt 10 | commands = 11 | /usr/bin/find . -type f -name "*.pyc" -delete 12 | nosetests \ 13 | [] 14 | [testenv:pep8] 15 | commands = flake8 16 | 17 | [testenv:venv] 18 | commands = {posargs} 19 | 20 | [testenv:cover] 21 | commands = 22 | coverage report 23 | 24 | [flake8] 25 | show-source = True 26 | exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build 27 | 28 | -------------------------------------------------------------------------------- /pyte/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.compat 4 | ~~~~~~~~~~~ 5 | 6 | Python version specific compatibility fixes. 7 | 8 | :copyright: (c) 2015-2016 by pyte authors and contributors, 9 | see AUTHORS for details. 10 | :license: LGPL, see LICENSE for more details. 11 | """ 12 | 13 | import sys 14 | 15 | if sys.version_info[0] == 2: 16 | from future_builtins import map 17 | 18 | range = xrange 19 | str = unicode 20 | chr = unichr 21 | 22 | from functools import partial 23 | iter_bytes = partial(map, ord) 24 | else: 25 | from builtins import map, range, str, chr 26 | iter_bytes = iter 27 | -------------------------------------------------------------------------------- /pyte/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte 4 | ~~~~ 5 | 6 | Command-line tool for "disassembling" escape and CSI sequences:: 7 | 8 | $ echo -e "\e[Jfoo" | python -m pyte 9 | ERASE_IN_DISPLAY 0 10 | DRAW f 11 | DRAW o 12 | DRAW o 13 | LINEFEED 14 | 15 | $ python -m pyte foo 16 | DRAW f 17 | DRAW o 18 | DRAW o 19 | 20 | :copyright: (c) 2011-2012 by Selectel. 21 | :copyright: (c) 2012-2016 by pyte authors and contributors, 22 | see AUTHORS for details. 23 | :license: LGPL, see LICENSE for more details. 24 | """ 25 | 26 | if __name__ == "__main__": 27 | import sys 28 | import pyte 29 | 30 | if len(sys.argv) == 1: 31 | pyte.dis(sys.stdin.read()) 32 | else: 33 | pyte.dis("".join(sys.argv[1:])) 34 | -------------------------------------------------------------------------------- /IdPFactory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2017 Ahmed Nazmy 5 | # 6 | 7 | # Meta 8 | __license__ = "AGPLv3" 9 | __author__ = 'Ahmed Nazmy ' 10 | 11 | 12 | import logging 13 | import importlib 14 | import sys 15 | import os 16 | 17 | 18 | class IdPFactory(object): 19 | 20 | @staticmethod 21 | def getIdP(choice): 22 | 23 | logging.info( 24 | "IdPFactory: trying dynamic loading of module : {0} ".format(choice)) 25 | # load module from subdir idp 26 | idp = "idp." + choice 27 | try: 28 | idp_module = importlib.import_module(idp) 29 | idp_class = getattr(idp_module, choice) 30 | except Exception as e: 31 | logging.error( 32 | "IdPFactory: error loading module : {0}".format( 33 | e.message)) 34 | 35 | return idp_class 36 | 37 | 38 | class IdP(object): 39 | ''' 40 | Base class to implement shared functionality 41 | This should enable different identity providers 42 | ''' 43 | 44 | def __init__(self, username, gateway_hostgroup): 45 | self._all_ssh_hosts = {} 46 | self._allowed_ssh_hosts = {} 47 | self.user = username 48 | self.gateway_hostgroup = gateway_hostgroup 49 | 50 | def list_allowed(self): 51 | pass 52 | 53 | def _load_all_hosts(self): 54 | pass 55 | -------------------------------------------------------------------------------- /hosts.json: -------------------------------------------------------------------------------- 1 | { 2 | "usergroups": [ 3 | "lnxadmins", 4 | "dbadmins" 5 | ], 6 | "users": [{ 7 | "username": "anazmy", 8 | "keyfile": "~/.ssh/id_rsa", 9 | "usergroups": ["lnxadmins"] 10 | }, 11 | { 12 | "username": "jsmith", 13 | "keyfile": "~/.ssh/id_rsa", 14 | "usergroups": ["dbadmins"] 15 | } 16 | ], 17 | "hosts": [{ 18 | "name": "web1.ipa.example", 19 | "hostname": "web1.ipa.example", 20 | "ssh_port": "22", 21 | "key": "~/.ssh/id_rsa", 22 | "usergroups": [ 23 | "lnxadmins" 24 | ], 25 | "hostgroups": [ 26 | "linuxservers" 27 | ] 28 | }, 29 | { 30 | "name": "web2.ipa.example", 31 | "hostname": "web2.ipa.example", 32 | "ssh_port": "22", 33 | "key": "~/.ssh/id_rsa", 34 | "usergroups": [ 35 | "lnxadmins" 36 | ], 37 | "hostgroups": [ 38 | "linuxservers" 39 | ] 40 | }, 41 | { 42 | "name": "db1.ipa.example", 43 | "hostname": "db1.ipa.example", 44 | "ssh_port": "22", 45 | "key": "~/.ssh/id_rsa", 46 | "usergroups": [ 47 | "lnxadmins", 48 | "dbadmins" 49 | ], 50 | "hostgroups": [ 51 | "linuxservers", 52 | "dbservers" 53 | ] 54 | }, 55 | { 56 | "name": "db2.ipa.example", 57 | "hostname": "db2.ipa.example", 58 | "ssh_port": "22", 59 | "key": "~/.ssh/id_rsa", 60 | "usergroups": [ 61 | "lnxadmins", 62 | "dbadmins" 63 | ], 64 | "hostgroups": [ 65 | "linuxservers", 66 | "dbservers" 67 | ] 68 | } 69 | ] 70 | } 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | local/ 85 | include/ 86 | bin/ 87 | 88 | # sublime 89 | *.sublime-* 90 | 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | .idea 99 | 100 | -------------------------------------------------------------------------------- /popup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2017 Ahmed Nazmy 5 | # 6 | 7 | # Meta 8 | 9 | import urwid 10 | import logging 11 | 12 | 13 | class SimplePopupDialog(urwid.WidgetWrap): 14 | """ 15 | A dialog that appears with nothing but a close button 16 | """ 17 | signals = ['popup_close'] 18 | 19 | def __init__(self, message): 20 | close_button = urwid.Button(u"OK") 21 | urwid.connect_signal(close_button, 'click', 22 | lambda button: self._emit("popup_close")) 23 | pile = urwid.Pile([urwid.Text(message, align='center'), urwid.Padding( 24 | close_button, align='center', left=13, right=13)]) 25 | fill = urwid.Filler(pile) 26 | self.__super.__init__(urwid.AttrMap(urwid.LineBox(fill), 'popup')) 27 | 28 | 29 | class SimplePopupLauncher(urwid.PopUpLauncher): 30 | def __init__(self): 31 | self.__super.__init__(urwid.Text(u"", align='right')) 32 | self._message = None 33 | 34 | @property 35 | def message(self): 36 | return self._message 37 | 38 | @message.setter 39 | def message(self, value): 40 | self._message = value 41 | 42 | def create_pop_up(self): 43 | pop_up = SimplePopupDialog(self._message) 44 | urwid.connect_signal(pop_up, 'popup_close', 45 | lambda button: self.close_pop_up()) 46 | return pop_up 47 | 48 | def get_pop_up_parameters(self): 49 | return {'left': 1, 'top': 1, 'overlay_width': 35, 'overlay_height': 7} 50 | 51 | def show_indicator(self, message): 52 | self.original_widget.set_text(message) 53 | -------------------------------------------------------------------------------- /pyte/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte 4 | ~~~~ 5 | 6 | `pyte` implements a mix of VT100, VT220 and VT520 specification, 7 | and aims to support most of the `TERM=linux` functionality. 8 | 9 | Two classes: :class:`~pyte.streams.Stream`, which parses the 10 | command stream and dispatches events for commands, and 11 | :class:`~pyte.screens.Screen` which, when used with a stream 12 | maintains a buffer of strings representing the screen of a 13 | terminal. 14 | 15 | .. warning:: From ``xterm/main.c`` "If you think you know what all 16 | of this code is doing, you are probably very mistaken. 17 | There be serious and nasty dragons here" -- nothing 18 | has changed. 19 | 20 | :copyright: (c) 2011-2012 by Selectel. 21 | :copyright: (c) 2012-2016 by pyte authors and contributors, 22 | see AUTHORS for details. 23 | :license: LGPL, see LICENSE for more details. 24 | """ 25 | 26 | from __future__ import absolute_import 27 | 28 | __all__ = ("Screen", "DiffScreen", "HistoryScreen", 29 | "Stream", "ByteStream", "DebugStream") 30 | 31 | import io 32 | 33 | from .screens import Screen, DiffScreen, HistoryScreen 34 | from .streams import Stream, ByteStream, DebugStream 35 | 36 | 37 | if __debug__: 38 | from .compat import str 39 | 40 | def dis(chars): 41 | """A :func:`dis.dis` for terminals. 42 | 43 | >>> dis(b"\x07") # doctest: +NORMALIZE_WHITESPACE 44 | BELL 45 | >>> dis(b"\x1b[20m") # doctest: +NORMALIZE_WHITESPACE 46 | SELECT_GRAPHIC_RENDITION 20 47 | """ 48 | if isinstance(chars, str): 49 | chars = chars.encode("utf-8") 50 | 51 | with io.StringIO() as buf: 52 | DebugStream(to=buf).feed(chars) 53 | print(buf.getvalue()) 54 | -------------------------------------------------------------------------------- /pyte/control.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.control 4 | ~~~~~~~~~~~~ 5 | 6 | This module defines simple control sequences, recognized by 7 | :class:`~pyte.streams.Stream`, the set of codes here is for 8 | ``TERM=linux`` which is a superset of VT102. 9 | 10 | :copyright: (c) 2011-2012 by Selectel. 11 | :copyright: (c) 2012-2016 by pyte authors and contributors, 12 | see AUTHORS for details. 13 | :license: LGPL, see LICENSE for more details. 14 | """ 15 | 16 | #: *Space*: Not suprisingly -- ``" "``. 17 | SP = b" " 18 | 19 | #: *Null*: Does nothing. 20 | NUL = b"\x00" 21 | 22 | #: *Bell*: Beeps. 23 | BEL = b"\x07" 24 | 25 | #: *Backspace*: Backspace one column, but not past the begining of the 26 | #: line. 27 | BS = b"\x08" 28 | 29 | #: *Horizontal tab*: Move cursor to the next tab stop, or to the end 30 | #: of the line if there is no earlier tab stop. 31 | HT = b"\x09" 32 | 33 | #: *Linefeed*: Give a line feed, and, if :data:`pyte.modes.LNM` (new 34 | #: line mode) is set also a carriage return. 35 | LF = b"\n" 36 | #: *Vertical tab*: Same as :data:`LF`. 37 | VT = b"\x0b" 38 | #: *Form feed*: Same as :data:`LF`. 39 | FF = b"\x0c" 40 | 41 | #: *Carriage return*: Move cursor to left margin on current line. 42 | CR = b"\r" 43 | 44 | #: *Shift out*: Activate G1 character set. 45 | SO = b"\x0e" 46 | 47 | #: *Shift in*: Activate G0 character set. 48 | SI = b"\x0f" 49 | 50 | #: *Cancel*: Interrupt escape sequence. If received during an escape or 51 | #: control sequence, cancels the sequence and displays substitution 52 | #: character. 53 | CAN = b"\x18" 54 | #: *Substitute*: Same as :data:`CAN`. 55 | SUB = b"\x1a" 56 | 57 | #: *Escape*: Starts an escape sequence. 58 | ESC = b"\x1b" 59 | 60 | #: *Delete*: Is ignored. 61 | DEL = b"\x7f" 62 | 63 | #: *Control sequence introducer*: An equivalent for ``ESC [``. 64 | CSI = b"\x9b" 65 | 66 | #: *String terminator*. 67 | ST = b"\x9c" 68 | 69 | #: *Operating system command*. 70 | OSC = b"\x9d" 71 | -------------------------------------------------------------------------------- /pyte/modes.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.modes 4 | ~~~~~~~~~~ 5 | 6 | This module defines terminal mode switches, used by 7 | :class:`~pyte.screens.Screen`. There're two types of terminal modes: 8 | 9 | * `non-private` which should be set with ``ESC [ N h``, where ``N`` 10 | is an integer, representing mode being set; and 11 | * `private` which should be set with ``ESC [ ? N h``. 12 | 13 | The latter are shifted 5 times to the right, to be easily 14 | distinguishable from the former ones; for example `Origin Mode` 15 | -- :data:`DECOM` is ``192`` not ``6``. 16 | 17 | >>> DECOM 18 | 192 19 | 20 | :copyright: (c) 2011-2012 by Selectel. 21 | :copyright: (c) 2012-2016 by pyte authors and contributors, 22 | see AUTHORS for details. 23 | :license: LGPL, see LICENSE for more details. 24 | """ 25 | 26 | #: *Line Feed/New Line Mode*: When enabled, causes a received 27 | #: :data:`~pyte.control.LF`, :data:`pyte.control.FF`, or 28 | #: :data:`~pyte.control.VT` to move the cursor to the first column of 29 | #: the next line. 30 | LNM = 20 31 | 32 | #: *Insert/Replace Mode*: When enabled, new display characters move 33 | #: old display characters to the right. Characters moved past the 34 | #: right margin are lost. Otherwise, new display characters replace 35 | #: old display characters at the cursor position. 36 | IRM = 4 37 | 38 | 39 | # Private modes. 40 | # .............. 41 | 42 | #: *Text Cursor Enable Mode*: determines if the text cursor is 43 | #: visible. 44 | DECTCEM = 25 << 5 45 | 46 | #: *Screen Mode*: toggles screen-wide reverse-video mode. 47 | DECSCNM = 5 << 5 48 | 49 | #: *Origin Mode*: allows cursor addressing relative to a user-defined 50 | #: origin. This mode resets when the terminal is powered up or reset. 51 | #: It does not affect the erase in display (ED) function. 52 | DECOM = 6 << 5 53 | 54 | #: *Auto Wrap Mode*: selects where received graphic characters appear 55 | #: when the cursor is at the right margin. 56 | DECAWM = 7 << 5 57 | 58 | #: *Column Mode*: selects the number of columns per line (80 or 132) 59 | #: on the screen. 60 | DECCOLM = 3 << 5 61 | -------------------------------------------------------------------------------- /session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2016 Ahmed Nazmy 4 | # 5 | 6 | # Meta 7 | __license__ = "AGPLv3" 8 | __author__ = 'Ahmed Nazmy ' 9 | 10 | 11 | import logging 12 | import signal 13 | import os 14 | import time 15 | import getpass 16 | from SSHClient import SSHClient 17 | 18 | 19 | class Session(object): 20 | """ 21 | Base Session class 22 | """ 23 | 24 | def __init__(self, aker_core, host, uuid): 25 | self.aker = aker_core 26 | self.host = host.fqdn 27 | self.host_user = self.aker.user.name 28 | self.host_port = int(host.ssh_port) 29 | self.src_port = self.aker.config.src_port 30 | self.uuid = uuid 31 | logging.debug("Session: Base Session created") 32 | 33 | def attach_sniffer(self, sniffer): 34 | self._client.attach_sniffer(sniffer) 35 | 36 | def stop_sniffer(self): 37 | self._client.stop_sniffer() 38 | 39 | def connect(self, size): 40 | self._client.connect(self.host, self.host_port, size) 41 | 42 | def start_session(self): 43 | raise NotImplementedError 44 | 45 | def close_session(self): 46 | self.aker.session_end_callback(self) 47 | 48 | def kill_session(self, signum, stack): 49 | logging.debug("Session: Session ended") 50 | self.close_session() 51 | 52 | 53 | class SSHSession(Session): 54 | """ Wrapper around SSHClient instantiating 55 | a new SSHClient instance every time 56 | """ 57 | 58 | def __init__(self, aker_core, host, uuid): 59 | super(SSHSession, self).__init__(aker_core, host, uuid) 60 | self._client = SSHClient(self) 61 | logging.debug("Session: SSHSession created") 62 | 63 | def start_session(self): 64 | try: 65 | auth_secret = self.aker.user.get_priv_key() 66 | # currently, if no SSH public key exists, an ``Exception`` 67 | # is raised. Catch it and try a password. 68 | except Exception as exc: 69 | if str(exc) == 'Core: Invalid Private Key': 70 | auth_secret = getpass.getpass("Password: ") 71 | else: 72 | raise 73 | try: 74 | self._client.start_session(self.host_user, auth_secret) 75 | except: 76 | logging.debug("Session: SSHSession failed") 77 | -------------------------------------------------------------------------------- /idp/Json.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2017 Ahmed Nazmy 5 | # 6 | 7 | # Meta 8 | 9 | from IdPFactory import IdP 10 | import json 11 | import logging 12 | 13 | 14 | class Json(IdP): 15 | """ 16 | Fetch the authority informataion from a JSON configuration 17 | """ 18 | 19 | def __init__(self, config, username, gateway_hostgroup): 20 | super(Json, self).__init__(username, gateway_hostgroup) 21 | logging.info("Json: loaded") 22 | self.config = config 23 | self.posix_user = username 24 | self._init_json_config() 25 | 26 | def _init_json_config(self): 27 | # Load the configration from the already intitialised config parser 28 | hosts_file = self.config.get("General", "hosts_file", "hosts.json") 29 | try: 30 | JSON = json.load(open(hosts_file, 'r')) 31 | except ValueError as e: 32 | logging.error( 33 | "JSON: could not read json file {0} , error : {1}".format( 34 | hosts_file, e.message)) 35 | 36 | logging.debug("Json: loading all hosts from {0}".format(hosts_file)) 37 | self._all_ssh_hosts = JSON["hosts"] 38 | logging.debug("Json: loading all users from {0}".format(hosts_file)) 39 | self._all_users = JSON.get("users") 40 | logging.debug( 41 | "Json: loading all usergroups from {0}".format(hosts_file)) 42 | self._all_usergroups = JSON.get("usergroups") 43 | self._allowed_ssh_hosts = {} 44 | self._load_user_allowed_hosts() 45 | 46 | def _load_user_allowed_hosts(self): 47 | """ 48 | Fetch the allowed hosts based usergroup/hostgroup membership 49 | """ 50 | for user in self._all_users: 51 | if user.get("username") == self.posix_user: 52 | logging.debug("Json: loading hosts/groups for user {0}".format( 53 | self.posix_user)) 54 | self._user_groups = user.get("usergroups") 55 | for host in self._all_ssh_hosts: 56 | for usergroup in host.get("usergroups"): 57 | if usergroup in self._user_groups: 58 | logging.debug( 59 | "Json: loading host {0} for user {1}".format( 60 | host.get("name"), self.posix_user)) 61 | 62 | self._allowed_ssh_hosts[host.get("name")] = { 63 | 'name': host.get("name"), 64 | 'fqdn': host.get("hostname"), 65 | 'ssh_port': host.get("ssh_port"), 66 | 'hostgroups': host.get("hostgroups") 67 | } 68 | 69 | def list_allowed(self): 70 | # is our list empty ? 71 | if not self._allowed_ssh_hosts: 72 | self._load_user_allowed_hosts() 73 | return self._allowed_ssh_hosts 74 | -------------------------------------------------------------------------------- /akerctl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2017 Ahmed Nazmy 5 | # 6 | 7 | # Meta 8 | __license__ = "AGPLv3" 9 | __author__ = 'Ahmed Nazmy ' 10 | 11 | import sys 12 | from contextlib import closing 13 | from math import ceil 14 | import time 15 | import os 16 | import codecs 17 | import os 18 | import fnmatch 19 | import argparse 20 | import json 21 | 22 | 23 | parser = argparse.ArgumentParser(description="Aker session reply") 24 | parser.add_argument( 25 | "-u", 26 | "--uuid", 27 | action="store", 28 | dest='uuid', 29 | help="Recorded Session UUID", 30 | required=True) 31 | group = parser.add_mutually_exclusive_group(required=True) 32 | group.add_argument( 33 | "-r", 34 | "--replay", 35 | action='store_true', 36 | dest="replay", 37 | help="Replay Session") 38 | group.add_argument( 39 | "-c", 40 | "--commands", 41 | action='store_true', 42 | dest="cmds", 43 | help="Print Commands Entered By User During Session") 44 | 45 | 46 | def main(argv): 47 | from aker import session_log_dir 48 | args = parser.parse_args() 49 | session_uuid = args.uuid 50 | log_file = "*" + session_uuid + "*" + ".log" 51 | log_timer = "*" + session_uuid + "*" + ".timer" 52 | cmds_file = "*" + session_uuid + "*" + ".cmds" 53 | logfile_path = locate(log_file, session_log_dir) 54 | timefile_path = locate(log_timer, session_log_dir) 55 | if args.replay: 56 | replay(logfile_path, timefile_path) 57 | elif args.cmds: 58 | cmds_filepath = locate(cmds_file, session_log_dir) 59 | show_cmds(cmds_filepath) 60 | 61 | 62 | def show_cmds(cmds_file): 63 | data = [] 64 | with open(cmds_file) as json_file: 65 | for line in json_file: 66 | data.append(json.loads(line)) 67 | for k in data: 68 | try: 69 | print (k['timing'] + ':' + k['cmd']) 70 | except Exception: 71 | pass 72 | 73 | 74 | def replay(log_file, time_file): 75 | with open(log_file) as logf: 76 | with open(time_file) as timef: 77 | timing = get_timing(timef) 78 | with closing(logf): 79 | logf.readline() # ignore first line, (Session Start) 80 | for t in timing: 81 | data = logf.read(t[1]) 82 | #print("data is %s , t is %s" % (data,t[1])) 83 | text = codecs.decode(data, 'UTF-8', "replace") 84 | time.sleep(t[0]) 85 | sys.stdout.write(text) 86 | sys.stdout.flush() 87 | text = "" 88 | 89 | 90 | def locate(pattern, root=os.curdir): 91 | match = "" 92 | for path, dirs, files in os.walk(os.path.abspath(root)): 93 | for filename in fnmatch.filter(files, pattern): 94 | matches = os.path.join(path, filename) 95 | return matches 96 | 97 | 98 | def get_timing(timef): 99 | timing = None 100 | with closing(timef): 101 | timing = [l.strip().split(' ') for l in timef] 102 | timing = [(float(r[0]), int(r[1])) for r in timing] 103 | return timing 104 | 105 | 106 | if __name__ == "__main__": 107 | main(sys.argv) 108 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at ahmed@nazmy.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /pyte/graphics.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.graphics 4 | ~~~~~~~~~~~~~ 5 | 6 | This module defines graphic-related constants, mostly taken from 7 | :manpage:`console_codes(4)` and 8 | http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html. 9 | 10 | :copyright: (c) 2011-2012 by Selectel. 11 | :copyright: (c) 2012-2016 by pyte authors and contributors, 12 | see AUTHORS for details. 13 | :license: LGPL, see LICENSE for more details. 14 | """ 15 | 16 | from __future__ import unicode_literals 17 | 18 | #: A mapping of ANSI text style codes to style names, "+" means the: 19 | #: attribute is set, "-" -- reset; example: 20 | #: 21 | #: >>> text[1] 22 | #: '+bold' 23 | #: >>> text[9] 24 | #: '+strikethrough' 25 | TEXT = { 26 | 1: "+bold", 27 | 3: "+italics", 28 | 4: "+underscore", 29 | 7: "+reverse", 30 | 9: "+strikethrough", 31 | 22: "-bold", 32 | 23: "-italics", 33 | 24: "-underscore", 34 | 27: "-reverse", 35 | 29: "-strikethrough", 36 | } 37 | 38 | #: A mapping of ANSI foreground color codes to color names. 39 | #: 40 | #: >>> FG_ANSI[30] 41 | #: 'black' 42 | #: >>> FG_ANSI[38] 43 | #: 'default' 44 | FG_ANSI = { 45 | 30: "black", 46 | 31: "red", 47 | 32: "green", 48 | 33: "brown", 49 | 34: "blue", 50 | 35: "magenta", 51 | 36: "cyan", 52 | 37: "white", 53 | 39: "default" # white. 54 | } 55 | 56 | #: An alias to :data:`~pyte.graphics.FG_ANSI` for compatibility. 57 | FG = FG_ANSI 58 | 59 | #: A mapping of non-standard ``aixterm`` foreground color codes to 60 | #: color names. These are high intensity colors and thus should be 61 | #: complemented by ``+bold``. 62 | FG_AIXTERM = { 63 | 90: "black", 64 | 91: "red", 65 | 92: "green", 66 | 93: "brown", 67 | 94: "blue", 68 | 95: "magenta", 69 | 96: "cyan", 70 | 97: "white" 71 | } 72 | 73 | #: A mapping of ANSI background color codes to color names. 74 | #: 75 | #: >>> BG_ANSI[40] 76 | #: 'black' 77 | #: >>> BG_ANSI[48] 78 | #: 'default' 79 | BG_ANSI = { 80 | 40: "black", 81 | 41: "red", 82 | 42: "green", 83 | 43: "brown", 84 | 44: "blue", 85 | 45: "magenta", 86 | 46: "cyan", 87 | 47: "white", 88 | 49: "default" # black. 89 | } 90 | 91 | #: An alias to :data:`~pyte.graphics.BG_ANSI` for compatibility. 92 | BG = BG_ANSI 93 | 94 | #: A mapping of non-standard ``aixterm`` background color codes to 95 | #: color names. These are high intensity colors and thus should be 96 | #: complemented by ``+bold``. 97 | BG_AIXTERM = { 98 | 100: "black", 99 | 101: "red", 100 | 102: "green", 101 | 103: "brown", 102 | 104: "blue", 103 | 105: "magenta", 104 | 106: "cyan", 105 | 107: "white" 106 | } 107 | 108 | #: SGR code for foreground in 256 or True color mode. 109 | FG_256 = 38 110 | 111 | #: SGR code for background in 256 or True color mode. 112 | BG_256 = 48 113 | 114 | #: A table of 256 foreground or background colors. 115 | # The following code is part of the Pygments project (BSD licensed). 116 | FG_BG_256 = [ 117 | (0x00, 0x00, 0x00), # 0 118 | (0xcd, 0x00, 0x00), # 1 119 | (0x00, 0xcd, 0x00), # 2 120 | (0xcd, 0xcd, 0x00), # 3 121 | (0x00, 0x00, 0xee), # 4 122 | (0xcd, 0x00, 0xcd), # 5 123 | (0x00, 0xcd, 0xcd), # 6 124 | (0xe5, 0xe5, 0xe5), # 7 125 | (0x7f, 0x7f, 0x7f), # 8 126 | (0xff, 0x00, 0x00), # 9 127 | (0x00, 0xff, 0x00), # 10 128 | (0xff, 0xff, 0x00), # 11 129 | (0x5c, 0x5c, 0xff), # 12 130 | (0xff, 0x00, 0xff), # 13 131 | (0x00, 0xff, 0xff), # 14 132 | (0xff, 0xff, 0xff), # 15 133 | ] 134 | 135 | # colors 16..232: the 6x6x6 color cube 136 | valuerange = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff) 137 | 138 | for i in range(217): 139 | r = valuerange[(i // 36) % 6] 140 | g = valuerange[(i // 6) % 6] 141 | b = valuerange[i % 6] 142 | FG_BG_256.append((r, g, b)) 143 | 144 | # colors 233..253: grayscale 145 | for i in range(1, 22): 146 | v = 8 + i * 10 147 | FG_BG_256.append((v, v, v)) 148 | 149 | FG_BG_256 = ["{0:02x}{1:02x}{2:02x}".format(r, g, b) for r, g, b in FG_BG_256] 150 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at aker@nazmy.io. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ 93 | -------------------------------------------------------------------------------- /pyte/escape.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.escape 4 | ~~~~~~~~~~~ 5 | 6 | This module defines both CSI and non-CSI escape sequences, recognized 7 | by :class:`~pyte.streams.Stream` and subclasses. 8 | 9 | :copyright: (c) 2011-2012 by Selectel. 10 | :copyright: (c) 2012-2016 by pyte authors and contributors, 11 | see AUTHORS for details. 12 | :license: LGPL, see LICENSE for more details. 13 | """ 14 | 15 | #: *Reset*. 16 | RIS = b"c" 17 | 18 | #: *Index*: Move cursor down one line in same column. If the cursor is 19 | #: at the bottom margin, the screen performs a scroll-up. 20 | IND = b"D" 21 | 22 | #: *Next line*: Same as :data:`pyte.control.LF`. 23 | NEL = b"E" 24 | 25 | #: Tabulation set: Set a horizontal tab stop at cursor position. 26 | HTS = b"H" 27 | 28 | #: *Reverse index*: Move cursor up one line in same column. If the 29 | #: cursor is at the top margin, the screen performs a scroll-down. 30 | RI = b"M" 31 | 32 | #: Save cursor: Save cursor position, character attribute (graphic 33 | #: rendition), character set, and origin mode selection (see 34 | #: :data:`DECRC`). 35 | DECSC = b"7" 36 | 37 | #: *Restore cursor*: Restore previously saved cursor position, character 38 | #: attribute (graphic rendition), character set, and origin mode 39 | #: selection. If none were saved, move cursor to home position. 40 | DECRC = b"8" 41 | 42 | # "Sharp" escape sequences. 43 | # ------------------------- 44 | 45 | #: *Alignment display*: Fill screen with uppercase E's for testing 46 | #: screen focus and alignment. 47 | DECALN = b"8" 48 | 49 | 50 | # ECMA-48 CSI sequences. 51 | # --------------------- 52 | 53 | #: *Insert character*: Insert the indicated # of blank characters. 54 | ICH = b"@" 55 | 56 | #: *Cursor up*: Move cursor up the indicated # of lines in same column. 57 | #: Cursor stops at top margin. 58 | CUU = b"A" 59 | 60 | #: *Cursor down*: Move cursor down the indicated # of lines in same 61 | #: column. Cursor stops at bottom margin. 62 | CUD = b"B" 63 | 64 | #: *Cursor forward*: Move cursor right the indicated # of columns. 65 | #: Cursor stops at right margin. 66 | CUF = b"C" 67 | 68 | #: *Cursor back*: Move cursor left the indicated # of columns. Cursor 69 | #: stops at left margin. 70 | CUB = b"D" 71 | 72 | #: *Cursor next line*: Move cursor down the indicated # of lines to 73 | #: column 1. 74 | CNL = b"E" 75 | 76 | #: *Cursor previous line*: Move cursor up the indicated # of lines to 77 | #: column 1. 78 | CPL = b"F" 79 | 80 | #: *Cursor horizontal align*: Move cursor to the indicated column in 81 | #: current line. 82 | CHA = b"G" 83 | 84 | #: *Cursor position*: Move cursor to the indicated line, column (origin 85 | #: at ``1, 1``). 86 | CUP = b"H" 87 | 88 | #: *Erase data* (default: from cursor to end of line). 89 | ED = b"J" 90 | 91 | #: *Erase in line* (default: from cursor to end of line). 92 | EL = b"K" 93 | 94 | #: *Insert line*: Insert the indicated # of blank lines, starting from 95 | #: the current line. Lines displayed below cursor move down. Lines moved 96 | #: past the bottom margin are lost. 97 | IL = b"L" 98 | 99 | #: *Delete line*: Delete the indicated # of lines, starting from the 100 | #: current line. As lines are deleted, lines displayed below cursor 101 | #: move up. Lines added to bottom of screen have spaces with same 102 | #: character attributes as last line move up. 103 | DL = b"M" 104 | 105 | #: *Delete character*: Delete the indicated # of characters on the 106 | #: current line. When character is deleted, all characters to the right 107 | #: of cursor move left. 108 | DCH = b"P" 109 | 110 | #: *Erase character*: Erase the indicated # of characters on the 111 | #: current line. 112 | ECH = b"X" 113 | 114 | #: *Horizontal position relative*: Same as :data:`CUF`. 115 | HPR = b"a" 116 | 117 | #: *Device Attributes*. 118 | DA = b"c" 119 | 120 | #: *Vertical position adjust*: Move cursor to the indicated line, 121 | #: current column. 122 | VPA = b"d" 123 | 124 | #: *Vertical position relative*: Same as :data:`CUD`. 125 | VPR = b"e" 126 | 127 | #: *Horizontal / Vertical position*: Same as :data:`CUP`. 128 | HVP = b"f" 129 | 130 | #: *Tabulation clear*: Clears a horizontal tab stop at cursor position. 131 | TBC = b"g" 132 | 133 | #: *Set mode*. 134 | SM = b"h" 135 | 136 | #: *Reset mode*. 137 | RM = b"l" 138 | 139 | #: *Select graphics rendition*: The terminal can display the following 140 | #: character attributes that change the character display without 141 | #: changing the character (see :mod:`pyte.graphics`). 142 | SGR = b"m" 143 | 144 | #: *Device status report*. 145 | DSR = b"n" 146 | 147 | #: *Select top and bottom margins*: Selects margins, defining the 148 | #: scrolling region; parameters are top and bottom line. If called 149 | #: without any arguments, whole screen is used. 150 | DECSTBM = b"r" 151 | 152 | #: *Horizontal position adjust*: Same as :data:`CHA`. 153 | HPA = b"'" 154 | -------------------------------------------------------------------------------- /idp/IPA.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2017 Ahmed Nazmy 5 | # 6 | 7 | # Meta 8 | 9 | 10 | from IdPFactory import IdP 11 | import logging 12 | import pyhbac 13 | from ipalib import api, errors, output, util 14 | from ipalib import Command, Str, Flag, Int 15 | from ipalib.cli import to_cli 16 | from ipalib import _, ngettext 17 | from ipapython.dn import DN 18 | from ipalib.plugable import Registry 19 | 20 | 21 | class IPA(IdP): 22 | ''' 23 | Abstract represtation of user allowed hosts. 24 | Currently relying on FreeIPA API 25 | ''' 26 | 27 | def __init__(self, config, username, gateway_hostgroup): 28 | super(IPA, self).__init__(username, gateway_hostgroup) 29 | logging.info("IPA: loaded") 30 | api.bootstrap(context='cli') 31 | api.finalize() 32 | try: 33 | api.Backend.rpcclient.connect() 34 | except AttributeError: 35 | api.Backend.xmlclient.connect() # FreeIPA < 4.0 compatibility 36 | self.api = api 37 | self.default_ssh_port = config.ssh_port 38 | 39 | def convert_to_ipa_rule(self, rule): 40 | # convert a dict with a rule to an pyhbac rule 41 | ipa_rule = pyhbac.HbacRule(rule['cn'][0]) 42 | ipa_rule.enabled = rule['ipaenabledflag'][0] 43 | # Following code attempts to process rule systematically 44 | structure = \ 45 | (('user', 'memberuser', 'user', 'group', ipa_rule.users), 46 | ('host', 'memberhost', 'host', 'hostgroup', ipa_rule.targethosts), 47 | ('sourcehost', 'sourcehost', 'host', 'hostgroup', ipa_rule.srchosts), 48 | ('service', 'memberservice', 'hbacsvc', 'hbacsvcgroup', ipa_rule.services), 49 | ) 50 | for element in structure: 51 | category = '%scategory' % (element[0]) 52 | if (category in rule and rule[category][0] == u'all') or ( 53 | element[0] == 'sourcehost'): 54 | # rule applies to all elements 55 | # sourcehost is always set to 'all' 56 | element[4].category = set([pyhbac.HBAC_CATEGORY_ALL]) 57 | else: 58 | # rule is about specific entities 59 | # Check if there are explicitly listed entities 60 | attr_name = '%s_%s' % (element[1], element[2]) 61 | if attr_name in rule: 62 | element[4].names = rule[attr_name] 63 | # Now add groups of entities if they are there 64 | attr_name = '%s_%s' % (element[1], element[3]) 65 | if attr_name in rule: 66 | element[4].groups = rule[attr_name] 67 | if 'externalhost' in rule: 68 | ipa_rule.srchosts.names.extend( 69 | rule['externalhost']) # pylint: disable=E1101 70 | return ipa_rule 71 | 72 | def _load_all_hosts(self, api): 73 | ''' 74 | This function prints a list of all hosts. This function requires 75 | one argument, the FreeIPA/IPA API object. 76 | ''' 77 | result = api.Command.host_find( 78 | not_in_hostgroup=self.gateway_hostgroup)['result'] 79 | members = {} 80 | for ipa_host in result: 81 | ipa_hostname = ipa_host['fqdn'] 82 | if isinstance(ipa_hostname, (tuple, list)): 83 | ipa_hostname = ipa_hostname[0] 84 | members[ipa_hostname] = {'fqdn': ipa_hostname} 85 | logging.debug("IPA: ALL_HOSTS %s", ipa_hostname) 86 | 87 | return members 88 | 89 | def _load_user_allowed_hosts(self): 90 | self._all_ssh_hosts = self._load_all_hosts(self.api) 91 | hbacset = [] 92 | rules = [] 93 | sizelimit = None 94 | hbacset = api.Command.hbacrule_find(sizelimit=sizelimit)['result'] 95 | for rule in hbacset: 96 | ipa_rule = self.convert_to_ipa_rule(rule) 97 | # Add only enabled rules 98 | if ipa_rule.enabled: 99 | rules.append(ipa_rule.name) 100 | 101 | for host, host_attributes in self._all_ssh_hosts.iteritems(): 102 | try: 103 | hostname = host_attributes['fqdn'] 104 | logging.debug("IPA: Checking %s", hostname) 105 | ret = api.Command.hbactest( 106 | user=self.user.decode('utf-8'), 107 | targethost=hostname, 108 | service=u"sshd", 109 | rules=rules) 110 | if ret['value']: 111 | result = api.Command.host_show( 112 | host_attributes['fqdn'])['result'] 113 | memberof_hostgroup = result['memberof_hostgroup'] 114 | # TODO: Add per-host ssh port checks 115 | sshport = self.default_ssh_port 116 | self._allowed_ssh_hosts[host] = { 117 | 'name': hostname, 'fqdn': hostname, 'ssh_port': sshport, 'hostgroups': memberof_hostgroup} 118 | logging.debug("IPA: ALLOWED_HOSTS %s", host) 119 | except Exception as e: 120 | logging.error( 121 | "IPA: error evaluating HBAC : {0}".format( 122 | e.message)) 123 | 124 | def list_allowed(self): 125 | self._all_ssh_hosts = self._load_all_hosts(self.api) 126 | self._load_user_allowed_hosts() 127 | return self._allowed_ssh_hosts 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Join the chat at https://gitter.im/Akergateway/Aker](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/Akergateway/Aker?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | 3 | # Aker SSH Gateway 4 | ![alt text](aker_logo.png "Aker") 5 | 6 | 7 | ### What is Aker? 8 | Aker is a security tool that helps you configure your own Linux ssh jump/bastion host. Named after an Egyptian mythology deity who guarded the borders, Aker would act as choke point through which all your sysadmins and support staff access Linux production servers. Aker SSH gateway includes a lot of security features that would help you manage and administer thousands of Linux servers at ease. For a detailed look check our [Wiki](https://github.com/aker-gateway/Aker/wiki) 9 | 10 | 11 | ### Motivation 12 | I couldn't find an open source tool similar to [CryptoAuditor](https://www.ssh.com/products/cryptoauditor/) and [fudo](http://www.wheelsystems.com/en/products/wheel-fudo-psm/), such tools are beneficial if you're seeking becoming PCI-DSS or HIPAA compliant for example, regardless of security standards compliance access to the server should be controlled and organized in a way convenient to both traditional and cloud workloads. 13 | 14 | 15 | ### Current Featuers 16 | 17 | * Supports FreeIPA 4.2 , 4.3 and 4.4 (Optional) 18 | * Extensible, [Write Your Own Module](https://github.com/aker-gateway/Aker/wiki/IdP-Modules#writing-your-custom-idp-module) 19 | * Session Playback 20 | * Extract Session Commands 21 | * SIEM-Ready json Session Logs 22 | * Elasticsearch Integration 23 | 24 | ### Roadmap 25 | * Phase 0 26 | * Integration with an identity provider (FreeIPA) 27 | * Extendable Modular structure, plugin your own module 28 | * Integration with config management tools 29 | * Parsable audit logs (json, shipped to Elasticsearch) 30 | * Highly available setup 31 | * Session playback 32 | 33 | 34 | * Phase 1 35 | * Admin WebUI 36 | * Live session monitoring 37 | * Cloud support (AWS,OpenStack etc..) or On-premises deployments 38 | * Command filtering (Prevent destructive commands like rm -rf) 39 | * Encrypt sessions logs stored on disk. 40 | 41 | * Phase 2 42 | * Support for graphical protocols (RDP, VNC, X11) monitoring 43 | * User productivity dashboard 44 | 45 | 46 | ### See it in action 47 | [![Aker - in action](https://i1.ytimg.com/vi/O-boM3LbVT4/hqdefault.jpg)](https://www.youtube.com/watch?v=H6dCCw666Xw) 48 | 49 | 50 | ### Requirements 51 | Software: 52 | - Linux (Tested on CentOS, Fedora and ubuntu) 53 | - Python (Tested on 2.7) 54 | - (Optional) FreeIPA, Tested on FreeIPA 4.2 & 4.3 55 | - redis 56 | 57 | Python Modules: 58 | - configparser 59 | - urwid 60 | - paramiko 61 | - wcwidth 62 | - pyte 63 | - redis 64 | 65 | ### Installation 66 | 67 | 68 | * Automated : 69 | * Use [this ansible playbook](https://github.com/aker-gateway/aker-freeipa-playbook) 70 | 71 | 72 | * Manually: 73 | - Aker can be setup on a FreeIPA client or indepentantly using json config file. 74 | 75 | * Common Steps (FreeIPA or Json): 76 | 77 | * Clone the repo 78 | ~~~ 79 | git clone https://github.com/aker-gateway/Aker.git /usr/bin/aker/ 80 | ~~~ 81 | 82 | * Install dependencies (adapt for Ubuntu) 83 | ~~~ 84 | yum -y install epel-release 85 | yum -y install python2-paramiko python-configparser python-redis python-urwid python2-wcwidth redis 86 | ~~~ 87 | 88 | 89 | * Set files executable perms 90 | ``` 91 | chmod 755 /usr/bin/aker/aker.py 92 | chmod 755 /usr/bin/aker/akerctl.py 93 | ``` 94 | 95 | * Setup logdir and perms 96 | ``` 97 | mkdir /var/log/aker 98 | chmod 777 /var/log/aker 99 | touch /var/log/aker/aker.log 100 | chmod 777 /var/log/aker/aker.log 101 | ``` 102 | 103 | * Enforce aker on all users but root, edit sshd_config 104 | ~~~ 105 | Match Group *,!root 106 | ForceCommand /usr/bin/aker/aker.py 107 | ~~~ 108 | 109 | * Restart ssh 110 | * Restart redis 111 | 112 | 113 | 114 | * Choosing FreeIPA: 115 | * Assumptions: 116 | * Aker server already enrolled to FreeIPA domain 117 | 118 | * Create /etc/aker and copy /usr/bin/aker/aker.ini in it and edit it like below : 119 | 120 | ``` 121 | [General] 122 | log_level = INFO 123 | ssh_port = 22 124 | 125 | # Identity Provider to determine the list of available hosts 126 | # options shipped are IPA, Json. Default is IPA 127 | idp = IPA 128 | hosts_file = /etc/aker/hosts.json 129 | 130 | # FreeIPA hostgroup name contatining Aker gateways 131 | # to be excluded from hosts presented to user 132 | gateway_group = gateways 133 | ``` 134 | 135 | 136 | 137 | * Choosing Json: 138 | * Create /etc/aker and copy /usr/bin/aker/aker.ini in it and edit it like below : 139 | 140 | ``` 141 | [General] 142 | log_level = INFO 143 | ssh_port = 22 144 | 145 | # Identity Provider to determine the list of available hosts 146 | # options shipped are IPA, Json. Default is IPA 147 | idp = Json 148 | hosts_file = /etc/aker/hosts.json 149 | 150 | # FreeIPA hostgroup name contatining Aker gateways 151 | # to be excluded from hosts presented to user 152 | gateway_group = gateways 153 | ``` 154 | 155 | * Edit /etc/aker/hosts.json to add users and hosts, a sample `hosts.json` file is provided . 156 | 157 | 158 | ### Contributing 159 | Currently I work on the code in my free time, any assistance is highly appreciated. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests. 160 | -------------------------------------------------------------------------------- /aker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2016 ahmed@nazmy.io 5 | # 6 | # For license information see LICENSE.txt 7 | 8 | 9 | # Meta 10 | __version__ = '0.4.5' 11 | __version_info__ = (0, 4, 5) 12 | __license__ = "AGPLv3" 13 | __license_info__ = { 14 | "AGPLv3": { 15 | "product": "aker", 16 | "users": 0, # 0 being unlimited 17 | "customer": "Unsupported", 18 | "version": __version__, 19 | "license_format": "1.0", 20 | } 21 | } 22 | 23 | import logging 24 | import os 25 | import sys 26 | import uuid 27 | import getpass 28 | import paramiko 29 | import socket 30 | from configparser import ConfigParser, NoOptionError 31 | import time 32 | import signal 33 | import sys 34 | 35 | from hosts import Hosts 36 | import tui 37 | from session import SSHSession 38 | from snoop import SSHSniffer 39 | 40 | def signal_handler(signal, frame): 41 | logging.debug("Core: user tried an invalid signal {}".format(signal)) 42 | # Capture CTRL-C 43 | signal.signal(signal.SIGINT, signal_handler) 44 | 45 | config_file = "/etc/aker/aker.ini" 46 | log_file = '/var/log/aker/aker.log' 47 | session_log_dir = '/var/log/aker/' 48 | 49 | 50 | class Configuration(object): 51 | def __init__(self, filename): 52 | remote_connection = os.environ.get('SSH_CLIENT', '0.0.0.0 0') 53 | self.src_ip = remote_connection.split()[0] 54 | self.src_port = remote_connection.split()[1] 55 | self.session_uuid = uuid.uuid1() 56 | # TODO: Check file existence, handle exception 57 | self.configparser = ConfigParser() 58 | if filename: 59 | self.configparser.read(filename) 60 | self.log_level = self.configparser.get('General', 'log_level') 61 | self.ssh_port = self.configparser.get('General', 'ssh_port') 62 | 63 | def get(self, *args): 64 | if len(args) == 3: 65 | try: 66 | return self.configparser.get(args[0], args[1]) 67 | except NoOptionError as e: 68 | return args[2] 69 | if len(args) == 2: 70 | return self.configparser.get(args[0], args[1]) 71 | else: 72 | return self.configparser.get('General', args[0]) 73 | 74 | 75 | class User(object): 76 | def __init__(self, username): 77 | self.name = username 78 | gateway_hostgroup = config.get('gateway_group') 79 | idp = config.get('idp') 80 | logging.debug("Core: using Identity Provider {0}".format(idp)) 81 | self.hosts = Hosts(config, self.name, gateway_hostgroup, idp) 82 | self.allowed_ssh_hosts, self.hostgroups = self.hosts.list_allowed(True) 83 | 84 | def get_priv_key(self): 85 | try: 86 | # TODO: check better identity options 87 | privkey = paramiko.RSAKey.from_private_key_file( 88 | os.path.expanduser("~/.ssh/id_rsa")) 89 | except Exception as e: 90 | logging.error( 91 | "Core: Invalid Private Key for user {0} : {1} ".format( 92 | self.name, e.message)) 93 | raise Exception("Core: Invalid Private Key") 94 | else: 95 | return privkey 96 | 97 | def refresh_allowed_hosts(self, fromcache): 98 | logging.info( 99 | "Core: reloading hosts for user {0} from backened identity provider".format( 100 | self.name)) 101 | self.allowed_ssh_hosts, self.hostgroups = self.hosts.list_allowed( 102 | from_cache=fromcache) 103 | 104 | 105 | class Aker(object): 106 | """ Aker core module, this is the management module 107 | """ 108 | 109 | def __init__(self, log_level='INFO'): 110 | global config 111 | config = Configuration(config_file) 112 | self.config = config 113 | self.posix_user = getpass.getuser() 114 | self.log_level = config.log_level 115 | self.port = config.ssh_port 116 | 117 | # Setup logging first thing 118 | for handler in logging.root.handlers[:]: 119 | logging.root.removeHandler(handler) 120 | logging.basicConfig( 121 | format='%(asctime)s - %(levelname)s - %(message)s', 122 | filename=log_file, 123 | level=config.log_level) 124 | logging.info( 125 | "Core: Starting up, user={0} from={1}:{2}".format( 126 | self.posix_user, 127 | config.src_ip, 128 | config.src_port)) 129 | 130 | self.user = User(self.posix_user) 131 | 132 | def build_tui(self): 133 | logging.debug("Core: Drawing TUI") 134 | self.tui = tui.Window(self) 135 | self.tui.draw() 136 | self.tui.start() 137 | 138 | def init_connection(self, host): 139 | screen_size = self.tui.loop.screen.get_cols_rows() 140 | logging.debug("Core: pausing TUI") 141 | self.tui.pause() 142 | # TODO: check for shorter yet unique uuid 143 | session_uuid = uuid.uuid4() 144 | session_start_time = time.strftime("%Y%m%d-%H%M%S") 145 | session = SSHSession(self, host, session_uuid) 146 | # TODO: add err handling 147 | sniffer = SSHSniffer( 148 | self.posix_user, 149 | config.src_port, 150 | host, 151 | session_uuid, 152 | screen_size) 153 | session.attach_sniffer(sniffer) 154 | logging.info( 155 | "Core: Starting session UUID {0} for user {1} to host {2}".format( 156 | session_uuid, self.posix_user, host.name)) 157 | try: 158 | session.connect(screen_size) 159 | session.start_session() 160 | except Exception as e: 161 | logging.error( 162 | "Core: Error during connection or starting session UUID {0} for user {1} : {2} ".format( 163 | session_uuid, self.posix_user, e.message)) 164 | print(e) 165 | raw_input("Press Enter to continue...") 166 | finally: 167 | session.stop_sniffer() 168 | self.tui.restore() 169 | self.tui.hostlist.search.clear() # Clear selected hosts 170 | 171 | def session_end_callback(self, session): 172 | logging.info( 173 | "Core: Finished session UUID {0} for user {1} to host {2}".format( 174 | session.uuid, 175 | self.posix_user, 176 | session.host)) 177 | 178 | 179 | if __name__ == '__main__': 180 | Aker().build_tui() 181 | -------------------------------------------------------------------------------- /SSHClient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2016 Ahmed Nazmy 4 | # 5 | 6 | # Meta 7 | __license__ = "AGPLv3" 8 | __author__ = 'Ahmed Nazmy ' 9 | 10 | import logging 11 | import paramiko 12 | import socket 13 | import tty 14 | import sys 15 | import termios 16 | import signal 17 | import select 18 | import os 19 | import errno 20 | import time 21 | import fcntl 22 | import getpass 23 | 24 | 25 | TIME_OUT = 10 26 | 27 | 28 | class Client(object): 29 | def __init__(self, session): 30 | self._session = session 31 | self.sniffers = [] 32 | 33 | def attach_sniffer(self, sniffer): 34 | self.sniffers.append(sniffer) 35 | 36 | def stop_sniffer(self): 37 | for sniffer in self.sniffers: 38 | sniffer.stop() 39 | 40 | @staticmethod 41 | def get_console_dimensions(): 42 | cols, lines = 80, 24 43 | try: 44 | fmt = 'HH' 45 | buffer = struct.pack(fmt, 0, 0) 46 | result = fcntl.ioctl( 47 | sys.stdout.fileno(), 48 | termios.TIOCGWINSZ, 49 | buffer) 50 | columns, lines = struct.unpack(fmt, result) 51 | except Exception as e: 52 | pass 53 | finally: 54 | return columns, lines 55 | 56 | 57 | class SSHClient(Client): 58 | def __init__(self, session): 59 | super(SSHClient, self).__init__(session) 60 | self._socket = None 61 | self.channel = None 62 | logging.debug("Client: Client Created") 63 | 64 | def connect(self, ip, port, size): 65 | self._size = size 66 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 67 | self._socket.settimeout(TIME_OUT) 68 | self._socket.connect((ip, port)) 69 | logging.debug("SSHClient: Connected to {0}:{1}".format(ip, port)) 70 | 71 | def get_transport(self): 72 | transport = paramiko.Transport(self._socket) 73 | transport.set_keepalive(10) 74 | transport.start_client() 75 | return transport 76 | 77 | def start_session(self, user, auth_secret): 78 | try: 79 | transport = self.get_transport() 80 | if isinstance(auth_secret, basestring): 81 | logging.debug("SSHClient: Authenticating using password") 82 | transport.auth_password(user, auth_secret) 83 | else: 84 | try: 85 | logging.debug("SSHClient: Authenticating using key-pair") 86 | transport.auth_publickey(user, auth_secret) 87 | # Failed to authenticate with SSH key, so 88 | # try a password instead. 89 | except paramiko.ssh_exception.AuthenticationException: 90 | logging.debug("SSHClient: Authenticating using password") 91 | transport.auth_password(user, getpass.getpass()) 92 | self._start_session(transport) 93 | except Exception as e: 94 | logging.error( 95 | "SSHClient:: error authenticating : {0} ".format( 96 | e.message)) 97 | self._session.close_session() 98 | if transport: 99 | transport.close() 100 | self._socket.close() 101 | raise e 102 | 103 | def attach(self, sniffer): 104 | """ 105 | Adds a sniffer to the session 106 | """ 107 | self.sniffers.append(sniffer) 108 | 109 | def _set_sniffer_logs(self): 110 | for sniffer in self.sniffers: 111 | try: 112 | # Incase a sniffer without logs 113 | sniffer.set_logs() 114 | except AttributeError: 115 | pass 116 | 117 | def _start_session(self, transport): 118 | self.channel = transport.open_session() 119 | columns, lines = self._size 120 | self.channel.get_pty('xterm', columns, lines) 121 | self.channel.invoke_shell() 122 | try: 123 | signal.signal(signal.SIGWINCH, self.sigwinch) 124 | except BaseException: 125 | pass 126 | self._set_sniffer_logs() 127 | self.interactive_shell(self.channel) 128 | self.channel.close() 129 | self._session.close_session() 130 | transport.close() 131 | self._socket.close() 132 | 133 | def sigwinch(self, signal, data): 134 | columns, lines = get_console_dimensions() 135 | logging.debug( 136 | "SSHClient: setting terminal to %s columns and %s lines" % 137 | (columns, lines)) 138 | self.channel.resize_pty(columns, lines) 139 | for sniffer in self.sniffers: 140 | sniffer.sigwinch(columns, lines) 141 | 142 | def interactive_shell(self, chan): 143 | """ 144 | Handles ssh IO 145 | """ 146 | sys.stdout.flush() 147 | oldtty = termios.tcgetattr(sys.stdin) 148 | try: 149 | tty.setraw(sys.stdin.fileno()) 150 | tty.setcbreak(sys.stdin.fileno()) 151 | chan.settimeout(0.0) 152 | 153 | while True: 154 | try: 155 | r, w, e = select.select([chan, sys.stdin], [], []) 156 | flag = fcntl.fcntl(sys.stdin, fcntl.F_GETFL, 0) 157 | fcntl.fcntl( 158 | sys.stdin.fileno(), 159 | fcntl.F_SETFL, 160 | flag | os.O_NONBLOCK) 161 | except Exception as e: 162 | logging.error(e) 163 | pass 164 | 165 | if chan in r: 166 | try: 167 | x = chan.recv(10240) 168 | len_x = len(x) 169 | if len_x == 0: 170 | break 171 | for sniffer in self.sniffers: 172 | sniffer.channel_filter(x) 173 | try: 174 | nbytes = os.write(sys.stdout.fileno(), x) 175 | logging.debug( 176 | "SSHClient: wrote %s bytes to stdout" % nbytes) 177 | sys.stdout.flush() 178 | except OSError as msg: 179 | if msg.errno == errno.EAGAIN: 180 | continue 181 | except socket.timeout: 182 | pass 183 | 184 | if sys.stdin in r: 185 | try: 186 | buf = os.read(sys.stdin.fileno(), 4096) 187 | except OSError as e: 188 | logging.error(e) 189 | pass 190 | for sniffer in self.sniffers: 191 | sniffer.stdin_filter(buf) 192 | 193 | chan.send(buf) 194 | 195 | finally: 196 | logging.debug("SSHClient: interactive session ending") 197 | termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty) 198 | sys.stdin = open('/dev/tty') 199 | 200 | -------------------------------------------------------------------------------- /pyte/charsets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.charsets 4 | ~~~~~~~~~~~~~ 5 | 6 | This module defines ``G0`` and ``G1`` charset mappings the same way 7 | they are defined for linux terminal, see 8 | ``linux/drivers/tty/consolemap.c`` @ http://git.kernel.org 9 | 10 | .. note:: ``VT100_MAP`` and ``IBMPC_MAP`` were taken unchanged 11 | from linux kernel source and therefore are licensed 12 | under **GPL**. 13 | 14 | :copyright: (c) 2011-2012 by Selectel. 15 | :copyright: (c) 2012-2016 by pyte authors and contributors, 16 | see AUTHORS for details. 17 | :license: LGPL, see LICENSE for more details. 18 | """ 19 | 20 | from __future__ import absolute_import, unicode_literals 21 | 22 | from .compat import chr, map 23 | 24 | 25 | #: Latin1. 26 | LAT1_MAP = "".join(map(chr, range(256))) 27 | 28 | #: VT100 graphic character set. 29 | VT100_MAP = "".join(chr(c) for c in [ 30 | 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 31 | 0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x000e, 0x000f, 32 | 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 33 | 0x0018, 0x0019, 0x001a, 0x001b, 0x001c, 0x001d, 0x001e, 0x001f, 34 | 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 35 | 0x0028, 0x0029, 0x002a, 0x2192, 0x2190, 0x2191, 0x2193, 0x002f, 36 | 0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 37 | 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f, 38 | 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 39 | 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f, 40 | 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 41 | 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x00a0, 42 | 0x25c6, 0x2592, 0x2409, 0x240c, 0x240d, 0x240a, 0x00b0, 0x00b1, 43 | 0x2591, 0x240b, 0x2518, 0x2510, 0x250c, 0x2514, 0x253c, 0x23ba, 44 | 0x23bb, 0x2500, 0x23bc, 0x23bd, 0x251c, 0x2524, 0x2534, 0x252c, 45 | 0x2502, 0x2264, 0x2265, 0x03c0, 0x2260, 0x00a3, 0x00b7, 0x007f, 46 | 0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087, 47 | 0x0088, 0x0089, 0x008a, 0x008b, 0x008c, 0x008d, 0x008e, 0x008f, 48 | 0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097, 49 | 0x0098, 0x0099, 0x009a, 0x009b, 0x009c, 0x009d, 0x009e, 0x009f, 50 | 0x00a0, 0x00a1, 0x00a2, 0x00a3, 0x00a4, 0x00a5, 0x00a6, 0x00a7, 51 | 0x00a8, 0x00a9, 0x00aa, 0x00ab, 0x00ac, 0x00ad, 0x00ae, 0x00af, 52 | 0x00b0, 0x00b1, 0x00b2, 0x00b3, 0x00b4, 0x00b5, 0x00b6, 0x00b7, 53 | 0x00b8, 0x00b9, 0x00ba, 0x00bb, 0x00bc, 0x00bd, 0x00be, 0x00bf, 54 | 0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c6, 0x00c7, 55 | 0x00c8, 0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf, 56 | 0x00d0, 0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d7, 57 | 0x00d8, 0x00d9, 0x00da, 0x00db, 0x00dc, 0x00dd, 0x00de, 0x00df, 58 | 0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4, 0x00e5, 0x00e6, 0x00e7, 59 | 0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00ed, 0x00ee, 0x00ef, 60 | 0x00f0, 0x00f1, 0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00f7, 61 | 0x00f8, 0x00f9, 0x00fa, 0x00fb, 0x00fc, 0x00fd, 0x00fe, 0x00ff 62 | ]) 63 | 64 | #: IBM Codepage 437. 65 | IBMPC_MAP = "".join(chr(c) for c in [ 66 | 0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 67 | 0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c, 68 | 0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8, 69 | 0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc, 70 | 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 71 | 0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f, 72 | 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 73 | 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f, 74 | 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 75 | 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f, 76 | 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 77 | 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f, 78 | 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 79 | 0x0068, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x006f, 80 | 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 81 | 0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302, 82 | 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7, 83 | 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5, 84 | 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9, 85 | 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192, 86 | 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba, 87 | 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb, 88 | 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 89 | 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510, 90 | 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f, 91 | 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567, 92 | 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b, 93 | 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580, 94 | 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4, 95 | 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229, 96 | 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248, 97 | 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0 98 | ]) 99 | 100 | 101 | #: VAX42 character set. 102 | VAX42_MAP = "".join(chr(c) for c in [ 103 | 0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 104 | 0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c, 105 | 0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8, 106 | 0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc, 107 | 0x0020, 0x043b, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 108 | 0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f, 109 | 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 110 | 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x0435, 111 | 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 112 | 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f, 113 | 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 114 | 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f, 115 | 0x0060, 0x0441, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 116 | 0x0435, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x043a, 117 | 0x0070, 0x0071, 0x0442, 0x0073, 0x043b, 0x0435, 0x0076, 0x0077, 118 | 0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302, 119 | 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7, 120 | 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5, 121 | 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9, 122 | 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192, 123 | 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba, 124 | 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb, 125 | 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 126 | 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510, 127 | 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f, 128 | 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567, 129 | 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b, 130 | 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580, 131 | 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4, 132 | 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229, 133 | 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248, 134 | 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0 135 | ]) 136 | 137 | 138 | MAPS = { 139 | b"B": LAT1_MAP, 140 | b"0": VT100_MAP, 141 | b"U": IBMPC_MAP, 142 | b"V": VAX42_MAP 143 | } 144 | -------------------------------------------------------------------------------- /snoop.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2016 Ahmed Nazmy 4 | # 5 | 6 | # Meta 7 | __license__ = "AGPLv3" 8 | __author__ = 'Ahmed Nazmy ' 9 | 10 | 11 | import logging 12 | import codecs 13 | import re 14 | import time 15 | import json 16 | import os 17 | import pyte 18 | import errno 19 | 20 | 21 | class Sniffer(object): 22 | """ 23 | Captures session IO to files 24 | """ 25 | 26 | def __init__(self, user, src_port, host, uuid, screen_size): 27 | self.user = user 28 | self.host = host.name 29 | self.uuid = uuid 30 | self.src_port = src_port 31 | self.log_file = None 32 | self.log_timer = None 33 | self.log_cmds = None 34 | self.session_start_time = time.strftime("%H%M%S") 35 | self.session_start_date = time.strftime("%Y%m%d") 36 | self.session_date_time = time.strftime("%Y/%m/%d %H:%M:%S") 37 | self.today = time.strftime("%Y%m%d") 38 | self.session_log = "{0}_{1}_{2}_{3}".format( 39 | self.user, self.host, self.session_start_time, self.uuid) 40 | self.stream = None 41 | self.screen = None 42 | self.term_cols, self.term_rows = screen_size 43 | self._fake_terminal() 44 | logging.debug("Sniffer: Sniffer Created") 45 | 46 | def _fake_terminal(self): 47 | logging.debug( 48 | "Sniffer: Creating Pyte screen with cols %i and rows %i" % 49 | (self.term_cols, self.term_rows)) 50 | self.screen = pyte.Screen(self.term_cols, self.term_rows) 51 | self.stream = pyte.ByteStream() 52 | self.stream.attach(self.screen) 53 | 54 | def extract_command(self, buf): 55 | """ 56 | Handle terminal escape sequences 57 | """ 58 | command = "" 59 | # Remove CR (\x0D) in middle of data 60 | # probably will need better handling 61 | # See https://github.com/selectel/pyte/issues/66 62 | logging.debug("buf b4 is %s" % str(buf)) 63 | buf = buf.replace('\x0D', '') 64 | logging.debug("buf after is %s" % buf) 65 | try: 66 | self.stream.feed(buf) 67 | output = "".join( 68 | [l for l in self.screen.display if len(l.strip()) > 0]).strip() 69 | # for line in reversed(self.screen.buffer): 70 | #output = "".join(map(operator.attrgetter("data"), line)).strip() 71 | logging.debug("output is %s" % output) 72 | command = self.ps1_parser(output) 73 | except Exception as e: 74 | logging.error( 75 | "Sniffer: extract command error {0} ".format( 76 | e.message)) 77 | pass 78 | self.screen.reset() 79 | return command 80 | 81 | def ps1_parser(self, command): 82 | """ 83 | Extract commands from PS1 or mysql> 84 | """ 85 | result = None 86 | match = re.compile('\[?.*@.*\]?[\$#]\s').split(command) 87 | logging.debug("Sniffer: command match is %s" % match) 88 | if match: 89 | result = match[-1].strip() 90 | else: 91 | # No PS1, try finding mysql 92 | match = re.split('mysql>\s', command) 93 | logging.debug("Sniffer: command match is %s" % match) 94 | if match: 95 | result = match[-1].strip() 96 | return result 97 | 98 | @staticmethod 99 | def got_cr_lf(string): 100 | newline_chars = ['\n', '\r', '\r\n'] 101 | for char in newline_chars: 102 | if char in string: 103 | return True 104 | return False 105 | 106 | @staticmethod 107 | def findlast(s, substrs): 108 | i = -1 109 | result = None 110 | for substr in substrs: 111 | pos = s.rfind(substr) 112 | if pos > i: 113 | i = pos 114 | result = substr 115 | return result 116 | 117 | def set_logs(self): 118 | # local import 119 | from aker import session_log_dir 120 | today_sessions_dir = os.path.join( 121 | session_log_dir, self.session_start_date) 122 | log_file_path = os.path.join(today_sessions_dir, self.session_log) 123 | try: 124 | os.makedirs(today_sessions_dir, 0o777) 125 | os.chmod(today_sessions_dir, 0o777) 126 | except OSError as e: 127 | if e.errno != errno.EEXIST: 128 | logging.error( 129 | "Sniffer: set_logs OS Error {0} ".format( 130 | e.message)) 131 | try: 132 | log_file = open(log_file_path + '.log', 'a') 133 | log_timer = open(log_file_path + '.timer', 'a') 134 | log_cmds = log_file_path + '.cmds' 135 | except IOError: 136 | logging.debug("Sniffer: set_logs IO error {0} ".format(e.message)) 137 | 138 | log_file.write('Session Start %s\r\n' % self.session_date_time) 139 | self.log_file = log_file 140 | self.log_timer = log_timer 141 | self.log_cmds = log_cmds 142 | 143 | def stop(self): 144 | session_end = time.strftime("%Y/%m/%d %H:%M:%S") 145 | # Sayonara 146 | jsonmsg = {'ver': '1', 147 | 'host': self.host, 148 | 'user': self.user, 149 | 'session': str(self.uuid), 150 | 'sessionstart': self.session_date_time, 151 | 'sessionend': session_end, 152 | 'timing': session_end, 153 | } 154 | 155 | try: 156 | with open(self.log_cmds, 'a') as outfile: 157 | jsonout = json.dumps(jsonmsg) 158 | outfile.write(jsonout + '\n') 159 | except Exception as e: 160 | logging.error( 161 | "Sniffer: close session files error {0} ".format( 162 | e.message)) 163 | 164 | try: 165 | self.log_file.write('Session End %s' % session_end) 166 | self.log_file.close() 167 | self.log_timer.close() 168 | except: 169 | logging.debug("Sniffer: Failed to close files. Likely due to a session close before establishing.") 170 | 171 | 172 | class SSHSniffer(Sniffer): 173 | def __init__(self, user, src_port, host, uuid, screen_size): 174 | super( 175 | SSHSniffer, 176 | self).__init__( 177 | user, 178 | src_port, 179 | host, 180 | uuid, 181 | screen_size) 182 | self.vim_regex = re.compile(r'\x1b\[\?1049', re.X) 183 | self.vim_data = "" 184 | self.stdin_active = False 185 | self.in_alt_mode = False 186 | self.buf = "" 187 | self.vim_data = "" 188 | self.before_timestamp = time.time() 189 | self.start_timestamp = self.before_timestamp 190 | self.start_alt_mode = set(['\x1b[?47h', '\x1b[?1049h', '\x1b[?1047h']) 191 | self.end_alt_mode = set(['\x1b[?47l', '\x1b[?1049l', '\x1b[?1047l']) 192 | self.alt_mode_flags = tuple( 193 | self.start_alt_mode) + tuple(self.end_alt_mode) 194 | 195 | def channel_filter(self, x): 196 | now_timestamp = time.time() 197 | # Write delta time and number of chrs to timer log 198 | self.log_timer.write( 199 | '%s %s\n' % 200 | (round( 201 | now_timestamp - 202 | self.before_timestamp, 203 | 4), 204 | len(x))) 205 | self.log_timer.flush() 206 | self.log_file.write(x) 207 | self.log_file.flush() 208 | self.before_timestamp = now_timestamp 209 | self.vim_data += x 210 | # Accumlate data when in stdin_active 211 | if self.stdin_active: 212 | self.buf += x 213 | 214 | def stdin_filter(self, x): 215 | self.stdin_active = True 216 | flag = self.findlast(self.vim_data, self.alt_mode_flags) 217 | if flag is not None: 218 | if flag in self.start_alt_mode: 219 | logging.debug("In ALT mode") 220 | self.in_alt_mode = True 221 | elif flag in self.end_alt_mode: 222 | logging.debug("Out of ALT mode") 223 | self.in_alt_mode = False 224 | # We got CR/LF? 225 | if self.got_cr_lf(str(x)): 226 | if not self.in_alt_mode: 227 | logging.debug("Sniffer: self.buf is : %s" % self.buf) 228 | 229 | # Did x capture the last character and CR ? 230 | if len(str(x)) > 1: 231 | self.buf = self.buf + x 232 | logging.debug("Sniffer: x is : %s" % x) 233 | 234 | self.buf = self.extract_command(self.buf) 235 | 236 | # If we got something back, log it 237 | if self.buf is not None and self.buf != "": 238 | now = time.strftime("%Y/%m/%d %H:%M:%S") 239 | # TODO: add a separate object for json later 240 | jsonmsg = { 241 | 'ver': '1', 242 | 'host': self.host, 243 | 'user': self.user, 244 | 'session': str( 245 | self.uuid), 246 | 'sessionstart': self.session_date_time, 247 | 'timing': now, 248 | 'cmd': codecs.decode( 249 | self.buf, 250 | 'UTF-8', 251 | "replace")} 252 | try: 253 | with open(self.log_cmds, 'a') as outfile: 254 | # ELK's filebeat require a jsonlines like file 255 | # (http://jsonlines.org/) 256 | jsonout = json.dumps(jsonmsg) 257 | outfile.write(jsonout + '\n') 258 | except Exception as e: 259 | logging.error( 260 | "Sniffer: stdin_filter error {0} ".format( 261 | e.message)) 262 | jsonmsg = {} 263 | 264 | self.buf = "" 265 | self.vim_data = "" 266 | self.stdin_active = False 267 | 268 | def sigwinch(self, columns, lines): 269 | logging.debug( 270 | "Sniffer: Setting Pyte screen size to cols %i and rows %i" % 271 | (columns, lines)) 272 | self.screen.resize(columns, lines) 273 | -------------------------------------------------------------------------------- /hosts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2016 Ahmed Nazmy 4 | # 5 | 6 | # Meta 7 | __license__ = "AGPLv3" 8 | __author__ = 'Ahmed Nazmy ' 9 | 10 | import json 11 | import logging 12 | import redis 13 | from IdPFactory import IdPFactory 14 | 15 | 16 | class HostGroup(object): 17 | """ 18 | Class representing single hostgroup.A hostgroup 19 | holds a list of hosts/servers that are members of it. 20 | Attributes 21 | name: Hostgroup name 22 | """ 23 | 24 | def __init__(self, name): 25 | self.name = name 26 | self.hosts = [] 27 | 28 | def __str__(self): 29 | return "fqdn:%s, port:%s, hosts:%s" % (self.fqdn, self.ssh_port, self.hosts) 30 | 31 | def __iter__(self): 32 | return self 33 | 34 | def add_host(self, hostname): 35 | self.hosts.append(hostname) 36 | 37 | 38 | class Host(object): 39 | """ 40 | Class representing a single server entry, 41 | each Host/server has to be a member one or more 42 | hostgroup. Servers have the following attributes : 43 | 44 | Attributes 45 | fqdn: fully qualified domain name 46 | ssh_port: server ssh port , default is 22 47 | hostgroups: list of hostgroups this server is part of 48 | """ 49 | 50 | def __init__(self, name, fqdn, ssh_port, memberof_hostgroups): 51 | self.fqdn = fqdn 52 | self.name = name 53 | self.ssh_port = ssh_port 54 | self.hostgroups = memberof_hostgroups 55 | 56 | def equal(self, server): 57 | if self.fqdn == server.fqdn and self.ssh_port == server.ssh_port: 58 | return True 59 | else: 60 | return False 61 | 62 | def __str__(self): 63 | return "fqdn:%s, ssh_port:%d, hostgroups:%s" % ( 64 | self.fqdn, int(self.ssh_port), self.hostgroups) 65 | 66 | def __iter__(self): 67 | return self 68 | 69 | 70 | class Hosts(object): 71 | """ 72 | A class to handle all interactions with hosts allowed to the user, 73 | it handles operations between cache(Redis) and backend identity providers 74 | like IPA, Json etc.. 75 | 76 | The responsibility of defining HBAC (hosts allowed to the user) lies on the 77 | underlaying identity provider . 78 | """ 79 | 80 | def __init__(self, config, username, gateway_hostgroup, idp): 81 | self._allowed_ssh_hosts = {} 82 | self.user = username 83 | self._hostgroups = {} 84 | # username is the redis key, well kinda 85 | self.hosts_cache_key = self.user + ":hosts" 86 | self.hostgroups_cache_key = self.user + ":hostgroups" 87 | self.gateway_hostgroup = gateway_hostgroup 88 | self.idp = IdPFactory.getIdP(idp)(config, username, gateway_hostgroup) 89 | # TODO: do we need a configurable redis host? 90 | self.redis = self._init_redis_conn('localhost') 91 | 92 | def _init_redis_conn(self, RedisHost): 93 | redis_connection = redis.StrictRedis( 94 | RedisHost, db=0, decode_responses=True) 95 | try: 96 | if redis_connection.ping(): 97 | return redis_connection 98 | except Exception as e: 99 | logging.error( 100 | "Hosts: all subsequent calls will fallback to backened idp, cache error: {0}".format( 101 | e.message)) 102 | return None 103 | 104 | def _load_hosts_from_cache(self, hkey): 105 | 106 | result = self.redis.hgetall(hkey) 107 | cached = False 108 | if result is not None: 109 | try: 110 | for k, v in result.iteritems(): 111 | # Deserialize back from redis 112 | hostentry = Host( 113 | json.loads(v)['name'], 114 | json.loads(v)['fqdn'], 115 | json.loads(v)['ssh_port'], 116 | json.loads(v)['hostgroups']) 117 | self._allowed_ssh_hosts[hostentry.name] = hostentry 118 | logging.debug( 119 | "Hosts: loading host {0} from cache".format( 120 | hostentry.name)) 121 | cached = True 122 | except Exception as e: 123 | logging.error("Hosts: redis error: {0}".format(e.message)) 124 | cached = False 125 | else: 126 | logging.info( 127 | "Hosts: no hosts loaded from cache for user %s" % 128 | self.user) 129 | cached = False 130 | 131 | return cached 132 | 133 | def _save_hosts_to_cache(self, hosts): 134 | """ 135 | hosts passed to this function should be a dict of Host object 136 | """ 137 | # Delete existing cache if any 138 | try: 139 | self._del_cache_key(self.hosts_cache_key) 140 | logging.debug( 141 | "Hosts: deleting hosts for user {0} from cache".format( 142 | self.user)) 143 | except Exception as e: 144 | logging.error( 145 | "Hosts: error deleting hosts from cache: {0}".format( 146 | e.message)) 147 | 148 | # populate cache with new entries 149 | for host in hosts.values(): 150 | try: 151 | # Serialize (cache) Host objects in redis under $user:hosts 152 | self.redis.hset( 153 | self.hosts_cache_key, 154 | host.name, 155 | json.dumps( 156 | vars(host))) 157 | logging.debug( 158 | "Hosts: adding host {0} to cache".format( 159 | host.name)) 160 | hostentry = None 161 | except Exception as e: 162 | logging.error( 163 | "Hosts: error saving to cache : {0}".format( 164 | e.message)) 165 | 166 | def _load_hostgroups_from_cache(self, hkey): 167 | 168 | result = self.redis.hgetall(hkey) 169 | cached = False 170 | if result is not None: 171 | try: 172 | for k, v in result.iteritems(): 173 | # Deserialize back from redis 174 | hostgroupentry = HostGroup(json.loads(v)['name']) 175 | for host in json.loads(v)['hosts']: 176 | hostgroupentry.add_host(host) 177 | self._hostgroups[hostgroupentry.name] = hostgroupentry 178 | cached = True 179 | except Exception as e: 180 | logging.error("Hostgroups: redis error: {0}".format(e.message)) 181 | cached = False 182 | else: 183 | logging.info( 184 | "Hostgroups: no hostgroups loaded from cache for user %s" % 185 | self.user) 186 | cached = False 187 | return cached 188 | 189 | def _save_hostgroups_to_cache(self, hostgroups): 190 | """ 191 | hosts passed to this function should be a dict of HostGroup object 192 | """ 193 | 194 | # Delete existing cache if any 195 | try: 196 | self._del_cache_key(self.hostgroups_cache_key) 197 | logging.debug( 198 | "Hosts: deleting hostgroups for user {0} from cache".format( 199 | self.user)) 200 | except Exception as e: 201 | logging.error( 202 | "Hosts: error deleting hostgroups from cache: {0}".format( 203 | e.message)) 204 | 205 | for hostgroup in hostgroups.values(): 206 | try: 207 | logging.debug( 208 | "Hosts: adding hostgroup {0} to cache".format( 209 | hostgroup.name)) 210 | self.redis.hset( 211 | self.hostgroups_cache_key, 212 | hostgroup.name, 213 | json.dumps( 214 | vars(hostgroup))) 215 | except Exception as e: 216 | logging.error( 217 | "Hosts: error saving to cache : {0}".format( 218 | e.message)) 219 | 220 | def _del_cache_key(self, hkey): 221 | try: 222 | self.redis.delete(hkey) 223 | except Exception as e: 224 | logging.error( 225 | "Hosts: error deleting from cache : {0}".format( 226 | e.message)) 227 | 228 | def list_allowed(self, from_cache): 229 | """ 230 | This function is the interface to the TUI 231 | """ 232 | 233 | cached = False 234 | 235 | # Clear our dicts first 236 | self._allowed_ssh_hosts.clear() 237 | self._hostgroups.clear() 238 | 239 | # load from cache 240 | if from_cache: 241 | logging.debug("Hosts: Trying to load from cache") 242 | # is redis up ? 243 | if self.redis is not None: 244 | cached = self._load_hosts_from_cache(self.hosts_cache_key) 245 | # FIXME: using cached twice!, need better approach 246 | cached = self._load_hostgroups_from_cache( 247 | self.hostgroups_cache_key) 248 | 249 | # backened cache has some entries for us? 250 | if cached is True: 251 | logging.info("Hosts: loading hosts from cache") 252 | return self._allowed_ssh_hosts, self._hostgroups 253 | 254 | # No cached objects 255 | else: 256 | logging.debug("Hosts: Trying to load from backend") 257 | # Passing the baton from the backend 258 | self._backend_hosts = self.idp.list_allowed() 259 | 260 | # Build Host() objects out of items we got from backend 261 | for backend_host, backend_host_attributes in self._backend_hosts.iteritems(): 262 | hostentry = Host( 263 | backend_host_attributes['name'], 264 | backend_host_attributes['fqdn'], 265 | backend_host_attributes['ssh_port'], 266 | backend_host_attributes['hostgroups']) 267 | self._allowed_ssh_hosts[hostentry.name] = hostentry 268 | 269 | # Build HostGroup() objects from items we got from backend 270 | for group in hostentry.hostgroups: 271 | if group not in self._hostgroups: 272 | self._hostgroups[group] = HostGroup(group) 273 | self._hostgroups[group].add_host(hostentry.name) 274 | else: 275 | self._hostgroups[group].add_host(hostentry.name) 276 | # Save entries we got to the cache 277 | if self.redis is not None: 278 | self._save_hosts_to_cache(self._allowed_ssh_hosts) 279 | self._save_hostgroups_to_cache(self._hostgroups) 280 | return self._allowed_ssh_hosts, self._hostgroups 281 | -------------------------------------------------------------------------------- /tui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2016 Ahmed Nazmy 4 | # 5 | 6 | # Meta 7 | __license__ = "AGPLv3" 8 | __author__ = 'Ahmed Nazmy ' 9 | 10 | 11 | import urwid 12 | import aker 13 | import signal 14 | import logging 15 | import os 16 | from popup import SimplePopupLauncher 17 | 18 | 19 | class Listing(urwid.ListBox): 20 | """ 21 | Base class to handle listbox actions 22 | """ 23 | 24 | def __init__(self, items=None): 25 | self.search = Search() 26 | self.search.update_text("Type to search:\n") 27 | self._items = [] 28 | if items is not None: 29 | for item in items: 30 | listitem = MenuItem("%s" % (item)) 31 | self._items.append( 32 | urwid.AttrMap( 33 | listitem, 34 | 'body', 35 | focus_map='SSH_focus')) 36 | super(Listing, self).__init__(urwid.SimpleFocusListWalker(self._items)) 37 | 38 | def updatelist(self, items): 39 | self.empty() 40 | for item in items: 41 | self.add_item(item) 42 | 43 | def add_item(self, item): 44 | listitem = MenuItem("%s" % (item)) 45 | self.body.append( 46 | urwid.AttrMap( 47 | listitem, 48 | 'body', 49 | focus_map='SSH_focus')) 50 | 51 | def empty(self): 52 | del self.body[:] # clear listbox 53 | 54 | def get_selected(self): 55 | return self.focus 56 | 57 | def get_box(self): 58 | self.search.clear() 59 | return urwid.Frame(urwid.AttrWrap(self, 'body'), header=self.search) 60 | 61 | 62 | class HostList(Listing): 63 | """ 64 | Class to handle hosts screen actions, 65 | keypresses for now. 66 | """ 67 | 68 | def __init__(self, hosts=None): 69 | super(HostList, self).__init__(hosts) 70 | 71 | def keypress(self, size, key): 72 | if (key == 'enter') or (key == 'right'): 73 | urwid.emit_signal( 74 | self, 75 | 'connect', 76 | self.focus.original_widget.get_caption()) 77 | key = None 78 | elif key == 'esc': 79 | if self.search.get_edit_text() == "": 80 | key = 'left' 81 | else: 82 | self.search.clear() 83 | key = None 84 | # Unless its arrow keys send keypress to search box, 85 | # implies emitting EditBox "change" signal 86 | elif key not in ['right', 'down', 'up', 'left', 'page up', 'page down']: 87 | self.search.keypress((10,), key) 88 | return super(HostList, self).keypress(size, key) 89 | 90 | 91 | class HostGroupList(Listing): 92 | """ 93 | Class to handle hostgroups screen actions, 94 | keypresses for now. 95 | """ 96 | 97 | def __init__(self, hostgroups=None): 98 | super(HostGroupList, self).__init__(hostgroups) 99 | 100 | def keypress(self, size, key): 101 | if (key == 'enter') or (key == 'right'): 102 | # emit signal to call hostgroup_chosen_handler with MenuItem caption, 103 | # caption is group name showing on screen 104 | if self.focus is not None: 105 | urwid.emit_signal( 106 | self, 107 | 'group_chosen', 108 | self.focus.original_widget.get_caption()) 109 | key = None 110 | elif key == 'esc': 111 | self.search.clear() 112 | key = None 113 | # Unless its arrow keys send keypress to search box, 114 | # implies emitting EditBox "change" signal 115 | elif key not in ['right', 'down', 'up', 'left', 'page up', 'page down']: 116 | self.search.keypress((10,), key) 117 | return super(HostGroupList, self).keypress(size, key) 118 | 119 | 120 | class Header(urwid.Columns): 121 | def __init__(self, text): 122 | self.text = text 123 | self.header_widget = urwid.Text(self.text, align='left') 124 | self.popup = SimplePopupLauncher() 125 | self.popup_padding = urwid.Padding(self.popup, 'right', 20) 126 | self.popup_map = urwid.AttrMap(self.popup_padding, 'indicator') 127 | self.header_map = urwid.AttrMap(self.header_widget, 'head') 128 | super(Header, self).__init__([self.header_map, self.popup_map]) 129 | 130 | def update_text(self, text): 131 | self.text = text 132 | self.header_map.original_widget.set_text(self.text) 133 | 134 | def popup_message(self, message): 135 | logging.debug("TUI: popup message is {0}".format(message)) 136 | self.popup.message = str(message) 137 | self.popup.open_pop_up() 138 | 139 | 140 | class Footer(urwid.AttrMap): 141 | def __init__(self, text): 142 | self.footer_text = urwid.Text(text, align='center') 143 | super(Footer, self).__init__(self.footer_text, 'foot') 144 | 145 | 146 | class Search(urwid.Edit): 147 | def __init__(self): 148 | super(Search, self).__init__() 149 | 150 | def update_text(self, caption): 151 | self.set_caption(caption) 152 | 153 | def clear(self): 154 | self.set_edit_text("") 155 | 156 | 157 | class MenuItem(urwid.Text): 158 | def __init__(self, caption): 159 | self.caption = caption 160 | urwid.Text.__init__(self, self.caption) 161 | 162 | def keypress(self, size, key): 163 | return key 164 | 165 | def selectable(self): 166 | return True 167 | 168 | def get_caption(self): 169 | return str(self.caption) 170 | 171 | 172 | class Window(object): 173 | """ 174 | Where all the Tui magic happens, 175 | handles creating urwid widgets and 176 | user interactions 177 | """ 178 | 179 | def __init__(self, aker_core): 180 | self.aker = aker_core 181 | self.user = self.aker.user 182 | self.current_hostgroup = "" 183 | self.set_palette() 184 | 185 | def set_palette(self): 186 | self.palette = [ 187 | ('body', 'black', 'light gray'), # Normal Text 188 | ('focus', 'light green', 'black', 'standout'), # Focus 189 | ('head', 'white', 'dark gray', 'standout'), # Header 190 | ('foot', 'light gray', 'dark gray'), # Footer Separator 191 | ('key', 'light green', 'dark gray', 'bold'), 192 | ('title', 'white', 'black', 'bold'), 193 | ('popup', 'white', 'dark red'), 194 | ('msg', 'yellow', 'dark gray'), 195 | ('SSH', 'dark blue', 'light gray', 'underline'), 196 | ('SSH_focus', 'light green', 'dark blue', 'standout')] # Focus 197 | 198 | def draw(self): 199 | self.header_text = [ 200 | ('key', "Aker"), " ", 201 | ('msg', "User:"), 202 | ('key', "%s" % self.user.name), " "] 203 | 204 | self.footer_text = [ 205 | ('msg', "Move:"), 206 | ('key', "Up"), ",", 207 | ('key', "Down"), ",", 208 | ('key', "Left"), ",", 209 | ('key', "Right"), ",", 210 | ('key', "PgUp"), ",", 211 | ('key', "PgDn"), ",", 212 | ('msg', "Select:"), 213 | ('key', "Enter"), " ", 214 | ('msg', "Refresh:"), 215 | ('key', "F5"), " ", 216 | ('msg', "Quit:"), 217 | ('key', "F9"), " ", 218 | ('msg', "By:"), 219 | ('key', "Ahmed Nazmy")] 220 | 221 | # Define widgets 222 | self.header = Header(self.header_text) 223 | self.footer = Footer(self.footer_text) 224 | self.hostgrouplist = HostGroupList(list(self.user.hostgroups.keys())) 225 | self.hostlist = HostList(list(self.user.allowed_ssh_hosts.keys())) 226 | self.topframe = urwid.Frame( 227 | self.hostgrouplist.get_box(), 228 | header=self.header, 229 | footer=self.footer) 230 | self.screen = urwid.raw_display.Screen() 231 | 232 | # Register signals 233 | urwid.register_signal(HostList, ['connect']) 234 | urwid.register_signal(HostGroupList, ['group_chosen']) 235 | 236 | # Connect signals 237 | urwid.connect_signal( 238 | self.hostgrouplist.search, 239 | 'change', 240 | self.group_search_handler) 241 | urwid.connect_signal( 242 | self.hostgrouplist, 243 | 'group_chosen', 244 | self.group_chosen_handler) 245 | urwid.connect_signal( 246 | self.hostlist.search, 247 | 'change', 248 | self.host_search_handler) 249 | urwid.connect_signal( 250 | self.hostlist, 251 | 'connect', 252 | self.host_chosen_handler) 253 | 254 | self.loop = urwid.MainLoop( 255 | self.topframe, 256 | palette=self.palette, 257 | unhandled_input=self._input_handler, 258 | screen=self.screen, 259 | pop_ups=True) 260 | 261 | def _input_handler(self, key): 262 | if not urwid.is_mouse_event(key): 263 | if key == 'f5': 264 | self.update_lists() 265 | elif key == 'f9': 266 | logging.info( 267 | "TUI: User {0} logging out of Aker".format( 268 | self.user.name)) 269 | raise urwid.ExitMainLoop() 270 | elif key == 'left': 271 | # For now if its not hostgroup window left should bring it up 272 | if self.topframe.get_body() != self.hostgrouplist.get_box(): 273 | self.current_hostgroup = "" 274 | self.hostlist.empty() 275 | self.header.update_text(self.header_text) 276 | self.topframe.set_body(self.hostgrouplist.get_box()) 277 | else: 278 | logging.debug( 279 | "TUI: User {0} unhandled input : {1}".format( 280 | self.user.name, key)) 281 | 282 | def group_search_handler(self, search, search_text): 283 | logging.debug( 284 | "TUI: Group search handler called with text {0}".format(search_text)) 285 | matchinghostgroups = [] 286 | for hostgroup in self.user.hostgroups.keys(): 287 | if search_text in hostgroup: 288 | logging.debug( 289 | "TUI: hostgroup {1} matches search text {0}".format( 290 | search_text, hostgroup)) 291 | matchinghostgroups.append(hostgroup) 292 | self.hostgrouplist.updatelist(matchinghostgroups) 293 | 294 | def host_search_handler(self, search, search_text): 295 | logging.debug( 296 | "TUI: Host search handler called with text {0}".format(search_text)) 297 | matchinghosts = [] 298 | for host in self.user.hostgroups[self.current_hostgroup].hosts: 299 | if search_text in host: 300 | logging.debug( 301 | "TUI: host {1} matches search text {0}".format( 302 | search_text, host)) 303 | matchinghosts.append(host) 304 | self.hostlist.updatelist(sorted(matchinghosts)) 305 | 306 | def group_chosen_handler(self, hostgroup): 307 | logging.debug( 308 | "TUI: user %s chose hostgroup %s " % 309 | (self.user.name, hostgroup)) 310 | self.current_hostgroup = hostgroup 311 | self.hostlist.empty() 312 | matchinghosts = [] 313 | for host in self.user.hostgroups[self.current_hostgroup].hosts: 314 | logging.debug( 315 | "TUI: host {1} is in hostgroup {0}, adding".format( 316 | hostgroup, host)) 317 | matchinghosts.append(host) 318 | self.hostlist.updatelist(sorted(matchinghosts)) 319 | header_text = [ 320 | ('key', "Aker"), " ", 321 | ('msg', "User:"), 322 | ('key', "%s" % self.user.name), " ", 323 | ('msg', "HostGroup:"), 324 | ('key', "%s" % self.current_hostgroup)] 325 | self.header.update_text(header_text) 326 | self.topframe.set_body(self.hostlist.get_box()) 327 | 328 | def host_chosen_handler(self, choice): 329 | host = choice 330 | logging.debug("TUI: user %s chose server %s " % (self.user.name, host)) 331 | self.aker.init_connection(self.user.allowed_ssh_hosts[host]) 332 | 333 | def update_lists(self): 334 | logging.info( 335 | "TUI: Refreshing entries for user {0}".format( 336 | self.aker.user.name)) 337 | self.aker.user.refresh_allowed_hosts(False) 338 | self.hostgrouplist.empty() 339 | for hostgroup in self.user.hostgroups.keys(): 340 | self.hostgrouplist.add_item(hostgroup) 341 | if self.current_hostgroup != "": 342 | self.hostlist.empty() 343 | for host in self.user.hostgroups[self.current_hostgroup].hosts: 344 | self.hostlist.add_item(host) 345 | self.header.popup_message("Entries Refreshed") 346 | 347 | def start(self): 348 | logging.debug("TUI: tui started") 349 | self.loop.run() 350 | 351 | def stop(self): 352 | logging.debug(u"TUI: tui stopped") 353 | raise urwid.ExitMainLoop() 354 | 355 | def pause(self): 356 | logging.debug("TUI: tui paused") 357 | self.loop.screen.stop() 358 | urwid.emit_signal(self.loop.screen, urwid.display_common.INPUT_DESCRIPTORS_CHANGED) 359 | 360 | def restore(self): 361 | logging.debug("TUI restored") 362 | self.loop.screen.start() 363 | urwid.emit_signal(self.loop.screen, urwid.display_common.INPUT_DESCRIPTORS_CHANGED) 364 | -------------------------------------------------------------------------------- /pyte/streams.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.streams 4 | ~~~~~~~~~~~~ 5 | 6 | This module provides three stream implementations with different 7 | features; for starters, here's a quick example of how streams are 8 | typically used: 9 | 10 | >>> import pyte 11 | >>> screen = pyte.Screen(80, 24) 12 | >>> stream = pyte.Stream(screen) 13 | >>> stream.feed(b"\x1B[5B") # Move the cursor down 5 rows. 14 | >>> screen.cursor.y 15 | 5 16 | 17 | :copyright: (c) 2011-2012 by Selectel. 18 | :copyright: (c) 2012-2016 by pyte authors and contributors, 19 | see AUTHORS for details. 20 | :license: LGPL, see LICENSE for more details. 21 | """ 22 | 23 | from __future__ import absolute_import, unicode_literals 24 | 25 | import itertools 26 | import os 27 | import re 28 | import sys 29 | import warnings 30 | from collections import defaultdict 31 | 32 | from . import control as ctrl, escape as esc 33 | from .compat import str 34 | 35 | 36 | class Stream(object): 37 | """A stream is a state machine that parses a stream of bytes and 38 | dispatches events based on what it sees. 39 | 40 | :param pyte.screens.Screen screen: a screen to dispatch events to. 41 | :param bool strict: check if a given screen implements all required 42 | events. 43 | 44 | .. note:: 45 | 46 | Stream only accepts :func:`bytes` as input. Decoding it into text 47 | is the responsibility of the :class:`~pyte.screens.Screen`. 48 | 49 | .. versionchanged 0.6.0:: 50 | 51 | For performance reasons the binding between stream events and 52 | screen methods was made static. As a result, the stream **will 53 | not** dispatch events to methods added to screen **after** the 54 | stream was created. 55 | 56 | .. seealso:: 57 | 58 | `man console_codes `_ 59 | For details on console codes listed bellow in :attr:`basic`, 60 | :attr:`escape`, :attr:`csi`, :attr:`sharp` and :attr:`percent`. 61 | """ 62 | 63 | #: Control sequences, which don't require any arguments. 64 | basic = { 65 | ctrl.BEL: "bell", 66 | ctrl.BS: "backspace", 67 | ctrl.HT: "tab", 68 | ctrl.LF: "linefeed", 69 | ctrl.VT: "linefeed", 70 | ctrl.FF: "linefeed", 71 | ctrl.CR: "carriage_return", 72 | ctrl.SO: "shift_out", 73 | ctrl.SI: "shift_in", 74 | } 75 | 76 | #: non-CSI escape sequences. 77 | escape = { 78 | esc.RIS: "reset", 79 | esc.IND: "index", 80 | esc.NEL: "linefeed", 81 | esc.RI: "reverse_index", 82 | esc.HTS: "set_tab_stop", 83 | esc.DECSC: "save_cursor", 84 | esc.DECRC: "restore_cursor", 85 | } 86 | 87 | #: "sharp" escape sequences -- ``ESC # ``. 88 | sharp = { 89 | esc.DECALN: "alignment_display", 90 | } 91 | 92 | #: CSI escape sequences -- ``CSI P1;P2;...;Pn ``. 93 | csi = { 94 | esc.ICH: "insert_characters", 95 | esc.CUU: "cursor_up", 96 | esc.CUD: "cursor_down", 97 | esc.CUF: "cursor_forward", 98 | esc.CUB: "cursor_back", 99 | esc.CNL: "cursor_down1", 100 | esc.CPL: "cursor_up1", 101 | esc.CHA: "cursor_to_column", 102 | esc.CUP: "cursor_position", 103 | esc.ED: "erase_in_display", 104 | esc.EL: "erase_in_line", 105 | esc.IL: "insert_lines", 106 | esc.DL: "delete_lines", 107 | esc.DCH: "delete_characters", 108 | esc.ECH: "erase_characters", 109 | esc.HPR: "cursor_forward", 110 | esc.DA: "report_device_attributes", 111 | esc.VPA: "cursor_to_line", 112 | esc.VPR: "cursor_down", 113 | esc.HVP: "cursor_position", 114 | esc.TBC: "clear_tab_stop", 115 | esc.SM: "set_mode", 116 | esc.RM: "reset_mode", 117 | esc.SGR: "select_graphic_rendition", 118 | esc.DSR: "report_device_status", 119 | esc.DECSTBM: "set_margins", 120 | esc.HPA: "cursor_to_column" 121 | } 122 | 123 | #: A set of all events dispatched by the stream. 124 | events = frozenset(itertools.chain( 125 | basic.values(), escape.values(), sharp.values(), csi.values(), 126 | ["define_charset", "select_other_charset"], 127 | ["set_icon", "set_title"], # OSC. 128 | ["draw", "debug"])) 129 | 130 | #: A regular expression pattern matching everything what can be 131 | #: considered plain text. 132 | _special = set([ctrl.ESC, ctrl.CSI, ctrl.NUL, ctrl.DEL, ctrl.OSC]) 133 | _special.update(basic) 134 | _text_pattern = re.compile( 135 | b"[^" + b"".join(map(re.escape, _special)) + b"]+") 136 | del _special 137 | 138 | def __init__(self, screen=None, strict=True): 139 | self.listener = None 140 | self.strict = False 141 | 142 | if screen is not None: 143 | self.attach(screen) 144 | 145 | def attach(self, screen, only=()): 146 | """Adds a given screen to the listener queue. 147 | 148 | :param pyte.screens.Screen screen: a screen to attach to. 149 | :param list only: a list of events you want to dispatch to a 150 | given screen (empty by default, which means 151 | -- dispatch all events). 152 | """ 153 | if self.strict: 154 | for event in self.events: 155 | if not hasattr(screen, event): 156 | error_message = "{0} is missing {1}".format(screen, event) 157 | raise TypeError(error_message) 158 | if self.listener is not None: 159 | warnings.warn("As of version 0.6.0 the listener queue is " 160 | "restricted to a single element. Existing " 161 | "listener {0} will be replaced." 162 | .format(self.listener), DeprecationWarning) 163 | 164 | self.listener = screen 165 | self._parser = self._parser_fsm() 166 | self._taking_plain_text = next(self._parser) 167 | 168 | def detach(self, screen): 169 | """Remove a given screen from the listener queue and fails 170 | silently if it's not attached. 171 | 172 | :param pyte.screens.Screen screen: a screen to detach. 173 | """ 174 | if screen is self.listener: 175 | self.listener = None 176 | 177 | def feed(self, data): 178 | """Consume a string and advances the state as necessary. 179 | 180 | :param bytes data: a blob of data to feed from. 181 | """ 182 | if isinstance(data, str): 183 | warnings.warn("As of version 0.6.0 ``pyte.streams.Stream.feed``" 184 | "requires input in bytes. This warnings will become " 185 | "and error in 0.6.1.") 186 | data = data.encode("utf-8") 187 | elif not isinstance(data, bytes): 188 | raise TypeError("{0} requires bytes input" 189 | .format(self.__class__.__name__)) 190 | 191 | send = self._parser.send 192 | draw = self.listener.draw 193 | match_text = self._text_pattern.match 194 | taking_plain_text = self._taking_plain_text 195 | 196 | # TODO: use memoryview? 197 | length = len(data) 198 | offset = 0 199 | while offset < length: 200 | if taking_plain_text: 201 | match = match_text(data, offset) 202 | if match: 203 | start, offset = match.span() 204 | draw(data[start:offset]) 205 | else: 206 | taking_plain_text = False 207 | else: 208 | taking_plain_text = send(data[offset:offset + 1]) 209 | offset += 1 210 | 211 | self._taking_plain_text = taking_plain_text 212 | 213 | def _parser_fsm(self): 214 | """An FSM implemented as a coroutine. 215 | 216 | This generator is not the most beautiful, but it is as performant 217 | as possible. When a process generates a lot of output, then this 218 | will be the bottleneck, because it processes just one character 219 | at a time. 220 | 221 | We did many manual optimizations to this function in order to make 222 | it as efficient as possible. Don't change anything without profiling 223 | first. 224 | """ 225 | basic = self.basic 226 | listener = self.listener 227 | draw = listener.draw 228 | debug = listener.debug 229 | 230 | ESC, CSI = ctrl.ESC, ctrl.CSI 231 | OSC, ST = ctrl.OSC, ctrl.ST 232 | SP_OR_GT = ctrl.SP + b">" 233 | NUL_OR_DEL = ctrl.NUL + ctrl.DEL 234 | CAN_OR_SUB = ctrl.CAN + ctrl.SUB 235 | ALLOWED_IN_CSI = b"".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, 236 | ctrl.VT, ctrl.FF, ctrl.CR]) 237 | 238 | def create_dispatcher(mapping): 239 | return defaultdict(lambda: debug, dict( 240 | (event, getattr(listener, attr)) 241 | for event, attr in mapping.items())) 242 | 243 | basic_dispatch = create_dispatcher(basic) 244 | sharp_dispatch = create_dispatcher(self.sharp) 245 | escape_dispatch = create_dispatcher(self.escape) 246 | csi_dispatch = create_dispatcher(self.csi) 247 | 248 | while True: 249 | # ``True`` tells ``Screen.feed`` that it is allowed to send 250 | # chunks of plain text directly to the listener, instead 251 | # of this generator.) 252 | char = yield True 253 | 254 | if char == ESC: 255 | # Most non-VT52 commands start with a left-bracket after the 256 | # escape and then a stream of parameters and a command; with 257 | # a single notable exception -- :data:`escape.DECOM` sequence, 258 | # which starts with a sharp. 259 | # 260 | # .. versionchanged:: 0.4.10 261 | # 262 | # For compatibility with Linux terminal stream also 263 | # recognizes ``ESC % C`` sequences for selecting control 264 | # character set. However, in the current version these 265 | # are noop. 266 | char = yield 267 | if char == b"[": 268 | char = CSI # Go to CSI. 269 | elif char == b"]": 270 | char = OSC # Go to OSC. 271 | else: 272 | if char == b"#": 273 | sharp_dispatch[(yield)]() 274 | if char == b"%": 275 | listener.select_other_charset((yield)) 276 | elif char in b"()": 277 | listener.define_charset((yield), mode=char) 278 | else: 279 | escape_dispatch[char]() 280 | continue # Don't go to CSI. 281 | 282 | if char in basic: 283 | basic_dispatch[char]() 284 | elif char == CSI: 285 | # All parameters are unsigned, positive decimal integers, with 286 | # the most significant digit sent first. Any parameter greater 287 | # than 9999 is set to 9999. If you do not specify a value, a 0 288 | # value is assumed. 289 | # 290 | # .. seealso:: 291 | # 292 | # `VT102 User Guide `_ 293 | # For details on the formatting of escape arguments. 294 | # 295 | # `VT220 Programmer Ref. `_ 296 | # For details on the characters valid for use as 297 | # arguments. 298 | params = [] 299 | current = bytearray() 300 | private = False 301 | while True: 302 | char = yield 303 | if char == b"?": 304 | private = True 305 | elif char in ALLOWED_IN_CSI: 306 | basic_dispatch[char]() 307 | elif char in SP_OR_GT: 308 | # We don't handle secondary DA atm. 309 | pass 310 | elif char in CAN_OR_SUB: 311 | # If CAN or SUB is received during a sequence, the 312 | # current sequence is aborted; terminal displays 313 | # the substitute character, followed by characters 314 | # in the sequence received after CAN or SUB. 315 | draw(char) 316 | break 317 | elif char.isdigit(): 318 | current.extend(char) 319 | else: 320 | params.append(min(int(bytes(current) or 0), 9999)) 321 | 322 | if char == b";": 323 | current = bytearray() 324 | else: 325 | if private: 326 | csi_dispatch[char](*params, private=True) 327 | else: 328 | csi_dispatch[char](*params) 329 | break # CSI is finished. 330 | elif char == OSC: 331 | code = yield 332 | param = bytearray() 333 | while True: 334 | char = yield 335 | if char == ST or char == ctrl.BEL: 336 | break 337 | else: 338 | param.extend(char) 339 | 340 | param = bytes(param[1:]) # Drop the ;. 341 | if code in b"01": 342 | listener.set_icon_name(param) 343 | if code in b"02": 344 | listener.set_title(param) 345 | elif char not in NUL_OR_DEL: 346 | draw(char) 347 | 348 | 349 | class ByteStream(Stream): 350 | def __init__(self, *args, **kwargs): 351 | warnings.warn("As of version 0.6.0 ``pyte.streams.ByteStream`` is an " 352 | "alias for ``pyte.streams.Stream``. The former will be " 353 | "removed in pyte 0.6.1.", DeprecationWarning) 354 | 355 | if kwargs.pop("encodings", None): 356 | warnings.warn( 357 | "As of version 0.6.0 ``pyte.streams.ByteStream`` no longer " 358 | "decodes input.", DeprecationWarning) 359 | 360 | super(ByteStream, self).__init__(*args, **kwargs) 361 | 362 | 363 | class DebugStream(Stream): 364 | r"""Stream, which dumps a subset of the dispatched events to a given 365 | file-like object (:data:`sys.stdout` by default). 366 | 367 | >>> import io 368 | >>> with io.StringIO() as buf: 369 | ... stream = DebugStream(to=buf) 370 | ... stream.feed(b"\x1b[1;24r\x1b[4l\x1b[24;1H\x1b[0;10m") 371 | ... print(buf.getvalue()) 372 | ... 373 | ... # doctest: +NORMALIZE_WHITESPACE 374 | SET_MARGINS 1; 24 375 | RESET_MODE 4 376 | CURSOR_POSITION 24; 1 377 | SELECT_GRAPHIC_RENDITION 0; 10 378 | 379 | :param file to: a file-like object to write debug information to. 380 | :param list only: a list of events you want to debug (empty by 381 | default, which means -- debug all events). 382 | """ 383 | 384 | def __init__(self, to=sys.stdout, only=(), *args, **kwargs): 385 | def safe_str(chunk): 386 | if isinstance(chunk, bytes): 387 | chunk = chunk.decode("utf-8") 388 | elif not isinstance(chunk, str): 389 | chunk = str(chunk) 390 | 391 | return chunk 392 | 393 | def noop(*args, **kwargs): 394 | pass 395 | 396 | class Bugger(object): 397 | def __getattr__(self, event): 398 | if only and event not in only: 399 | return noop 400 | 401 | def inner(*args, **kwargs): 402 | to.write(event.upper() + " ") 403 | to.write("; ".join(map(safe_str, args))) 404 | to.write(" ") 405 | to.write(", ".join("{0}: {1}".format(k, safe_str(v)) 406 | for k, v in kwargs.items())) 407 | to.write(os.linesep) 408 | return inner 409 | 410 | super(DebugStream, self).__init__(Bugger(), *args, **kwargs) 411 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | --- 2 | title: GNU Affero General Public License v3.0 3 | nickname: GNU Affero GPL v3.0 4 | category: GPL 5 | tab-slug: agpl-v3 6 | hide-from-license-list: true 7 | layout: license 8 | permalink: /licenses/agpl-3.0/ 9 | source: http://www.gnu.org/licenses/agpl-3.0.txt 10 | redirect_from: /licenses/agpl/ 11 | 12 | description: "The GPL family of licenses is the most widely used free software license and has a strong copyleft requirement. When distributing derived works, the source code of the work must be made available under the same license. The AGPL family of licenses is distinguished from GPLv2 and GPLv3 in that hosted services using the code are considered distribution and trigger the copyleft requirements." 13 | 14 | note: The Free Software Foundation recommends taking the additional step of adding a boilerplate notice to the top of each file. The boilerplate can be found at the end of the license. 15 | 16 | how: Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. 17 | 18 | required: 19 | - include-copyright 20 | - document-changes 21 | - disclose-source 22 | - network-use-disclose 23 | 24 | permitted: 25 | - commercial-use 26 | - modifications 27 | - distribution 28 | - patent-grant 29 | - private-use 30 | 31 | forbidden: 32 | - no-liability 33 | - no-sublicense 34 | 35 | --- 36 | 37 | GNU AFFERO GENERAL PUBLIC LICENSE 38 | Version 3, 19 November 2007 39 | 40 | Copyright (C) 2007 Free Software Foundation, Inc. 41 | Everyone is permitted to copy and distribute verbatim copies 42 | of this license document, but changing it is not allowed. 43 | 44 | Preamble 45 | 46 | The GNU Affero General Public License is a free, copyleft license for 47 | software and other kinds of works, specifically designed to ensure 48 | cooperation with the community in the case of network server software. 49 | 50 | The licenses for most software and other practical works are designed 51 | to take away your freedom to share and change the works. By contrast, 52 | our General Public Licenses are intended to guarantee your freedom to 53 | share and change all versions of a program--to make sure it remains free 54 | software for all its users. 55 | 56 | When we speak of free software, we are referring to freedom, not 57 | price. Our General Public Licenses are designed to make sure that you 58 | have the freedom to distribute copies of free software (and charge for 59 | them if you wish), that you receive source code or can get it if you 60 | want it, that you can change the software or use pieces of it in new 61 | free programs, and that you know you can do these things. 62 | 63 | Developers that use our General Public Licenses protect your rights 64 | with two steps: (1) assert copyright on the software, and (2) offer 65 | you this License which gives you legal permission to copy, distribute 66 | and/or modify the software. 67 | 68 | A secondary benefit of defending all users' freedom is that 69 | improvements made in alternate versions of the program, if they 70 | receive widespread use, become available for other developers to 71 | incorporate. Many developers of free software are heartened and 72 | encouraged by the resulting cooperation. However, in the case of 73 | software used on network servers, this result may fail to come about. 74 | The GNU General Public License permits making a modified version and 75 | letting the public access it on a server without ever releasing its 76 | source code to the public. 77 | 78 | The GNU Affero General Public License is designed specifically to 79 | ensure that, in such cases, the modified source code becomes available 80 | to the community. It requires the operator of a network server to 81 | provide the source code of the modified version running there to the 82 | users of that server. Therefore, public use of a modified version, on 83 | a publicly accessible server, gives the public access to the source 84 | code of the modified version. 85 | 86 | An older license, called the Affero General Public License and 87 | published by Affero, was designed to accomplish similar goals. This is 88 | a different license, not a version of the Affero GPL, but Affero has 89 | released a new version of the Affero GPL which permits relicensing under 90 | this license. 91 | 92 | The precise terms and conditions for copying, distribution and 93 | modification follow. 94 | 95 | TERMS AND CONDITIONS 96 | 97 | 0. Definitions. 98 | 99 | "This License" refers to version 3 of the GNU Affero General Public License. 100 | 101 | "Copyright" also means copyright-like laws that apply to other kinds of 102 | works, such as semiconductor masks. 103 | 104 | "The Program" refers to any copyrightable work licensed under this 105 | License. Each licensee is addressed as "you". "Licensees" and 106 | "recipients" may be individuals or organizations. 107 | 108 | To "modify" a work means to copy from or adapt all or part of the work 109 | in a fashion requiring copyright permission, other than the making of an 110 | exact copy. The resulting work is called a "modified version" of the 111 | earlier work or a work "based on" the earlier work. 112 | 113 | A "covered work" means either the unmodified Program or a work based 114 | on the Program. 115 | 116 | To "propagate" a work means to do anything with it that, without 117 | permission, would make you directly or secondarily liable for 118 | infringement under applicable copyright law, except executing it on a 119 | computer or modifying a private copy. Propagation includes copying, 120 | distribution (with or without modification), making available to the 121 | public, and in some countries other activities as well. 122 | 123 | To "convey" a work means any kind of propagation that enables other 124 | parties to make or receive copies. Mere interaction with a user through 125 | a computer network, with no transfer of a copy, is not conveying. 126 | 127 | An interactive user interface displays "Appropriate Legal Notices" 128 | to the extent that it includes a convenient and prominently visible 129 | feature that (1) displays an appropriate copyright notice, and (2) 130 | tells the user that there is no warranty for the work (except to the 131 | extent that warranties are provided), that licensees may convey the 132 | work under this License, and how to view a copy of this License. If 133 | the interface presents a list of user commands or options, such as a 134 | menu, a prominent item in the list meets this criterion. 135 | 136 | 1. Source Code. 137 | 138 | The "source code" for a work means the preferred form of the work 139 | for making modifications to it. "Object code" means any non-source 140 | form of a work. 141 | 142 | A "Standard Interface" means an interface that either is an official 143 | standard defined by a recognized standards body, or, in the case of 144 | interfaces specified for a particular programming language, one that 145 | is widely used among developers working in that language. 146 | 147 | The "System Libraries" of an executable work include anything, other 148 | than the work as a whole, that (a) is included in the normal form of 149 | packaging a Major Component, but which is not part of that Major 150 | Component, and (b) serves only to enable use of the work with that 151 | Major Component, or to implement a Standard Interface for which an 152 | implementation is available to the public in source code form. A 153 | "Major Component", in this context, means a major essential component 154 | (kernel, window system, and so on) of the specific operating system 155 | (if any) on which the executable work runs, or a compiler used to 156 | produce the work, or an object code interpreter used to run it. 157 | 158 | The "Corresponding Source" for a work in object code form means all 159 | the source code needed to generate, install, and (for an executable 160 | work) run the object code and to modify the work, including scripts to 161 | control those activities. However, it does not include the work's 162 | System Libraries, or general-purpose tools or generally available free 163 | programs which are used unmodified in performing those activities but 164 | which are not part of the work. For example, Corresponding Source 165 | includes interface definition files associated with source files for 166 | the work, and the source code for shared libraries and dynamically 167 | linked subprograms that the work is specifically designed to require, 168 | such as by intimate data communication or control flow between those 169 | subprograms and other parts of the work. 170 | 171 | The Corresponding Source need not include anything that users 172 | can regenerate automatically from other parts of the Corresponding 173 | Source. 174 | 175 | The Corresponding Source for a work in source code form is that 176 | same work. 177 | 178 | 2. Basic Permissions. 179 | 180 | All rights granted under this License are granted for the term of 181 | copyright on the Program, and are irrevocable provided the stated 182 | conditions are met. This License explicitly affirms your unlimited 183 | permission to run the unmodified Program. The output from running a 184 | covered work is covered by this License only if the output, given its 185 | content, constitutes a covered work. This License acknowledges your 186 | rights of fair use or other equivalent, as provided by copyright law. 187 | 188 | You may make, run and propagate covered works that you do not 189 | convey, without conditions so long as your license otherwise remains 190 | in force. You may convey covered works to others for the sole purpose 191 | of having them make modifications exclusively for you, or provide you 192 | with facilities for running those works, provided that you comply with 193 | the terms of this License in conveying all material for which you do 194 | not control copyright. Those thus making or running the covered works 195 | for you must do so exclusively on your behalf, under your direction 196 | and control, on terms that prohibit them from making any copies of 197 | your copyrighted material outside their relationship with you. 198 | 199 | Conveying under any other circumstances is permitted solely under 200 | the conditions stated below. Sublicensing is not allowed; section 10 201 | makes it unnecessary. 202 | 203 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 204 | 205 | No covered work shall be deemed part of an effective technological 206 | measure under any applicable law fulfilling obligations under article 207 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 208 | similar laws prohibiting or restricting circumvention of such 209 | measures. 210 | 211 | When you convey a covered work, you waive any legal power to forbid 212 | circumvention of technological measures to the extent such circumvention 213 | is effected by exercising rights under this License with respect to 214 | the covered work, and you disclaim any intention to limit operation or 215 | modification of the work as a means of enforcing, against the work's 216 | users, your or third parties' legal rights to forbid circumvention of 217 | technological measures. 218 | 219 | 4. Conveying Verbatim Copies. 220 | 221 | You may convey verbatim copies of the Program's source code as you 222 | receive it, in any medium, provided that you conspicuously and 223 | appropriately publish on each copy an appropriate copyright notice; 224 | keep intact all notices stating that this License and any 225 | non-permissive terms added in accord with section 7 apply to the code; 226 | keep intact all notices of the absence of any warranty; and give all 227 | recipients a copy of this License along with the Program. 228 | 229 | You may charge any price or no price for each copy that you convey, 230 | and you may offer support or warranty protection for a fee. 231 | 232 | 5. Conveying Modified Source Versions. 233 | 234 | You may convey a work based on the Program, or the modifications to 235 | produce it from the Program, in the form of source code under the 236 | terms of section 4, provided that you also meet all of these conditions: 237 | 238 | a) The work must carry prominent notices stating that you modified 239 | it, and giving a relevant date. 240 | 241 | b) The work must carry prominent notices stating that it is 242 | released under this License and any conditions added under section 243 | 7. This requirement modifies the requirement in section 4 to 244 | "keep intact all notices". 245 | 246 | c) You must license the entire work, as a whole, under this 247 | License to anyone who comes into possession of a copy. This 248 | License will therefore apply, along with any applicable section 7 249 | additional terms, to the whole of the work, and all its parts, 250 | regardless of how they are packaged. This License gives no 251 | permission to license the work in any other way, but it does not 252 | invalidate such permission if you have separately received it. 253 | 254 | d) If the work has interactive user interfaces, each must display 255 | Appropriate Legal Notices; however, if the Program has interactive 256 | interfaces that do not display Appropriate Legal Notices, your 257 | work need not make them do so. 258 | 259 | A compilation of a covered work with other separate and independent 260 | works, which are not by their nature extensions of the covered work, 261 | and which are not combined with it such as to form a larger program, 262 | in or on a volume of a storage or distribution medium, is called an 263 | "aggregate" if the compilation and its resulting copyright are not 264 | used to limit the access or legal rights of the compilation's users 265 | beyond what the individual works permit. Inclusion of a covered work 266 | in an aggregate does not cause this License to apply to the other 267 | parts of the aggregate. 268 | 269 | 6. Conveying Non-Source Forms. 270 | 271 | You may convey a covered work in object code form under the terms 272 | of sections 4 and 5, provided that you also convey the 273 | machine-readable Corresponding Source under the terms of this License, 274 | in one of these ways: 275 | 276 | a) Convey the object code in, or embodied in, a physical product 277 | (including a physical distribution medium), accompanied by the 278 | Corresponding Source fixed on a durable physical medium 279 | customarily used for software interchange. 280 | 281 | b) Convey the object code in, or embodied in, a physical product 282 | (including a physical distribution medium), accompanied by a 283 | written offer, valid for at least three years and valid for as 284 | long as you offer spare parts or customer support for that product 285 | model, to give anyone who possesses the object code either (1) a 286 | copy of the Corresponding Source for all the software in the 287 | product that is covered by this License, on a durable physical 288 | medium customarily used for software interchange, for a price no 289 | more than your reasonable cost of physically performing this 290 | conveying of source, or (2) access to copy the 291 | Corresponding Source from a network server at no charge. 292 | 293 | c) Convey individual copies of the object code with a copy of the 294 | written offer to provide the Corresponding Source. This 295 | alternative is allowed only occasionally and noncommercially, and 296 | only if you received the object code with such an offer, in accord 297 | with subsection 6b. 298 | 299 | d) Convey the object code by offering access from a designated 300 | place (gratis or for a charge), and offer equivalent access to the 301 | Corresponding Source in the same way through the same place at no 302 | further charge. You need not require recipients to copy the 303 | Corresponding Source along with the object code. If the place to 304 | copy the object code is a network server, the Corresponding Source 305 | may be on a different server (operated by you or a third party) 306 | that supports equivalent copying facilities, provided you maintain 307 | clear directions next to the object code saying where to find the 308 | Corresponding Source. Regardless of what server hosts the 309 | Corresponding Source, you remain obligated to ensure that it is 310 | available for as long as needed to satisfy these requirements. 311 | 312 | e) Convey the object code using peer-to-peer transmission, provided 313 | you inform other peers where the object code and Corresponding 314 | Source of the work are being offered to the general public at no 315 | charge under subsection 6d. 316 | 317 | A separable portion of the object code, whose source code is excluded 318 | from the Corresponding Source as a System Library, need not be 319 | included in conveying the object code work. 320 | 321 | A "User Product" is either (1) a "consumer product", which means any 322 | tangible personal property which is normally used for personal, family, 323 | or household purposes, or (2) anything designed or sold for incorporation 324 | into a dwelling. In determining whether a product is a consumer product, 325 | doubtful cases shall be resolved in favor of coverage. For a particular 326 | product received by a particular user, "normally used" refers to a 327 | typical or common use of that class of product, regardless of the status 328 | of the particular user or of the way in which the particular user 329 | actually uses, or expects or is expected to use, the product. A product 330 | is a consumer product regardless of whether the product has substantial 331 | commercial, industrial or non-consumer uses, unless such uses represent 332 | the only significant mode of use of the product. 333 | 334 | "Installation Information" for a User Product means any methods, 335 | procedures, authorization keys, or other information required to install 336 | and execute modified versions of a covered work in that User Product from 337 | a modified version of its Corresponding Source. The information must 338 | suffice to ensure that the continued functioning of the modified object 339 | code is in no case prevented or interfered with solely because 340 | modification has been made. 341 | 342 | If you convey an object code work under this section in, or with, or 343 | specifically for use in, a User Product, and the conveying occurs as 344 | part of a transaction in which the right of possession and use of the 345 | User Product is transferred to the recipient in perpetuity or for a 346 | fixed term (regardless of how the transaction is characterized), the 347 | Corresponding Source conveyed under this section must be accompanied 348 | by the Installation Information. But this requirement does not apply 349 | if neither you nor any third party retains the ability to install 350 | modified object code on the User Product (for example, the work has 351 | been installed in ROM). 352 | 353 | The requirement to provide Installation Information does not include a 354 | requirement to continue to provide support service, warranty, or updates 355 | for a work that has been modified or installed by the recipient, or for 356 | the User Product in which it has been modified or installed. Access to a 357 | network may be denied when the modification itself materially and 358 | adversely affects the operation of the network or violates the rules and 359 | protocols for communication across the network. 360 | 361 | Corresponding Source conveyed, and Installation Information provided, 362 | in accord with this section must be in a format that is publicly 363 | documented (and with an implementation available to the public in 364 | source code form), and must require no special password or key for 365 | unpacking, reading or copying. 366 | 367 | 7. Additional Terms. 368 | 369 | "Additional permissions" are terms that supplement the terms of this 370 | License by making exceptions from one or more of its conditions. 371 | Additional permissions that are applicable to the entire Program shall 372 | be treated as though they were included in this License, to the extent 373 | that they are valid under applicable law. If additional permissions 374 | apply only to part of the Program, that part may be used separately 375 | under those permissions, but the entire Program remains governed by 376 | this License without regard to the additional permissions. 377 | 378 | When you convey a copy of a covered work, you may at your option 379 | remove any additional permissions from that copy, or from any part of 380 | it. (Additional permissions may be written to require their own 381 | removal in certain cases when you modify the work.) You may place 382 | additional permissions on material, added by you to a covered work, 383 | for which you have or can give appropriate copyright permission. 384 | 385 | Notwithstanding any other provision of this License, for material you 386 | add to a covered work, you may (if authorized by the copyright holders of 387 | that material) supplement the terms of this License with terms: 388 | 389 | a) Disclaiming warranty or limiting liability differently from the 390 | terms of sections 15 and 16 of this License; or 391 | 392 | b) Requiring preservation of specified reasonable legal notices or 393 | author attributions in that material or in the Appropriate Legal 394 | Notices displayed by works containing it; or 395 | 396 | c) Prohibiting misrepresentation of the origin of that material, or 397 | requiring that modified versions of such material be marked in 398 | reasonable ways as different from the original version; or 399 | 400 | d) Limiting the use for publicity purposes of names of licensors or 401 | authors of the material; or 402 | 403 | e) Declining to grant rights under trademark law for use of some 404 | trade names, trademarks, or service marks; or 405 | 406 | f) Requiring indemnification of licensors and authors of that 407 | material by anyone who conveys the material (or modified versions of 408 | it) with contractual assumptions of liability to the recipient, for 409 | any liability that these contractual assumptions directly impose on 410 | those licensors and authors. 411 | 412 | All other non-permissive additional terms are considered "further 413 | restrictions" within the meaning of section 10. If the Program as you 414 | received it, or any part of it, contains a notice stating that it is 415 | governed by this License along with a term that is a further 416 | restriction, you may remove that term. If a license document contains 417 | a further restriction but permits relicensing or conveying under this 418 | License, you may add to a covered work material governed by the terms 419 | of that license document, provided that the further restriction does 420 | not survive such relicensing or conveying. 421 | 422 | If you add terms to a covered work in accord with this section, you 423 | must place, in the relevant source files, a statement of the 424 | additional terms that apply to those files, or a notice indicating 425 | where to find the applicable terms. 426 | 427 | Additional terms, permissive or non-permissive, may be stated in the 428 | form of a separately written license, or stated as exceptions; 429 | the above requirements apply either way. 430 | 431 | 8. Termination. 432 | 433 | You may not propagate or modify a covered work except as expressly 434 | provided under this License. Any attempt otherwise to propagate or 435 | modify it is void, and will automatically terminate your rights under 436 | this License (including any patent licenses granted under the third 437 | paragraph of section 11). 438 | 439 | However, if you cease all violation of this License, then your 440 | license from a particular copyright holder is reinstated (a) 441 | provisionally, unless and until the copyright holder explicitly and 442 | finally terminates your license, and (b) permanently, if the copyright 443 | holder fails to notify you of the violation by some reasonable means 444 | prior to 60 days after the cessation. 445 | 446 | Moreover, your license from a particular copyright holder is 447 | reinstated permanently if the copyright holder notifies you of the 448 | violation by some reasonable means, this is the first time you have 449 | received notice of violation of this License (for any work) from that 450 | copyright holder, and you cure the violation prior to 30 days after 451 | your receipt of the notice. 452 | 453 | Termination of your rights under this section does not terminate the 454 | licenses of parties who have received copies or rights from you under 455 | this License. If your rights have been terminated and not permanently 456 | reinstated, you do not qualify to receive new licenses for the same 457 | material under section 10. 458 | 459 | 9. Acceptance Not Required for Having Copies. 460 | 461 | You are not required to accept this License in order to receive or 462 | run a copy of the Program. Ancillary propagation of a covered work 463 | occurring solely as a consequence of using peer-to-peer transmission 464 | to receive a copy likewise does not require acceptance. However, 465 | nothing other than this License grants you permission to propagate or 466 | modify any covered work. These actions infringe copyright if you do 467 | not accept this License. Therefore, by modifying or propagating a 468 | covered work, you indicate your acceptance of this License to do so. 469 | 470 | 10. Automatic Licensing of Downstream Recipients. 471 | 472 | Each time you convey a covered work, the recipient automatically 473 | receives a license from the original licensors, to run, modify and 474 | propagate that work, subject to this License. You are not responsible 475 | for enforcing compliance by third parties with this License. 476 | 477 | An "entity transaction" is a transaction transferring control of an 478 | organization, or substantially all assets of one, or subdividing an 479 | organization, or merging organizations. If propagation of a covered 480 | work results from an entity transaction, each party to that 481 | transaction who receives a copy of the work also receives whatever 482 | licenses to the work the party's predecessor in interest had or could 483 | give under the previous paragraph, plus a right to possession of the 484 | Corresponding Source of the work from the predecessor in interest, if 485 | the predecessor has it or can get it with reasonable efforts. 486 | 487 | You may not impose any further restrictions on the exercise of the 488 | rights granted or affirmed under this License. For example, you may 489 | not impose a license fee, royalty, or other charge for exercise of 490 | rights granted under this License, and you may not initiate litigation 491 | (including a cross-claim or counterclaim in a lawsuit) alleging that 492 | any patent claim is infringed by making, using, selling, offering for 493 | sale, or importing the Program or any portion of it. 494 | 495 | 11. Patents. 496 | 497 | A "contributor" is a copyright holder who authorizes use under this 498 | License of the Program or a work on which the Program is based. The 499 | work thus licensed is called the contributor's "contributor version". 500 | 501 | A contributor's "essential patent claims" are all patent claims 502 | owned or controlled by the contributor, whether already acquired or 503 | hereafter acquired, that would be infringed by some manner, permitted 504 | by this License, of making, using, or selling its contributor version, 505 | but do not include claims that would be infringed only as a 506 | consequence of further modification of the contributor version. For 507 | purposes of this definition, "control" includes the right to grant 508 | patent sublicenses in a manner consistent with the requirements of 509 | this License. 510 | 511 | Each contributor grants you a non-exclusive, worldwide, royalty-free 512 | patent license under the contributor's essential patent claims, to 513 | make, use, sell, offer for sale, import and otherwise run, modify and 514 | propagate the contents of its contributor version. 515 | 516 | In the following three paragraphs, a "patent license" is any express 517 | agreement or commitment, however denominated, not to enforce a patent 518 | (such as an express permission to practice a patent or covenant not to 519 | sue for patent infringement). To "grant" such a patent license to a 520 | party means to make such an agreement or commitment not to enforce a 521 | patent against the party. 522 | 523 | If you convey a covered work, knowingly relying on a patent license, 524 | and the Corresponding Source of the work is not available for anyone 525 | to copy, free of charge and under the terms of this License, through a 526 | publicly available network server or other readily accessible means, 527 | then you must either (1) cause the Corresponding Source to be so 528 | available, or (2) arrange to deprive yourself of the benefit of the 529 | patent license for this particular work, or (3) arrange, in a manner 530 | consistent with the requirements of this License, to extend the patent 531 | license to downstream recipients. "Knowingly relying" means you have 532 | actual knowledge that, but for the patent license, your conveying the 533 | covered work in a country, or your recipient's use of the covered work 534 | in a country, would infringe one or more identifiable patents in that 535 | country that you have reason to believe are valid. 536 | 537 | If, pursuant to or in connection with a single transaction or 538 | arrangement, you convey, or propagate by procuring conveyance of, a 539 | covered work, and grant a patent license to some of the parties 540 | receiving the covered work authorizing them to use, propagate, modify 541 | or convey a specific copy of the covered work, then the patent license 542 | you grant is automatically extended to all recipients of the covered 543 | work and works based on it. 544 | 545 | A patent license is "discriminatory" if it does not include within 546 | the scope of its coverage, prohibits the exercise of, or is 547 | conditioned on the non-exercise of one or more of the rights that are 548 | specifically granted under this License. You may not convey a covered 549 | work if you are a party to an arrangement with a third party that is 550 | in the business of distributing software, under which you make payment 551 | to the third party based on the extent of your activity of conveying 552 | the work, and under which the third party grants, to any of the 553 | parties who would receive the covered work from you, a discriminatory 554 | patent license (a) in connection with copies of the covered work 555 | conveyed by you (or copies made from those copies), or (b) primarily 556 | for and in connection with specific products or compilations that 557 | contain the covered work, unless you entered into that arrangement, 558 | or that patent license was granted, prior to 28 March 2007. 559 | 560 | Nothing in this License shall be construed as excluding or limiting 561 | any implied license or other defenses to infringement that may 562 | otherwise be available to you under applicable patent law. 563 | 564 | 12. No Surrender of Others' Freedom. 565 | 566 | If conditions are imposed on you (whether by court order, agreement or 567 | otherwise) that contradict the conditions of this License, they do not 568 | excuse you from the conditions of this License. If you cannot convey a 569 | covered work so as to satisfy simultaneously your obligations under this 570 | License and any other pertinent obligations, then as a consequence you may 571 | not convey it at all. For example, if you agree to terms that obligate you 572 | to collect a royalty for further conveying from those to whom you convey 573 | the Program, the only way you could satisfy both those terms and this 574 | License would be to refrain entirely from conveying the Program. 575 | 576 | 13. Remote Network Interaction; Use with the GNU General Public License. 577 | 578 | Notwithstanding any other provision of this License, if you modify the 579 | Program, your modified version must prominently offer all users 580 | interacting with it remotely through a computer network (if your version 581 | supports such interaction) an opportunity to receive the Corresponding 582 | Source of your version by providing access to the Corresponding Source 583 | from a network server at no charge, through some standard or customary 584 | means of facilitating copying of software. This Corresponding Source 585 | shall include the Corresponding Source for any work covered by version 3 586 | of the GNU General Public License that is incorporated pursuant to the 587 | following paragraph. 588 | 589 | Notwithstanding any other provision of this License, you have 590 | permission to link or combine any covered work with a work licensed 591 | under version 3 of the GNU General Public License into a single 592 | combined work, and to convey the resulting work. The terms of this 593 | License will continue to apply to the part which is the covered work, 594 | but the work with which it is combined will remain governed by version 595 | 3 of the GNU General Public License. 596 | 597 | 14. Revised Versions of this License. 598 | 599 | The Free Software Foundation may publish revised and/or new versions of 600 | the GNU Affero General Public License from time to time. Such new versions 601 | will be similar in spirit to the present version, but may differ in detail to 602 | address new problems or concerns. 603 | 604 | Each version is given a distinguishing version number. If the 605 | Program specifies that a certain numbered version of the GNU Affero General 606 | Public License "or any later version" applies to it, you have the 607 | option of following the terms and conditions either of that numbered 608 | version or of any later version published by the Free Software 609 | Foundation. If the Program does not specify a version number of the 610 | GNU Affero General Public License, you may choose any version ever published 611 | by the Free Software Foundation. 612 | 613 | If the Program specifies that a proxy can decide which future 614 | versions of the GNU Affero General Public License can be used, that proxy's 615 | public statement of acceptance of a version permanently authorizes you 616 | to choose that version for the Program. 617 | 618 | Later license versions may give you additional or different 619 | permissions. However, no additional obligations are imposed on any 620 | author or copyright holder as a result of your choosing to follow a 621 | later version. 622 | 623 | 15. Disclaimer of Warranty. 624 | 625 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 626 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 627 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 628 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 629 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 630 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 631 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 632 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 633 | 634 | 16. Limitation of Liability. 635 | 636 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 637 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 638 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 639 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 640 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 641 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 642 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 643 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 644 | SUCH DAMAGES. 645 | 646 | 17. Interpretation of Sections 15 and 16. 647 | 648 | If the disclaimer of warranty and limitation of liability provided 649 | above cannot be given local legal effect according to their terms, 650 | reviewing courts shall apply local law that most closely approximates 651 | an absolute waiver of all civil liability in connection with the 652 | Program, unless a warranty or assumption of liability accompanies a 653 | copy of the Program in return for a fee. 654 | 655 | END OF TERMS AND CONDITIONS 656 | 657 | How to Apply These Terms to Your New Programs 658 | 659 | If you develop a new program, and you want it to be of the greatest 660 | possible use to the public, the best way to achieve this is to make it 661 | free software which everyone can redistribute and change under these terms. 662 | 663 | To do so, attach the following notices to the program. It is safest 664 | to attach them to the start of each source file to most effectively 665 | state the exclusion of warranty; and each file should have at least 666 | the "copyright" line and a pointer to where the full notice is found. 667 | 668 | 669 | Copyright (C) 670 | 671 | This program is free software: you can redistribute it and/or modify 672 | it under the terms of the GNU Affero General Public License as published 673 | by the Free Software Foundation, either version 3 of the License, or 674 | (at your option) any later version. 675 | 676 | This program is distributed in the hope that it will be useful, 677 | but WITHOUT ANY WARRANTY; without even the implied warranty of 678 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 679 | GNU Affero General Public License for more details. 680 | 681 | You should have received a copy of the GNU Affero General Public License 682 | along with this program. If not, see . 683 | 684 | Also add information on how to contact you by electronic and paper mail. 685 | 686 | If your software can interact with users remotely through a computer 687 | network, you should also make sure that it provides a way for users to 688 | get its source. For example, if your program is a web application, its 689 | interface could display a "Source" link that leads users to an archive 690 | of the code. There are many ways you could offer source, and different 691 | solutions will be better for different programs; see section 13 for the 692 | specific requirements. 693 | 694 | You should also get your employer (if you work as a programmer) or school, 695 | if any, to sign a "copyright disclaimer" for the program, if necessary. 696 | For more information on this, and how to apply and follow the GNU AGPL, see 697 | . 698 | -------------------------------------------------------------------------------- /pyte/screens.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pyte.screens 4 | ~~~~~~~~~~~~ 5 | 6 | This module provides classes for terminal screens, currently 7 | it contains three screens with different features: 8 | 9 | * :class:`~pyte.screens.Screen` -- base screen implementation, 10 | which handles all the core escape sequences, recognized by 11 | :class:`~pyte.streams.Stream`. 12 | * If you need a screen to keep track of the changed lines 13 | (which you probably do need) -- use 14 | :class:`~pyte.screens.DiffScreen`. 15 | * If you also want a screen to collect history and allow 16 | pagination -- :class:`pyte.screen.HistoryScreen` is here 17 | for ya ;) 18 | 19 | .. note:: It would be nice to split those features into mixin 20 | classes, rather than subclasses, but it's not obvious 21 | how to do -- feel free to submit a pull request. 22 | 23 | :copyright: (c) 2011-2012 by Selectel. 24 | :copyright: (c) 2012-2016 by pyte authors and contributors, 25 | see AUTHORS for details. 26 | :license: LGPL, see LICENSE for more details. 27 | """ 28 | 29 | from __future__ import absolute_import, unicode_literals, division 30 | 31 | import codecs 32 | import copy 33 | import math 34 | import unicodedata 35 | from collections import deque, namedtuple 36 | from itertools import islice, repeat 37 | 38 | from wcwidth import wcwidth 39 | 40 | from . import ( 41 | charsets as cs, 42 | control as ctrl, 43 | graphics as g, 44 | modes as mo 45 | ) 46 | from .compat import iter_bytes, map, range 47 | from .streams import Stream 48 | 49 | 50 | def take(n, iterable): 51 | """Returns first n items of the iterable as a list.""" 52 | return list(islice(iterable, n)) 53 | 54 | 55 | #: A container for screen's scroll margins. 56 | Margins = namedtuple("Margins", "top bottom") 57 | 58 | #: A container for savepoint, created on :data:`~pyte.escape.DECSC`. 59 | Savepoint = namedtuple("Savepoint", [ 60 | "cursor", 61 | "g0_charset", 62 | "g1_charset", 63 | "charset", 64 | "use_utf8", 65 | "origin", 66 | "wrap" 67 | ]) 68 | 69 | #: A container for a single character, field names are *hopefully* 70 | #: self-explanatory. 71 | _Char = namedtuple("_Char", [ 72 | "data", 73 | "fg", 74 | "bg", 75 | "bold", 76 | "italics", 77 | "underscore", 78 | "strikethrough", 79 | "reverse", 80 | ]) 81 | 82 | 83 | class Char(_Char): 84 | """A wrapper around :class:`_Char`, providing some useful defaults 85 | for most of the attributes. 86 | """ 87 | __slots__ = () 88 | 89 | def __new__(cls, data, fg="default", bg="default", bold=False, 90 | italics=False, underscore=False, reverse=False, 91 | strikethrough=False): 92 | return super(Char, cls).__new__(cls, data, fg, bg, bold, italics, 93 | underscore, strikethrough, reverse) 94 | 95 | 96 | class Cursor(object): 97 | """Screen cursor. 98 | 99 | :param int x: 0-based horizontal cursor position. 100 | :param int y: 0-based vertical cursor position. 101 | :param pyte.screens.Char attrs: cursor attributes (see 102 | :meth:`~pyte.screens.Screen.select_graphic_rendition` 103 | for details). 104 | """ 105 | __slots__ = ("x", "y", "attrs", "hidden") 106 | 107 | def __init__(self, x, y, attrs=Char(" ")): 108 | self.x = x 109 | self.y = y 110 | self.attrs = attrs 111 | self.hidden = False 112 | 113 | 114 | class Screen(object): 115 | """ 116 | A screen is an in-memory matrix of characters that represents the 117 | screen display of the terminal. It can be instantiated on it's own 118 | and given explicit commands, or it can be attached to a stream and 119 | will respond to events. 120 | 121 | .. attribute:: buffer 122 | 123 | A ``lines x columns`` :class:`~pyte.screens.Char` matrix. 124 | 125 | .. attribute:: cursor 126 | 127 | Reference to the :class:`~pyte.screens.Cursor` object, holding 128 | cursor position and attributes. 129 | 130 | .. attribute:: margins 131 | 132 | Top and bottom screen margins, defining the scrolling region; 133 | the actual values are top and bottom line. 134 | 135 | .. attribute:: charset 136 | 137 | Current charset number; can be either ``0`` or ``1`` for `G0` 138 | and `G1` respectively, note that `G0` is activated by default. 139 | 140 | .. attribute:: use_utf8 141 | 142 | Assume the input to :meth:`~pyte.screens.Screen.draw` is encoded 143 | using UTF-8. Defaults to ``True``. 144 | 145 | .. note:: 146 | 147 | According to ``ECMA-48`` standard, **lines and columns are 148 | 1-indexed**, so, for instance ``ESC [ 10;10 f`` really means 149 | -- move cursor to position (9, 9) in the display matrix. 150 | 151 | .. versionchanged:: 0.4.7 152 | .. warning:: 153 | 154 | :data:`~pyte.modes.LNM` is reset by default, to match VT220 155 | specification. 156 | 157 | .. versionchanged:: 0.4.8 158 | .. warning:: 159 | 160 | If `DECAWM` mode is set than a cursor will be wrapped to the 161 | **beginning** of the next line, which is the behaviour described 162 | in ``man console_codes``. 163 | 164 | .. seealso:: 165 | 166 | `Standard ECMA-48, Section 6.1.1 \ 167 | `_ 168 | for a description of the presentational component, implemented 169 | by ``Screen``. 170 | """ 171 | #: A plain empty character with default foreground and background 172 | #: colors. 173 | default_char = Char(data=" ", fg="default", bg="default") 174 | 175 | #: An infinite sequence of default characters, used for populating 176 | #: new lines and columns. 177 | default_line = repeat(default_char) 178 | 179 | def __init__(self, columns, lines): 180 | self.savepoints = [] 181 | self.columns = columns 182 | self.lines = lines 183 | self.buffer = [] 184 | self.reset() 185 | 186 | def __repr__(self): 187 | return ("{0}({1}, {2})".format(self.__class__.__name__, 188 | self.columns, self.lines)) 189 | 190 | @property 191 | def display(self): 192 | """A :func:`list` of screen lines as unicode strings.""" 193 | def render(line): 194 | it = iter(line) 195 | while True: 196 | char = next(it).data 197 | assert sum(map(wcwidth, char[1:])) == 0 198 | char_width = wcwidth(char[0]) 199 | if char_width == 1: 200 | yield char 201 | elif char_width == 2: 202 | yield char 203 | next(it) # Skip stub. 204 | 205 | return ["".join(render(line)) for line in self.buffer] 206 | 207 | def reset(self): 208 | """Reset the terminal to its initial state. 209 | 210 | * Scroll margins are reset to screen boundaries. 211 | * Cursor is moved to home location -- ``(0, 0)`` and its 212 | attributes are set to defaults (see :attr:`default_char`). 213 | * Screen is cleared -- each character is reset to 214 | :attr:`default_char`. 215 | * Tabstops are reset to "every eight columns". 216 | 217 | .. note:: 218 | 219 | Neither VT220 nor VT102 manuals mention that terminal modes 220 | and tabstops should be reset as well, thanks to 221 | :manpage:`xterm` -- we now know that. 222 | """ 223 | self.buffer[:] = (take(self.columns, self.default_line) 224 | for _ in range(self.lines)) 225 | self.mode = set([mo.DECAWM, mo.DECTCEM]) 226 | self.margins = Margins(0, self.lines - 1) 227 | 228 | self.title = "" 229 | self.icon_name = "" 230 | 231 | self.charset = 0 232 | self.g0_charset = cs.LAT1_MAP 233 | self.g1_charset = cs.VT100_MAP 234 | self.use_utf8 = True 235 | self.utf8_decoder = codecs.getincrementaldecoder("utf-8")("replace") 236 | 237 | # From ``man terminfo`` -- "... hardware tabs are initially 238 | # set every `n` spaces when the terminal is powered up. Since 239 | # we aim to support VT102 / VT220 and linux -- we use n = 8. 240 | self.tabstops = set(range(7, self.columns, 8)) 241 | 242 | self.cursor = Cursor(0, 0) 243 | self.cursor_position() 244 | 245 | def resize(self, lines=None, columns=None): 246 | """Resize the screen to the given size. 247 | 248 | If the requested screen size has more lines than the existing 249 | screen, lines will be added at the bottom. If the requested 250 | size has less lines than the existing screen lines will be 251 | clipped at the top of the screen. Similarly, if the existing 252 | screen has less columns than the requested screen, columns will 253 | be added at the right, and if it has more -- columns will be 254 | clipped at the right. 255 | 256 | .. note:: According to `xterm`, we should also reset origin 257 | mode and screen margins, see ``xterm/screen.c:1761``. 258 | 259 | :param int lines: number of lines in the new screen. 260 | :param int columns: number of columns in the new screen. 261 | """ 262 | lines = lines or self.lines 263 | columns = columns or self.columns 264 | 265 | # First resize the lines: 266 | diff = self.lines - lines 267 | 268 | # a) if the current display size is less than the requested 269 | # size, add lines to the bottom. 270 | if diff < 0: 271 | self.buffer.extend(take(self.columns, self.default_line) 272 | for _ in range(diff, 0)) 273 | # b) if the current display size is greater than requested 274 | # size, take lines off the top. 275 | elif diff > 0: 276 | self.buffer[:diff] = () 277 | 278 | # Then resize the columns: 279 | diff = self.columns - columns 280 | 281 | # a) if the current display size is less than the requested 282 | # size, expand each line to the new size. 283 | if diff < 0: 284 | for y in range(lines): 285 | self.buffer[y].extend(take(abs(diff), self.default_line)) 286 | # b) if the current display size is greater than requested 287 | # size, trim each line from the right to the new size. 288 | elif diff > 0: 289 | for line in self.buffer: 290 | del line[columns:] 291 | 292 | self.lines, self.columns = lines, columns 293 | self.set_margins() 294 | self.reset_mode(mo.DECOM) 295 | 296 | def set_margins(self, top=None, bottom=None): 297 | """Select top and bottom margins for the scrolling region. 298 | 299 | Margins determine which screen lines move during scrolling 300 | (see :meth:`index` and :meth:`reverse_index`). Characters added 301 | outside the scrolling region do not cause the screen to scroll. 302 | 303 | :param int top: the smallest line number that is scrolled. 304 | :param int bottom: the biggest line number that is scrolled. 305 | """ 306 | if top is None or bottom is None: 307 | self.margins = Margins(0, self.lines - 1) 308 | else: 309 | # Arguments are 1-based, while :attr:`margins` are zero 310 | # based -- so we have to decrement them by one. We also 311 | # make sure that both of them is bounded by [0, lines - 1]. 312 | top = max(0, min(top - 1, self.lines - 1)) 313 | bottom = max(0, min(bottom - 1, self.lines - 1)) 314 | 315 | # Even though VT102 and VT220 require DECSTBM to ignore 316 | # regions of width less than 2, some programs (like aptitude 317 | # for example) rely on it. Practicality beats purity. 318 | if bottom - top >= 1: 319 | self.margins = Margins(top, bottom) 320 | 321 | # The cursor moves to the home position when the top and 322 | # bottom margins of the scrolling region (DECSTBM) changes. 323 | self.cursor_position() 324 | 325 | def set_mode(self, *modes, **kwargs): 326 | """Set (enable) a given list of modes. 327 | 328 | :param list modes: modes to set, where each mode is a constant 329 | from :mod:`pyte.modes`. 330 | """ 331 | # Private mode codes are shifted, to be distingiushed from non 332 | # private ones. 333 | if kwargs.get("private"): 334 | modes = [mode << 5 for mode in modes] 335 | 336 | self.mode.update(modes) 337 | 338 | # When DECOLM mode is set, the screen is erased and the cursor 339 | # moves to the home position. 340 | if mo.DECCOLM in modes: 341 | self.resize(columns=132) 342 | self.erase_in_display(2) 343 | self.cursor_position() 344 | 345 | # According to `vttest`, DECOM should also home the cursor, see 346 | # vttest/main.c:303. 347 | if mo.DECOM in modes: 348 | self.cursor_position() 349 | 350 | # Mark all displayed characters as reverse. 351 | if mo.DECSCNM in modes: 352 | self.buffer[:] = ([char._replace(reverse=True) for char in line] 353 | for line in self.buffer) 354 | self.select_graphic_rendition(7) # +reverse. 355 | 356 | # Make the cursor visible. 357 | if mo.DECTCEM in modes: 358 | self.cursor.hidden = False 359 | 360 | def reset_mode(self, *modes, **kwargs): 361 | """Reset (disable) a given list of modes. 362 | 363 | :param list modes: modes to reset -- hopefully, each mode is a 364 | constant from :mod:`pyte.modes`. 365 | """ 366 | # Private mode codes are shifted, to be distinguished from non 367 | # private ones. 368 | if kwargs.get("private"): 369 | modes = [mode << 5 for mode in modes] 370 | 371 | self.mode.difference_update(modes) 372 | 373 | # Lines below follow the logic in :meth:`set_mode`. 374 | if mo.DECCOLM in modes: 375 | self.resize(columns=80) 376 | self.erase_in_display(2) 377 | self.cursor_position() 378 | 379 | if mo.DECOM in modes: 380 | self.cursor_position() 381 | 382 | if mo.DECSCNM in modes: 383 | self.buffer[:] = ([char._replace(reverse=False) for char in line] 384 | for line in self.buffer) 385 | self.select_graphic_rendition(27) # -reverse. 386 | 387 | # Hide the cursor. 388 | if mo.DECTCEM in modes: 389 | self.cursor.hidden = True 390 | 391 | def define_charset(self, code, mode): 392 | """Define ``G0`` or ``G1`` charset. 393 | 394 | :param str code: character set code, should be a character 395 | from ``b"B0UK"``, otherwise ignored. 396 | :param str mode: if ``"("`` ``G0`` charset is defined, if 397 | ``")"`` -- we operate on ``G1``. 398 | 399 | .. warning:: User-defined charsets are currently not supported. 400 | """ 401 | if code in cs.MAPS: 402 | if mode == b"(": 403 | self.g0_charset = cs.MAPS[code] 404 | elif mode == b")": 405 | self.g1_charset = cs.MAPS[code] 406 | 407 | def shift_in(self): 408 | """Select ``G0`` character set.""" 409 | self.charset = 0 410 | 411 | def shift_out(self): 412 | """Select ``G1`` character set.""" 413 | self.charset = 1 414 | 415 | def select_other_charset(self, code): 416 | """Select other (non G0 or G1) charset. 417 | 418 | :param str code: character set code, should be a character from 419 | ``b"@G8"``, otherwise ignored. 420 | 421 | .. note:: We currently follow ``"linux"`` and only use this 422 | command to switch from ISO-8859-1 to UTF-8 and back. 423 | 424 | .. versionadded:: 0.6.0 425 | 426 | .. seealso:: 427 | 428 | `Standard ECMA-35, Section 15.4 \ 429 | `_ 430 | for a description of VTXXX character set machinery. 431 | """ 432 | if code == b"@": 433 | self.use_utf8 = False 434 | self.utf8_decoder.reset() 435 | elif code in b"G8": 436 | self.use_utf8 = True 437 | 438 | def _decode(self, data): 439 | """Decode bytes to text according to the selected charset. 440 | 441 | :param bytes data: bytes to decode. 442 | """ 443 | if self.charset: 444 | return "".join(self.g1_charset[b] for b in iter_bytes(data)) 445 | elif self.use_utf8: 446 | return self.utf8_decoder.decode(data) 447 | else: 448 | return "".join(self.g0_charset[b] for b in iter_bytes(data)) 449 | 450 | def draw(self, data): 451 | """Display decoded characters at the current cursor position and 452 | advances the cursor if :data:`~pyte.modes.DECAWM` is set. 453 | 454 | :param bytes data: bytes to display. 455 | 456 | .. versionchanged:: 0.5.0 457 | 458 | Character width is taken into account. Specifically, zero-width 459 | and unprintable characters do not affect screen state. Full-width 460 | characters are rendered into two consecutive character containers. 461 | 462 | .. versionchanged:: 0.6.0 463 | 464 | The input is now supposed to be in :func:`bytes`, which may encode 465 | multiple characters. 466 | """ 467 | for char in self._decode(data): 468 | char_width = wcwidth(char) 469 | 470 | # If this was the last column in a line and auto wrap mode is 471 | # enabled, move the cursor to the beginning of the next line, 472 | # otherwise replace characters already displayed with newly 473 | # entered. 474 | if self.cursor.x == self.columns: 475 | if mo.DECAWM in self.mode: 476 | self.carriage_return() 477 | self.linefeed() 478 | elif char_width > 0: 479 | self.cursor.x -= char_width 480 | 481 | # If Insert mode is set, new characters move old characters to 482 | # the right, otherwise terminal is in Replace mode and new 483 | # characters replace old characters at cursor position. 484 | if mo.IRM in self.mode and char_width > 0: 485 | self.insert_characters(char_width) 486 | 487 | line = self.buffer[self.cursor.y] 488 | if char_width == 1: 489 | line[self.cursor.x] = self.cursor.attrs._replace(data=char) 490 | elif char_width == 2: 491 | # A two-cell character has a stub slot after it. 492 | line[self.cursor.x] = self.cursor.attrs._replace(data=char) 493 | if self.cursor.x + 1 < self.columns: 494 | line[self.cursor.x + 495 | 1] = self.cursor.attrs._replace(data=" ") 496 | elif char_width == 0 and unicodedata.combining(char): 497 | # A zero-cell character is combined with the previous 498 | # character either on this or preceeding line. 499 | if self.cursor.x: 500 | last = line[self.cursor.x - 1] 501 | normalized = unicodedata.normalize("NFC", last.data + char) 502 | line[self.cursor.x - 1] = last._replace(data=normalized) 503 | elif self.cursor.y: 504 | last = self.buffer[self.cursor.y - 1][self.columns - 1] 505 | normalized = unicodedata.normalize("NFC", last.data + char) 506 | self.buffer[self.cursor.y - 1][self.columns - 1] = \ 507 | last._replace(data=normalized) 508 | else: 509 | pass # Unprintable character or doesn't advance the cursor. 510 | 511 | # .. note:: We can't use :meth:`cursor_forward()`, because that 512 | # way, we'll never know when to linefeed. 513 | if char_width > 0: 514 | self.cursor.x = min(self.cursor.x + char_width, self.columns) 515 | 516 | def set_title(self, param): 517 | """Set terminal title. 518 | 519 | .. note:: This is an XTerm extension supported by the Linux terminal. 520 | """ 521 | self.title = self._decode(param) 522 | 523 | def set_icon_name(self, param): 524 | """Set icon name. 525 | 526 | .. note:: This is an XTerm extension supported by the Linux terminal. 527 | """ 528 | self.icon_name = self._decode(param) 529 | 530 | def carriage_return(self): 531 | """Move the cursor to the beginning of the current line.""" 532 | self.cursor.x = 0 533 | 534 | def index(self): 535 | """Move the cursor down one line in the same column. If the 536 | cursor is at the last line, create a new line at the bottom. 537 | """ 538 | top, bottom = self.margins 539 | 540 | if self.cursor.y == bottom: 541 | self.buffer.pop(top) 542 | self.buffer.insert(bottom, take(self.columns, self.default_line)) 543 | else: 544 | self.cursor_down() 545 | 546 | def reverse_index(self): 547 | """Move the cursor up one line in the same column. If the cursor 548 | is at the first line, create a new line at the top. 549 | """ 550 | top, bottom = self.margins 551 | 552 | if self.cursor.y == top: 553 | self.buffer.pop(bottom) 554 | self.buffer.insert(top, take(self.columns, self.default_line)) 555 | else: 556 | self.cursor_up() 557 | 558 | def linefeed(self): 559 | """Perform an index and, if :data:`~pyte.modes.LNM` is set, a 560 | carriage return. 561 | """ 562 | self.index() 563 | 564 | if mo.LNM in self.mode: 565 | self.carriage_return() 566 | 567 | def tab(self): 568 | """Move to the next tab space, or the end of the screen if there 569 | aren't anymore left. 570 | """ 571 | for stop in sorted(self.tabstops): 572 | if self.cursor.x < stop: 573 | column = stop 574 | break 575 | else: 576 | column = self.columns - 1 577 | 578 | self.cursor.x = column 579 | 580 | def backspace(self): 581 | """Move cursor to the left one or keep it in it's position if 582 | it's at the beginning of the line already. 583 | """ 584 | self.cursor_back() 585 | 586 | def save_cursor(self): 587 | """Push the current cursor position onto the stack.""" 588 | self.savepoints.append(Savepoint(copy.copy(self.cursor), 589 | self.g0_charset, 590 | self.g1_charset, 591 | self.charset, 592 | self.use_utf8, 593 | mo.DECOM in self.mode, 594 | mo.DECAWM in self.mode)) 595 | 596 | def restore_cursor(self): 597 | """Set the current cursor position to whatever cursor is on top 598 | of the stack. 599 | """ 600 | if self.savepoints: 601 | savepoint = self.savepoints.pop() 602 | 603 | self.g0_charset = savepoint.g0_charset 604 | self.g1_charset = savepoint.g1_charset 605 | self.charset = savepoint.charset 606 | self.use_utf8 = savepoint.use_utf8 607 | 608 | if savepoint.origin: 609 | self.set_mode(mo.DECOM) 610 | if savepoint.wrap: 611 | self.set_mode(mo.DECAWM) 612 | 613 | self.cursor = savepoint.cursor 614 | self.ensure_hbounds() 615 | self.ensure_vbounds(use_margins=True) 616 | else: 617 | # If nothing was saved, the cursor moves to home position; 618 | # origin mode is reset. :todo: DECAWM? 619 | self.reset_mode(mo.DECOM) 620 | self.cursor_position() 621 | 622 | def insert_lines(self, count=None): 623 | """Insert the indicated # of lines at line with cursor. Lines 624 | displayed **at** and below the cursor move down. Lines moved 625 | past the bottom margin are lost. 626 | 627 | :param count: number of lines to delete. 628 | """ 629 | count = count or 1 630 | top, bottom = self.margins 631 | 632 | # If cursor is outside scrolling margins it -- do nothin'. 633 | if top <= self.cursor.y <= bottom: 634 | # v +1, because range() is exclusive. 635 | for line in range(self.cursor.y, 636 | min(bottom + 1, self.cursor.y + count)): 637 | self.buffer.pop(bottom) 638 | self.buffer.insert(line, take(self.columns, self.default_line)) 639 | 640 | self.carriage_return() 641 | 642 | def delete_lines(self, count=None): 643 | """Delete the indicated # of lines, starting at line with 644 | cursor. As lines are deleted, lines displayed below cursor 645 | move up. Lines added to bottom of screen have spaces with same 646 | character attributes as last line moved up. 647 | 648 | :param int count: number of lines to delete. 649 | """ 650 | count = count or 1 651 | top, bottom = self.margins 652 | 653 | # If cursor is outside scrolling margins it -- do nothin'. 654 | if top <= self.cursor.y <= bottom: 655 | # v -- +1 to include the bottom margin. 656 | for _ in range(min(bottom - self.cursor.y + 1, count)): 657 | self.buffer.pop(self.cursor.y) 658 | self.buffer.insert(bottom, list( 659 | repeat(self.cursor.attrs, self.columns))) 660 | 661 | self.carriage_return() 662 | 663 | def insert_characters(self, count=None): 664 | """Insert the indicated # of blank characters at the cursor 665 | position. The cursor does not move and remains at the beginning 666 | of the inserted blank characters. Data on the line is shifted 667 | forward. 668 | 669 | :param int count: number of characters to insert. 670 | """ 671 | count = count or 1 672 | 673 | for _ in range(min(self.columns - self.cursor.y, count)): 674 | self.buffer[self.cursor.y].insert(self.cursor.x, self.cursor.attrs) 675 | self.buffer[self.cursor.y].pop() 676 | 677 | def delete_characters(self, count=None): 678 | """Delete the indicated # of characters, starting with the 679 | character at cursor position. When a character is deleted, all 680 | characters to the right of cursor move left. Character attributes 681 | move with the characters. 682 | 683 | :param int count: number of characters to delete. 684 | """ 685 | count = count or 1 686 | 687 | for _ in range(min(self.columns - self.cursor.x, count)): 688 | self.buffer[self.cursor.y].pop(self.cursor.x) 689 | self.buffer[self.cursor.y].append(self.cursor.attrs) 690 | 691 | def erase_characters(self, count=None): 692 | """Erase the indicated # of characters, starting with the 693 | character at cursor position. Character attributes are set 694 | cursor attributes. The cursor remains in the same position. 695 | 696 | :param int count: number of characters to erase. 697 | 698 | .. warning:: 699 | 700 | Even though *ALL* of the VTXXX manuals state that character 701 | attributes **should be reset to defaults**, ``libvte``, 702 | ``xterm`` and ``ROTE`` completely ignore this. Same applies 703 | too all ``erase_*()`` and ``delete_*()`` methods. 704 | """ 705 | count = count or 1 706 | 707 | for column in range(self.cursor.x, 708 | min(self.cursor.x + count, self.columns)): 709 | self.buffer[self.cursor.y][column] = self.cursor.attrs 710 | 711 | def erase_in_line(self, how=0, private=False): 712 | """Erase a line in a specific way. 713 | 714 | :param int how: defines the way the line should be erased in: 715 | 716 | * ``0`` -- Erases from cursor to end of line, including cursor 717 | position. 718 | * ``1`` -- Erases from beginning of line to cursor, 719 | including cursor position. 720 | * ``2`` -- Erases complete line. 721 | :param bool private: when ``True`` character attributes are left 722 | unchanged **not implemented**. 723 | """ 724 | if how == 0: 725 | # a) erase from the cursor to the end of line, including 726 | # the cursor, 727 | interval = range(self.cursor.x, self.columns) 728 | elif how == 1: 729 | # b) erase from the beginning of the line to the cursor, 730 | # including it, 731 | interval = range(self.cursor.x + 1) 732 | elif how == 2: 733 | # c) erase the entire line. 734 | interval = range(self.columns) 735 | 736 | for column in interval: 737 | self.buffer[self.cursor.y][column] = self.cursor.attrs 738 | 739 | def erase_in_display(self, how=0, private=False): 740 | """Erases display in a specific way. 741 | 742 | :param int how: defines the way the line should be erased in: 743 | 744 | * ``0`` -- Erases from cursor to end of screen, including 745 | cursor position. 746 | * ``1`` -- Erases from beginning of screen to cursor, 747 | including cursor position. 748 | * ``2`` -- Erases complete display. All lines are erased 749 | and changed to single-width. Cursor does not move. 750 | :param bool private: when ``True`` character attributes are left 751 | unchanged **not implemented**. 752 | """ 753 | if how == 0: 754 | # a) erase from cursor to the end of the display, including 755 | # the cursor, 756 | interval = range(self.cursor.y + 1, self.lines) 757 | elif how == 1: 758 | # b) erase from the beginning of the display to the cursor, 759 | # including it, 760 | interval = range(self.cursor.y) 761 | elif how == 2: 762 | # c) erase the whole display. 763 | interval = range(self.lines) 764 | 765 | for line in interval: 766 | self.buffer[line][:] = \ 767 | (self.cursor.attrs for _ in range(self.columns)) 768 | 769 | # In case of 0 or 1 we have to erase the line with the cursor. 770 | if how == 0 or how == 1: 771 | self.erase_in_line(how) 772 | 773 | def set_tab_stop(self): 774 | """Set a horizontal tab stop at cursor position.""" 775 | self.tabstops.add(self.cursor.x) 776 | 777 | def clear_tab_stop(self, how=0): 778 | """Clear a horizontal tab stop. 779 | 780 | :param int how: defines a way the tab stop should be cleared: 781 | 782 | * ``0`` or nothing -- Clears a horizontal tab stop at cursor 783 | position. 784 | * ``3`` -- Clears all horizontal tab stops. 785 | """ 786 | if how == 0: 787 | # Clears a horizontal tab stop at cursor position, if it's 788 | # present, or silently fails if otherwise. 789 | self.tabstops.discard(self.cursor.x) 790 | elif how == 3: 791 | self.tabstops = set() # Clears all horizontal tab stops. 792 | 793 | def ensure_hbounds(self): 794 | """Ensure the cursor is within horizontal screen bounds.""" 795 | self.cursor.x = min(max(0, self.cursor.x), self.columns - 1) 796 | 797 | def ensure_vbounds(self, use_margins=None): 798 | """Ensure the cursor is within vertical screen bounds. 799 | 800 | :param bool use_margins: when ``True`` or when 801 | :data:`~pyte.modes.DECOM` is set, 802 | cursor is bounded by top and and bottom 803 | margins, instead of ``[0; lines - 1]``. 804 | """ 805 | if use_margins or mo.DECOM in self.mode: 806 | top, bottom = self.margins 807 | else: 808 | top, bottom = 0, self.lines - 1 809 | 810 | self.cursor.y = min(max(top, self.cursor.y), bottom) 811 | 812 | def cursor_up(self, count=None): 813 | """Move cursor up the indicated # of lines in same column. 814 | Cursor stops at top margin. 815 | 816 | :param int count: number of lines to skip. 817 | """ 818 | self.cursor.y = max(self.cursor.y - (count or 1), self.margins.top) 819 | 820 | def cursor_up1(self, count=None): 821 | """Move cursor up the indicated # of lines to column 1. Cursor 822 | stops at bottom margin. 823 | 824 | :param int count: number of lines to skip. 825 | """ 826 | self.cursor_up(count) 827 | self.carriage_return() 828 | 829 | def cursor_down(self, count=None): 830 | """Move cursor down the indicated # of lines in same column. 831 | Cursor stops at bottom margin. 832 | 833 | :param int count: number of lines to skip. 834 | """ 835 | self.cursor.y = min(self.cursor.y + (count or 1), self.margins.bottom) 836 | 837 | def cursor_down1(self, count=None): 838 | """Move cursor down the indicated # of lines to column 1. 839 | Cursor stops at bottom margin. 840 | 841 | :param int count: number of lines to skip. 842 | """ 843 | self.cursor_down(count) 844 | self.carriage_return() 845 | 846 | def cursor_back(self, count=None): 847 | """Move cursor left the indicated # of columns. Cursor stops 848 | at left margin. 849 | 850 | :param int count: number of columns to skip. 851 | """ 852 | self.cursor.x -= count or 1 853 | self.ensure_hbounds() 854 | 855 | def cursor_forward(self, count=None): 856 | """Move cursor right the indicated # of columns. Cursor stops 857 | at right margin. 858 | 859 | :param int count: number of columns to skip. 860 | """ 861 | self.cursor.x += count or 1 862 | self.ensure_hbounds() 863 | 864 | def cursor_position(self, line=None, column=None): 865 | """Set the cursor to a specific `line` and `column`. 866 | 867 | Cursor is allowed to move out of the scrolling region only when 868 | :data:`~pyte.modes.DECOM` is reset, otherwise -- the position 869 | doesn't change. 870 | 871 | :param int line: line number to move the cursor to. 872 | :param int column: column number to move the cursor to. 873 | """ 874 | column = (column or 1) - 1 875 | line = (line or 1) - 1 876 | 877 | # If origin mode (DECOM) is set, line number are relative to 878 | # the top scrolling margin. 879 | if mo.DECOM in self.mode: 880 | line += self.margins.top 881 | 882 | # Cursor is not allowed to move out of the scrolling region. 883 | if not self.margins.top <= line <= self.margins.bottom: 884 | return 885 | 886 | self.cursor.x = column 887 | self.cursor.y = line 888 | self.ensure_hbounds() 889 | self.ensure_vbounds() 890 | 891 | def cursor_to_column(self, column=None): 892 | """Move cursor to a specific column in the current line. 893 | 894 | :param int column: column number to move the cursor to. 895 | """ 896 | self.cursor.x = (column or 1) - 1 897 | self.ensure_hbounds() 898 | 899 | def cursor_to_line(self, line=None): 900 | """Move cursor to a specific line in the current column. 901 | 902 | :param int line: line number to move the cursor to. 903 | """ 904 | self.cursor.y = (line or 1) - 1 905 | 906 | # If origin mode (DECOM) is set, line number are relative to 907 | # the top scrolling margin. 908 | if mo.DECOM in self.mode: 909 | self.cursor.y += self.margins.top 910 | 911 | # FIXME: should we also restrict the cursor to the scrolling 912 | # region? 913 | 914 | self.ensure_vbounds() 915 | 916 | def bell(self, *args): 917 | """Bell stub -- the actual implementation should probably be 918 | provided by the end-user. 919 | """ 920 | 921 | def alignment_display(self): 922 | """Fills screen with uppercase E's for screen focus and alignment.""" 923 | for line in self.buffer: 924 | for column, char in enumerate(line): 925 | line[column] = char._replace(data="E") 926 | 927 | def select_graphic_rendition(self, *attrs): 928 | """Set display attributes. 929 | 930 | :param list attrs: a list of display attributes to set. 931 | """ 932 | replace = {} 933 | 934 | if not attrs: 935 | attrs = [0] 936 | else: 937 | attrs = list(reversed(attrs)) 938 | 939 | while attrs: 940 | attr = attrs.pop() 941 | if attr in g.FG_ANSI: 942 | replace["fg"] = g.FG_ANSI[attr] 943 | elif attr in g.BG: 944 | replace["bg"] = g.BG_ANSI[attr] 945 | elif attr in g.TEXT: 946 | attr = g.TEXT[attr] 947 | replace[attr[1:]] = attr.startswith("+") 948 | elif not attr: 949 | replace = self.default_char._asdict() 950 | elif attr in g.FG_AIXTERM: 951 | replace.update(fg=g.FG_AIXTERM[attr], bold=True) 952 | elif attr in g.BG_AIXTERM: 953 | replace.update(bg=g.BG_AIXTERM[attr], bold=True) 954 | elif attr in (g.FG_256, g.BG_256): 955 | key = "fg" if attr == g.FG_256 else "bg" 956 | n = attrs.pop() 957 | try: 958 | if n == 5: # 256. 959 | m = attrs.pop() 960 | replace[key] = g.FG_BG_256[m] 961 | elif n == 2: # 24bit. 962 | # This is somewhat non-standard but is nonetheless 963 | # supported in quite a few terminals. See discussion 964 | # here https://gist.github.com/XVilka/8346728. 965 | replace[key] = "{0:02x}{1:02x}{2:02x}".format( 966 | attrs.pop(), attrs.pop(), attrs.pop()) 967 | except IndexError: 968 | pass 969 | 970 | self.cursor.attrs = self.cursor.attrs._replace(**replace) 971 | 972 | def report_device_attributes(self, mode=0, **kwargs): 973 | """Report terminal identity. 974 | 975 | .. versionadded:: 0.5.0 976 | """ 977 | # We only implement "primary" DA which is the only DA request 978 | # VT102 understood, see ``VT102ID`` in ``linux/drivers/tty/vt.c``. 979 | if mode == 0: 980 | self.write_process_input(ctrl.CSI + b"?6c") 981 | 982 | def report_device_status(self, mode): 983 | """Report terminal status or cursor position. 984 | 985 | :param int mode: if 5 -- terminal status, 6 -- cursor position, 986 | otherwise a noop. 987 | 988 | .. versionadded:: 0.5.0 989 | """ 990 | if mode == 5: # Request for terminal status. 991 | self.write_process_input(ctrl.CSI + b"0n") 992 | elif mode == 6: # Request for cursor position. 993 | x = self.cursor.x + 1 994 | y = self.cursor.y + 1 995 | 996 | # "Origin mode (DECOM) selects line numbering." 997 | if mo.DECOM in self.mode: 998 | y -= self.margins.top 999 | self.write_process_input( 1000 | ctrl.CSI + "{0};{1}R".format(y, x).encode()) 1001 | 1002 | def write_process_input(self, data): 1003 | """Write data to the process running inside the terminal. 1004 | 1005 | By default is a noop. 1006 | 1007 | :param bytes data: data to write to the process ``stdin``. 1008 | 1009 | .. versionadded:: 0.5.0 1010 | """ 1011 | 1012 | def debug(self, *args, **kwargs): 1013 | """Endpoint for unrecognized escape sequences. 1014 | 1015 | By default is a noop. 1016 | """ 1017 | 1018 | 1019 | class DiffScreen(Screen): 1020 | """A screen subclass, which maintains a set of dirty lines in its 1021 | :attr:`dirty` attribute. The end user is responsible for emptying 1022 | a set, when a diff is applied. 1023 | 1024 | .. attribute:: dirty 1025 | 1026 | A set of line numbers, which should be re-drawn. 1027 | 1028 | >>> screen = DiffScreen(80, 24) 1029 | >>> screen.dirty.clear() 1030 | >>> screen.draw("!") 1031 | >>> list(screen.dirty) 1032 | [0] 1033 | """ 1034 | 1035 | def __init__(self, *args): 1036 | self.dirty = set() 1037 | super(DiffScreen, self).__init__(*args) 1038 | 1039 | def set_mode(self, *modes, **kwargs): 1040 | if mo.DECSCNM >> 5 in modes and kwargs.get("private"): 1041 | self.dirty.update(range(self.lines)) 1042 | super(DiffScreen, self).set_mode(*modes, **kwargs) 1043 | 1044 | def reset_mode(self, *modes, **kwargs): 1045 | if mo.DECSCNM >> 5 in modes and kwargs.get("private"): 1046 | self.dirty.update(range(self.lines)) 1047 | super(DiffScreen, self).reset_mode(*modes, **kwargs) 1048 | 1049 | def reset(self): 1050 | self.dirty.update(range(self.lines)) 1051 | super(DiffScreen, self).reset() 1052 | 1053 | def resize(self, *args, **kwargs): 1054 | self.dirty.update(range(self.lines)) 1055 | super(DiffScreen, self).resize(*args, **kwargs) 1056 | 1057 | def draw(self, *args): 1058 | # Call the superclass's method before marking the row as 1059 | # dirty, as when wrapping is enabled, draw() might change 1060 | # self.cursor.y. 1061 | super(DiffScreen, self).draw(*args) 1062 | self.dirty.add(self.cursor.y) 1063 | 1064 | def index(self): 1065 | if self.cursor.y == self.margins.bottom: 1066 | self.dirty.update(range(self.lines)) 1067 | 1068 | super(DiffScreen, self).index() 1069 | 1070 | def reverse_index(self): 1071 | if self.cursor.y == self.margins.top: 1072 | self.dirty.update(range(self.lines)) 1073 | 1074 | super(DiffScreen, self).reverse_index() 1075 | 1076 | def insert_lines(self, *args): 1077 | self.dirty.update(range(self.cursor.y, self.lines)) 1078 | super(DiffScreen, self).insert_lines(*args) 1079 | 1080 | def delete_lines(self, *args): 1081 | self.dirty.update(range(self.cursor.y, self.lines)) 1082 | super(DiffScreen, self).delete_lines(*args) 1083 | 1084 | def insert_characters(self, *args): 1085 | self.dirty.add(self.cursor.y) 1086 | super(DiffScreen, self).insert_characters(*args) 1087 | 1088 | def delete_characters(self, *args): 1089 | self.dirty.add(self.cursor.y) 1090 | super(DiffScreen, self).delete_characters(*args) 1091 | 1092 | def erase_characters(self, *args): 1093 | self.dirty.add(self.cursor.y) 1094 | super(DiffScreen, self).erase_characters(*args) 1095 | 1096 | def erase_in_line(self, *args): 1097 | self.dirty.add(self.cursor.y) 1098 | super(DiffScreen, self).erase_in_line(*args) 1099 | 1100 | def erase_in_display(self, how=0): 1101 | if how == 0: 1102 | self.dirty.update(range(self.cursor.y + 1, self.lines)) 1103 | elif how == 1: 1104 | self.dirty.update(range(self.cursor.y)) 1105 | elif how == 2: 1106 | self.dirty.update(range(self.lines)) 1107 | 1108 | super(DiffScreen, self).erase_in_display(how) 1109 | 1110 | def alignment_display(self): 1111 | self.dirty.update(range(self.lines)) 1112 | super(DiffScreen, self).alignment_display() 1113 | 1114 | 1115 | History = namedtuple("History", "top bottom ratio size position") 1116 | 1117 | 1118 | class HistoryScreen(DiffScreen): 1119 | """A :class:~`pyte.screens.DiffScreen` subclass, which keeps track 1120 | of screen history and allows pagination. This is not linux-specific, 1121 | but still useful; see page 462 of VT520 User's Manual. 1122 | 1123 | :param int history: total number of history lines to keep; is split 1124 | between top and bottom queues. 1125 | :param int ratio: defines how much lines to scroll on :meth:`next_page` 1126 | and :meth:`prev_page` calls. 1127 | 1128 | .. attribute:: history 1129 | 1130 | A pair of history queues for top and bottom margins accordingly; 1131 | here's the overall screen structure:: 1132 | 1133 | [ 1: .......] 1134 | [ 2: .......] <- top history 1135 | [ 3: .......] 1136 | ------------ 1137 | [ 4: .......] s 1138 | [ 5: .......] c 1139 | [ 6: .......] r 1140 | [ 7: .......] e 1141 | [ 8: .......] e 1142 | [ 9: .......] n 1143 | ------------ 1144 | [10: .......] 1145 | [11: .......] <- bottom history 1146 | [12: .......] 1147 | 1148 | .. note:: 1149 | 1150 | Don't forget to update :class:`~pyte.streams.Stream` class with 1151 | appropriate escape sequences -- you can use any, since pagination 1152 | protocol is not standardized, for example:: 1153 | 1154 | Stream.escape[b"N"] = "next_page" 1155 | Stream.escape[b"P"] = "prev_page" 1156 | """ 1157 | _wrapped = set(Stream.events) 1158 | _wrapped.update(["next_page", "prev_page"]) 1159 | 1160 | def __init__(self, columns, lines, history=100, ratio=.5): 1161 | self.history = History(deque(maxlen=history // 2), 1162 | deque(maxlen=history), 1163 | float(ratio), 1164 | history, 1165 | history) 1166 | 1167 | super(HistoryScreen, self).__init__(columns, lines) 1168 | 1169 | def _make_wrapper(self, event, handler): 1170 | def inner(*args, **kwargs): 1171 | self.before_event(event) 1172 | result = handler(*args, **kwargs) 1173 | self.after_event(event) 1174 | return result 1175 | return inner 1176 | 1177 | def __getattribute__(self, attr): 1178 | value = super(HistoryScreen, self).__getattribute__(attr) 1179 | if attr in HistoryScreen._wrapped: 1180 | return HistoryScreen._make_wrapper(self, attr, value) 1181 | else: 1182 | return value 1183 | 1184 | def before_event(self, event): 1185 | """Ensure a screen is at the bottom of the history buffer. 1186 | 1187 | :param str event: event name, for example ``"linefeed"``. 1188 | """ 1189 | if event not in ["prev_page", "next_page"]: 1190 | while self.history.position < self.history.size: 1191 | self.next_page() 1192 | 1193 | def after_event(self, event): 1194 | """Ensure all lines on a screen have proper width (:attr:`columns`). 1195 | 1196 | Extra characters are truncated, missing characters are filled 1197 | with whitespace. 1198 | 1199 | :param str event: event name, for example ``"linefeed"``. 1200 | """ 1201 | if event in ["prev_page", "next_page"]: 1202 | for idx, line in enumerate(self.buffer): 1203 | if len(line) > self.columns: 1204 | self.buffer[idx] = line[:self.columns] 1205 | elif len(line) < self.columns: 1206 | self.buffer[idx] = line + take(self.columns - len(line), 1207 | self.default_line) 1208 | 1209 | # If we're at the bottom of the history buffer and `DECTCEM` 1210 | # mode is set -- show the cursor. 1211 | self.cursor.hidden = not ( 1212 | abs(self.history.position - self.history.size) < self.lines and 1213 | mo.DECTCEM in self.mode 1214 | ) 1215 | 1216 | def reset(self): 1217 | """Overloaded to reset screen history state: history position 1218 | is reset to bottom of both queues; queues themselves are 1219 | emptied. 1220 | """ 1221 | super(HistoryScreen, self).reset() 1222 | 1223 | self.history.top.clear() 1224 | self.history.bottom.clear() 1225 | self.history = self.history._replace(position=self.history.size) 1226 | 1227 | def index(self): 1228 | """Overloaded to update top history with the removed lines.""" 1229 | top, bottom = self.margins 1230 | 1231 | if self.cursor.y == bottom: 1232 | self.history.top.append(self.buffer[top]) 1233 | 1234 | super(HistoryScreen, self).index() 1235 | 1236 | def reverse_index(self): 1237 | """Overloaded to update bottom history with the removed lines.""" 1238 | top, bottom = self.margins 1239 | 1240 | if self.cursor.y == top: 1241 | self.history.bottom.append(self.buffer[bottom]) 1242 | 1243 | super(HistoryScreen, self).reverse_index() 1244 | 1245 | def prev_page(self): 1246 | """Move the screen page up through the history buffer. Page 1247 | size is defined by ``history.ratio``, so for instance 1248 | ``ratio = .5`` means that half the screen is restored from 1249 | history on page switch. 1250 | """ 1251 | if self.history.position > self.lines and self.history.top: 1252 | mid = min(len(self.history.top), 1253 | int(math.ceil(self.lines * self.history.ratio))) 1254 | 1255 | self.history.bottom.extendleft(reversed(self.buffer[-mid:])) 1256 | self.history = self.history \ 1257 | ._replace(position=self.history.position - self.lines) 1258 | 1259 | self.buffer[:] = list(reversed([ 1260 | self.history.top.pop() for _ in range(mid) 1261 | ])) + self.buffer[:-mid] 1262 | 1263 | self.dirty = set(range(self.lines)) 1264 | 1265 | def next_page(self): 1266 | """Move the screen page down through the history buffer.""" 1267 | if self.history.position < self.history.size and self.history.bottom: 1268 | mid = min(len(self.history.bottom), 1269 | int(math.ceil(self.lines * self.history.ratio))) 1270 | 1271 | self.history.top.extend(self.buffer[:mid]) 1272 | self.history = self.history \ 1273 | ._replace(position=self.history.position + self.lines) 1274 | 1275 | self.buffer[:] = self.buffer[mid:] + [ 1276 | self.history.bottom.popleft() for _ in range(mid) 1277 | ] 1278 | 1279 | self.dirty = set(range(self.lines)) 1280 | --------------------------------------------------------------------------------