diff --git a/letsencrypt/README.rst b/letsencrypt/README.rst new file mode 100644 index 000000000..dfcd02acc --- /dev/null +++ b/letsencrypt/README.rst @@ -0,0 +1,166 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +============================================= +Request SSL certificates from letsencrypt.org +============================================= + +This module was written to have your Odoo installation request SSL certificates +from https://letsencrypt.org automatically. + +Installation +============ + +After installation, this module generates a private key for your account at +letsencrypt.org automatically in ``$data_dir/letsencrypt/account.key``. If you +want or need to use your own account key, replace the file. + +For certificate requests to work, your site needs to be accessible via plain +HTTP, see below for configuration examples in case you force your clients to +the SSL version. + +After installation, trigger the cronjob `Update letsencrypt certificates` and +watch your log for messages. + +This addon depends on the ``openssl`` binary and the ``acme_tiny`` and ``IPy`` +python modules. + +For installing the OpenSSL binary you can use your distro package manager. +For Debian and Ubuntu, that would be: + + sudo apt-get install openssl + +For installing the ACME-Tiny python module, use the PIP package manager: + + sudo pip install acme-tiny + +For installing the IPy python module, use the PIP package manager: + + sudo pip install IPy + + +Configuration +============= + +This addons requests a certificate for the domain named in the configuration +parameter ``web.base.url`` - if this comes back as ``localhost`` or the like, +the module doesn't request anything. + +If you want your certificate to contain multiple alternative names, just add +them as configuration parameters ``letsencrypt.altname.N`` with ``N`` starting +from ``0``. The amount of domains that can be added are subject to `rate +limiting `_. + +Note that all those domains must be publicly reachable on port 80 via HTTP, and +they must have an entry for ``.well-known/acme-challenge`` pointing to your odoo +instance. + +Usage +===== + +The module sets up a cronjob that requests and renews certificates automatically. + +After the first run, you'll find a file called ``domain.crt`` in +``$datadir/letsencrypt``, configure your SSL proxy to use this file as certificate. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/149/8.0 + +For further information, please visit: + +* https://www.odoo.com/forum/help-1 + +In depth configuration +====================== + +This module uses ``openssl`` to generate CSRs suitable to be submitted to +letsencrypt.org. In order to do this, it copies ``/etc/ssl/openssl.cnf`` to a +temporary and adapts it according to its needs (currently, that's just adding a +``[SAN]`` section if necessary). If you want the module to use another configuration +template, set config parameter ``letsencrypt.openssl.cnf``. + +After refreshing the certificate, the module attempts to run the content of +``letsencrypt.reload_command``, which is by default ``sudo service nginx reload``. +Change this to match your server's configuration. + +You'll also need a matching sudo configuration, like:: + + your_odoo_user ALL = NOPASSWD: /usr/sbin/service nginx reload + +Further, if you force users to https, you'll need something like for nginx:: + + if ($scheme = "http") { + set $redirect_https 1; + } + if ($request_uri ~ ^/.well-known/acme-challenge/) { + set $redirect_https 0; + } + if ($redirect_https) { + rewrite ^ https://$server_name$request_uri? permanent; + } + +and this for apache:: + + RewriteEngine On + RewriteCond %{HTTPS} !=on + RewriteCond %{REQUEST_URI} "!^/.well-known/" + RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L] + +In case you need to redirect other nginx sites to your Odoo instance, declare +an upstream for your odoo instance and do something like:: + + location /.well-known { + proxy_pass http://yourodooupstream; + } + +If you're using a multi-database installation (with or without dbfilter option) +where /web/databse/selector returns a list of more than one database, then +you need to add ``letsencrypt`` addon to wide load addons list +(by default, only ``web`` addon), setting ``--load`` option. +For example, ``--load=web,letsencrypt`` + + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed feedback +`here `_. + +Credits +======= + +Contributors +------------ + +* Holger Brunn +* Antonio Espinosa +* Dave Lasley + +ACME implementation +------------------- + +* https://github.com/diafygi/acme-tiny/blob/master/acme_tiny.py + +Icon +---- + +* https://helloworld.letsencrypt.org + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/letsencrypt/__init__.py b/letsencrypt/__init__.py new file mode 100644 index 000000000..5a561ec10 --- /dev/null +++ b/letsencrypt/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import models +from . import controllers +from .hooks import post_init_hook diff --git a/letsencrypt/__openerp__.py b/letsencrypt/__openerp__.py new file mode 100644 index 000000000..01457b807 --- /dev/null +++ b/letsencrypt/__openerp__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Let's encrypt", + "version": "9.0.1.0.0", + "author": "Therp BV," + "Tecnativa," + "Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Hidden/Dependency", + "summary": "Request SSL certificates from letsencrypt.org", + "depends": [ + 'base', + ], + "data": [ + "data/ir_config_parameter.xml", + "data/ir_cron.xml", + ], + "post_init_hook": 'post_init_hook', + "installable": True, + "external_dependencies": { + 'bin': [ + 'openssl', + ], + 'python': [ + 'acme_tiny', + 'IPy', + ], + }, +} diff --git a/letsencrypt/controllers/__init__.py b/letsencrypt/controllers/__init__.py new file mode 100644 index 000000000..b0d7e8a38 --- /dev/null +++ b/letsencrypt/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import main diff --git a/letsencrypt/controllers/main.py b/letsencrypt/controllers/main.py new file mode 100644 index 000000000..03b8c4d15 --- /dev/null +++ b/letsencrypt/controllers/main.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# © 2016 Antonio Espinosa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import os +from openerp import http +from openerp.http import request +from ..models.letsencrypt import get_challenge_dir + + +class Letsencrypt(http.Controller): + @http.route('/.well-known/acme-challenge/', auth='none') + def acme_challenge(self, filename): + try: + with file(os.path.join(get_challenge_dir(), filename)) as key: + return key.read() + except IOError: + pass + return request.not_found() diff --git a/letsencrypt/data/ir_config_parameter.xml b/letsencrypt/data/ir_config_parameter.xml new file mode 100644 index 000000000..eb3c3f54b --- /dev/null +++ b/letsencrypt/data/ir_config_parameter.xml @@ -0,0 +1,10 @@ + + + + + letsencrypt.reload_command + sudo /usr/sbin/service nginx reload + + + + diff --git a/letsencrypt/data/ir_cron.xml b/letsencrypt/data/ir_cron.xml new file mode 100644 index 000000000..1b43f8609 --- /dev/null +++ b/letsencrypt/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + Update letsencrypt certificates + weeks + 11 + -1 + letsencrypt + cron + 2016-01-01 + + + diff --git a/letsencrypt/hooks.py b/letsencrypt/hooks.py new file mode 100644 index 000000000..3332fa23f --- /dev/null +++ b/letsencrypt/hooks.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp import SUPERUSER_ID, api + + +def post_init_hook(cr, pool): + env = api.Environment(cr, SUPERUSER_ID, {}) + env['letsencrypt'].generate_account_key() diff --git a/letsencrypt/models/__init__.py b/letsencrypt/models/__init__.py new file mode 100644 index 000000000..c77cf9b6c --- /dev/null +++ b/letsencrypt/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import letsencrypt diff --git a/letsencrypt/models/letsencrypt.py b/letsencrypt/models/letsencrypt.py new file mode 100644 index 000000000..daa262bb1 --- /dev/null +++ b/letsencrypt/models/letsencrypt.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# © 2016 Antonio Espinosa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import os +import logging +import urllib2 +import urlparse +import subprocess +import tempfile +from openerp import _, api, models, exceptions +from openerp.tools import config + + +DEFAULT_KEY_LENGTH = 4096 +_logger = logging.getLogger(__name__) + + +def get_data_dir(): + return os.path.join(config.options.get('data_dir'), 'letsencrypt') + + +def get_challenge_dir(): + return os.path.join(get_data_dir(), 'acme-challenge') + + +class Letsencrypt(models.AbstractModel): + _name = 'letsencrypt' + _description = 'Abstract model providing functions for letsencrypt' + + @api.model + def call_cmdline(self, cmdline, loglevel=logging.INFO, + raise_on_result=True): + process = subprocess.Popen( + cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, stderr = process.communicate() + if stderr: + _logger.log(loglevel, stderr) + if stdout: + _logger.log(loglevel, stdout) + + if process.returncode: + raise exceptions.Warning( + _('Error calling %s: %d') % (cmdline[0], process.returncode), + ' '.join(cmdline), + ) + + return process.returncode + + @api.model + def generate_account_key(self): + data_dir = get_data_dir() + if not os.path.isdir(data_dir): + os.makedirs(data_dir) + account_key = os.path.join(data_dir, 'account.key') + if not os.path.isfile(account_key): + _logger.info('generating rsa account key') + self.call_cmdline([ + 'openssl', 'genrsa', '-out', account_key, + str(DEFAULT_KEY_LENGTH), + ]) + assert os.path.isfile(account_key), 'failed to create rsa key' + return account_key + + @api.model + def generate_domain_key(self, domain): + domain_key = os.path.join(get_data_dir(), '%s.key' % domain) + if not os.path.isfile(domain_key): + _logger.info('generating rsa domain key for %s', domain) + self.call_cmdline([ + 'openssl', 'genrsa', '-out', domain_key, + str(DEFAULT_KEY_LENGTH), + ]) + return domain_key + + @api.model + def validate_domain(self, domain): + local_domains = [ + 'localhost', 'localhost.localdomain', 'localhost6', + 'localhost6.localdomain6' + ] + + def _ip_is_private(address): + import IPy + try: + ip = IPy.IP(address) + except: + return False + return ip.iptype() == 'PRIVATE' + + if domain in local_domains or _ip_is_private(domain): + raise exceptions.Warning( + _("Let's encrypt doesn't work with private addresses " + "or local domains!")) + + @api.model + def generate_csr(self, domain): + domains = [domain] + i = 0 + while self.env['ir.config_parameter'].get_param( + 'letsencrypt.altname.%d' % i): + domains.append( + self.env['ir.config_parameter'] + .get_param('letsencrypt.altname.%d' % i) + ) + i += 1 + _logger.info('generating csr for %s', domain) + if len(domains) > 1: + _logger.info('with alternative subjects %s', ','.join(domains[1:])) + config = self.env['ir.config_parameter'].get_param( + 'letsencrypt.openssl.cnf', '/etc/ssl/openssl.cnf') + csr = os.path.join(get_data_dir(), '%s.csr' % domain) + with tempfile.NamedTemporaryFile() as cfg: + cfg.write(open(config).read()) + if len(domains) > 1: + cfg.write( + '\n[SAN]\nsubjectAltName=' + + ','.join(map(lambda x: 'DNS:%s' % x, domains)) + '\n') + cfg.file.flush() + cmdline = [ + 'openssl', 'req', '-new', + self.env['ir.config_parameter'].get_param( + 'letsencrypt.openssl.digest', '-sha256'), + '-key', self.generate_domain_key(domain), + '-subj', '/CN=%s' % domain, '-config', cfg.name, + '-out', csr, + ] + if len(domains) > 1: + cmdline.extend([ + '-reqexts', 'SAN', + ]) + self.call_cmdline(cmdline) + return csr + + @api.model + def cron(self): + domain = urlparse.urlparse( + self.env['ir.config_parameter'].get_param( + 'web.base.url', 'localhost')).netloc + self.validate_domain(domain) + account_key = self.generate_account_key() + csr = self.generate_csr(domain) + acme_challenge = get_challenge_dir() + if not os.path.isdir(acme_challenge): + os.makedirs(acme_challenge) + if self.env.context.get('letsencrypt_dry_run'): + crt_text = 'I\'m a test text' + else: # pragma: no cover + from acme_tiny import get_crt, DEFAULT_CA + crt_text = get_crt( + account_key, csr, acme_challenge, log=_logger, CA=DEFAULT_CA) + with open(os.path.join(get_data_dir(), '%s.crt' % domain), 'w')\ + as crt: + crt.write(crt_text) + chain_cert = urllib2.urlopen( + self.env['ir.config_parameter'].get_param( + 'letsencrypt.chain_certificate_address', + 'https://letsencrypt.org/certs/' + 'lets-encrypt-x3-cross-signed.pem') + ) + crt.write(chain_cert.read()) + chain_cert.close() + _logger.info('wrote %s', crt.name) + reload_cmd = self.env['ir.config_parameter'].get_param( + 'letsencrypt.reload_command', False) + if reload_cmd: + _logger.info('reloading webserver...') + self.call_cmdline(['sh', '-c', reload_cmd]) + else: + _logger.info('no command defined for reloading webserver, please ' + 'do it manually in order to apply new certificate') diff --git a/letsencrypt/static/description/icon.png b/letsencrypt/static/description/icon.png new file mode 100644 index 000000000..cf6e022be Binary files /dev/null and b/letsencrypt/static/description/icon.png differ diff --git a/letsencrypt/tests/__init__.py b/letsencrypt/tests/__init__.py new file mode 100644 index 000000000..69c38ee80 --- /dev/null +++ b/letsencrypt/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import test_letsencrypt diff --git a/letsencrypt/tests/test_letsencrypt.py b/letsencrypt/tests/test_letsencrypt.py new file mode 100644 index 000000000..c0dd72a2f --- /dev/null +++ b/letsencrypt/tests/test_letsencrypt.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from openerp.tests.common import TransactionCase + + +class TestLetsencrypt(TransactionCase): + def test_letsencrypt(self): + from ..hooks import post_init_hook + post_init_hook(self.cr, None) + self.env.ref('letsencrypt.config_parameter_reload').write({ + 'value': '', + }) + self.env['letsencrypt'].with_context(letsencrypt_dry_run=True).cron() diff --git a/requirements.txt b/requirements.txt index d3ce6075a..d40cb3d33 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ python-ldap unidecode +acme_tiny +IPy