Mini Shell
"""
ACME / Let's Encrypt module
===========================
.. versionadded:: 2016.3.0
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 datetime
import logging
import os
import salt.utils.path
from salt.exceptions import SaltInvocationError
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/"
if salt.utils.platform.is_freebsd():
LE_LIVE = "/usr/local" + LE_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, f"{cert_type}.pem")
def _expires(name):
"""
Return the expiry date of a cert
:rtype: datetime
:return: 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).get("not_after", 0)
# Cobble it together using the openssl binary
else:
openssl_cmd = f"openssl x509 -in {cert_file} -noout -enddate"
# No %e format on my Linux'es here
strptime_sux_cmd = f'date --date="$({openssl_cmd} | cut -d= -f2)" +%s'
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 str name: Name of the certificate
:param int window: days before expiry date to renew
:rtype: datetime
:return: 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,
manual_auth_hook=None,
manual_cleanup_hook=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'
or 'digitalocean')
:param dns_plugin_credentials: Path to the credentials file if required by
the specified DNS plugin
:param dns_plugin_propagate_seconds: Number of seconds to wait for DNS propogations
before asking ACME servers to verify the DNS record. (default 10)
:param manual_auth_hook: Path to the manual authentication hook script.
:param manual_cleanup_hook: Path to the manual cleanup or post-authentication hook script.
:rtype: dict
:return: Dictionary 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"]
if certname is None:
certname = name
supported_dns_plugins = ["cloudflare"]
cert_file = _cert_file(certname, "cert")
if not __salt__["file.file_exists"](cert_file):
log.debug("Certificate %s does not exist (yet)", cert_file)
renew = False
elif needs_renewal(certname, renew):
log.debug("Certificate %s will be renewed", cert_file)
cmd.append("--renew-by-default")
renew = True
if server:
cmd.append(f"--server {server}")
if certname:
cmd.append(f"--cert-name {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(f"--webroot-path {webroot}")
elif dns_plugin in supported_dns_plugins:
if dns_plugin == "cloudflare":
cmd.append("--dns-cloudflare")
cmd.append(f"--dns-cloudflare-credentials {dns_plugin_credentials}")
else:
return {
"result": False,
"comment": f"DNS plugin '{dns_plugin}' is not supported",
}
elif manual_auth_hook:
cmd.append("--manual")
cmd.append(f"--manual-auth-hook '{manual_auth_hook}'")
if manual_cleanup_hook:
cmd.append(f"--manual-cleanup-hook '{manual_cleanup_hook}'")
else:
cmd.append("--authenticator standalone")
if email:
cmd.append(f"--email {email}")
if keysize:
cmd.append(f"--rsa-key-size {keysize}")
cmd.append(f"--domains {name}")
if aliases is not None:
for dns in aliases:
cmd.append(f"--domains {dns}")
if preferred_challenges:
cmd.append(f"--preferred-challenges {preferred_challenges}")
if tls_sni_01_port:
cmd.append(f"--tls-sni-01-port {tls_sni_01_port}")
if tls_sni_01_address:
cmd.append(f"--tls-sni-01-address {tls_sni_01_address}")
if http_01_port:
cmd.append(f"--http-01-port {http_01_port}")
if http_01_address:
cmd.append(f"--http-01-address {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 {} renewal failed with:\n{}".format(
name, res["stderr"]
),
}
else:
return {
"result": False,
"comment": "Certificate {} renewal failed with:\n{}".format(
name, res["stderr"]
),
}
if "no action taken" in res["stdout"]:
comment = f"Certificate {cert_file} unchanged"
result = None
elif renew:
comment = f"Certificate {certname} renewed"
result = True
else:
comment = f"Certificate {certname} obtained"
result = True
ret = {
"comment": comment,
"not_after": expires(certname),
"changes": {},
"result": result,
}
ret, _ = __salt__["file.check_perms"](
_cert_file(certname, "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 [
item
for item in __salt__["file.readdir"](LE_LIVE)[2:]
if os.path.isdir(os.path.join(LE_LIVE, item))
]
def info(name):
"""
Return information about a certificate
:param str name: Name of certificate
:rtype: dict
:return: Dictionary with information about the certificate.
If neither the ``tls`` nor the ``x509`` module can be used to determine
the certificate information, the information will be retrieved as one
big text block under the key ``text`` using the openssl cli.
CLI Example:
.. code-block:: bash
salt 'gitlab.example.com' acme.info dev.example.com
"""
if not has(name):
return {}
cert_file = _cert_file(name, "cert")
# Use the tls 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"] = list(cert_info["extensions"])
elif "x509.read_certificate" in __salt__:
cert_info = __salt__["x509.read_certificate"](cert_file)
else:
# Cobble it together using the openssl binary
openssl_cmd = f"openssl x509 -in {cert_file} -noout -text"
cert_info = {"text": __salt__["cmd.run"](openssl_cmd, output_loglevel="quiet")}
return cert_info
def expires(name):
"""
The expiry date of a certificate in ISO format
:param str name: Name of certificate
:rtype: str
:return: Expiry date in ISO format.
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 str name: Name of certificate
:rtype: bool
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 str name: Name of certificate
:param int window: number of days before expiry when renewal should take place
:rtype: str
:return: Date of certificate renewal in ISO format.
"""
return _renew_by(name, window).isoformat()
def needs_renewal(name, window=None):
"""
Check if a certificate needs renewal
:param str name: Name of certificate
:param bool/str/int window: Window in days to renew earlier or True/force to just return True
:rtype: bool
:return: Whether or not the certificate needs to be renewed.
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:
if str(window).lower() in ("force", "true"):
return True
if not (
isinstance(window, int) or (hasattr(window, "isdigit") and window.isdigit())
):
raise SaltInvocationError(
'The argument "window", if provided, must be one of the following : '
'True (boolean), "force" or "Force" (str) or a numerical value in days.'
)
window = int(window)
return _renew_by(name, window) <= datetime.datetime.today()
Zerion Mini Shell 1.0