Browse Source

[ADD] letsencrypt

pull/347/head
Holger Brunn 9 years ago
parent
commit
7e7c489bf5
  1. 125
      letsencrypt/README.rst
  2. 6
      letsencrypt/__init__.py
  3. 25
      letsencrypt/__openerp__.py
  4. 4
      letsencrypt/controllers/__init__.py
  5. 20
      letsencrypt/controllers/letsencrypt.py
  6. 9
      letsencrypt/data/ir_config_parameter.xml
  7. 14
      letsencrypt/data/ir_cron.xml
  8. 9
      letsencrypt/hooks.py
  9. 4
      letsencrypt/models/__init__.py
  10. 199
      letsencrypt/models/acme_tiny.py
  11. 157
      letsencrypt/models/letsencrypt.py
  12. BIN
      letsencrypt/static/description/icon.png
  13. 4
      letsencrypt/tests/__init__.py
  14. 11
      letsencrypt/tests/test_letsencrypt.py

125
letsencrypt/README.rst

@ -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.

6
letsencrypt/__init__.py

@ -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

25
letsencrypt/__openerp__.py

@ -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',
],
},
}

4
letsencrypt/controllers/__init__.py

@ -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

20
letsencrypt/controllers/letsencrypt.py

@ -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()

9
letsencrypt/data/ir_config_parameter.xml

@ -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>

14
letsencrypt/data/ir_cron.xml

@ -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>

9
letsencrypt/hooks.py

@ -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()

4
letsencrypt/models/__init__.py

@ -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

199
letsencrypt/models/acme_tiny.py

@ -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:])

157
letsencrypt/models/letsencrypt.py

@ -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', ''),
])

BIN
letsencrypt/static/description/icon.png

After

Width: 100  |  Height: 100  |  Size: 2.1 KiB

4
letsencrypt/tests/__init__.py

@ -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

11
letsencrypt/tests/test_letsencrypt.py

@ -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()
Loading…
Cancel
Save