From 3e818e8048f0b4839ec4fa9b7d439cc12d19531d Mon Sep 17 00:00:00 2001 From: George Daramouskas Date: Thu, 8 Feb 2018 17:23:09 +0100 Subject: [PATCH 01/22] [ADD] Adds support for Acme V2 --- letsencrypt/README.rst | 59 +-- letsencrypt/__manifest__.py | 21 +- letsencrypt/controllers/main.py | 4 +- letsencrypt/data/ir_config_parameter.xml | 22 +- letsencrypt/data/ir_cron.xml | 2 +- letsencrypt/hooks.py | 2 +- .../migrations/11.0.2.0.0/post-migrate.py | 27 ++ letsencrypt/models/__init__.py | 1 + letsencrypt/models/base_config_settings.py | 75 ++++ letsencrypt/models/letsencrypt.py | 340 +++++++++++------- letsencrypt/tests/__init__.py | 2 +- letsencrypt/tests/test_letsencrypt.py | 109 +++++- letsencrypt/views/base_config_settings.xml | 33 ++ 13 files changed, 517 insertions(+), 180 deletions(-) create mode 100644 letsencrypt/migrations/11.0.2.0.0/post-migrate.py create mode 100644 letsencrypt/models/base_config_settings.py create mode 100644 letsencrypt/views/base_config_settings.xml diff --git a/letsencrypt/README.rst b/letsencrypt/README.rst index b1b419bcd28..46735c0989e 100644 --- a/letsencrypt/README.rst +++ b/letsencrypt/README.rst @@ -23,22 +23,6 @@ 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 ============= @@ -47,15 +31,26 @@ 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 +Futher self-explanatory settings are in Settings -> General Settings. There you +can add further domains to the CSR, add a custom script that updates your DNS +and add a script that will be used to reload your web server (if needed). +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. +Since DNS changes can take some time to propagate, when we respond to a DNS challenge +and the server tries to check our response, it might fail (and probably will). +The solution to this is documented in https://tools.ietf.org/html/rfc8555#section-8.2 +and basically is a `Retry-After` header under which we can instruct the server to +retry the challenge. +At the time these lines were written, Boulder had not implemented this functionality. +This prompted us to use `letsencrypt_backoff` configuration parameter, which is the +amount of minutes this module will try poll the server to retry validating the answer +to our challenge, specifically it is the `deadline` parameter of `poll_and_finalize`. + Usage ===== @@ -75,15 +70,22 @@ For further information, please visit: 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``. +If you want to use multiple domains on your CSR then you have to configure them +from Settings -> General Settings. If you use a wildcard in any of those domains +then letsencrypt will return a DNS challenge. In order for that challenge to be +answered you will need to **either** provide a script (as seen in General Settings) +or install a module that provides support for your VPS. In that module you will +need to create a function in the letsencrypt model with the name +`_respond_challenge_dns_$DNS_PROVIDER` where `$DNS_PROVIDER` is the name of your +provider and can be any string with length greater than zero, and add the name +of your DNS provider in the settings dns_provider selection field. + +In any case if a script path is inserted in the settings page, it will be run +in case you want to update multiple DNS servers. + +A reload command can be set in the Settings as well in case you need to reload +your web server. This by default is ``sudo /usr/sbin/service nginx reload`` -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:: @@ -141,11 +143,12 @@ Contributors * Dave Lasley * Ronald Portier * Ignacio Ibeas +* George Daramouskas ACME implementation ------------------- -* https://github.com/diafygi/acme-tiny/blob/master/acme_tiny.py +* https://github.com/certbot/certbot/tree/0.22.x/acme Icon ---- diff --git a/letsencrypt/__manifest__.py b/letsencrypt/__manifest__.py index 5c2984289b2..ed1c796ed57 100644 --- a/letsencrypt/__manifest__.py +++ b/letsencrypt/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { "name": "Let's encrypt", - "version": "11.0.1.0.0", + "version": "11.0.2.0.0", "author": "Therp BV," "Tecnativa," "Acysos S.L," @@ -11,22 +11,23 @@ "category": "Hidden/Dependency", "summary": "Request SSL certificates from letsencrypt.org", "depends": [ - 'base', + "base_setup", ], "data": [ "data/ir_config_parameter.xml", "data/ir_cron.xml", "demo/ir_cron.xml", + "views/base_config_settings.xml", ], - "post_init_hook": 'post_init_hook', - 'installable': True, + "post_init_hook": "post_init_hook", + "installable": True, "external_dependencies": { - 'bin': [ - 'openssl', - ], - 'python': [ - 'acme_tiny', - 'IPy', + "python": [ + "acme", + "cryptography", + "josepy", + "IPy", + "OpenSSL", ], }, } diff --git a/letsencrypt/controllers/main.py b/letsencrypt/controllers/main.py index 9d9599117e3..bba661710ce 100644 --- a/letsencrypt/controllers/main.py +++ b/letsencrypt/controllers/main.py @@ -5,14 +5,14 @@ import os from odoo import http from odoo.http import request -from ..models.letsencrypt import get_challenge_dir +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 open(os.path.join(get_challenge_dir(), filename)) as key: + with open(os.path.join(_get_challenge_dir(), filename)) as key: return key.read() except IOError: pass diff --git a/letsencrypt/data/ir_config_parameter.xml b/letsencrypt/data/ir_config_parameter.xml index 13f91f2edbc..e7932e7af14 100644 --- a/letsencrypt/data/ir_config_parameter.xml +++ b/letsencrypt/data/ir_config_parameter.xml @@ -1,9 +1,25 @@ - + - + + letsencrypt.reload_command sudo /usr/sbin/service nginx reload + + + letsencrypt_backoff + 3 + + + - + diff --git a/letsencrypt/data/ir_cron.xml b/letsencrypt/data/ir_cron.xml index f2f065cd9e3..aab48cc528b 100644 --- a/letsencrypt/data/ir_cron.xml +++ b/letsencrypt/data/ir_cron.xml @@ -5,7 +5,7 @@ Update letsencrypt certificates code - model.cron() + model._cron() 11 weeks -1 diff --git a/letsencrypt/hooks.py b/letsencrypt/hooks.py index 88ac2e8892e..51477ae97f9 100644 --- a/letsencrypt/hooks.py +++ b/letsencrypt/hooks.py @@ -5,4 +5,4 @@ def post_init_hook(cr, pool): env = api.Environment(cr, SUPERUSER_ID, {}) - env['letsencrypt'].generate_account_key() + env['letsencrypt']._generate_key('account_key') diff --git a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py new file mode 100644 index 00000000000..0340c51af26 --- /dev/null +++ b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py @@ -0,0 +1,27 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import api, SUPERUSER_ID + + +def migrate_altnames(env): + ir_config_parameter = env['ir.config_parameter'] + new_domains = ','.join(ir_config_parameter.search([ + ('key', '=like', 'letsencrypt.altname.%')]).mapped('value')) + ir_config_parameter.set_param('letsencrypt_altnames', new_domains) + ir_config_parameter.search([ + ('key', '=like', 'letsencrypt.altname.%')]).unlink() + + +def migrate_cron(env): + ir_cron = env['ir.cron'] + old_cron = ir_cron.search([ + ('model', '=', 'letsencrypt'), + ('function', '=', 'cron')]) + if old_cron: + old_cron.write({'function': '_cron'}) + + +def migrate(cr, version): + env = api.Environment(cr, SUPERUSER_ID, {}) + migrate_altnames(env) + migrate_cron(env) diff --git a/letsencrypt/models/__init__.py b/letsencrypt/models/__init__.py index 953f1c5d0aa..bb6955a8e93 100644 --- a/letsencrypt/models/__init__.py +++ b/letsencrypt/models/__init__.py @@ -1,3 +1,4 @@ # © 2016 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import letsencrypt +from . import base_config_settings diff --git a/letsencrypt/models/base_config_settings.py b/letsencrypt/models/base_config_settings.py new file mode 100644 index 00000000000..4634e2a2fdd --- /dev/null +++ b/letsencrypt/models/base_config_settings.py @@ -0,0 +1,75 @@ +# Copyright 2018 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import api, fields, models + + +class BaseConfigSettings(models.TransientModel): + _inherit = 'base.config.settings' + + dns_provider = fields.Selection( + [('shell', 'Shell')], + string='DNS Provider', + help='If we need to respond to a DNS challenge we need to add ' + 'a TXT record on your DNS. If you leave this to Shell ' + 'then you signify to the module that this will be taken ' + 'care off by that script written below. ' + 'Generally new modules that are made ' + 'to support various VPS providers add attributes here.', + ) + script = fields.Text( + 'Script', + help='Write your script that will update your DNS TXT records.', + ) + altnames = fields.Text( + default='', + help='Domains for which you want to include on the CSR ' + 'Separate with commas.', + ) + needs_dns_provider = fields.Boolean() + reload_command = fields.Text( + 'Reload Command', + help='Fill this with the command to restart your web server.', + ) + + @api.onchange('altnames') + def onchange_altnames(self): + if self.altnames: + self.needs_dns_provider = any( + '*.' in altname for altname in self.altnames.split(',')) + + @api.model + def default_get(self, field_list): + res = super(BaseConfigSettings, self).default_get(field_list) + ir_config_parameter = self.env['ir.config_parameter'] + res.update({ + 'dns_provider': ir_config_parameter.get_param( + 'letsencrypt_dns_provider'), + 'script': ir_config_parameter.get_param( + 'letsencrypt_script'), + 'altnames': ir_config_parameter.get_param( + 'letsencrypt_altnames'), + 'reload_command': ir_config_parameter.get_param( + 'letsencrypt.reload_command'), + }) + return res + + @api.multi + def set_dns_provider(self): + self.ensure_one() + ir_config_parameter = self.env['ir.config_parameter'] + ir_config_parameter.set_param( + 'letsencrypt_dns_provider', + self.dns_provider) + ir_config_parameter.set_param( + 'letsencrypt_needs_dns_provider', + self.needs_dns_provider) + ir_config_parameter.set_param( + 'letsencrypt_script', + self.script) + ir_config_parameter.set_param( + 'letsencrypt_altnames', + self.altnames) + ir_config_parameter.set_param( + 'letsencrypt.reload_command', + self.reload_command) + return True diff --git a/letsencrypt/models/letsencrypt.py b/letsencrypt/models/letsencrypt.py index 8899f135a8c..6f61f0969b3 100644 --- a/letsencrypt/models/letsencrypt.py +++ b/letsencrypt/models/letsencrypt.py @@ -2,26 +2,50 @@ # © 2016 Antonio Espinosa # © 2018 Ignacio Ibeas # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -import os +from datetime import datetime, timedelta +from os.path import join, isdir, isfile +from os import makedirs +from odoo import _, api, models, exceptions +from odoo.tools import config import logging -import urllib.request import urllib.parse import subprocess -import tempfile -from odoo import _, api, models, exceptions -from odoo.tools import config +import requests +import base64 +import os +import re + +_logger = logging.getLogger(__name__) + +try: + from cryptography.hazmat.backends import default_backend + import josepy as jose + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization, hashes + from acme import client, crypto_util, errors + from acme.messages import Registration, NewRegistration, \ + RegistrationResource + from acme import challenges + import IPy +except ImportError as e: + _logger.debug(e) +WILDCARD = '*.' # as defined in the spec DEFAULT_KEY_LENGTH = 4096 -_logger = logging.getLogger(__name__) +TYPE_CHALLENGE_HTTP = 'http-01' +TYPE_CHALLENGE_DNS = 'dns-01' +V2_STAGING_DIRECTORY_URL = \ + 'https://acme-staging-v02.api.letsencrypt.org/directory' +V2_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory' -def get_data_dir(): - return os.path.join(config.options.get('data_dir'), 'letsencrypt') +def _get_data_dir(): + return join(config.options.get('data_dir'), 'letsencrypt') -def get_challenge_dir(): - return os.path.join(get_data_dir(), 'acme-challenge') +def _get_challenge_dir(): + return join(_get_data_dir(), 'acme-challenge') class Letsencrypt(models.AbstractModel): @@ -29,140 +53,208 @@ class Letsencrypt(models.AbstractModel): _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) - ) - 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 + def _generate_key(self, key_name): + _logger.info('Generating key ' + str(key_name)) + data_dir = _get_data_dir() + if not isdir(data_dir): + makedirs(data_dir) + key_file = join(data_dir, key_name) + if not isfile(key_file): + _logger.info('Generating a new key') + key_json = jose.JWKRSA(key=jose.ComparableRSAKey( + rsa.generate_private_key( + public_exponent=65537, + key_size=DEFAULT_KEY_LENGTH, + backend=default_backend()))) + key = key_json.key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + with open(key_file, 'wb') as _file: + _file.write(key) + return key_file @api.model - def validate_domain(self, domain): + 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 Exception: + except ValueError: 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] - parameter_model = self.env['ir.config_parameter'] - altnames = parameter_model.search( - [('key', 'like', 'letsencrypt.altname.')], - order='key' - ) - for altname in altnames: - domains.append(altname.value) - _logger.info('generating csr for %s', domain) - if len(domains) > 1: - _logger.info('with alternative subjects %s', ','.join(domains[1:])) - config = parameter_model.get_param( - 'letsencrypt.openssl.cnf', '/etc/ssl/openssl.cnf' - ) - csr = os.path.join(get_data_dir(), '%s.csr' % domain) - with tempfile.NamedTemporaryFile(mode='wt') as cfg: - cfg.write(open(config).read()) - if len(domains) > 1: - cfg.write( - '\n[SAN]\nsubjectAltName=' + - ','.join(['DNS:%s' % x for x in domains]) + '\n') - cfg.file.flush() - cmdline = [ - 'openssl', 'req', '-new', - parameter_model.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 + "or local domains!")) @api.model - def cron(self): + def _cron(self): + ir_config_parameter = self.env['ir.config_parameter'] domain = urllib.parse.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 = urllib.request.urlopen( - self.env['ir.config_parameter'].get_param( - 'letsencrypt.chain_certificate_address', - 'https://letsencrypt.org/certs/' - 'lets-encrypt-x3-cross-signed.pem') + self._validate_domain(domain) + # Generate account key + account_key_file = self._generate_key('account.key') + account_key = jose.JWKRSA.load(open(account_key_file).read()) + # Generate domain key + domain_key_file = self._generate_key(domain) + client = self._create_client(account_key) + new_reg = NewRegistration( + key=account_key.public_key(), + terms_of_service_agreed=True) + try: + client.new_account(new_reg) + except errors.ConflictError as e: + reg = Registration(key=account_key.public_key()) + reg_res = RegistrationResource( + body=reg, + uri=e.location, ) - crt.write(str(chain_cert.read())) - chain_cert.close() - _logger.info('wrote %s', crt.name) - reload_cmd = self.env['ir.config_parameter'].sudo().get_param( - 'letsencrypt.reload_command', False) - if reload_cmd: - _logger.info('reloading webserver...') - self.call_cmdline(['sh', '-c', reload_cmd]) + client.query_registration(reg_res) + csr = self._make_csr(account_key, domain_key_file, domain) + authzr = client.new_order(csr) + auth_responded = False + for authorizations in authzr.authorizations: + for challenge in sorted( + authorizations.body.challenges, + key=lambda x: x.chall.typ == TYPE_CHALLENGE_HTTP, + reverse=True): + if challenge.chall.typ == TYPE_CHALLENGE_HTTP: + self._respond_challenge_http(challenge, account_key) + client.answer_challenge( + challenge, + challenges.HTTP01Response()) + auth_responded = True + break + elif challenge.chall.typ == TYPE_CHALLENGE_DNS: + self._respond_challenge_dns( + challenge, + account_key, + authorizations.body.identifier.value, + ) + client.answer_challenge( + challenge, challenges.DNSResponse()) + auth_responded = True + break + if not auth_responded: + raise exceptions.ValidationError( + _('Could not respond to letsencrypt challenges.')) + # let them know we are done and they should check + deadline = datetime.now() + timedelta( + minutes=int( + ir_config_parameter.get_param('letsencrypt_backoff', 3))) + order_resource = client.poll_and_finalize(authzr, deadline) + with open(join(_get_data_dir(), '%s.crt' % domain), 'w') as crt: + crt.write(order_resource.fullchain_pem) + _logger.info('SUCCESS: Certificate saved :%s', crt.name) + reload_cmd = ir_config_parameter.get_param( + 'letsencrypt.reload_command', False) + if reload_cmd: + self._call_cmdline(['sh', '-c', reload_cmd]) + else: + _logger.warning("No reload command defined.") + + def _create_client(self, account_key): + net = client.ClientNetwork(account_key) + if config['test_enable']: + directory_url = V2_STAGING_DIRECTORY_URL else: - _logger.info('no command defined for reloading webserver, please ' - 'do it manually in order to apply new certificate') + directory_url = V2_DIRECTORY_URL + directory_json = requests.get(directory_url).json() + return client.ClientV2(directory_json, net) + + def _cascade_domains(self, altnames): + """ Given an list of domains containing one or more wildcard domains + the following are performed: + 1) for every wildcard domain: + a) gets the index of it's wildcard characters + b) if there are non wildcard domain names that are the same + after the index of the current wildcard name remove them. + 2) when done, return the modified altnames + """ + for altname in filter(lambda x: WILDCARD in x, altnames): + pat = re.compile('^.*' + altname.replace(WILDCARD, '') + '.*$') + for _altname in filter(lambda x: WILDCARD not in x, altnames): + if pat.search(_altname): + altnames.remove(_altname) + return altnames + + def _make_csr(self, account_key, domain_key_file, domain): + parameter_model = self.env['ir.config_parameter'] + altnames = parameter_model.get_param('letsencrypt_altnames') + if altnames: + altnames = re.split(',|\n| |;', altnames) + valid_domains = altnames + [domain] + valid_domains = self._cascade_domains(valid_domains) + else: + valid_domains = [domain] + _logger.info( + 'Making CSR for the following domains: ' + str(valid_domains)) + return crypto_util.make_csr( + open(domain_key_file).read(), valid_domains) + + def _respond_challenge_http(self, challenge, account_key): + """ + Respond to the HTTP challenge by writing the file to serve. + """ + challenge_dir = _get_challenge_dir() + if not isdir(challenge_dir): + makedirs(challenge_dir) + token = base64.urlsafe_b64encode(challenge.token) + challenge_file = join(_get_challenge_dir(), '%s' % token.rstrip('=')) + with open(challenge_file, 'wb') as challenge_file: + challenge_file.write(token.rstrip('=') + '.' + jose.b64encode( + account_key.thumbprint(hash_function=hashes.SHA256)).decode()) + + def _respond_challenge_dns(self, challenge, account_key, domain): + """ + Respond to the DNS challenge by creating the DNS record + on the provider. + """ + letsencrypt_dns_function = '_respond_challenge_dns_' + \ + self.env['ir.config_parameter'].get_param( + 'letsencrypt_dns_provider') + getattr(self, letsencrypt_dns_function)(challenge, account_key, domain) + + @api.model + def _call_cmdline(self, cmdline, env=None, shell=False): + process = subprocess.Popen( + cmdline, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + shell=shell, + ) + stdout, stderr = process.communicate() + if process.returncode: + raise exceptions.Warning(_( + 'Error calling %s: %s %s %s' % ( + cmdline, + str(process.returncode) + ' '.join(cmdline), + stdout, + stderr, + ))) + + @api.model + def _respond_challenge_dns_shell(self, challenge, account_key, domain): + script_str = self.env['ir.config_parameter'].get_param( + 'letsencrypt_script') + if script_str: + env = os.environ + env.update( + LETSENCRYPT_DNS_CHALLENGE=jose.encode_b64jose( + challenge.chall.token), + LETSENCRYPT_DNS_DOMAIN=domain, + ) + self.env['letsencrypt']._call_cmdline( + script_str, + env=env, + shell=True, + ) diff --git a/letsencrypt/tests/__init__.py b/letsencrypt/tests/__init__.py index c5eb768cad8..4a7abea1fc7 100644 --- a/letsencrypt/tests/__init__.py +++ b/letsencrypt/tests/__init__.py @@ -1,3 +1,3 @@ -# © 2016 Therp BV +# Copyright 2018 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 index 60616d9af7e..a9fce6f410a 100644 --- a/letsencrypt/tests/test_letsencrypt.py +++ b/letsencrypt/tests/test_letsencrypt.py @@ -1,13 +1,102 @@ -# © 2016 Therp BV +# Copyright 2018 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo.tests.common import TransactionCase +from odoo.tests import SingleTransactionCase +from ..models.letsencrypt import _get_data_dir, WILDCARD +from os import path +import shutil +import mock +import os -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() +class TestLetsencrypt(SingleTransactionCase): + + post_install = True + at_install = False + + def setUp(self): + super(TestLetsencrypt, self).setUp() + + def test_config_settings(self): + settings_model = self.env['base.config.settings'] + letsencrypt_model = self.env['letsencrypt'] + settings = settings_model.create({ + 'dns_provider': 'shell', + 'script': 'touch /tmp/.letsencrypt_test', + 'altnames': + 'test.example.com', + 'reload_command': 'echo', + }) + self.env['ir.config_parameter'].set_param( + 'web.base.url', 'http://www.example.com') + settings.set_dns_provider() + setting_vals = settings.default_get([]) + self.assertEquals(setting_vals['dns_provider'], 'shell') + letsencrypt_model._call_cmdline(setting_vals['script'], shell=True) + self.assertEquals(path.exists('/tmp/.letsencrypt_test'), True) + self.assertEquals( + setting_vals['altnames'], + settings.altnames, + ) + self.assertEquals(setting_vals['reload_command'], 'echo') + settings.onchange_altnames() + self.assertEquals(settings.needs_dns_provider, False) + settings.unlink() + + def new_order(self, typ): + if typ not in ['http-01', 'dns-01']: + raise ValueError + authzr = mock.Mock + order_resource = mock.Mock + order_resource.fullchain_pem = 'test' + authorization = mock.Mock + body = mock.Mock + challenge = mock.Mock + challenge.chall = mock.Mock + challenge.chall.typ = typ + challenge.chall.token = 'a_token' + body.challenges = [challenge] + authorization.body = body + authzr.authorizations = [authorization] + return authzr + + def poll(self, deadline): + order_resource = mock.Mock + order_resource.fullchain_pem = 'chain' + return order_resource + + @mock.patch('acme.client.ClientV2.answer_challenge') + @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=poll) + def test_http_challenge(self, poll, answer_challenge): + letsencrypt = self.env['letsencrypt'] + self.env['ir.config_parameter'].set_param( + 'web.base.url', 'http://www.example.com') + settings = self.env['base.config.settings'] + settings.create({ + 'altnames': 'test.example.com', + }).set_dns_provider() + letsencrypt._cron() + poll.assert_called() + self.assertTrue( + open(path.join(_get_data_dir(), 'www.example.com.crt')).read(), + ) + os.remove(path.join(_get_data_dir(), 'www.example.com.crt')) + + @mock.patch('acme.client.ClientV2.answer_challenge') + @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=poll) + def test_dns_challenge(self, poll, answer_challenge): + letsencrypt = self.env['letsencrypt'] + settings = self.env['base.config.settings'] + settings.create({ + 'dns_provider': 'shell', + 'script': 'echo', + 'altnames': WILDCARD + 'example.com', + }).set_dns_provider() + letsencrypt._cron() + poll.assert_called() + self.assertTrue( + open(path.join(_get_data_dir(), 'www.example.com.crt')).read(), + ) + + def tearDown(self): + super(TestLetsencrypt, self).tearDown() + shutil.rmtree(_get_data_dir(), ignore_errors=True) diff --git a/letsencrypt/views/base_config_settings.xml b/letsencrypt/views/base_config_settings.xml new file mode 100644 index 00000000000..6637a7cafce --- /dev/null +++ b/letsencrypt/views/base_config_settings.xml @@ -0,0 +1,33 @@ + + + + Letsencrypt base config settings + base.config.settings + + + + + + + + + + + + + + + + + From b2aa893f6ffd72af56a0664fe547725640e9cbbf Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Fri, 15 Nov 2019 17:19:36 +0100 Subject: [PATCH 02/22] [IMP] Refactor and debug Let's Encrypt ACMEv2 support --- letsencrypt/README.rst | 106 ++-- letsencrypt/__manifest__.py | 9 +- letsencrypt/data/ir_config_parameter.xml | 2 +- letsencrypt/data/ir_cron.xml | 6 +- letsencrypt/demo/ir_cron.xml | 12 +- letsencrypt/hooks.py | 2 +- .../migrations/11.0.2.0.0/post-migrate.py | 31 +- letsencrypt/models/base_config_settings.py | 164 ++++-- letsencrypt/models/letsencrypt.py | 550 ++++++++++++------ letsencrypt/readme/CONFIGURE.rst | 24 + letsencrypt/readme/CONTRIBUTORS.rst | 6 + letsencrypt/readme/CREDITS.rst | 9 + letsencrypt/readme/DESCRIPTION.rst | 2 + letsencrypt/readme/INSTALL.rst | 10 + letsencrypt/readme/USAGE.rst | 63 ++ letsencrypt/static/description/index.html | 544 +++++++++++++++++ letsencrypt/tests/test_letsencrypt.py | 377 +++++++++--- letsencrypt/views/base_config_settings.xml | 24 +- requirements.txt | 4 + 19 files changed, 1575 insertions(+), 370 deletions(-) create mode 100644 letsencrypt/readme/CONFIGURE.rst create mode 100644 letsencrypt/readme/CONTRIBUTORS.rst create mode 100644 letsencrypt/readme/CREDITS.rst create mode 100644 letsencrypt/readme/DESCRIPTION.rst create mode 100644 letsencrypt/readme/INSTALL.rst create mode 100644 letsencrypt/readme/USAGE.rst create mode 100644 letsencrypt/static/description/index.html diff --git a/letsencrypt/README.rst b/letsencrypt/README.rst index 46735c0989e..dba2941dfb4 100644 --- a/letsencrypt/README.rst +++ b/letsencrypt/README.rst @@ -1,14 +1,38 @@ -.. 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 +============= +Let's Encrypt +============= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/10.0/letsencrypt + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-10-0/server-tools-10-0-letsencrypt + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/149/10.0 + :alt: Try me on Runbot -============================================= -Request SSL certificates from letsencrypt.org -============================================= +|badge1| |badge2| |badge3| |badge4| |badge5| This module was written to have your Odoo installation request SSL certificates from https://letsencrypt.org automatically. +**Table of contents** + +.. contents:: + :local: + Installation ============ @@ -23,7 +47,6 @@ the SSL version. After installation, trigger the cronjob `Update letsencrypt certificates` and watch your log for messages. - Configuration ============= @@ -34,49 +57,45 @@ the module doesn't request anything. Futher self-explanatory settings are in Settings -> General Settings. There you can add further domains to the CSR, add a custom script that updates your DNS and add a script that will be used to reload your web server (if needed). -The amount of domains that can be added are subject to `rate -limiting `_. +The number of domains that can be added to a certificate is +`capped at 100 `_. A wildcard +certificate can be used to avoid that limit. 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. +they must have an entry for ``.well-known/acme-challenge`` pointing to +``$datadir/letsencrypt/acme-challenge`` of your odoo instance. Since DNS changes can take some time to propagate, when we respond to a DNS challenge and the server tries to check our response, it might fail (and probably will). The solution to this is documented in https://tools.ietf.org/html/rfc8555#section-8.2 -and basically is a `Retry-After` header under which we can instruct the server to +and basically is a ``Retry-After`` header under which we can instruct the server to retry the challenge. At the time these lines were written, Boulder had not implemented this functionality. -This prompted us to use `letsencrypt_backoff` configuration parameter, which is the +This prompted us to use ``letsencrypt.backoff`` configuration parameter, which is the amount of minutes this module will try poll the server to retry validating the answer -to our challenge, specifically it is the `deadline` parameter of `poll_and_finalize`. +to our challenge, specifically it is the ``deadline`` parameter of ``poll_and_finalize``. Usage ===== The module sets up a cronjob that requests and renews certificates automatically. +Certificates are renewed a month before they expire. Renewal is then attempted +every day until it succeeds. + 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 -====================== +~~~~~~~~~~~~~~~~~~~~~~ If you want to use multiple domains on your CSR then you have to configure them from Settings -> General Settings. If you use a wildcard in any of those domains then letsencrypt will return a DNS challenge. In order for that challenge to be answered you will need to **either** provide a script (as seen in General Settings) -or install a module that provides support for your VPS. In that module you will -need to create a function in the letsencrypt model with the name -`_respond_challenge_dns_$DNS_PROVIDER` where `$DNS_PROVIDER` is the name of your +or install a module that provides support for your DNS provider. In that module +you will need to create a function in the letsencrypt model with the name +``_respond_challenge_dns_$DNS_PROVIDER`` where ``$DNS_PROVIDER`` is the name of your provider and can be any string with length greater than zero, and add the name of your DNS provider in the settings dns_provider selection field. @@ -123,20 +142,27 @@ 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 `_. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. Credits ======= +Authors +~~~~~~~ + +* Therp BV +* Tecnativa + Contributors ------------- +~~~~~~~~~~~~ * Holger Brunn * Antonio Espinosa @@ -144,28 +170,34 @@ Contributors * Ronald Portier * Ignacio Ibeas * George Daramouskas +* Jan Verbeek + +Other credits +~~~~~~~~~~~~~ ACME implementation -------------------- +~~~~~~~~~~~~~~~~~~~ * https://github.com/certbot/certbot/tree/0.22.x/acme Icon ----- +~~~~ * https://helloworld.letsencrypt.org -Maintainer ----------- +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. .. 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. +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/letsencrypt/__manifest__.py b/letsencrypt/__manifest__.py index ed1c796ed57..e3f8f9fdca8 100644 --- a/letsencrypt/__manifest__.py +++ b/letsencrypt/__manifest__.py @@ -1,7 +1,7 @@ # © 2016 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { - "name": "Let's encrypt", + "name": "Let's Encrypt", "version": "11.0.2.0.0", "author": "Therp BV," "Tecnativa," @@ -16,18 +16,19 @@ "data": [ "data/ir_config_parameter.xml", "data/ir_cron.xml", - "demo/ir_cron.xml", "views/base_config_settings.xml", ], + "demo": [ + "demo/ir_cron.xml", + ], "post_init_hook": "post_init_hook", "installable": True, "external_dependencies": { "python": [ "acme", "cryptography", + "dns", "josepy", - "IPy", - "OpenSSL", ], }, } diff --git a/letsencrypt/data/ir_config_parameter.xml b/letsencrypt/data/ir_config_parameter.xml index e7932e7af14..9637cbfd86f 100644 --- a/letsencrypt/data/ir_config_parameter.xml +++ b/letsencrypt/data/ir_config_parameter.xml @@ -14,7 +14,7 @@ id="letsencrypt_backoff" model="ir.config_parameter" forcecreate="True"> - letsencrypt_backoff + letsencrypt.backoff 3 - Update letsencrypt certificates + Check Let's Encrypt certificates code model._cron() - 11 - weeks + days + 1 -1 diff --git a/letsencrypt/demo/ir_cron.xml b/letsencrypt/demo/ir_cron.xml index e4451aa5946..926e80d367a 100644 --- a/letsencrypt/demo/ir_cron.xml +++ b/letsencrypt/demo/ir_cron.xml @@ -1,8 +1,6 @@ - - - - - - - + + + + + diff --git a/letsencrypt/hooks.py b/letsencrypt/hooks.py index 51477ae97f9..087deaf779d 100644 --- a/letsencrypt/hooks.py +++ b/letsencrypt/hooks.py @@ -5,4 +5,4 @@ def post_init_hook(cr, pool): env = api.Environment(cr, SUPERUSER_ID, {}) - env['letsencrypt']._generate_key('account_key') + env['letsencrypt']._get_key('account.key') diff --git a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py index 0340c51af26..57a6a9c3aa6 100644 --- a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py +++ b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py @@ -4,21 +4,28 @@ def migrate_altnames(env): - ir_config_parameter = env['ir.config_parameter'] - new_domains = ','.join(ir_config_parameter.search([ - ('key', '=like', 'letsencrypt.altname.%')]).mapped('value')) - ir_config_parameter.set_param('letsencrypt_altnames', new_domains) - ir_config_parameter.search([ - ('key', '=like', 'letsencrypt.altname.%')]).unlink() + config = env["ir.config_parameter"] + existing = config.search([("key", "=like", "letsencrypt.altname.%")]) + new_domains = "\n".join(existing.mapped("value")) + config.set_param("letsencrypt.altnames", new_domains) + existing.unlink() def migrate_cron(env): - ir_cron = env['ir.cron'] - old_cron = ir_cron.search([ - ('model', '=', 'letsencrypt'), - ('function', '=', 'cron')]) - if old_cron: - old_cron.write({'function': '_cron'}) + # Any interval that was appropriate for the old version is inappropriate + # for the new one, so it's ok to clobber it. + # But tweaking it afterwards is fine, so noupdate="1" still makes sense. + jobs = ( + env["ir.cron"] + .with_context(active_test=False) + .search([("model", "=", "letsencrypt"), ("function", "=", "cron")]) + ) + if not jobs: + # ir.cron._try_lock doesn't handle empty recordsets well + return + jobs.write( + {"function": "_cron", "interval_type": "days", "interval_number": "1"} + ) def migrate(cr, version): diff --git a/letsencrypt/models/base_config_settings.py b/letsencrypt/models/base_config_settings.py index 4634e2a2fdd..2f89183915f 100644 --- a/letsencrypt/models/base_config_settings.py +++ b/letsencrypt/models/base_config_settings.py @@ -1,75 +1,131 @@ # Copyright 2018 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import api, fields, models + +from odoo import api, exceptions, fields, models + + +DNS_SCRIPT_DEFAULT = """# Write your script here +# It should create a TXT record of $LETSENCRYPT_DNS_CHALLENGE +# on _acme-challenge.$LETSENCRYPT_DNS_DOMAIN +""" class BaseConfigSettings(models.TransientModel): _inherit = 'base.config.settings' - dns_provider = fields.Selection( - [('shell', 'Shell')], - string='DNS Provider', - help='If we need to respond to a DNS challenge we need to add ' - 'a TXT record on your DNS. If you leave this to Shell ' - 'then you signify to the module that this will be taken ' - 'care off by that script written below. ' - 'Generally new modules that are made ' - 'to support various VPS providers add attributes here.', + letsencrypt_altnames = fields.Text( + string="Domain names", + default='', + help=( + 'Additional domains to include on the CSR. ' + 'Separate with commas or newlines.' + ), ) - script = fields.Text( - 'Script', - help='Write your script that will update your DNS TXT records.', + letsencrypt_dns_provider = fields.Selection( + selection=[('shell', 'Shell script')], + string='DNS provider', + help=( + 'For wildcard certificates we need to add a TXT record on your ' + 'DNS. If you set this to "Shell script" you can enter a shell ' + 'script. Other options can be added by installing additional ' + 'modules.' + ), ) - altnames = fields.Text( - default='', - help='Domains for which you want to include on the CSR ' - 'Separate with commas.', + letsencrypt_dns_shell_script = fields.Text( + string='DNS update script', + help=( + 'Write a shell script that will update your DNS TXT records. ' + 'You can use the $LETSENCRYPT_DNS_CHALLENGE and ' + '$LETSENCRYPT_DNS_DOMAIN variables.' + ), + default=DNS_SCRIPT_DEFAULT, ) - needs_dns_provider = fields.Boolean() - reload_command = fields.Text( - 'Reload Command', + letsencrypt_needs_dns_provider = fields.Boolean() + letsencrypt_reload_command = fields.Text( + string='Server reload command', help='Fill this with the command to restart your web server.', ) + letsencrypt_testing_mode = fields.Boolean( + string='Use testing server', + help=( + "Use the Let's Encrypt staging server, which has higher rate " + "limits but doesn't create valid certificates." + ), + ) + letsencrypt_prefer_dns = fields.Boolean( + string="Prefer DNS validation", + help=( + "Validate through DNS even when HTTP validation is possible. " + "Use this if your Odoo instance isn't publicly accessible.", + ) + ) - @api.onchange('altnames') - def onchange_altnames(self): - if self.altnames: - self.needs_dns_provider = any( - '*.' in altname for altname in self.altnames.split(',')) + @api.onchange('letsencrypt_altnames', 'letsencrypt_prefer_dns') + def letsencrypt_check_dns_required(self): + altnames = self.letsencrypt_altnames or '' + self.letsencrypt_needs_dns_provider = ( + "*." in altnames or self.letsencrypt_prefer_dns + ) @api.model - def default_get(self, field_list): - res = super(BaseConfigSettings, self).default_get(field_list) - ir_config_parameter = self.env['ir.config_parameter'] - res.update({ - 'dns_provider': ir_config_parameter.get_param( - 'letsencrypt_dns_provider'), - 'script': ir_config_parameter.get_param( - 'letsencrypt_script'), - 'altnames': ir_config_parameter.get_param( - 'letsencrypt_altnames'), - 'reload_command': ir_config_parameter.get_param( - 'letsencrypt.reload_command'), - }) + def default_get(self, fields_list): + res = super(BaseConfigSettings, self).default_get(fields_list) + get_param = self.env['ir.config_parameter'].get_param + res.update( + { + 'letsencrypt_dns_provider': get_param( + 'letsencrypt.dns_provider' + ), + 'letsencrypt_dns_shell_script': get_param( + 'letsencrypt.dns_shell_script', DNS_SCRIPT_DEFAULT + ), + 'letsencrypt_altnames': get_param('letsencrypt.altnames', ''), + 'letsencrypt_reload_command': get_param( + 'letsencrypt.reload_command' + ), + 'letsencrypt_needs_dns_provider': ( + '*.' in get_param('letsencrypt.altnames', '') + ), + 'letsencrypt_testing_mode': ( + get_param('letsencrypt.testing_mode', 'False') == 'True' + ), + 'letsencrypt_prefer_dns': ( + get_param('letsencrypt.prefer_dns', 'False') == 'True' + ), + } + ) return res @api.multi def set_dns_provider(self): self.ensure_one() - ir_config_parameter = self.env['ir.config_parameter'] - ir_config_parameter.set_param( - 'letsencrypt_dns_provider', - self.dns_provider) - ir_config_parameter.set_param( - 'letsencrypt_needs_dns_provider', - self.needs_dns_provider) - ir_config_parameter.set_param( - 'letsencrypt_script', - self.script) - ir_config_parameter.set_param( - 'letsencrypt_altnames', - self.altnames) - ir_config_parameter.set_param( - 'letsencrypt.reload_command', - self.reload_command) + self.letsencrypt_check_dns_required() + + if self.letsencrypt_dns_provider == 'shell': + lines = [ + line.strip() + for line in self.letsencrypt_dns_shell_script.split('\n') + ] + if all(line == '' or line.startswith('#') for line in lines): + raise exceptions.ValidationError( + "You didn't write a DNS update script!" + ) + + set_param = self.env['ir.config_parameter'].set_param + set_param('letsencrypt.dns_provider', self.letsencrypt_dns_provider) + set_param( + 'letsencrypt.dns_shell_script', self.letsencrypt_dns_shell_script + ) + set_param('letsencrypt.altnames', self.letsencrypt_altnames) + set_param( + 'letsencrypt.reload_command', self.letsencrypt_reload_command + ) + set_param( + 'letsencrypt.testing_mode', + 'True' if self.letsencrypt_testing_mode else 'False', + ) + set_param( + 'letsencrypt.prefer_dns', + 'True' if self.letsencrypt_prefer_dns else 'False', + ) return True diff --git a/letsencrypt/models/letsencrypt.py b/letsencrypt/models/letsencrypt.py index 6f61f0969b3..85acdabc39d 100644 --- a/letsencrypt/models/letsencrypt.py +++ b/letsencrypt/models/letsencrypt.py @@ -2,50 +2,77 @@ # © 2016 Antonio Espinosa # © 2018 Ignacio Ibeas # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from datetime import datetime, timedelta -from os.path import join, isdir, isfile -from os import makedirs -from odoo import _, api, models, exceptions -from odoo.tools import config -import logging -import urllib.parse -import subprocess -import requests + import base64 +import collections +import logging import os import re +import subprocess +import time +import urlparse -_logger = logging.getLogger(__name__) +from datetime import datetime, timedelta + +import requests +from odoo import _, api, models +from odoo.exceptions import UserError +from odoo.tools import config + +_logger = logging.getLogger(__name__) try: + import acme.challenges + import acme.client + import acme.crypto_util + import acme.errors + import acme.messages + + from cryptography import x509 from cryptography.hazmat.backends import default_backend - import josepy as jose + from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.hazmat.primitives import serialization, hashes - from acme import client, crypto_util, errors - from acme.messages import Registration, NewRegistration, \ - RegistrationResource - from acme import challenges - import IPy + + import dns.resolver + + import josepy except ImportError as e: _logger.debug(e) - WILDCARD = '*.' # as defined in the spec DEFAULT_KEY_LENGTH = 4096 TYPE_CHALLENGE_HTTP = 'http-01' TYPE_CHALLENGE_DNS = 'dns-01' -V2_STAGING_DIRECTORY_URL = \ +V2_STAGING_DIRECTORY_URL = ( 'https://acme-staging-v02.api.letsencrypt.org/directory' +) V2_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory' +LOCAL_DOMAINS = { + 'localhost', + 'localhost.localdomain', + 'localhost6', + 'localhost6.localdomain6', + 'ip6-localhost', + 'ip6-loopback', +} + +DNSUpdate = collections.namedtuple( + "DNSUpdate", ("challenge", "domain", "token") +) def _get_data_dir(): - return join(config.options.get('data_dir'), 'letsencrypt') + dir_ = os.path.join(config.options.get('data_dir'), 'letsencrypt') + if not os.path.isdir(dir_): + os.makedirs(dir_) + return dir_ def _get_challenge_dir(): - return join(_get_data_dir(), 'acme-challenge') + dir_ = os.path.join(_get_data_dir(), 'acme-challenge') + if not os.path.isdir(dir_): + os.makedirs(dir_) + return dir_ class Letsencrypt(models.AbstractModel): @@ -53,208 +80,399 @@ class Letsencrypt(models.AbstractModel): _description = 'Abstract model providing functions for letsencrypt' @api.model - def _generate_key(self, key_name): - _logger.info('Generating key ' + str(key_name)) - data_dir = _get_data_dir() - if not isdir(data_dir): - makedirs(data_dir) - key_file = join(data_dir, key_name) - if not isfile(key_file): - _logger.info('Generating a new key') - key_json = jose.JWKRSA(key=jose.ComparableRSAKey( - rsa.generate_private_key( - public_exponent=65537, - key_size=DEFAULT_KEY_LENGTH, - backend=default_backend()))) - key = key_json.key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption()) - with open(key_file, 'wb') as _file: - _file.write(key) - return key_file + def _generate_key(self): + """Generate an entirely new key.""" + return rsa.generate_private_key( + public_exponent=65537, + key_size=DEFAULT_KEY_LENGTH, + backend=default_backend(), + ).private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + @api.model + def _get_key(self, key_name): + """Get a key for a filename, generating if if it doesn't exist.""" + key_file = os.path.join(_get_data_dir(), key_name) + if not os.path.isfile(key_file): + _logger.info("Generating new key %s", key_name) + key_bytes = self._generate_key() + try: + with open(key_file, 'wb') as file_: + os.fchmod(file_.fileno(), 0o600) + file_.write(key_bytes) + except BaseException: + # An incomplete file would block generation of a new one + if os.path.isfile(key_file): + os.remove(key_file) + raise + else: + _logger.info("Getting existing key %s", key_name) + with open(key_file, 'rb') as file_: + key_bytes = file_.read() + return key_bytes @api.model def _validate_domain(self, domain): - local_domains = [ - 'localhost', 'localhost.localdomain', 'localhost6', - 'localhost6.localdomain6' - ] + """Validate that a domain is publicly accessible.""" + if ':' in domain or all( + char.isdigit() or char == '.' for char in domain + ): + raise UserError( + _("Domain %s: Let's Encrypt doesn't support IP addresses!") + % domain + ) - def _ip_is_private(address): - try: - ip = IPy.IP(address) - except ValueError: - return False - return ip.iptype() == 'PRIVATE' + if domain in LOCAL_DOMAINS or '.' not in domain: + raise UserError( + _("Domain %s: Let's encrypt doesn't work with local domains!") + % domain + ) + + @api.model + def _should_run(self, cert_file, domains): + """Inspect the existing certificate to see if action is necessary.""" + domains = set(domains) + + if not os.path.isfile(cert_file): + _logger.info("No existing certificate found, creating a new one") + return True + + with open(cert_file, 'rb') as file_: + cert = x509.load_pem_x509_certificate( + file_.read(), default_backend() + ) + expiry = cert.not_valid_after + remaining = expiry - datetime.now() + if remaining < timedelta(): + _logger.warning( + "Certificate expired on %s, which was %d days ago!", + expiry, + -remaining.days, + ) + _logger.info("Renewing certificate now.") + return True + if remaining < timedelta(days=30): + _logger.info( + "Certificate expires on %s, which is in %d days, renewing it", + expiry, + remaining.days, + ) + return True + + # Should be a single name, but this is how the API works + names = { + entry.value + for entry in cert.subject.get_attributes_for_oid( + x509.oid.NameOID.COMMON_NAME + ) + } + try: + names.update( + cert.extensions.get_extension_for_oid( + x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME + ).value.get_values_for_type(x509.DNSName) + ) + except x509.extensions.ExtensionNotFound: + pass - 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!")) + missing = domains - names + if missing: + _logger.info( + "Found new domains %s, requesting new certificate", + ', '.join(missing), + ) + return True + + _logger.info( + "Certificate expires on %s, which is in %d days, no action needed", + expiry, + remaining.days, + ) + return False @api.model def _cron(self): ir_config_parameter = self.env['ir.config_parameter'] - domain = urllib.parse.urlparse( - self.env['ir.config_parameter'].get_param( - 'web.base.url', 'localhost')).netloc - self._validate_domain(domain) - # Generate account key - account_key_file = self._generate_key('account.key') - account_key = jose.JWKRSA.load(open(account_key_file).read()) - # Generate domain key - domain_key_file = self._generate_key(domain) + base_url = ir_config_parameter.get_param('web.base.url', 'localhost') + domain = urlparse.urlparse(base_url).hostname + cert_file = os.path.join(_get_data_dir(), '%s.crt' % domain) + + domains = self._cascade_domains([domain] + self._get_altnames()) + for dom in domains: + self._validate_domain(dom) + + if not self._should_run(cert_file, domains): + return + + account_key = josepy.JWKRSA.load(self._get_key('account.key')) + domain_key = self._get_key('%s.key' % domain) + client = self._create_client(account_key) - new_reg = NewRegistration( - key=account_key.public_key(), - terms_of_service_agreed=True) + new_reg = acme.messages.NewRegistration( + key=account_key.public_key(), terms_of_service_agreed=True + ) try: client.new_account(new_reg) - except errors.ConflictError as e: - reg = Registration(key=account_key.public_key()) - reg_res = RegistrationResource( - body=reg, - uri=e.location, + _logger.info("Successfully registered.") + except acme.errors.ConflictError as err: + reg = acme.messages.Registration(key=account_key.public_key()) + reg_res = acme.messages.RegistrationResource( + body=reg, uri=err.location ) client.query_registration(reg_res) - csr = self._make_csr(account_key, domain_key_file, domain) + _logger.info("Reusing existing account.") + + _logger.info('Making CSR for the following domains: %s', domains) + csr = acme.crypto_util.make_csr( + private_key_pem=domain_key, domains=domains + ) authzr = client.new_order(csr) - auth_responded = False + + # For each requested domain name we receive a list of challenges. + # We only have to do one from each list. + # HTTP challenges are the easiest, so do one of those if possible. + # We can do DNS challenges too. There are other types that we don't + # support. + pending_responses = [] + + prefer_dns = ( + self.env["ir.config_parameter"].get_param("letsencrypt.prefer_dns") + == "True" + ) for authorizations in authzr.authorizations: - for challenge in sorted( - authorizations.body.challenges, - key=lambda x: x.chall.typ == TYPE_CHALLENGE_HTTP, - reverse=True): + http_challenges = [ + challenge + for challenge in authorizations.body.challenges + if challenge.chall.typ == TYPE_CHALLENGE_HTTP + ] + other_challenges = [ + challenge + for challenge in authorizations.body.challenges + if challenge.chall.typ != TYPE_CHALLENGE_HTTP + ] + if prefer_dns: + ordered_challenges = other_challenges + http_challenges + else: + ordered_challenges = http_challenges + other_challenges + for challenge in ordered_challenges: if challenge.chall.typ == TYPE_CHALLENGE_HTTP: self._respond_challenge_http(challenge, account_key) client.answer_challenge( - challenge, - challenges.HTTP01Response()) - auth_responded = True + challenge, acme.challenges.HTTP01Response() + ) break elif challenge.chall.typ == TYPE_CHALLENGE_DNS: - self._respond_challenge_dns( - challenge, - account_key, - authorizations.body.identifier.value, + domain = authorizations.body.identifier.value + token = challenge.validation(account_key) + self._respond_challenge_dns(domain, token) + # We delay this because we wait for each domain. + # That takes less time if they've all already been changed. + pending_responses.append( + DNSUpdate( + challenge=challenge, domain=domain, token=token + ) ) - client.answer_challenge( - challenge, challenges.DNSResponse()) - auth_responded = True break - if not auth_responded: - raise exceptions.ValidationError( - _('Could not respond to letsencrypt challenges.')) + else: + raise UserError( + _('Could not respond to letsencrypt challenges.') + ) + + if pending_responses: + for update in pending_responses: + self._wait_for_record(update.domain, update.token) + # 1 minute was not always enough during testing, even once records + # were visible locally + _logger.info( + "All TXT records found, waiting 5 minutes more to make sure." + ) + time.sleep(300) + for update in pending_responses: + client.answer_challenge( + update.challenge, acme.challenges.DNSResponse() + ) + # let them know we are done and they should check - deadline = datetime.now() + timedelta( - minutes=int( - ir_config_parameter.get_param('letsencrypt_backoff', 3))) - order_resource = client.poll_and_finalize(authzr, deadline) - with open(join(_get_data_dir(), '%s.crt' % domain), 'w') as crt: + backoff = int(ir_config_parameter.get_param('letsencrypt.backoff', 3)) + deadline = datetime.now() + timedelta(minutes=backoff) + try: + order_resource = client.poll_and_finalize(authzr, deadline) + except acme.errors.ValidationError as error: + _logger.error("Let's Encrypt validation failed!") + for authz in error.failed_authzrs: + for challenge in authz.body.challenges: + _logger.error(str(challenge.error)) + raise + + with open(cert_file, 'w') as crt: crt.write(order_resource.fullchain_pem) - _logger.info('SUCCESS: Certificate saved :%s', crt.name) - reload_cmd = ir_config_parameter.get_param( - 'letsencrypt.reload_command', False) - if reload_cmd: - self._call_cmdline(['sh', '-c', reload_cmd]) + _logger.info('SUCCESS: Certificate saved: %s', cert_file) + reload_cmd = ir_config_parameter.get_param( + 'letsencrypt.reload_command', '' + ) + if reload_cmd.strip(): + self._call_cmdline(reload_cmd) + else: + _logger.warning("No reload command defined.") + + @api.model + def _wait_for_record(self, domain, token): + """Wait until a TXT record for a domain is visible.""" + if not domain.endswith("."): + # Fully qualify domain name, or it may try unsuitable names too + domain += "." + attempt = 0 + while True: + attempt += 1 + try: + for record in dns.resolver.query( + "_acme-challenge." + domain, "TXT" + ): + value = record.to_text()[1:-1] + if value == token: + return + else: + _logger.debug("Found %r instead of %r", value, token) + except dns.resolver.NXDOMAIN: + _logger.debug("Record for %r does not exist yet", domain) + if attempt < 30: + _logger.info("Waiting for DNS update.") + time.sleep(60) else: - _logger.warning("No reload command defined.") + _logger.warning( + "Could not find new record after 30 minutes! " + "Giving up and hoping for the best." + ) + return + @api.model def _create_client(self, account_key): - net = client.ClientNetwork(account_key) - if config['test_enable']: + param = self.env['ir.config_parameter'] + testing_mode = param.get_param('letsencrypt.testing_mode') == 'True' + if config['test_enable'] or testing_mode: directory_url = V2_STAGING_DIRECTORY_URL else: directory_url = V2_DIRECTORY_URL directory_json = requests.get(directory_url).json() - return client.ClientV2(directory_json, net) - - def _cascade_domains(self, altnames): - """ Given an list of domains containing one or more wildcard domains - the following are performed: - 1) for every wildcard domain: - a) gets the index of it's wildcard characters - b) if there are non wildcard domain names that are the same - after the index of the current wildcard name remove them. - 2) when done, return the modified altnames + net = acme.client.ClientNetwork(account_key) + return acme.client.ClientV2(directory_json, net) + + @api.model + def _cascade_domains(self, domains): + """Remove domains that are obsoleted by wildcard domains in the list. + + Requesting www.example.com is unnecessary if *.example.com is also + requested. example.com isn't obsoleted however, and neither is + sub.domain.example.com. """ - for altname in filter(lambda x: WILDCARD in x, altnames): - pat = re.compile('^.*' + altname.replace(WILDCARD, '') + '.*$') - for _altname in filter(lambda x: WILDCARD not in x, altnames): - if pat.search(_altname): - altnames.remove(_altname) - return altnames - - def _make_csr(self, account_key, domain_key_file, domain): - parameter_model = self.env['ir.config_parameter'] - altnames = parameter_model.get_param('letsencrypt_altnames') - if altnames: - altnames = re.split(',|\n| |;', altnames) - valid_domains = altnames + [domain] - valid_domains = self._cascade_domains(valid_domains) - else: - valid_domains = [domain] - _logger.info( - 'Making CSR for the following domains: ' + str(valid_domains)) - return crypto_util.make_csr( - open(domain_key_file).read(), valid_domains) + to_remove = set() + for domain in domains: + if WILDCARD in domain[1:]: + raise UserError( + _("A wildcard is only allowed at the start of a domain") + ) + if domain.startswith(WILDCARD): + postfix = domain[1:] # e.g. ".example.com" + # This makes it O(n²) but n <= 100 so it's ok + for other in domains: + if other.startswith(WILDCARD): + continue + if other.endswith(postfix): + prefix = other[: -len(postfix)] # e.g. "www" + if '.' not in prefix: + to_remove.add(other) + return sorted(set(domains) - to_remove) + + @api.model + def _get_altnames(self): + """Get the configured altnames as a list of strings.""" + altnames = self.env['ir.config_parameter'].get_param( + 'letsencrypt.altnames' + ) + if not altnames: + return [] + return re.split('(?:,|\n| |;)+', altnames) + + @api.model def _respond_challenge_http(self, challenge, account_key): """ Respond to the HTTP challenge by writing the file to serve. """ - challenge_dir = _get_challenge_dir() - if not isdir(challenge_dir): - makedirs(challenge_dir) - token = base64.urlsafe_b64encode(challenge.token) - challenge_file = join(_get_challenge_dir(), '%s' % token.rstrip('=')) - with open(challenge_file, 'wb') as challenge_file: - challenge_file.write(token.rstrip('=') + '.' + jose.b64encode( - account_key.thumbprint(hash_function=hashes.SHA256)).decode()) - - def _respond_challenge_dns(self, challenge, account_key, domain): + token = self._base64_encode(challenge.token) + challenge_file = os.path.join(_get_challenge_dir(), token.decode()) + with open(challenge_file, 'w') as file_: + file_.write(challenge.validation(account_key)) + + @api.model + def _respond_challenge_dns(self, domain, token): """ Respond to the DNS challenge by creating the DNS record on the provider. """ - letsencrypt_dns_function = '_respond_challenge_dns_' + \ - self.env['ir.config_parameter'].get_param( - 'letsencrypt_dns_provider') - getattr(self, letsencrypt_dns_function)(challenge, account_key, domain) + provider = self.env['ir.config_parameter'].get_param( + 'letsencrypt.dns_provider' + ) + if not provider: + raise UserError( + _("No DNS provider set, can't request wildcard certificate") + ) + dns_function = getattr(self, "_respond_challenge_dns_" + provider) + dns_function(domain.replace("*.", ""), token) @api.model - def _call_cmdline(self, cmdline, env=None, shell=False): + def _call_cmdline(self, cmdline, env=None): + """Call a shell command.""" process = subprocess.Popen( cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, - shell=shell, + shell=True, ) stdout, stderr = process.communicate() + stdout = stdout.strip() + stderr = stderr.strip() if process.returncode: - raise exceptions.Warning(_( - 'Error calling %s: %s %s %s' % ( - cmdline, - str(process.returncode) + ' '.join(cmdline), - stdout, - stderr, - ))) + if stdout: + _logger.warning(stdout) + if stderr: + _logger.warning(stderr) + raise UserError( + _('Error calling %s: %d') % (cmdline, process.returncode) + ) + if stdout: + _logger.info(stdout) + if stderr: + _logger.info(stderr) @api.model - def _respond_challenge_dns_shell(self, challenge, account_key, domain): + def _respond_challenge_dns_shell(self, domain, token): + """Respond to a DNS challenge using an arbitrary shell command.""" script_str = self.env['ir.config_parameter'].get_param( - 'letsencrypt_script') + 'letsencrypt.dns_shell_script' + ) if script_str: - env = os.environ + env = os.environ.copy() env.update( - LETSENCRYPT_DNS_CHALLENGE=jose.encode_b64jose( - challenge.chall.token), LETSENCRYPT_DNS_DOMAIN=domain, + LETSENCRYPT_DNS_CHALLENGE=token, ) - self.env['letsencrypt']._call_cmdline( - script_str, - env=env, - shell=True, + self._call_cmdline(script_str, env=env) + else: + raise UserError( + _("No shell command configured for updating DNS records") ) + + @api.model + def _base64_encode(self, data): + """Encode data as a URL-safe base64 string without padding. + + This should be the encoding that Let's Encrypt uses for all base64. See + https://github.com/ietf-wg-acme/acme/issues/64#issuecomment-168852757 + and https://golang.org/pkg/encoding/base64/#RawURLEncoding + """ + return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii') diff --git a/letsencrypt/readme/CONFIGURE.rst b/letsencrypt/readme/CONFIGURE.rst new file mode 100644 index 00000000000..0b07a720ebf --- /dev/null +++ b/letsencrypt/readme/CONFIGURE.rst @@ -0,0 +1,24 @@ +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. + +Futher self-explanatory settings are in Settings -> General Settings. There you +can add further domains to the CSR, add a custom script that updates your DNS +and add a script that will be used to reload your web server (if needed). +The number of domains that can be added to a certificate is +`capped at 100 `_. A wildcard +certificate can be used to avoid that limit. + +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 +``$datadir/letsencrypt/acme-challenge`` of your odoo instance. + +Since DNS changes can take some time to propagate, when we respond to a DNS challenge +and the server tries to check our response, it might fail (and probably will). +The solution to this is documented in https://tools.ietf.org/html/rfc8555#section-8.2 +and basically is a ``Retry-After`` header under which we can instruct the server to +retry the challenge. +At the time these lines were written, Boulder had not implemented this functionality. +This prompted us to use ``letsencrypt.backoff`` configuration parameter, which is the +amount of minutes this module will try poll the server to retry validating the answer +to our challenge, specifically it is the ``deadline`` parameter of ``poll_and_finalize``. diff --git a/letsencrypt/readme/CONTRIBUTORS.rst b/letsencrypt/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..5da59e4d396 --- /dev/null +++ b/letsencrypt/readme/CONTRIBUTORS.rst @@ -0,0 +1,6 @@ +* Holger Brunn +* Antonio Espinosa +* Dave Lasley +* Ronald Portier +* George Daramouskas +* Jan Verbeek diff --git a/letsencrypt/readme/CREDITS.rst b/letsencrypt/readme/CREDITS.rst new file mode 100644 index 00000000000..cea29d7411e --- /dev/null +++ b/letsencrypt/readme/CREDITS.rst @@ -0,0 +1,9 @@ +ACME implementation +~~~~~~~~~~~~~~~~~~~ + +* https://github.com/certbot/certbot/tree/0.22.x/acme + +Icon +~~~~ + +* https://helloworld.letsencrypt.org diff --git a/letsencrypt/readme/DESCRIPTION.rst b/letsencrypt/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..4f0be24b106 --- /dev/null +++ b/letsencrypt/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module was written to have your Odoo installation request SSL certificates +from https://letsencrypt.org automatically. diff --git a/letsencrypt/readme/INSTALL.rst b/letsencrypt/readme/INSTALL.rst new file mode 100644 index 00000000000..5e35d3b7e99 --- /dev/null +++ b/letsencrypt/readme/INSTALL.rst @@ -0,0 +1,10 @@ +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. diff --git a/letsencrypt/readme/USAGE.rst b/letsencrypt/readme/USAGE.rst new file mode 100644 index 00000000000..b26fe486a22 --- /dev/null +++ b/letsencrypt/readme/USAGE.rst @@ -0,0 +1,63 @@ +The module sets up a cronjob that requests and renews certificates automatically. + +Certificates are renewed a month before they expire. Renewal is then attempted +every day until it succeeds. + +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. + +In depth configuration +~~~~~~~~~~~~~~~~~~~~~~ + +If you want to use multiple domains on your CSR then you have to configure them +from Settings -> General Settings. If you use a wildcard in any of those domains +then letsencrypt will return a DNS challenge. In order for that challenge to be +answered you will need to **either** provide a script (as seen in General Settings) +or install a module that provides support for your DNS provider. In that module +you will need to create a function in the letsencrypt model with the name +``_respond_challenge_dns_$DNS_PROVIDER`` where ``$DNS_PROVIDER`` is the name of your +provider and can be any string with length greater than zero, and add the name +of your DNS provider in the settings dns_provider selection field. + +In any case if a script path is inserted in the settings page, it will be run +in case you want to update multiple DNS servers. + +A reload command can be set in the Settings as well in case you need to reload +your web server. This by default is ``sudo /usr/sbin/service nginx reload`` + + +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`` diff --git a/letsencrypt/static/description/index.html b/letsencrypt/static/description/index.html new file mode 100644 index 00000000000..7706bfc69a0 --- /dev/null +++ b/letsencrypt/static/description/index.html @@ -0,0 +1,544 @@ + + + + + + +Let's Encrypt + + + +
+

Let’s Encrypt

+ + +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runbot

+

This module was written to have your Odoo installation request SSL certificates +from https://letsencrypt.org automatically.

+

Table of contents

+ +
+

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.

+

Futher self-explanatory settings are in Settings -> General Settings. There you +can add further domains to the CSR, add a custom script that updates your DNS +and add a script that will be used to reload your web server (if needed). +The number of domains that can be added to a certificate is +capped at 100. A wildcard +certificate can be used to avoid that limit.

+

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 +$datadir/letsencrypt/acme-challenge of your odoo instance.

+

Since DNS changes can take some time to propagate, when we respond to a DNS challenge +and the server tries to check our response, it might fail (and probably will). +The solution to this is documented in https://tools.ietf.org/html/rfc8555#section-8.2 +and basically is a Retry-After header under which we can instruct the server to +retry the challenge. +At the time these lines were written, Boulder had not implemented this functionality. +This prompted us to use letsencrypt.backoff configuration parameter, which is the +amount of minutes this module will try poll the server to retry validating the answer +to our challenge, specifically it is the deadline parameter of poll_and_finalize.

+
+
+

Usage

+

The module sets up a cronjob that requests and renews certificates automatically.

+

Certificates are renewed a month before they expire. Renewal is then attempted +every day until it succeeds.

+

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.

+
+

In depth configuration

+

If you want to use multiple domains on your CSR then you have to configure them +from Settings -> General Settings. If you use a wildcard in any of those domains +then letsencrypt will return a DNS challenge. In order for that challenge to be +answered you will need to either provide a script (as seen in General Settings) +or install a module that provides support for your DNS provider. In that module +you will need to create a function in the letsencrypt model with the name +_respond_challenge_dns_$DNS_PROVIDER where $DNS_PROVIDER is the name of your +provider and can be any string with length greater than zero, and add the name +of your DNS provider in the settings dns_provider selection field.

+

In any case if a script path is inserted in the settings page, it will be run +in case you want to update multiple DNS servers.

+

A reload command can be set in the Settings as well in case you need to reload +your web server. This by default is sudo /usr/sbin/service nginx reload

+

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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
  • Tecnativa
  • +
+
+
+

Contributors

+ +
+ + + +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/letsencrypt/tests/test_letsencrypt.py b/letsencrypt/tests/test_letsencrypt.py index a9fce6f410a..29226491b5a 100644 --- a/letsencrypt/tests/test_letsencrypt.py +++ b/letsencrypt/tests/test_letsencrypt.py @@ -1,102 +1,333 @@ # Copyright 2018 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo.tests import SingleTransactionCase -from ..models.letsencrypt import _get_data_dir, WILDCARD -from os import path + +from __future__ import unicode_literals # cryptography is picky + +import os import shutil + +from datetime import datetime, timedelta +from os import path + import mock -import os +from odoo.exceptions import UserError +from odoo.tests import SingleTransactionCase -class TestLetsencrypt(SingleTransactionCase): +from ..models.letsencrypt import _get_data_dir, _get_challenge_dir + + +CERT_DIR = path.join(path.dirname(__file__), 'certs') + + +def _poll(order, deadline): + order_resource = mock.Mock(['fullchain_pem']) + order_resource.fullchain_pem = 'chain' + return order_resource - post_install = True - at_install = False +class TestLetsencrypt(SingleTransactionCase): def setUp(self): super(TestLetsencrypt, self).setUp() + self.env['ir.config_parameter'].set_param( + 'web.base.url', 'http://www.example.com' + ) + self.env['base.config.settings'].create( + { + 'letsencrypt_dns_provider': 'shell', + 'letsencrypt_dns_shell_script': 'touch /tmp/.letsencrypt_test', + 'letsencrypt_altnames': '*.example.com', + 'letsencrypt_reload_command': 'true', # i.e. /bin/true + } + ).set_dns_provider() def test_config_settings(self): - settings_model = self.env['base.config.settings'] - letsencrypt_model = self.env['letsencrypt'] - settings = settings_model.create({ - 'dns_provider': 'shell', - 'script': 'touch /tmp/.letsencrypt_test', - 'altnames': - 'test.example.com', - 'reload_command': 'echo', - }) - self.env['ir.config_parameter'].set_param( - 'web.base.url', 'http://www.example.com') - settings.set_dns_provider() - setting_vals = settings.default_get([]) - self.assertEquals(setting_vals['dns_provider'], 'shell') - letsencrypt_model._call_cmdline(setting_vals['script'], shell=True) - self.assertEquals(path.exists('/tmp/.letsencrypt_test'), True) - self.assertEquals( - setting_vals['altnames'], - settings.altnames, - ) - self.assertEquals(setting_vals['reload_command'], 'echo') - settings.onchange_altnames() - self.assertEquals(settings.needs_dns_provider, False) - settings.unlink() - - def new_order(self, typ): - if typ not in ['http-01', 'dns-01']: - raise ValueError - authzr = mock.Mock - order_resource = mock.Mock - order_resource.fullchain_pem = 'test' - authorization = mock.Mock - body = mock.Mock - challenge = mock.Mock - challenge.chall = mock.Mock - challenge.chall.typ = typ - challenge.chall.token = 'a_token' - body.challenges = [challenge] - authorization.body = body - authzr.authorizations = [authorization] - return authzr - - def poll(self, deadline): - order_resource = mock.Mock - order_resource.fullchain_pem = 'chain' - return order_resource + setting_vals = self.env['base.config.settings'].default_get([]) + self.assertEqual(setting_vals['letsencrypt_dns_provider'], 'shell') + self.assertEqual( + setting_vals['letsencrypt_dns_shell_script'], + 'touch /tmp/.letsencrypt_test', + ) + self.assertEqual(setting_vals['letsencrypt_altnames'], '*.example.com') + self.assertEqual(setting_vals['letsencrypt_reload_command'], 'true') + self.assertTrue(setting_vals['letsencrypt_needs_dns_provider']) + self.assertFalse(setting_vals['letsencrypt_prefer_dns']) @mock.patch('acme.client.ClientV2.answer_challenge') - @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=poll) + @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=_poll) def test_http_challenge(self, poll, answer_challenge): letsencrypt = self.env['letsencrypt'] - self.env['ir.config_parameter'].set_param( - 'web.base.url', 'http://www.example.com') - settings = self.env['base.config.settings'] - settings.create({ - 'altnames': 'test.example.com', - }).set_dns_provider() + self.env['base.config.settings'].create( + {'letsencrypt_altnames': 'test.example.com'} + ).set_dns_provider() letsencrypt._cron() poll.assert_called() + self.assertTrue(os.listdir(_get_challenge_dir())) + self.assertFalse(path.isfile('/tmp/.letsencrypt_test')) self.assertTrue( - open(path.join(_get_data_dir(), 'www.example.com.crt')).read(), + path.isfile(path.join(_get_data_dir(), 'www.example.com.crt')) ) - os.remove(path.join(_get_data_dir(), 'www.example.com.crt')) + # pylint: disable=unused-argument + @mock.patch('odoo.addons.letsencrypt.models.letsencrypt.DNSUpdate') + @mock.patch('dns.resolver.query') + @mock.patch('time.sleep') @mock.patch('acme.client.ClientV2.answer_challenge') - @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=poll) - def test_dns_challenge(self, poll, answer_challenge): - letsencrypt = self.env['letsencrypt'] - settings = self.env['base.config.settings'] - settings.create({ - 'dns_provider': 'shell', - 'script': 'echo', - 'altnames': WILDCARD + 'example.com', - }).set_dns_provider() - letsencrypt._cron() + @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=_poll) + def test_dns_challenge(self, poll, answer_challenge, sleep, query, dnsupd): + + def register_update(challenge, domain, token): + record = mock.Mock() + record.to_text.return_value = '"%s"' % token + query.return_value = [record] + ret = mock.Mock() + ret.challenge = challenge + ret.domain = domain + ret.token = token + return ret + + dnsupd.side_effect = register_update + + self.install_certificate(days_left=10) + self.env['letsencrypt']._cron() poll.assert_called() + query.assert_called_with("_acme-challenge.example.com.", "TXT") + self.assertTrue(path.isfile('/tmp/.letsencrypt_test')) self.assertTrue( - open(path.join(_get_data_dir(), 'www.example.com.crt')).read(), + path.isfile(path.join(_get_data_dir(), 'www.example.com.crt')) + ) + + def test_dns_challenge_error_on_missing_provider(self): + self.env['base.config.settings'].create( + { + 'letsencrypt_altnames': '*.example.com', + 'letsencrypt_dns_provider': False, + } + ).set_dns_provider() + with self.assertRaises(UserError): + self.env['letsencrypt']._cron() + + def test_prefer_dns_setting(self): + self.env['base.config.settings'].create( + { + 'letsencrypt_altnames': 'example.com', + 'letsencrypt_prefer_dns': True, + } + ).set_dns_provider() + self.env['ir.config_parameter'].set_param( + 'web.base.url', 'http://example.com' + ) + # pylint: disable=no-value-for-parameter + self.test_dns_challenge() + + def test_cascading(self): + cascade = self.env['letsencrypt']._cascade_domains + self.assertEqual( + cascade( + [ + 'www.example.com', + '*.example.com', + 'example.com', + 'example.com', + 'notexample.com', + 'multi.sub.example.com', + 'www2.example.com', + 'unrelated.com', + ] + ), + [ + '*.example.com', + 'example.com', + 'multi.sub.example.com', + 'notexample.com', + 'unrelated.com', + ], + ) + self.assertEqual(cascade([]), []) + self.assertEqual(cascade(['*.example.com']), ['*.example.com']) + self.assertEqual(cascade(['www.example.com']), ['www.example.com']) + self.assertEqual( + cascade(['www.example.com', 'example.com']), + ['example.com', 'www.example.com'], ) + with self.assertRaises(UserError): + cascade(['www.*.example.com']) + + with self.assertRaises(UserError): + cascade(['*.*.example.com']) + + def test_altnames_parsing(self): + config = self.env['ir.config_parameter'] + letsencrypt = self.env['letsencrypt'] + + self.assertEqual(letsencrypt._get_altnames(), ['*.example.com']) + + config.set_param('letsencrypt.altnames', '') + self.assertEqual(letsencrypt._get_altnames(), []) + + config.set_param('letsencrypt.altnames', 'www.example.com') + self.assertEqual(letsencrypt._get_altnames(), ['www.example.com']) + + config.set_param( + 'letsencrypt.altnames', 'example.com,example.org,example.net' + ) + self.assertEqual( + letsencrypt._get_altnames(), + ['example.com', 'example.org', 'example.net'], + ) + + config.set_param( + 'letsencrypt.altnames', 'example.com, example.org\nexample.net' + ) + self.assertEqual( + letsencrypt._get_altnames(), + ['example.com', 'example.org', 'example.net'], + ) + + def test_key_generation_and_retrieval(self): + key_a1 = self.env['letsencrypt']._get_key('a.key') + key_a2 = self.env['letsencrypt']._get_key('a.key') + key_b = self.env['letsencrypt']._get_key('b.key') + self.assertIsInstance(key_a1, bytes) + self.assertIsInstance(key_a2, bytes) + self.assertIsInstance(key_b, bytes) + self.assertTrue(path.isfile(path.join(_get_data_dir(), 'a.key'))) + self.assertEqual(key_a1, key_a2) + self.assertNotEqual(key_a1, key_b) + + @mock.patch('os.remove', side_effect=os.remove) + @mock.patch( + 'odoo.addons.letsencrypt.models.letsencrypt.Letsencrypt._generate_key', + side_effect=lambda: None, + ) + def test_interrupted_key_writing(self, generate_key, remove): + with self.assertRaises(TypeError): + self.env['letsencrypt']._get_key('a.key') + self.assertFalse(path.isfile(path.join(_get_data_dir(), 'a.key'))) + remove.assert_called() + generate_key.assert_called() + + def test_domain_validation(self): + self.env['letsencrypt']._validate_domain('example.com') + self.env['letsencrypt']._validate_domain('www.example.com') + + with self.assertRaises(UserError): + self.env['letsencrypt']._validate_domain('1.1.1.1') + with self.assertRaises(UserError): + self.env['letsencrypt']._validate_domain('192.168.1.1') + with self.assertRaises(UserError): + self.env['letsencrypt']._validate_domain('localhost.localdomain') + with self.assertRaises(UserError): + self.env['letsencrypt']._validate_domain('testdomain') + with self.assertRaises(UserError): + self.env['letsencrypt']._validate_domain('::1') + + def test_young_certificate(self): + self.install_certificate(60) + self.assertFalse( + self.env['letsencrypt']._should_run( + path.join(_get_data_dir(), 'www.example.com.crt'), + ['www.example.com', '*.example.com'], + ) + ) + + def test_old_certificate(self): + self.install_certificate(20) + self.assertTrue( + self.env['letsencrypt']._should_run( + path.join(_get_data_dir(), 'www.example.com.crt'), + ['www.example.com', '*.example.com'], + ) + ) + + def test_expired_certificate(self): + self.install_certificate(-10) + self.assertTrue( + self.env['letsencrypt']._should_run( + path.join(_get_data_dir(), 'www.example.com.crt'), + ['www.example.com', '*.example.com'], + ) + ) + + def test_missing_certificate(self): + self.assertTrue( + self.env['letsencrypt']._should_run( + path.join(_get_data_dir(), 'www.example.com.crt'), + ['www.example.com', '*.example.com'], + ) + ) + + def test_new_altnames(self): + self.install_certificate(60, 'www.example.com', ()) + self.assertTrue( + self.env['letsencrypt']._should_run( + path.join(_get_data_dir(), 'www.example.com.crt'), + ['www.example.com', '*.example.com'], + ) + ) + + def test_legacy_certificate_without_altnames(self): + self.install_certificate(60, use_altnames=False) + self.assertFalse( + self.env['letsencrypt']._should_run( + path.join(_get_data_dir(), 'www.example.com.crt'), + ['www.example.com'], + ) + ) + + def install_certificate( + self, + days_left, + common_name='www.example.com', + altnames=('*.example.com',), + use_altnames=True, + ): + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + not_after = datetime.now() + timedelta(days=days_left) + not_before = not_after - timedelta(days=90) + + key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + cert_builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name( + [x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)] + ) + ) + .issuer_name( + x509.Name( + [x509.NameAttribute(x509.NameOID.COMMON_NAME, 'myca.biz')] + ) + ) + .not_valid_before(not_before) + .not_valid_after(not_after) + .serial_number(x509.random_serial_number()) + .public_key(key.public_key()) + ) + + if use_altnames: + cert_builder = cert_builder.add_extension( + x509.SubjectAlternativeName( + [x509.DNSName(common_name)] + + [x509.DNSName(name) for name in altnames] + ), + critical=False, + ) + + cert = cert_builder.sign(key, hashes.SHA256(), default_backend()) + cert_file = path.join(_get_data_dir(), '%s.crt' % common_name) + with open(cert_file, 'wb') as file_: + file_.write(cert.public_bytes(serialization.Encoding.PEM)) + def tearDown(self): super(TestLetsencrypt, self).tearDown() shutil.rmtree(_get_data_dir(), ignore_errors=True) + if path.isfile('/tmp/.letsencrypt_test'): + os.remove('/tmp/.letsencrypt_test') diff --git a/letsencrypt/views/base_config_settings.xml b/letsencrypt/views/base_config_settings.xml index 6637a7cafce..93402ee1ebb 100644 --- a/letsencrypt/views/base_config_settings.xml +++ b/letsencrypt/views/base_config_settings.xml @@ -1,5 +1,4 @@ - Letsencrypt base config settings base.config.settings @@ -7,27 +6,28 @@ - + + name="letsencrypt_dns_provider" + attrs="{'required': + [('letsencrypt_needs_dns_provider', '=', True)]}" /> - - + + + + - diff --git a/requirements.txt b/requirements.txt index 7aff6f7e79b..a417232eadc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,7 @@ pysftp acme_tiny IPy pyopenssl +acme +cryptography +dnspython +josepy From f91217f74cf6c04ae6a064b8b157e1043c6d2031 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 22 Apr 2020 19:39:48 +0200 Subject: [PATCH 03/22] [MIG] Migrate letsencrypt 2.0.0 to Odoo 11.0 --- letsencrypt/README.rst | 11 +-- letsencrypt/__manifest__.py | 2 +- letsencrypt/data/ir_config_parameter.xml | 3 - .../migrations/11.0.2.0.0/post-migrate.py | 4 + letsencrypt/models/__init__.py | 2 +- letsencrypt/models/letsencrypt.py | 6 +- ...fig_settings.py => res_config_settings.py} | 11 +-- letsencrypt/readme/CONTRIBUTORS.rst | 1 + letsencrypt/static/description/index.html | 8 +- letsencrypt/tests/test_letsencrypt.py | 24 +++--- letsencrypt/views/base_config_settings.xml | 33 -------- letsencrypt/views/res_config_settings.xml | 75 +++++++++++++++++++ requirements.txt | 2 - 13 files changed, 113 insertions(+), 69 deletions(-) rename letsencrypt/models/{base_config_settings.py => res_config_settings.py} (95%) delete mode 100644 letsencrypt/views/base_config_settings.xml create mode 100644 letsencrypt/views/res_config_settings.xml diff --git a/letsencrypt/README.rst b/letsencrypt/README.rst index dba2941dfb4..5ba19fa2d81 100644 --- a/letsencrypt/README.rst +++ b/letsencrypt/README.rst @@ -14,13 +14,13 @@ Let's Encrypt :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github - :target: https://github.com/OCA/server-tools/tree/10.0/letsencrypt + :target: https://github.com/OCA/server-tools/tree/11.0/letsencrypt :alt: OCA/server-tools .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/server-tools-10-0/server-tools-10-0-letsencrypt + :target: https://translation.odoo-community.org/projects/server-tools-11-0/server-tools-11-0-letsencrypt :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/149/10.0 + :target: https://runbot.odoo-community.org/runbot/149/11.0 :alt: Try me on Runbot |badge1| |badge2| |badge3| |badge4| |badge5| @@ -148,7 +148,7 @@ 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 `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -160,6 +160,7 @@ Authors * Therp BV * Tecnativa +* Acysos S.L Contributors ~~~~~~~~~~~~ @@ -198,6 +199,6 @@ 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. -This module is part of the `OCA/server-tools `_ project on GitHub. +This module is part of the `OCA/server-tools `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/letsencrypt/__manifest__.py b/letsencrypt/__manifest__.py index e3f8f9fdca8..faaedf5900e 100644 --- a/letsencrypt/__manifest__.py +++ b/letsencrypt/__manifest__.py @@ -16,7 +16,7 @@ "data": [ "data/ir_config_parameter.xml", "data/ir_cron.xml", - "views/base_config_settings.xml", + "views/res_config_settings.xml", ], "demo": [ "demo/ir_cron.xml", diff --git a/letsencrypt/data/ir_config_parameter.xml b/letsencrypt/data/ir_config_parameter.xml index 9637cbfd86f..804b1357858 100644 --- a/letsencrypt/data/ir_config_parameter.xml +++ b/letsencrypt/data/ir_config_parameter.xml @@ -16,9 +16,6 @@ forcecreate="True"> letsencrypt.backoff 3 - diff --git a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py index 57a6a9c3aa6..91962b6a04e 100644 --- a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py +++ b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py @@ -6,6 +6,10 @@ def migrate_altnames(env): config = env["ir.config_parameter"] existing = config.search([("key", "=like", "letsencrypt.altname.%")]) + if not existing: + # We may be migrating from 10.0.2.0.0, in which case + # letsencrypt.altnames already exists and shouldn't be clobbered. + return new_domains = "\n".join(existing.mapped("value")) config.set_param("letsencrypt.altnames", new_domains) existing.unlink() diff --git a/letsencrypt/models/__init__.py b/letsencrypt/models/__init__.py index bb6955a8e93..54285a070df 100644 --- a/letsencrypt/models/__init__.py +++ b/letsencrypt/models/__init__.py @@ -1,4 +1,4 @@ # © 2016 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import letsencrypt -from . import base_config_settings +from . import res_config_settings diff --git a/letsencrypt/models/letsencrypt.py b/letsencrypt/models/letsencrypt.py index 85acdabc39d..e973f43f9e4 100644 --- a/letsencrypt/models/letsencrypt.py +++ b/letsencrypt/models/letsencrypt.py @@ -10,7 +10,7 @@ import re import subprocess import time -import urlparse +import urllib.parse from datetime import datetime, timedelta @@ -197,7 +197,7 @@ def _should_run(self, cert_file, domains): def _cron(self): ir_config_parameter = self.env['ir.config_parameter'] base_url = ir_config_parameter.get_param('web.base.url', 'localhost') - domain = urlparse.urlparse(base_url).hostname + domain = urllib.parse.urlparse(base_url).hostname cert_file = os.path.join(_get_data_dir(), '%s.crt' % domain) domains = self._cascade_domains([domain] + self._get_altnames()) @@ -403,7 +403,7 @@ def _respond_challenge_http(self, challenge, account_key): Respond to the HTTP challenge by writing the file to serve. """ token = self._base64_encode(challenge.token) - challenge_file = os.path.join(_get_challenge_dir(), token.decode()) + challenge_file = os.path.join(_get_challenge_dir(), token) with open(challenge_file, 'w') as file_: file_.write(challenge.validation(account_key)) diff --git a/letsencrypt/models/base_config_settings.py b/letsencrypt/models/res_config_settings.py similarity index 95% rename from letsencrypt/models/base_config_settings.py rename to letsencrypt/models/res_config_settings.py index 2f89183915f..f35a6f8b96b 100644 --- a/letsencrypt/models/base_config_settings.py +++ b/letsencrypt/models/res_config_settings.py @@ -10,8 +10,8 @@ """ -class BaseConfigSettings(models.TransientModel): - _inherit = 'base.config.settings' +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' letsencrypt_altnames = fields.Text( string="Domain names", @@ -69,7 +69,7 @@ def letsencrypt_check_dns_required(self): @api.model def default_get(self, fields_list): - res = super(BaseConfigSettings, self).default_get(fields_list) + res = super().default_get(fields_list) get_param = self.env['ir.config_parameter'].get_param res.update( { @@ -97,8 +97,9 @@ def default_get(self, fields_list): return res @api.multi - def set_dns_provider(self): - self.ensure_one() + def set_values(self): + super().set_values() + self.letsencrypt_check_dns_required() if self.letsencrypt_dns_provider == 'shell': diff --git a/letsencrypt/readme/CONTRIBUTORS.rst b/letsencrypt/readme/CONTRIBUTORS.rst index 5da59e4d396..1aeb78d8db1 100644 --- a/letsencrypt/readme/CONTRIBUTORS.rst +++ b/letsencrypt/readme/CONTRIBUTORS.rst @@ -2,5 +2,6 @@ * Antonio Espinosa * Dave Lasley * Ronald Portier +* Ignacio Ibeas * George Daramouskas * Jan Verbeek diff --git a/letsencrypt/static/description/index.html b/letsencrypt/static/description/index.html index 7706bfc69a0..f24ebd0d6b4 100644 --- a/letsencrypt/static/description/index.html +++ b/letsencrypt/static/description/index.html @@ -367,7 +367,7 @@

Let’s Encrypt

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runbot

+

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runbot

This module was written to have your Odoo installation request SSL certificates from https://letsencrypt.org automatically.

Table of contents

@@ -490,7 +490,7 @@

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.

+feedback.

Do not contact contributors directly about support or help with technical issues.

@@ -500,6 +500,7 @@

Authors

  • Therp BV
  • Tecnativa
  • +
  • Acysos S.L
@@ -509,6 +510,7 @@

Contributors

  • Antonio Espinosa <antonio.espinosa@tecnativa.com>
  • Dave Lasley <dave@laslabs.com>
  • Ronald Portier <ronald@therp.nl>
  • +
  • Ignacio Ibeas <ignacio@acysos.com>
  • George Daramouskas <gdaramouskas@therp.nl>
  • Jan Verbeek <jverbeek@therp.nl>
  • @@ -535,7 +537,7 @@

    Maintainers

    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.

    -

    This module is part of the OCA/server-tools project on GitHub.

    +

    This module is part of the OCA/server-tools project on GitHub.

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    diff --git a/letsencrypt/tests/test_letsencrypt.py b/letsencrypt/tests/test_letsencrypt.py index 29226491b5a..f7e693a0212 100644 --- a/letsencrypt/tests/test_letsencrypt.py +++ b/letsencrypt/tests/test_letsencrypt.py @@ -1,8 +1,6 @@ # Copyright 2018 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from __future__ import unicode_literals # cryptography is picky - import os import shutil @@ -28,21 +26,21 @@ def _poll(order, deadline): class TestLetsencrypt(SingleTransactionCase): def setUp(self): - super(TestLetsencrypt, self).setUp() + super().setUp() self.env['ir.config_parameter'].set_param( 'web.base.url', 'http://www.example.com' ) - self.env['base.config.settings'].create( + self.env['res.config.settings'].create( { 'letsencrypt_dns_provider': 'shell', 'letsencrypt_dns_shell_script': 'touch /tmp/.letsencrypt_test', 'letsencrypt_altnames': '*.example.com', 'letsencrypt_reload_command': 'true', # i.e. /bin/true } - ).set_dns_provider() + ).set_values() def test_config_settings(self): - setting_vals = self.env['base.config.settings'].default_get([]) + setting_vals = self.env['res.config.settings'].default_get([]) self.assertEqual(setting_vals['letsencrypt_dns_provider'], 'shell') self.assertEqual( setting_vals['letsencrypt_dns_shell_script'], @@ -57,9 +55,9 @@ def test_config_settings(self): @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=_poll) def test_http_challenge(self, poll, answer_challenge): letsencrypt = self.env['letsencrypt'] - self.env['base.config.settings'].create( + self.env['res.config.settings'].create( {'letsencrypt_altnames': 'test.example.com'} - ).set_dns_provider() + ).set_values() letsencrypt._cron() poll.assert_called() self.assertTrue(os.listdir(_get_challenge_dir())) @@ -98,22 +96,22 @@ def register_update(challenge, domain, token): ) def test_dns_challenge_error_on_missing_provider(self): - self.env['base.config.settings'].create( + self.env['res.config.settings'].create( { 'letsencrypt_altnames': '*.example.com', 'letsencrypt_dns_provider': False, } - ).set_dns_provider() + ).set_values() with self.assertRaises(UserError): self.env['letsencrypt']._cron() def test_prefer_dns_setting(self): - self.env['base.config.settings'].create( + self.env['res.config.settings'].create( { 'letsencrypt_altnames': 'example.com', 'letsencrypt_prefer_dns': True, } - ).set_dns_provider() + ).set_values() self.env['ir.config_parameter'].set_param( 'web.base.url', 'http://example.com' ) @@ -327,7 +325,7 @@ def install_certificate( file_.write(cert.public_bytes(serialization.Encoding.PEM)) def tearDown(self): - super(TestLetsencrypt, self).tearDown() + super().tearDown() shutil.rmtree(_get_data_dir(), ignore_errors=True) if path.isfile('/tmp/.letsencrypt_test'): os.remove('/tmp/.letsencrypt_test') diff --git a/letsencrypt/views/base_config_settings.xml b/letsencrypt/views/base_config_settings.xml deleted file mode 100644 index 93402ee1ebb..00000000000 --- a/letsencrypt/views/base_config_settings.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - Letsencrypt base config settings - base.config.settings - - - - - - - - - - - - - - - - - - diff --git a/letsencrypt/views/res_config_settings.xml b/letsencrypt/views/res_config_settings.xml new file mode 100644 index 00000000000..5bb56b4299c --- /dev/null +++ b/letsencrypt/views/res_config_settings.xml @@ -0,0 +1,75 @@ + + + Letsencrypt settings view + res.config.settings + + + +
    +
    +

    Let's Encrypt

    + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    diff --git a/requirements.txt b/requirements.txt index a417232eadc..907f26d8497 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ checksumdir raven pysftp -acme_tiny -IPy pyopenssl acme cryptography From 7b403a006e53d388e87c3c4ef7e1c7e969e50dfb Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 27 May 2020 18:40:37 +0200 Subject: [PATCH 04/22] [FIX] configuration_helper: Disable _rec_name magic The tests for configuration_helper combined with the changes to res.config.settings in letsencrypt revealed an edge case where Odoo sets an inappropriate _rec_name. --- configuration_helper/models/config.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/configuration_helper/models/config.py b/configuration_helper/models/config.py index 10433a9490a..ad8ecf03bf3 100644 --- a/configuration_helper/models/config.py +++ b/configuration_helper/models/config.py @@ -28,7 +28,13 @@ def _filter_field(self, field_key): @api.model def _setup_base(self): cls = type(self) + old_rec_name = cls._rec_name super(AbstractConfigSettings, self)._setup_base() + # If a field called "name" or "x_name" exists, _setup_base() + # automatically sets it as _rec_name. But that _rec_name can carry + # over to places where that field doesn't exist, so we want to + # avoid that magic. + cls._rec_name = old_rec_name comp_fields = filter( lambda f: (f[0].startswith(self._prefix) and From de3d629c38548c9c4646e5a7b441180708dcbee9 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Thu, 28 May 2020 11:44:15 +0200 Subject: [PATCH 05/22] [FIX] letsencrypt: Increase test coverage --- letsencrypt/tests/__init__.py | 1 + letsencrypt/tests/test_http.py | 26 +++++++++++++++++++++ letsencrypt/tests/test_letsencrypt.py | 33 ++++++++++++++++++++++----- 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 letsencrypt/tests/test_http.py diff --git a/letsencrypt/tests/__init__.py b/letsencrypt/tests/__init__.py index 4a7abea1fc7..f5551bae996 100644 --- a/letsencrypt/tests/__init__.py +++ b/letsencrypt/tests/__init__.py @@ -1,3 +1,4 @@ # Copyright 2018 Therp BV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import test_http from . import test_letsencrypt diff --git a/letsencrypt/tests/test_http.py b/letsencrypt/tests/test_http.py new file mode 100644 index 00000000000..d28032b46ce --- /dev/null +++ b/letsencrypt/tests/test_http.py @@ -0,0 +1,26 @@ +# Copyright 2020 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import os +import shutil + +from odoo.tests import HttpCase + +from ..models.letsencrypt import _get_challenge_dir + + +class TestHTTP(HttpCase): + def test_query_existing(self): + with open(os.path.join(_get_challenge_dir(), "foobar"), "w") as file: + file.write("content") + res = self.url_open("/.well-known/acme-challenge/foobar") + self.assertEqual(res.status_code, 200) + self.assertEqual(res.text, "content") + + def test_query_missing(self): + res = self.url_open("/.well-known/acme-challenge/foobar") + self.assertEqual(res.status_code, 404) + + def tearDown(self): + super().tearDown() + shutil.rmtree(_get_challenge_dir(), ignore_errors=True) diff --git a/letsencrypt/tests/test_letsencrypt.py b/letsencrypt/tests/test_letsencrypt.py index f7e693a0212..4aeb21be97a 100644 --- a/letsencrypt/tests/test_letsencrypt.py +++ b/letsencrypt/tests/test_letsencrypt.py @@ -9,7 +9,7 @@ import mock -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.tests import SingleTransactionCase from ..models.letsencrypt import _get_data_dir, _get_challenge_dir @@ -35,7 +35,7 @@ def setUp(self): 'letsencrypt_dns_provider': 'shell', 'letsencrypt_dns_shell_script': 'touch /tmp/.letsencrypt_test', 'letsencrypt_altnames': '*.example.com', - 'letsencrypt_reload_command': 'true', # i.e. /bin/true + 'letsencrypt_reload_command': 'echo reloaded', } ).set_values() @@ -47,13 +47,18 @@ def test_config_settings(self): 'touch /tmp/.letsencrypt_test', ) self.assertEqual(setting_vals['letsencrypt_altnames'], '*.example.com') - self.assertEqual(setting_vals['letsencrypt_reload_command'], 'true') + self.assertEqual(setting_vals['letsencrypt_reload_command'], 'echo reloaded') self.assertTrue(setting_vals['letsencrypt_needs_dns_provider']) self.assertFalse(setting_vals['letsencrypt_prefer_dns']) + with self.assertRaises(ValidationError): + self.env["res.config.settings"].create( + {"letsencrypt_dns_shell_script": "# Empty script"} + ).set_values() + @mock.patch('acme.client.ClientV2.answer_challenge') @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=_poll) - def test_http_challenge(self, poll, answer_challenge): + def test_http_challenge(self, poll, _answer_challenge): letsencrypt = self.env['letsencrypt'] self.env['res.config.settings'].create( {'letsencrypt_altnames': 'test.example.com'} @@ -74,10 +79,12 @@ def test_http_challenge(self, poll, answer_challenge): @mock.patch('acme.client.ClientV2.poll_and_finalize', side_effect=_poll) def test_dns_challenge(self, poll, answer_challenge, sleep, query, dnsupd): + record = None + def register_update(challenge, domain, token): + nonlocal record record = mock.Mock() record.to_text.return_value = '"%s"' % token - query.return_value = [record] ret = mock.Mock() ret.challenge = challenge ret.domain = domain @@ -86,10 +93,24 @@ def register_update(challenge, domain, token): dnsupd.side_effect = register_update + ncalls = 0 + + def query_effect(domain, rectype): + nonlocal ncalls + self.assertEqual(domain, "_acme-challenge.example.com.") + self.assertEqual(rectype, "TXT") + ncalls += 1 + if ncalls < 3: + return [] + else: + return [record] + + query.side_effect = query_effect + self.install_certificate(days_left=10) self.env['letsencrypt']._cron() poll.assert_called() - query.assert_called_with("_acme-challenge.example.com.", "TXT") + self.assertEqual(ncalls, 3) self.assertTrue(path.isfile('/tmp/.letsencrypt_test')) self.assertTrue( path.isfile(path.join(_get_data_dir(), 'www.example.com.crt')) From a139a5bf6c0bac48e99cee853aa2ed0bfe94ea82 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Thu, 28 May 2020 12:22:55 +0200 Subject: [PATCH 06/22] fixup! [FIX] letsencrypt: Increase test coverage --- letsencrypt/tests/test_letsencrypt.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/letsencrypt/tests/test_letsencrypt.py b/letsencrypt/tests/test_letsencrypt.py index 4aeb21be97a..cd9621b02de 100644 --- a/letsencrypt/tests/test_letsencrypt.py +++ b/letsencrypt/tests/test_letsencrypt.py @@ -12,6 +12,11 @@ from odoo.exceptions import UserError, ValidationError from odoo.tests import SingleTransactionCase +try: + import dns.resolver +except ImportError: + pass + from ..models.letsencrypt import _get_data_dir, _get_challenge_dir @@ -100,8 +105,12 @@ def query_effect(domain, rectype): self.assertEqual(domain, "_acme-challenge.example.com.") self.assertEqual(rectype, "TXT") ncalls += 1 - if ncalls < 3: - return [] + if ncalls == 1: + raise dns.resolver.NXDOMAIN + elif ncalls == 2: + wrong_record = mock.Mock() + wrong_record.to_text.return_value = '"not right"' + return [wrong_record] else: return [record] From 47e0c0ff83195fa467f5c072d48bed5f253a7bb1 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Mon, 15 Jun 2020 18:29:39 +0200 Subject: [PATCH 07/22] fixup! [MIG] Migrate letsencrypt 2.0.0 to Odoo 11.0 --- letsencrypt/migrations/11.0.2.0.0/post-migrate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py index 91962b6a04e..60a66058c26 100644 --- a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py +++ b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py @@ -22,14 +22,18 @@ def migrate_cron(env): jobs = ( env["ir.cron"] .with_context(active_test=False) - .search([("model", "=", "letsencrypt"), ("function", "=", "cron")]) + .search( + [ + ("ir_actions_server_id.model_id.model", "=", "letsencrypt"), + ("ir_actions_server_id.code", "=", "model.cron()"), + ] + ) ) if not jobs: # ir.cron._try_lock doesn't handle empty recordsets well return - jobs.write( - {"function": "_cron", "interval_type": "days", "interval_number": "1"} - ) + jobs.write({"interval_type": "days", "interval_number": "1"}) + jobs.mapped("ir_actions_server_id").write({"code": "model._cron()"}) def migrate(cr, version): From ad6284bce27fe2fe29b369fadb3260e937782b3c Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Thu, 6 Aug 2020 10:40:03 +0200 Subject: [PATCH 08/22] fixup! [IMP] Refactor and debug Let's Encrypt ACMEv2 support --- letsencrypt/models/res_config_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/models/res_config_settings.py b/letsencrypt/models/res_config_settings.py index f35a6f8b96b..7200b20c584 100644 --- a/letsencrypt/models/res_config_settings.py +++ b/letsencrypt/models/res_config_settings.py @@ -56,8 +56,8 @@ class ResConfigSettings(models.TransientModel): string="Prefer DNS validation", help=( "Validate through DNS even when HTTP validation is possible. " - "Use this if your Odoo instance isn't publicly accessible.", - ) + "Use this if your Odoo instance isn't publicly accessible." + ), ) @api.onchange('letsencrypt_altnames', 'letsencrypt_prefer_dns') From 9c40d41b940e6800d35e54994a716cff691e1e27 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Tue, 11 Aug 2020 15:58:52 +0200 Subject: [PATCH 09/22] [UPD] letsencrypt: Update hbrunn's email address --- letsencrypt/README.rst | 2 +- letsencrypt/readme/CONTRIBUTORS.rst | 2 +- letsencrypt/static/description/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/README.rst b/letsencrypt/README.rst index 5ba19fa2d81..5e252a04ca3 100644 --- a/letsencrypt/README.rst +++ b/letsencrypt/README.rst @@ -165,7 +165,7 @@ Authors Contributors ~~~~~~~~~~~~ -* Holger Brunn +* Holger Brunn * Antonio Espinosa * Dave Lasley * Ronald Portier diff --git a/letsencrypt/readme/CONTRIBUTORS.rst b/letsencrypt/readme/CONTRIBUTORS.rst index 1aeb78d8db1..46b22c02abe 100644 --- a/letsencrypt/readme/CONTRIBUTORS.rst +++ b/letsencrypt/readme/CONTRIBUTORS.rst @@ -1,4 +1,4 @@ -* Holger Brunn +* Holger Brunn * Antonio Espinosa * Dave Lasley * Ronald Portier diff --git a/letsencrypt/static/description/index.html b/letsencrypt/static/description/index.html index f24ebd0d6b4..d94531e8d7a 100644 --- a/letsencrypt/static/description/index.html +++ b/letsencrypt/static/description/index.html @@ -506,7 +506,7 @@

    Authors

    Contributors

      -
    • Holger Brunn <hbrunn@therp.nl>
    • +
    • Holger Brunn <mail@hunki-enterprises.nl>
    • Antonio Espinosa <antonio.espinosa@tecnativa.com>
    • Dave Lasley <dave@laslabs.com>
    • Ronald Portier <ronald@therp.nl>
    • From c691a29985027e68fa4e0d22bbf855bb72a1a177 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Tue, 11 Aug 2020 17:05:38 +0200 Subject: [PATCH 10/22] [IMP] letsencrypt: Handle web.base.url in a saner way --- .../migrations/11.0.2.0.0/post-migrate.py | 15 ++++++++-- letsencrypt/models/letsencrypt.py | 14 ++++----- letsencrypt/models/res_config_settings.py | 2 +- letsencrypt/tests/test_letsencrypt.py | 29 ++++++++++++------- letsencrypt/views/res_config_settings.xml | 2 +- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py index 60a66058c26..6b5a1d2eec2 100644 --- a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py +++ b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py @@ -1,5 +1,7 @@ # Copyright 2018 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import urllib.parse + from odoo import api, SUPERUSER_ID @@ -10,8 +12,17 @@ def migrate_altnames(env): # We may be migrating from 10.0.2.0.0, in which case # letsencrypt.altnames already exists and shouldn't be clobbered. return - new_domains = "\n".join(existing.mapped("value")) - config.set_param("letsencrypt.altnames", new_domains) + domains = existing.mapped("value") + base_url = config.get_param("web.base.url", "http://localhost:8069") + base_domain = urllib.parse.urlparse(base_url).hostname + if ( + domains + and base_domain + and base_domain != "localhost" + and base_domain not in domains + ): + domains.insert(0, base_domain) + config.set_param("letsencrypt.altnames", "\n".join(domains)) existing.unlink() diff --git a/letsencrypt/models/letsencrypt.py b/letsencrypt/models/letsencrypt.py index e973f43f9e4..305204310f0 100644 --- a/letsencrypt/models/letsencrypt.py +++ b/letsencrypt/models/letsencrypt.py @@ -196,11 +196,11 @@ def _should_run(self, cert_file, domains): @api.model def _cron(self): ir_config_parameter = self.env['ir.config_parameter'] - base_url = ir_config_parameter.get_param('web.base.url', 'localhost') - domain = urllib.parse.urlparse(base_url).hostname + domains = self._get_altnames() + domain = domains[0] cert_file = os.path.join(_get_data_dir(), '%s.crt' % domain) - domains = self._cascade_domains([domain] + self._get_altnames()) + domains = self._cascade_domains(domains) for dom in domains: self._validate_domain(dom) @@ -390,11 +390,11 @@ def _cascade_domains(self, domains): @api.model def _get_altnames(self): """Get the configured altnames as a list of strings.""" - altnames = self.env['ir.config_parameter'].get_param( - 'letsencrypt.altnames' - ) + parameter = self.env['ir.config_parameter'] + altnames = parameter.get_param("letsencrypt.altnames") if not altnames: - return [] + base_url = parameter.get_param("web.base.url", "http://localhost") + return [urllib.parse.urlparse(base_url).hostname] return re.split('(?:,|\n| |;)+', altnames) @api.model diff --git a/letsencrypt/models/res_config_settings.py b/letsencrypt/models/res_config_settings.py index 7200b20c584..9d8aa09219d 100644 --- a/letsencrypt/models/res_config_settings.py +++ b/letsencrypt/models/res_config_settings.py @@ -17,7 +17,7 @@ class ResConfigSettings(models.TransientModel): string="Domain names", default='', help=( - 'Additional domains to include on the CSR. ' + 'Domains to use for the certificate. ' 'Separate with commas or newlines.' ), ) diff --git a/letsencrypt/tests/test_letsencrypt.py b/letsencrypt/tests/test_letsencrypt.py index cd9621b02de..4e43c1f711c 100644 --- a/letsencrypt/tests/test_letsencrypt.py +++ b/letsencrypt/tests/test_letsencrypt.py @@ -39,7 +39,7 @@ def setUp(self): { 'letsencrypt_dns_provider': 'shell', 'letsencrypt_dns_shell_script': 'touch /tmp/.letsencrypt_test', - 'letsencrypt_altnames': '*.example.com', + 'letsencrypt_altnames': 'www.example.com,*.example.com', 'letsencrypt_reload_command': 'echo reloaded', } ).set_values() @@ -51,7 +51,10 @@ def test_config_settings(self): setting_vals['letsencrypt_dns_shell_script'], 'touch /tmp/.letsencrypt_test', ) - self.assertEqual(setting_vals['letsencrypt_altnames'], '*.example.com') + self.assertEqual( + setting_vals['letsencrypt_altnames'], + 'www.example.com,*.example.com' + ) self.assertEqual(setting_vals['letsencrypt_reload_command'], 'echo reloaded') self.assertTrue(setting_vals['letsencrypt_needs_dns_provider']) self.assertFalse(setting_vals['letsencrypt_prefer_dns']) @@ -66,7 +69,7 @@ def test_config_settings(self): def test_http_challenge(self, poll, _answer_challenge): letsencrypt = self.env['letsencrypt'] self.env['res.config.settings'].create( - {'letsencrypt_altnames': 'test.example.com'} + {'letsencrypt_altnames': ''} ).set_values() letsencrypt._cron() poll.assert_called() @@ -142,9 +145,6 @@ def test_prefer_dns_setting(self): 'letsencrypt_prefer_dns': True, } ).set_values() - self.env['ir.config_parameter'].set_param( - 'web.base.url', 'http://example.com' - ) # pylint: disable=no-value-for-parameter self.test_dns_challenge() @@ -189,14 +189,17 @@ def test_altnames_parsing(self): config = self.env['ir.config_parameter'] letsencrypt = self.env['letsencrypt'] - self.assertEqual(letsencrypt._get_altnames(), ['*.example.com']) + self.assertEqual( + letsencrypt._get_altnames(), + ['www.example.com', '*.example.com'] + ) config.set_param('letsencrypt.altnames', '') - self.assertEqual(letsencrypt._get_altnames(), []) - - config.set_param('letsencrypt.altnames', 'www.example.com') self.assertEqual(letsencrypt._get_altnames(), ['www.example.com']) + config.set_param('letsencrypt.altnames', 'foobar.example.com') + self.assertEqual(letsencrypt._get_altnames(), ['foobar.example.com']) + config.set_param( 'letsencrypt.altnames', 'example.com,example.org,example.net' ) @@ -294,6 +297,12 @@ def test_new_altnames(self): ['www.example.com', '*.example.com'], ) ) + self.assertFalse( + self.env['letsencrypt']._should_run( + path.join(_get_data_dir(), 'www.example.com.crt'), + ['www.example.com'], + ) + ) def test_legacy_certificate_without_altnames(self): self.install_certificate(60, use_altnames=False) diff --git a/letsencrypt/views/res_config_settings.xml b/letsencrypt/views/res_config_settings.xml index 5bb56b4299c..eacc48d8d76 100644 --- a/letsencrypt/views/res_config_settings.xml +++ b/letsencrypt/views/res_config_settings.xml @@ -20,7 +20,7 @@
      From 1b2fc17a9cf519cc2992857af78282e3d3c9295c Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 12 Aug 2020 13:46:22 +0200 Subject: [PATCH 11/22] [FIX] Modernize XML root tags --- letsencrypt/data/ir_config_parameter.xml | 32 +++++++++++------------- letsencrypt/data/ir_cron.xml | 24 ++++++++---------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/letsencrypt/data/ir_config_parameter.xml b/letsencrypt/data/ir_config_parameter.xml index 804b1357858..737ca4bec40 100644 --- a/letsencrypt/data/ir_config_parameter.xml +++ b/letsencrypt/data/ir_config_parameter.xml @@ -1,22 +1,20 @@ - - + - - letsencrypt.reload_command - sudo /usr/sbin/service nginx reload - + + letsencrypt.reload_command + sudo /usr/sbin/service nginx reload + - - letsencrypt.backoff - 3 - + + letsencrypt.backoff + 3 + - diff --git a/letsencrypt/data/ir_cron.xml b/letsencrypt/data/ir_cron.xml index e707ba15cc9..cd8232c5d20 100644 --- a/letsencrypt/data/ir_cron.xml +++ b/letsencrypt/data/ir_cron.xml @@ -1,14 +1,12 @@ - - - - Check Let's Encrypt certificates - - code - model._cron() - days - 1 - -1 - - - + + + Check Let's Encrypt certificates + + code + model._cron() + days + 1 + -1 + + From f90d845d99772754af04f9dcce1ed0d96ced3951 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Mon, 14 Dec 2020 17:30:02 +0100 Subject: [PATCH 12/22] [IMP] letsencrypt: Make domain and key locations fixed The domain-based filename is fragile. Just reordering domains in the settings can change it. If migrating from an old version symlinks are used to avoid breaking anything. --- .../migrations/11.0.2.0.0/post-migrate.py | 47 ++++++++++++++----- letsencrypt/models/letsencrypt.py | 4 +- letsencrypt/tests/test_letsencrypt.py | 24 ++++------ 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py index 6b5a1d2eec2..3f11f21d46b 100644 --- a/letsencrypt/migrations/11.0.2.0.0/post-migrate.py +++ b/letsencrypt/migrations/11.0.2.0.0/post-migrate.py @@ -1,29 +1,50 @@ # Copyright 2018 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import os import urllib.parse from odoo import api, SUPERUSER_ID +from odoo.addons.letsencrypt.models.letsencrypt import _get_data_dir + def migrate_altnames(env): config = env["ir.config_parameter"] - existing = config.search([("key", "=like", "letsencrypt.altname.%")]) - if not existing: + existing = config.search( + [("key", "=like", "letsencrypt.altname.%")], order="key" + ) + base_url = config.get_param("web.base.url", "http://localhost:8069") + if existing: # We may be migrating from 10.0.2.0.0, in which case # letsencrypt.altnames already exists and shouldn't be clobbered. - return - domains = existing.mapped("value") - base_url = config.get_param("web.base.url", "http://localhost:8069") - base_domain = urllib.parse.urlparse(base_url).hostname + domains = existing.mapped("value") + base_domain = urllib.parse.urlparse(base_url).hostname + if ( + domains + and base_domain + and base_domain != "localhost" + and base_domain not in domains + ): + domains.insert(0, base_domain) + config.set_param("letsencrypt.altnames", "\n".join(domains)) + existing.unlink() + + old_location = os.path.join( + # .netloc includes the port, which is not right, but that's what + # the old version did and we're trying to match it + _get_data_dir(), urllib.parse.urlparse(base_url).netloc + ) + new_location = os.path.join(_get_data_dir(), "domain") if ( - domains - and base_domain - and base_domain != "localhost" - and base_domain not in domains + os.path.isfile(old_location + ".crt") + and os.path.isfile(old_location + ".key") + and not os.path.isfile(new_location + ".crt") + and not os.path.isfile(new_location + ".key") ): - domains.insert(0, base_domain) - config.set_param("letsencrypt.altnames", "\n".join(domains)) - existing.unlink() + os.rename(old_location + ".crt", new_location + ".crt") + os.symlink(new_location + ".crt", old_location + ".crt") + os.rename(old_location + ".key", new_location + ".key") + os.symlink(new_location + ".key", old_location + ".key") def migrate_cron(env): diff --git a/letsencrypt/models/letsencrypt.py b/letsencrypt/models/letsencrypt.py index 305204310f0..3f06ce798d5 100644 --- a/letsencrypt/models/letsencrypt.py +++ b/letsencrypt/models/letsencrypt.py @@ -198,7 +198,7 @@ def _cron(self): ir_config_parameter = self.env['ir.config_parameter'] domains = self._get_altnames() domain = domains[0] - cert_file = os.path.join(_get_data_dir(), '%s.crt' % domain) + cert_file = os.path.join(_get_data_dir(), 'domain.crt') domains = self._cascade_domains(domains) for dom in domains: @@ -208,7 +208,7 @@ def _cron(self): return account_key = josepy.JWKRSA.load(self._get_key('account.key')) - domain_key = self._get_key('%s.key' % domain) + domain_key = self._get_key('domain.key') client = self._create_client(account_key) new_reg = acme.messages.NewRegistration( diff --git a/letsencrypt/tests/test_letsencrypt.py b/letsencrypt/tests/test_letsencrypt.py index 4e43c1f711c..8989f1c9456 100644 --- a/letsencrypt/tests/test_letsencrypt.py +++ b/letsencrypt/tests/test_letsencrypt.py @@ -75,9 +75,7 @@ def test_http_challenge(self, poll, _answer_challenge): poll.assert_called() self.assertTrue(os.listdir(_get_challenge_dir())) self.assertFalse(path.isfile('/tmp/.letsencrypt_test')) - self.assertTrue( - path.isfile(path.join(_get_data_dir(), 'www.example.com.crt')) - ) + self.assertTrue(path.isfile(path.join(_get_data_dir(), 'domain.crt'))) # pylint: disable=unused-argument @mock.patch('odoo.addons.letsencrypt.models.letsencrypt.DNSUpdate') @@ -124,9 +122,7 @@ def query_effect(domain, rectype): poll.assert_called() self.assertEqual(ncalls, 3) self.assertTrue(path.isfile('/tmp/.letsencrypt_test')) - self.assertTrue( - path.isfile(path.join(_get_data_dir(), 'www.example.com.crt')) - ) + self.assertTrue(path.isfile(path.join(_get_data_dir(), 'domain.crt'))) def test_dns_challenge_error_on_missing_provider(self): self.env['res.config.settings'].create( @@ -258,7 +254,7 @@ def test_young_certificate(self): self.install_certificate(60) self.assertFalse( self.env['letsencrypt']._should_run( - path.join(_get_data_dir(), 'www.example.com.crt'), + path.join(_get_data_dir(), 'domain.crt'), ['www.example.com', '*.example.com'], ) ) @@ -267,7 +263,7 @@ def test_old_certificate(self): self.install_certificate(20) self.assertTrue( self.env['letsencrypt']._should_run( - path.join(_get_data_dir(), 'www.example.com.crt'), + path.join(_get_data_dir(), 'domain.crt'), ['www.example.com', '*.example.com'], ) ) @@ -276,7 +272,7 @@ def test_expired_certificate(self): self.install_certificate(-10) self.assertTrue( self.env['letsencrypt']._should_run( - path.join(_get_data_dir(), 'www.example.com.crt'), + path.join(_get_data_dir(), 'domain.crt'), ['www.example.com', '*.example.com'], ) ) @@ -284,7 +280,7 @@ def test_expired_certificate(self): def test_missing_certificate(self): self.assertTrue( self.env['letsencrypt']._should_run( - path.join(_get_data_dir(), 'www.example.com.crt'), + path.join(_get_data_dir(), 'domain.crt'), ['www.example.com', '*.example.com'], ) ) @@ -293,13 +289,13 @@ def test_new_altnames(self): self.install_certificate(60, 'www.example.com', ()) self.assertTrue( self.env['letsencrypt']._should_run( - path.join(_get_data_dir(), 'www.example.com.crt'), + path.join(_get_data_dir(), 'domain.crt'), ['www.example.com', '*.example.com'], ) ) self.assertFalse( self.env['letsencrypt']._should_run( - path.join(_get_data_dir(), 'www.example.com.crt'), + path.join(_get_data_dir(), 'domain.crt'), ['www.example.com'], ) ) @@ -308,7 +304,7 @@ def test_legacy_certificate_without_altnames(self): self.install_certificate(60, use_altnames=False) self.assertFalse( self.env['letsencrypt']._should_run( - path.join(_get_data_dir(), 'www.example.com.crt'), + path.join(_get_data_dir(), 'domain.crt'), ['www.example.com'], ) ) @@ -359,7 +355,7 @@ def install_certificate( ) cert = cert_builder.sign(key, hashes.SHA256(), default_backend()) - cert_file = path.join(_get_data_dir(), '%s.crt' % common_name) + cert_file = path.join(_get_data_dir(), 'domain.crt') with open(cert_file, 'wb') as file_: file_.write(cert.public_bytes(serialization.Encoding.PEM)) From f5bf7bf63fc21a1f10b7f3695aa120ec991403a1 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 12 Apr 2021 13:21:31 +0000 Subject: [PATCH 13/22] [UPD] Update auto_backup.pot --- auto_backup/i18n/auto_backup.pot | 39 +++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/auto_backup/i18n/auto_backup.pot b/auto_backup/i18n/auto_backup.pot index 8bf4c3f9584..9ed534b213c 100644 --- a/auto_backup/i18n/auto_backup.pot +++ b/auto_backup/i18n/auto_backup.pot @@ -51,6 +51,13 @@ msgstr "" msgid "Backup Scheduler" msgstr "" +#. module: auto_backup +#: model:ir.actions.server,name:auto_backup.ir_cron_backup_scheduler_hourly_ir_actions_server +#: model:ir.cron,cron_name:auto_backup.ir_cron_backup_scheduler_hourly +#: model:ir.cron,name:auto_backup.ir_cron_backup_scheduler_hourly +msgid "Backup Scheduler Hourly" +msgstr "" + #. module: auto_backup #: model:mail.message.subtype,name:auto_backup.mail_message_subtype_success msgid "Backup Successful" @@ -82,19 +89,19 @@ msgid "Choose the storage method for this backup." msgstr "" #. module: auto_backup -#: code:addons/auto_backup/models/db_backup.py:265 +#: code:addons/auto_backup/models/db_backup.py:279 #, python-format msgid "Cleanup of old database backups failed." msgstr "" #. module: auto_backup -#: code:addons/auto_backup/models/db_backup.py:137 +#: code:addons/auto_backup/models/db_backup.py:144 #, python-format msgid "Connection Test Failed!" msgstr "" #. module: auto_backup -#: code:addons/auto_backup/models/db_backup.py:132 +#: code:addons/auto_backup/models/db_backup.py:139 #, python-format msgid "Connection Test Succeeded!" msgstr "" @@ -109,20 +116,25 @@ msgstr "" msgid "Created on" msgstr "" +#. module: auto_backup +#: selection:db.backup,frequency:0 +msgid "Daily" +msgstr "" + #. module: auto_backup #: model:ir.model,name:auto_backup.model_db_backup msgid "Database Backup" msgstr "" #. module: auto_backup -#: code:addons/auto_backup/models/db_backup.py:219 +#: code:addons/auto_backup/models/db_backup.py:230 #: model:mail.message.subtype,description:auto_backup.mail_message_subtype_failure #, python-format msgid "Database backup failed." msgstr "" #. module: auto_backup -#: code:addons/auto_backup/models/db_backup.py:227 +#: code:addons/auto_backup/models/db_backup.py:238 #: model:mail.message.subtype,description:auto_backup.mail_message_subtype_success #, python-format msgid "Database backup succeeded." @@ -139,7 +151,7 @@ msgid "Display Name" msgstr "" #. module: auto_backup -#: code:addons/auto_backup/models/db_backup.py:123 +#: code:addons/auto_backup/models/db_backup.py:130 #, python-format msgid "Do not save backups on your filestore, or you will backup your backups too!" msgstr "" @@ -159,6 +171,11 @@ msgstr "" msgid "Folder" msgstr "" +#. module: auto_backup +#: model:ir.model.fields,field_description:auto_backup.field_db_backup_frequency +msgid "Frequency" +msgstr "" + #. module: auto_backup #: model:ir.ui.view,arch_db:auto_backup.view_backup_conf_form msgid "Go to Settings / Technical / Automation / Scheduled Actions." @@ -169,6 +186,16 @@ msgstr "" msgid "Help" msgstr "" +#. module: auto_backup +#: selection:db.backup,frequency:0 +msgid "Hourly" +msgstr "" + +#. module: auto_backup +#: model:ir.model.fields,help:auto_backup.field_db_backup_frequency +msgid "How often this backup is ran." +msgstr "" + #. module: auto_backup #: sql_constraint:db.backup:0 msgid "I cannot remove backups from the future. Ask Doc for that." From e6f0846e22bd5c7236b4e4cadcb4dee7f7bb6188 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 12 Apr 2021 13:21:41 +0000 Subject: [PATCH 14/22] [UPD] Update letsencrypt.pot --- letsencrypt/i18n/letsencrypt.pot | 150 +++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 7 deletions(-) diff --git a/letsencrypt/i18n/letsencrypt.pot b/letsencrypt/i18n/letsencrypt.pot index a6db4f01186..6d41152edfb 100644 --- a/letsencrypt/i18n/letsencrypt.pot +++ b/letsencrypt/i18n/letsencrypt.pot @@ -13,22 +13,83 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: letsencrypt +#: code:addons/letsencrypt/models/letsencrypt.py:375 +#, python-format +msgid "A wildcard is only allowed at the start of a domain" +msgstr "" + #. module: letsencrypt #: model:ir.model,name:letsencrypt.model_letsencrypt msgid "Abstract model providing functions for letsencrypt" msgstr "" +#. module: letsencrypt +#: model:ir.actions.server,name:letsencrypt.cronjob_ir_actions_server +#: model:ir.cron,cron_name:letsencrypt.cronjob +#: model:ir.cron,name:letsencrypt.cronjob +msgid "Check Let's Encrypt certificates" +msgstr "" + +#. module: letsencrypt +#: code:addons/letsencrypt/models/letsencrypt.py:281 +#, python-format +msgid "Could not respond to letsencrypt challenges." +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,field_description:letsencrypt.field_res_config_settings_letsencrypt_dns_provider +msgid "DNS provider" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,field_description:letsencrypt.field_res_config_settings_letsencrypt_dns_shell_script +msgid "DNS update script" +msgstr "" + #. module: letsencrypt #: model:ir.model.fields,field_description:letsencrypt.field_letsencrypt_display_name msgid "Display Name" msgstr "" #. module: letsencrypt -#: code:addons/letsencrypt/models/letsencrypt.py:43 +#: code:addons/letsencrypt/models/letsencrypt.py:124 +#, python-format +msgid "Domain %s: Let's Encrypt doesn't support IP addresses!" +msgstr "" + +#. module: letsencrypt +#: code:addons/letsencrypt/models/letsencrypt.py:130 +#, python-format +msgid "Domain %s: Let's encrypt doesn't work with local domains!" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,field_description:letsencrypt.field_res_config_settings_letsencrypt_altnames +msgid "Domain names" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,help:letsencrypt.field_res_config_settings_letsencrypt_altnames +msgid "Domains to use for the certificate. Separate with commas or newlines." +msgstr "" + +#. module: letsencrypt +#: code:addons/letsencrypt/models/letsencrypt.py:445 #, python-format msgid "Error calling %s: %d" msgstr "" +#. module: letsencrypt +#: model:ir.model.fields,help:letsencrypt.field_res_config_settings_letsencrypt_reload_command +msgid "Fill this with the command to restart your web server." +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,help:letsencrypt.field_res_config_settings_letsencrypt_dns_provider +msgid "For wildcard certificates we need to add a TXT record on your DNS. If you set this to \"Shell script\" you can enter a shell script. Other options can be added by installing additional modules." +msgstr "" + #. module: letsencrypt #: model:ir.model.fields,field_description:letsencrypt.field_letsencrypt_id msgid "ID" @@ -40,15 +101,90 @@ msgid "Last Modified on" msgstr "" #. module: letsencrypt -#: code:addons/letsencrypt/models/letsencrypt.py:90 +#: model:ir.ui.view,arch_db:letsencrypt.res_config_settings_view_form +msgid "Let's Encrypt" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,field_description:letsencrypt.field_res_config_settings_letsencrypt_needs_dns_provider +msgid "Letsencrypt Needs Dns Provider" +msgstr "" + +#. module: letsencrypt +#: model:ir.ui.view,arch_db:letsencrypt.res_config_settings_view_form +msgid "List the domains for the certificate" +msgstr "" + +#. module: letsencrypt +#: code:addons/letsencrypt/models/letsencrypt.py:421 #, python-format -msgid "Let's encrypt doesn't work with private addresses or local domains!" +msgid "No DNS provider set, can't request wildcard certificate" msgstr "" #. module: letsencrypt -#: model:ir.actions.server,name:letsencrypt.cronjob_ir_actions_server -#: model:ir.cron,cron_name:letsencrypt.cronjob -#: model:ir.cron,name:letsencrypt.cronjob -msgid "Update letsencrypt certificates" +#: code:addons/letsencrypt/models/letsencrypt.py:467 +#, python-format +msgid "No shell command configured for updating DNS records" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,field_description:letsencrypt.field_res_config_settings_letsencrypt_prefer_dns +msgid "Prefer DNS validation" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,field_description:letsencrypt.field_res_config_settings_letsencrypt_reload_command +msgid "Server reload command" +msgstr "" + +#. module: letsencrypt +#: model:ir.ui.view,arch_db:letsencrypt.res_config_settings_view_form +msgid "Set a DNS provider if you need wildcard certificates" +msgstr "" + +#. module: letsencrypt +#: selection:res.config.settings,letsencrypt_dns_provider:0 +msgid "Shell script" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,field_description:letsencrypt.field_res_config_settings_letsencrypt_testing_mode +msgid "Use testing server" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,help:letsencrypt.field_res_config_settings_letsencrypt_testing_mode +msgid "Use the Let's Encrypt staging server, which has higher rate limits but doesn't create valid certificates." +msgstr "" + +#. module: letsencrypt +#: model:ir.ui.view,arch_db:letsencrypt.res_config_settings_view_form +msgid "Use the testing server, which has higher rate limits but creates invalid certificates." +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,help:letsencrypt.field_res_config_settings_letsencrypt_prefer_dns +#: model:ir.ui.view,arch_db:letsencrypt.res_config_settings_view_form +msgid "Validate through DNS even when HTTP validation is possible. Use this if your Odoo instance isn't publicly accessible." +msgstr "" + +#. module: letsencrypt +#: model:ir.ui.view,arch_db:letsencrypt.res_config_settings_view_form +msgid "Write a command to reload the server" +msgstr "" + +#. module: letsencrypt +#: model:ir.model.fields,help:letsencrypt.field_res_config_settings_letsencrypt_dns_shell_script +msgid "Write a shell script that will update your DNS TXT records. You can use the $LETSENCRYPT_DNS_CHALLENGE and $LETSENCRYPT_DNS_DOMAIN variables." +msgstr "" + +#. module: letsencrypt +#: model:ir.ui.view,arch_db:letsencrypt.res_config_settings_view_form +msgid "Write a shell script to update your DNS records" +msgstr "" + +#. module: letsencrypt +#: model:ir.model,name:letsencrypt.model_res_config_settings +msgid "res.config.settings" msgstr "" From 58055b4c5bd5ac4156bf95461525d53d4b2860fd Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 12 Apr 2021 13:21:42 +0000 Subject: [PATCH 15/22] [UPD] Update mail_cleanup.pot --- mail_cleanup/i18n/mail_cleanup.pot | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/mail_cleanup/i18n/mail_cleanup.pot b/mail_cleanup/i18n/mail_cleanup.pot index 14adb05d505..78af94ba069 100644 --- a/mail_cleanup/i18n/mail_cleanup.pot +++ b/mail_cleanup/i18n/mail_cleanup.pot @@ -4,7 +4,7 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 9.0c\n" +"Project-Id-Version: Odoo Server 11.0\n" "Report-Msgid-Bugs-To: \n" "Last-Translator: <>\n" "Language-Team: \n" @@ -13,6 +13,26 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: mail_cleanup +#: model:ir.model.fields,field_description:mail_cleanup.field_fetchmail_server_cleanup_days_env_default +msgid "Cleanup Days Env Default" +msgstr "" + +#. module: mail_cleanup +#: model:ir.model.fields,field_description:mail_cleanup.field_fetchmail_server_cleanup_days_env_is_editable +msgid "Cleanup Days Env Is Editable" +msgstr "" + +#. module: mail_cleanup +#: model:ir.model.fields,field_description:mail_cleanup.field_fetchmail_server_cleanup_folder_env_default +msgid "Cleanup Folder Env Default" +msgstr "" + +#. module: mail_cleanup +#: model:ir.model.fields,field_description:mail_cleanup.field_fetchmail_server_cleanup_folder_env_is_editable +msgid "Cleanup Folder Env Is Editable" +msgstr "" + #. module: mail_cleanup #: model:ir.model.fields,field_description:mail_cleanup.field_fetchmail_server_purge_days msgid "Deletion days" @@ -48,3 +68,13 @@ msgstr "" msgid "POP/IMAP Server" msgstr "" +#. module: mail_cleanup +#: model:ir.model.fields,field_description:mail_cleanup.field_fetchmail_server_purge_days_env_default +msgid "Purge Days Env Default" +msgstr "" + +#. module: mail_cleanup +#: model:ir.model.fields,field_description:mail_cleanup.field_fetchmail_server_purge_days_env_is_editable +msgid "Purge Days Env Is Editable" +msgstr "" + From c525822dcac1c1f3cb9dfdbd766c8c6e98e98163 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 12 Apr 2021 13:21:43 +0000 Subject: [PATCH 16/22] [UPD] Update profiler.pot --- profiler/i18n/profiler.pot | 140 ++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 3 deletions(-) diff --git a/profiler/i18n/profiler.pot b/profiler/i18n/profiler.pot index 73a683931dc..6f94db055d6 100644 --- a/profiler/i18n/profiler.pot +++ b/profiler/i18n/profiler.pot @@ -13,6 +13,18 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: profiler +#: selection:profiler.profile,python_method:0 +msgid "All activity" +msgstr "" + +#. module: profiler +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:17 +#, python-format +msgid "Analyze your application performance in the Profiler app." +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_attachment_count msgid "Attachment Count" @@ -38,15 +50,22 @@ msgstr "" msgid "Clear" msgstr "" +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_user_context +msgid "Context" +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_create_uid #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_create_uid +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_create_uid msgid "Created by" msgstr "" #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_create_date #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_create_date +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_create_date msgid "Created on" msgstr "" @@ -116,9 +135,27 @@ msgstr "" msgid "Getting the path to the logger" msgstr "" +#. module: profiler +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:37 +#, python-format +msgid "Give this session a name." +msgstr "" + +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_py_request_lines +msgid "HTTP requests" +msgstr "" + +#. module: profiler +#: model:ir.model,name:profiler.model_ir_http +msgid "HTTP routing" +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_id #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_id +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_id msgid "ID" msgstr "" @@ -135,7 +172,7 @@ msgid "It requires postgresql server logs seudo-enabled" msgstr "" #. module: profiler -#: code:addons/profiler/models/profiler_profile.py:250 +#: code:addons/profiler/models/profiler_profile.py:372 #, python-format msgid "It's not possible change parameter.\n" "%s\n" @@ -145,31 +182,66 @@ msgstr "" #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile___last_update #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line___last_update +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line___last_update msgid "Last Modified on" msgstr "" #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_write_uid +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_write_uid #: model:ir.model.fields,field_description:profiler.field_profiler_profile_write_uid msgid "Last Updated by" msgstr "" #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_write_date +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_write_date #: model:ir.model.fields,field_description:profiler.field_profiler_profile_write_date msgid "Last Updated on" msgstr "" +#. module: profiler +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:32 +#, python-format +msgid "Let's create a new profiler session." +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_name +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_display_name msgid "Name" msgstr "" +#. module: profiler +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:60 +#, python-format +msgid "Now disable it to stop profiling." +msgstr "" + +#. module: profiler +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:54 +#, python-format +msgid "Now enable it to start profiling." +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_py_stats_lines msgid "PY Stats Lines" msgstr "" +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_name +msgid "Path" +msgstr "" + +#. module: profiler +#: selection:profiler.profile,python_method:0 +msgid "Per HTTP request" +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_pg_log_path msgid "Pg Log Path" @@ -200,6 +272,7 @@ msgstr "" #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_profile_id +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_profile_id #: model:ir.ui.menu,name:profiler.menu_profile #: model:ir.ui.view,arch_db:profiler.view_profile_form msgid "Profile" @@ -212,6 +285,11 @@ msgstr "" msgid "Profiler" msgstr "" +#. module: profiler +#: model:ir.model,name:profiler.model_profiler_profile_request_line +msgid "Profiler HTTP request Line to save cProfiling results" +msgstr "" + #. module: profiler #: model:ir.model,name:profiler.model_profiler_profile msgid "Profiler Profile" @@ -227,25 +305,41 @@ msgstr "" msgid "Profiling lines" msgstr "" +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_method +msgid "Python Method" +msgstr "" + #. module: profiler #: model:ir.ui.view,arch_db:profiler.view_profile_form msgid "Python Stats - Profiling Lines" msgstr "" +#. module: profiler +#: model:ir.ui.view,arch_db:profiler.view_profile_form +msgid "Python Stats - Requests" +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_cprof_nrcalls msgid "Recursive Calls" msgstr "" +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_root_url +msgid "Root URL" +msgstr "" + #. module: profiler #: model:ir.ui.view,arch_db:profiler.view_profiling_lines_search msgid "Search Profiling lines" msgstr "" #. module: profiler -#: code:addons/profiler/models/profiler_profile.py:219 +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:43 #, python-format -msgid "Start the odoo server using the parameter '--workers=0'" +msgid "Select a profiling method." msgstr "" #. module: profiler @@ -253,11 +347,22 @@ msgstr "" msgid "State" msgstr "" +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_total_time +msgid "Time in ms" +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_cprof_ttpercall msgid "Time per call" msgstr "" +#. module: profiler +#: code:addons/profiler/models/profiler_profile.py:337 +#, python-format +msgid "To profile all activity, start the odoo server using the parameter '--workers=0'" +msgstr "" + #. module: profiler #: model:ir.model.fields,field_description:profiler.field_profiler_profile_python_line_cprof_tottime msgid "Total time" @@ -268,8 +373,37 @@ msgstr "" msgid "Use Py Index" msgstr "" +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_user_id +msgid "User" +msgstr "" + #. module: profiler #: model:ir.ui.view,arch_db:profiler.view_profile_form msgid "View profiling lines" msgstr "" +#. module: profiler +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:66 +#, python-format +msgid "We now have measurements." +msgstr "" + +#. module: profiler +#. openerp-web +#: code:addons/profiler/static/src/js/tour.js:49 +#, python-format +msgid "When you are happy, save it." +msgstr "" + +#. module: profiler +#: selection:profiler.profile.request.line,attachment_id:0 +msgid "ir.attachment" +msgstr "" + +#. module: profiler +#: model:ir.model.fields,field_description:profiler.field_profiler_profile_request_line_attachment_id +msgid "pStats file" +msgstr "" + From f58ecc208754b5c64f741da2dec92740b0d93534 Mon Sep 17 00:00:00 2001 From: oca-travis Date: Mon, 12 Apr 2021 13:21:46 +0000 Subject: [PATCH 17/22] [UPD] Update users_ldap_groups.pot --- users_ldap_groups/i18n/users_ldap_groups.pot | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/users_ldap_groups/i18n/users_ldap_groups.pot b/users_ldap_groups/i18n/users_ldap_groups.pot index 278f12d6889..a98fb65f176 100644 --- a/users_ldap_groups/i18n/users_ldap_groups.pot +++ b/users_ldap_groups/i18n/users_ldap_groups.pot @@ -4,7 +4,7 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 10.0\n" +"Project-Id-Version: Odoo Server 11.0\n" "Report-Msgid-Bugs-To: \n" "Last-Translator: <>\n" "Language-Team: \n" @@ -82,7 +82,7 @@ msgid "Last Updated on" msgstr "" #. module: users_ldap_groups -#: model:ir.ui.view,arch_db:users_ldap_groups.company_form_view +#: model:ir.ui.view,arch_db:users_ldap_groups.view_ldap_installer_form msgid "Map User Groups" msgstr "" From ba9133e0b0065e6c7f1ab37779a70c20e387d720 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 12 Apr 2021 14:05:00 +0000 Subject: [PATCH 18/22] [UPD] README.rst --- letsencrypt/static/description/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/static/description/index.html b/letsencrypt/static/description/index.html index d94531e8d7a..0d4d252b713 100644 --- a/letsencrypt/static/description/index.html +++ b/letsencrypt/static/description/index.html @@ -3,7 +3,7 @@ - + Let's Encrypt