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 %}
+
+
+
+
+
+
+
+ | {% trans "Domain" %} |
+ {% trans "Certificate Status" %} |
+ {% trans "Website Security" %} |
+ {% trans "Actions" %} |
+
+
+
+ {% for domain, domain_status in status.domains.items %}
+
+ | {{ 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 %}
+
+
+ {% else %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% 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