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.
This commit is contained in:
James Valleroy 2015-12-22 16:00:27 -05:00 committed by Sunil Mohan Adapa
parent 447f067734
commit 70d85cbd6f
No known key found for this signature in database
GPG Key ID: 36C361440C9BC971
10 changed files with 464 additions and 19 deletions

126
actions/monkeysphere Executable file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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()

View File

@ -0,0 +1 @@
plinth.modules.monkeysphere

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block page_head %}
{% if running %}
<meta http-equiv="refresh" content="3"/>
{% endif %}
{% endblock %}
{% block content %}
<h2>{% trans "Monkeysphere" %}</h2>
<p>
{% 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
<a href="http://web.monkeysphere.info/getting-started-ssh/">
Monkeysphere SSH documentation</a> for more details.
{% endblocktrans %}
</p>
{% if running %}
<p class="running-status-parent">
<span class="running-status active"></span>
{% trans "Publishing key to keyserver..." %}
<form class="form" method="post"
action="{% url 'monkeysphere:cancel' %}">
{% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm">
{% trans "Cancel" %}</button>
</form>
</p>
{% endif %}
<div class="row">
<div class="col-sm-8">
<table class="table table-bordered table-condensed table-striped">
{% for name_service in status.name_services %}
<tr>
<td>
<b>{{ name_service.type }}</b><br>
<i>{{ name_service.name }}</i>
</td>
<td>
{% if name_service.key %}
{{ name_service.key.pgp_fingerprint }}
{% else %}
{% trans "Not Available" %}
{% endif %}
</td>
<td>
{% if name_service.available %}
{% if not name_service.key %}
<form class="form" method="post"
action="{% url 'monkeysphere:generate' name_service.short_type %}">
{% csrf_token %}
<button type="submit" class="btn btn-primary btn-sm pull-right">
{% trans "Generate PGP Key" %}</button>
</form>
{% elif not running %}
<form class="form" method="post"
action="{% url 'monkeysphere:publish' name_service.key.pgp_fingerprint %}">
{% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm pull-right">
{% trans "Publish Key" %}</button>
</form>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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<service>[\w]+)/generate/$',
views.generate, name='generate'),
url(r'^sys/monkeysphere/(?P<fingerprint>[\w]+)/publish/$',
views.publish, name='publish'),
url(r'^sys/monkeysphere/cancel/$', views.cancel, name='cancel'),
]

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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

View File

@ -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]

View File

@ -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."""

View File

@ -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),
})