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.
This commit is contained in:
Sunil Mohan Adapa 2016-01-08 12:35:53 +05:30
parent 6afe350fe5
commit 1a17819380
No known key found for this signature in database
GPG Key ID: 36C361440C9BC971
7 changed files with 496 additions and 0 deletions

184
actions/letsencrypt Executable file
View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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 = '''
<IfModule mod_gnutls.c>
<VirtualHost _default_:443>
ServerAdmin webmaster@localhost
ServerName {domain}
DocumentRoot /var/www/html
<Directory />
Options FollowSymLinks
AllowOverride None
</Directory>
<Directory /var/www/html>
Options Indexes FollowSymLinks MultiViews
AllowOverride None
Order allow,deny
allow from all
</Directory>
ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/
<Directory "/usr/lib/cgi-bin">
AllowOverride None
Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch
Order allow,deny
Allow from all
</Directory>
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
</VirtualHost>
</IfModule>
'''
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()

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block page_head %}
<style type="text/css">
.table .form .btn {
width: 7em;
}
.form-inline {
display: inline;
}
</style>
{% endblock %}
{% block content %}
<h2>{% trans "Certificates (Let's Encrypt)" %}</h2>
<p>
{% 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 %}
</p>
<p>
{% blocktrans trimmed %}
Let's Encrypt is a free, automated, and open certificate
authority, run for the publics benefit by the Internet Security
Research Group (ISRG). Please read and agree with the
<a href="https://letsencrypt.org/repository/">Let's Encrypt
Subscriber Agreement</a> before using this service.
{% endblocktrans %}
</p>
<div class="row">
<div class="col-lg-8">
<table class="table table-bordered table-condensed table-striped">
<thead>
<tr>
<th>{% trans "Domain" %}</th>
<th>{% trans "Certificate Status" %}</th>
<th>{% trans "Website Security" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for domain, domain_status in status.domains.items %}
<tr>
<td>{{ domain }}</td>
<td>
{% if domain_status.certificate_available %}
<span class="label label-success">
{% blocktrans trimmed with expiry_date=domain_status.expiry_date %}
Expires on {{ expiry_date }}
{% endblocktrans %}
</span>
{% else %}
<span class="label label-warning">
{% trans "No certficate" %}
</span>
{% endif %}
</td>
<td>
{% if domain_status.web_enabled %}
<span class="label label-success">{% trans "Enabled" %}</span>
{% else %}
<span class="label label-warning">{% trans "Disabled" %}</span>
{% endif %}
</td>
<td>
{% if domain_status.certificate_available %}
<form class="form form-inline" method="post"
action="{% url 'letsencrypt:revoke' domain %}">
{% csrf_token %}
<button class="btn btn-sm btn-default" type="submit">
{% trans "Revoke" %}</button>
</form>
<form class="form form-inline" method="post"
action="{% url 'letsencrypt:obtain' domain %}">
{% csrf_token %}
<button class="btn btn-sm btn-default" type="submit">
{% trans "Re-obtain" %}</button>
</form>
{% else %}
<form class="form form-inline" method="post"
action="{% url 'letsencrypt:obtain' domain %}">
{% csrf_token %}
<button class="btn btn-sm btn-primary" type="submit">
{% trans "Obtain" %}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% include "diagnostics_button.html" with module="letsencrypt" %}
{% endblock %}

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/>.
#
"""
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<domain>[^/]+)/$', views.revoke,
name='revoke'),
url(r'^sys/letsencrypt/obtain/(?P<domain>[^/]+)/$', views.obtain,
name='obtain'),
]

View File

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