From 7e7c489bf59274b409339d895cf607ef3810886c Mon Sep 17 00:00:00 2001 From: Holger Brunn Date: Mon, 1 Feb 2016 13:40:22 +0100 Subject: [PATCH] [ADD] letsencrypt --- letsencrypt/README.rst | 125 ++++++++++++++ letsencrypt/__init__.py | 6 + letsencrypt/__openerp__.py | 25 +++ letsencrypt/controllers/__init__.py | 4 + letsencrypt/controllers/letsencrypt.py | 20 +++ letsencrypt/data/ir_config_parameter.xml | 9 + letsencrypt/data/ir_cron.xml | 14 ++ letsencrypt/hooks.py | 9 + letsencrypt/models/__init__.py | 4 + letsencrypt/models/acme_tiny.py | 199 +++++++++++++++++++++++ letsencrypt/models/letsencrypt.py | 157 ++++++++++++++++++ letsencrypt/static/description/icon.png | Bin 0 -> 2157 bytes letsencrypt/tests/__init__.py | 4 + letsencrypt/tests/test_letsencrypt.py | 11 ++ 14 files changed, 587 insertions(+) create mode 100644 letsencrypt/README.rst create mode 100644 letsencrypt/__init__.py create mode 100644 letsencrypt/__openerp__.py create mode 100644 letsencrypt/controllers/__init__.py create mode 100644 letsencrypt/controllers/letsencrypt.py create mode 100644 letsencrypt/data/ir_config_parameter.xml create mode 100644 letsencrypt/data/ir_cron.xml create mode 100644 letsencrypt/hooks.py create mode 100644 letsencrypt/models/__init__.py create mode 100644 letsencrypt/models/acme_tiny.py create mode 100644 letsencrypt/models/letsencrypt.py create mode 100644 letsencrypt/static/description/icon.png create mode 100644 letsencrypt/tests/__init__.py create mode 100644 letsencrypt/tests/test_letsencrypt.py diff --git a/letsencrypt/README.rst b/letsencrypt/README.rst new file mode 100644 index 000000000..eebce3d77 --- /dev/null +++ b/letsencrypt/README.rst @@ -0,0 +1,125 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :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. + +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:: + + 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; + } + +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 + +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..ffce21bc1 --- /dev/null +++ b/letsencrypt/__openerp__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Let's encrypt", + "version": "8.0.1.0.0", + "author": "Therp BV,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', + ], + }, +} diff --git a/letsencrypt/controllers/__init__.py b/letsencrypt/controllers/__init__.py new file mode 100644 index 000000000..c77cf9b6c --- /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 letsencrypt diff --git a/letsencrypt/controllers/letsencrypt.py b/letsencrypt/controllers/letsencrypt.py new file mode 100644 index 000000000..198fe8f22 --- /dev/null +++ b/letsencrypt/controllers/letsencrypt.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# 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 openerp.tools.misc import file_open + + +class Letsencrypt(http.Controller): + @http.route('/.well-known/acme-challenge/', auth='none') + def acme_challenge(self, filename): + try: + return file_open( + os.path.join('letsencrypt', 'static', 'acme-challenge', + filename) + ).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..13f91f2ed --- /dev/null +++ b/letsencrypt/data/ir_config_parameter.xml @@ -0,0 +1,9 @@ + + + + + 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..9492c6d80 --- /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/acme_tiny.py b/letsencrypt/models/acme_tiny.py new file mode 100644 index 000000000..603554dde --- /dev/null +++ b/letsencrypt/models/acme_tiny.py @@ -0,0 +1,199 @@ +# flake8: noqa +#!/usr/bin/env python +import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging +try: + from urllib.request import urlopen # Python 3 +except ImportError: + from urllib2 import urlopen # Python 2 + +#DEFAULT_CA = "https://acme-staging.api.letsencrypt.org" +DEFAULT_CA = "https://acme-v01.api.letsencrypt.org" + +LOGGER = logging.getLogger(__name__) +LOGGER.addHandler(logging.StreamHandler()) +LOGGER.setLevel(logging.INFO) + +def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA): + # helper function base64 encode for jose spec + def _b64(b): + return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") + + # parse account key to get public key + log.info("Parsing account key...") + proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = proc.communicate() + if proc.returncode != 0: + raise IOError("OpenSSL Error: {0}".format(err)) + pub_hex, pub_exp = re.search( + r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", + out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() + pub_exp = "{0:x}".format(int(pub_exp)) + pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp + header = { + "alg": "RS256", + "jwk": { + "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), + "kty": "RSA", + "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), + }, + } + accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) + thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) + + # helper function make signed requests + def _send_signed_request(url, payload): + payload64 = _b64(json.dumps(payload).encode('utf8')) + protected = copy.deepcopy(header) + protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce'] + protected64 = _b64(json.dumps(protected).encode('utf8')) + proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8')) + if proc.returncode != 0: + raise IOError("OpenSSL Error: {0}".format(err)) + data = json.dumps({ + "header": header, "protected": protected64, + "payload": payload64, "signature": _b64(out), + }) + try: + resp = urlopen(url, data.encode('utf8')) + return resp.getcode(), resp.read() + except IOError as e: + return getattr(e, "code", None), getattr(e, "read", e.__str__)() + + # find domains + log.info("Parsing CSR...") + proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + out, err = proc.communicate() + if proc.returncode != 0: + raise IOError("Error loading {0}: {1}".format(csr, err)) + domains = set([]) + common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8')) + if common_name is not None: + domains.add(common_name.group(1)) + subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) + if subject_alt_names is not None: + for san in subject_alt_names.group(1).split(", "): + if san.startswith("DNS:"): + domains.add(san[4:]) + + # get the certificate domains and expiration + log.info("Registering account...") + code, result = _send_signed_request(CA + "/acme/new-reg", { + "resource": "new-reg", + "agreement": "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf", + }) + if code == 201: + log.info("Registered!") + elif code == 409: + log.info("Already registered!") + else: + raise ValueError("Error registering: {0} {1}".format(code, result)) + + # verify each domain + for domain in domains: + log.info("Verifying {0}...".format(domain)) + + # get new challenge + code, result = _send_signed_request(CA + "/acme/new-authz", { + "resource": "new-authz", + "identifier": {"type": "dns", "value": domain}, + }) + if code != 201: + raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) + + # make the challenge file + challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0] + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + keyauthorization = "{0}.{1}".format(token, thumbprint) + wellknown_path = os.path.join(acme_dir, token) + with open(wellknown_path, "w") as wellknown_file: + wellknown_file.write(keyauthorization) + + # check that the file is in place + wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) + try: + resp = urlopen(wellknown_url) + resp_data = resp.read().decode('utf8').strip() + assert resp_data == keyauthorization + except (IOError, AssertionError): + os.remove(wellknown_path) + raise ValueError("Wrote file to {0}, but couldn't download {1}".format( + wellknown_path, wellknown_url)) + + # notify challenge are met + code, result = _send_signed_request(challenge['uri'], { + "resource": "challenge", + "keyAuthorization": keyauthorization, + }) + if code != 202: + raise ValueError("Error triggering challenge: {0} {1}".format(code, result)) + + # wait for challenge to be verified + while True: + try: + resp = urlopen(challenge['uri']) + challenge_status = json.loads(resp.read().decode('utf8')) + except IOError as e: + raise ValueError("Error checking challenge: {0} {1}".format( + e.code, json.loads(e.read().decode('utf8')))) + if challenge_status['status'] == "pending": + time.sleep(2) + elif challenge_status['status'] == "valid": + log.info("{0} verified!".format(domain)) + os.remove(wellknown_path) + break + else: + raise ValueError("{0} challenge did not pass: {1}".format( + domain, challenge_status)) + + # get the new certificate + log.info("Signing certificate...") + proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + csr_der, err = proc.communicate() + code, result = _send_signed_request(CA + "/acme/new-cert", { + "resource": "new-cert", + "csr": _b64(csr_der), + }) + if code != 201: + raise ValueError("Error signing certificate: {0} {1}".format(code, result)) + + # return signed certificate! + log.info("Certificate signed!") + return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( + "\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64))) + +def main(argv): + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description=textwrap.dedent("""\ + This script automates the process of getting a signed TLS certificate from + Let's Encrypt using the ACME protocol. It will need to be run on your server + and have access to your private account key, so PLEASE READ THROUGH IT! It's + only ~200 lines, so it won't take long. + + ===Example Usage=== + python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed.crt + =================== + + ===Example Crontab Renewal (once per month)=== + 0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed.crt 2>> /var/log/acme_tiny.log + ============================================== + """) + ) + parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") + parser.add_argument("--csr", required=True, help="path to your certificate signing request") + parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") + parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") + parser.add_argument("--ca", default=DEFAULT_CA, help="certificate authority, default is Let's Encrypt") + + args = parser.parse_args(argv) + LOGGER.setLevel(args.quiet or LOGGER.level) + signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca) + sys.stdout.write(signed_crt) + +if __name__ == "__main__": # pragma: no cover + main(sys.argv[1:]) diff --git a/letsencrypt/models/letsencrypt.py b/letsencrypt/models/letsencrypt.py new file mode 100644 index 000000000..2bab77cc4 --- /dev/null +++ b/letsencrypt/models/letsencrypt.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# © 2016 Therp BV +# 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.config import _get_default_datadir +from openerp.tools.misc import file_open +from .acme_tiny import get_crt, DEFAULT_CA + + +DEFAULT_KEY_LENGTH = 4096 +_logger = logging.getLogger(__name__) + + +class Letsencrypt(models.AbstractModel): + _name = 'letsencrypt' + _description = 'Abstract model providing functions for letsencrypt' + + @api.model + def get_data_dir(self): + return os.path.join(_get_default_datadir(), '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 = self.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(self.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): + if domain in ['localhost', '127.0.0.1'] or\ + domain.startswith('10.') or\ + domain.startswith('172.16.') or\ + domain.startswith('192.168.'): + raise exceptions.Warning( + _("Let's encrypt doesn't work with private addresses!")) + + @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(self.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) + manifest, manifest_path = file_open( + 'letsencrypt/__openerp__.py', pathinfo=True) + manifest.close() + acme_challenge = os.path.join( + os.path.dirname(manifest_path), + 'static', 'acme-challenge') + if not os.path.isdir(acme_challenge): + os.makedirs(acme_challenge) + if not self.env.context.get('letsencrypt_fake_cert'): + crt_text = get_crt( + account_key, csr, acme_challenge, log=_logger, CA=DEFAULT_CA) + else: + crt_text = 'I\'m a test text' + with open(os.path.join(self.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-x1-cross-signed.pem') + ) + crt.write(chain_cert.read()) + chain_cert.close() + _logger.info('wrote %s', crt.name) + self.call_cmdline([ + 'sh', '-c', self.env['ir.config_parameter'].get_param( + 'letsencrypt.reload_command', ''), + ]) diff --git a/letsencrypt/static/description/icon.png b/letsencrypt/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cf6e022beb0480e7e51e3507b0d5b8a83c8e533b GIT binary patch literal 2157 zcmV-z2$J`SP)sZ}~N3j%@sJCV~r#o2WDGq-9o7W57k7tW0{_3MQfiCbbx0 zF>;C9*d|j6*$gfX*!EV!w(Q3r3(hDETZK-u44q5|xGCt20y2U1$Gzv-AH7RUxj)X~ zo^$U#?fXsoPv6sX-uL~y@AEwG^S12jWG$O24w;kol?hP1Ef$e+L(** zOW^a#U-ctABy~JtfaDv-7REA;w}ENHifnA=NCW$V*e)xu(_<_ZDPRn=$>P?aHY>P5 zgy|VdFET)k3=jjv$N(`wj0_MX1H=F^a;6e5Zldkn0z3d*47^T=^>QbF8Oa@|JD5yA zkD$y0`hX4&vQeVg1qGrpzJn4QGTSa8!F-LeLh5*WM8at0YWnysXQysvb1AccrG+(F z%8ydqrxaz2#@MX8yqSeX1EY;QvDibZJeqQsw({kI0nr_dPnxN8uvT!Cow|e1dfqd_ zM;V^`k>?cdoJ>>pN00r*cxLh| zU@h6VEDje8h(xgwe(GQ}&%owoZDWCF6Qqt4z%rm`Skcd=o}He-h;a#hyaL?rpqGJ)4Shr7jV=u&F>DEegL*Kii9LYIM--5XK3V z`Ch{CC3PYVyi1%p$;D!}seWUwal7|ZgcT01`Uf^uQqS{VKi-#ImMo!%2=xeSAWJGC z#21RnT*1^Vmpw;671F=~KkDFHa!~}8I)02&3!F|f8hf$%d$9l!L4o}lJnrL^u|Ssa ziEnktQ^axw%I|tnzwiO_C!3R@9+vAJ9td3$u5tUi_(9LB)V3=?v&`#F+lh;F5vQV!>^=t}^v zA-so855hFyYG8a0)vAyPe=by?6!wTt8FlD+!8nev0b!dg=HnD;mIVmw;k*u3JP6bl zgOjY54iRv7E_XkC^9Wx zr=wJ2aiOajZF7Jyucq7Xl?NQ--lp7 zQg8YOzD5T#odOx>5s(QP%4WH^Q$JRE6Oy$W5ncyZ9}<|MTPXFlZw7jQyfYx`(P1;2 z7cz{(=`QZnoz2NQ7#z>Jiay7rjsA9dgyTd0!yC?k*sKQL$?<^)2=j1;@(C_X$Ui;k zfPdvWdI6ZmQ%KIIP0k=Jm1XohT8Bs@N4167gt-g2%H=fu6s4OmtyxYcmb)0;Cq0<0 z$6bXGcIr@Ht|P8dZe|9HfVqf+W2S?OBfN>tc8PG%)xuEj=2KuZ#UZ7LlzdC-+2oI> zGVd4`nG6sE#K-^%ko--*S2ukVn>C;_K+4WuL93e_7K-HT*xfu+b@=nt20lO~MtI^z zc1PoWbeVtY_o}8QY<)akpro$5H}^ox-7`~Od?BJ6=s$5SFyD{pFXRi1%9Ya*`8|xR zSE7f^wV7O0d64(^?GJ_zu}Z!HJ|1!_3MO)6aGbj2w1pZB+5WWAL|G>6H8Ma95F-P` z05LK^3=ks&WRzxpO1f&tLEX&>=;H3aj2Q3Vyq7WO{bmA16|XU7n2GC=Yru{yF|Q#$Dn;NM4L z43MExE4w+`9gTy&V}RsCcHH4hGBVi!$xp3s$a>8+&Hw*&lL3+kHB)`xq|B2e@hAcO zKaD{Jaxr%JH_Tmmu1TtimN4i3jle<^W#w1b5)YmGLFT@b3qhKL+NfaXvLr?QMMFVu zl1VNaAVvm=0g^8tqUT0gHj@pILP@Eis%h|hCf78LLAX1}O$zu1m6W5}Mb)ZbpnL*a z2SxTR`OyUgfQcxpjSn$0KnxHg1H=F^GC+(BkWmgq?8M0LIY|Tvh#VUEW7$3w93Y(+ z>;d+SU>YY8o(hl;iQ1G{5#Xf}2;&IwEjl;7>x*&mx9(Ak>JxLYtxF;8MwP jPL=Ft|Hji8jSl?}T@X4bC?)d700000NkvXXu0mjfhQ{G5 literal 0 HcmV?d00001 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..bd02f6847 --- /dev/null +++ b/letsencrypt/tests/test_letsencrypt.py @@ -0,0 +1,11 @@ +# -*- 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['letsencrypt'].with_context(letsencrypt_fake_cert=True).cron()