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..." %}
+
+
+
+{% endif %}
+
+
+
+
+ {% for name_service in status.name_services %}
+
+
+ {{ 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 %}
+
+
+ {% elif not running %}
+
+ {% endif %}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+{% 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),
})