From 70d85cbd6fdb669bd1a0937b43b54fec296ef104 Mon Sep 17 00:00:00 2001 From: James Valleroy Date: Tue, 22 Dec 2015 16:00:27 -0500 Subject: [PATCH] monkeysphere: New module for verifying SSH servers monkeysphere: Run publish as background task, allow user to cancel. Small fixes to names module: - Remove unused ugettext import. - Change SERVICES to tuple. - If a domain is not available for a service type, return None instead of (translated) "Not Available". - Rename get_services -> get_enabled_services. --- actions/monkeysphere | 126 +++++++++++++++ data/etc/plinth/modules-enabled/monkeysphere | 1 + plinth/modules/monkeysphere/__init__.py | 33 ++++ .../monkeysphere/templates/monkeysphere.html | 108 +++++++++++++ plinth/modules/monkeysphere/tests/__init__.py | 0 plinth/modules/monkeysphere/urls.py | 34 +++++ plinth/modules/monkeysphere/views.py | 144 ++++++++++++++++++ plinth/modules/names/__init__.py | 21 ++- plinth/modules/names/tests/test_names.py | 14 +- plinth/modules/names/views.py | 2 +- 10 files changed, 464 insertions(+), 19 deletions(-) create mode 100755 actions/monkeysphere create mode 100644 data/etc/plinth/modules-enabled/monkeysphere create mode 100644 plinth/modules/monkeysphere/__init__.py create mode 100644 plinth/modules/monkeysphere/templates/monkeysphere.html create mode 100644 plinth/modules/monkeysphere/tests/__init__.py create mode 100644 plinth/modules/monkeysphere/urls.py create mode 100644 plinth/modules/monkeysphere/views.py diff --git a/actions/monkeysphere b/actions/monkeysphere new file mode 100755 index 000000000..bce21c8fd --- /dev/null +++ b/actions/monkeysphere @@ -0,0 +1,126 @@ +#!/usr/bin/python3 +# +# This file is part of Plinth. +# +# 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 . +# + +""" +Configuration helper for monkeysphere. +""" + +import argparse +import os +import subprocess + +HOST_TOOL = 'monkeysphere-host' + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + host_show_key = subparsers.add_parser('host-show-key', + help='Show host key fingerprint') + host_show_key.add_argument('keyid', nargs='*', + help='Optional list of KEYIDs') + + host_import_ssh_key = subparsers.add_parser('host-import-ssh-key', + help='Import host SSH key') + host_import_ssh_key.add_argument('hostname', + help='Fully-qualified hostname') + + host_publish_key = subparsers.add_parser( + 'host-publish-key', + help='Push host key to keyserver') + host_publish_key.add_argument( + 'keyid', nargs='*', + help='Optional list of KEYIDs') + + return parser.parse_args() + + +def subcommand_host_show_key(arguments): + """Show host key fingerprint.""" + keyid = ' '.join(arguments.keyid) + try: + output = subprocess.check_output([HOST_TOOL, 'show-key', keyid]) + except subprocess.CalledProcessError: + # no keys available + return + + # parse output + keys = [dict()] + lines = output.decode().strip().split('\n') + for line in lines: + if line.startswith('pub'): + data = line.lstrip('pub').split() + keys[-1]['pub'] = data[0] + keys[-1]['date'] = data[1] + elif line.startswith('uid'): + keys[-1]['uid'] = line.lstrip('uid').strip() + elif line.startswith('OpenPGP fingerprint:'): + keys[-1]['pgp_fingerprint'] = line.lstrip('Open PGP fingerprint:') + elif line.startswith('ssh fingerprint:'): + data = line.lstrip('ssh fingerprint:').split() + keys[-1]['ssh_keysize'] = data[0] + keys[-1]['ssh_fingerprint'] = data[1] + keys[-1]['ssh_keytype'] = data[2].strip('()') + elif line == '': + keys.append(dict()) + + for key in keys: + print(key['pub'], key['date'], key['uid'], key['pgp_fingerprint'], + key['ssh_keysize'], key['ssh_fingerprint'], key['ssh_keytype']) + + +def subcommand_host_import_ssh_key(arguments): + """Import host SSH key.""" + output = subprocess.check_output( + [HOST_TOOL, 'import-key', + '/etc/ssh/ssh_host_rsa_key', arguments.hostname]) + print(output.decode()) + + +def subcommand_host_publish_key(arguments): + """Push host key to keyserver.""" + keyid = ' '.join(arguments.keyid) + # setting TMPDIR as workaround for Debian bug #656750 + proc = subprocess.Popen( + [HOST_TOOL, 'publish-keys', keyid], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False, + env=dict( + os.environ, + TMPDIR='/var/lib/monkeysphere/authentication/tmp/', + MONKEYSPHERE_PROMPT='false')) + output, error = proc.communicate() + output, error = output.decode(), error.decode() + if proc.returncode != 0: + raise Exception(output, error) + + print(output) + + +def main(): + """Parse arguments and perform all duties.""" + arguments = parse_arguments() + + subcommand = arguments.subcommand.replace('-', '_') + subcommand_method = globals()['subcommand_' + subcommand] + subcommand_method(arguments) + + +if __name__ == '__main__': + main() diff --git a/data/etc/plinth/modules-enabled/monkeysphere b/data/etc/plinth/modules-enabled/monkeysphere new file mode 100644 index 000000000..2de664397 --- /dev/null +++ b/data/etc/plinth/modules-enabled/monkeysphere @@ -0,0 +1 @@ +plinth.modules.monkeysphere diff --git a/plinth/modules/monkeysphere/__init__.py b/plinth/modules/monkeysphere/__init__.py new file mode 100644 index 000000000..d9501870d --- /dev/null +++ b/plinth/modules/monkeysphere/__init__.py @@ -0,0 +1,33 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +Plinth module for monkeysphere. +""" + +from django.utils.translation import ugettext_lazy as _ + +from plinth import cfg + +depends = ['plinth.modules.system'] + + +def init(): + """Initialize the monkeysphere module.""" + menu = cfg.main_menu.get('system:index') + menu.add_urlname(_('Monkeysphere'), 'glyphicon-certificate', + 'monkeysphere:index', 970) diff --git a/plinth/modules/monkeysphere/templates/monkeysphere.html b/plinth/modules/monkeysphere/templates/monkeysphere.html new file mode 100644 index 000000000..fbab833e0 --- /dev/null +++ b/plinth/modules/monkeysphere/templates/monkeysphere.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% comment %} +# +# This file is part of Plinth. +# +# 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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block page_head %} + + {% if running %} + + {% endif %} + +{% endblock %} + + +{% block content %} + +

{% trans "Monkeysphere" %}

+ +

+ {% blocktrans trimmed with box_name=cfg.box_name %} + With Monkeysphere, a PGP key can be generated for each domain + serving SSH on this {{ box_name }}. The PGP public key can then be + uploaded to the PGP keyservers. Users connecting to this {{ box_name }} + through SSH can verify that they are connecting to the correct + host. See the + + Monkeysphere SSH documentation for more details. + {% endblocktrans %} +

+ +{% if running %} +

+ + {% trans "Publishing key to keyserver..." %} + +

+ {% csrf_token %} + + +
+

+{% endif %} + +
+
+ + {% for name_service in status.name_services %} + + + + + + {% endfor %} +
+ {{ name_service.type }}
+ {{ name_service.name }} +
+ {% if name_service.key %} + {{ name_service.key.pgp_fingerprint }} + {% else %} + {% trans "Not Available" %} + {% endif %} + + {% if name_service.available %} + {% if not name_service.key %} +
+ {% csrf_token %} + + +
+ + {% elif not running %} +
+ {% csrf_token %} + + +
+ {% endif %} + {% endif %} +
+
+
+ +{% endblock %} diff --git a/plinth/modules/monkeysphere/tests/__init__.py b/plinth/modules/monkeysphere/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/monkeysphere/urls.py b/plinth/modules/monkeysphere/urls.py new file mode 100644 index 000000000..d45cd3855 --- /dev/null +++ b/plinth/modules/monkeysphere/urls.py @@ -0,0 +1,34 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +URLs for the monkeysphere module. +""" + +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^sys/monkeysphere/$', views.index, name='index'), + url(r'^sys/monkeysphere/(?P[\w]+)/generate/$', + views.generate, name='generate'), + url(r'^sys/monkeysphere/(?P[\w]+)/publish/$', + views.publish, name='publish'), + url(r'^sys/monkeysphere/cancel/$', views.cancel, name='cancel'), +] diff --git a/plinth/modules/monkeysphere/views.py b/plinth/modules/monkeysphere/views.py new file mode 100644 index 000000000..c3184e47d --- /dev/null +++ b/plinth/modules/monkeysphere/views.py @@ -0,0 +1,144 @@ +# +# This file is part of Plinth. +# +# 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 . +# + +""" +Views for the monkeysphere module. +""" + +from django.contrib import messages +from django.core.urlresolvers import reverse_lazy +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST + +from plinth import actions +from plinth import package +from plinth.modules import names + +publish_process = None + + +@package.required(['monkeysphere']) +def index(request): + """Serve configuration page.""" + _collect_publish_result(request) + status = get_status() + return TemplateResponse( + request, 'monkeysphere.html', + {'title': _('Monkeysphere'), + 'status': status, + 'running': bool(publish_process)}) + + +@require_POST +def generate(request, service): + """Generate PGP key for SSH service.""" + for domain_type in sorted(names.get_domain_types()): + if domain_type == service: + domain = names.get_domain(domain_type) + + try: + actions.superuser_run( + 'monkeysphere', + ['host-import-ssh-key', 'ssh://' + domain]) + messages.success(request, _('Generated PGP key')) + except actions.ActionError as exception: + messages.error(request, str(exception)) + + return redirect(reverse_lazy('monkeysphere:index')) + + +@require_POST +def publish(request, fingerprint): + """Publish PGP key for SSH service.""" + global publish_process + if not publish_process: + publish_process = actions.superuser_run( + 'monkeysphere', ['host-publish-key', fingerprint], async=True) + + return redirect(reverse_lazy('monkeysphere:index')) + + +@require_POST +def cancel(request): + """Cancel ongoing process.""" + global publish_process + if publish_process: + publish_process.terminate() + publish_process = None + messages.info(request, _('Cancelled publish key.')) + + return redirect(reverse_lazy('monkeysphere:index')) + + +def get_status(): + """Get the current status.""" + output = actions.superuser_run('monkeysphere', ['host-show-key']) + keys = [] + for line in output.split('\n'): + data = line.strip().split() + if data and len(data) == 7: + keys.append(dict()) + keys[-1]['pub'] = data[0] + keys[-1]['date'] = data[1] + keys[-1]['uid'] = data[2] + keys[-1]['name'] = data[2].replace('ssh://', '') + keys[-1]['pgp_fingerprint'] = data[3] + keys[-1]['ssh_keysize'] = data[4] + keys[-1]['ssh_fingerprint'] = data[5] + keys[-1]['ssh_keytype'] = data[6] + + name_services = [] + for domain_type in sorted(names.get_domain_types()): + domain = names.get_domain(domain_type) + name_services.append({ + 'type': names.get_description(domain_type), + 'short_type': domain_type, + 'name': domain or _('Not Available'), + 'available': bool(domain), + 'key': None, + }) + + # match up keys with name services + for key in keys: + for name_service in name_services: + if key['name'] == name_service['name']: + name_service['key'] = key + continue + + return {'name_services': name_services} + + +def _collect_publish_result(request): + """Handle publish process completion.""" + global publish_process + if not publish_process: + return + + return_code = publish_process.poll() + + # Publish process is not complete yet + if return_code is None: + return + + if not return_code: + messages.success(request, _('Published key to keyserver.')) + else: + messages.error(request, _('Error occurred while publishing key.')) + + publish_process = None diff --git a/plinth/modules/names/__init__.py b/plinth/modules/names/__init__.py index 36e4d03ec..841b95f7f 100644 --- a/plinth/modules/names/__init__.py +++ b/plinth/modules/names/__init__.py @@ -19,18 +19,17 @@ Plinth module to configure name services """ -from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils.translation import ugettext_lazy as _ import logging from plinth import cfg from plinth.signals import domain_added, domain_removed - -SERVICES = [ - ('http', ugettext_lazy('HTTP'), 80), - ('https', ugettext_lazy('HTTPS'), 443), - ('ssh', ugettext_lazy('SSH'), 22), -] +SERVICES = ( + ('http', _('HTTP'), 80), + ('https', _('HTTPS'), 443), + ('ssh', _('SSH'), 22), +) depends = ['plinth.modules.system'] @@ -43,7 +42,7 @@ logger = logging.getLogger(__name__) def init(): """Initialize the names module.""" menu = cfg.main_menu.get('system:index') - menu.add_urlname(ugettext_lazy('Name Services'), 'glyphicon-tag', + menu.add_urlname(_('Name Services'), 'glyphicon-tag', 'names:index', 19) domain_added.connect(on_domain_added) @@ -104,10 +103,10 @@ def get_domain(domain_type): if domain_type in domains and len(domains[domain_type]) > 0: return list(domains[domain_type].keys())[0] else: - return _('Not Available') + return None -def get_services(domain_type, domain): +def get_enabled_services(domain_type, domain): """Get list of enabled services for a domain.""" try: return domains[domain_type][domain] @@ -118,5 +117,5 @@ def get_services(domain_type, domain): def get_services_status(domain_type, domain): """Get list of whether each service is enabled for a domain.""" - enabled = get_services(domain_type, domain) + enabled = get_enabled_services(domain_type, domain) return [service[0] in enabled for service in SERVICES] diff --git a/plinth/modules/names/tests/test_names.py b/plinth/modules/names/tests/test_names.py index 6c98ca9d4..6c28aa3a3 100644 --- a/plinth/modules/names/tests/test_names.py +++ b/plinth/modules/names/tests/test_names.py @@ -24,7 +24,7 @@ import unittest from .. import domain_types, domains from .. import on_domain_added, on_domain_removed from .. import get_domain_types, get_description -from .. import get_domain, get_services, get_services_status +from .. import get_domain, get_enabled_services, get_services_status class TestNames(unittest.TestCase): @@ -73,20 +73,20 @@ class TestNames(unittest.TestCase): on_domain_added('', 'hiddenservice', 'aaaaa.onion') self.assertEqual(get_domain('hiddenservice'), 'aaaaa.onion') - self.assertEqual('Not Available', get_domain('abcdef')) + self.assertEqual(None, get_domain('abcdef')) on_domain_removed('', 'hiddenservice') - self.assertEqual('Not Available', get_domain('hiddenservice')) + self.assertEqual(None, get_domain('hiddenservice')) - def test_get_services(self): + def test_get_enabled_services(self): """Test getting enabled services for a domain.""" on_domain_added('', 'domainname', 'bbbbb', '', ['http', 'https', 'ssh']) - self.assertEqual(get_services('domainname', 'bbbbb'), + self.assertEqual(get_enabled_services('domainname', 'bbbbb'), ['http', 'https', 'ssh']) - self.assertEqual(get_services('xxxxx', 'yyyyy'), []) - self.assertEqual(get_services('domainname', 'zzzzz'), []) + self.assertEqual(get_enabled_services('xxxxx', 'yyyyy'), []) + self.assertEqual(get_enabled_services('domainname', 'zzzzz'), []) def test_get_services_status(self): """Test getting whether each service is enabled for a domain.""" diff --git a/plinth/modules/names/views.py b/plinth/modules/names/views.py index cd5d94267..c95ea0b00 100644 --- a/plinth/modules/names/views.py +++ b/plinth/modules/names/views.py @@ -42,7 +42,7 @@ def get_status(): domain = get_domain(domain_type) name_services.append({ 'type': get_description(domain_type), - 'name': domain, + 'name': domain or _('Not Available'), 'services_enabled': get_services_status(domain_type, domain), })