From 6c62d1ba4267cf6c0a2c496a50b4e2cee90b8d17 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 24 Mar 2017 16:34:15 +0100 Subject: [PATCH] contrib: add a shellinabox plugin --- passerelle/contrib/shellinabox/README | 11 ++ passerelle/contrib/shellinabox/__init__.py | 27 +++++ passerelle/contrib/shellinabox/urls.py | 25 +++++ passerelle/contrib/shellinabox/views.py | 160 +++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 passerelle/contrib/shellinabox/README create mode 100644 passerelle/contrib/shellinabox/__init__.py create mode 100644 passerelle/contrib/shellinabox/urls.py create mode 100644 passerelle/contrib/shellinabox/views.py diff --git a/passerelle/contrib/shellinabox/README b/passerelle/contrib/shellinabox/README new file mode 100644 index 0000000..ea4b671 --- /dev/null +++ b/passerelle/contrib/shellinabox/README @@ -0,0 +1,11 @@ +Shellinabox for Passerelle +========================== + +How to use +---------- + +1) Add to your settings.py + + # local_settings.py: + INSTALLED_APPS += ('passerelle.contrib.shellinabox',) + PASSERELLE_APP_SHELLINABOX_ENABLED = True diff --git a/passerelle/contrib/shellinabox/__init__.py b/passerelle/contrib/shellinabox/__init__.py new file mode 100644 index 0000000..e8e7a86 --- /dev/null +++ b/passerelle/contrib/shellinabox/__init__.py @@ -0,0 +1,27 @@ +# passerelle.contrib.seisin_by_email +# Copyright (C) 2015 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import django.apps + +class AppConfig(django.apps.AppConfig): + name = 'passerelle.contrib.shellinabox' + label = 'shellinabox' + + def get_after_urls(self): + from . import urls + return urls.urlpatterns + +default_app_config = 'passerelle.contrib.shellinabox.AppConfig' diff --git a/passerelle/contrib/shellinabox/urls.py b/passerelle/contrib/shellinabox/urls.py new file mode 100644 index 0000000..e78e2e7 --- /dev/null +++ b/passerelle/contrib/shellinabox/urls.py @@ -0,0 +1,25 @@ +# passerelle.contrib.seisin_by_email +# Copyright (C) 2015 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from django.conf.urls import patterns, url +from passerelle.urls_utils import required, app_enabled + +from .views import shellinabox + +urlpatterns = required( + app_enabled('shellinabox'), + patterns('', url(r'^shellinabox(/.*)$', shellinabox)) +) diff --git a/passerelle/contrib/shellinabox/views.py b/passerelle/contrib/shellinabox/views.py new file mode 100644 index 0000000..c333e21 --- /dev/null +++ b/passerelle/contrib/shellinabox/views.py @@ -0,0 +1,160 @@ +# passerelle.contrib.seisin_by_email +# Copyright (C) 2015 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import fcntl +import os +import time + +import requests + +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponse +from django.http import QueryDict +from django.core.exceptions import PermissionDenied + +LOCKFILE = '/tmp/shellinabox.lock' +PIDFILE = '/tmp/shellinabox.pid' +SHELLINABOXD = None + +for bindir in ['/usr/bin/', '/usr/sbin/', '/usr/local/bin/', '/usr/local/sbin']: + path = os.path.join(bindir, 'shellinaboxd') + if os.path.exists(path): + SHELLINABOXD = path + break + + +@csrf_exempt +def shellinabox(request, path): + if not SHELLINABOXD: + return HttpResponse('shellinabox is not available', + status_code=500) + + if not request.user.is_authenticated() or not request.user.is_superuser: + raise PermissionDenied + while not os.path.exists(PIDFILE): + with open(LOCKFILE, 'w') as lockfile: + try: + fcntl.lockf(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except IOError: + time.sleep(1) + continue + else: + # launch shell in a box + newpid = os.fork() + if newpid: + time.sleep(1) + pid, errcode = os.waitpid(newpid, os.WNOHANG) + if pid != 0: + return HttpResponse('failure to launch shellinaboxd: %r' % errcode) + with file(PIDFILE, 'w') as pidfile: + pidfile.write(str(newpid)) + else: + uid = os.getuid() + gid = os.getgid() + os.execv(SHELLINABOXD, [SHELLINABOXD, '-d', '-t', '-s', + '/:%s:%s:HOME:/bin/bash' % (uid, gid)]) + finally: + fcntl.lockf(lockfile.fileno(), fcntl.LOCK_UN) + return proxy_view(request, 'http://localhost:4200%s' % path) + + +def proxy_view(request, url, requests_args=None): + """ + Forward as close to an exact copy of the request as possible along to the + given url. Respond with as close to an exact copy of the resulting + response as possible. + If there are any additional arguments you wish to send to requests, put + them in the requests_args dictionary. + """ + if not SHELLINABOXD: + return HttpResponse('shellinabox is not available', + status_code=500) + + requests_args = (requests_args or {}).copy() + headers = get_headers(request.META) + params = request.GET.copy() + + if 'headers' not in requests_args: + requests_args['headers'] = {} + if 'data' not in requests_args: + requests_args['data'] = request.body + if 'params' not in requests_args: + requests_args['params'] = QueryDict('', mutable=True) + + # Overwrite any headers and params from the incoming request with explicitly + # specified values for the requests library. + headers.update(requests_args['headers']) + params.update(requests_args['params']) + + # If there's a content-length header from Django, it's probably in all-caps + # and requests might not notice it, so just remove it. + for key in headers.keys(): + if key.lower() == 'content-length': + del headers[key] + + requests_args['headers'] = headers + requests_args['params'] = params + if not requests_args['params']: + del requests_args['params'] + + response = requests.request(request.method, url, **requests_args) + + proxy_response = HttpResponse( + response.content, + status=response.status_code) + + excluded_headers = set([ + # Hop-by-hop headers + # ------------------ + # Certain response headers should NOT be just tunneled through. These + # are they. For more info, see: + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.5.1 + 'connection', 'keep-alive', 'proxy-authenticate', + 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', + 'upgrade', + + # Although content-encoding is not listed among the hop-by-hop headers, + # it can cause trouble as well. Just let the server set the value as + # it should be. + 'content-encoding', + + # Since the remote server may or may not have sent the content in the + # same encoding as Django will, let Django worry about what the length + # should be. + 'content-length', + ]) + for key, value in response.headers.iteritems(): + if key.lower() in excluded_headers: + continue + proxy_response[key] = value + + return proxy_response + + +def get_headers(environ): + """ + Retrieve the HTTP headers from a WSGI environment dictionary. See + https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.HttpRequest.META + """ + headers = {} + for key, value in environ.iteritems(): + # Sometimes, things don't like when you send the requesting host through. + if key.startswith('HTTP_') and key != 'HTTP_HOST': + headers[key[5:].replace('_', '-')] = value + elif key in ('CONTENT_TYPE', 'CONTENT_LENGTH'): + headers[key.replace('_', '-')] = value + + return headers -- 2.1.4