Browse Source
[ADD] letsencrypt (#425)
[ADD] letsencrypt (#425)
* [ADD] letsencrypt (#347)
* [ADD] letsencrypt
* [ADD] write bogus restart script for tests
* [IMP] exclude library call from coveralls
* [IMP] try moving the library import into nocover branch
* [ADD] explain how to redirect the well known uri to the odoo instance
* [ADD] example for apache
* [FIX] cronjob should be noupdate
* [FIX] community review
* [FIX] flake8
* [DEL] unused imports
* [UPD] chain cert
* Multi-database support and other fixes (#2)
[ADD] multi-database support and other fixes
* [ADD] eggs necessary for letsencrypt
* [IMP] readme
* [ADD] ipv6 localhosts
* [ADD] restrict reload command
* Revert "[ADD] eggs necessary for letsencrypt"
This reverts commit 642df6ba50
.
* [ADD] eggs necessary for letsencrypt
Conflicts:
requirements.txt
* Migrate letsencrypt to v9
* Add AGPL target link to ReadMe in letsencrypt
pull/1244/head
Dave Lasley
9 years ago
committed by
Ignacio Ibeas - Acysos S.L
13 changed files with 452 additions and 0 deletions
-
166letsencrypt/README.rst
-
6letsencrypt/__init__.py
-
31letsencrypt/__openerp__.py
-
4letsencrypt/controllers/__init__.py
-
19letsencrypt/controllers/main.py
-
10letsencrypt/data/ir_config_parameter.xml
-
14letsencrypt/data/ir_cron.xml
-
9letsencrypt/hooks.py
-
4letsencrypt/models/__init__.py
-
171letsencrypt/models/letsencrypt.py
-
BINletsencrypt/static/description/icon.png
-
4letsencrypt/tests/__init__.py
-
14letsencrypt/tests/test_letsencrypt.py
@ -0,0 +1,166 @@ |
|||
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg |
|||
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html |
|||
:alt: License: AGPL-3 |
|||
|
|||
============================================= |
|||
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. |
|||
|
|||
This addon depends on the ``openssl`` binary and the ``acme_tiny`` and ``IPy`` |
|||
python modules. |
|||
|
|||
For installing the OpenSSL binary you can use your distro package manager. |
|||
For Debian and Ubuntu, that would be: |
|||
|
|||
sudo apt-get install openssl |
|||
|
|||
For installing the ACME-Tiny python module, use the PIP package manager: |
|||
|
|||
sudo pip install acme-tiny |
|||
|
|||
For installing the IPy python module, use the PIP package manager: |
|||
|
|||
sudo pip install IPy |
|||
|
|||
|
|||
Configuration |
|||
============= |
|||
|
|||
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 for nginx:: |
|||
|
|||
if ($scheme = "http") { |
|||
set $redirect_https 1; |
|||
} |
|||
if ($request_uri ~ ^/.well-known/acme-challenge/) { |
|||
set $redirect_https 0; |
|||
} |
|||
if ($redirect_https) { |
|||
rewrite ^ https://$server_name$request_uri? permanent; |
|||
} |
|||
|
|||
and this for apache:: |
|||
|
|||
RewriteEngine On |
|||
RewriteCond %{HTTPS} !=on |
|||
RewriteCond %{REQUEST_URI} "!^/.well-known/" |
|||
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L] |
|||
|
|||
In case you need to redirect other nginx sites to your Odoo instance, declare |
|||
an upstream for your odoo instance and do something like:: |
|||
|
|||
location /.well-known { |
|||
proxy_pass http://yourodooupstream; |
|||
} |
|||
|
|||
If you're using a multi-database installation (with or without dbfilter option) |
|||
where /web/databse/selector returns a list of more than one database, then |
|||
you need to add ``letsencrypt`` addon to wide load addons list |
|||
(by default, only ``web`` addon), setting ``--load`` option. |
|||
For example, ``--load=web,letsencrypt`` |
|||
|
|||
|
|||
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:%209.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_. |
|||
|
|||
Credits |
|||
======= |
|||
|
|||
Contributors |
|||
------------ |
|||
|
|||
* Holger Brunn <hbrunn@therp.nl> |
|||
* Antonio Espinosa <antonio.espinosa@tecnativa.com> |
|||
* Dave Lasley <dave@laslabs.com> |
|||
|
|||
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,31 @@ |
|||
# -*- 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": "9.0.1.0.0", |
|||
"author": "Therp BV," |
|||
"Tecnativa," |
|||
"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', |
|||
], |
|||
'python': [ |
|||
'acme_tiny', |
|||
'IPy', |
|||
], |
|||
}, |
|||
} |
@ -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 main |
@ -0,0 +1,19 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# © 2016 Therp BV <http://therp.nl> |
|||
# © 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com> |
|||
# 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 ..models.letsencrypt import get_challenge_dir |
|||
|
|||
|
|||
class Letsencrypt(http.Controller): |
|||
@http.route('/.well-known/acme-challenge/<filename>', auth='none') |
|||
def acme_challenge(self, filename): |
|||
try: |
|||
with file(os.path.join(get_challenge_dir(), filename)) as key: |
|||
return key.read() |
|||
except IOError: |
|||
pass |
|||
return request.not_found() |
@ -0,0 +1,10 @@ |
|||
<?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> |
|||
<field name="group_ids" eval="[(6, False, [ref('base.group_system')])]" /> |
|||
</record> |
|||
</data> |
|||
</openerp> |
@ -0,0 +1,14 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<openerp> |
|||
<data noupdate="1"> |
|||
<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,171 @@ |
|||
# -*- coding: utf-8 -*- |
|||
# © 2016 Therp BV <http://therp.nl> |
|||
# © 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com> |
|||
# 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 import config |
|||
|
|||
|
|||
DEFAULT_KEY_LENGTH = 4096 |
|||
_logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
def get_data_dir(): |
|||
return os.path.join(config.options.get('data_dir'), 'letsencrypt') |
|||
|
|||
|
|||
def get_challenge_dir(): |
|||
return os.path.join(get_data_dir(), 'acme-challenge') |
|||
|
|||
|
|||
class Letsencrypt(models.AbstractModel): |
|||
_name = 'letsencrypt' |
|||
_description = 'Abstract model providing functions for letsencrypt' |
|||
|
|||
@api.model |
|||
def call_cmdline(self, cmdline, loglevel=logging.INFO, |
|||
raise_on_result=True): |
|||
process = subprocess.Popen( |
|||
cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
|||
stdout, stderr = process.communicate() |
|||
if stderr: |
|||
_logger.log(loglevel, stderr) |
|||
if stdout: |
|||
_logger.log(loglevel, stdout) |
|||
|
|||
if process.returncode: |
|||
raise exceptions.Warning( |
|||
_('Error calling %s: %d') % (cmdline[0], process.returncode), |
|||
' '.join(cmdline), |
|||
) |
|||
|
|||
return process.returncode |
|||
|
|||
@api.model |
|||
def generate_account_key(self): |
|||
data_dir = get_data_dir() |
|||
if not os.path.isdir(data_dir): |
|||
os.makedirs(data_dir) |
|||
account_key = os.path.join(data_dir, 'account.key') |
|||
if not os.path.isfile(account_key): |
|||
_logger.info('generating rsa account key') |
|||
self.call_cmdline([ |
|||
'openssl', 'genrsa', '-out', account_key, |
|||
str(DEFAULT_KEY_LENGTH), |
|||
]) |
|||
assert os.path.isfile(account_key), 'failed to create rsa key' |
|||
return account_key |
|||
|
|||
@api.model |
|||
def generate_domain_key(self, domain): |
|||
domain_key = os.path.join(get_data_dir(), '%s.key' % domain) |
|||
if not os.path.isfile(domain_key): |
|||
_logger.info('generating rsa domain key for %s', domain) |
|||
self.call_cmdline([ |
|||
'openssl', 'genrsa', '-out', domain_key, |
|||
str(DEFAULT_KEY_LENGTH), |
|||
]) |
|||
return domain_key |
|||
|
|||
@api.model |
|||
def validate_domain(self, domain): |
|||
local_domains = [ |
|||
'localhost', 'localhost.localdomain', 'localhost6', |
|||
'localhost6.localdomain6' |
|||
] |
|||
|
|||
def _ip_is_private(address): |
|||
import IPy |
|||
try: |
|||
ip = IPy.IP(address) |
|||
except: |
|||
return False |
|||
return ip.iptype() == 'PRIVATE' |
|||
|
|||
if domain in local_domains or _ip_is_private(domain): |
|||
raise exceptions.Warning( |
|||
_("Let's encrypt doesn't work with private addresses " |
|||
"or local domains!")) |
|||
|
|||
@api.model |
|||
def generate_csr(self, domain): |
|||
domains = [domain] |
|||
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(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) |
|||
acme_challenge = get_challenge_dir() |
|||
if not os.path.isdir(acme_challenge): |
|||
os.makedirs(acme_challenge) |
|||
if self.env.context.get('letsencrypt_dry_run'): |
|||
crt_text = 'I\'m a test text' |
|||
else: # pragma: no cover |
|||
from acme_tiny import get_crt, DEFAULT_CA |
|||
crt_text = get_crt( |
|||
account_key, csr, acme_challenge, log=_logger, CA=DEFAULT_CA) |
|||
with open(os.path.join(get_data_dir(), '%s.crt' % domain), 'w')\ |
|||
as crt: |
|||
crt.write(crt_text) |
|||
chain_cert = urllib2.urlopen( |
|||
self.env['ir.config_parameter'].get_param( |
|||
'letsencrypt.chain_certificate_address', |
|||
'https://letsencrypt.org/certs/' |
|||
'lets-encrypt-x3-cross-signed.pem') |
|||
) |
|||
crt.write(chain_cert.read()) |
|||
chain_cert.close() |
|||
_logger.info('wrote %s', crt.name) |
|||
reload_cmd = self.env['ir.config_parameter'].get_param( |
|||
'letsencrypt.reload_command', False) |
|||
if reload_cmd: |
|||
_logger.info('reloading webserver...') |
|||
self.call_cmdline(['sh', '-c', reload_cmd]) |
|||
else: |
|||
_logger.info('no command defined for reloading webserver, please ' |
|||
'do it manually in order to apply new certificate') |
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,14 @@ |
|||
# -*- 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.ref('letsencrypt.config_parameter_reload').write({ |
|||
'value': '', |
|||
}) |
|||
self.env['letsencrypt'].with_context(letsencrypt_dry_run=True).cron() |
Write
Preview
Loading…
Cancel
Save
Reference in new issue