From 1a17819380770c5d8956c3541b22111784becdf1 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Fri, 8 Jan 2016 12:35:53 +0530 Subject: [PATCH] letsencrypt: New basic module for SSL certficates This is the first implementation for obtaining certificates from Let's Encrypt. Following the features and limitations. - Requires manual operation. - Registrations are done anonymously. - Supports revoking and re-obtaining certificates. Does not have a way to show if a certficate is already renewed. - Automatic renewal is not available. - Details messages in case of errors. - Has ability to switch to testing mode by using LE's staging servers. - Sets up Apache configuration for the domain and enables/disables it. When certificates are not available for a domain, default website configuration is used. When certificates are available, separate SSL website configuration for each domain is used. - Many domain will work with a single IP address with the help of Server Name Indication (SNI) which is supported by all modern browsers. - Supports diagnostics on websites. --- actions/letsencrypt | 184 ++++++++++++++++++ data/etc/plinth/modules-enabled/letsencrypt | 1 + plinth/modules/letsencrypt/__init__.py | 58 ++++++ .../letsencrypt/templates/letsencrypt.html | 128 ++++++++++++ plinth/modules/letsencrypt/tests/__init__.py | 0 plinth/modules/letsencrypt/urls.py | 33 ++++ plinth/modules/letsencrypt/views.py | 92 +++++++++ 7 files changed, 496 insertions(+) create mode 100755 actions/letsencrypt create mode 100644 data/etc/plinth/modules-enabled/letsencrypt create mode 100644 plinth/modules/letsencrypt/__init__.py create mode 100644 plinth/modules/letsencrypt/templates/letsencrypt.html create mode 100644 plinth/modules/letsencrypt/tests/__init__.py create mode 100644 plinth/modules/letsencrypt/urls.py create mode 100644 plinth/modules/letsencrypt/views.py diff --git a/actions/letsencrypt b/actions/letsencrypt new file mode 100755 index 000000000..6999590de --- /dev/null +++ b/actions/letsencrypt @@ -0,0 +1,184 @@ +#!/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 Let's Encrypt. +""" + +import argparse +import json +import os +import subprocess +import sys + +from plinth import action_utils + +TEST_MODE = False +LIVE_DIRECTORY = '/etc/letsencrypt/live/' +APACHE_PREFIX = '/etc/apache2/sites-available/' +APACHE_CONFIGURATION = ''' + + + ServerAdmin webmaster@localhost + ServerName {domain} + DocumentRoot /var/www/html + + Options FollowSymLinks + AllowOverride None + + + Options Indexes FollowSymLinks MultiViews + AllowOverride None + Order allow,deny + allow from all + + ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/ + + AllowOverride None + Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch + Order allow,deny + Allow from all + + ErrorLog ${{APACHE_LOG_DIR}}/error.log + # Possible values include: debug, info, notice, warn, error, crit, alert, emerg. + LogLevel warn + CustomLog ${{APACHE_LOG_DIR}}/ssl_access.log combined + # GnuTLS Switch: Enable/Disable SSL/TLS for this virtual host. + GnuTLSEnable On + # Automatically obtained certficates from Let's Encrypt + GnuTLSCertificateFile /etc/letsencrypt/live/{domain}/fullchain.pem + GnuTLSKeyFile /etc/letsencrypt/live/{domain}/privkey.pem + # See http://www.outoforder.cc/projects/apache/mod_gnutls/docs/#GnuTLSPriorities + GnuTLSPriorities NORMAL + + +''' + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparsers.add_parser( + 'get-status', help='Return the status of configured domains.') + revoke_parser = subparsers.add_parser( + 'revoke', help='Disable and domain and revoke its certificate.') + revoke_parser.add_argument( + '--domain', help='Domain name to revoke certificate for') + obtain_parser = subparsers.add_parser( + 'obtain', help='Obtain certficate for a domain and setup website.') + obtain_parser.add_argument( + '--domain', help='Domain name to obtain certificate for') + + return parser.parse_args() + + +def get_certficate_expiry(domain): + """Return the expiry date of a certificate.""" + certificate_file = os.path.join(LIVE_DIRECTORY, domain, 'cert.pem') + output = subprocess.check_output(['openssl', 'x509', '-enddate', '-noout', + '-in', certificate_file]) + return output.decode().strip().split('=')[1] + + +def subcommand_get_status(_): + """Return a JSON dictionary of currently configured domains.""" + domains = os.listdir(LIVE_DIRECTORY) + domains = [domain for domain in domains + if os.path.isdir(os.path.join(LIVE_DIRECTORY, domain))] + + domain_status = {} + for domain in domains: + domain_status[domain] = { + 'certificate_available': True, + 'expiry_date': get_certficate_expiry(domain), + 'web_enabled': + action_utils.webserver_is_enabled(domain, kind='site') + } + + print(json.dumps({'domains': domain_status})) + + +def subcommand_revoke(arguments): + """Disable a domain and revoke the certificate.""" + domain = arguments.domain + + command = ['letsencrypt', 'revoke', '--domain', domain, '--cert-path', + os.path.join(LIVE_DIRECTORY, domain, 'cert.pem')] + if TEST_MODE: + command.append('--staging') + + process = subprocess.Popen(command, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + if process.returncode: + print(stderr.decode(), file=sys.stderr) + sys.exit(1) + + action_utils.webserver_disable(domain, kind='site') + + +def subcommand_obtain(arguments): + """Obtain a certificate for a domain and setup website.""" + domain = arguments.domain + + command = [ + 'letsencrypt', 'certonly', '--agree-tos', + '--register-unsafely-without-email', '--domain', arguments.domain, + '--authenticator', 'webroot', '--webroot-path', '/var/www/html/', + '--renew-by-default'] + if TEST_MODE: + command.append('--staging') + + process = subprocess.Popen(command, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdin, stderr = process.communicate() + if process.returncode: + print(stderr.decode(), file=sys.stderr) + sys.exit(1) + + setup_webserver_config(domain) + + action_utils.webserver_enable(domain, kind='site') + + +def setup_webserver_config(domain): + """Create SSL web server configuration for a domain. + + Do so only if there is no configuration existing. + """ + file_name = os.path.join(APACHE_PREFIX, domain + '.conf') + if os.path.isfile(file_name): + return + + with open(file_name, 'w') as file_handle: + file_handle.write(APACHE_CONFIGURATION.format(domain=domain)) + + +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/letsencrypt b/data/etc/plinth/modules-enabled/letsencrypt new file mode 100644 index 000000000..92134d1c2 --- /dev/null +++ b/data/etc/plinth/modules-enabled/letsencrypt @@ -0,0 +1 @@ +plinth.modules.letsencrypt diff --git a/plinth/modules/letsencrypt/__init__.py b/plinth/modules/letsencrypt/__init__.py new file mode 100644 index 000000000..565d78bed --- /dev/null +++ b/plinth/modules/letsencrypt/__init__.py @@ -0,0 +1,58 @@ +# +# 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 using Let's Encrypt. +""" + +from django.utils.translation import ugettext_lazy as _ +import json + +from plinth import actions +from plinth import action_utils +from plinth import cfg +from plinth import service as service_module +from plinth.modules import names + + +depends = [ + 'plinth.modules.apps', + 'plinth.modules.names' +] + +service = None + + +def init(): + """Intialize the module.""" + menu = cfg.main_menu.get('system:index') + menu.add_urlname(_('Certificates (Let\'s Encrypt)'), + 'glyphicon-lock', 'letsencrypt:index', 20) + + +def diagnose(): + """Run diagnostics and return the results.""" + results = [] + + for domain_type, domains in names.domains.items(): + if domain_type == 'hiddenservice': + continue + + for domain in domains: + results.append(action_utils.diagnose_url('https://' + domain)) + + return results diff --git a/plinth/modules/letsencrypt/templates/letsencrypt.html b/plinth/modules/letsencrypt/templates/letsencrypt.html new file mode 100644 index 000000000..ba92f871d --- /dev/null +++ b/plinth/modules/letsencrypt/templates/letsencrypt.html @@ -0,0 +1,128 @@ +{% 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 %} + +{% endblock %} + +{% block content %} + +

{% trans "Certificates (Let's Encrypt)" %}

+ +

+ {% blocktrans trimmed with box_name=cfg.box_name %} + A digital certficate allows users of a web service to verify the + identity of the service and to securely communicate with it. + {{ box_name }} can automatically obtain and setup digital + certificates for each available domain. It does so by proving + itself to be the owner of a domain to Let's Encrypt, a + certficate authority (CA). + {% endblocktrans %} +

+ +

+ {% blocktrans trimmed %} + Let's Encrypt is a free, automated, and open certificate + authority, run for the public’s benefit by the Internet Security + Research Group (ISRG). Please read and agree with the + Let's Encrypt + Subscriber Agreement before using this service. + {% endblocktrans %} +

+ +
+
+ + + + + + + + + + + {% for domain, domain_status in status.domains.items %} + + + + + + + {% endfor %} + +
{% trans "Domain" %}{% trans "Certificate Status" %}{% trans "Website Security" %}{% trans "Actions" %}
{{ domain }} + {% if domain_status.certificate_available %} + + {% blocktrans trimmed with expiry_date=domain_status.expiry_date %} + Expires on {{ expiry_date }} + {% endblocktrans %} + + {% else %} + + {% trans "No certficate" %} + + {% endif %} + + {% if domain_status.web_enabled %} + {% trans "Enabled" %} + {% else %} + {% trans "Disabled" %} + {% endif %} + + {% if domain_status.certificate_available %} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+
+ + {% include "diagnostics_button.html" with module="letsencrypt" %} + +{% endblock %} diff --git a/plinth/modules/letsencrypt/tests/__init__.py b/plinth/modules/letsencrypt/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/letsencrypt/urls.py b/plinth/modules/letsencrypt/urls.py new file mode 100644 index 000000000..09d0bdb9a --- /dev/null +++ b/plinth/modules/letsencrypt/urls.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 . +# + +""" +URLs for the Let's Encrypt module. +""" + +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^sys/letsencrypt/$', views.index, name='index'), + url(r'^sys/letsencrypt/revoke/(?P[^/]+)/$', views.revoke, + name='revoke'), + url(r'^sys/letsencrypt/obtain/(?P[^/]+)/$', views.obtain, + name='obtain'), +] diff --git a/plinth/modules/letsencrypt/views.py b/plinth/modules/letsencrypt/views.py new file mode 100644 index 000000000..eb8660759 --- /dev/null +++ b/plinth/modules/letsencrypt/views.py @@ -0,0 +1,92 @@ +# +# 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 using Let's Encrypt. +""" + +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 +import json +import logging + +from plinth import actions +from plinth import package +from plinth.errors import ActionError +from plinth.modules import names + +logger = logging.getLogger(__name__) + + +@package.required(['letsencrypt']) +def index(request): + """Serve configuration page.""" + status = get_status() + + return TemplateResponse(request, 'letsencrypt.html', + {'title': _('Certificates (Let\'s Encrypt)'), + 'status': status}) + + +@require_POST +def revoke(request, domain): + """Revoke a certficate for a given domain.""" + try: + actions.superuser_run('letsencrypt', ['revoke', '--domain', domain]) + messages.success( + request, _('Certificate successfully revoked for domain {domain}') + .format(domain=domain)) + except ActionError as exception: + messages.error( + request, + _('Failed to revoke certificate for domain {domain}: {error}') + .format(domain=domain, error=exception.args[2])) + + return redirect(reverse_lazy('letsencrypt:index')) + + +@require_POST +def obtain(request, domain): + """Obtain and install a certficate for a given domain.""" + try: + actions.superuser_run('letsencrypt', ['obtain', '--domain', domain]) + messages.success( + request, _('Certificate successfully obtained for domain {domain}') + .format(domain=domain)) + except ActionError as exception: + messages.error( + request, + _('Failed to obtain certificate for domain {domain}: {error}') + .format(domain=domain, error=exception.args[2])) + + return redirect(reverse_lazy('letsencrypt:index')) + + +def get_status(): + """Get the current settings.""" + status = actions.superuser_run('letsencrypt', ['get-status']) + status = json.loads(status) + + for domains in names.domains.values(): + for domain in domains: + status['domains'].setdefault(domain, {}) + + return status