From 20ccadf3e1a901ef7f3c681b4a5b98423bf5fc4b Mon Sep 17 00:00:00 2001 From: Marc Aymerich Date: Mon, 11 May 2015 14:05:39 +0000 Subject: [PATCH] Fixes on systemusers ACL permissions --- TODO.md | 7 ++ orchestra/contrib/orchestration/methods.py | 29 ++++---- orchestra/contrib/orchestration/settings.py | 8 +-- orchestra/contrib/plans/ratings.py | 25 ++++++- orchestra/contrib/systemusers/actions.py | 55 +++++++++------ orchestra/contrib/systemusers/admin.py | 4 +- orchestra/contrib/systemusers/backends.py | 68 ++++++++++++++++--- orchestra/contrib/systemusers/forms.py | 45 +++++++++--- orchestra/contrib/systemusers/settings.py | 7 ++ .../systemuser/grant_permission.html | 65 ------------------ .../contrib/webapps/backends/__init__.py | 2 +- orchestra/contrib/webapps/settings.py | 10 ++- .../admin/orchestra/generic_confirmation.html | 1 + orchestra/utils/sys.py | 7 +- requirements.txt | 1 - 15 files changed, 201 insertions(+), 133 deletions(-) delete mode 100644 orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/grant_permission.html diff --git a/TODO.md b/TODO.md index 59614868..e52c80b1 100644 --- a/TODO.md +++ b/TODO.md @@ -359,3 +359,10 @@ resorce monitoring more efficient, less mem an better queries for calc current d ciphers=['arcfour128', 'aes256'] http://paramiko-docs.readthedocs.org/en/latest/api/transport.html + + + +* Grant and revoke permissions + +setfacl: /home/marcay//logs: Operation not permitted + diff --git a/orchestra/contrib/orchestration/methods.py b/orchestra/contrib/orchestration/methods.py index 938db0e5..8ff5ed38 100644 --- a/orchestra/contrib/orchestration/methods.py +++ b/orchestra/contrib/orchestration/methods.py @@ -6,7 +6,6 @@ import socket import sys import select -import paramiko from celery.datastructures import ExceptionInfo from django.conf import settings as djsettings @@ -25,11 +24,12 @@ def Paramiko(backend, log, server, cmds, async=False): """ Executes cmds to remote server using Pramaiko """ + import paramiko script = '\n'.join(cmds) script = script.replace('\r', '') log.state = log.STARTED log.script = script - log.save(update_fields=('script', 'state')) + log.save(update_fields=('script', 'state', 'updated_at')) if not cmds: return channel = None @@ -48,7 +48,7 @@ def Paramiko(backend, log, server, cmds, async=False): logger.error('%s timed out on %s' % (backend, addr)) log.state = log.TIMEOUT log.stderr = str(e) - log.save(update_fields=['state', 'stderr']) + log.save(update_fields=('state', 'stderr', 'updated_at')) return paramiko_connections[addr] = ssh transport = ssh.get_transport() @@ -73,7 +73,7 @@ def Paramiko(backend, log, server, cmds, async=False): while part: log.stderr += part part = channel.recv_stderr(1024).decode('utf-8') - log.save(update_fields=['stdout', 'stderr']) + log.save(update_fields=('stdout', 'stderr', 'updated_at')) if channel.exit_status_ready(): if second: break @@ -95,7 +95,7 @@ def Paramiko(backend, log, server, cmds, async=False): finally: if log.state == log.STARTED: log.state = log.ABORTED - log.save(update_fields=['state']) + log.save(update_fields=('state', 'updated_at')) if channel is not None: channel.close() @@ -108,25 +108,25 @@ def OpenSSH(backend, log, server, cmds, async=False): script = script.replace('\r', '') log.state = log.STARTED log.script = script - log.save(update_fields=('script', 'state')) + log.save(update_fields=('script', 'state', 'updated_at')) if not cmds: return channel = None ssh = None try: ssh = sshrun(server.get_address(), script, executable=backend.script_executable, - persist=True, async=async) + persist=True, async=async, silent=True) logger.debug('%s running on %s' % (backend, server)) if async: second = False for state in ssh: log.stdout += state.stdout.decode('utf8') log.stderr += state.stderr.decode('utf8') - log.save() + log.save(update_fields=('stdout', 'stderr', 'updated_at')) log.exit_code = state.exit_code else: - log.stdout = ssh.stdout - log.stderr = ssh.stderr + log.stdout = ssh.stdout.decode('utf8') + log.stderr = ssh.stderr.decode('utf8') log.exit_code = ssh.exit_code log.state = log.SUCCESS if log.exit_code == 0 else log.FAILURE logger.debug('%s execution state on %s is %s' % (backend, server, log.state)) @@ -140,7 +140,7 @@ def OpenSSH(backend, log, server, cmds, async=False): finally: if log.state == log.STARTED: log.state = log.ABORTED - log.save(update_fields=['state']) + log.save(update_fields=('state', 'updated_at')) def SSH(*args, **kwargs): @@ -150,12 +150,11 @@ def SSH(*args, **kwargs): def Python(backend, log, server, cmds, async=False): - # TODO collect stdout? script = [ str(cmd.func.__name__) + str(cmd.args) for cmd in cmds ] script = json.dumps(script, indent=4).replace('"', '') log.state = log.STARTED - log.script = '\n'.join([log.script, script]) - log.save(update_fields=('script', 'state')) + log.script = '\n'.join((log.script, script)) + log.save(update_fields=('script', 'state', 'updated_at')) try: for cmd in cmds: with CaptureStdout() as stdout: @@ -163,7 +162,7 @@ def Python(backend, log, server, cmds, async=False): for line in stdout: log.stdout += line + '\n' if async: - log.save(update_fields=['stdout']) + log.save(update_fields=('stdout', 'updated_at')) except: log.exit_code = 1 log.state = log.FAILURE diff --git a/orchestra/contrib/orchestration/settings.py b/orchestra/contrib/orchestration/settings.py index 7539dc8b..b115ce48 100644 --- a/orchestra/contrib/orchestration/settings.py +++ b/orchestra/contrib/orchestration/settings.py @@ -43,9 +43,9 @@ ORCHESTRATION_BACKEND_CLEANUP_DAYS = Setting('ORCHESTRATION_BACKEND_CLEANUP_DAYS ORCHESTRATION_SSH_METHOD_BACKEND = Setting('ORCHESTRATION_SSH_METHOD_BACKEND', 'orchestra.contrib.orchestration.methods.OpenSSH', - help_text=_("Two methods provided:
" - "orchestra.contrib.orchestration.methods.OpenSSH with ControlPersist.
" - "orchestra.contrib.orchestration.methods.Paramiko with connection pool.
" - "Both perform similarly, but OpenSSH has the advantage that the connections are shared between workers,
" + help_text=_("Two methods are provided:
" + "1) orchestra.contrib.orchestration.methods.OpenSSH with ControlPersist.
" + "2) orchestra.contrib.orchestration.methods.Paramiko with connection pool.
" + "Both perform similarly, but OpenSSH has the advantage that the connections are shared between workers. " "Paramiko, in contrast, has a per worker connection pool.") ) diff --git a/orchestra/contrib/plans/ratings.py b/orchestra/contrib/plans/ratings.py index 70c9d80f..58bb8d4d 100644 --- a/orchestra/contrib/plans/ratings.py +++ b/orchestra/contrib/plans/ratings.py @@ -155,6 +155,29 @@ match_price.help_text = _("Only the rate with a) inmediate inferior metri def best_price(rates, metric): - pass + candidates = [] + selected = False + prev = None + rates = _prepend_missing(rates.distinct()) + for rate in rates: + if prev: + if prev.plan != rate.plan: + if not selected and prev.quantity <= metric: + candidates.append(prev) + selected = False + if not selected and rate.quantity > metric: + if prev.quantity <= metric: + candidates.append(prev) + selected = True + prev = rate + if not selected and prev.quantity <= metric: + candidates.append(prev) + candidates.sort(key=lambda r: r.price) + if candidates: + return [AttrDict(**{ + 'quantity': metric, + 'price': candidates[0].price, + })] + return None best_price.verbose_name = _("Best price") best_price.help_text = _("Produces the best possible price given all active rating lines.") diff --git a/orchestra/contrib/systemusers/actions.py b/orchestra/contrib/systemusers/actions.py index 5bcfa37c..6363bb86 100644 --- a/orchestra/contrib/systemusers/actions.py +++ b/orchestra/contrib/systemusers/actions.py @@ -7,54 +7,67 @@ from django.template.response import TemplateResponse from django.utils.translation import ungettext, ugettext_lazy as _ from orchestra.admin.decorators import action_with_confirmation -from orchestra.contrib.orchestration.middlewares import OperationsMiddleware +from orchestra.contrib.orchestration import Operation, helpers -from .forms import GrantPermissionForm +from .forms import PermissionForm -def grant_permission(modeladmin, request, queryset): +def get_verbose_choice(choices, value): + for choice, verbose in choices: + if choice == value: + return verbose + + +def set_permission(modeladmin, request, queryset): account_id = None for user in queryset: account_id = account_id or user.account_id if user.account_id != account_id: messages.error("Users from the same account should be selected.") user = queryset[0] + form = PermissionForm(user) if request.method == 'POST': - form = GrantPermissionForm(user, request.POST) + form = PermissionForm(user, request.POST) if form.is_valid(): cleaned_data = form.cleaned_data - to = os.path.join(cleaned_data['base_path'], cleaned_data['path_extension']) - ro = cleaned_data['read_only'] + operations = [] for user in queryset: - user.grant_to = to - user.grant_ro = ro - # DOn't collect, execute right away for path validation - OperationsMiddleware.collect('grant_permission', instance=user) + user.set_perm_action = cleaned_data['set_action'] + user.set_perm_base_home = cleaned_data['base_home'] + user.set_perm_home_extension = cleaned_data['home_extension'] + user.set_perm_perms = cleaned_data['permissions'] + operations.extend(Operation.create_for_action(user, 'set_permission')) + verbose_action = get_verbose_choice(form.fields['set_action'].choices, + user.set_perm_action) + verbose_permissions = get_verbose_choice(form.fields['permissions'].choices, + user.set_perm_perms) context = { - 'type': _("read-only") if ro else _("read-write"), - 'to': to, + 'action': verbose_action, + 'perms': verbose_permissions, + 'to': os.path.join(user.set_perm_base_home, user.set_perm_home_extension), } - msg = _("Granted %(type)s permissions on %(to)s") % context + msg = _("%(action)s %(perms)s permission to %(to)s") % context modeladmin.log_change(request, user, msg) - # TODO feedback message + logs = Operation.execute(operations) + helpers.message_user(request, logs) return opts = modeladmin.model._meta app_label = opts.app_label context = { - 'title': _("Grant permission"), - 'action_name': _("Grant permission"), - 'action_value': 'grant_permission', + 'title': _("Set permission"), + 'action_name': _("Set permission"), + 'action_value': 'set_permission', 'queryset': queryset, 'opts': opts, 'obj': user, 'app_label': app_label, 'action_checkbox_name': admin.helpers.ACTION_CHECKBOX_NAME, - 'form': GrantPermissionForm(user), + 'form': form, } - return TemplateResponse(request, 'admin/systemusers/systemuser/grant_permission.html', + return TemplateResponse(request, 'admin/systemusers/systemuser/set_permission.html', context, current_app=modeladmin.admin_site.name) -grant_permission.url_name = 'grant-permission' -grant_permission.verbose_name = _("Grant permission") +set_permission.url_name = 'set-permission' +set_permission.verbose_name = _("Set permission") def delete_selected(modeladmin, request, queryset): diff --git a/orchestra/contrib/systemusers/admin.py b/orchestra/contrib/systemusers/admin.py index 0c21ed8b..c0fa6cf4 100644 --- a/orchestra/contrib/systemusers/admin.py +++ b/orchestra/contrib/systemusers/admin.py @@ -6,7 +6,7 @@ from orchestra.admin.actions import disable from orchestra.contrib.accounts.admin import SelectAccountAdminMixin from orchestra.contrib.accounts.filters import IsActiveListFilter -from .actions import grant_permission, delete_selected +from .actions import set_permission, delete_selected from .filters import IsMainListFilter from .forms import SystemUserCreationForm, SystemUserChangeForm from .models import SystemUser @@ -41,7 +41,7 @@ class SystemUserAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, Extende add_form = SystemUserCreationForm form = SystemUserChangeForm ordering = ('-id',) - actions = (delete_selected, grant_permission, disable) + actions = (delete_selected, set_permission, disable) change_view_actions = actions def display_main(self, user): diff --git a/orchestra/contrib/systemusers/backends.py b/orchestra/contrib/systemusers/backends.py index a831a418..e0366c75 100644 --- a/orchestra/contrib/systemusers/backends.py +++ b/orchestra/contrib/systemusers/backends.py @@ -15,7 +15,7 @@ class UNIXUserBackend(ServiceController): """ verbose_name = _("UNIX user") model = 'systemusers.SystemUser' - actions = ('save', 'delete', 'grant_permission') + actions = ('save', 'delete', 'set_permission', 'validate_path') doc_settings = (settings, ('SYSTEMUSERS_DEFAULT_GROUP_MEMBERS', 'SYSTEMUSERS_MOVE_ON_DELETE_PATH') ) @@ -65,17 +65,69 @@ class UNIXUserBackend(ServiceController): else: self.append("rm -fr %(base_home)s" % context) - def grant_permission(self, user): - # TODO + def set_permission(self, user): context = self.get_context(user) context.update({ - 'to': user.grant_to, - 'ro': user.grant_ro, + 'perm_action': user.set_perm_action, + 'perm_home': user.set_perm_base_home, + 'perm_to': os.path.join(user.set_perm_base_home, user.set_perm_home_extension), + 'exclude': '', }) - if user.grant_ro: - self.append('echo "acl add read permissions for %(user)s to %(to)s"' % context) + + exclude_acl = [] + for exclude in settings.SYSTEMUSERS_EXLUDE_ACL_PATHS: + context['exclude'] = exclude + exclude_acl.append('-not -path "%(perm_home)s/%(exclude)s"' % context) + if exclude_acl: + context['exclude'] = ' \\\n -a '.join(exclude_acl) + + if user.set_perm_perms == 'read-write': + context['perm_perms'] = 'rwx' if user.set_perm_action == 'grant' else '---' + elif user.set_perm_perms == 'read-only': + context['perm_perms'] = 'r-x' if user.set_perm_action == 'grant' else '-wx' + elif user.set_perm_perms == 'write-only': + context['perm_perms'] = '-wx' if user.set_perm_action == 'grant' else 'r-x' + if user.set_perm_action == 'grant': + self.append(textwrap.dedent("""\ + # Home access + setfacl -m u:%(user)s:--x '%(perm_home)s' + # Grant perms to existing and future files + find '%(perm_to)s' %(exclude)s \\ + -exec setfacl -m u:%(user)s:%(perm_perms)s {} \\; + find '%(perm_to)s' -type d %(exclude)s \\ + -exec setfacl -m d:u:%(user)s:%(perm_perms)s {} \\; + # Account group as the owner of new files + chmod g+s '%(perm_to)s' + """) % context + ) + if not user.is_main: + self.append(textwrap.dedent("""\ + # Grant access to main user + find '%(perm_to)s' -type d %(exclude)s \\ + -exec setfacl -m d:u:%(mainuser)s:rwx {} \\; + """) % context + ) + elif user.set_perm_action == 'revoke': + self.append(textwrap.dedent("""\ + # Revoke permissions + find '%(perm_to)s' %(exclude)s \\ + -exec setfacl -m u:%(user)s:%(perm_perms)s {} \\; + """) % context + ) else: - self.append('echo "acl add read-write permissions for %(user)s to %(to)s"' % context) + raise NotImplementedError() + + def validate_path(self, user): + context = { + 'perm_to': os.path.join(user.set_perm_base_home, user.set_perm_home_extension) + } + self.append(textwrap.dedent("""\ + if [[ ! -e '%(perm_to)s' ]]; then + echo "%(perm_to)s path does not exists." >&2 + exit 1 + fi + """) % context + ) def get_groups(self, user): if user.is_main: diff --git a/orchestra/contrib/systemusers/forms.py b/orchestra/contrib/systemusers/forms.py index a6682a86..825cbd74 100644 --- a/orchestra/contrib/systemusers/forms.py +++ b/orchestra/contrib/systemusers/forms.py @@ -1,8 +1,10 @@ import textwrap from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import ngettext, ugettext_lazy as _ +from orchestra.contrib.orchestration import Operation from orchestra.forms import UserCreationForm, UserChangeForm from . import settings @@ -79,21 +81,44 @@ class SystemUserChangeForm(SystemUserFormMixin, UserChangeForm): pass -class GrantPermissionForm(forms.Form): - base_path = forms.ChoiceField(label=_("Grant access to"), choices=(), - help_text=_("User will be granted access to this directory.")) - path_extension = forms.CharField(label=_("Path extension"), required=False, initial='', +class PermissionForm(forms.Form): + set_action = forms.ChoiceField(label=_("Action"), initial='grant', + choices=( + ('grant', _("Grant")), + ('revoke', _("Revoke")) + )) + base_home = forms.ChoiceField(label=_("Set permissions to"), choices=(), + help_text=_("User will be granted/revoked access to this directory.")) + home_extension = forms.CharField(label=_("Home extension"), required=False, initial='', widget=forms.TextInput(attrs={'size':'70'}), help_text=_("Relative to chosen home.")) - read_only = forms.BooleanField(label=_("Read only"), initial=False, required=False, - help_text=_("Designates whether the permissions granted will be read-only or read/write.")) + permissions = forms.ChoiceField(label=_("Permissions"), initial='read-write', + choices=( + ('read-write', _("Read and write")), + ('read-only', _("Read only")), + ('write-only', _("Write only")) + )) def __init__(self, *args, **kwargs): - instance = args[0] + self.instance = args[0] super_args = [] if len(args) > 1: super_args.append(args[1]) - super(GrantPermissionForm, self).__init__(*super_args, **kwargs) - related_users = type(instance).objects.filter(account=instance.account_id) - self.fields['base_path'].choices = ( + super(PermissionForm, self).__init__(*super_args, **kwargs) + related_users = type(self.instance).objects.filter(account=self.instance.account_id) + self.fields['base_home'].choices = ( (user.get_base_home(), user.get_base_home()) for user in related_users ) + + def clean(self): + cleaned_data = super(PermissionForm, self).clean() + user = self.instance + user.set_perm_action = cleaned_data['set_action'] + user.set_perm_base_home = cleaned_data['base_home'] + user.set_perm_home_extension = cleaned_data['home_extension'] + user.set_perm_perms = cleaned_data['permissions'] + log = Operation.execute_action(user, 'validate_path')[0] + if 'path does not exists' in log.stderr: + raise ValidationError({ + 'home_extension': log.stderr, + }) + return cleaned_data diff --git a/orchestra/contrib/systemusers/settings.py b/orchestra/contrib/systemusers/settings.py index d2ac7c46..d435c18b 100644 --- a/orchestra/contrib/systemusers/settings.py +++ b/orchestra/contrib/systemusers/settings.py @@ -58,3 +58,10 @@ SYSTEMUSERS_MOVE_ON_DELETE_PATH = Setting('SYSTEMUSERS_MOVE_ON_DELETE_PATH', help_text="Available fromat names: %s" % ', '.join(_backend_names), validators=[Setting.string_format_validator(_backend_names)], ) + + +SYSTEMUSERS_EXLUDE_ACL_PATHS = Setting('SYSTEMUSERS_EXLUDE_ACL_PATHS', + (), + help_text=("Relative to user's home.
" + "e.g. ('logs', 'logs/apache*', 'webapps')"), +) diff --git a/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/grant_permission.html b/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/grant_permission.html deleted file mode 100644 index 3fa452c3..00000000 --- a/orchestra/contrib/systemusers/templates/admin/systemusers/systemuser/grant_permission.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n l10n %} -{% load url from future %} -{% load admin_urls static utils %} - -{% block extrastyle %} -{{ block.super }} - - -{% endblock %} - -{% block breadcrumbs %} - -{% endblock %} - -{% block content %} -
-
- Grant permissions to these system users: {% for user in queryset %}{{ user.username }}{% if not forloop.last %},{% endif %}{% endfor %}. -
    {{ display_objects | unordered_list }}
-
{% csrf_token %} -
-
-
- {{ form.path_extension.errors }} - - {{ form.base_path }}{% for x in ""|ljust:"50" %} {% endfor %} -

{{ form.base_path.help_text|safe }}

-
-
- {{ form.path_extension.errors }} - - {{ form.path_extension }} -

{{ form.path_extension.help_text|safe }}

-
-
-
- {{ form.read_only }} -

{{ form.read_only.help_text|safe }}

-
-
-
- {% for obj in queryset %} - - {% endfor %} - - - -
-
-{% endblock %} - diff --git a/orchestra/contrib/webapps/backends/__init__.py b/orchestra/contrib/webapps/backends/__init__.py index 36e4ccc7..1fedf217 100644 --- a/orchestra/contrib/webapps/backends/__init__.py +++ b/orchestra/contrib/webapps/backends/__init__.py @@ -31,7 +31,7 @@ class WebAppServiceMixin(object): if [[ $CREATED == 1 && ! $(ls -A %(app_path)s) ]]; then { # Wait for other backends to do their thing or cp under construction - sleep 1 + sleep 10 if [[ ! $(ls -A %(app_path)s) ]]; then cp -r %(under_construction_path)s %(app_path)s chown -R %(user)s:%(group)s %(app_path)s diff --git a/orchestra/contrib/webapps/settings.py b/orchestra/contrib/webapps/settings.py index 13628088..44cfff6a 100644 --- a/orchestra/contrib/webapps/settings.py +++ b/orchestra/contrib/webapps/settings.py @@ -179,7 +179,6 @@ WEBAPPS_UNDER_CONSTRUCTION_PATH = Setting('WEBAPPS_UNDER_CONSTRUCTION_PATH', '', # WEBAPPS_TYPES[webapp_type] = value - WEBAPPS_PHP_DISABLED_FUNCTIONS = Setting('WEBAPPS_PHP_DISABLED_FUNCTION', ( 'exec', 'passthru', @@ -200,7 +199,14 @@ WEBAPPS_PHP_DISABLED_FUNCTIONS = Setting('WEBAPPS_PHP_DISABLED_FUNCTION', ( 'openlog', 'escapeshellcmd', 'escapeshellarg', - 'dl' + 'dl', + 'fsockopen', + 'pfsockopen', + 'stream_socket_client', + # Used for spamming + 'getmxrr', + # Used in some php shells + 'str_rot13', )) diff --git a/orchestra/templates/admin/orchestra/generic_confirmation.html b/orchestra/templates/admin/orchestra/generic_confirmation.html index 5a99b0dd..dc815acd 100644 --- a/orchestra/templates/admin/orchestra/generic_confirmation.html +++ b/orchestra/templates/admin/orchestra/generic_confirmation.html @@ -34,6 +34,7 @@
{% csrf_token %} {% if form %}
+ {{ form.non_field_errors }} {% for field in form %}
diff --git a/orchestra/utils/sys.py b/orchestra/utils/sys.py index abaef876..eb589fbd 100644 --- a/orchestra/utils/sys.py +++ b/orchestra/utils/sys.py @@ -148,8 +148,9 @@ def sshrun(addr, command, *args, executable='bash', persist=False, **kwargs): 'ControlPersist=yes', 'ControlPath=~/.ssh/orchestra-%r-%h-%p', )) - cmd = 'ssh -o {options} -C root@{addr} {executable}'.format(options=' -o '.join(options), - addr=addr, executable=executable) + options = ' -o '.join(options) + cmd = 'ssh -o {options} -C root@{addr} {executable}'.format(options=options, addr=addr, + executable=executable) return run(cmd, *args, stdin=command.encode('utf8'), **kwargs) @@ -181,7 +182,7 @@ class OperationLocked(Exception): class LockFile(object): """ File-based lock mechanism used for preventing concurrency problems """ def __init__(self, lockfile, expire=5*60, unlocked=False): - """ /dev/shm/ can be a good place for storing locks ;) """ + # /dev/shm/ can be a good place for storing locks self.lockfile = lockfile self.expire = expire self.unlocked = unlocked diff --git a/requirements.txt b/requirements.txt index e2d64fb2..7ee081dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,6 @@ kombu==3.0.23 billiard==3.3.0.18 Markdown==2.4 djangorestframework==3.1.1 -paramiko==1.15.2 ecdsa==0.11 Pygments==1.6 django-filter==0.7