[IMP] letsencrypt: black, isort, prettier
parent
fc9fd8f48e
commit
7db8ebadb4
|
@ -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",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue