Browse Source

Multi-database support and other fixes (#2)

[ADD] multi-database support and other fixes
pull/347/head
Antonio Espinosa 9 years ago
committed by Holger Brunn
parent
commit
89db6d0395
  1. 25
      letsencrypt/README.rst
  2. 8
      letsencrypt/__openerp__.py
  3. 9
      letsencrypt/controllers/main.py
  4. 199
      letsencrypt/models/acme_tiny.py
  5. 61
      letsencrypt/models/letsencrypt.py

25
letsencrypt/README.rst

@ -22,6 +22,23 @@ the SSL version.
After installation, trigger the cronjob `Update letsencrypt certificates` and After installation, trigger the cronjob `Update letsencrypt certificates` and
watch your log for messages. watch your log for messages.
This addon depends on ``openssl`` binary and ``acme_tiny`` and ``IPy``
python modules.
For installing OpenSSL binary you can use you distro package manager. For Debian
and Ubuntu would be:
sudo apt-get install openssl
For installing ACME-Tiny python module you can use PIP package manager:
sudo pip install acme-tiny
For installing ACME-Tiny python module you can use PIP package manager:
sudo pip install IPy
Configuration Configuration
============= =============
@ -97,6 +114,13 @@ an upstream for your odoo instance and do something like::
proxy_pass http://yourodooupstream; 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 Bug Tracker
=========== ===========
@ -112,6 +136,7 @@ Contributors
------------ ------------
* Holger Brunn <hbrunn@therp.nl> * Holger Brunn <hbrunn@therp.nl>
* Antonio Espinosa <antonio.espinosa@tecnativa.com>
ACME implementation ACME implementation
------------------- -------------------

8
letsencrypt/__openerp__.py

@ -4,7 +4,9 @@
{ {
"name": "Let's encrypt", "name": "Let's encrypt",
"version": "8.0.1.0.0", "version": "8.0.1.0.0",
"author": "Therp BV,Odoo Community Association (OCA)",
"author": "Therp BV,"
"Tecnativa,"
"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",
@ -21,5 +23,9 @@
'bin': [ 'bin': [
'openssl', 'openssl',
], ],
'python': [
'acme_tiny',
'IPy',
],
}, },
} }

9
letsencrypt/controllers/main.py

@ -1,20 +1,19 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl> # © 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). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import os import os
from openerp import http from openerp import http
from openerp.http import request from openerp.http import request
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 file(
os.path.join(request.env['letsencrypt'].get_challenge_dir(),
filename)
) as challenge:
return challenge.read()
with file(os.path.join(get_challenge_dir(), filename)) as key:
return key.read()
except IOError: except IOError:
pass pass
return request.not_found() return request.not_found()

199
letsencrypt/models/acme_tiny.py

@ -1,199 +0,0 @@
# flake8: noqa
# -*- coding: utf-8 -*-
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:])

61
letsencrypt/models/letsencrypt.py

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# © 2016 Therp BV <http://therp.nl> # © 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). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import os import os
import logging import logging
@ -8,25 +9,25 @@ import urlparse
import subprocess import subprocess
import tempfile import tempfile
from openerp import _, api, models, exceptions from openerp import _, api, models, exceptions
from openerp.tools.config import _get_default_datadir
from openerp.tools import config
DEFAULT_KEY_LENGTH = 4096 DEFAULT_KEY_LENGTH = 4096
_logger = logging.getLogger(__name__) _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): class Letsencrypt(models.AbstractModel):
_name = 'letsencrypt' _name = 'letsencrypt'
_description = 'Abstract model providing functions for 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 get_challenge_dir(self):
return os.path.join(self.get_data_dir(), 'acme-challenge')
@api.model @api.model
def call_cmdline(self, cmdline, loglevel=logging.INFO, def call_cmdline(self, cmdline, loglevel=logging.INFO,
raise_on_result=True): raise_on_result=True):
@ -48,7 +49,7 @@ class Letsencrypt(models.AbstractModel):
@api.model @api.model
def generate_account_key(self): def generate_account_key(self):
data_dir = self.get_data_dir()
data_dir = get_data_dir()
if not os.path.isdir(data_dir): if not os.path.isdir(data_dir):
os.makedirs(data_dir) os.makedirs(data_dir)
account_key = os.path.join(data_dir, 'account.key') account_key = os.path.join(data_dir, 'account.key')
@ -63,7 +64,7 @@ class Letsencrypt(models.AbstractModel):
@api.model @api.model
def generate_domain_key(self, domain): def generate_domain_key(self, domain):
domain_key = os.path.join(self.get_data_dir(), '%s.key' % domain)
domain_key = os.path.join(get_data_dir(), '%s.key' % domain)
if not os.path.isfile(domain_key): if not os.path.isfile(domain_key):
_logger.info('generating rsa domain key for %s', domain) _logger.info('generating rsa domain key for %s', domain)
self.call_cmdline([ self.call_cmdline([
@ -74,12 +75,20 @@ class Letsencrypt(models.AbstractModel):
@api.model @api.model
def validate_domain(self, domain): 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.'):
local_domains = ['localhost', 'localhost.localdomain']
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( raise exceptions.Warning(
_("Let's encrypt doesn't work with private addresses!"))
_("Let's encrypt doesn't work with private addresses "
"or local domains!"))
@api.model @api.model
def generate_csr(self, domain): def generate_csr(self, domain):
@ -97,7 +106,7 @@ class Letsencrypt(models.AbstractModel):
_logger.info('with alternative subjects %s', ','.join(domains[1:])) _logger.info('with alternative subjects %s', ','.join(domains[1:]))
config = self.env['ir.config_parameter'].get_param( config = self.env['ir.config_parameter'].get_param(
'letsencrypt.openssl.cnf', '/etc/ssl/openssl.cnf') 'letsencrypt.openssl.cnf', '/etc/ssl/openssl.cnf')
csr = os.path.join(self.get_data_dir(), '%s.csr' % domain)
csr = os.path.join(get_data_dir(), '%s.csr' % domain)
with tempfile.NamedTemporaryFile() as cfg: with tempfile.NamedTemporaryFile() as cfg:
cfg.write(open(config).read()) cfg.write(open(config).read())
if len(domains) > 1: if len(domains) > 1:
@ -128,16 +137,16 @@ class Letsencrypt(models.AbstractModel):
self.validate_domain(domain) self.validate_domain(domain)
account_key = self.generate_account_key() account_key = self.generate_account_key()
csr = self.generate_csr(domain) csr = self.generate_csr(domain)
acme_challenge = self.get_challenge_dir()
acme_challenge = get_challenge_dir()
if not os.path.isdir(acme_challenge): if not os.path.isdir(acme_challenge):
os.makedirs(acme_challenge) os.makedirs(acme_challenge)
if self.env.context.get('letsencrypt_dry_run'): if self.env.context.get('letsencrypt_dry_run'):
crt_text = 'I\'m a test text' crt_text = 'I\'m a test text'
else: # pragma: no cover else: # pragma: no cover
from .acme_tiny import get_crt, DEFAULT_CA
from acme_tiny import get_crt, DEFAULT_CA
crt_text = get_crt( crt_text = get_crt(
account_key, csr, acme_challenge, log=_logger, CA=DEFAULT_CA) account_key, csr, acme_challenge, log=_logger, CA=DEFAULT_CA)
with open(os.path.join(self.get_data_dir(), '%s.crt' % domain), 'w')\
with open(os.path.join(get_data_dir(), '%s.crt' % domain), 'w')\
as crt: as crt:
crt.write(crt_text) crt.write(crt_text)
chain_cert = urllib2.urlopen( chain_cert = urllib2.urlopen(
@ -149,7 +158,11 @@ class Letsencrypt(models.AbstractModel):
crt.write(chain_cert.read()) crt.write(chain_cert.read())
chain_cert.close() chain_cert.close()
_logger.info('wrote %s', crt.name) _logger.info('wrote %s', crt.name)
self.call_cmdline([
'sh', '-c', self.env['ir.config_parameter'].get_param(
'letsencrypt.reload_command', ''),
])
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')
Loading…
Cancel
Save