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
+
+
+
+
This module was written to have your Odoo installation request SSL certificates
+from https://letsencrypt.org automatically.
+
Table of contents
+
+
+
+
After installation, this module generates a private key for your account at
+letsencrypt.org automatically in $data_dir/letsencrypt/account.key . If you
+want or need to use your own account key, replace the file.
+
For certificate requests to work, your site needs to be accessible via plain
+HTTP, see below for configuration examples in case you force your clients to
+the SSL version.
+
After installation, trigger the cronjob Update letsencrypt certificates and
+watch your log for messages.
+
+
+
+
This 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 .
+
+
+
+
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.
+
+
+
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
+
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+
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.
+
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. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
-
+
This module was written to have your Odoo installation request SSL certificates
from https://letsencrypt.org automatically.
Table of contents
@@ -490,7 +490,7 @@
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 @@
Therp BV
Tecnativa
+Acysos S.L
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
+
+
+
+
+
+
List additional domains for the certificate
+
+
+
+
+
Write a command to reload the server
+
+
+
+
+
Set a DNS provider if you need wildcard certificates
+
+
+
+
+
+
Write a shell script to update your DNS records
+
+
+
+
+
+
+
+
+
Use the testing server, which has higher rate limits but creates invalid certificates.
+
+
+
+
+
+
+
Validate through DNS even when HTTP validation is possible. Use this if your Odoo instance isn't publicly accessible.
+
+
+
+
+
+
+
+
+
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 @@
-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 @@
-
List additional domains for the certificate
+
List the domains for the certificate
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