mirror of
https://github.com/saltstack/salt.git
synced 2025-04-16 09:40:20 +00:00
349 lines
11 KiB
Python
349 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
'''
|
|
ACME / Let's Encrypt module
|
|
===========================
|
|
|
|
.. versionadded: 2016.3
|
|
|
|
This module currently looks for certbot script in the $PATH as
|
|
- certbot,
|
|
- lestsencrypt,
|
|
- certbot-auto,
|
|
- letsencrypt-auto
|
|
eventually falls back to /opt/letsencrypt/letsencrypt-auto
|
|
|
|
.. note::
|
|
|
|
Installation & configuration of the Let's Encrypt client can for example be done using
|
|
https://github.com/saltstack-formulas/letsencrypt-formula
|
|
|
|
.. warning::
|
|
|
|
Be sure to set at least accept-tos = True in cli.ini!
|
|
|
|
Most parameters will fall back to cli.ini defaults if None is given.
|
|
|
|
DNS plugins
|
|
-----------
|
|
|
|
This module currently supports the CloudFlare certbot DNS plugin. The DNS
|
|
plugin credentials file needs to be passed in using the
|
|
``dns_plugin_credentials`` argument.
|
|
|
|
Make sure the appropriate certbot plugin for the wanted DNS provider is
|
|
installed before using this module.
|
|
|
|
'''
|
|
# Import python libs
|
|
from __future__ import absolute_import, print_function, unicode_literals
|
|
import logging
|
|
import datetime
|
|
import os
|
|
|
|
# Import salt libs
|
|
import salt.utils.path
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
LEA = salt.utils.path.which_bin(['certbot', 'letsencrypt',
|
|
'certbot-auto', 'letsencrypt-auto',
|
|
'/opt/letsencrypt/letsencrypt-auto'])
|
|
LE_LIVE = '/etc/letsencrypt/live/'
|
|
|
|
|
|
def __virtual__():
|
|
'''
|
|
Only work when letsencrypt-auto is installed
|
|
'''
|
|
return LEA is not None, 'The ACME execution module cannot be loaded: letsencrypt-auto not installed.'
|
|
|
|
|
|
def _cert_file(name, cert_type):
|
|
'''
|
|
Return expected path of a Let's Encrypt live cert
|
|
'''
|
|
return os.path.join(LE_LIVE, name, '{0}.pem'.format(cert_type))
|
|
|
|
|
|
def _expires(name):
|
|
'''
|
|
Return the expiry date of a cert
|
|
|
|
:return datetime object of expiry date
|
|
'''
|
|
cert_file = _cert_file(name, 'cert')
|
|
# Use the salt module if available
|
|
if 'tls.cert_info' in __salt__:
|
|
expiry = __salt__['tls.cert_info'](cert_file)['not_after']
|
|
# Cobble it together using the openssl binary
|
|
else:
|
|
openssl_cmd = 'openssl x509 -in {0} -noout -enddate'.format(cert_file)
|
|
# No %e format on my Linux'es here
|
|
strptime_sux_cmd = 'date --date="$({0} | cut -d= -f2)" +%s'.format(openssl_cmd)
|
|
expiry = float(__salt__['cmd.shell'](strptime_sux_cmd, output_loglevel='quiet'))
|
|
# expiry = datetime.datetime.strptime(expiry.split('=', 1)[-1], '%b %e %H:%M:%S %Y %Z')
|
|
|
|
return datetime.datetime.fromtimestamp(expiry)
|
|
|
|
|
|
def _renew_by(name, window=None):
|
|
'''
|
|
Date before a certificate should be renewed
|
|
|
|
:param name: Common Name of the certificate (DNS name of certificate)
|
|
:param window: days before expiry date to renew
|
|
:return datetime object of first renewal date
|
|
'''
|
|
expiry = _expires(name)
|
|
if window is not None:
|
|
expiry = expiry - datetime.timedelta(days=window)
|
|
|
|
return expiry
|
|
|
|
|
|
def cert(name,
|
|
aliases=None,
|
|
email=None,
|
|
webroot=None,
|
|
test_cert=False,
|
|
renew=None,
|
|
keysize=None,
|
|
server=None,
|
|
owner='root',
|
|
group='root',
|
|
mode='0640',
|
|
certname=None,
|
|
preferred_challenges=None,
|
|
tls_sni_01_port=None,
|
|
tls_sni_01_address=None,
|
|
http_01_port=None,
|
|
http_01_address=None,
|
|
dns_plugin=None,
|
|
dns_plugin_credentials=None):
|
|
'''
|
|
Obtain/renew a certificate from an ACME CA, probably Let's Encrypt.
|
|
|
|
:param name: Common Name of the certificate (DNS name of certificate)
|
|
:param aliases: subjectAltNames (Additional DNS names on certificate)
|
|
:param email: e-mail address for interaction with ACME provider
|
|
:param webroot: True or a full path to use to use webroot. Otherwise use standalone mode
|
|
:param test_cert: Request a certificate from the Happy Hacker Fake CA (mutually exclusive with 'server')
|
|
:param renew: True/'force' to force a renewal, or a window of renewal before expiry in days
|
|
:param keysize: RSA key bits
|
|
:param server: API endpoint to talk to
|
|
:param owner: owner of the private key file
|
|
:param group: group of the private key file
|
|
:param mode: mode of the private key file
|
|
:param certname: Name of the certificate to save
|
|
:param preferred_challenges: A sorted, comma delimited list of the preferred
|
|
challenge to use during authorization with the
|
|
most preferred challenge listed first.
|
|
:param tls_sni_01_port: Port used during tls-sni-01 challenge. This only affects
|
|
the port Certbot listens on. A conforming ACME server
|
|
will still attempt to connect on port 443.
|
|
:param tls_sni_01_address: The address the server listens to during tls-sni-01
|
|
challenge.
|
|
:param http_01_port: Port used in the http-01 challenge. This only affects
|
|
the port Certbot listens on. A conforming ACME server
|
|
will still attempt to connect on port 80.
|
|
:param https_01_address: The address the server listens to during http-01 challenge.
|
|
:param dns_plugin: Name of a DNS plugin to use (currently only 'cloudflare')
|
|
:param dns_plugin_credentials: Path to the credentials file if required by the specified DNS plugin
|
|
:return: dict with 'result' True/False/None, 'comment' and certificate's expiry date ('not_after')
|
|
|
|
CLI example:
|
|
|
|
.. code-block:: bash
|
|
|
|
salt 'gitlab.example.com' acme.cert dev.example.com "[gitlab.example.com]" test_cert=True renew=14 webroot=/opt/gitlab/embedded/service/gitlab-rails/public
|
|
'''
|
|
|
|
cmd = [LEA, 'certonly', '--non-interactive', '--agree-tos']
|
|
|
|
supported_dns_plugins = ['cloudflare']
|
|
|
|
cert_file = _cert_file(name, 'cert')
|
|
if not __salt__['file.file_exists'](cert_file):
|
|
log.debug('Certificate %s does not exist (yet)', cert_file)
|
|
renew = False
|
|
elif needs_renewal(name, renew):
|
|
log.debug('Certificate %s will be renewed', cert_file)
|
|
cmd.append('--renew-by-default')
|
|
renew = True
|
|
if server:
|
|
cmd.append('--server {0}'.format(server))
|
|
|
|
if certname:
|
|
cmd.append('--cert-name {0}'.format(certname))
|
|
|
|
if test_cert:
|
|
if server:
|
|
return {'result': False, 'comment': 'Use either server or test_cert, not both'}
|
|
cmd.append('--test-cert')
|
|
|
|
if webroot:
|
|
cmd.append('--authenticator webroot')
|
|
if webroot is not True:
|
|
cmd.append('--webroot-path {0}'.format(webroot))
|
|
elif dns_plugin in supported_dns_plugins:
|
|
if dns_plugin == 'cloudflare':
|
|
cmd.append('--dns-cloudflare')
|
|
cmd.append('--dns-cloudflare-credentials {0}'.format(dns_plugin_credentials))
|
|
else:
|
|
return {'result': False, 'comment': 'DNS plugin \'{0}\' is not supported'.format(dns_plugin)}
|
|
else:
|
|
cmd.append('--authenticator standalone')
|
|
|
|
if email:
|
|
cmd.append('--email {0}'.format(email))
|
|
|
|
if keysize:
|
|
cmd.append('--rsa-key-size {0}'.format(keysize))
|
|
|
|
cmd.append('--domains {0}'.format(name))
|
|
if aliases is not None:
|
|
for dns in aliases:
|
|
cmd.append('--domains {0}'.format(dns))
|
|
|
|
if preferred_challenges:
|
|
cmd.append('--preferred-challenges {}'.format(preferred_challenges))
|
|
|
|
if tls_sni_01_port:
|
|
cmd.append('--tls-sni-01-port {}'.format(tls_sni_01_port))
|
|
if tls_sni_01_address:
|
|
cmd.append('--tls-sni-01-address {}'.format(tls_sni_01_address))
|
|
if http_01_port:
|
|
cmd.append('--http-01-port {}'.format(http_01_port))
|
|
if http_01_address:
|
|
cmd.append('--http-01-address {}'.format(http_01_address))
|
|
|
|
res = __salt__['cmd.run_all'](' '.join(cmd))
|
|
|
|
if res['retcode'] != 0:
|
|
if 'expand' in res['stderr']:
|
|
cmd.append('--expand')
|
|
res = __salt__['cmd.run_all'](' '.join(cmd))
|
|
if res['retcode'] != 0:
|
|
return {'result': False, 'comment': 'Certificate {0} renewal failed with:\n{1}'.format(name, res['stderr'])}
|
|
else:
|
|
return {'result': False, 'comment': 'Certificate {0} renewal failed with:\n{1}'.format(name, res['stderr'])}
|
|
|
|
if 'no action taken' in res['stdout']:
|
|
comment = 'Certificate {0} unchanged'.format(cert_file)
|
|
elif renew:
|
|
comment = 'Certificate {0} renewed'.format(name)
|
|
else:
|
|
comment = 'Certificate {0} obtained'.format(name)
|
|
|
|
ret = {'comment': comment, 'not_after': expires(name), 'changes': {}, 'result': True}
|
|
ret, _ = __salt__['file.check_perms'](_cert_file(name, 'privkey'),
|
|
ret,
|
|
owner, group, mode,
|
|
follow_symlinks=True)
|
|
|
|
return ret
|
|
|
|
|
|
def certs():
|
|
'''
|
|
Return a list of active certificates
|
|
|
|
CLI example:
|
|
|
|
.. code-block:: bash
|
|
|
|
salt 'vhost.example.com' acme.certs
|
|
'''
|
|
return __salt__['file.readdir'](LE_LIVE)[2:]
|
|
|
|
|
|
def info(name):
|
|
'''
|
|
Return information about a certificate
|
|
|
|
.. note::
|
|
Will output tls.cert_info if that's available, or OpenSSL text if not
|
|
|
|
:param name: CommonName of cert
|
|
|
|
CLI example:
|
|
|
|
.. code-block:: bash
|
|
|
|
salt 'gitlab.example.com' acme.info dev.example.com
|
|
'''
|
|
cert_file = _cert_file(name, 'cert')
|
|
# Use the salt module if available
|
|
if 'tls.cert_info' in __salt__:
|
|
cert_info = __salt__['tls.cert_info'](cert_file)
|
|
# Strip out the extensions object contents;
|
|
# these trip over our poor state output
|
|
# and they serve no real purpose here anyway
|
|
cert_info['extensions'] = cert_info['extensions'].keys()
|
|
return cert_info
|
|
# Cobble it together using the openssl binary
|
|
openssl_cmd = 'openssl x509 -in {0} -noout -text'.format(cert_file)
|
|
return __salt__['cmd.run'](openssl_cmd, output_loglevel='quiet')
|
|
|
|
|
|
def expires(name):
|
|
'''
|
|
The expiry date of a certificate in ISO format
|
|
|
|
:param name: CommonName of cert
|
|
|
|
CLI example:
|
|
|
|
.. code-block:: bash
|
|
|
|
salt 'gitlab.example.com' acme.expires dev.example.com
|
|
'''
|
|
return _expires(name).isoformat()
|
|
|
|
|
|
def has(name):
|
|
'''
|
|
Test if a certificate is in the Let's Encrypt Live directory
|
|
|
|
:param name: CommonName of cert
|
|
|
|
Code example:
|
|
|
|
.. code-block:: python
|
|
|
|
if __salt__['acme.has']('dev.example.com'):
|
|
log.info('That is one nice certificate you have there!')
|
|
'''
|
|
return __salt__['file.file_exists'](_cert_file(name, 'cert'))
|
|
|
|
|
|
def renew_by(name, window=None):
|
|
'''
|
|
Date in ISO format when a certificate should first be renewed
|
|
|
|
:param name: CommonName of cert
|
|
:param window: number of days before expiry when renewal should take place
|
|
'''
|
|
return _renew_by(name, window).isoformat()
|
|
|
|
|
|
def needs_renewal(name, window=None):
|
|
'''
|
|
Check if a certificate needs renewal
|
|
|
|
:param name: CommonName of cert
|
|
:param window: Window in days to renew earlier or True/force to just return True
|
|
|
|
Code example:
|
|
|
|
.. code-block:: python
|
|
|
|
if __salt__['acme.needs_renewal']('dev.example.com'):
|
|
__salt__['acme.cert']('dev.example.com', **kwargs)
|
|
else:
|
|
log.info('Your certificate is still good')
|
|
'''
|
|
if window is not None and window in ('force', 'Force', True):
|
|
return True
|
|
|
|
return _renew_by(name, window) <= datetime.datetime.today()
|