[IMP] letsencrypt: black, isort, prettier

pull/2236/head
Ronald Portier 2020-11-19 15:32:40 +01:00
parent fc9fd8f48e
commit 7db8ebadb4
11 changed files with 260 additions and 375 deletions

View File

@ -1,34 +1,20 @@
# © 2016 Therp BV <http://therp.nl> # 2016-2020 Therp BV <https://therp.nl>.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
{ {
"name": "Let's Encrypt", "name": "Let's Encrypt",
"version": "12.0.2.0.0", "version": "13.0.1.0.0",
"author": "Therp BV," "author": "Therp BV," "Tecnativa," "Acysos S.L," "Odoo Community Association (OCA)",
"Tecnativa,"
"Acysos S.L,"
"Odoo Community Association (OCA)",
"license": "AGPL-3", "license": "AGPL-3",
"category": "Hidden/Dependency", "category": "Hidden/Dependency",
"summary": "Request SSL certificates from letsencrypt.org", "summary": "Request SSL certificates from letsencrypt.org",
"depends": [ "depends": ["base_setup"],
"base_setup",
],
"data": [ "data": [
"data/ir_config_parameter.xml", "data/ir_config_parameter.xml",
"data/ir_cron.xml", "data/ir_cron.xml",
"views/res_config_settings.xml", "views/res_config_settings.xml",
], ],
"demo": [ "demo": ["demo/ir_cron.xml"],
"demo/ir_cron.xml",
],
"post_init_hook": "post_init_hook", "post_init_hook": "post_init_hook",
"installable": True, "installable": True,
"external_dependencies": { "external_dependencies": {"python": ["acme", "cryptography", "dns", "josepy"]},
"python": [
"acme",
"cryptography",
"dns",
"josepy",
],
},
} }

View File

@ -3,13 +3,15 @@
# © 2018 Ignacio Ibeas <ignacio@acysos.com> # © 2018 Ignacio Ibeas <ignacio@acysos.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import os import os
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
from ..models.letsencrypt import _get_challenge_dir from ..models.letsencrypt import _get_challenge_dir
class Letsencrypt(http.Controller): class Letsencrypt(http.Controller):
@http.route('/.well-known/acme-challenge/<filename>', auth='none') @http.route("/.well-known/acme-challenge/<filename>", auth="none")
def acme_challenge(self, filename): def acme_challenge(self, filename):
try: try:
with open(os.path.join(_get_challenge_dir(), filename)) as key: with open(os.path.join(_get_challenge_dir(), filename)) as key:

View File

@ -1,20 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1"> <odoo noupdate="1">
<record id="config_parameter_reload" model="ir.config_parameter" forcecreate="True">
<record
id="config_parameter_reload"
model="ir.config_parameter"
forcecreate="True">
<field name="key">letsencrypt.reload_command</field> <field name="key">letsencrypt.reload_command</field>
<field name="value">sudo /usr/sbin/service nginx reload</field> <field name="value">sudo /usr/sbin/service nginx reload</field>
</record> </record>
<record id="letsencrypt_backoff" model="ir.config_parameter" forcecreate="True">
<record
id="letsencrypt_backoff"
model="ir.config_parameter"
forcecreate="True">
<field name="key">letsencrypt.backoff</field> <field name="key">letsencrypt.backoff</field>
<field name="value">3</field> <field name="value">3</field>
</record> </record>
</odoo> </odoo>

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo noupdate="1"> <odoo noupdate="1">
<record id="cronjob" model="ir.cron"> <record id="cronjob" model="ir.cron">
<field name="name">Check Let's Encrypt certificates</field> <field name="name">Check Let's Encrypt certificates</field>
<field name="model_id" ref="model_letsencrypt"/> <field name="model_id" ref="model_letsencrypt" />
<field name="state">code</field> <field name="state">code</field>
<field name="code">model._cron()</field> <field name="code">model._cron()</field>
<field name="interval_type">days</field> <field name="interval_type">days</field>

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<record id="cronjob" model="ir.cron"> <record id="cronjob" model="ir.cron">
<field name="active" eval="False" /> <field name="active" eval="False" />

View File

@ -5,4 +5,4 @@ from odoo import SUPERUSER_ID, api
def post_init_hook(cr, pool): def post_init_hook(cr, pool):
env = api.Environment(cr, SUPERUSER_ID, {}) env = api.Environment(cr, SUPERUSER_ID, {})
env['letsencrypt']._get_key('account.key') env["letsencrypt"]._get_key("account.key")

View File

@ -1,52 +0,0 @@
# Copyright 2018 Therp BV <https://therp.nl>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import urllib.parse
from odoo import api, SUPERUSER_ID
def migrate_altnames(env):
config = env["ir.config_parameter"]
existing = config.search([("key", "=like", "letsencrypt.altname.%")])
if not existing:
# If letsencrypt.altnames already exists it 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
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()
def migrate_cron(env):
# 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(
[
("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({"interval_type": "days", "interval_number": "1"})
jobs.mapped("ir_actions_server_id").write({"code": "model._cron()"})
def migrate(cr, version):
env = api.Environment(cr, SUPERUSER_ID, {})
migrate_altnames(env)
migrate_cron(env)

View File

@ -1,7 +1,7 @@
# © 2016 Therp BV <http://therp.nl> # Copyright 2016-2020 Therp BV <https://therp.nl>.
# © 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com> # Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>.
# © 2018 Ignacio Ibeas <ignacio@acysos.com> # Copyright 2018 Ignacio Ibeas <ignacio@acysos.com>.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import base64 import base64
import collections import collections
@ -11,7 +11,6 @@ import re
import subprocess import subprocess
import time import time
import urllib.parse import urllib.parse
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests import requests
@ -39,45 +38,41 @@ try:
except ImportError as e: except ImportError as e:
_logger.debug(e) _logger.debug(e)
WILDCARD = '*.' # as defined in the spec WILDCARD = "*." # as defined in the spec
DEFAULT_KEY_LENGTH = 4096 DEFAULT_KEY_LENGTH = 4096
TYPE_CHALLENGE_HTTP = 'http-01' TYPE_CHALLENGE_HTTP = "http-01"
TYPE_CHALLENGE_DNS = 'dns-01' TYPE_CHALLENGE_DNS = "dns-01"
V2_STAGING_DIRECTORY_URL = ( V2_STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
'https://acme-staging-v02.api.letsencrypt.org/directory' V2_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
)
V2_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory'
LOCAL_DOMAINS = { LOCAL_DOMAINS = {
'localhost', "localhost",
'localhost.localdomain', "localhost.localdomain",
'localhost6', "localhost6",
'localhost6.localdomain6', "localhost6.localdomain6",
'ip6-localhost', "ip6-localhost",
'ip6-loopback', "ip6-loopback",
} }
DNSUpdate = collections.namedtuple( DNSUpdate = collections.namedtuple("DNSUpdate", ("challenge", "domain", "token"))
"DNSUpdate", ("challenge", "domain", "token")
)
def _get_data_dir(): def _get_data_dir():
dir_ = os.path.join(config.options.get('data_dir'), 'letsencrypt') dir_ = os.path.join(config.options.get("data_dir"), "letsencrypt")
if not os.path.isdir(dir_): if not os.path.isdir(dir_):
os.makedirs(dir_) os.makedirs(dir_)
return dir_ return dir_
def _get_challenge_dir(): def _get_challenge_dir():
dir_ = os.path.join(_get_data_dir(), 'acme-challenge') dir_ = os.path.join(_get_data_dir(), "acme-challenge")
if not os.path.isdir(dir_): if not os.path.isdir(dir_):
os.makedirs(dir_) os.makedirs(dir_)
return dir_ return dir_
class Letsencrypt(models.AbstractModel): class Letsencrypt(models.AbstractModel):
_name = 'letsencrypt' _name = "letsencrypt"
_description = 'Abstract model providing functions for letsencrypt' _description = "Abstract model providing functions for letsencrypt"
@api.model @api.model
def _generate_key(self): def _generate_key(self):
@ -100,7 +95,7 @@ class Letsencrypt(models.AbstractModel):
_logger.info("Generating new key %s", key_name) _logger.info("Generating new key %s", key_name)
key_bytes = self._generate_key() key_bytes = self._generate_key()
try: try:
with open(key_file, 'wb') as file_: with open(key_file, "wb") as file_:
os.fchmod(file_.fileno(), 0o600) os.fchmod(file_.fileno(), 0o600)
file_.write(key_bytes) file_.write(key_bytes)
except BaseException: except BaseException:
@ -110,25 +105,21 @@ class Letsencrypt(models.AbstractModel):
raise raise
else: else:
_logger.info("Getting existing key %s", key_name) _logger.info("Getting existing key %s", key_name)
with open(key_file, 'rb') as file_: with open(key_file, "rb") as file_:
key_bytes = file_.read() key_bytes = file_.read()
return key_bytes return key_bytes
@api.model @api.model
def _validate_domain(self, domain): def _validate_domain(self, domain):
"""Validate that a domain is publicly accessible.""" """Validate that a domain is publicly accessible."""
if ':' in domain or all( if ":" in domain or all(char.isdigit() or char == "." for char in domain):
char.isdigit() or char == '.' for char in domain
):
raise UserError( raise UserError(
_("Domain %s: Let's Encrypt doesn't support IP addresses!") _("Domain %s: Let's Encrypt doesn't support IP addresses!") % domain
% domain
) )
if domain in LOCAL_DOMAINS or '.' not in domain: if domain in LOCAL_DOMAINS or "." not in domain:
raise UserError( raise UserError(
_("Domain %s: Let's encrypt doesn't work with local domains!") _("Domain %s: Let's encrypt doesn't work with local domains!") % domain
% domain
) )
@api.model @api.model
@ -140,10 +131,8 @@ class Letsencrypt(models.AbstractModel):
_logger.info("No existing certificate found, creating a new one") _logger.info("No existing certificate found, creating a new one")
return True return True
with open(cert_file, 'rb') as file_: with open(cert_file, "rb") as file_:
cert = x509.load_pem_x509_certificate( cert = x509.load_pem_x509_certificate(file_.read(), default_backend())
file_.read(), default_backend()
)
expiry = cert.not_valid_after expiry = cert.not_valid_after
remaining = expiry - datetime.now() remaining = expiry - datetime.now()
if remaining < timedelta(): if remaining < timedelta():
@ -181,8 +170,7 @@ class Letsencrypt(models.AbstractModel):
missing = domains - names missing = domains - names
if missing: if missing:
_logger.info( _logger.info(
"Found new domains %s, requesting new certificate", "Found new domains %s, requesting new certificate", ", ".join(missing),
', '.join(missing),
) )
return True return True
@ -195,10 +183,10 @@ class Letsencrypt(models.AbstractModel):
@api.model @api.model
def _cron(self): def _cron(self):
ir_config_parameter = self.env['ir.config_parameter'] ir_config_parameter = self.env["ir.config_parameter"]
domains = self._get_altnames() domains = self._get_altnames()
domain = domains[0] domain = domains[0]
cert_file = os.path.join(_get_data_dir(), '%s.crt' % domain) cert_file = os.path.join(_get_data_dir(), "%s.crt" % domain)
domains = self._cascade_domains(domains) domains = self._cascade_domains(domains)
for dom in domains: for dom in domains:
@ -207,8 +195,8 @@ class Letsencrypt(models.AbstractModel):
if not self._should_run(cert_file, domains): if not self._should_run(cert_file, domains):
return return
account_key = josepy.JWKRSA.load(self._get_key('account.key')) account_key = josepy.JWKRSA.load(self._get_key("account.key"))
domain_key = self._get_key('%s.key' % domain) domain_key = self._get_key("%s.key" % domain)
client = self._create_client(account_key) client = self._create_client(account_key)
new_reg = acme.messages.NewRegistration( new_reg = acme.messages.NewRegistration(
@ -219,16 +207,12 @@ class Letsencrypt(models.AbstractModel):
_logger.info("Successfully registered.") _logger.info("Successfully registered.")
except acme.errors.ConflictError as err: except acme.errors.ConflictError as err:
reg = acme.messages.Registration(key=account_key.public_key()) reg = acme.messages.Registration(key=account_key.public_key())
reg_res = acme.messages.RegistrationResource( reg_res = acme.messages.RegistrationResource(body=reg, uri=err.location)
body=reg, uri=err.location
)
client.query_registration(reg_res) client.query_registration(reg_res)
_logger.info("Reusing existing account.") _logger.info("Reusing existing account.")
_logger.info('Making CSR for the following domains: %s', domains) _logger.info("Making CSR for the following domains: %s", domains)
csr = acme.crypto_util.make_csr( csr = acme.crypto_util.make_csr(private_key_pem=domain_key, domains=domains)
private_key_pem=domain_key, domains=domains
)
authzr = client.new_order(csr) authzr = client.new_order(csr)
# For each requested domain name we receive a list of challenges. # For each requested domain name we receive a list of challenges.
@ -260,9 +244,7 @@ class Letsencrypt(models.AbstractModel):
for challenge in ordered_challenges: for challenge in ordered_challenges:
if challenge.chall.typ == TYPE_CHALLENGE_HTTP: if challenge.chall.typ == TYPE_CHALLENGE_HTTP:
self._respond_challenge_http(challenge, account_key) self._respond_challenge_http(challenge, account_key)
client.answer_challenge( client.answer_challenge(challenge, acme.challenges.HTTP01Response())
challenge, acme.challenges.HTTP01Response()
)
break break
elif challenge.chall.typ == TYPE_CHALLENGE_DNS: elif challenge.chall.typ == TYPE_CHALLENGE_DNS:
domain = authorizations.body.identifier.value domain = authorizations.body.identifier.value
@ -271,32 +253,24 @@ class Letsencrypt(models.AbstractModel):
# We delay this because we wait for each domain. # We delay this because we wait for each domain.
# That takes less time if they've all already been changed. # That takes less time if they've all already been changed.
pending_responses.append( pending_responses.append(
DNSUpdate( DNSUpdate(challenge=challenge, domain=domain, token=token)
challenge=challenge, domain=domain, token=token
)
) )
break break
else: else:
raise UserError( raise UserError(_("Could not respond to letsencrypt challenges."))
_('Could not respond to letsencrypt challenges.')
)
if pending_responses: if pending_responses:
for update in pending_responses: for update in pending_responses:
self._wait_for_record(update.domain, update.token) self._wait_for_record(update.domain, update.token)
# 1 minute was not always enough during testing, even once records # 1 minute was not always enough during testing, even once records
# were visible locally # were visible locally
_logger.info( _logger.info("All TXT records found, waiting 5 minutes more to make sure.")
"All TXT records found, waiting 5 minutes more to make sure."
)
time.sleep(300) time.sleep(300)
for update in pending_responses: for update in pending_responses:
client.answer_challenge( client.answer_challenge(update.challenge, acme.challenges.DNSResponse())
update.challenge, acme.challenges.DNSResponse()
)
# let them know we are done and they should check # let them know we are done and they should check
backoff = int(ir_config_parameter.get_param('letsencrypt.backoff', 3)) backoff = int(ir_config_parameter.get_param("letsencrypt.backoff", 3))
deadline = datetime.now() + timedelta(minutes=backoff) deadline = datetime.now() + timedelta(minutes=backoff)
try: try:
order_resource = client.poll_and_finalize(authzr, deadline) order_resource = client.poll_and_finalize(authzr, deadline)
@ -307,12 +281,10 @@ class Letsencrypt(models.AbstractModel):
_logger.error(str(challenge.error)) _logger.error(str(challenge.error))
raise raise
with open(cert_file, 'w') as crt: with open(cert_file, "w") as crt:
crt.write(order_resource.fullchain_pem) crt.write(order_resource.fullchain_pem)
_logger.info('SUCCESS: Certificate saved: %s', cert_file) _logger.info("SUCCESS: Certificate saved: %s", cert_file)
reload_cmd = ir_config_parameter.get_param( reload_cmd = ir_config_parameter.get_param("letsencrypt.reload_command", "")
'letsencrypt.reload_command', ''
)
if reload_cmd.strip(): if reload_cmd.strip():
self._call_cmdline(reload_cmd) self._call_cmdline(reload_cmd)
else: else:
@ -328,9 +300,7 @@ class Letsencrypt(models.AbstractModel):
while True: while True:
attempt += 1 attempt += 1
try: try:
for record in dns.resolver.query( for record in dns.resolver.query("_acme-challenge." + domain, "TXT"):
"_acme-challenge." + domain, "TXT"
):
value = record.to_text()[1:-1] value = record.to_text()[1:-1]
if value == token: if value == token:
return return
@ -350,9 +320,9 @@ class Letsencrypt(models.AbstractModel):
@api.model @api.model
def _create_client(self, account_key): def _create_client(self, account_key):
param = self.env['ir.config_parameter'] param = self.env["ir.config_parameter"]
testing_mode = param.get_param('letsencrypt.testing_mode') == 'True' testing_mode = param.get_param("letsencrypt.testing_mode") == "True"
if config['test_enable'] or testing_mode: if config["test_enable"] or testing_mode:
directory_url = V2_STAGING_DIRECTORY_URL directory_url = V2_STAGING_DIRECTORY_URL
else: else:
directory_url = V2_DIRECTORY_URL directory_url = V2_DIRECTORY_URL
@ -382,7 +352,7 @@ class Letsencrypt(models.AbstractModel):
continue continue
if other.endswith(postfix): if other.endswith(postfix):
prefix = other[: -len(postfix)] # e.g. "www" prefix = other[: -len(postfix)] # e.g. "www"
if '.' not in prefix: if "." not in prefix:
to_remove.add(other) to_remove.add(other)
return sorted(set(domains) - to_remove) return sorted(set(domains) - to_remove)
@ -390,12 +360,12 @@ class Letsencrypt(models.AbstractModel):
@api.model @api.model
def _get_altnames(self): def _get_altnames(self):
"""Get the configured altnames as a list of strings.""" """Get the configured altnames as a list of strings."""
parameter = self.env['ir.config_parameter'] parameter = self.env["ir.config_parameter"]
altnames = parameter.get_param("letsencrypt.altnames") altnames = parameter.get_param("letsencrypt.altnames")
if not altnames: if not altnames:
base_url = parameter.get_param("web.base.url", "http://localhost") base_url = parameter.get_param("web.base.url", "http://localhost")
return [urllib.parse.urlparse(base_url).hostname] return [urllib.parse.urlparse(base_url).hostname]
return re.split('(?:,|\n| |;)+', altnames) return re.split("(?:,|\n| |;)+", altnames)
@api.model @api.model
def _respond_challenge_http(self, challenge, account_key): def _respond_challenge_http(self, challenge, account_key):
@ -404,7 +374,7 @@ class Letsencrypt(models.AbstractModel):
""" """
token = self._base64_encode(challenge.token) token = self._base64_encode(challenge.token)
challenge_file = os.path.join(_get_challenge_dir(), token) challenge_file = os.path.join(_get_challenge_dir(), token)
with open(challenge_file, 'w') as file_: with open(challenge_file, "w") as file_:
file_.write(challenge.validation(account_key)) file_.write(challenge.validation(account_key))
@api.model @api.model
@ -413,9 +383,7 @@ class Letsencrypt(models.AbstractModel):
Respond to the DNS challenge by creating the DNS record Respond to the DNS challenge by creating the DNS record
on the provider. on the provider.
""" """
provider = self.env['ir.config_parameter'].get_param( provider = self.env["ir.config_parameter"].get_param("letsencrypt.dns_provider")
'letsencrypt.dns_provider'
)
if not provider: if not provider:
raise UserError( raise UserError(
_("No DNS provider set, can't request wildcard certificate") _("No DNS provider set, can't request wildcard certificate")
@ -441,9 +409,7 @@ class Letsencrypt(models.AbstractModel):
_logger.warning(stdout) _logger.warning(stdout)
if stderr: if stderr:
_logger.warning(stderr) _logger.warning(stderr)
raise UserError( raise UserError(_("Error calling %s: %d") % (cmdline, process.returncode))
_('Error calling %s: %d') % (cmdline, process.returncode)
)
if stdout: if stdout:
_logger.info(stdout) _logger.info(stdout)
if stderr: if stderr:
@ -452,20 +418,17 @@ class Letsencrypt(models.AbstractModel):
@api.model @api.model
def _respond_challenge_dns_shell(self, domain, token): def _respond_challenge_dns_shell(self, domain, token):
"""Respond to a DNS challenge using an arbitrary shell command.""" """Respond to a DNS challenge using an arbitrary shell command."""
script_str = self.env['ir.config_parameter'].get_param( script_str = self.env["ir.config_parameter"].get_param(
'letsencrypt.dns_shell_script' "letsencrypt.dns_shell_script"
) )
if script_str: if script_str:
env = os.environ.copy() env = os.environ.copy()
env.update( env.update(
LETSENCRYPT_DNS_DOMAIN=domain, LETSENCRYPT_DNS_DOMAIN=domain, LETSENCRYPT_DNS_CHALLENGE=token,
LETSENCRYPT_DNS_CHALLENGE=token,
) )
self._call_cmdline(script_str, env=env) self._call_cmdline(script_str, env=env)
else: else:
raise UserError( raise UserError(_("No shell command configured for updating DNS records"))
_("No shell command configured for updating DNS records")
)
@api.model @api.model
def _base64_encode(self, data): def _base64_encode(self, data):
@ -475,4 +438,4 @@ class Letsencrypt(models.AbstractModel):
https://github.com/ietf-wg-acme/acme/issues/64#issuecomment-168852757 https://github.com/ietf-wg-acme/acme/issues/64#issuecomment-168852757
and https://golang.org/pkg/encoding/base64/#RawURLEncoding and https://golang.org/pkg/encoding/base64/#RawURLEncoding
""" """
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii') return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")

View File

@ -3,7 +3,6 @@
from odoo import _, api, exceptions, fields, models from odoo import _, api, exceptions, fields, models
DNS_SCRIPT_DEFAULT = """# Write your script here DNS_SCRIPT_DEFAULT = """# Write your script here
# It should create a TXT record of $LETSENCRYPT_DNS_CHALLENGE # It should create a TXT record of $LETSENCRYPT_DNS_CHALLENGE
# on _acme-challenge.$LETSENCRYPT_DNS_DOMAIN # on _acme-challenge.$LETSENCRYPT_DNS_DOMAIN
@ -11,46 +10,45 @@ DNS_SCRIPT_DEFAULT = """# Write your script here
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings' _inherit = "res.config.settings"
letsencrypt_altnames = fields.Text( letsencrypt_altnames = fields.Text(
string="Domain names", string="Domain names",
default='', default="",
help=( help=(
'Domains to use for the certificate. ' "Domains to use for the certificate. " "Separate with commas or newlines."
'Separate with commas or newlines.'
), ),
force_config_parameter="letsencrypt.altnames", force_config_parameter="letsencrypt.altnames",
) )
letsencrypt_dns_provider = fields.Selection( letsencrypt_dns_provider = fields.Selection(
selection=[('shell', 'Shell script')], selection=[("shell", "Shell script")],
string='DNS provider', string="DNS provider",
help=( help=(
'For wildcard certificates we need to add a TXT record on your ' "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 ' 'DNS. If you set this to "Shell script" you can enter a shell '
'script. Other options can be added by installing additional ' "script. Other options can be added by installing additional "
'modules.' "modules."
), ),
config_parameter="letsencrypt.dns_provider", config_parameter="letsencrypt.dns_provider",
) )
letsencrypt_dns_shell_script = fields.Text( letsencrypt_dns_shell_script = fields.Text(
string='DNS update script', string="DNS update script",
help=( help=(
'Write a shell script that will update your DNS TXT records. ' "Write a shell script that will update your DNS TXT records. "
'You can use the $LETSENCRYPT_DNS_CHALLENGE and ' "You can use the $LETSENCRYPT_DNS_CHALLENGE and "
'$LETSENCRYPT_DNS_DOMAIN variables.' "$LETSENCRYPT_DNS_DOMAIN variables."
), ),
default=DNS_SCRIPT_DEFAULT, default=DNS_SCRIPT_DEFAULT,
force_config_parameter="letsencrypt.dns_shell_script", force_config_parameter="letsencrypt.dns_shell_script",
) )
letsencrypt_needs_dns_provider = fields.Boolean() letsencrypt_needs_dns_provider = fields.Boolean()
letsencrypt_reload_command = fields.Text( letsencrypt_reload_command = fields.Text(
string='Server reload command', string="Server reload command",
help='Fill this with the command to restart your web server.', help="Fill this with the command to restart your web server.",
force_config_parameter="letsencrypt.reload_command", force_config_parameter="letsencrypt.reload_command",
) )
letsencrypt_testing_mode = fields.Boolean( letsencrypt_testing_mode = fields.Boolean(
string='Use testing server', string="Use testing server",
help=( help=(
"Use the Let's Encrypt staging server, which has higher rate " "Use the Let's Encrypt staging server, which has higher rate "
"limits but doesn't create valid certificates." "limits but doesn't create valid certificates."
@ -66,9 +64,9 @@ class ResConfigSettings(models.TransientModel):
config_parameter="letsencrypt.prefer_dns", config_parameter="letsencrypt.prefer_dns",
) )
@api.onchange('letsencrypt_altnames', 'letsencrypt_prefer_dns') @api.onchange("letsencrypt_altnames", "letsencrypt_prefer_dns")
def letsencrypt_check_dns_required(self): def letsencrypt_check_dns_required(self):
altnames = self.letsencrypt_altnames or '' altnames = self.letsencrypt_altnames or ""
self.letsencrypt_needs_dns_provider = ( self.letsencrypt_needs_dns_provider = (
"*." in altnames or self.letsencrypt_prefer_dns "*." in altnames or self.letsencrypt_prefer_dns
) )
@ -77,8 +75,7 @@ class ResConfigSettings(models.TransientModel):
def default_get(self, fields_list): def default_get(self, fields_list):
res = super().default_get(fields_list) res = super().default_get(fields_list)
res["letsencrypt_needs_dns_provider"] = ( res["letsencrypt_needs_dns_provider"] = (
"*." in res["letsencrypt_altnames"] "*." in res["letsencrypt_altnames"] or res["letsencrypt_prefer_dns"]
or res["letsencrypt_prefer_dns"]
) )
return res return res
@ -88,12 +85,11 @@ class ResConfigSettings(models.TransientModel):
self.letsencrypt_check_dns_required() self.letsencrypt_check_dns_required()
if self.letsencrypt_dns_provider == 'shell': if self.letsencrypt_dns_provider == "shell":
lines = [ lines = [
line.strip() line.strip() for line in self.letsencrypt_dns_shell_script.split("\n")
for line in self.letsencrypt_dns_shell_script.split('\n')
] ]
if all(line == '' or line.startswith('#') for line in lines): if all(line == "" or line.startswith("#") for line in lines):
raise exceptions.ValidationError( raise exceptions.ValidationError(
_("You didn't write a DNS update script!") _("You didn't write a DNS update script!")
) )
@ -114,9 +110,7 @@ class ResConfigSettings(models.TransientModel):
for name in classified["other"]: for name in classified["other"]:
field = self._fields[name] field = self._fields[name]
if hasattr(field, "force_config_parameter"): if hasattr(field, "force_config_parameter"):
classified["config"].append( classified["config"].append((name, field.force_config_parameter))
(name, field.force_config_parameter)
)
else: else:
new_other.append(name) new_other.append(name)
classified["other"] = new_other classified["other"] = new_other

View File

@ -3,7 +3,6 @@
import os import os
import shutil import shutil
from datetime import datetime, timedelta from datetime import datetime, timedelta
from os import path from os import path
@ -12,79 +11,76 @@ import mock
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
from odoo.tests import SingleTransactionCase from odoo.tests import SingleTransactionCase
from ..models.letsencrypt import _get_challenge_dir, _get_data_dir
try: try:
import dns.resolver import dns.resolver
except ImportError: except ImportError:
pass pass
from ..models.letsencrypt import _get_data_dir, _get_challenge_dir
CERT_DIR = path.join(path.dirname(__file__), "certs")
CERT_DIR = path.join(path.dirname(__file__), 'certs')
def _poll(order, deadline): def _poll(order, deadline):
order_resource = mock.Mock(['fullchain_pem']) order_resource = mock.Mock(["fullchain_pem"])
order_resource.fullchain_pem = 'chain' order_resource.fullchain_pem = "chain"
return order_resource return order_resource
class TestLetsencrypt(SingleTransactionCase): class TestLetsencrypt(SingleTransactionCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.env['ir.config_parameter'].set_param( self.env["ir.config_parameter"].set_param(
'web.base.url', 'http://www.example.com' "web.base.url", "http://www.example.com"
) )
self.env['res.config.settings'].create( self.env["res.config.settings"].create(
{ {
'letsencrypt_dns_provider': 'shell', "letsencrypt_dns_provider": "shell",
'letsencrypt_dns_shell_script': 'touch /tmp/.letsencrypt_test', "letsencrypt_dns_shell_script": "touch /tmp/.letsencrypt_test",
'letsencrypt_altnames': 'www.example.com,*.example.com', "letsencrypt_altnames": "www.example.com,*.example.com",
'letsencrypt_reload_command': 'echo reloaded', "letsencrypt_reload_command": "echo reloaded",
} }
).set_values() ).set_values()
def test_config_settings(self): def test_config_settings(self):
setting_vals = self.env['res.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_provider"], "shell")
self.assertEqual( self.assertEqual(
setting_vals['letsencrypt_dns_shell_script'], setting_vals["letsencrypt_dns_shell_script"],
'touch /tmp/.letsencrypt_test', "touch /tmp/.letsencrypt_test",
) )
self.assertEqual( self.assertEqual(
setting_vals['letsencrypt_altnames'], setting_vals["letsencrypt_altnames"], "www.example.com,*.example.com"
'www.example.com,*.example.com'
) )
self.assertEqual(setting_vals['letsencrypt_reload_command'], 'echo reloaded') self.assertEqual(setting_vals["letsencrypt_reload_command"], "echo reloaded")
self.assertTrue(setting_vals['letsencrypt_needs_dns_provider']) self.assertTrue(setting_vals["letsencrypt_needs_dns_provider"])
self.assertFalse(setting_vals['letsencrypt_prefer_dns']) self.assertFalse(setting_vals["letsencrypt_prefer_dns"])
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
self.env["res.config.settings"].create( self.env["res.config.settings"].create(
{"letsencrypt_dns_shell_script": "# Empty script"} {"letsencrypt_dns_shell_script": "# Empty script"}
).set_values() ).set_values()
@mock.patch('acme.client.ClientV2.answer_challenge') @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): def test_http_challenge(self, poll, _answer_challenge):
letsencrypt = self.env['letsencrypt'] letsencrypt = self.env["letsencrypt"]
self.env['res.config.settings'].create( self.env["res.config.settings"].create(
{'letsencrypt_altnames': ''} {"letsencrypt_altnames": ""}
).set_values() ).set_values()
letsencrypt._cron() letsencrypt._cron()
poll.assert_called() poll.assert_called()
self.assertTrue(os.listdir(_get_challenge_dir())) self.assertTrue(os.listdir(_get_challenge_dir()))
self.assertFalse(path.isfile('/tmp/.letsencrypt_test')) self.assertFalse(path.isfile("/tmp/.letsencrypt_test"))
self.assertTrue( self.assertTrue(path.isfile(path.join(_get_data_dir(), "www.example.com.crt")))
path.isfile(path.join(_get_data_dir(), 'www.example.com.crt'))
)
# pylint: disable=unused-argument # pylint: disable=unused-argument
@mock.patch('odoo.addons.letsencrypt.models.letsencrypt.DNSUpdate') @mock.patch("odoo.addons.letsencrypt.models.letsencrypt.DNSUpdate")
@mock.patch('dns.resolver.query') @mock.patch("dns.resolver.query")
@mock.patch('time.sleep') @mock.patch("time.sleep")
@mock.patch('acme.client.ClientV2.answer_challenge') @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_dns_challenge(self, poll, answer_challenge, sleep, query, dnsupd): def test_dns_challenge(self, poll, answer_challenge, sleep, query, dnsupd):
record = None record = None
@ -120,204 +116,192 @@ class TestLetsencrypt(SingleTransactionCase):
query.side_effect = query_effect query.side_effect = query_effect
self.install_certificate(days_left=10) self.install_certificate(days_left=10)
self.env['letsencrypt']._cron() self.env["letsencrypt"]._cron()
poll.assert_called() poll.assert_called()
self.assertEqual(ncalls, 3) self.assertEqual(ncalls, 3)
self.assertTrue(path.isfile('/tmp/.letsencrypt_test')) self.assertTrue(path.isfile("/tmp/.letsencrypt_test"))
self.assertTrue( self.assertTrue(path.isfile(path.join(_get_data_dir(), "www.example.com.crt")))
path.isfile(path.join(_get_data_dir(), 'www.example.com.crt'))
)
def test_dns_challenge_error_on_missing_provider(self): def test_dns_challenge_error_on_missing_provider(self):
self.env['res.config.settings'].create( self.env["res.config.settings"].create(
{ {
'letsencrypt_altnames': '*.example.com', "letsencrypt_altnames": "*.example.com",
'letsencrypt_dns_provider': False, "letsencrypt_dns_provider": False,
} }
).set_values() ).set_values()
with self.assertRaises(UserError): with self.assertRaises(UserError):
self.env['letsencrypt']._cron() self.env["letsencrypt"]._cron()
def test_prefer_dns_setting(self): def test_prefer_dns_setting(self):
self.env['res.config.settings'].create( self.env["res.config.settings"].create(
{ {"letsencrypt_altnames": "example.com", "letsencrypt_prefer_dns": True}
'letsencrypt_altnames': 'example.com',
'letsencrypt_prefer_dns': True,
}
).set_values() ).set_values()
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
self.test_dns_challenge() self.test_dns_challenge()
def test_cascading(self): def test_cascading(self):
cascade = self.env['letsencrypt']._cascade_domains cascade = self.env["letsencrypt"]._cascade_domains
self.assertEqual( self.assertEqual(
cascade( cascade(
[ [
'www.example.com', "www.example.com",
'*.example.com', "*.example.com",
'example.com', "example.com",
'example.com', "example.com",
'notexample.com', "notexample.com",
'multi.sub.example.com', "multi.sub.example.com",
'www2.example.com', "www2.example.com",
'unrelated.com', "unrelated.com",
] ]
), ),
[ [
'*.example.com', "*.example.com",
'example.com', "example.com",
'multi.sub.example.com', "multi.sub.example.com",
'notexample.com', "notexample.com",
'unrelated.com', "unrelated.com",
], ],
) )
self.assertEqual(cascade([]), []) self.assertEqual(cascade([]), [])
self.assertEqual(cascade(['*.example.com']), ['*.example.com']) self.assertEqual(cascade(["*.example.com"]), ["*.example.com"])
self.assertEqual(cascade(['www.example.com']), ['www.example.com']) self.assertEqual(cascade(["www.example.com"]), ["www.example.com"])
self.assertEqual( self.assertEqual(
cascade(['www.example.com', 'example.com']), cascade(["www.example.com", "example.com"]),
['example.com', 'www.example.com'], ["example.com", "www.example.com"],
) )
with self.assertRaises(UserError): with self.assertRaises(UserError):
cascade(['www.*.example.com']) cascade(["www.*.example.com"])
with self.assertRaises(UserError): with self.assertRaises(UserError):
cascade(['*.*.example.com']) cascade(["*.*.example.com"])
def test_altnames_parsing(self): def test_altnames_parsing(self):
config = self.env['ir.config_parameter'] config = self.env["ir.config_parameter"]
letsencrypt = self.env['letsencrypt'] letsencrypt = self.env["letsencrypt"]
self.assertEqual( self.assertEqual(
letsencrypt._get_altnames(), letsencrypt._get_altnames(), ["www.example.com", "*.example.com"]
['www.example.com', '*.example.com']
) )
config.set_param('letsencrypt.altnames', '') config.set_param("letsencrypt.altnames", "")
self.assertEqual(letsencrypt._get_altnames(), ['www.example.com']) self.assertEqual(letsencrypt._get_altnames(), ["www.example.com"])
config.set_param('letsencrypt.altnames', 'foobar.example.com') config.set_param("letsencrypt.altnames", "foobar.example.com")
self.assertEqual(letsencrypt._get_altnames(), ['foobar.example.com']) self.assertEqual(letsencrypt._get_altnames(), ["foobar.example.com"])
config.set_param( config.set_param("letsencrypt.altnames", "example.com,example.org,example.net")
'letsencrypt.altnames', 'example.com,example.org,example.net'
)
self.assertEqual( self.assertEqual(
letsencrypt._get_altnames(), letsencrypt._get_altnames(), ["example.com", "example.org", "example.net"],
['example.com', 'example.org', 'example.net'],
) )
config.set_param( config.set_param(
'letsencrypt.altnames', 'example.com, example.org\nexample.net' "letsencrypt.altnames", "example.com, example.org\nexample.net"
) )
self.assertEqual( self.assertEqual(
letsencrypt._get_altnames(), letsencrypt._get_altnames(), ["example.com", "example.org", "example.net"],
['example.com', 'example.org', 'example.net'],
) )
def test_key_generation_and_retrieval(self): def test_key_generation_and_retrieval(self):
key_a1 = self.env['letsencrypt']._get_key('a.key') key_a1 = self.env["letsencrypt"]._get_key("a.key")
key_a2 = 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') key_b = self.env["letsencrypt"]._get_key("b.key")
self.assertIsInstance(key_a1, bytes) self.assertIsInstance(key_a1, bytes)
self.assertIsInstance(key_a2, bytes) self.assertIsInstance(key_a2, bytes)
self.assertIsInstance(key_b, bytes) self.assertIsInstance(key_b, bytes)
self.assertTrue(path.isfile(path.join(_get_data_dir(), 'a.key'))) self.assertTrue(path.isfile(path.join(_get_data_dir(), "a.key")))
self.assertEqual(key_a1, key_a2) self.assertEqual(key_a1, key_a2)
self.assertNotEqual(key_a1, key_b) self.assertNotEqual(key_a1, key_b)
@mock.patch('os.remove', side_effect=os.remove) @mock.patch("os.remove", side_effect=os.remove)
@mock.patch( @mock.patch(
'odoo.addons.letsencrypt.models.letsencrypt.Letsencrypt._generate_key', "odoo.addons.letsencrypt.models.letsencrypt.Letsencrypt._generate_key",
side_effect=lambda: None, side_effect=lambda: None,
) )
def test_interrupted_key_writing(self, generate_key, remove): def test_interrupted_key_writing(self, generate_key, remove):
with self.assertRaises(TypeError): with self.assertRaises(TypeError):
self.env['letsencrypt']._get_key('a.key') self.env["letsencrypt"]._get_key("a.key")
self.assertFalse(path.isfile(path.join(_get_data_dir(), 'a.key'))) self.assertFalse(path.isfile(path.join(_get_data_dir(), "a.key")))
remove.assert_called() remove.assert_called()
generate_key.assert_called() generate_key.assert_called()
def test_domain_validation(self): def test_domain_validation(self):
self.env['letsencrypt']._validate_domain('example.com') self.env["letsencrypt"]._validate_domain("example.com")
self.env['letsencrypt']._validate_domain('www.example.com') self.env["letsencrypt"]._validate_domain("www.example.com")
with self.assertRaises(UserError): with self.assertRaises(UserError):
self.env['letsencrypt']._validate_domain('1.1.1.1') self.env["letsencrypt"]._validate_domain("1.1.1.1")
with self.assertRaises(UserError): with self.assertRaises(UserError):
self.env['letsencrypt']._validate_domain('192.168.1.1') self.env["letsencrypt"]._validate_domain("192.168.1.1")
with self.assertRaises(UserError): with self.assertRaises(UserError):
self.env['letsencrypt']._validate_domain('localhost.localdomain') self.env["letsencrypt"]._validate_domain("localhost.localdomain")
with self.assertRaises(UserError): with self.assertRaises(UserError):
self.env['letsencrypt']._validate_domain('testdomain') self.env["letsencrypt"]._validate_domain("testdomain")
with self.assertRaises(UserError): with self.assertRaises(UserError):
self.env['letsencrypt']._validate_domain('::1') self.env["letsencrypt"]._validate_domain("::1")
def test_young_certificate(self): def test_young_certificate(self):
self.install_certificate(60) self.install_certificate(60)
self.assertFalse( self.assertFalse(
self.env['letsencrypt']._should_run( self.env["letsencrypt"]._should_run(
path.join(_get_data_dir(), 'www.example.com.crt'), path.join(_get_data_dir(), "www.example.com.crt"),
['www.example.com', '*.example.com'], ["www.example.com", "*.example.com"],
) )
) )
def test_old_certificate(self): def test_old_certificate(self):
self.install_certificate(20) self.install_certificate(20)
self.assertTrue( self.assertTrue(
self.env['letsencrypt']._should_run( self.env["letsencrypt"]._should_run(
path.join(_get_data_dir(), 'www.example.com.crt'), path.join(_get_data_dir(), "www.example.com.crt"),
['www.example.com', '*.example.com'], ["www.example.com", "*.example.com"],
) )
) )
def test_expired_certificate(self): def test_expired_certificate(self):
self.install_certificate(-10) self.install_certificate(-10)
self.assertTrue( self.assertTrue(
self.env['letsencrypt']._should_run( self.env["letsencrypt"]._should_run(
path.join(_get_data_dir(), 'www.example.com.crt'), path.join(_get_data_dir(), "www.example.com.crt"),
['www.example.com', '*.example.com'], ["www.example.com", "*.example.com"],
) )
) )
def test_missing_certificate(self): def test_missing_certificate(self):
self.assertTrue( self.assertTrue(
self.env['letsencrypt']._should_run( self.env["letsencrypt"]._should_run(
path.join(_get_data_dir(), 'www.example.com.crt'), path.join(_get_data_dir(), "www.example.com.crt"),
['www.example.com', '*.example.com'], ["www.example.com", "*.example.com"],
) )
) )
def test_new_altnames(self): def test_new_altnames(self):
self.install_certificate(60, 'www.example.com', ()) self.install_certificate(60, "www.example.com", ())
self.assertTrue( self.assertTrue(
self.env['letsencrypt']._should_run( self.env["letsencrypt"]._should_run(
path.join(_get_data_dir(), 'www.example.com.crt'), path.join(_get_data_dir(), "www.example.com.crt"),
['www.example.com', '*.example.com'], ["www.example.com", "*.example.com"],
) )
) )
self.assertFalse( self.assertFalse(
self.env['letsencrypt']._should_run( self.env["letsencrypt"]._should_run(
path.join(_get_data_dir(), 'www.example.com.crt'), path.join(_get_data_dir(), "www.example.com.crt"), ["www.example.com"],
['www.example.com'],
) )
) )
def test_legacy_certificate_without_altnames(self): def test_legacy_certificate_without_altnames(self):
self.install_certificate(60, use_altnames=False) self.install_certificate(60, use_altnames=False)
self.assertFalse( self.assertFalse(
self.env['letsencrypt']._should_run( self.env["letsencrypt"]._should_run(
path.join(_get_data_dir(), 'www.example.com.crt'), path.join(_get_data_dir(), "www.example.com.crt"), ["www.example.com"],
['www.example.com'],
) )
) )
def install_certificate( def install_certificate(
self, self,
days_left, days_left,
common_name='www.example.com', common_name="www.example.com",
altnames=('*.example.com',), altnames=("*.example.com",),
use_altnames=True, use_altnames=True,
): ):
from cryptography import x509 from cryptography import x509
@ -334,14 +318,10 @@ class TestLetsencrypt(SingleTransactionCase):
cert_builder = ( cert_builder = (
x509.CertificateBuilder() x509.CertificateBuilder()
.subject_name( .subject_name(
x509.Name( x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)])
[x509.NameAttribute(x509.NameOID.COMMON_NAME, common_name)]
)
) )
.issuer_name( .issuer_name(
x509.Name( x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "myca.biz")])
[x509.NameAttribute(x509.NameOID.COMMON_NAME, 'myca.biz')]
)
) )
.not_valid_before(not_before) .not_valid_before(not_before)
.not_valid_after(not_after) .not_valid_after(not_after)
@ -359,12 +339,12 @@ class TestLetsencrypt(SingleTransactionCase):
) )
cert = cert_builder.sign(key, hashes.SHA256(), default_backend()) 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(), "%s.crt" % common_name)
with open(cert_file, 'wb') as file_: with open(cert_file, "wb") as file_:
file_.write(cert.public_bytes(serialization.Encoding.PEM)) file_.write(cert.public_bytes(serialization.Encoding.PEM))
def tearDown(self): def tearDown(self):
super().tearDown() super().tearDown()
shutil.rmtree(_get_data_dir(), ignore_errors=True) shutil.rmtree(_get_data_dir(), ignore_errors=True)
if path.isfile('/tmp/.letsencrypt_test'): if path.isfile("/tmp/.letsencrypt_test"):
os.remove('/tmp/.letsencrypt_test') os.remove("/tmp/.letsencrypt_test")

View File

@ -2,68 +2,89 @@
<record id="res_config_settings_view_form" model="ir.ui.view"> <record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">Letsencrypt settings view</field> <field name="name">Letsencrypt settings view</field>
<field name="model">res.config.settings</field> <field name="model">res.config.settings</field>
<field name="inherit_id" <field name="inherit_id" ref="base_setup.res_config_settings_view_form" />
ref="base_setup.res_config_settings_view_form" />
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//div[hasclass('settings')]" position="inside"> <xpath expr="//div[hasclass('settings')]" position="inside">
<div class="app_settings_block" <div
class="app_settings_block"
data-string="Let's Encrypt" data-string="Let's Encrypt"
string="Let's Encrypt" string="Let's Encrypt"
data-key="letsencrypt"> data-key="letsencrypt"
>
<div id="letsencrypt_settings"> <div id="letsencrypt_settings">
<h2>Let's Encrypt</h2> <h2>Let's Encrypt</h2>
<field <field
name="letsencrypt_needs_dns_provider" name="letsencrypt_needs_dns_provider"
readonly="1" readonly="1"
invisible="1"/> invisible="1"
/>
<div class="row mt16 o_settings_container"> <div class="row mt16 o_settings_container">
<div class="col-xs-12 col-md-12 o_setting_box"> <div class="col-xs-12 col-md-12 o_setting_box">
<div class="o_setting_right_pane"> <div class="o_setting_right_pane">
<label for="letsencrypt_altnames"/> <label for="letsencrypt_altnames" />
<div class="text-muted">List the domains for the certificate</div> <div
class="text-muted"
>List the domains for the certificate</div>
<field name="letsencrypt_altnames" /> <field name="letsencrypt_altnames" />
</div> </div>
<div class="o_setting_right_pane"> <div class="o_setting_right_pane">
<label for="letsencrypt_reload_command"/> <label for="letsencrypt_reload_command" />
<div class="text-muted">Write a command to reload the server</div> <div
class="text-muted"
>Write a command to reload the server</div>
<field name="letsencrypt_reload_command" /> <field name="letsencrypt_reload_command" />
</div> </div>
<div class="o_setting_right_pane"> <div class="o_setting_right_pane">
<label for="letsencrypt_dns_provider"/> <label for="letsencrypt_dns_provider" />
<div class="text-muted">Set a DNS provider if you need wildcard certificates</div> <div
class="text-muted"
>Set a DNS provider if you need wildcard certificates</div>
<div class="content-group"> <div class="content-group">
<div class="mt16 row"> <div class="mt16 row">
<label for="letsencrypt_dns_provider" <label
class="col-xs-3 col-md-3 o_light_label"/> for="letsencrypt_dns_provider"
class="col-xs-3 col-md-3 o_light_label"
/>
<field <field
class="oe_inline" class="oe_inline"
name="letsencrypt_dns_provider" name="letsencrypt_dns_provider"
attrs="{'required': [('letsencrypt_needs_dns_provider', '=', True)]}" /> attrs="{'required': [('letsencrypt_needs_dns_provider', '=', True)]}"
/>
</div> </div>
</div> </div>
</div> </div>
<span id="letsencrypt_dns_provider_settings"> <span id="letsencrypt_dns_provider_settings">
<div class="o_setting_right_pane" <div
attrs="{'invisible': [('letsencrypt_dns_provider', '!=', 'shell')]}"> class="o_setting_right_pane"
attrs="{'invisible': [('letsencrypt_dns_provider', '!=', 'shell')]}"
>
<label for="letsencrypt_dns_shell_script" /> <label for="letsencrypt_dns_shell_script" />
<div class="text-muted">Write a shell script to update your DNS records</div> <div
<field name="letsencrypt_dns_shell_script" class="text-muted"
attrs="{'required': [('letsencrypt_dns_provider', '=', 'shell')]}" /> >Write a shell script to update your DNS records</div>
<field
name="letsencrypt_dns_shell_script"
attrs="{'required': [('letsencrypt_dns_provider', '=', 'shell')]}"
/>
</div> </div>
</span> </span>
<div class="o_setting_left_pane"> <div class="o_setting_left_pane">
<field name="letsencrypt_testing_mode" /> <field name="letsencrypt_testing_mode" />
</div> </div>
<div class="o_setting_right_pane"> <div class="o_setting_right_pane">
<label for="letsencrypt_testing_mode"/> <label for="letsencrypt_testing_mode" />
<div class="text-muted">Use the testing server, which has higher rate limits but creates invalid certificates.</div> <div
class="text-muted"
>Use the testing server, which has higher rate limits but creates invalid certificates.</div>
</div> </div>
<div class="o_setting_left_pane"> <div class="o_setting_left_pane">
<field name="letsencrypt_prefer_dns" /> <field name="letsencrypt_prefer_dns" />
</div> </div>
<div class="o_setting_right_pane"> <div class="o_setting_right_pane">
<label for="letsencrypt_prefer_dns"/> <label for="letsencrypt_prefer_dns" />
<div class="text-muted">Validate through DNS even when HTTP validation is possible. Use this if your Odoo instance isn't publicly accessible.</div> <div
class="text-muted"
>Validate through DNS even when HTTP validation is possible. Use this if your Odoo instance isn't publicly accessible.</div>
</div> </div>
</div> </div>
</div> </div>