mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-21 07:55:00 +00:00
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:
parent
6afe350fe5
commit
1a17819380
184
actions/letsencrypt
Executable file
184
actions/letsencrypt
Executable 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()
|
||||
1
data/etc/plinth/modules-enabled/letsencrypt
Normal file
1
data/etc/plinth/modules-enabled/letsencrypt
Normal file
@ -0,0 +1 @@
|
||||
plinth.modules.letsencrypt
|
||||
58
plinth/modules/letsencrypt/__init__.py
Normal file
58
plinth/modules/letsencrypt/__init__.py
Normal 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
|
||||
128
plinth/modules/letsencrypt/templates/letsencrypt.html
Normal file
128
plinth/modules/letsencrypt/templates/letsencrypt.html
Normal 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 public’s 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 %}
|
||||
0
plinth/modules/letsencrypt/tests/__init__.py
Normal file
0
plinth/modules/letsencrypt/tests/__init__.py
Normal file
33
plinth/modules/letsencrypt/urls.py
Normal file
33
plinth/modules/letsencrypt/urls.py
Normal 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'),
|
||||
]
|
||||
92
plinth/modules/letsencrypt/views.py
Normal file
92
plinth/modules/letsencrypt/views.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user