14 changed files with 587 additions and 0 deletions
-
125letsencrypt/README.rst
-
6letsencrypt/__init__.py
-
25letsencrypt/__openerp__.py
-
4letsencrypt/controllers/__init__.py
-
20letsencrypt/controllers/letsencrypt.py
-
9letsencrypt/data/ir_config_parameter.xml
-
14letsencrypt/data/ir_cron.xml
-
9letsencrypt/hooks.py
-
4letsencrypt/models/__init__.py
-
199letsencrypt/models/acme_tiny.py
-
157letsencrypt/models/letsencrypt.py
-
BINletsencrypt/static/description/icon.png
-
4letsencrypt/tests/__init__.py
-
11letsencrypt/tests/test_letsencrypt.py
@ -0,0 +1,125 @@ |
|||||
|
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg |
||||
|
:alt: License: AGPL-3 |
||||
|
|
||||
|
============================================= |
||||
|
Request SSL certificates from letsencrypt.org |
||||
|
============================================= |
||||
|
|
||||
|
This module was written to have your Odoo installation request SSL certificates |
||||
|
from https://letsencrypt.org automatically. |
||||
|
|
||||
|
Installation |
||||
|
============ |
||||
|
|
||||
|
After installation, this module generates a private key for your account at |
||||
|
letsencrypt.org automatically in ``$data_dir/letsencrypt/account.key``. If you |
||||
|
want or need to use your own account key, replace the file. |
||||
|
|
||||
|
For certificate requests to work, your site needs to be accessible via plain |
||||
|
HTTP, see below for configuration examples in case you force your clients to |
||||
|
the SSL version. |
||||
|
|
||||
|
After installation, trigger the cronjob `Update letsencrypt certificates` and |
||||
|
watch your log for messages. |
||||
|
|
||||
|
Configuration |
||||
|
============= |
||||
|
|
||||
|
This addons requests a certificate for the domain named in the configuration |
||||
|
parameter ``web.base.url`` - if this comes back as ``localhost`` or the like, |
||||
|
the module doesn't request anything. |
||||
|
|
||||
|
If you want your certificate to contain multiple alternative names, just add |
||||
|
them as configuration parameters ``letsencrypt.altname.N`` with ``N`` starting |
||||
|
from ``0``. The amount of domains that can be added are subject to `rate |
||||
|
limiting <https://community.letsencrypt.org/t/rate-limits-for-lets-encrypt/6769>`_. |
||||
|
|
||||
|
Note that all those domains must be publicly reachable on port 80 via HTTP, and |
||||
|
they must have an entry for ``.well-known/acme-challenge`` pointing to your odoo |
||||
|
instance. |
||||
|
|
||||
|
Usage |
||||
|
===== |
||||
|
|
||||
|
The module sets up a cronjob that requests and renews certificates automatically. |
||||
|
|
||||
|
After the first run, you'll find a file called ``domain.crt`` in |
||||
|
``$datadir/letsencrypt``, configure your SSL proxy to use this file as certificate. |
||||
|
|
||||
|
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas |
||||
|
:alt: Try me on Runbot |
||||
|
:target: https://runbot.odoo-community.org/runbot/149/8.0 |
||||
|
|
||||
|
For further information, please visit: |
||||
|
|
||||
|
* https://www.odoo.com/forum/help-1 |
||||
|
|
||||
|
In depth configuration |
||||
|
====================== |
||||
|
|
||||
|
This module uses ``openssl`` to generate CSRs suitable to be submitted to |
||||
|
letsencrypt.org. In order to do this, it copies ``/etc/ssl/openssl.cnf`` to a |
||||
|
temporary and adapts it according to its needs (currently, that's just adding a |
||||
|
``[SAN]`` section if necessary). If you want the module to use another configuration |
||||
|
template, set config parameter ``letsencrypt.openssl.cnf``. |
||||
|
|
||||
|
After refreshing the certificate, the module attempts to run the content of |
||||
|
``letsencrypt.reload_command``, which is by default ``sudo service nginx reload``. |
||||
|
Change this to match your server's configuration. |
||||
|
|
||||
|
You'll also need a matching sudo configuration, like:: |
||||
|
|
||||
|
your_odoo_user ALL = NOPASSWD: /usr/sbin/service nginx reload |
||||
|
|
||||
|
Further, if you force users to https, you'll need something like:: |
||||
|
|
||||
|
if ($scheme = "http") { |
||||
|
set $redirect_https 1; |
||||
|
} |
||||
|
if ($request_uri ~ ^/.well-known/acme-challenge/) { |
||||
|
set $redirect_https 0; |
||||
|
} |
||||
|
if ($redirect_https) { |
||||
|
rewrite ^ https://$server_name$request_uri? permanent; |
||||
|
} |
||||
|
|
||||
|
Bug Tracker |
||||
|
=========== |
||||
|
|
||||
|
Bugs are tracked on `GitHub Issues <https://github.com/OCA/server-tools/issues>`_. |
||||
|
In case of trouble, please check there if your issue has already been reported. |
||||
|
If you spotted it first, help us smashing it by providing a detailed and welcomed feedback |
||||
|
`here <https://github.com/OCA/server-tools/issues/new?body=module:%20letsencrypt%0Aversion:%208.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_. |
||||
|
|
||||
|
Credits |
||||
|
======= |
||||
|
|
||||
|
Contributors |
||||
|
------------ |
||||
|
|
||||
|
* Holger Brunn <hbrunn@therp.nl> |
||||
|
|
||||
|
ACME implementation |
||||
|
------------------- |
||||
|
|
||||
|
* https://github.com/diafygi/acme-tiny/blob/master/acme_tiny.py |
||||
|
|
||||
|
Icon |
||||
|
---- |
||||
|
|
||||
|
* https://helloworld.letsencrypt.org |
||||
|
|
||||
|
Maintainer |
||||
|
---------- |
||||
|
|
||||
|
.. image:: https://odoo-community.org/logo.png |
||||
|
:alt: Odoo Community Association |
||||
|
:target: https://odoo-community.org |
||||
|
|
||||
|
This module is maintained by the OCA. |
||||
|
|
||||
|
OCA, or the Odoo Community Association, is a nonprofit organization whose |
||||
|
mission is to support the collaborative development of Odoo features and |
||||
|
promote its widespread use. |
||||
|
|
||||
|
To contribute to this module, please visit https://odoo-community.org. |
@ -0,0 +1,6 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
from . import models |
||||
|
from . import controllers |
||||
|
from .hooks import post_init_hook |
@ -0,0 +1,25 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
{ |
||||
|
"name": "Let's encrypt", |
||||
|
"version": "8.0.1.0.0", |
||||
|
"author": "Therp BV,Odoo Community Association (OCA)", |
||||
|
"license": "AGPL-3", |
||||
|
"category": "Hidden/Dependency", |
||||
|
"summary": "Request SSL certificates from letsencrypt.org", |
||||
|
"depends": [ |
||||
|
'base', |
||||
|
], |
||||
|
"data": [ |
||||
|
"data/ir_config_parameter.xml", |
||||
|
"data/ir_cron.xml", |
||||
|
], |
||||
|
"post_init_hook": 'post_init_hook', |
||||
|
"installable": True, |
||||
|
"external_dependencies": { |
||||
|
'bin': [ |
||||
|
'openssl', |
||||
|
], |
||||
|
}, |
||||
|
} |
@ -0,0 +1,4 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
from . import letsencrypt |
@ -0,0 +1,20 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
import os |
||||
|
from openerp import http |
||||
|
from openerp.http import request |
||||
|
from openerp.tools.misc import file_open |
||||
|
|
||||
|
|
||||
|
class Letsencrypt(http.Controller): |
||||
|
@http.route('/.well-known/acme-challenge/<filename>', auth='none') |
||||
|
def acme_challenge(self, filename): |
||||
|
try: |
||||
|
return file_open( |
||||
|
os.path.join('letsencrypt', 'static', 'acme-challenge', |
||||
|
filename) |
||||
|
).read() |
||||
|
except IOError: |
||||
|
pass |
||||
|
return request.not_found() |
@ -0,0 +1,9 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<openerp> |
||||
|
<data noupdate="1"> |
||||
|
<record id="config_parameter_reload" model="ir.config_parameter" forcecreate="True"> |
||||
|
<field name="key">letsencrypt.reload_command</field> |
||||
|
<field name="value">sudo /usr/sbin/service nginx reload</field> |
||||
|
</record> |
||||
|
</data> |
||||
|
</openerp> |
@ -0,0 +1,14 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<openerp> |
||||
|
<data> |
||||
|
<record id="cronjob" model="ir.cron"> |
||||
|
<field name="name">Update letsencrypt certificates</field> |
||||
|
<field name="interval_type">weeks</field> |
||||
|
<field name="interval_number">11</field> |
||||
|
<field name="numbercall">-1</field> |
||||
|
<field name="model">letsencrypt</field> |
||||
|
<field name="function">cron</field> |
||||
|
<field name="nextcall">2016-01-01</field> |
||||
|
</record> |
||||
|
</data> |
||||
|
</openerp> |
@ -0,0 +1,9 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
from openerp import SUPERUSER_ID, api |
||||
|
|
||||
|
|
||||
|
def post_init_hook(cr, pool): |
||||
|
env = api.Environment(cr, SUPERUSER_ID, {}) |
||||
|
env['letsencrypt'].generate_account_key() |
@ -0,0 +1,4 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
from . import letsencrypt |
@ -0,0 +1,199 @@ |
|||||
|
# flake8: noqa |
||||
|
#!/usr/bin/env python |
||||
|
import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging |
||||
|
try: |
||||
|
from urllib.request import urlopen # Python 3 |
||||
|
except ImportError: |
||||
|
from urllib2 import urlopen # Python 2 |
||||
|
|
||||
|
#DEFAULT_CA = "https://acme-staging.api.letsencrypt.org" |
||||
|
DEFAULT_CA = "https://acme-v01.api.letsencrypt.org" |
||||
|
|
||||
|
LOGGER = logging.getLogger(__name__) |
||||
|
LOGGER.addHandler(logging.StreamHandler()) |
||||
|
LOGGER.setLevel(logging.INFO) |
||||
|
|
||||
|
def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA): |
||||
|
# helper function base64 encode for jose spec |
||||
|
def _b64(b): |
||||
|
return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") |
||||
|
|
||||
|
# parse account key to get public key |
||||
|
log.info("Parsing account key...") |
||||
|
proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"], |
||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
||||
|
out, err = proc.communicate() |
||||
|
if proc.returncode != 0: |
||||
|
raise IOError("OpenSSL Error: {0}".format(err)) |
||||
|
pub_hex, pub_exp = re.search( |
||||
|
r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)", |
||||
|
out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() |
||||
|
pub_exp = "{0:x}".format(int(pub_exp)) |
||||
|
pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp |
||||
|
header = { |
||||
|
"alg": "RS256", |
||||
|
"jwk": { |
||||
|
"e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), |
||||
|
"kty": "RSA", |
||||
|
"n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), |
||||
|
}, |
||||
|
} |
||||
|
accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) |
||||
|
thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) |
||||
|
|
||||
|
# helper function make signed requests |
||||
|
def _send_signed_request(url, payload): |
||||
|
payload64 = _b64(json.dumps(payload).encode('utf8')) |
||||
|
protected = copy.deepcopy(header) |
||||
|
protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce'] |
||||
|
protected64 = _b64(json.dumps(protected).encode('utf8')) |
||||
|
proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key], |
||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
||||
|
out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8')) |
||||
|
if proc.returncode != 0: |
||||
|
raise IOError("OpenSSL Error: {0}".format(err)) |
||||
|
data = json.dumps({ |
||||
|
"header": header, "protected": protected64, |
||||
|
"payload": payload64, "signature": _b64(out), |
||||
|
}) |
||||
|
try: |
||||
|
resp = urlopen(url, data.encode('utf8')) |
||||
|
return resp.getcode(), resp.read() |
||||
|
except IOError as e: |
||||
|
return getattr(e, "code", None), getattr(e, "read", e.__str__)() |
||||
|
|
||||
|
# find domains |
||||
|
log.info("Parsing CSR...") |
||||
|
proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"], |
||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
||||
|
out, err = proc.communicate() |
||||
|
if proc.returncode != 0: |
||||
|
raise IOError("Error loading {0}: {1}".format(csr, err)) |
||||
|
domains = set([]) |
||||
|
common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out.decode('utf8')) |
||||
|
if common_name is not None: |
||||
|
domains.add(common_name.group(1)) |
||||
|
subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) |
||||
|
if subject_alt_names is not None: |
||||
|
for san in subject_alt_names.group(1).split(", "): |
||||
|
if san.startswith("DNS:"): |
||||
|
domains.add(san[4:]) |
||||
|
|
||||
|
# get the certificate domains and expiration |
||||
|
log.info("Registering account...") |
||||
|
code, result = _send_signed_request(CA + "/acme/new-reg", { |
||||
|
"resource": "new-reg", |
||||
|
"agreement": "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf", |
||||
|
}) |
||||
|
if code == 201: |
||||
|
log.info("Registered!") |
||||
|
elif code == 409: |
||||
|
log.info("Already registered!") |
||||
|
else: |
||||
|
raise ValueError("Error registering: {0} {1}".format(code, result)) |
||||
|
|
||||
|
# verify each domain |
||||
|
for domain in domains: |
||||
|
log.info("Verifying {0}...".format(domain)) |
||||
|
|
||||
|
# get new challenge |
||||
|
code, result = _send_signed_request(CA + "/acme/new-authz", { |
||||
|
"resource": "new-authz", |
||||
|
"identifier": {"type": "dns", "value": domain}, |
||||
|
}) |
||||
|
if code != 201: |
||||
|
raise ValueError("Error requesting challenges: {0} {1}".format(code, result)) |
||||
|
|
||||
|
# make the challenge file |
||||
|
challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0] |
||||
|
token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) |
||||
|
keyauthorization = "{0}.{1}".format(token, thumbprint) |
||||
|
wellknown_path = os.path.join(acme_dir, token) |
||||
|
with open(wellknown_path, "w") as wellknown_file: |
||||
|
wellknown_file.write(keyauthorization) |
||||
|
|
||||
|
# check that the file is in place |
||||
|
wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token) |
||||
|
try: |
||||
|
resp = urlopen(wellknown_url) |
||||
|
resp_data = resp.read().decode('utf8').strip() |
||||
|
assert resp_data == keyauthorization |
||||
|
except (IOError, AssertionError): |
||||
|
os.remove(wellknown_path) |
||||
|
raise ValueError("Wrote file to {0}, but couldn't download {1}".format( |
||||
|
wellknown_path, wellknown_url)) |
||||
|
|
||||
|
# notify challenge are met |
||||
|
code, result = _send_signed_request(challenge['uri'], { |
||||
|
"resource": "challenge", |
||||
|
"keyAuthorization": keyauthorization, |
||||
|
}) |
||||
|
if code != 202: |
||||
|
raise ValueError("Error triggering challenge: {0} {1}".format(code, result)) |
||||
|
|
||||
|
# wait for challenge to be verified |
||||
|
while True: |
||||
|
try: |
||||
|
resp = urlopen(challenge['uri']) |
||||
|
challenge_status = json.loads(resp.read().decode('utf8')) |
||||
|
except IOError as e: |
||||
|
raise ValueError("Error checking challenge: {0} {1}".format( |
||||
|
e.code, json.loads(e.read().decode('utf8')))) |
||||
|
if challenge_status['status'] == "pending": |
||||
|
time.sleep(2) |
||||
|
elif challenge_status['status'] == "valid": |
||||
|
log.info("{0} verified!".format(domain)) |
||||
|
os.remove(wellknown_path) |
||||
|
break |
||||
|
else: |
||||
|
raise ValueError("{0} challenge did not pass: {1}".format( |
||||
|
domain, challenge_status)) |
||||
|
|
||||
|
# get the new certificate |
||||
|
log.info("Signing certificate...") |
||||
|
proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"], |
||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
||||
|
csr_der, err = proc.communicate() |
||||
|
code, result = _send_signed_request(CA + "/acme/new-cert", { |
||||
|
"resource": "new-cert", |
||||
|
"csr": _b64(csr_der), |
||||
|
}) |
||||
|
if code != 201: |
||||
|
raise ValueError("Error signing certificate: {0} {1}".format(code, result)) |
||||
|
|
||||
|
# return signed certificate! |
||||
|
log.info("Certificate signed!") |
||||
|
return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( |
||||
|
"\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64))) |
||||
|
|
||||
|
def main(argv): |
||||
|
parser = argparse.ArgumentParser( |
||||
|
formatter_class=argparse.RawDescriptionHelpFormatter, |
||||
|
description=textwrap.dedent("""\ |
||||
|
This script automates the process of getting a signed TLS certificate from |
||||
|
Let's Encrypt using the ACME protocol. It will need to be run on your server |
||||
|
and have access to your private account key, so PLEASE READ THROUGH IT! It's |
||||
|
only ~200 lines, so it won't take long. |
||||
|
|
||||
|
===Example Usage=== |
||||
|
python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed.crt |
||||
|
=================== |
||||
|
|
||||
|
===Example Crontab Renewal (once per month)=== |
||||
|
0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed.crt 2>> /var/log/acme_tiny.log |
||||
|
============================================== |
||||
|
""") |
||||
|
) |
||||
|
parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") |
||||
|
parser.add_argument("--csr", required=True, help="path to your certificate signing request") |
||||
|
parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") |
||||
|
parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") |
||||
|
parser.add_argument("--ca", default=DEFAULT_CA, help="certificate authority, default is Let's Encrypt") |
||||
|
|
||||
|
args = parser.parse_args(argv) |
||||
|
LOGGER.setLevel(args.quiet or LOGGER.level) |
||||
|
signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca) |
||||
|
sys.stdout.write(signed_crt) |
||||
|
|
||||
|
if __name__ == "__main__": # pragma: no cover |
||||
|
main(sys.argv[1:]) |
@ -0,0 +1,157 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
import os |
||||
|
import logging |
||||
|
import urllib2 |
||||
|
import urlparse |
||||
|
import subprocess |
||||
|
import tempfile |
||||
|
from openerp import _, api, models, exceptions |
||||
|
from openerp.tools.config import _get_default_datadir |
||||
|
from openerp.tools.misc import file_open |
||||
|
from .acme_tiny import get_crt, DEFAULT_CA |
||||
|
|
||||
|
|
||||
|
DEFAULT_KEY_LENGTH = 4096 |
||||
|
_logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class Letsencrypt(models.AbstractModel): |
||||
|
_name = 'letsencrypt' |
||||
|
_description = 'Abstract model providing functions for letsencrypt' |
||||
|
|
||||
|
@api.model |
||||
|
def get_data_dir(self): |
||||
|
return os.path.join(_get_default_datadir(), 'letsencrypt') |
||||
|
|
||||
|
@api.model |
||||
|
def call_cmdline(self, cmdline, loglevel=logging.INFO, |
||||
|
raise_on_result=True): |
||||
|
process = subprocess.Popen( |
||||
|
cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
||||
|
stdout, stderr = process.communicate() |
||||
|
if stderr: |
||||
|
_logger.log(loglevel, stderr) |
||||
|
if stdout: |
||||
|
_logger.log(loglevel, stdout) |
||||
|
|
||||
|
if process.returncode: |
||||
|
raise exceptions.Warning( |
||||
|
_('Error calling %s: %d') % (cmdline[0], process.returncode), |
||||
|
' '.join(cmdline), |
||||
|
) |
||||
|
|
||||
|
return process.returncode |
||||
|
|
||||
|
@api.model |
||||
|
def generate_account_key(self): |
||||
|
data_dir = self.get_data_dir() |
||||
|
if not os.path.isdir(data_dir): |
||||
|
os.makedirs(data_dir) |
||||
|
account_key = os.path.join(data_dir, 'account.key') |
||||
|
if not os.path.isfile(account_key): |
||||
|
_logger.info('generating rsa account key') |
||||
|
self.call_cmdline([ |
||||
|
'openssl', 'genrsa', '-out', account_key, |
||||
|
str(DEFAULT_KEY_LENGTH), |
||||
|
]) |
||||
|
assert os.path.isfile(account_key), 'failed to create rsa key' |
||||
|
return account_key |
||||
|
|
||||
|
@api.model |
||||
|
def generate_domain_key(self, domain): |
||||
|
domain_key = os.path.join(self.get_data_dir(), '%s.key' % domain) |
||||
|
if not os.path.isfile(domain_key): |
||||
|
_logger.info('generating rsa domain key for %s', domain) |
||||
|
self.call_cmdline([ |
||||
|
'openssl', 'genrsa', '-out', domain_key, |
||||
|
str(DEFAULT_KEY_LENGTH), |
||||
|
]) |
||||
|
return domain_key |
||||
|
|
||||
|
@api.model |
||||
|
def validate_domain(self, domain): |
||||
|
if domain in ['localhost', '127.0.0.1'] or\ |
||||
|
domain.startswith('10.') or\ |
||||
|
domain.startswith('172.16.') or\ |
||||
|
domain.startswith('192.168.'): |
||||
|
raise exceptions.Warning( |
||||
|
_("Let's encrypt doesn't work with private addresses!")) |
||||
|
|
||||
|
@api.model |
||||
|
def generate_csr(self, domain): |
||||
|
domains = [domain] |
||||
|
i = 0 |
||||
|
while self.env['ir.config_parameter'].get_param( |
||||
|
'letsencrypt.altname.%d' % i): |
||||
|
domains.append( |
||||
|
self.env['ir.config_parameter'] |
||||
|
.get_param('letsencrypt.altname.%d' % i) |
||||
|
) |
||||
|
i += 1 |
||||
|
_logger.info('generating csr for %s', domain) |
||||
|
if len(domains) > 1: |
||||
|
_logger.info('with alternative subjects %s', ','.join(domains[1:])) |
||||
|
config = self.env['ir.config_parameter'].get_param( |
||||
|
'letsencrypt.openssl.cnf', '/etc/ssl/openssl.cnf') |
||||
|
csr = os.path.join(self.get_data_dir(), '%s.csr' % domain) |
||||
|
with tempfile.NamedTemporaryFile() as cfg: |
||||
|
cfg.write(open(config).read()) |
||||
|
if len(domains) > 1: |
||||
|
cfg.write( |
||||
|
'\n[SAN]\nsubjectAltName=' + |
||||
|
','.join(map(lambda x: 'DNS:%s' % x, domains)) + '\n') |
||||
|
cfg.file.flush() |
||||
|
cmdline = [ |
||||
|
'openssl', 'req', '-new', |
||||
|
self.env['ir.config_parameter'].get_param( |
||||
|
'letsencrypt.openssl.digest', '-sha256'), |
||||
|
'-key', self.generate_domain_key(domain), |
||||
|
'-subj', '/CN=%s' % domain, '-config', cfg.name, |
||||
|
'-out', csr, |
||||
|
] |
||||
|
if len(domains) > 1: |
||||
|
cmdline.extend([ |
||||
|
'-reqexts', 'SAN', |
||||
|
]) |
||||
|
self.call_cmdline(cmdline) |
||||
|
return csr |
||||
|
|
||||
|
@api.model |
||||
|
def cron(self): |
||||
|
domain = urlparse.urlparse( |
||||
|
self.env['ir.config_parameter'].get_param( |
||||
|
'web.base.url', 'localhost')).netloc |
||||
|
self.validate_domain(domain) |
||||
|
account_key = self.generate_account_key() |
||||
|
csr = self.generate_csr(domain) |
||||
|
manifest, manifest_path = file_open( |
||||
|
'letsencrypt/__openerp__.py', pathinfo=True) |
||||
|
manifest.close() |
||||
|
acme_challenge = os.path.join( |
||||
|
os.path.dirname(manifest_path), |
||||
|
'static', 'acme-challenge') |
||||
|
if not os.path.isdir(acme_challenge): |
||||
|
os.makedirs(acme_challenge) |
||||
|
if not self.env.context.get('letsencrypt_fake_cert'): |
||||
|
crt_text = get_crt( |
||||
|
account_key, csr, acme_challenge, log=_logger, CA=DEFAULT_CA) |
||||
|
else: |
||||
|
crt_text = 'I\'m a test text' |
||||
|
with open(os.path.join(self.get_data_dir(), '%s.crt' % domain), 'w')\ |
||||
|
as crt: |
||||
|
crt.write(crt_text) |
||||
|
chain_cert = urllib2.urlopen( |
||||
|
self.env['ir.config_parameter'].get_param( |
||||
|
'letsencrypt.chain_certificate_address', |
||||
|
'https://letsencrypt.org/certs/' |
||||
|
'lets-encrypt-x1-cross-signed.pem') |
||||
|
) |
||||
|
crt.write(chain_cert.read()) |
||||
|
chain_cert.close() |
||||
|
_logger.info('wrote %s', crt.name) |
||||
|
self.call_cmdline([ |
||||
|
'sh', '-c', self.env['ir.config_parameter'].get_param( |
||||
|
'letsencrypt.reload_command', ''), |
||||
|
]) |
After Width: 100 | Height: 100 | Size: 2.1 KiB |
@ -0,0 +1,4 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
from . import test_letsencrypt |
@ -0,0 +1,11 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# © 2016 Therp BV <http://therp.nl> |
||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). |
||||
|
from openerp.tests.common import TransactionCase |
||||
|
|
||||
|
|
||||
|
class TestLetsencrypt(TransactionCase): |
||||
|
def test_letsencrypt(self): |
||||
|
from ..hooks import post_init_hook |
||||
|
post_init_hook(self.cr, None) |
||||
|
self.env['letsencrypt'].with_context(letsencrypt_fake_cert=True).cron() |
Write
Preview
Loading…
Cancel
Save
Reference in new issue