diff --git a/.gitignore b/.gitignore index dff85a0..b906920 100644 --- a/.gitignore +++ b/.gitignore @@ -7,22 +7,22 @@ __pycache__/ *.so # Distribution / packaging +.eggs/ +.installed.cfg .Python -env/ +*.egg +*.egg-info/ build/ develop-eggs/ dist/ downloads/ eggs/ -.eggs/ +env/ lib/ lib64/ parts/ sdist/ var/ -*.egg-info/ -.installed.cfg -*.egg # PyInstaller # Usually these files are written by a python script from a template @@ -35,15 +35,15 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports -htmlcov/ -.tox/ +.cache .coverage .coverage.* -.cache -nosetests.xml -coverage.xml -*,cover .hypothesis/ +.tox/ +*,cover +coverage.xml +htmlcov/ +nosetests.xml # Translations *.mo @@ -79,13 +79,13 @@ celerybeat-schedule .env # virtualenv -venv/ +bin/ ENV/ -local/ include/ -bin/ +local/ +venv/ -# sublime +# sublime *.sublime-* @@ -96,4 +96,3 @@ bin/ .ropeproject .idea - diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..4babf0f --- /dev/null +++ b/.pylintrc @@ -0,0 +1,407 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Allow optimization of some AST trees. This will activate a peephole AST +# optimizer, which will apply various small optimizations. For instance, it can +# be used to obtain the result of joining multiple strings with the addition +# operator. Joining a lot of strings can lead to a maximum recursion error in +# Pylint and this flag can prevent that. It has one side effect, the resulting +# AST will be different than the one from reality. This option is deprecated +# and it will be removed in Pylint 2.0. +optimize-ast=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,import-star-module-level,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,long-suffix,old-ne-operator,old-octal-literal,suppressed-message,useless-suppression,locally-disabled,missing-docstring,broad-except + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". This option is deprecated +# and it will be removed in Pylint 2.0. +files-output=no + +# Tells whether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + + +[ELIF] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO,HINT + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=666 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format=LF + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..1635e89 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include aker/aker.ini +include README.md +include requirements.txt diff --git a/README.md b/README.md index e320a36..1d4c1ea 100644 --- a/README.md +++ b/README.md @@ -23,24 +23,25 @@ I couldn't find an open source tool similar to [CryptoAuditor](https://www.ssh.c ### Roadmap * Phase 0 - * Integration with an identity provider (FreeIPA) - * Extendable Modular structure, plugin your own module - * Integration with config management tools - * Parsable audit logs (json, shipped to Elasticsearch) - * Highly available setup - * Session playback + * Integration with an identity provider (FreeIPA) + * Extendable Modular structure, plugin your own module + * Integration with config management tools + * Parsable audit logs (json, shipped to Elasticsearch) + * Highly available setup + * Session playback * Phase 1 - * Admin WebUI - * Live session monitoring - * Cloud support (AWS,OpenStack etc..) or On-premises deployments - * Command filtering (Prevent destructive commands like rm -rf) - * Encrypt sessions logs stored on disk. + * Admin WebUI + * Live session monitoring + * Cloud support (AWS,OpenStack etc..) or On-premises deployments + * Command filtering (Prevent destructive commands like rm -rf) + * Encrypt sessions logs stored on disk. + * Phase 2 - * Support for graphical protocols (RDP, VNC, X11) monitoring - * User productivity dashboard + * Support for graphical protocols (RDP, VNC, X11) monitoring + * User productivity dashboard ### See it in action @@ -49,109 +50,121 @@ I couldn't find an open source tool similar to [CryptoAuditor](https://www.ssh.c ### Requirements Software: -- Linux (Tested on CentOS, Fedora and ubuntu) -- Python (Tested on 2.7) -- (Optional) FreeIPA, Tested on FreeIPA 4.2 & 4.3 -- redis +* Linux (Tested on CentOS, Fedora and ubuntu) +* Python (Tested on 2.7) +* (Optional) FreeIPA, Tested on FreeIPA 4.2 & 4.3 +* redis Python Modules: -- configparser -- urwid -- paramiko -- wcwidth -- pyte -- redis +* configparser +* urwid +* paramiko +* wcwidth +* pyte +* redis ### Installation -* Automated : - * Use [this ansible playbook](https://github.com/aker-gateway/aker-freeipa-playbook) - - -* Manually: -- Aker can be setup on a FreeIPA client or indepentantly using json config file. - - * Common Steps (FreeIPA or Json): - - * Clone the repo - ~~~ - git clone https://github.com/aker-gateway/Aker.git /usr/bin/aker/ - ~~~ - - * Install dependencies (adapt for Ubuntu) - ~~~ - yum -y install epel-release - yum -y install python2-paramiko python-configparser python-redis python-urwid python2-wcwidth redis - ~~~ - - - * Set files executable perms - ``` - chmod 755 /usr/bin/aker/aker.py - chmod 755 /usr/bin/aker/akerctl.py - ``` - - * Setup logdir and perms - ``` - mkdir /var/log/aker - chmod 777 /var/log/aker - ``` - - * Enforce aker on all users but root, edit sshd_config - ~~~ - Match Group *,!root - ForceCommand /usr/bin/aker/aker.py - ~~~ - - * Restart ssh - * Restart redis - - - - * Choosing FreeIPA: - * Assumptions: - * Aker server already enrolled to FreeIPA domain - - * Create /etc/aker and copy /usr/bin/aker/aker.ini in it and edit it like below : - - ``` - [General] - log_level = INFO - ssh_port = 22 - - # Identity Provider to determine the list of available hosts - # options shipped are IPA, Json. Default is IPA - idp = IPA - hosts_file = /etc/aker/hosts.json - - # FreeIPA hostgroup name contatining Aker gateways - # to be excluded from hosts presented to user - gateway_group = gateways - ``` - - - - * Choosing Json: - * Create /etc/aker and copy /usr/bin/aker/aker.ini in it and edit it like below : - - ``` - [General] - log_level = INFO - ssh_port = 22 - - # Identity Provider to determine the list of available hosts - # options shipped are IPA, Json. Default is IPA - idp = Json - hosts_file = /etc/aker/hosts.json - - # FreeIPA hostgroup name contatining Aker gateways - # to be excluded from hosts presented to user - gateway_group = gateways - ``` - - * Edit /etc/aker/hosts.json to add users and hosts, a sample `hosts.json` file is provided . - - +#### Automated : +* Use [this ansible playbook](https://github.com/aker-gateway/aker-freeipa-playbook) + + +#### Manually: +Aker can be setup on a FreeIPA client or indepentantly using json config file. + +* Common Steps (FreeIPA or Json): + + * Clone the repo + ``` + git clone https://github.com/aker-gateway/Aker.git /usr/bin/aker/ + ``` + + * Install dependencies (adapt for Ubuntu) + ``` + yum -y install epel-release + yum -y install python2-paramiko python-configparser python-redis python-urwid python2-wcwidth redis + ``` + + * Set files executable perms + ``` + chmod 755 /usr/bin/aker/aker.py + chmod 755 /usr/bin/aker/akerctl.py + ``` + + * Setup logdir and perms + ``` + mkdir /var/log/aker + chmod 777 /var/log/aker + ``` + + * Enforce aker on all users but root, edit sshd_config + ``` + Match Group *,!root + ForceCommand /usr/bin/aker/aker.py + ``` + + * Restart ssh + * Restart redis + + +* Choosing FreeIPA: + * Assumptions: + * Aker server already enrolled to FreeIPA domain + + * Create /etc/aker and copy /usr/bin/aker/aker.ini in it and edit it like below : + ``` + [General] + log_level = INFO + ssh_port = 22 + + # Identity Provider to determine the list of available hosts + # options shipped are IPA, Json. Default is IPA + idp = IPA + hosts_file = /etc/aker/hosts.json + + # FreeIPA hostgroup name contatining Aker gateways + # to be excluded from hosts presented to user + gateway_group = gateways + ``` + + +* Choosing Json: + * Create /etc/aker and copy /usr/bin/aker/aker.ini in it and edit it like below : + ``` + [General] + log_level = INFO + ssh_port = 22 + + # Identity Provider to determine the list of available hosts + # options shipped are IPA, Json. Default is IPA + idp = Json + hosts_file = /etc/aker/hosts.json + + # FreeIPA hostgroup name contatining Aker gateways + # to be excluded from hosts presented to user + gateway_group = gateways + ``` + + * Edit /etc/aker/hosts.json to add users and hosts, a sample `hosts.json` file is provided . + + +* Aker also consumes commandline arguments: + +``` +usage: aker.py [-h] [--config CONFIG] [--log-file LOG_FILE] + [--log-level LOG_LEVEL] [--session-log-dir SESSION_LOG_DIR] + +optional arguments: + -h, --help show this help message and exit + --config CONFIG, -c CONFIG + Path to config file + --log-file LOG_FILE Path to log file + --log-level {DEBUG,INFO,WARNING,ERROR,CRITICAL,FATAL} + Set log level + --session-log-dir SESSION_LOG_DIR + Session log dir +``` + ### Contributing 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. diff --git a/aker.ini b/aker.ini deleted file mode 100644 index a6f52dd..0000000 --- a/aker.ini +++ /dev/null @@ -1,12 +0,0 @@ -[General] -log_level = INFO -ssh_port = 22 - -# Identity Provider to determine the list of available hosts -# options shipped are IPA, Json. Default is IPA -idp = IPA -hosts_file = /etc/aker/hosts.json - -# FreeIPA hostgroup name contatining Aker gateways -# to be excluded from hosts presented to user -gateway_group = gateways diff --git a/aker.py b/aker.py deleted file mode 100755 index bd5a68c..0000000 --- a/aker.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright 2016 ahmed@nazmy.io -# -# For license information see LICENSE.txt - - -# Meta -__version__ = '0.4.4' -__version_info__ = (0, 4, 4) -__license__ = "AGPLv3" -__license_info__ = { - "AGPLv3": { - "product": "aker", - "users": 0, # 0 being unlimited - "customer": "Unsupported", - "version": __version__, - "license_format": "1.0", - } -} - -import logging -import os -import sys -import uuid -import getpass -import paramiko -import socket -from configparser import ConfigParser, NoOptionError -import time - -from hosts import Hosts -import tui -from session import SSHSession -from snoop import SSHSniffer - - -config_file = "/etc/aker/aker.ini" -log_file = '/var/log/aker/aker.log' -session_log_dir = '/var/log/aker/' - - -class Configuration(object): - def __init__(self, filename): - remote_connection = os.environ.get('SSH_CLIENT', '0.0.0.0 0') - self.src_ip = remote_connection.split()[0] - self.src_port = remote_connection.split()[1] - self.session_uuid = uuid.uuid1() - # TODO: Check file existence, handle exception - self.configparser = ConfigParser() - if filename: - self.configparser.read(filename) - self.log_level = self.configparser.get('General', 'log_level') - self.ssh_port = self.configparser.get('General', 'ssh_port') - - def get(self, *args): - if len(args) == 3: - try: - return self.configparser.get(args[0], args[1]) - except NoOptionError as e: - return args[2] - if len(args) == 2: - return self.configparser.get(args[0], args[1]) - else: - return self.configparser.get('General', args[0]) - - -class User(object): - def __init__(self, username): - self.name = username - gateway_hostgroup = config.get('gateway_group') - idp = config.get('idp') - logging.debug("Core: using Identity Provider {0}".format(idp)) - self.hosts = Hosts(config, self.name, gateway_hostgroup, idp) - self.allowed_ssh_hosts, self.hostgroups = self.hosts.list_allowed() - - def get_priv_key(self): - try: - # TODO: check better identity options - privkey = paramiko.RSAKey.from_private_key_file( - os.path.expanduser("~/.ssh/id_rsa")) - except Exception as e: - logging.error( - "Core: Invalid Private Key for user {0} : {1} ".format( - self.name, e.message)) - raise Exception("Core: Invalid Private Key") - else: - return privkey - - def refresh_allowed_hosts(self, fromcache): - logging.info( - "Core: reloading hosts for user {0} from backened identity provider".format( - self.name)) - self.allowed_ssh_hosts, self.hostgroups = self.hosts.list_allowed( - from_cache=fromcache) - - -class Aker(object): - """ Aker core module, this is the management module - """ - - def __init__(self, log_level='INFO'): - global config - config = Configuration(config_file) - self.config = config - self.posix_user = getpass.getuser() - self.log_level = config.log_level - self.port = config.ssh_port - - # Setup logging first thing - for handler in logging.root.handlers[:]: - logging.root.removeHandler(handler) - logging.basicConfig( - format='%(asctime)s - %(levelname)s - %(message)s', - filename=log_file, - level=config.log_level) - logging.info( - "Core: Starting up, user={0} from={1}:{2}".format( - self.posix_user, - config.src_ip, - config.src_port)) - - self.user = User(self.posix_user) - - def build_tui(self): - logging.debug("Core: Drawing TUI") - self.tui = tui.Window(self) - self.tui.draw() - self.tui.start() - - def init_connection(self, host): - screen_size = self.tui.loop.screen.get_cols_rows() - logging.debug("Core: pausing TUI") - self.tui.pause() - # TODO: check for shorter yet unique uuid - session_uuid = uuid.uuid4() - session_start_time = time.strftime("%Y%m%d-%H%M%S") - session = SSHSession(self, host, session_uuid) - # TODO: add err handling - sniffer = SSHSniffer( - self.posix_user, - config.src_port, - host, - session_uuid, - screen_size) - session.attach_sniffer(sniffer) - logging.info( - "Core: Starting session UUID {0} for user {1} to host {2}".format( - session_uuid, self.posix_user, host)) - session.connect(screen_size) - try: - session.start_session() - finally: - session.stop_sniffer() - self.tui.restore() - self.tui.hostlist.search.clear() # Clear selected hosts - - def session_end_callback(self, session): - logging.info( - "Core: Finished session UUID {0} for user {1} to host {2}".format( - session.uuid, - self.posix_user, - session.host)) - - -if __name__ == '__main__': - Aker().build_tui() diff --git a/aker/__init__.py b/aker/__init__.py new file mode 100644 index 0000000..f8f707d --- /dev/null +++ b/aker/__init__.py @@ -0,0 +1,13 @@ + +__version__ = '0.5.0' +__version_info__ = tuple(__version__.split('.')) +__license__ = 'AGPLv3' +__license_info__ = { + 'AGPLv3': { + 'customer': 'Unsupported', + 'license_format': '1.0', + 'product': 'aker', + 'users': 0, # 0 being unlimited + 'version': __version__, + } +} diff --git a/aker/aker.ini b/aker/aker.ini new file mode 100644 index 0000000..c56f310 --- /dev/null +++ b/aker/aker.ini @@ -0,0 +1,13 @@ +[General] +log_level = INFO +log_file = /var/log/aker/aker.log +ssh_port = 22 + +; Identity Provider to determine the list of available hosts +; options shipped are ipa, json. Default is ipa +idp = ipa +hosts_file = /etc/aker/hosts.json + +; FreeIPA hostgroup name contatining Aker gateways +; to be excluded from hosts presented to user +gateway_group = gateways diff --git a/aker/aker.py b/aker/aker.py new file mode 100755 index 0000000..356ea7d --- /dev/null +++ b/aker/aker.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 ahmed@nazmy.io +# +# For license information see LICENSE.txt + +import getpass +import logging + +from uuid import uuid4 + +from .session import SSHSession +from .snoop import SSHSniffer +from .tui import Window +from .user import User + + +class Aker(object): + """Aker core module, this is the management module""" + + def __init__(self, config): + self.config = config + self.posix_user = getpass.getuser() + self.port = config.ssh_port + self.tui = None + + logging.info('Core: Starting up, user=%s from=%s: %s', self.posix_user, config.src_ip, config.src_port) + + self.user = User(self.config, self.posix_user) + + def build_tui(self): + logging.debug('Core: Drawing TUI') + self.tui = Window(self) + self.tui.draw() + self.tui.start() + + def init_connection(self, host): + screen_size = self.tui.loop.screen.get_cols_rows() + logging.debug('Core: pausing TUI') + self.tui.pause() + # TODO: check for shorter yet unique uuid + session_uuid = uuid4() + # session_start_time = time.strftime('%Y%m%d-%H%M%S') + session = SSHSession(self, host, session_uuid) + # TODO: add err handling + sniffer = SSHSniffer( + self.posix_user, + self.config.src_port, + host, + session_uuid, + screen_size, + self.config.session_log_dir) + session.attach_sniffer(sniffer) + logging.info('Core: Starting session UUID %s for user %s to host %s', session_uuid, self.posix_user, host) + session.connect(screen_size) + try: + session.start_session() + except Exception as exc: + logging.error('Core: start_session failed: %s', exc.message) + raise + finally: + session.stop_sniffer() + self.tui.restore() + self.tui.hostlist.search.clear() # Clear selected hosts + + def session_end_callback(self, session): + logging.info('Core: Finished session UUID %s for user %s to host %s', session.uuid, self.posix_user, session.host) diff --git a/idp/__init__.py b/aker/cli/__init__.py similarity index 100% rename from idp/__init__.py rename to aker/cli/__init__.py diff --git a/aker/cli/aker.py b/aker/cli/aker.py new file mode 100644 index 0000000..c92031f --- /dev/null +++ b/aker/cli/aker.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python + +import logging + +from argparse import ArgumentParser + +from ..config import Config +from ..aker import Aker + + +def run(): + parser = ArgumentParser(description='Aker SSH gateway') + parser.add_argument('--config', '-c', default='/etc/aker/aker.ini', help='Path to config file') + parser.add_argument('--hosts-file', default='/etc/aker/hosts.json', help='Path to JSON file with allowed hosts') + parser.add_argument('--idp', default='ipa', help='idp provider', choices=('ipa', 'json',)) + parser.add_argument('--log-file', default='/var/log/aker/aker.log', help='Path to log file') + parser.add_argument('--log-level', default='INFO', help='Set log level', choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL', 'FATAL')) + parser.add_argument('--session-log-dir', default='/var/log/aker', help='Session log dir') + args = parser.parse_args() + + config = Config( + filename=args.config, + hosts_file=args.hosts_file, + idp=args.idp, + log_file=args.log_file, + log_level=args.log_level, + session_log_dir=args.session_log_dir, + ) + + # Setup logging first thing + for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + + logging.basicConfig( + format='%(asctime)s - %(levelname)s - %(message)s', + filename=config.log_file, + level=config.log_level) + + Aker(config).build_tui() + + +if __name__ == '__main__': + run() diff --git a/aker/cli/akerctl.py b/aker/cli/akerctl.py new file mode 100644 index 0000000..e3efd71 --- /dev/null +++ b/aker/cli/akerctl.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +from argparse import ArgumentParser + +from ..commands import akerctl_commands +from ..config import Config + + +def run(): + parser = ArgumentParser(description='Aker session replay') + parser.add_argument('--config', '-c', default='/etc/aker/aker.ini', help='Path to config file') + parser.add_argument('--log-file', default='/var/log/aker/aker.log', help='Path to log file') + parser.add_argument('--log-level', default='INFO', help='Set log level', choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL', 'FATAL')) + parser.add_argument('--session-log-dir', default='/var/log/aker', help='Session log dir') + parser.add_argument('--uuid', action='store', help='Recorded Session UUID', required=True) + + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--replay', action='store_true', help='Replay Session') + group.add_argument('--commands', action='store_true', help='Print Commands Entered By User During Session') + + args = parser.parse_args() + + config = Config( + filename=args.config, + log_file=args.log_file, + log_level=args.log_level, + session_log_dir=args.session_log_dir, + ) + + akerctl_commands(args, config) + + +if __name__ == '__main__': + run() diff --git a/aker/commands.py b/aker/commands.py new file mode 100755 index 0000000..e37b7c3 --- /dev/null +++ b/aker/commands.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2017 Ahmed Nazmy +# + +# Meta +__license__ = 'AGPLv3' +__author__ = 'Ahmed Nazmy ' + +import codecs +import fnmatch +import json +import os +import sys +import time + +from contextlib import closing + + +def akerctl_commands(args, config): + session_uuid = args.uuid + base_filename = '*' + session_uuid + '*' + log_file = base_filename + '.log' + log_timer = base_filename + '.timer' + cmds_file = base_filename + '.cmds' + + logfile_path = locate(log_file, config.session_log_dir) + timefile_path = locate(log_timer, config.session_log_dir) + if args.replay: + replay(logfile_path, timefile_path) + elif args.commands: + cmds_filepath = locate(cmds_file, config.session_log_dir) + show_cmds(cmds_filepath) + + +def show_cmds(cmds_file): + data = [] + with open(cmds_file) as json_file: + for line in json_file: + data.append(json.loads(line)) + for k in data: + try: + print(k['timing'] + ':' + k['cmd']) + except Exception: + pass + + +def replay(log_file, time_file): + with open(log_file) as logf: + with open(time_file) as timef: + timing = get_timing(timef) + with closing(logf): + logf.readline() # ignore first line, (Session Start) + for times in timing: + data = logf.read(times[1]) + # print('data is %s , t is %s' % (data,t[1])) + text = codecs.decode(data, 'UTF-8', 'replace') + time.sleep(times[0]) + sys.stdout.write(text) + sys.stdout.flush() + text = '' + + +def locate(pattern, root=os.curdir): + for path, _, files in os.walk(os.path.abspath(root)): + for filename in fnmatch.filter(files, pattern): + matches = os.path.join(path, filename) + return matches + + +def get_timing(timef): + timing = None + with closing(timef): + timing = [l.strip().split(' ') for l in timef] + timing = [(float(r[0]), int(r[1])) for r in timing] + return timing diff --git a/aker/config.py b/aker/config.py new file mode 100644 index 0000000..b268394 --- /dev/null +++ b/aker/config.py @@ -0,0 +1,38 @@ + +import os + +from configparser import ConfigParser, NoOptionError +from uuid import uuid1 + + +class Config(object): + """Configuration object""" + + def __init__(self, filename, hosts_file='', idp='', log_file='', log_level='', session_log_dir=''): + remote_connection = os.environ.get('SSH_CLIENT', '0.0.0.0 0').split() + self.src_ip = remote_connection[0] + self.src_port = remote_connection[1] + self.session_uuid = uuid1() + # TODO: Check file existence, handle exception + + self.parser = ConfigParser() + if filename: + self.parser.read(filename) + + self.hosts_file = hosts_file or self.parser.get('General', 'hosts_file') + self.idp = idp or self.parser.get('General', 'idp') + self.log_file = log_file or self.parser.get('General', 'log_file') + self.log_level = log_level or self.parser.get('General', 'log_level') + self.session_log_dir = session_log_dir or self.parser.get('General', 'session_log_dir') + self.ssh_port = self.parser.get('General', 'ssh_port') + + def get(self, *args): + """Get arbitrary config value from config file""" + if len(args) == 3: + try: + return self.parser.get(args[0], args[1]) + except NoOptionError: + return args[2] + if len(args) == 2: + return self.parser.get(args[0], args[1]) + return self.parser.get('General', args[0]) diff --git a/hosts.py b/aker/hosts.py similarity index 58% rename from hosts.py rename to aker/hosts.py index 45d8dc5..6726441 100644 --- a/hosts.py +++ b/aker/hosts.py @@ -4,18 +4,17 @@ # # Meta -__license__ = "AGPLv3" +__license__ = 'AGPLv3' __author__ = 'Ahmed Nazmy ' import json import logging -import redis -from IdPFactory import IdPFactory +from redis import StrictRedis +from .idp_factory import IdPFactory class HostGroup(object): - """ - Class representing single hostgroup.A hostgroup + """Class representing single hostgroup.A hostgroup holds a list of hosts/servers that are members of it. Attributes name: Hostgroup name @@ -26,7 +25,7 @@ def __init__(self, name): self.hosts = [] def __str__(self): - return "name:%s, hosts:%s" % (self.fqdn, self.ssh_port, self.hosts) + return '{0}(name: {1}, hosts:{2})'.format(self.__class__.__name__, self.name, self.hosts) def __iter__(self): return self @@ -36,8 +35,7 @@ def add_host(self, hostname): class Host(object): - """ - Class representing a single server entry, + """Class representing a single server entry, each Host/server has to be a member one or more hostgroup. Servers have the following attributes : @@ -54,22 +52,17 @@ def __init__(self, name, memberof_hostgroups, ssh_port=22): self.hostgroups = memberof_hostgroups def equal(self, server): - if self.fqdn == server.fqdn and self.ssh_port == server.ssh_port: - return True - else: - return False + return bool(self.fqdn == server.fqdn and self.ssh_port == server.ssh_port) def __str__(self): - return "fqdn:%s, ssh_port:%d, hostgroups:%s" % ( - self.fqdn, self.ssh_port, self.hostgroups) + return '{0}(fqdn: {1}, ssh_port: {2}, hostgroups: {3}'.format(self.__class__.__name__, self.fqdn, self.ssh_port, self.hostgroups) def __iter__(self): return self class Hosts(object): - """ - A class to handle all interactions with hosts allowed to the user, + """A class to handle all interactions with hosts allowed to the user, it handles operations between cache(Redis) and backend identity providers like IPA, Json etc.. @@ -79,69 +72,57 @@ class Hosts(object): def __init__(self, config, username, gateway_hostgroup, idp): self._allowed_ssh_hosts = {} + self._backend_hosts = None self.user = username self._hostgroups = {} # username is the redis key, well kinda - self.hosts_cache_key = self.user + ":hosts" - self.hostgroups_cache_key = self.user + ":hostgroups" + self.hosts_cache_key = self.user + ':hosts' + self.hostgroups_cache_key = self.user + ':hostgroups' self.gateway_hostgroup = gateway_hostgroup - self.idp = IdPFactory.getIdP(idp)(config, username, gateway_hostgroup) + self.idp = IdPFactory.get_idp(idp)(config, username, gateway_hostgroup) # TODO: do we need a configurable redis host? self.redis = self._init_redis_conn('localhost') - def _init_redis_conn(self, RedisHost): - redis_connection = redis.StrictRedis( - RedisHost, db=0, decode_responses=True) + @staticmethod + def _init_redis_conn(redis_host): + redis_connection = StrictRedis(redis_host, db=0, decode_responses=True) try: if redis_connection.ping(): return redis_connection - except Exception as e: - logging.error( - "Hosts: all subsequent calls will fallback to backened idp, cache error: {0}".format( - e.message)) + except Exception as exc: + logging.error('Hosts: all subsequent calls will fallback to backened idp, cache error: %s', exc.message) return None def _load_hosts_from_cache(self, hkey): - result = self.redis.hgetall(hkey) cached = False if result is not None: try: - for k, v in result.iteritems(): + for _, val in result.iteritems(): # Deserialize back from redis hostentry = Host( - json.loads(v)['fqdn'], - json.loads(v)['hostgroups']) + json.loads(val)['fqdn'], + json.loads(val)['hostgroups']) self._allowed_ssh_hosts[hostentry.fqdn] = hostentry - logging.debug( - "Hosts: loading host {0} from cache".format( - hostentry.fqdn)) + logging.debug('Hosts: loading host %s from cache', hostentry.fqdn) cached = True - except Exception as e: - logging.error("Hosts: redis error: {0}".format(e.message)) + except Exception as exc: + logging.error('Hosts: redis error: %s', exc.message) cached = False else: - logging.info( - "Hosts: no hosts loaded from cache for user %s" % - self.user) + logging.info('Hosts: no hosts loaded from cache for user %s', self.user) cached = False return cached def _save_hosts_to_cache(self, hosts): - """ - hosts passed to this function should be a dict of Host object - """ + """Hosts passed to this function should be a dict of Host object""" # Delete existing cache if any try: self._del_cache_key(self.hosts_cache_key) - logging.debug( - "Hosts: deleting hosts for user {0} from cache".format( - self.user)) - except Exception as e: - logging.error( - "Hosts: error deleting hosts from cache: {0}".format( - e.message)) + logging.debug('Hosts: deleting hosts for user %s from cache', self.user) + except Exception as exc: + logging.error('Hosts: error deleting hosts from cache: %s', exc.message) # populate cache with new entries for host in hosts.values(): @@ -152,81 +133,59 @@ def _save_hosts_to_cache(self, hosts): host.fqdn, json.dumps( vars(host))) - logging.debug( - "Hosts: adding host {0} to cache".format( - host.fqdn)) - hostentry = None - except Exception as e: - logging.error( - "Hosts: error saving to cache : {0}".format( - e.message)) + logging.debug('Hosts: adding host %s to cache', host.fqdn) + except Exception as exc: + logging.error('Hosts: error saving to cache: %s', exc.message) def _load_hostgroups_from_cache(self, hkey): - result = self.redis.hgetall(hkey) cached = False if result is not None: try: - for k, v in result.iteritems(): + for _, val in result.iteritems(): # Deserialize back from redis - hostgroupentry = HostGroup(json.loads(v)['name']) - for host in json.loads(v)['hosts']: + hostgroupentry = HostGroup(json.loads(val)['name']) + for host in json.loads(val)['hosts']: hostgroupentry.add_host(host) self._hostgroups[hostgroupentry.name] = hostgroupentry cached = True - except Exception as e: - logging.error("Hostgroups: redis error: {0}".format(e.message)) + except Exception as exc: + logging.error('Hostgroups: redis error: %s', exc.message) cached = False else: - logging.info( - "Hostgroups: no hostgroups loaded from cache for user %s" % - self.user) + logging.info('Hostgroups: no hostgroups loaded from cache for user %s', self.user) cached = False return cached def _save_hostgroups_to_cache(self, hostgroups): - """ - hosts passed to this function should be a dict of HostGroup object - """ + """Hosts passed to this function should be a dict of HostGroup object""" # Delete existing cache if any try: self._del_cache_key(self.hostgroups_cache_key) - logging.debug( - "Hosts: deleting hostgroups for user {0} from cache".format( - self.user)) - except Exception as e: - logging.error( - "Hosts: error deleting hostgroups from cache: {0}".format( - e.message)) + logging.debug('Hosts: deleting hostgroups for user %s from cache', self.user) + except Exception as exc: + logging.error('Hosts: error deleting hostgroups from cache: %s', exc.message) for hostgroup in hostgroups.values(): try: - logging.debug( - "Hosts: adding hostgroup {0} to cache".format( - hostgroup.name)) + logging.debug('Hosts: adding hostgroup %s to cache', hostgroup.name) self.redis.hset( self.hostgroups_cache_key, hostgroup.name, - json.dumps( - vars(hostgroup))) - except Exception as e: - logging.error( - "Hosts: error saving to cache : {0}".format( - e.message)) + json.dumps(vars(hostgroup)) + ) + except Exception as exc: + logging.error('Hosts: error saving to cache: %s', exc.message) def _del_cache_key(self, hkey): try: self.redis.delete(hkey) - except Exception as e: - logging.error( - "Hosts: error deleting from cache : {0}".format( - e.message)) + except Exception as exc: + logging.error('Hosts: error deleting from cache: %s', exc.message) def list_allowed(self, from_cache=True): - """ - This function is the interface to the TUI - """ + """This function is the interface to the TUI""" cached = False @@ -245,17 +204,15 @@ def list_allowed(self, from_cache=True): # backened cache has some entries for us? if cached is True: - logging.info("Hosts: loading hosts from cache") + logging.info('Hosts: loading hosts from cache') return self._allowed_ssh_hosts, self._hostgroups - # No cached objects else: - # Passing the baton from the backend self._backend_hosts = self.idp.list_allowed() # Build Host() objects out of items we got from backend - for backend_host, backend_host_attributes in self._backend_hosts.iteritems(): + for _, backend_host_attributes in self._backend_hosts.iteritems(): hostentry = Host( backend_host_attributes['fqdn'], backend_host_attributes['hostgroups']) diff --git a/test-requirements.txt b/aker/idp/__init__.py similarity index 100% rename from test-requirements.txt rename to aker/idp/__init__.py diff --git a/idp/IPA.py b/aker/idp/ipa.py similarity index 69% rename from idp/IPA.py rename to aker/idp/ipa.py index 8e4ca6b..3f1c4c0 100644 --- a/idp/IPA.py +++ b/aker/idp/ipa.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright 2017 Ahmed Nazmy @@ -7,26 +6,26 @@ # Meta -from IdPFactory import IdP import logging import pyhbac + +from ipalib import _, ngettext from ipalib import api, errors, output, util from ipalib import Command, Str, Flag, Int from ipalib.cli import to_cli -from ipalib import _, ngettext -from ipapython.dn import DN from ipalib.plugable import Registry +from ipapython.dn import DN + +from ..idp_factory import IdP -class IPA(IdP): - ''' - Abstract represtation of user allowed hosts. - Currently relying on FreeIPA API - ''' +class Ipa(IdP): + """Abstract represtation of user allowed hosts. + Currently relying on FreeIPA API""" def __init__(self, config, username, gateway_hostgroup): - super(IPA, self).__init__(username, gateway_hostgroup) - logging.info("IPA: loaded") + super(Ipa, self).__init__(username, gateway_hostgroup) + logging.info('IPA: loaded') api.bootstrap(context='cli') api.finalize() try: @@ -36,17 +35,23 @@ def __init__(self, config, username, gateway_hostgroup): self.api = api self.default_ssh_port = config.ssh_port - def convert_to_ipa_rule(self, rule): + def list_allowed(self): + self._all_ssh_hosts = self._load_all_hosts() + self._load_user_allowed_hosts() + return self._allowed_ssh_hosts + + @staticmethod + def convert_to_ipa_rule(rule): # convert a dict with a rule to an pyhbac rule ipa_rule = pyhbac.HbacRule(rule['cn'][0]) ipa_rule.enabled = rule['ipaenabledflag'][0] # Following code attempts to process rule systematically - structure = \ - (('user', 'memberuser', 'user', 'group', ipa_rule.users), - ('host', 'memberhost', 'host', 'hostgroup', ipa_rule.targethosts), - ('sourcehost', 'sourcehost', 'host', 'hostgroup', ipa_rule.srchosts), - ('service', 'memberservice', 'hbacsvc', 'hbacsvcgroup', ipa_rule.services), - ) + structure = ( + ('user', 'memberuser', 'user', 'group', ipa_rule.users), + ('host', 'memberhost', 'host', 'hostgroup', ipa_rule.targethosts), + ('sourcehost', 'sourcehost', 'host', 'hostgroup', ipa_rule.srchosts), + ('service', 'memberservice', 'hbacsvc', 'hbacsvcgroup', ipa_rule.services), + ) for element in structure: category = '%scategory' % (element[0]) if (category in rule and rule[category][0] == u'all') or ( @@ -69,12 +74,10 @@ def convert_to_ipa_rule(self, rule): rule['externalhost']) # pylint: disable=E1101 return ipa_rule - def _load_all_hosts(self, api): - ''' - This function prints a list of all hosts. This function requires - one argument, the FreeIPA/IPA API object. - ''' - result = api.Command.host_find( + def _load_all_hosts(self): + """This function prints a list of all hosts. This function requires + one argument, the FreeIPA/IPA API object""" + result = self.api.Command.host_find( not_in_hostgroup=self.gateway_hostgroup)['result'] members = {} for ipa_host in result: @@ -82,12 +85,12 @@ def _load_all_hosts(self, api): if isinstance(ipa_hostname, (tuple, list)): ipa_hostname = ipa_hostname[0] members[ipa_hostname] = {'fqdn': ipa_hostname} - logging.debug("IPA: ALL_HOSTS %s", ipa_hostname) + logging.debug('IPA: ALL_HOSTS %s', ipa_hostname) return members def _load_user_allowed_hosts(self): - self._all_ssh_hosts = self._load_all_hosts(self.api) + self._all_ssh_hosts = self._load_all_hosts() hbacset = [] rules = [] sizelimit = None @@ -101,11 +104,11 @@ def _load_user_allowed_hosts(self): for host, host_attributes in self._all_ssh_hosts.iteritems(): try: hostname = host_attributes['fqdn'] - logging.debug("IPA: Checking %s", hostname) + logging.debug('IPA: Checking %s', hostname) ret = api.Command.hbactest( user=self.user.decode('utf-8'), targethost=hostname, - service=u"sshd", + service=u'sshd', rules=rules) if ret['value']: result = api.Command.host_show( @@ -114,14 +117,10 @@ def _load_user_allowed_hosts(self): # TODO: Add per-host ssh port checks sshport = self.default_ssh_port self._allowed_ssh_hosts[host] = { - 'fqdn': hostname, 'ssh_port': sshport, 'hostgroups': memberof_hostgroup} - logging.debug("IPA: ALLOWED_HOSTS %s", host) - except Exception as e: - logging.error( - "IPA: error evaluating HBAC : {0}".format( - e.message)) - - def list_allowed(self): - self._all_ssh_hosts = self._load_all_hosts(self.api) - self._load_user_allowed_hosts() - return self._allowed_ssh_hosts + 'fqdn': hostname, + 'hostgroups': memberof_hostgroup, + 'ssh_port': sshport, + } + logging.debug('IPA: ALLOWED_HOSTS %s', host) + except Exception as exc: + logging.error('IPA: error evaluating HBAC : %s', exc.message) diff --git a/aker/idp/json.py b/aker/idp/json.py new file mode 100644 index 0000000..06cf06b --- /dev/null +++ b/aker/idp/json.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2017 Ahmed Nazmy +# + +# Meta + +from __future__ import absolute_import # TODO Remove when use Python3 only + +import json +import logging + +from ..idp_factory import IdP + + +class Json(IdP): + """Fetch the authority informataion from a JSON configuration""" + + def __init__(self, config, username, gateway_hostgroup): + super(Json, self).__init__(username, gateway_hostgroup) + logging.info('Json: loaded') + self.config = config + self.posix_user = username + self._init_json_config() + self._user_groups = None + + def list_allowed(self): + # is our list empty ? + if not self._allowed_ssh_hosts: + self._load_user_allowed_hosts() + return self._allowed_ssh_hosts + + def _init_json_config(self): + # Load the configration from the already intitialised config + hosts_file = self.config.hosts_file + try: + data = json.load(open(hosts_file, 'r')) + except ValueError as exc: + logging.error('JSON: could not read json file %s , error : %s', hosts_file, exc.message) + + self._all_ssh_hosts = data['hosts'] + self._all_users = data.get('users') + self._all_usergroups = data.get('usergroups') + self._allowed_ssh_hosts = {} + self._load_user_allowed_hosts() + + def _load_user_allowed_hosts(self): + """Fetch the allowed hosts based usergroup/hostgroup membership""" + for user in self._all_users: + if user.get('username') == self.posix_user: + self._user_groups = user.get('usergroups') + for host in self._all_ssh_hosts: + for usergroup in host.get('usergroups'): + if usergroup in self._user_groups: + self._allowed_ssh_hosts[host.get('name')] = { + 'fqdn': host.get('name'), + 'hostgroups': host.get('hostgroups'), + 'ssh_port': host.get('port'), + } diff --git a/IdPFactory.py b/aker/idp_factory.py similarity index 58% rename from IdPFactory.py rename to aker/idp_factory.py index 6b1e969..a5077fd 100644 --- a/IdPFactory.py +++ b/aker/idp_factory.py @@ -1,45 +1,39 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright 2017 Ahmed Nazmy # # Meta -__license__ = "AGPLv3" +__license__ = 'AGPLv3' __author__ = 'Ahmed Nazmy ' -import logging import importlib -import sys -import os +import logging class IdPFactory(object): @staticmethod - def getIdP(choice): + def get_idp(choice): - logging.info( - "IdPFactory: trying dynamic loading of module : {0} ".format(choice)) + logging.info('IdPFactory: trying dynamic loading of module: %s', choice) # load module from subdir idp - idp = "idp." + choice + idp = 'aker.idp.' + choice try: idp_module = importlib.import_module(idp) - idp_class = getattr(idp_module, choice) - except Exception as e: - logging.error( - "IdPFactory: error loading module : {0}".format( - e.message)) + idp_class = getattr(idp_module, choice.title()) + except Exception as exc: + logging.error('IdPFactory: error loading module: %s', exc.message) + raise return idp_class class IdP(object): - ''' - Base class to implement shared functionality + """Base class to implement shared functionality This should enable different identity providers - ''' + """ def __init__(self, username, gateway_hostgroup): self._all_ssh_hosts = {} diff --git a/popup.py b/aker/popup.py similarity index 73% rename from popup.py rename to aker/popup.py index a0d727c..5f977da 100644 --- a/popup.py +++ b/aker/popup.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright 2017 Ahmed Nazmy @@ -11,15 +10,14 @@ class SimplePopupDialog(urwid.WidgetWrap): - """ - A dialog that appears with nothing but a close button - """ + """A dialog that appears with nothing but a close button""" + signals = ['popup_close'] def __init__(self, message): - close_button = urwid.Button(u"OK") + close_button = urwid.Button(u'OK') urwid.connect_signal(close_button, 'click', - lambda button: self._emit("popup_close")) + lambda button: self._emit('popup_close')) pile = urwid.Pile([urwid.Text(message, align='center'), urwid.Padding( close_button, align='center', left=13, right=13)]) fill = urwid.Filler(pile) @@ -28,7 +26,7 @@ def __init__(self, message): class SimplePopupLauncher(urwid.PopUpLauncher): def __init__(self): - self.__super.__init__(urwid.Text(u"", align='right')) + self.__super.__init__(urwid.Text(u'', align='right')) self._message = None @property @@ -41,8 +39,7 @@ def message(self, value): def create_pop_up(self): pop_up = SimplePopupDialog(self._message) - urwid.connect_signal(pop_up, 'popup_close', - lambda button: self.close_pop_up()) + urwid.connect_signal(pop_up, 'popup_close', lambda button: self.close_pop_up()) return pop_up def get_pop_up_parameters(self): diff --git a/session.py b/aker/session.py similarity index 56% rename from session.py rename to aker/session.py index 94ce37a..a5ca881 100644 --- a/session.py +++ b/aker/session.py @@ -8,27 +8,36 @@ __author__ = 'Ahmed Nazmy ' -import logging -import signal -import os -import time import getpass -from SSHClient import SSHClient +import logging + +from .ssh_client import SSHClient class Session(object): - """ - Base Session class - """ + """Base Session class""" def __init__(self, aker_core, host, uuid): self.aker = aker_core self.host = host - self.host_user = self.aker.user.name self.host_port = int(self.aker.port) + self.host_user = self.aker.user.name self.src_port = self.aker.config.src_port self.uuid = uuid - logging.debug("Session: Base Session created") + self._client = self.create_client() + logging.debug('Session: Base Session created') + + def create_client(self): + """Abstract method that creates client instance""" + raise NotImplementedError() + + def get_credentials(self): + """Abstract method that must return user and secret to start session""" + raise NotImplementedError() + + def start_session(self): + user, secret = self.get_credentials() + self._client.start_session(user, secret) def attach_sniffer(self, sniffer): self._client.attach_sniffer(sniffer) @@ -39,35 +48,30 @@ def stop_sniffer(self): def connect(self, size): self._client.connect(self.host, self.host_port, size) - def start_session(self): - raise NotImplementedError - def close_session(self): self.aker.session_end_callback(self) def kill_session(self, signum, stack): - logging.debug("Session: Session ended") + logging.debug('Session: Session ended') self.close_session() class SSHSession(Session): - """ Wrapper around SSHClient instantiating - a new SSHClient instance every time - """ + """Wrapper around SSHClient instantiating a new SSHClient instance every time""" - def __init__(self, aker_core, host, uuid): - super(SSHSession, self).__init__(aker_core, host, uuid) - self._client = SSHClient(self) - logging.debug("Session: SSHSession created") + def create_client(self): + client = SSHClient(self) + logging.debug('Session: SSHSession created') + return client - def start_session(self): + def get_credentials(self): try: auth_secret = self.aker.user.get_priv_key() # currently, if no SSH public key exists, an ``Exception`` # is raised. Catch it and try a password. except Exception as exc: if str(exc) == 'Core: Invalid Private Key': - auth_secret = getpass.getpass("Password: ") + auth_secret = getpass.getpass('Password: ') else: raise - self._client.start_session(self.host_user, auth_secret) + return self.host_user, auth_secret diff --git a/aker/snoop.py b/aker/snoop.py new file mode 100644 index 0000000..95215a7 --- /dev/null +++ b/aker/snoop.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- +""" +Copyright 2016 Ahmed Nazmy +""" + +# Meta +__license__ = 'AGPLv3' +__author__ = 'Ahmed Nazmy ' + + +import logging +import codecs +import re +import time +import json +import os +import errno + +from pyte import ByteStream, Screen + + +class Sniffer(object): + """Captures session IO to files""" + + def __init__(self, user, src_port, host, uuid, screen_size, session_log_dir): + self.user = user + self.host = host + self.uuid = uuid + self.src_port = src_port + self.log_file = None + self.log_timer = None + self.log_cmds = None + self.session_log_dir = session_log_dir + self.session_start_time = time.strftime('%H%M%S') + self.session_start_date = time.strftime('%Y%m%d') + self.session_date_time = time.strftime('%Y/%m/%d %H:%M:%S') + self.today = time.strftime('%Y%m%d') + self.session_log = '{0}_{1}_{2}_{3}'.format(self.user, self.host, self.session_start_time, self.uuid) + self.stream = None + self.screen = None + self.term_cols, self.term_rows = screen_size + self._fake_terminal() + logging.debug('Sniffer: Sniffer Created') + + def _fake_terminal(self): + logging.debug('Sniffer: Creating Pyte screen with cols %i and rows %i', self.term_cols, self.term_rows) + self.screen = Screen(self.term_cols, self.term_rows) + self.stream = ByteStream() + self.stream.attach(self.screen) + + def extract_command(self, buf): + """Handle terminal escape sequences""" + command = '' + # Remove CR (\x0D) in middle of data + # probably will need better handling + # See https://github.com/selectel/pyte/issues/66 + logging.debug('buf b4 is %s', str(buf)) + buf = buf.replace('\x0D', '') + logging.debug('buf after is %s', buf) + try: + self.stream.feed(buf) + output = ''.join( + [l for l in self.screen.display if len(l.strip()) > 0]).strip() + # for line in reversed(self.screen.buffer): + # output = ''.join(map(operator.attrgetter('data'), line)).strip() + logging.debug('output is %s', output) + command = self.ps1_parser(output) + except Exception as exc: + logging.error('Sniffer: extract command error %s', exc.message) + self.screen.reset() + return command + + @staticmethod + def ps1_parser(command): + """Extract commands from PS1 or mysql>""" + result = None + match = re.compile(r'\[?.*@.*\]?[\$#]\s').split(command) + logging.debug('Sniffer: command match is %s', match) + if match: + result = match[-1].strip() + else: + # No PS1, try finding mysql + match = re.split(r'mysql>\s', command) + logging.debug('Sniffer: command match is %s', match) + if match: + result = match[-1].strip() + return result + + @staticmethod + def got_cr_lf(string): + newline_chars = ['\n', '\r', '\r\n'] + for char in newline_chars: + if char in string: + return True + return False + + @staticmethod + def findlast(data, substrs): + last_pos = -1 + result = None + for substr in substrs: + pos = data.rfind(substr) + if pos > last_pos: + last_pos = pos + result = substr + return result + + def set_logs(self): + # local import + today_sessions_dir = os.path.join(self.session_log_dir, self.session_start_date) + log_file_path = os.path.join(today_sessions_dir, self.session_log) + try: + os.makedirs(today_sessions_dir, 0o777) + os.chmod(today_sessions_dir, 0o777) + except OSError as exc: + if exc.errno != errno.EEXIST: + logging.error('Sniffer: set_logs OS Error %s', exc.message) + try: + log_file = open(log_file_path + '.log', 'a') + log_timer = open(log_file_path + '.timer', 'a') + log_cmds = log_file_path + '.cmds' + except IOError as exc: + logging.debug('Sniffer: set_logs IO error %s', exc.message) + + log_file.write('Session Start {0}\r\n'.format(self.session_date_time)) + self.log_file = log_file + self.log_timer = log_timer + self.log_cmds = log_cmds + + def stop(self): + session_end = time.strftime('%Y/%m/%d %H:%M:%S') + # Sayonara + jsonmsg = { + 'host': self.host, + 'session': str(self.uuid), + 'sessionend': session_end, + 'sessionstart': self.session_date_time, + 'timing': session_end, + 'user': self.user, + 'ver': '1', + } + + try: + with open(self.log_cmds, 'a') as outfile: + jsonout = json.dumps(jsonmsg) + outfile.write(jsonout + '\n') + except Exception as exc: + logging.error('Sniffer: close session files error %s', exc.message) + + self.log_file.write('Session End {0}'.format(session_end)) + self.log_file.close() + self.log_timer.close() + + +class SSHSniffer(Sniffer): + + def __init__(self, user, src_port, host, uuid, screen_size, session_log_dir): + super(SSHSniffer, self).__init__(user, src_port, host, uuid, screen_size, session_log_dir) + self.vim_regex = re.compile(r'\x1b\[\?1049', re.X) + self.vim_data = '' + self.stdin_active = False + self.in_alt_mode = False + self.buf = '' + self.vim_data = '' + self.before_timestamp = time.time() + self.start_timestamp = self.before_timestamp + self.start_alt_mode = set(['\x1b[?47h', '\x1b[?1049h', '\x1b[?1047h']) + self.end_alt_mode = set(['\x1b[?47l', '\x1b[?1049l', '\x1b[?1047l']) + self.alt_mode_flags = tuple(self.start_alt_mode) + tuple(self.end_alt_mode) + + def channel_filter(self, data): + now_timestamp = time.time() + # Write delta time and number of chrs to timer log + self.log_timer.write('{0} {1}\n'.format(round(now_timestamp - self.before_timestamp, 4), len(data))) + self.log_timer.flush() + self.log_file.write(data) + self.log_file.flush() + self.before_timestamp = now_timestamp + self.vim_data += data + # Accumlate data when in stdin_active + if self.stdin_active: + self.buf += data + + def stdin_filter(self, data): + self.stdin_active = True + flag = self.findlast(self.vim_data, self.alt_mode_flags) + if flag is not None: + if flag in self.start_alt_mode: + logging.debug('In ALT mode') + self.in_alt_mode = True + elif flag in self.end_alt_mode: + logging.debug('Out of ALT mode') + self.in_alt_mode = False + # We got CR/LF? + if self.got_cr_lf(str(data)): + if not self.in_alt_mode: + logging.debug('Sniffer: self.buf is : %s', self.buf) + + # Did x capture the last character and CR ? + if len(str(data)) > 1: + self.buf = self.buf + data + logging.debug('Sniffer: x is : %s', data) + + self.buf = self.extract_command(self.buf) + + # If we got something back, log it + if self.buf is not None and self.buf != '': + now = time.strftime('%Y/%m/%d %H:%M:%S') + # TODO: add a separate object for json later + jsonmsg = { + 'cmd': codecs.decode(self.buf, 'UTF-8', 'replace'), + 'host': self.host, + 'session': str(self.uuid), + 'sessionstart': self.session_date_time, + 'timing': now, + 'user': self.user, + 'ver': '1', + } + try: + with open(self.log_cmds, 'a') as outfile: + # ELK's filebeat require a jsonlines like file + # (http://jsonlines.org/) + jsonout = json.dumps(jsonmsg) + outfile.write(jsonout + '\n') + except Exception as exc: + logging.error('Sniffer: stdin_filter error %s', exc.message) + jsonmsg = {} + + self.buf = '' + self.vim_data = '' + self.stdin_active = False + + def sigwinch(self, columns, lines): + logging.debug('Sniffer: Setting Pyte screen size to cols %i and rows %i', columns, lines) + self.screen.resize(columns, lines) diff --git a/SSHClient.py b/aker/ssh_client.py similarity index 82% rename from SSHClient.py rename to aker/ssh_client.py index f3acffb..640ab8d 100644 --- a/SSHClient.py +++ b/aker/ssh_client.py @@ -4,23 +4,22 @@ # # Meta -__license__ = "AGPLv3" +__license__ = 'AGPLv3' __author__ = 'Ahmed Nazmy ' +import errno +import fcntl +import getpass import logging -import paramiko +import os +import select +import signal import socket -import tty import sys import termios -import signal -import select -import os -import errno -import time -import fcntl -import getpass +import tty +import paramiko TIME_OUT = 10 @@ -48,7 +47,7 @@ def get_console_dimensions(): termios.TIOCGWINSZ, buffer) columns, lines = struct.unpack(fmt, result) - except Exception as e: + except Exception as exc: pass finally: return columns, lines @@ -59,14 +58,14 @@ def __init__(self, session): super(SSHClient, self).__init__(session) self._socket = None self.channel = None - logging.debug("Client: Client Created") + logging.debug('Client: Client Created') def connect(self, ip, port, size): self._size = size self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(TIME_OUT) self._socket.connect((ip, port)) - logging.debug("SSHClient: Connected to {0}:{1}".format(ip, port)) + logging.debug('SSHClient: Connected to %s:%s', ip, port) def get_transport(self): transport = paramiko.Transport(self._socket) @@ -78,32 +77,28 @@ def start_session(self, user, auth_secret): try: transport = self.get_transport() if isinstance(auth_secret, basestring): - logging.debug("SSHClient: Authenticating using password") + logging.debug('SSHClient: Authenticating using password') transport.auth_password(user, auth_secret) else: try: - logging.debug("SSHClient: Authenticating using key-pair") + logging.debug('SSHClient: Authenticating using key-pair') transport.auth_publickey(user, auth_secret) # Failed to authenticate with SSH key, so # try a password instead. except paramiko.ssh_exception.AuthenticationException: - logging.debug("SSHClient: Authenticating using password") + logging.debug('SSHClient: Authenticating using password') transport.auth_password(user, getpass.getpass()) self._start_session(transport) - except Exception as e: - logging.error( - "SSHClient:: error authenticating : {0} ".format( - e.message)) + except Exception as exc: + logging.error('SSHClient: error authenticating: %s', exc.message) self._session.close_session() if transport: transport.close() self._socket.close() - raise e + raise exc def attach(self, sniffer): - """ - Adds a sniffer to the session - """ + """Adds a sniffer to the session""" self.sniffers.append(sniffer) def _set_sniffer_logs(self): @@ -133,16 +128,14 @@ def _start_session(self, transport): def sigwinch(self, signal, data): columns, lines = get_console_dimensions() logging.debug( - "SSHClient: setting terminal to %s columns and %s lines" % + 'SSHClient: setting terminal to %s columns and %s lines' % (columns, lines)) self.channel.resize_pty(columns, lines) for sniffer in self.sniffers: sniffer.sigwinch(columns, lines) def interactive_shell(self, chan): - """ - Handles ssh IO - """ + """Handles ssh IO""" sys.stdout.flush() oldtty = termios.tcgetattr(sys.stdin) try: @@ -158,8 +151,8 @@ def interactive_shell(self, chan): sys.stdin.fileno(), fcntl.F_SETFL, flag | os.O_NONBLOCK) - except Exception as e: - logging.error(e) + except Exception as exc: + logging.error(exc) pass if chan in r: @@ -173,7 +166,7 @@ def interactive_shell(self, chan): try: nbytes = os.write(sys.stdout.fileno(), x) logging.debug( - "SSHClient: wrote %s bytes to stdout" % nbytes) + 'SSHClient: wrote %s bytes to stdout' % nbytes) sys.stdout.flush() except OSError as msg: if msg.errno == errno.EAGAIN: @@ -184,8 +177,8 @@ def interactive_shell(self, chan): if sys.stdin in r: try: buf = os.read(sys.stdin.fileno(), 4096) - except OSError as e: - logging.error(e) + except OSError as exc: + logging.error(exc) pass for sniffer in self.sniffers: sniffer.stdin_filter(buf) @@ -193,5 +186,5 @@ def interactive_shell(self, chan): chan.send(buf) finally: - logging.debug("SSHClient: interactive session ending") + logging.debug('SSHClient: interactive session ending') termios.tcsetattr(sys.stdin, termios.TCSADRAIN, oldtty) diff --git a/tui.py b/aker/tui.py similarity index 69% rename from tui.py rename to aker/tui.py index 6377e3f..e50e008 100644 --- a/tui.py +++ b/aker/tui.py @@ -4,30 +4,26 @@ # # Meta -__license__ = "AGPLv3" +__license__ = 'AGPLv3' __author__ = 'Ahmed Nazmy ' -import urwid -import aker -import signal import logging -import os -from popup import SimplePopupLauncher +import urwid + +from .popup import SimplePopupLauncher class Listing(urwid.ListBox): - """ - Base class to handle listbox actions - """ + """Base class to handle listbox actions""" def __init__(self, items=None): self.search = Search() - self.search.update_text("Type to search:\n") + self.search.update_text('Type to search:\n') self._items = [] if items is not None: for item in items: - listitem = MenuItem("%s" % (item)) + listitem = MenuItem('%s' % (item)) self._items.append( urwid.AttrMap( listitem, @@ -41,7 +37,7 @@ def updatelist(self, items): self.add_item(item) def add_item(self, item): - listitem = MenuItem("%s" % (item)) + listitem = MenuItem('%s' % (item)) self.body.append( urwid.AttrMap( listitem, @@ -60,13 +56,8 @@ def get_box(self): class HostList(Listing): - """ - Class to handle hosts screen actions, - keypresses for now. - """ - - def __init__(self, hosts=None): - super(HostList, self).__init__(hosts) + """Class to handle hosts screen actions, + keypresses for now.""" def keypress(self, size, key): if key == 'enter': @@ -79,20 +70,15 @@ def keypress(self, size, key): self.search.clear() key = None # Unless its arrow keys send keypress to search box, - # implies emitting EditBox "change" signal + # implies emitting EditBox 'change' signal elif key not in ['right', 'down', 'up', 'left', 'page up', 'page down']: self.search.keypress((10,), key) return super(HostList, self).keypress(size, key) class HostGroupList(Listing): - """ - Class to handle hostgroups screen actions, - keypresses for now. - """ - - def __init__(self, hostgroups=None): - super(HostGroupList, self).__init__(hostgroups) + """Class to handle hostgroups screen actions, + keypresses for now.""" def keypress(self, size, key): if key == 'enter': @@ -107,13 +93,14 @@ def keypress(self, size, key): self.search.clear() key = None # Unless its arrow keys send keypress to search box, - # implies emitting EditBox "change" signal + # implies emitting EditBox 'change' signal elif key not in ['right', 'down', 'up', 'left', 'page up', 'page down']: self.search.keypress((10,), key) return super(HostGroupList, self).keypress(size, key) class Header(urwid.Columns): + def __init__(self, text): self.text = text self.header_widget = urwid.Text(self.text, align='left') @@ -128,34 +115,36 @@ def update_text(self, text): self.header_map.original_widget.set_text(self.text) def popup_message(self, message): - logging.debug("TUI: popup message is {0}".format(message)) + logging.debug('TUI: popup message is %s', message) self.popup.message = str(message) self.popup.open_pop_up() class Footer(urwid.AttrMap): + def __init__(self, text): self.footer_text = urwid.Text(text, align='center') super(Footer, self).__init__(self.footer_text, 'foot') class Search(urwid.Edit): - def __init__(self): - super(Search, self).__init__() + # FIXME No longer supported http://urwid.org/reference/widget.html#urwid.Edit.update_text def update_text(self, caption): self.set_caption(caption) def clear(self): - self.set_edit_text("") + self.set_edit_text('') class MenuItem(urwid.Text): + def __init__(self, caption): self.caption = caption urwid.Text.__init__(self, self.caption) - def keypress(self, size, key): + @staticmethod + def keypress(_, key): return key def selectable(self): @@ -166,18 +155,27 @@ def get_caption(self): class Window(object): - """ - Where all the Tui magic happens, + """Where all the Tui magic happens, handles creating urwid widgets and - user interactions - """ + user interactions""" def __init__(self, aker_core): self.aker = aker_core + self.current_hostgroup = '' self.user = self.aker.user - self.current_hostgroup = "" self.set_palette() + # Define attributes + self.footer = None + self.footer_text = None + self.header = None + self.header_text = None + self.hostgrouplist = None + self.hostlist = None + self.loop = None + self.screen = None + self.topframe = None + def set_palette(self): self.palette = [ ('body', 'black', 'light gray'), # Normal Text @@ -193,25 +191,25 @@ def set_palette(self): def draw(self): self.header_text = [ - ('key', "Aker"), " ", - ('msg', "User:"), - ('key', "%s" % self.user.name), " "] + ('key', 'Aker'), ' ', + ('msg', 'User:'), + ('key', '%s' % self.user.name), ' '] self.footer_text = [ - ('msg', "Move:"), - ('key', "Up"), ",", - ('key', "Down"), ",", - ('key', "Left"), ",", - ('key', "PgUp"), ",", - ('key', "PgDn"), ",", - ('msg', "Select:"), - ('key', "Enter"), " ", - ('msg', "Refresh:"), - ('key', "F5"), " ", - ('msg', "Quit:"), - ('key', "F9"), " ", - ('msg', "By:"), - ('key', "Ahmed Nazmy")] + ('msg', 'Move:'), + ('key', 'Up'), ',', + ('key', 'Down'), ',', + ('key', 'Left'), ',', + ('key', 'PgUp'), ',', + ('key', 'PgDn'), ',', + ('msg', 'Select:'), + ('key', 'Enter'), ' ', + ('msg', 'Refresh:'), + ('key', 'F5'), ' ', + ('msg', 'Quit:'), + ('key', 'F9'), ' ', + ('msg', 'By:'), + ('key', 'Ahmed Nazmy')] # Define widgets self.header = Header(self.header_text) @@ -258,99 +256,84 @@ def _input_handler(self, key): if key == 'f5': self.update_lists() elif key == 'f9': - logging.info( - "TUI: User {0} logging out of Aker".format( - self.user.name)) + logging.info('TUI: User %s logging out of Aker', self.user.name) raise urwid.ExitMainLoop() elif key == 'left': # For now if its not hostgroup window left should bring it up if self.topframe.get_body() != self.hostgrouplist.get_box(): - self.current_hostgroup = "" + self.current_hostgroup = '' self.hostlist.empty() self.header.update_text(self.header_text) self.topframe.set_body(self.hostgrouplist.get_box()) else: - logging.debug( - "TUI: User {0} unhandled input : {1}".format( - self.user.name, key)) + logging.debug('TUI: User %s unhandled input : %s', self.user.name, key) - def group_search_handler(self, search, search_text): - logging.debug( - "TUI: Group search handler called with text {0}".format(search_text)) + def group_search_handler(self, _, search_text): + logging.debug('TUI: Group search handler called with text %s', search_text) matchinghostgroups = [] for hostgroup in self.user.hostgroups.keys(): if search_text in hostgroup: - logging.debug( - "TUI: hostgroup {1} matches search text {0}".format( - search_text, hostgroup)) + logging.debug('TUI: hostgroup %s matches search text %s', hostgroup, search_text) matchinghostgroups.append(hostgroup) self.hostgrouplist.updatelist(matchinghostgroups) - def host_search_handler(self, search, search_text): - logging.debug( - "TUI: Host search handler called with text {0}".format(search_text)) + def host_search_handler(self, _, search_text): + logging.debug('TUI: Host search handler called with text %s', search_text) matchinghosts = [] for host in self.user.hostgroups[self.current_hostgroup].hosts: if search_text in host: - logging.debug( - "TUI: host {1} matches search text {0}".format( - search_text, host)) + logging.debug('TUI: host %s matches search text %s', host, search_text) matchinghosts.append(host) self.hostlist.updatelist(matchinghosts) def group_chosen_handler(self, hostgroup): - logging.debug( - "TUI: user %s chose hostgroup %s " % - (self.user.name, hostgroup)) + logging.debug('TUI: user %s chose hostgroup %s', self.user.name, hostgroup) self.current_hostgroup = hostgroup self.hostlist.empty() matchinghosts = [] for host in self.user.hostgroups[self.current_hostgroup].hosts: - logging.debug( - "TUI: host {1} is in hostgroup {0}, adding".format( - hostgroup, host)) + logging.debug('TUI: host %s is in hostgroup %s, adding', host, hostgroup) matchinghosts.append(host) self.hostlist.updatelist(matchinghosts) header_text = [ - ('key', "Aker"), " ", - ('msg', "User:"), - ('key', "%s" % self.user.name), " ", - ('msg', "HostGroup:"), - ('key', "%s" % self.current_hostgroup)] + ('key', 'Aker'), ' ', + ('msg', 'User:'), + ('key', '%s' % self.user.name), ' ', + ('msg', 'HostGroup:'), + ('key', '%s' % self.current_hostgroup)] self.header.update_text(header_text) self.topframe.set_body(self.hostlist.get_box()) def host_chosen_handler(self, choice): host = choice - logging.debug("TUI: user %s chose server %s " % (self.user.name, host)) + logging.debug('TUI: user %s chose server %s ', self.user.name, host) self.aker.init_connection(host) def update_lists(self): - logging.info( - "TUI: Refreshing entries for user {0}".format( - self.aker.user.name)) + logging.info('TUI: Refreshing entries for user %s', self.aker.user.name) self.aker.user.refresh_allowed_hosts(False) self.hostgrouplist.empty() for hostgroup in self.user.hostgroups.keys(): self.hostgrouplist.add_item(hostgroup) - if self.current_hostgroup != "": + if self.current_hostgroup != '': self.hostlist.empty() for host in self.user.hostgroups[self.current_hostgroup].hosts: self.hostlist.add_item(host) - self.header.popup_message("Entries Refreshed") + self.header.popup_message('Entries Refreshed') def start(self): - logging.debug("TUI: tui started") + logging.debug('TUI: tui started') self.loop.run() - def stop(self): - logging.debug(u"TUI: tui stopped") + @staticmethod + def stop(): + logging.debug(u'TUI: tui stopped') raise urwid.ExitMainLoop() def pause(self): - logging.debug("TUI: tui paused") + logging.debug('TUI: tui paused') self.loop.screen.stop() def restore(self): - logging.debug("TUI restored") + logging.debug('TUI restored') self.loop.screen.start() diff --git a/aker/user.py b/aker/user.py new file mode 100644 index 0000000..17b820a --- /dev/null +++ b/aker/user.py @@ -0,0 +1,33 @@ + +import logging +import os + +import paramiko + +from .hosts import Hosts + + +class User(object): + + def __init__(self, config, username): + self.config = config + self.name = username + gateway_hostgroup = self.config.get('gateway_group') + idp = self.config.idp + logging.debug('Core: using Identity Provider %s', idp) + self.hosts = Hosts(self.config, self.name, gateway_hostgroup, idp) + self.allowed_ssh_hosts, self.hostgroups = self.hosts.list_allowed() + + def get_priv_key(self): + try: + # TODO: check better identity options + privkey = paramiko.RSAKey.from_private_key_file(os.path.expanduser('~/.ssh/id_rsa')) + except Exception as exc: + logging.error('Core: Invalid Private Key for user %s: %s ', self.name, exc.message) + raise Exception('Core: Invalid Private Key') + else: + return privkey + + def refresh_allowed_hosts(self, fromcache): + logging.info('Core: reloading hosts for user %s from backened identity provider', self.name) + self.allowed_ssh_hosts, self.hostgroups = self.hosts.list_allowed(from_cache=fromcache) diff --git a/akerctl.py b/akerctl.py deleted file mode 100755 index 8b7c640..0000000 --- a/akerctl.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright 2017 Ahmed Nazmy -# - -# Meta -__license__ = "AGPLv3" -__author__ = 'Ahmed Nazmy ' - -import sys -from contextlib import closing -from math import ceil -import time -import os -import codecs -import os -import fnmatch -import argparse -import json - - -parser = argparse.ArgumentParser(description="Aker session reply") -parser.add_argument( - "-u", - "--uuid", - action="store", - dest='uuid', - help="Recorded Session UUID", - required=True) -group = parser.add_mutually_exclusive_group(required=True) -group.add_argument( - "-r", - "--replay", - action='store_true', - dest="replay", - help="Replay Session") -group.add_argument( - "-c", - "--commands", - action='store_true', - dest="cmds", - help="Print Commands Entered By User During Session") - - -def main(argv): - from aker import session_log_dir - args = parser.parse_args() - session_uuid = args.uuid - log_file = "*" + session_uuid + "*" + ".log" - log_timer = "*" + session_uuid + "*" + ".timer" - cmds_file = "*" + session_uuid + "*" + ".cmds" - logfile_path = locate(log_file, session_log_dir) - timefile_path = locate(log_timer, session_log_dir) - if args.replay: - replay(logfile_path, timefile_path) - elif args.cmds: - cmds_filepath = locate(cmds_file, session_log_dir) - show_cmds(cmds_filepath) - - -def show_cmds(cmds_file): - data = [] - with open(cmds_file) as json_file: - for line in json_file: - data.append(json.loads(line)) - for k in data: - try: - print (k['timing'] + ':' + k['cmd']) - except Exception: - pass - - -def replay(log_file, time_file): - with open(log_file) as logf: - with open(time_file) as timef: - timing = get_timing(timef) - with closing(logf): - logf.readline() # ignore first line, (Session Start) - for t in timing: - data = logf.read(t[1]) - #print("data is %s , t is %s" % (data,t[1])) - text = codecs.decode(data, 'UTF-8', "replace") - time.sleep(t[0]) - sys.stdout.write(text) - sys.stdout.flush() - text = "" - - -def locate(pattern, root=os.curdir): - match = "" - for path, dirs, files in os.walk(os.path.abspath(root)): - for filename in fnmatch.filter(files, pattern): - matches = os.path.join(path, filename) - return matches - - -def get_timing(timef): - timing = None - with closing(timef): - timing = [l.strip().split(' ') for l in timef] - timing = [(float(r[0]), int(r[1])) for r in timing] - return timing - - -if __name__ == "__main__": - main(sys.argv) diff --git a/idp/Json.py b/idp/Json.py deleted file mode 100644 index abe76e0..0000000 --- a/idp/Json.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# Copyright 2017 Ahmed Nazmy -# - -# Meta - -from IdPFactory import IdP -import json -import logging - - -class Json(IdP): - """ - Fetch the authority informataion from a JSON configuration - """ - - def __init__(self, config, username, gateway_hostgroup): - super(Json, self).__init__(username, gateway_hostgroup) - logging.info("Json: loaded") - self.config = config - self.posix_user = username - self._init_json_config() - - def _init_json_config(self): - # Load the configration from the already intitialised config parser - hosts_file = self.config.get("General", "hosts_file", "hosts.json") - try: - JSON = json.load(open(hosts_file, 'r')) - except ValueError as e: - logging.error( - "JSON: could not read json file {0} , error : {1}".format( - hosts_file, e.message)) - - self._all_ssh_hosts = JSON["hosts"] - self._all_users = JSON.get("users") - self._all_usergroups = JSON.get("usergroups") - self._allowed_ssh_hosts = {} - self._load_user_allowed_hosts() - - def _load_user_allowed_hosts(self): - """ - Fetch the allowed hosts based usergroup/hostgroup membership - """ - for user in self._all_users: - if user.get("username") == self.posix_user: - self._user_groups = user.get("usergroups") - for host in self._all_ssh_hosts: - for usergroup in host.get("usergroups"): - if usergroup in self._user_groups: - self._allowed_ssh_hosts[ - host.get("name")] = { - 'fqdn': host.get("name"), - 'ssh_port': host.get("port"), - 'hostgroups': host.get("hostgroups")} - - def list_allowed(self): - # is our list empty ? - if not self._allowed_ssh_hosts: - self._load_user_allowed_hosts() - return self._allowed_ssh_hosts diff --git a/pyte/__init__.py b/pyte/__init__.py deleted file mode 100644 index eae245a..0000000 --- a/pyte/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte - ~~~~ - - `pyte` implements a mix of VT100, VT220 and VT520 specification, - and aims to support most of the `TERM=linux` functionality. - - Two classes: :class:`~pyte.streams.Stream`, which parses the - command stream and dispatches events for commands, and - :class:`~pyte.screens.Screen` which, when used with a stream - maintains a buffer of strings representing the screen of a - terminal. - - .. warning:: From ``xterm/main.c`` "If you think you know what all - of this code is doing, you are probably very mistaken. - There be serious and nasty dragons here" -- nothing - has changed. - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -from __future__ import absolute_import - -__all__ = ("Screen", "DiffScreen", "HistoryScreen", - "Stream", "ByteStream", "DebugStream") - -import io - -from .screens import Screen, DiffScreen, HistoryScreen -from .streams import Stream, ByteStream, DebugStream - - -if __debug__: - from .compat import str - - def dis(chars): - """A :func:`dis.dis` for terminals. - - >>> dis(b"\x07") # doctest: +NORMALIZE_WHITESPACE - BELL - >>> dis(b"\x1b[20m") # doctest: +NORMALIZE_WHITESPACE - SELECT_GRAPHIC_RENDITION 20 - """ - if isinstance(chars, str): - chars = chars.encode("utf-8") - - with io.StringIO() as buf: - DebugStream(to=buf).feed(chars) - print(buf.getvalue()) diff --git a/pyte/__main__.py b/pyte/__main__.py deleted file mode 100644 index c87805a..0000000 --- a/pyte/__main__.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte - ~~~~ - - Command-line tool for "disassembling" escape and CSI sequences:: - - $ echo -e "\e[Jfoo" | python -m pyte - ERASE_IN_DISPLAY 0 - DRAW f - DRAW o - DRAW o - LINEFEED - - $ python -m pyte foo - DRAW f - DRAW o - DRAW o - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -if __name__ == "__main__": - import sys - import pyte - - if len(sys.argv) == 1: - pyte.dis(sys.stdin.read()) - else: - pyte.dis("".join(sys.argv[1:])) diff --git a/pyte/charsets.py b/pyte/charsets.py deleted file mode 100644 index 4957fe2..0000000 --- a/pyte/charsets.py +++ /dev/null @@ -1,143 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.charsets - ~~~~~~~~~~~~~ - - This module defines ``G0`` and ``G1`` charset mappings the same way - they are defined for linux terminal, see - ``linux/drivers/tty/consolemap.c`` @ http://git.kernel.org - - .. note:: ``VT100_MAP`` and ``IBMPC_MAP`` were taken unchanged - from linux kernel source and therefore are licensed - under **GPL**. - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -from __future__ import absolute_import, unicode_literals - -from .compat import chr, map - - -#: Latin1. -LAT1_MAP = "".join(map(chr, range(256))) - -#: VT100 graphic character set. -VT100_MAP = "".join(chr(c) for c in [ - 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, - 0x0008, 0x0009, 0x000a, 0x000b, 0x000c, 0x000d, 0x000e, 0x000f, - 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, - 0x0018, 0x0019, 0x001a, 0x001b, 0x001c, 0x001d, 0x001e, 0x001f, - 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, - 0x0028, 0x0029, 0x002a, 0x2192, 0x2190, 0x2191, 0x2193, 0x002f, - 0x2588, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, - 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f, - 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, - 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f, - 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, - 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x00a0, - 0x25c6, 0x2592, 0x2409, 0x240c, 0x240d, 0x240a, 0x00b0, 0x00b1, - 0x2591, 0x240b, 0x2518, 0x2510, 0x250c, 0x2514, 0x253c, 0x23ba, - 0x23bb, 0x2500, 0x23bc, 0x23bd, 0x251c, 0x2524, 0x2534, 0x252c, - 0x2502, 0x2264, 0x2265, 0x03c0, 0x2260, 0x00a3, 0x00b7, 0x007f, - 0x0080, 0x0081, 0x0082, 0x0083, 0x0084, 0x0085, 0x0086, 0x0087, - 0x0088, 0x0089, 0x008a, 0x008b, 0x008c, 0x008d, 0x008e, 0x008f, - 0x0090, 0x0091, 0x0092, 0x0093, 0x0094, 0x0095, 0x0096, 0x0097, - 0x0098, 0x0099, 0x009a, 0x009b, 0x009c, 0x009d, 0x009e, 0x009f, - 0x00a0, 0x00a1, 0x00a2, 0x00a3, 0x00a4, 0x00a5, 0x00a6, 0x00a7, - 0x00a8, 0x00a9, 0x00aa, 0x00ab, 0x00ac, 0x00ad, 0x00ae, 0x00af, - 0x00b0, 0x00b1, 0x00b2, 0x00b3, 0x00b4, 0x00b5, 0x00b6, 0x00b7, - 0x00b8, 0x00b9, 0x00ba, 0x00bb, 0x00bc, 0x00bd, 0x00be, 0x00bf, - 0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c6, 0x00c7, - 0x00c8, 0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf, - 0x00d0, 0x00d1, 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d7, - 0x00d8, 0x00d9, 0x00da, 0x00db, 0x00dc, 0x00dd, 0x00de, 0x00df, - 0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4, 0x00e5, 0x00e6, 0x00e7, - 0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00ed, 0x00ee, 0x00ef, - 0x00f0, 0x00f1, 0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00f7, - 0x00f8, 0x00f9, 0x00fa, 0x00fb, 0x00fc, 0x00fd, 0x00fe, 0x00ff -]) - -#: IBM Codepage 437. -IBMPC_MAP = "".join(chr(c) for c in [ - 0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, - 0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c, - 0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8, - 0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc, - 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, - 0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f, - 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, - 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x003f, - 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, - 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f, - 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, - 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f, - 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, - 0x0068, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x006f, - 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, - 0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302, - 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7, - 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5, - 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9, - 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192, - 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba, - 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb, - 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, - 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510, - 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f, - 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567, - 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b, - 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580, - 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4, - 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229, - 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248, - 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0 -]) - - -#: VAX42 character set. -VAX42_MAP = "".join(chr(c) for c in [ - 0x0000, 0x263a, 0x263b, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, - 0x25d8, 0x25cb, 0x25d9, 0x2642, 0x2640, 0x266a, 0x266b, 0x263c, - 0x25b6, 0x25c0, 0x2195, 0x203c, 0x00b6, 0x00a7, 0x25ac, 0x21a8, - 0x2191, 0x2193, 0x2192, 0x2190, 0x221f, 0x2194, 0x25b2, 0x25bc, - 0x0020, 0x043b, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, - 0x0028, 0x0029, 0x002a, 0x002b, 0x002c, 0x002d, 0x002e, 0x002f, - 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, - 0x0038, 0x0039, 0x003a, 0x003b, 0x003c, 0x003d, 0x003e, 0x0435, - 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, - 0x0048, 0x0049, 0x004a, 0x004b, 0x004c, 0x004d, 0x004e, 0x004f, - 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, - 0x0058, 0x0059, 0x005a, 0x005b, 0x005c, 0x005d, 0x005e, 0x005f, - 0x0060, 0x0441, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, - 0x0435, 0x0069, 0x006a, 0x006b, 0x006c, 0x006d, 0x006e, 0x043a, - 0x0070, 0x0071, 0x0442, 0x0073, 0x043b, 0x0435, 0x0076, 0x0077, - 0x0078, 0x0079, 0x007a, 0x007b, 0x007c, 0x007d, 0x007e, 0x2302, - 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7, - 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5, - 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9, - 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192, - 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba, - 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb, - 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, - 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510, - 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f, - 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567, - 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b, - 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580, - 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4, - 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229, - 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248, - 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0 -]) - - -MAPS = { - b"B": LAT1_MAP, - b"0": VT100_MAP, - b"U": IBMPC_MAP, - b"V": VAX42_MAP -} diff --git a/pyte/compat.py b/pyte/compat.py deleted file mode 100644 index cc60c19..0000000 --- a/pyte/compat.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.compat - ~~~~~~~~~~~ - - Python version specific compatibility fixes. - - :copyright: (c) 2015-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -import sys - -if sys.version_info[0] == 2: - from future_builtins import map - - range = xrange - str = unicode - chr = unichr - - from functools import partial - iter_bytes = partial(map, ord) -else: - from builtins import map, range, str, chr - iter_bytes = iter diff --git a/pyte/control.py b/pyte/control.py deleted file mode 100644 index f2a7749..0000000 --- a/pyte/control.py +++ /dev/null @@ -1,70 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.control - ~~~~~~~~~~~~ - - This module defines simple control sequences, recognized by - :class:`~pyte.streams.Stream`, the set of codes here is for - ``TERM=linux`` which is a superset of VT102. - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -#: *Space*: Not suprisingly -- ``" "``. -SP = b" " - -#: *Null*: Does nothing. -NUL = b"\x00" - -#: *Bell*: Beeps. -BEL = b"\x07" - -#: *Backspace*: Backspace one column, but not past the begining of the -#: line. -BS = b"\x08" - -#: *Horizontal tab*: Move cursor to the next tab stop, or to the end -#: of the line if there is no earlier tab stop. -HT = b"\x09" - -#: *Linefeed*: Give a line feed, and, if :data:`pyte.modes.LNM` (new -#: line mode) is set also a carriage return. -LF = b"\n" -#: *Vertical tab*: Same as :data:`LF`. -VT = b"\x0b" -#: *Form feed*: Same as :data:`LF`. -FF = b"\x0c" - -#: *Carriage return*: Move cursor to left margin on current line. -CR = b"\r" - -#: *Shift out*: Activate G1 character set. -SO = b"\x0e" - -#: *Shift in*: Activate G0 character set. -SI = b"\x0f" - -#: *Cancel*: Interrupt escape sequence. If received during an escape or -#: control sequence, cancels the sequence and displays substitution -#: character. -CAN = b"\x18" -#: *Substitute*: Same as :data:`CAN`. -SUB = b"\x1a" - -#: *Escape*: Starts an escape sequence. -ESC = b"\x1b" - -#: *Delete*: Is ignored. -DEL = b"\x7f" - -#: *Control sequence introducer*: An equivalent for ``ESC [``. -CSI = b"\x9b" - -#: *String terminator*. -ST = b"\x9c" - -#: *Operating system command*. -OSC = b"\x9d" diff --git a/pyte/escape.py b/pyte/escape.py deleted file mode 100644 index 7c7908f..0000000 --- a/pyte/escape.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.escape - ~~~~~~~~~~~ - - This module defines both CSI and non-CSI escape sequences, recognized - by :class:`~pyte.streams.Stream` and subclasses. - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -#: *Reset*. -RIS = b"c" - -#: *Index*: Move cursor down one line in same column. If the cursor is -#: at the bottom margin, the screen performs a scroll-up. -IND = b"D" - -#: *Next line*: Same as :data:`pyte.control.LF`. -NEL = b"E" - -#: Tabulation set: Set a horizontal tab stop at cursor position. -HTS = b"H" - -#: *Reverse index*: Move cursor up one line in same column. If the -#: cursor is at the top margin, the screen performs a scroll-down. -RI = b"M" - -#: Save cursor: Save cursor position, character attribute (graphic -#: rendition), character set, and origin mode selection (see -#: :data:`DECRC`). -DECSC = b"7" - -#: *Restore cursor*: Restore previously saved cursor position, character -#: attribute (graphic rendition), character set, and origin mode -#: selection. If none were saved, move cursor to home position. -DECRC = b"8" - -# "Sharp" escape sequences. -# ------------------------- - -#: *Alignment display*: Fill screen with uppercase E's for testing -#: screen focus and alignment. -DECALN = b"8" - - -# ECMA-48 CSI sequences. -# --------------------- - -#: *Insert character*: Insert the indicated # of blank characters. -ICH = b"@" - -#: *Cursor up*: Move cursor up the indicated # of lines in same column. -#: Cursor stops at top margin. -CUU = b"A" - -#: *Cursor down*: Move cursor down the indicated # of lines in same -#: column. Cursor stops at bottom margin. -CUD = b"B" - -#: *Cursor forward*: Move cursor right the indicated # of columns. -#: Cursor stops at right margin. -CUF = b"C" - -#: *Cursor back*: Move cursor left the indicated # of columns. Cursor -#: stops at left margin. -CUB = b"D" - -#: *Cursor next line*: Move cursor down the indicated # of lines to -#: column 1. -CNL = b"E" - -#: *Cursor previous line*: Move cursor up the indicated # of lines to -#: column 1. -CPL = b"F" - -#: *Cursor horizontal align*: Move cursor to the indicated column in -#: current line. -CHA = b"G" - -#: *Cursor position*: Move cursor to the indicated line, column (origin -#: at ``1, 1``). -CUP = b"H" - -#: *Erase data* (default: from cursor to end of line). -ED = b"J" - -#: *Erase in line* (default: from cursor to end of line). -EL = b"K" - -#: *Insert line*: Insert the indicated # of blank lines, starting from -#: the current line. Lines displayed below cursor move down. Lines moved -#: past the bottom margin are lost. -IL = b"L" - -#: *Delete line*: Delete the indicated # of lines, starting from the -#: current line. As lines are deleted, lines displayed below cursor -#: move up. Lines added to bottom of screen have spaces with same -#: character attributes as last line move up. -DL = b"M" - -#: *Delete character*: Delete the indicated # of characters on the -#: current line. When character is deleted, all characters to the right -#: of cursor move left. -DCH = b"P" - -#: *Erase character*: Erase the indicated # of characters on the -#: current line. -ECH = b"X" - -#: *Horizontal position relative*: Same as :data:`CUF`. -HPR = b"a" - -#: *Device Attributes*. -DA = b"c" - -#: *Vertical position adjust*: Move cursor to the indicated line, -#: current column. -VPA = b"d" - -#: *Vertical position relative*: Same as :data:`CUD`. -VPR = b"e" - -#: *Horizontal / Vertical position*: Same as :data:`CUP`. -HVP = b"f" - -#: *Tabulation clear*: Clears a horizontal tab stop at cursor position. -TBC = b"g" - -#: *Set mode*. -SM = b"h" - -#: *Reset mode*. -RM = b"l" - -#: *Select graphics rendition*: The terminal can display the following -#: character attributes that change the character display without -#: changing the character (see :mod:`pyte.graphics`). -SGR = b"m" - -#: *Device status report*. -DSR = b"n" - -#: *Select top and bottom margins*: Selects margins, defining the -#: scrolling region; parameters are top and bottom line. If called -#: without any arguments, whole screen is used. -DECSTBM = b"r" - -#: *Horizontal position adjust*: Same as :data:`CHA`. -HPA = b"'" diff --git a/pyte/graphics.py b/pyte/graphics.py deleted file mode 100644 index fcaa03a..0000000 --- a/pyte/graphics.py +++ /dev/null @@ -1,149 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.graphics - ~~~~~~~~~~~~~ - - This module defines graphic-related constants, mostly taken from - :manpage:`console_codes(4)` and - http://pueblo.sourceforge.net/doc/manual/ansi_color_codes.html. - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -from __future__ import unicode_literals - -#: A mapping of ANSI text style codes to style names, "+" means the: -#: attribute is set, "-" -- reset; example: -#: -#: >>> text[1] -#: '+bold' -#: >>> text[9] -#: '+strikethrough' -TEXT = { - 1: "+bold", - 3: "+italics", - 4: "+underscore", - 7: "+reverse", - 9: "+strikethrough", - 22: "-bold", - 23: "-italics", - 24: "-underscore", - 27: "-reverse", - 29: "-strikethrough", -} - -#: A mapping of ANSI foreground color codes to color names. -#: -#: >>> FG_ANSI[30] -#: 'black' -#: >>> FG_ANSI[38] -#: 'default' -FG_ANSI = { - 30: "black", - 31: "red", - 32: "green", - 33: "brown", - 34: "blue", - 35: "magenta", - 36: "cyan", - 37: "white", - 39: "default" # white. -} - -#: An alias to :data:`~pyte.graphics.FG_ANSI` for compatibility. -FG = FG_ANSI - -#: A mapping of non-standard ``aixterm`` foreground color codes to -#: color names. These are high intensity colors and thus should be -#: complemented by ``+bold``. -FG_AIXTERM = { - 90: "black", - 91: "red", - 92: "green", - 93: "brown", - 94: "blue", - 95: "magenta", - 96: "cyan", - 97: "white" -} - -#: A mapping of ANSI background color codes to color names. -#: -#: >>> BG_ANSI[40] -#: 'black' -#: >>> BG_ANSI[48] -#: 'default' -BG_ANSI = { - 40: "black", - 41: "red", - 42: "green", - 43: "brown", - 44: "blue", - 45: "magenta", - 46: "cyan", - 47: "white", - 49: "default" # black. -} - -#: An alias to :data:`~pyte.graphics.BG_ANSI` for compatibility. -BG = BG_ANSI - -#: A mapping of non-standard ``aixterm`` background color codes to -#: color names. These are high intensity colors and thus should be -#: complemented by ``+bold``. -BG_AIXTERM = { - 100: "black", - 101: "red", - 102: "green", - 103: "brown", - 104: "blue", - 105: "magenta", - 106: "cyan", - 107: "white" -} - -#: SGR code for foreground in 256 or True color mode. -FG_256 = 38 - -#: SGR code for background in 256 or True color mode. -BG_256 = 48 - -#: A table of 256 foreground or background colors. -# The following code is part of the Pygments project (BSD licensed). -FG_BG_256 = [ - (0x00, 0x00, 0x00), # 0 - (0xcd, 0x00, 0x00), # 1 - (0x00, 0xcd, 0x00), # 2 - (0xcd, 0xcd, 0x00), # 3 - (0x00, 0x00, 0xee), # 4 - (0xcd, 0x00, 0xcd), # 5 - (0x00, 0xcd, 0xcd), # 6 - (0xe5, 0xe5, 0xe5), # 7 - (0x7f, 0x7f, 0x7f), # 8 - (0xff, 0x00, 0x00), # 9 - (0x00, 0xff, 0x00), # 10 - (0xff, 0xff, 0x00), # 11 - (0x5c, 0x5c, 0xff), # 12 - (0xff, 0x00, 0xff), # 13 - (0x00, 0xff, 0xff), # 14 - (0xff, 0xff, 0xff), # 15 -] - -# colors 16..232: the 6x6x6 color cube -valuerange = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff) - -for i in range(217): - r = valuerange[(i // 36) % 6] - g = valuerange[(i // 6) % 6] - b = valuerange[i % 6] - FG_BG_256.append((r, g, b)) - -# colors 233..253: grayscale -for i in range(1, 22): - v = 8 + i * 10 - FG_BG_256.append((v, v, v)) - -FG_BG_256 = ["{0:02x}{1:02x}{2:02x}".format(r, g, b) for r, g, b in FG_BG_256] diff --git a/pyte/modes.py b/pyte/modes.py deleted file mode 100644 index 5f7386c..0000000 --- a/pyte/modes.py +++ /dev/null @@ -1,60 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.modes - ~~~~~~~~~~ - - This module defines terminal mode switches, used by - :class:`~pyte.screens.Screen`. There're two types of terminal modes: - - * `non-private` which should be set with ``ESC [ N h``, where ``N`` - is an integer, representing mode being set; and - * `private` which should be set with ``ESC [ ? N h``. - - The latter are shifted 5 times to the right, to be easily - distinguishable from the former ones; for example `Origin Mode` - -- :data:`DECOM` is ``192`` not ``6``. - - >>> DECOM - 192 - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -#: *Line Feed/New Line Mode*: When enabled, causes a received -#: :data:`~pyte.control.LF`, :data:`pyte.control.FF`, or -#: :data:`~pyte.control.VT` to move the cursor to the first column of -#: the next line. -LNM = 20 - -#: *Insert/Replace Mode*: When enabled, new display characters move -#: old display characters to the right. Characters moved past the -#: right margin are lost. Otherwise, new display characters replace -#: old display characters at the cursor position. -IRM = 4 - - -# Private modes. -# .............. - -#: *Text Cursor Enable Mode*: determines if the text cursor is -#: visible. -DECTCEM = 25 << 5 - -#: *Screen Mode*: toggles screen-wide reverse-video mode. -DECSCNM = 5 << 5 - -#: *Origin Mode*: allows cursor addressing relative to a user-defined -#: origin. This mode resets when the terminal is powered up or reset. -#: It does not affect the erase in display (ED) function. -DECOM = 6 << 5 - -#: *Auto Wrap Mode*: selects where received graphic characters appear -#: when the cursor is at the right margin. -DECAWM = 7 << 5 - -#: *Column Mode*: selects the number of columns per line (80 or 132) -#: on the screen. -DECCOLM = 3 << 5 diff --git a/pyte/screens.py b/pyte/screens.py deleted file mode 100644 index 3c10998..0000000 --- a/pyte/screens.py +++ /dev/null @@ -1,1279 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.screens - ~~~~~~~~~~~~ - - This module provides classes for terminal screens, currently - it contains three screens with different features: - - * :class:`~pyte.screens.Screen` -- base screen implementation, - which handles all the core escape sequences, recognized by - :class:`~pyte.streams.Stream`. - * If you need a screen to keep track of the changed lines - (which you probably do need) -- use - :class:`~pyte.screens.DiffScreen`. - * If you also want a screen to collect history and allow - pagination -- :class:`pyte.screen.HistoryScreen` is here - for ya ;) - - .. note:: It would be nice to split those features into mixin - classes, rather than subclasses, but it's not obvious - how to do -- feel free to submit a pull request. - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -from __future__ import absolute_import, unicode_literals, division - -import codecs -import copy -import math -import unicodedata -from collections import deque, namedtuple -from itertools import islice, repeat - -from wcwidth import wcwidth - -from . import ( - charsets as cs, - control as ctrl, - graphics as g, - modes as mo -) -from .compat import iter_bytes, map, range -from .streams import Stream - - -def take(n, iterable): - """Returns first n items of the iterable as a list.""" - return list(islice(iterable, n)) - - -#: A container for screen's scroll margins. -Margins = namedtuple("Margins", "top bottom") - -#: A container for savepoint, created on :data:`~pyte.escape.DECSC`. -Savepoint = namedtuple("Savepoint", [ - "cursor", - "g0_charset", - "g1_charset", - "charset", - "use_utf8", - "origin", - "wrap" -]) - -#: A container for a single character, field names are *hopefully* -#: self-explanatory. -_Char = namedtuple("_Char", [ - "data", - "fg", - "bg", - "bold", - "italics", - "underscore", - "strikethrough", - "reverse", -]) - - -class Char(_Char): - """A wrapper around :class:`_Char`, providing some useful defaults - for most of the attributes. - """ - __slots__ = () - - def __new__(cls, data, fg="default", bg="default", bold=False, - italics=False, underscore=False, reverse=False, - strikethrough=False): - return super(Char, cls).__new__(cls, data, fg, bg, bold, italics, - underscore, strikethrough, reverse) - - -class Cursor(object): - """Screen cursor. - - :param int x: 0-based horizontal cursor position. - :param int y: 0-based vertical cursor position. - :param pyte.screens.Char attrs: cursor attributes (see - :meth:`~pyte.screens.Screen.select_graphic_rendition` - for details). - """ - __slots__ = ("x", "y", "attrs", "hidden") - - def __init__(self, x, y, attrs=Char(" ")): - self.x = x - self.y = y - self.attrs = attrs - self.hidden = False - - -class Screen(object): - """ - A screen is an in-memory matrix of characters that represents the - screen display of the terminal. It can be instantiated on it's own - and given explicit commands, or it can be attached to a stream and - will respond to events. - - .. attribute:: buffer - - A ``lines x columns`` :class:`~pyte.screens.Char` matrix. - - .. attribute:: cursor - - Reference to the :class:`~pyte.screens.Cursor` object, holding - cursor position and attributes. - - .. attribute:: margins - - Top and bottom screen margins, defining the scrolling region; - the actual values are top and bottom line. - - .. attribute:: charset - - Current charset number; can be either ``0`` or ``1`` for `G0` - and `G1` respectively, note that `G0` is activated by default. - - .. attribute:: use_utf8 - - Assume the input to :meth:`~pyte.screens.Screen.draw` is encoded - using UTF-8. Defaults to ``True``. - - .. note:: - - According to ``ECMA-48`` standard, **lines and columns are - 1-indexed**, so, for instance ``ESC [ 10;10 f`` really means - -- move cursor to position (9, 9) in the display matrix. - - .. versionchanged:: 0.4.7 - .. warning:: - - :data:`~pyte.modes.LNM` is reset by default, to match VT220 - specification. - - .. versionchanged:: 0.4.8 - .. warning:: - - If `DECAWM` mode is set than a cursor will be wrapped to the - **beginning** of the next line, which is the behaviour described - in ``man console_codes``. - - .. seealso:: - - `Standard ECMA-48, Section 6.1.1 \ - `_ - for a description of the presentational component, implemented - by ``Screen``. - """ - #: A plain empty character with default foreground and background - #: colors. - default_char = Char(data=" ", fg="default", bg="default") - - #: An infinite sequence of default characters, used for populating - #: new lines and columns. - default_line = repeat(default_char) - - def __init__(self, columns, lines): - self.savepoints = [] - self.columns = columns - self.lines = lines - self.buffer = [] - self.reset() - - def __repr__(self): - return ("{0}({1}, {2})".format(self.__class__.__name__, - self.columns, self.lines)) - - @property - def display(self): - """A :func:`list` of screen lines as unicode strings.""" - def render(line): - it = iter(line) - while True: - char = next(it).data - assert sum(map(wcwidth, char[1:])) == 0 - char_width = wcwidth(char[0]) - if char_width == 1: - yield char - elif char_width == 2: - yield char - next(it) # Skip stub. - - return ["".join(render(line)) for line in self.buffer] - - def reset(self): - """Reset the terminal to its initial state. - - * Scroll margins are reset to screen boundaries. - * Cursor is moved to home location -- ``(0, 0)`` and its - attributes are set to defaults (see :attr:`default_char`). - * Screen is cleared -- each character is reset to - :attr:`default_char`. - * Tabstops are reset to "every eight columns". - - .. note:: - - Neither VT220 nor VT102 manuals mention that terminal modes - and tabstops should be reset as well, thanks to - :manpage:`xterm` -- we now know that. - """ - self.buffer[:] = (take(self.columns, self.default_line) - for _ in range(self.lines)) - self.mode = set([mo.DECAWM, mo.DECTCEM]) - self.margins = Margins(0, self.lines - 1) - - self.title = "" - self.icon_name = "" - - self.charset = 0 - self.g0_charset = cs.LAT1_MAP - self.g1_charset = cs.VT100_MAP - self.use_utf8 = True - self.utf8_decoder = codecs.getincrementaldecoder("utf-8")("replace") - - # From ``man terminfo`` -- "... hardware tabs are initially - # set every `n` spaces when the terminal is powered up. Since - # we aim to support VT102 / VT220 and linux -- we use n = 8. - self.tabstops = set(range(7, self.columns, 8)) - - self.cursor = Cursor(0, 0) - self.cursor_position() - - def resize(self, lines=None, columns=None): - """Resize the screen to the given size. - - If the requested screen size has more lines than the existing - screen, lines will be added at the bottom. If the requested - size has less lines than the existing screen lines will be - clipped at the top of the screen. Similarly, if the existing - screen has less columns than the requested screen, columns will - be added at the right, and if it has more -- columns will be - clipped at the right. - - .. note:: According to `xterm`, we should also reset origin - mode and screen margins, see ``xterm/screen.c:1761``. - - :param int lines: number of lines in the new screen. - :param int columns: number of columns in the new screen. - """ - lines = lines or self.lines - columns = columns or self.columns - - # First resize the lines: - diff = self.lines - lines - - # a) if the current display size is less than the requested - # size, add lines to the bottom. - if diff < 0: - self.buffer.extend(take(self.columns, self.default_line) - for _ in range(diff, 0)) - # b) if the current display size is greater than requested - # size, take lines off the top. - elif diff > 0: - self.buffer[:diff] = () - - # Then resize the columns: - diff = self.columns - columns - - # a) if the current display size is less than the requested - # size, expand each line to the new size. - if diff < 0: - for y in range(lines): - self.buffer[y].extend(take(abs(diff), self.default_line)) - # b) if the current display size is greater than requested - # size, trim each line from the right to the new size. - elif diff > 0: - for line in self.buffer: - del line[columns:] - - self.lines, self.columns = lines, columns - self.set_margins() - self.reset_mode(mo.DECOM) - - def set_margins(self, top=None, bottom=None): - """Select top and bottom margins for the scrolling region. - - Margins determine which screen lines move during scrolling - (see :meth:`index` and :meth:`reverse_index`). Characters added - outside the scrolling region do not cause the screen to scroll. - - :param int top: the smallest line number that is scrolled. - :param int bottom: the biggest line number that is scrolled. - """ - if top is None or bottom is None: - self.margins = Margins(0, self.lines - 1) - else: - # Arguments are 1-based, while :attr:`margins` are zero - # based -- so we have to decrement them by one. We also - # make sure that both of them is bounded by [0, lines - 1]. - top = max(0, min(top - 1, self.lines - 1)) - bottom = max(0, min(bottom - 1, self.lines - 1)) - - # Even though VT102 and VT220 require DECSTBM to ignore - # regions of width less than 2, some programs (like aptitude - # for example) rely on it. Practicality beats purity. - if bottom - top >= 1: - self.margins = Margins(top, bottom) - - # The cursor moves to the home position when the top and - # bottom margins of the scrolling region (DECSTBM) changes. - self.cursor_position() - - def set_mode(self, *modes, **kwargs): - """Set (enable) a given list of modes. - - :param list modes: modes to set, where each mode is a constant - from :mod:`pyte.modes`. - """ - # Private mode codes are shifted, to be distingiushed from non - # private ones. - if kwargs.get("private"): - modes = [mode << 5 for mode in modes] - - self.mode.update(modes) - - # When DECOLM mode is set, the screen is erased and the cursor - # moves to the home position. - if mo.DECCOLM in modes: - self.resize(columns=132) - self.erase_in_display(2) - self.cursor_position() - - # According to `vttest`, DECOM should also home the cursor, see - # vttest/main.c:303. - if mo.DECOM in modes: - self.cursor_position() - - # Mark all displayed characters as reverse. - if mo.DECSCNM in modes: - self.buffer[:] = ([char._replace(reverse=True) for char in line] - for line in self.buffer) - self.select_graphic_rendition(7) # +reverse. - - # Make the cursor visible. - if mo.DECTCEM in modes: - self.cursor.hidden = False - - def reset_mode(self, *modes, **kwargs): - """Reset (disable) a given list of modes. - - :param list modes: modes to reset -- hopefully, each mode is a - constant from :mod:`pyte.modes`. - """ - # Private mode codes are shifted, to be distinguished from non - # private ones. - if kwargs.get("private"): - modes = [mode << 5 for mode in modes] - - self.mode.difference_update(modes) - - # Lines below follow the logic in :meth:`set_mode`. - if mo.DECCOLM in modes: - self.resize(columns=80) - self.erase_in_display(2) - self.cursor_position() - - if mo.DECOM in modes: - self.cursor_position() - - if mo.DECSCNM in modes: - self.buffer[:] = ([char._replace(reverse=False) for char in line] - for line in self.buffer) - self.select_graphic_rendition(27) # -reverse. - - # Hide the cursor. - if mo.DECTCEM in modes: - self.cursor.hidden = True - - def define_charset(self, code, mode): - """Define ``G0`` or ``G1`` charset. - - :param str code: character set code, should be a character - from ``b"B0UK"``, otherwise ignored. - :param str mode: if ``"("`` ``G0`` charset is defined, if - ``")"`` -- we operate on ``G1``. - - .. warning:: User-defined charsets are currently not supported. - """ - if code in cs.MAPS: - if mode == b"(": - self.g0_charset = cs.MAPS[code] - elif mode == b")": - self.g1_charset = cs.MAPS[code] - - def shift_in(self): - """Select ``G0`` character set.""" - self.charset = 0 - - def shift_out(self): - """Select ``G1`` character set.""" - self.charset = 1 - - def select_other_charset(self, code): - """Select other (non G0 or G1) charset. - - :param str code: character set code, should be a character from - ``b"@G8"``, otherwise ignored. - - .. note:: We currently follow ``"linux"`` and only use this - command to switch from ISO-8859-1 to UTF-8 and back. - - .. versionadded:: 0.6.0 - - .. seealso:: - - `Standard ECMA-35, Section 15.4 \ - `_ - for a description of VTXXX character set machinery. - """ - if code == b"@": - self.use_utf8 = False - self.utf8_decoder.reset() - elif code in b"G8": - self.use_utf8 = True - - def _decode(self, data): - """Decode bytes to text according to the selected charset. - - :param bytes data: bytes to decode. - """ - if self.charset: - return "".join(self.g1_charset[b] for b in iter_bytes(data)) - elif self.use_utf8: - return self.utf8_decoder.decode(data) - else: - return "".join(self.g0_charset[b] for b in iter_bytes(data)) - - def draw(self, data): - """Display decoded characters at the current cursor position and - advances the cursor if :data:`~pyte.modes.DECAWM` is set. - - :param bytes data: bytes to display. - - .. versionchanged:: 0.5.0 - - Character width is taken into account. Specifically, zero-width - and unprintable characters do not affect screen state. Full-width - characters are rendered into two consecutive character containers. - - .. versionchanged:: 0.6.0 - - The input is now supposed to be in :func:`bytes`, which may encode - multiple characters. - """ - for char in self._decode(data): - char_width = wcwidth(char) - - # If this was the last column in a line and auto wrap mode is - # enabled, move the cursor to the beginning of the next line, - # otherwise replace characters already displayed with newly - # entered. - if self.cursor.x == self.columns: - if mo.DECAWM in self.mode: - self.carriage_return() - self.linefeed() - elif char_width > 0: - self.cursor.x -= char_width - - # If Insert mode is set, new characters move old characters to - # the right, otherwise terminal is in Replace mode and new - # characters replace old characters at cursor position. - if mo.IRM in self.mode and char_width > 0: - self.insert_characters(char_width) - - line = self.buffer[self.cursor.y] - if char_width == 1: - line[self.cursor.x] = self.cursor.attrs._replace(data=char) - elif char_width == 2: - # A two-cell character has a stub slot after it. - line[self.cursor.x] = self.cursor.attrs._replace(data=char) - if self.cursor.x + 1 < self.columns: - line[self.cursor.x + - 1] = self.cursor.attrs._replace(data=" ") - elif char_width == 0 and unicodedata.combining(char): - # A zero-cell character is combined with the previous - # character either on this or preceeding line. - if self.cursor.x: - last = line[self.cursor.x - 1] - normalized = unicodedata.normalize("NFC", last.data + char) - line[self.cursor.x - 1] = last._replace(data=normalized) - elif self.cursor.y: - last = self.buffer[self.cursor.y - 1][self.columns - 1] - normalized = unicodedata.normalize("NFC", last.data + char) - self.buffer[self.cursor.y - 1][self.columns - 1] = \ - last._replace(data=normalized) - else: - pass # Unprintable character or doesn't advance the cursor. - - # .. note:: We can't use :meth:`cursor_forward()`, because that - # way, we'll never know when to linefeed. - if char_width > 0: - self.cursor.x = min(self.cursor.x + char_width, self.columns) - - def set_title(self, param): - """Set terminal title. - - .. note:: This is an XTerm extension supported by the Linux terminal. - """ - self.title = self._decode(param) - - def set_icon_name(self, param): - """Set icon name. - - .. note:: This is an XTerm extension supported by the Linux terminal. - """ - self.icon_name = self._decode(param) - - def carriage_return(self): - """Move the cursor to the beginning of the current line.""" - self.cursor.x = 0 - - def index(self): - """Move the cursor down one line in the same column. If the - cursor is at the last line, create a new line at the bottom. - """ - top, bottom = self.margins - - if self.cursor.y == bottom: - self.buffer.pop(top) - self.buffer.insert(bottom, take(self.columns, self.default_line)) - else: - self.cursor_down() - - def reverse_index(self): - """Move the cursor up one line in the same column. If the cursor - is at the first line, create a new line at the top. - """ - top, bottom = self.margins - - if self.cursor.y == top: - self.buffer.pop(bottom) - self.buffer.insert(top, take(self.columns, self.default_line)) - else: - self.cursor_up() - - def linefeed(self): - """Perform an index and, if :data:`~pyte.modes.LNM` is set, a - carriage return. - """ - self.index() - - if mo.LNM in self.mode: - self.carriage_return() - - def tab(self): - """Move to the next tab space, or the end of the screen if there - aren't anymore left. - """ - for stop in sorted(self.tabstops): - if self.cursor.x < stop: - column = stop - break - else: - column = self.columns - 1 - - self.cursor.x = column - - def backspace(self): - """Move cursor to the left one or keep it in it's position if - it's at the beginning of the line already. - """ - self.cursor_back() - - def save_cursor(self): - """Push the current cursor position onto the stack.""" - self.savepoints.append(Savepoint(copy.copy(self.cursor), - self.g0_charset, - self.g1_charset, - self.charset, - self.use_utf8, - mo.DECOM in self.mode, - mo.DECAWM in self.mode)) - - def restore_cursor(self): - """Set the current cursor position to whatever cursor is on top - of the stack. - """ - if self.savepoints: - savepoint = self.savepoints.pop() - - self.g0_charset = savepoint.g0_charset - self.g1_charset = savepoint.g1_charset - self.charset = savepoint.charset - self.use_utf8 = savepoint.use_utf8 - - if savepoint.origin: - self.set_mode(mo.DECOM) - if savepoint.wrap: - self.set_mode(mo.DECAWM) - - self.cursor = savepoint.cursor - self.ensure_hbounds() - self.ensure_vbounds(use_margins=True) - else: - # If nothing was saved, the cursor moves to home position; - # origin mode is reset. :todo: DECAWM? - self.reset_mode(mo.DECOM) - self.cursor_position() - - def insert_lines(self, count=None): - """Insert the indicated # of lines at line with cursor. Lines - displayed **at** and below the cursor move down. Lines moved - past the bottom margin are lost. - - :param count: number of lines to delete. - """ - count = count or 1 - top, bottom = self.margins - - # If cursor is outside scrolling margins it -- do nothin'. - if top <= self.cursor.y <= bottom: - # v +1, because range() is exclusive. - for line in range(self.cursor.y, - min(bottom + 1, self.cursor.y + count)): - self.buffer.pop(bottom) - self.buffer.insert(line, take(self.columns, self.default_line)) - - self.carriage_return() - - def delete_lines(self, count=None): - """Delete the indicated # of lines, starting at line with - cursor. As lines are deleted, lines displayed below cursor - move up. Lines added to bottom of screen have spaces with same - character attributes as last line moved up. - - :param int count: number of lines to delete. - """ - count = count or 1 - top, bottom = self.margins - - # If cursor is outside scrolling margins it -- do nothin'. - if top <= self.cursor.y <= bottom: - # v -- +1 to include the bottom margin. - for _ in range(min(bottom - self.cursor.y + 1, count)): - self.buffer.pop(self.cursor.y) - self.buffer.insert(bottom, list( - repeat(self.cursor.attrs, self.columns))) - - self.carriage_return() - - def insert_characters(self, count=None): - """Insert the indicated # of blank characters at the cursor - position. The cursor does not move and remains at the beginning - of the inserted blank characters. Data on the line is shifted - forward. - - :param int count: number of characters to insert. - """ - count = count or 1 - - for _ in range(min(self.columns - self.cursor.y, count)): - self.buffer[self.cursor.y].insert(self.cursor.x, self.cursor.attrs) - self.buffer[self.cursor.y].pop() - - def delete_characters(self, count=None): - """Delete the indicated # of characters, starting with the - character at cursor position. When a character is deleted, all - characters to the right of cursor move left. Character attributes - move with the characters. - - :param int count: number of characters to delete. - """ - count = count or 1 - - for _ in range(min(self.columns - self.cursor.x, count)): - self.buffer[self.cursor.y].pop(self.cursor.x) - self.buffer[self.cursor.y].append(self.cursor.attrs) - - def erase_characters(self, count=None): - """Erase the indicated # of characters, starting with the - character at cursor position. Character attributes are set - cursor attributes. The cursor remains in the same position. - - :param int count: number of characters to erase. - - .. warning:: - - Even though *ALL* of the VTXXX manuals state that character - attributes **should be reset to defaults**, ``libvte``, - ``xterm`` and ``ROTE`` completely ignore this. Same applies - too all ``erase_*()`` and ``delete_*()`` methods. - """ - count = count or 1 - - for column in range(self.cursor.x, - min(self.cursor.x + count, self.columns)): - self.buffer[self.cursor.y][column] = self.cursor.attrs - - def erase_in_line(self, how=0, private=False): - """Erase a line in a specific way. - - :param int how: defines the way the line should be erased in: - - * ``0`` -- Erases from cursor to end of line, including cursor - position. - * ``1`` -- Erases from beginning of line to cursor, - including cursor position. - * ``2`` -- Erases complete line. - :param bool private: when ``True`` character attributes are left - unchanged **not implemented**. - """ - if how == 0: - # a) erase from the cursor to the end of line, including - # the cursor, - interval = range(self.cursor.x, self.columns) - elif how == 1: - # b) erase from the beginning of the line to the cursor, - # including it, - interval = range(self.cursor.x + 1) - elif how == 2: - # c) erase the entire line. - interval = range(self.columns) - - for column in interval: - self.buffer[self.cursor.y][column] = self.cursor.attrs - - def erase_in_display(self, how=0, private=False): - """Erases display in a specific way. - - :param int how: defines the way the line should be erased in: - - * ``0`` -- Erases from cursor to end of screen, including - cursor position. - * ``1`` -- Erases from beginning of screen to cursor, - including cursor position. - * ``2`` -- Erases complete display. All lines are erased - and changed to single-width. Cursor does not move. - :param bool private: when ``True`` character attributes are left - unchanged **not implemented**. - """ - if how == 0: - # a) erase from cursor to the end of the display, including - # the cursor, - interval = range(self.cursor.y + 1, self.lines) - elif how == 1: - # b) erase from the beginning of the display to the cursor, - # including it, - interval = range(self.cursor.y) - elif how == 2: - # c) erase the whole display. - interval = range(self.lines) - - for line in interval: - self.buffer[line][:] = \ - (self.cursor.attrs for _ in range(self.columns)) - - # In case of 0 or 1 we have to erase the line with the cursor. - if how == 0 or how == 1: - self.erase_in_line(how) - - def set_tab_stop(self): - """Set a horizontal tab stop at cursor position.""" - self.tabstops.add(self.cursor.x) - - def clear_tab_stop(self, how=0): - """Clear a horizontal tab stop. - - :param int how: defines a way the tab stop should be cleared: - - * ``0`` or nothing -- Clears a horizontal tab stop at cursor - position. - * ``3`` -- Clears all horizontal tab stops. - """ - if how == 0: - # Clears a horizontal tab stop at cursor position, if it's - # present, or silently fails if otherwise. - self.tabstops.discard(self.cursor.x) - elif how == 3: - self.tabstops = set() # Clears all horizontal tab stops. - - def ensure_hbounds(self): - """Ensure the cursor is within horizontal screen bounds.""" - self.cursor.x = min(max(0, self.cursor.x), self.columns - 1) - - def ensure_vbounds(self, use_margins=None): - """Ensure the cursor is within vertical screen bounds. - - :param bool use_margins: when ``True`` or when - :data:`~pyte.modes.DECOM` is set, - cursor is bounded by top and and bottom - margins, instead of ``[0; lines - 1]``. - """ - if use_margins or mo.DECOM in self.mode: - top, bottom = self.margins - else: - top, bottom = 0, self.lines - 1 - - self.cursor.y = min(max(top, self.cursor.y), bottom) - - def cursor_up(self, count=None): - """Move cursor up the indicated # of lines in same column. - Cursor stops at top margin. - - :param int count: number of lines to skip. - """ - self.cursor.y = max(self.cursor.y - (count or 1), self.margins.top) - - def cursor_up1(self, count=None): - """Move cursor up the indicated # of lines to column 1. Cursor - stops at bottom margin. - - :param int count: number of lines to skip. - """ - self.cursor_up(count) - self.carriage_return() - - def cursor_down(self, count=None): - """Move cursor down the indicated # of lines in same column. - Cursor stops at bottom margin. - - :param int count: number of lines to skip. - """ - self.cursor.y = min(self.cursor.y + (count or 1), self.margins.bottom) - - def cursor_down1(self, count=None): - """Move cursor down the indicated # of lines to column 1. - Cursor stops at bottom margin. - - :param int count: number of lines to skip. - """ - self.cursor_down(count) - self.carriage_return() - - def cursor_back(self, count=None): - """Move cursor left the indicated # of columns. Cursor stops - at left margin. - - :param int count: number of columns to skip. - """ - self.cursor.x -= count or 1 - self.ensure_hbounds() - - def cursor_forward(self, count=None): - """Move cursor right the indicated # of columns. Cursor stops - at right margin. - - :param int count: number of columns to skip. - """ - self.cursor.x += count or 1 - self.ensure_hbounds() - - def cursor_position(self, line=None, column=None): - """Set the cursor to a specific `line` and `column`. - - Cursor is allowed to move out of the scrolling region only when - :data:`~pyte.modes.DECOM` is reset, otherwise -- the position - doesn't change. - - :param int line: line number to move the cursor to. - :param int column: column number to move the cursor to. - """ - column = (column or 1) - 1 - line = (line or 1) - 1 - - # If origin mode (DECOM) is set, line number are relative to - # the top scrolling margin. - if mo.DECOM in self.mode: - line += self.margins.top - - # Cursor is not allowed to move out of the scrolling region. - if not self.margins.top <= line <= self.margins.bottom: - return - - self.cursor.x = column - self.cursor.y = line - self.ensure_hbounds() - self.ensure_vbounds() - - def cursor_to_column(self, column=None): - """Move cursor to a specific column in the current line. - - :param int column: column number to move the cursor to. - """ - self.cursor.x = (column or 1) - 1 - self.ensure_hbounds() - - def cursor_to_line(self, line=None): - """Move cursor to a specific line in the current column. - - :param int line: line number to move the cursor to. - """ - self.cursor.y = (line or 1) - 1 - - # If origin mode (DECOM) is set, line number are relative to - # the top scrolling margin. - if mo.DECOM in self.mode: - self.cursor.y += self.margins.top - - # FIXME: should we also restrict the cursor to the scrolling - # region? - - self.ensure_vbounds() - - def bell(self, *args): - """Bell stub -- the actual implementation should probably be - provided by the end-user. - """ - - def alignment_display(self): - """Fills screen with uppercase E's for screen focus and alignment.""" - for line in self.buffer: - for column, char in enumerate(line): - line[column] = char._replace(data="E") - - def select_graphic_rendition(self, *attrs): - """Set display attributes. - - :param list attrs: a list of display attributes to set. - """ - replace = {} - - if not attrs: - attrs = [0] - else: - attrs = list(reversed(attrs)) - - while attrs: - attr = attrs.pop() - if attr in g.FG_ANSI: - replace["fg"] = g.FG_ANSI[attr] - elif attr in g.BG: - replace["bg"] = g.BG_ANSI[attr] - elif attr in g.TEXT: - attr = g.TEXT[attr] - replace[attr[1:]] = attr.startswith("+") - elif not attr: - replace = self.default_char._asdict() - elif attr in g.FG_AIXTERM: - replace.update(fg=g.FG_AIXTERM[attr], bold=True) - elif attr in g.BG_AIXTERM: - replace.update(bg=g.BG_AIXTERM[attr], bold=True) - elif attr in (g.FG_256, g.BG_256): - key = "fg" if attr == g.FG_256 else "bg" - n = attrs.pop() - try: - if n == 5: # 256. - m = attrs.pop() - replace[key] = g.FG_BG_256[m] - elif n == 2: # 24bit. - # This is somewhat non-standard but is nonetheless - # supported in quite a few terminals. See discussion - # here https://gist.github.com/XVilka/8346728. - replace[key] = "{0:02x}{1:02x}{2:02x}".format( - attrs.pop(), attrs.pop(), attrs.pop()) - except IndexError: - pass - - self.cursor.attrs = self.cursor.attrs._replace(**replace) - - def report_device_attributes(self, mode=0, **kwargs): - """Report terminal identity. - - .. versionadded:: 0.5.0 - """ - # We only implement "primary" DA which is the only DA request - # VT102 understood, see ``VT102ID`` in ``linux/drivers/tty/vt.c``. - if mode == 0: - self.write_process_input(ctrl.CSI + b"?6c") - - def report_device_status(self, mode): - """Report terminal status or cursor position. - - :param int mode: if 5 -- terminal status, 6 -- cursor position, - otherwise a noop. - - .. versionadded:: 0.5.0 - """ - if mode == 5: # Request for terminal status. - self.write_process_input(ctrl.CSI + b"0n") - elif mode == 6: # Request for cursor position. - x = self.cursor.x + 1 - y = self.cursor.y + 1 - - # "Origin mode (DECOM) selects line numbering." - if mo.DECOM in self.mode: - y -= self.margins.top - self.write_process_input( - ctrl.CSI + "{0};{1}R".format(y, x).encode()) - - def write_process_input(self, data): - """Write data to the process running inside the terminal. - - By default is a noop. - - :param bytes data: data to write to the process ``stdin``. - - .. versionadded:: 0.5.0 - """ - - def debug(self, *args, **kwargs): - """Endpoint for unrecognized escape sequences. - - By default is a noop. - """ - - -class DiffScreen(Screen): - """A screen subclass, which maintains a set of dirty lines in its - :attr:`dirty` attribute. The end user is responsible for emptying - a set, when a diff is applied. - - .. attribute:: dirty - - A set of line numbers, which should be re-drawn. - - >>> screen = DiffScreen(80, 24) - >>> screen.dirty.clear() - >>> screen.draw("!") - >>> list(screen.dirty) - [0] - """ - - def __init__(self, *args): - self.dirty = set() - super(DiffScreen, self).__init__(*args) - - def set_mode(self, *modes, **kwargs): - if mo.DECSCNM >> 5 in modes and kwargs.get("private"): - self.dirty.update(range(self.lines)) - super(DiffScreen, self).set_mode(*modes, **kwargs) - - def reset_mode(self, *modes, **kwargs): - if mo.DECSCNM >> 5 in modes and kwargs.get("private"): - self.dirty.update(range(self.lines)) - super(DiffScreen, self).reset_mode(*modes, **kwargs) - - def reset(self): - self.dirty.update(range(self.lines)) - super(DiffScreen, self).reset() - - def resize(self, *args, **kwargs): - self.dirty.update(range(self.lines)) - super(DiffScreen, self).resize(*args, **kwargs) - - def draw(self, *args): - # Call the superclass's method before marking the row as - # dirty, as when wrapping is enabled, draw() might change - # self.cursor.y. - super(DiffScreen, self).draw(*args) - self.dirty.add(self.cursor.y) - - def index(self): - if self.cursor.y == self.margins.bottom: - self.dirty.update(range(self.lines)) - - super(DiffScreen, self).index() - - def reverse_index(self): - if self.cursor.y == self.margins.top: - self.dirty.update(range(self.lines)) - - super(DiffScreen, self).reverse_index() - - def insert_lines(self, *args): - self.dirty.update(range(self.cursor.y, self.lines)) - super(DiffScreen, self).insert_lines(*args) - - def delete_lines(self, *args): - self.dirty.update(range(self.cursor.y, self.lines)) - super(DiffScreen, self).delete_lines(*args) - - def insert_characters(self, *args): - self.dirty.add(self.cursor.y) - super(DiffScreen, self).insert_characters(*args) - - def delete_characters(self, *args): - self.dirty.add(self.cursor.y) - super(DiffScreen, self).delete_characters(*args) - - def erase_characters(self, *args): - self.dirty.add(self.cursor.y) - super(DiffScreen, self).erase_characters(*args) - - def erase_in_line(self, *args): - self.dirty.add(self.cursor.y) - super(DiffScreen, self).erase_in_line(*args) - - def erase_in_display(self, how=0): - if how == 0: - self.dirty.update(range(self.cursor.y + 1, self.lines)) - elif how == 1: - self.dirty.update(range(self.cursor.y)) - elif how == 2: - self.dirty.update(range(self.lines)) - - super(DiffScreen, self).erase_in_display(how) - - def alignment_display(self): - self.dirty.update(range(self.lines)) - super(DiffScreen, self).alignment_display() - - -History = namedtuple("History", "top bottom ratio size position") - - -class HistoryScreen(DiffScreen): - """A :class:~`pyte.screens.DiffScreen` subclass, which keeps track - of screen history and allows pagination. This is not linux-specific, - but still useful; see page 462 of VT520 User's Manual. - - :param int history: total number of history lines to keep; is split - between top and bottom queues. - :param int ratio: defines how much lines to scroll on :meth:`next_page` - and :meth:`prev_page` calls. - - .. attribute:: history - - A pair of history queues for top and bottom margins accordingly; - here's the overall screen structure:: - - [ 1: .......] - [ 2: .......] <- top history - [ 3: .......] - ------------ - [ 4: .......] s - [ 5: .......] c - [ 6: .......] r - [ 7: .......] e - [ 8: .......] e - [ 9: .......] n - ------------ - [10: .......] - [11: .......] <- bottom history - [12: .......] - - .. note:: - - Don't forget to update :class:`~pyte.streams.Stream` class with - appropriate escape sequences -- you can use any, since pagination - protocol is not standardized, for example:: - - Stream.escape[b"N"] = "next_page" - Stream.escape[b"P"] = "prev_page" - """ - _wrapped = set(Stream.events) - _wrapped.update(["next_page", "prev_page"]) - - def __init__(self, columns, lines, history=100, ratio=.5): - self.history = History(deque(maxlen=history // 2), - deque(maxlen=history), - float(ratio), - history, - history) - - super(HistoryScreen, self).__init__(columns, lines) - - def _make_wrapper(self, event, handler): - def inner(*args, **kwargs): - self.before_event(event) - result = handler(*args, **kwargs) - self.after_event(event) - return result - return inner - - def __getattribute__(self, attr): - value = super(HistoryScreen, self).__getattribute__(attr) - if attr in HistoryScreen._wrapped: - return HistoryScreen._make_wrapper(self, attr, value) - else: - return value - - def before_event(self, event): - """Ensure a screen is at the bottom of the history buffer. - - :param str event: event name, for example ``"linefeed"``. - """ - if event not in ["prev_page", "next_page"]: - while self.history.position < self.history.size: - self.next_page() - - def after_event(self, event): - """Ensure all lines on a screen have proper width (:attr:`columns`). - - Extra characters are truncated, missing characters are filled - with whitespace. - - :param str event: event name, for example ``"linefeed"``. - """ - if event in ["prev_page", "next_page"]: - for idx, line in enumerate(self.buffer): - if len(line) > self.columns: - self.buffer[idx] = line[:self.columns] - elif len(line) < self.columns: - self.buffer[idx] = line + take(self.columns - len(line), - self.default_line) - - # If we're at the bottom of the history buffer and `DECTCEM` - # mode is set -- show the cursor. - self.cursor.hidden = not ( - abs(self.history.position - self.history.size) < self.lines and - mo.DECTCEM in self.mode - ) - - def reset(self): - """Overloaded to reset screen history state: history position - is reset to bottom of both queues; queues themselves are - emptied. - """ - super(HistoryScreen, self).reset() - - self.history.top.clear() - self.history.bottom.clear() - self.history = self.history._replace(position=self.history.size) - - def index(self): - """Overloaded to update top history with the removed lines.""" - top, bottom = self.margins - - if self.cursor.y == bottom: - self.history.top.append(self.buffer[top]) - - super(HistoryScreen, self).index() - - def reverse_index(self): - """Overloaded to update bottom history with the removed lines.""" - top, bottom = self.margins - - if self.cursor.y == top: - self.history.bottom.append(self.buffer[bottom]) - - super(HistoryScreen, self).reverse_index() - - def prev_page(self): - """Move the screen page up through the history buffer. Page - size is defined by ``history.ratio``, so for instance - ``ratio = .5`` means that half the screen is restored from - history on page switch. - """ - if self.history.position > self.lines and self.history.top: - mid = min(len(self.history.top), - int(math.ceil(self.lines * self.history.ratio))) - - self.history.bottom.extendleft(reversed(self.buffer[-mid:])) - self.history = self.history \ - ._replace(position=self.history.position - self.lines) - - self.buffer[:] = list(reversed([ - self.history.top.pop() for _ in range(mid) - ])) + self.buffer[:-mid] - - self.dirty = set(range(self.lines)) - - def next_page(self): - """Move the screen page down through the history buffer.""" - if self.history.position < self.history.size and self.history.bottom: - mid = min(len(self.history.bottom), - int(math.ceil(self.lines * self.history.ratio))) - - self.history.top.extend(self.buffer[:mid]) - self.history = self.history \ - ._replace(position=self.history.position + self.lines) - - self.buffer[:] = self.buffer[mid:] + [ - self.history.bottom.popleft() for _ in range(mid) - ] - - self.dirty = set(range(self.lines)) diff --git a/pyte/streams.py b/pyte/streams.py deleted file mode 100644 index bf13eae..0000000 --- a/pyte/streams.py +++ /dev/null @@ -1,410 +0,0 @@ -# -*- coding: utf-8 -*- -""" - pyte.streams - ~~~~~~~~~~~~ - - This module provides three stream implementations with different - features; for starters, here's a quick example of how streams are - typically used: - - >>> import pyte - >>> screen = pyte.Screen(80, 24) - >>> stream = pyte.Stream(screen) - >>> stream.feed(b"\x1B[5B") # Move the cursor down 5 rows. - >>> screen.cursor.y - 5 - - :copyright: (c) 2011-2012 by Selectel. - :copyright: (c) 2012-2016 by pyte authors and contributors, - see AUTHORS for details. - :license: LGPL, see LICENSE for more details. -""" - -from __future__ import absolute_import, unicode_literals - -import itertools -import os -import re -import sys -import warnings -from collections import defaultdict - -from . import control as ctrl, escape as esc -from .compat import str - - -class Stream(object): - """A stream is a state machine that parses a stream of bytes and - dispatches events based on what it sees. - - :param pyte.screens.Screen screen: a screen to dispatch events to. - :param bool strict: check if a given screen implements all required - events. - - .. note:: - - Stream only accepts :func:`bytes` as input. Decoding it into text - is the responsibility of the :class:`~pyte.screens.Screen`. - - .. versionchanged 0.6.0:: - - For performance reasons the binding between stream events and - screen methods was made static. As a result, the stream **will - not** dispatch events to methods added to screen **after** the - stream was created. - - .. seealso:: - - `man console_codes `_ - For details on console codes listed bellow in :attr:`basic`, - :attr:`escape`, :attr:`csi`, :attr:`sharp` and :attr:`percent`. - """ - - #: Control sequences, which don't require any arguments. - basic = { - ctrl.BEL: "bell", - ctrl.BS: "backspace", - ctrl.HT: "tab", - ctrl.LF: "linefeed", - ctrl.VT: "linefeed", - ctrl.FF: "linefeed", - ctrl.CR: "carriage_return", - ctrl.SO: "shift_out", - ctrl.SI: "shift_in", - } - - #: non-CSI escape sequences. - escape = { - esc.RIS: "reset", - esc.IND: "index", - esc.NEL: "linefeed", - esc.RI: "reverse_index", - esc.HTS: "set_tab_stop", - esc.DECSC: "save_cursor", - esc.DECRC: "restore_cursor", - } - - #: "sharp" escape sequences -- ``ESC # ``. - sharp = { - esc.DECALN: "alignment_display", - } - - #: CSI escape sequences -- ``CSI P1;P2;...;Pn ``. - csi = { - esc.ICH: "insert_characters", - esc.CUU: "cursor_up", - esc.CUD: "cursor_down", - esc.CUF: "cursor_forward", - esc.CUB: "cursor_back", - esc.CNL: "cursor_down1", - esc.CPL: "cursor_up1", - esc.CHA: "cursor_to_column", - esc.CUP: "cursor_position", - esc.ED: "erase_in_display", - esc.EL: "erase_in_line", - esc.IL: "insert_lines", - esc.DL: "delete_lines", - esc.DCH: "delete_characters", - esc.ECH: "erase_characters", - esc.HPR: "cursor_forward", - esc.DA: "report_device_attributes", - esc.VPA: "cursor_to_line", - esc.VPR: "cursor_down", - esc.HVP: "cursor_position", - esc.TBC: "clear_tab_stop", - esc.SM: "set_mode", - esc.RM: "reset_mode", - esc.SGR: "select_graphic_rendition", - esc.DSR: "report_device_status", - esc.DECSTBM: "set_margins", - esc.HPA: "cursor_to_column" - } - - #: A set of all events dispatched by the stream. - events = frozenset(itertools.chain( - basic.values(), escape.values(), sharp.values(), csi.values(), - ["define_charset", "select_other_charset"], - ["set_icon", "set_title"], # OSC. - ["draw", "debug"])) - - #: A regular expression pattern matching everything what can be - #: considered plain text. - _special = set([ctrl.ESC, ctrl.CSI, ctrl.NUL, ctrl.DEL, ctrl.OSC]) - _special.update(basic) - _text_pattern = re.compile( - b"[^" + b"".join(map(re.escape, _special)) + b"]+") - del _special - - def __init__(self, screen=None, strict=True): - self.listener = None - self.strict = False - - if screen is not None: - self.attach(screen) - - def attach(self, screen, only=()): - """Adds a given screen to the listener queue. - - :param pyte.screens.Screen screen: a screen to attach to. - :param list only: a list of events you want to dispatch to a - given screen (empty by default, which means - -- dispatch all events). - """ - if self.strict: - for event in self.events: - if not hasattr(screen, event): - error_message = "{0} is missing {1}".format(screen, event) - raise TypeError(error_message) - if self.listener is not None: - warnings.warn("As of version 0.6.0 the listener queue is " - "restricted to a single element. Existing " - "listener {0} will be replaced." - .format(self.listener), DeprecationWarning) - - self.listener = screen - self._parser = self._parser_fsm() - self._taking_plain_text = next(self._parser) - - def detach(self, screen): - """Remove a given screen from the listener queue and fails - silently if it's not attached. - - :param pyte.screens.Screen screen: a screen to detach. - """ - if screen is self.listener: - self.listener = None - - def feed(self, data): - """Consume a string and advances the state as necessary. - - :param bytes data: a blob of data to feed from. - """ - if isinstance(data, str): - warnings.warn("As of version 0.6.0 ``pyte.streams.Stream.feed``" - "requires input in bytes. This warnings will become " - "and error in 0.6.1.") - data = data.encode("utf-8") - elif not isinstance(data, bytes): - raise TypeError("{0} requires bytes input" - .format(self.__class__.__name__)) - - send = self._parser.send - draw = self.listener.draw - match_text = self._text_pattern.match - taking_plain_text = self._taking_plain_text - - # TODO: use memoryview? - length = len(data) - offset = 0 - while offset < length: - if taking_plain_text: - match = match_text(data, offset) - if match: - start, offset = match.span() - draw(data[start:offset]) - else: - taking_plain_text = False - else: - taking_plain_text = send(data[offset:offset + 1]) - offset += 1 - - self._taking_plain_text = taking_plain_text - - def _parser_fsm(self): - """An FSM implemented as a coroutine. - - This generator is not the most beautiful, but it is as performant - as possible. When a process generates a lot of output, then this - will be the bottleneck, because it processes just one character - at a time. - - We did many manual optimizations to this function in order to make - it as efficient as possible. Don't change anything without profiling - first. - """ - basic = self.basic - listener = self.listener - draw = listener.draw - debug = listener.debug - - ESC, CSI = ctrl.ESC, ctrl.CSI - OSC, ST = ctrl.OSC, ctrl.ST - SP_OR_GT = ctrl.SP + b">" - NUL_OR_DEL = ctrl.NUL + ctrl.DEL - CAN_OR_SUB = ctrl.CAN + ctrl.SUB - ALLOWED_IN_CSI = b"".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, - ctrl.VT, ctrl.FF, ctrl.CR]) - - def create_dispatcher(mapping): - return defaultdict(lambda: debug, dict( - (event, getattr(listener, attr)) - for event, attr in mapping.items())) - - basic_dispatch = create_dispatcher(basic) - sharp_dispatch = create_dispatcher(self.sharp) - escape_dispatch = create_dispatcher(self.escape) - csi_dispatch = create_dispatcher(self.csi) - - while True: - # ``True`` tells ``Screen.feed`` that it is allowed to send - # chunks of plain text directly to the listener, instead - # of this generator.) - char = yield True - - if char == ESC: - # Most non-VT52 commands start with a left-bracket after the - # escape and then a stream of parameters and a command; with - # a single notable exception -- :data:`escape.DECOM` sequence, - # which starts with a sharp. - # - # .. versionchanged:: 0.4.10 - # - # For compatibility with Linux terminal stream also - # recognizes ``ESC % C`` sequences for selecting control - # character set. However, in the current version these - # are noop. - char = yield - if char == b"[": - char = CSI # Go to CSI. - elif char == b"]": - char = OSC # Go to OSC. - else: - if char == b"#": - sharp_dispatch[(yield)]() - if char == b"%": - listener.select_other_charset((yield)) - elif char in b"()": - listener.define_charset((yield), mode=char) - else: - escape_dispatch[char]() - continue # Don't go to CSI. - - if char in basic: - basic_dispatch[char]() - elif char == CSI: - # All parameters are unsigned, positive decimal integers, with - # the most significant digit sent first. Any parameter greater - # than 9999 is set to 9999. If you do not specify a value, a 0 - # value is assumed. - # - # .. seealso:: - # - # `VT102 User Guide `_ - # For details on the formatting of escape arguments. - # - # `VT220 Programmer Ref. `_ - # For details on the characters valid for use as - # arguments. - params = [] - current = bytearray() - private = False - while True: - char = yield - if char == b"?": - private = True - elif char in ALLOWED_IN_CSI: - basic_dispatch[char]() - elif char in SP_OR_GT: - # We don't handle secondary DA atm. - pass - elif char in CAN_OR_SUB: - # If CAN or SUB is received during a sequence, the - # current sequence is aborted; terminal displays - # the substitute character, followed by characters - # in the sequence received after CAN or SUB. - draw(char) - break - elif char.isdigit(): - current.extend(char) - else: - params.append(min(int(bytes(current) or 0), 9999)) - - if char == b";": - current = bytearray() - else: - if private: - csi_dispatch[char](*params, private=True) - else: - csi_dispatch[char](*params) - break # CSI is finished. - elif char == OSC: - code = yield - param = bytearray() - while True: - char = yield - if char == ST or char == ctrl.BEL: - break - else: - param.extend(char) - - param = bytes(param[1:]) # Drop the ;. - if code in b"01": - listener.set_icon_name(param) - if code in b"02": - listener.set_title(param) - elif char not in NUL_OR_DEL: - draw(char) - - -class ByteStream(Stream): - def __init__(self, *args, **kwargs): - warnings.warn("As of version 0.6.0 ``pyte.streams.ByteStream`` is an " - "alias for ``pyte.streams.Stream``. The former will be " - "removed in pyte 0.6.1.", DeprecationWarning) - - if kwargs.pop("encodings", None): - warnings.warn( - "As of version 0.6.0 ``pyte.streams.ByteStream`` no longer " - "decodes input.", DeprecationWarning) - - super(ByteStream, self).__init__(*args, **kwargs) - - -class DebugStream(Stream): - r"""Stream, which dumps a subset of the dispatched events to a given - file-like object (:data:`sys.stdout` by default). - - >>> import io - >>> with io.StringIO() as buf: - ... stream = DebugStream(to=buf) - ... stream.feed(b"\x1b[1;24r\x1b[4l\x1b[24;1H\x1b[0;10m") - ... print(buf.getvalue()) - ... - ... # doctest: +NORMALIZE_WHITESPACE - SET_MARGINS 1; 24 - RESET_MODE 4 - CURSOR_POSITION 24; 1 - SELECT_GRAPHIC_RENDITION 0; 10 - - :param file to: a file-like object to write debug information to. - :param list only: a list of events you want to debug (empty by - default, which means -- debug all events). - """ - - def __init__(self, to=sys.stdout, only=(), *args, **kwargs): - def safe_str(chunk): - if isinstance(chunk, bytes): - chunk = chunk.decode("utf-8") - elif not isinstance(chunk, str): - chunk = str(chunk) - - return chunk - - def noop(*args, **kwargs): - pass - - class Bugger(object): - def __getattr__(self, event): - if only and event not in only: - return noop - - def inner(*args, **kwargs): - to.write(event.upper() + " ") - to.write("; ".join(map(safe_str, args))) - to.write(" ") - to.write(", ".join("{0}: {1}".format(k, safe_str(v)) - for k, v in kwargs.items())) - to.write(os.linesep) - return inner - - super(DebugStream, self).__init__(Bugger(), *args, **kwargs) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..400d0d2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +ipython +pylint diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..bc04b49 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1 @@ +-r requirements.txt diff --git a/requirements.txt b/requirements.txt index 8fac981..6f0b714 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ configparser -urwid paramiko +pyte==0.7.0 redis +urwid wcwidth diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..79df703 --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""Setup file for Aker""" + +import os + +from setuptools import find_packages, setup + + +def __path(filename): + return os.path.join(os.path.dirname(__file__), filename) + +with open(__path('requirements.txt')) as f: + REQUIRES = [l.strip() for l in f.readlines()] + +VERSION = '0.5.0' + +setup( + name='aker', + packages=find_packages(exclude=('tests',)), + install_requires=REQUIRES, + version=VERSION, + description='Aker SSH gateway', + author='Ahmed Nazmy', + author_email='ahmed@nazmy.io', + url='https://github.com/aker-gateway/Aker', + include_package_data=True, + entry_points={ + 'console_scripts': [ + 'aker = aker.cli.aker:run', + 'akerctl = aker.cli.akerctl:run', + ] + }, + classifiers=[ + 'Programming Language :: Python :: 2', # :( + 'Operating System :: OS Independent', + ], +) diff --git a/snoop.py b/snoop.py deleted file mode 100644 index 55ac386..0000000 --- a/snoop.py +++ /dev/null @@ -1,269 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright 2016 Ahmed Nazmy -# - -# Meta -__license__ = "AGPLv3" -__author__ = 'Ahmed Nazmy ' - - -import logging -import codecs -import re -import time -import json -import os -import pyte -import errno - - -class Sniffer(object): - """ - Captures session IO to files - """ - - def __init__(self, user, src_port, host, uuid, screen_size): - self.user = user - self.host = host - self.uuid = uuid - self.src_port = src_port - self.log_file = None - self.log_timer = None - self.log_cmds = None - self.session_start_time = time.strftime("%H%M%S") - self.session_start_date = time.strftime("%Y%m%d") - self.session_date_time = time.strftime("%Y/%m/%d %H:%M:%S") - self.today = time.strftime("%Y%m%d") - self.session_log = "{0}_{1}_{2}_{3}".format( - self.user, self.host, self.session_start_time, self.uuid) - self.stream = None - self.screen = None - self.term_cols, self.term_rows = screen_size - self._fake_terminal() - logging.debug("Sniffer: Sniffer Created") - - def _fake_terminal(self): - logging.debug( - "Sniffer: Creating Pyte screen with cols %i and rows %i" % - (self.term_cols, self.term_rows)) - self.screen = pyte.Screen(self.term_cols, self.term_rows) - self.stream = pyte.ByteStream() - self.stream.attach(self.screen) - - def extract_command(self, buf): - """ - Handle terminal escape sequences - """ - command = "" - # Remove CR (\x0D) in middle of data - # probably will need better handling - # See https://github.com/selectel/pyte/issues/66 - logging.debug("buf b4 is %s" % str(buf)) - buf = buf.replace('\x0D', '') - logging.debug("buf after is %s" % buf) - try: - self.stream.feed(buf) - output = "".join( - [l for l in self.screen.display if len(l.strip()) > 0]).strip() - # for line in reversed(self.screen.buffer): - #output = "".join(map(operator.attrgetter("data"), line)).strip() - logging.debug("output is %s" % output) - command = self.ps1_parser(output) - except Exception as e: - logging.error( - "Sniffer: extract command error {0} ".format( - e.message)) - pass - self.screen.reset() - return command - - def ps1_parser(self, command): - """ - Extract commands from PS1 or mysql> - """ - result = None - match = re.compile('\[?.*@.*\]?[\$#]\s').split(command) - logging.debug("Sniffer: command match is %s" % match) - if match: - result = match[-1].strip() - else: - # No PS1, try finding mysql - match = re.split('mysql>\s', command) - logging.debug("Sniffer: command match is %s" % match) - if match: - result = match[-1].strip() - return result - - @staticmethod - def got_cr_lf(string): - newline_chars = ['\n', '\r', '\r\n'] - for char in newline_chars: - if char in string: - return True - return False - - @staticmethod - def findlast(s, substrs): - i = -1 - result = None - for substr in substrs: - pos = s.rfind(substr) - if pos > i: - i = pos - result = substr - return result - - def set_logs(self): - # local import - from aker import session_log_dir - today_sessions_dir = os.path.join( - session_log_dir, self.session_start_date) - log_file_path = os.path.join(today_sessions_dir, self.session_log) - try: - os.makedirs(today_sessions_dir, 0o777) - os.chmod(today_sessions_dir, 0o777) - except OSError as e: - if e.errno != errno.EEXIST: - logging.error( - "Sniffer: set_logs OS Error {0} ".format( - e.message)) - try: - log_file = open(log_file_path + '.log', 'a') - log_timer = open(log_file_path + '.timer', 'a') - log_cmds = log_file_path + '.cmds' - except IOError: - logging.debug("Sniffer: set_logs IO error {0} ".format(e.message)) - - log_file.write('Session Start %s\r\n' % self.session_date_time) - self.log_file = log_file - self.log_timer = log_timer - self.log_cmds = log_cmds - - def stop(self): - session_end = time.strftime("%Y/%m/%d %H:%M:%S") - # Sayonara - jsonmsg = {'ver': '1', - 'host': self.host, - 'user': self.user, - 'session': str(self.uuid), - 'sessionstart': self.session_date_time, - 'sessionend': session_end, - 'timing': session_end, - } - - try: - with open(self.log_cmds, 'a') as outfile: - jsonout = json.dumps(jsonmsg) - outfile.write(jsonout + '\n') - except Exception as e: - logging.error( - "Sniffer: close session files error {0} ".format( - e.message)) - - self.log_file.write('Session End %s' % session_end) - self.log_file.close() - self.log_timer.close() - - -class SSHSniffer(Sniffer): - def __init__(self, user, src_port, host, uuid, screen_size): - super( - SSHSniffer, - self).__init__( - user, - src_port, - host, - uuid, - screen_size) - self.vim_regex = re.compile(r'\x1b\[\?1049', re.X) - self.vim_data = "" - self.stdin_active = False - self.in_alt_mode = False - self.buf = "" - self.vim_data = "" - self.before_timestamp = time.time() - self.start_timestamp = self.before_timestamp - self.start_alt_mode = set(['\x1b[?47h', '\x1b[?1049h', '\x1b[?1047h']) - self.end_alt_mode = set(['\x1b[?47l', '\x1b[?1049l', '\x1b[?1047l']) - self.alt_mode_flags = tuple( - self.start_alt_mode) + tuple(self.end_alt_mode) - - def channel_filter(self, x): - now_timestamp = time.time() - # Write delta time and number of chrs to timer log - self.log_timer.write( - '%s %s\n' % - (round( - now_timestamp - - self.before_timestamp, - 4), - len(x))) - self.log_timer.flush() - self.log_file.write(x) - self.log_file.flush() - self.before_timestamp = now_timestamp - self.vim_data += x - # Accumlate data when in stdin_active - if self.stdin_active: - self.buf += x - - def stdin_filter(self, x): - self.stdin_active = True - flag = self.findlast(self.vim_data, self.alt_mode_flags) - if flag is not None: - if flag in self.start_alt_mode: - logging.debug("In ALT mode") - self.in_alt_mode = True - elif flag in self.end_alt_mode: - logging.debug("Out of ALT mode") - self.in_alt_mode = False - # We got CR/LF? - if self.got_cr_lf(str(x)): - if not self.in_alt_mode: - logging.debug("Sniffer: self.buf is : %s" % self.buf) - - # Did x capture the last character and CR ? - if len(str(x)) > 1: - self.buf = self.buf + x - logging.debug("Sniffer: x is : %s" % x) - - self.buf = self.extract_command(self.buf) - - # If we got something back, log it - if self.buf is not None and self.buf != "": - now = time.strftime("%Y/%m/%d %H:%M:%S") - # TODO: add a separate object for json later - jsonmsg = { - 'ver': '1', - 'host': self.host, - 'user': self.user, - 'session': str( - self.uuid), - 'sessionstart': self.session_date_time, - 'timing': now, - 'cmd': codecs.decode( - self.buf, - 'UTF-8', - "replace")} - try: - with open(self.log_cmds, 'a') as outfile: - # ELK's filebeat require a jsonlines like file - # (http://jsonlines.org/) - jsonout = json.dumps(jsonmsg) - outfile.write(jsonout + '\n') - except Exception as e: - logging.error( - "Sniffer: stdin_filter error {0} ".format( - e.message)) - jsonmsg = {} - - self.buf = "" - self.vim_data = "" - self.stdin_active = False - - def sigwinch(self, columns, lines): - logging.debug( - "Sniffer: Setting Pyte screen size to cols %i and rows %i" % - (columns, lines)) - self.screen.resize(columns, lines) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tox.ini b/tox.ini index e0b76fc..6b4c215 100644 --- a/tox.ini +++ b/tox.ini @@ -6,8 +6,8 @@ skipsdist = True [testenv] passenv = CI TRAVIS TRAVIS_* deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt -commands = + -r{toxinidir}/requirements-test.txt +commands = /usr/bin/find . -type f -name "*.pyc" -delete nosetests \ [] @@ -24,4 +24,3 @@ commands = [flake8] show-source = True exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build -