mirror of
https://github.com/certbot/certbot.git
synced 2026-06-06 07:12:54 -04:00
tls-sni-01 with the manual plugin (#4636)
* Add TLS-SNI-01 support to Manual plugin * Add environment variable CERTBOT_SNI_DOMAIN for manual-auth-hook * Make AuthenticatorTest inherit from TempDirTestCase * Add test_get_z_domain() * Document CERTBOT_SNI_DOMAIN in docs/using.rst
This commit is contained in:
parent
811d436d5a
commit
ed717d6bc4
5 changed files with 175 additions and 37 deletions
|
|
@ -241,6 +241,10 @@ class TLSSNI01(object):
|
|||
return os.path.join(self.configurator.config.work_dir,
|
||||
achall.chall.encode("token") + '.pem')
|
||||
|
||||
def get_z_domain(self, achall):
|
||||
"""Returns z_domain (SNI) name for the challenge."""
|
||||
return achall.response(achall.account_key).z_domain.decode("utf-8")
|
||||
|
||||
def _setup_challenge_cert(self, achall, cert_key=None):
|
||||
|
||||
"""Generate and write out challenge certificate."""
|
||||
|
|
|
|||
|
|
@ -221,6 +221,11 @@ class TLSSNI01Test(unittest.TestCase):
|
|||
mock_safe_open.return_value.write.assert_called_once_with(
|
||||
OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key))
|
||||
|
||||
def test_get_z_domain(self):
|
||||
achall = self.achalls[0]
|
||||
self.assertEqual(self.sni.get_z_domain(achall),
|
||||
achall.response(achall.account_key).z_domain.decode("utf-8"))
|
||||
|
||||
|
||||
class InstallSslOptionsConfTest(test_util.TempDirTestCase):
|
||||
"""Tests for certbot.plugins.common.install_ssl_options_conf."""
|
||||
|
|
|
|||
|
|
@ -9,9 +9,38 @@ from acme import challenges
|
|||
from certbot import interfaces
|
||||
from certbot import errors
|
||||
from certbot import hooks
|
||||
from certbot import reverter
|
||||
from certbot.plugins import common
|
||||
|
||||
|
||||
class ManualTlsSni01(common.TLSSNI01):
|
||||
"""TLS-SNI-01 authenticator for the Manual plugin
|
||||
|
||||
:ivar configurator: Authenticator object
|
||||
:type configurator: :class:`~certbot.plugins.manual.Authenticator`
|
||||
|
||||
:ivar list achalls: Annotated
|
||||
class:`~certbot.achallenges.KeyAuthorizationAnnotatedChallenge`
|
||||
challenges
|
||||
|
||||
:param list indices: Meant to hold indices of challenges in a
|
||||
larger array. NginxTlsSni01 is capable of solving many challenges
|
||||
at once which causes an indexing issue within NginxConfigurator
|
||||
who must return all responses in order. Imagine NginxConfigurator
|
||||
maintaining state about where all of the http-01 Challenges,
|
||||
TLS-SNI-01 Challenges belong in the response array. This is an
|
||||
optional utility.
|
||||
|
||||
:param str challenge_conf: location of the challenge config file
|
||||
"""
|
||||
|
||||
def perform(self):
|
||||
"""Create the SSL certificates and private keys"""
|
||||
|
||||
for achall in self.achalls:
|
||||
self._setup_challenge_cert(achall)
|
||||
|
||||
|
||||
@zope.interface.implementer(interfaces.IAuthenticator)
|
||||
@zope.interface.provider(interfaces.IPluginFactory)
|
||||
class Authenticator(common.Plugin):
|
||||
|
|
@ -28,14 +57,18 @@ class Authenticator(common.Plugin):
|
|||
long_description = (
|
||||
'Authenticate through manual configuration or custom shell scripts. '
|
||||
'When using shell scripts, an authenticator script must be provided. '
|
||||
'The environment variables available to this script are '
|
||||
'$CERTBOT_DOMAIN which contains the domain being authenticated, '
|
||||
'$CERTBOT_VALIDATION which is the validation string, and '
|
||||
'$CERTBOT_TOKEN which is the filename of the resource requested when '
|
||||
'performing an HTTP-01 challenge. An additional cleanup script can '
|
||||
'also be provided and can use the additional variable '
|
||||
'$CERTBOT_AUTH_OUTPUT which contains the stdout output from the auth '
|
||||
'script.')
|
||||
'The environment variables available to this script depend on the '
|
||||
'type of challenge. $CERTBOT_DOMAIN will always contain the domain '
|
||||
'being authenticated. For HTTP-01 and DNS-01, $CERTBOT_VALIDATION '
|
||||
'is the validation string, and $CERTBOT_TOKEN is the filename of the '
|
||||
'resource requested when performing an HTTP-01 challenge. When '
|
||||
'performing a TLS-SNI-01 challenge, $CERTBOT_SNI_DOMAIN will contain '
|
||||
'the SNI name for which the ACME server expects to be presented with '
|
||||
'the self-signed certificate located at $CERTBOT_CERT_PATH. The '
|
||||
'secret key needed to complete the TLS handshake is located at '
|
||||
'$CERTBOT_KEY_PATH. An additional cleanup script can also be '
|
||||
'provided and can use the additional variable $CERTBOT_AUTH_OUTPUT '
|
||||
'which contains the stdout output from the auth script.')
|
||||
_DNS_INSTRUCTIONS = """\
|
||||
Please deploy a DNS TXT record under the name
|
||||
{domain} with the following value:
|
||||
|
|
@ -51,11 +84,22 @@ Create a file containing just this data:
|
|||
And make it available on your web server at this URL:
|
||||
|
||||
{uri}
|
||||
"""
|
||||
_TLSSNI_INSTRUCTIONS = """\
|
||||
Configure the service listening on port {port} to present the certificate
|
||||
{cert}
|
||||
using the secret key
|
||||
{key}
|
||||
when it receives a TLS ClientHello with the SNI extension set to
|
||||
{sni_domain}
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Authenticator, self).__init__(*args, **kwargs)
|
||||
self.reverter = reverter.Reverter(self.config)
|
||||
self.reverter.recovery_routine()
|
||||
self.env = dict()
|
||||
self.tls_sni_01 = None
|
||||
|
||||
@classmethod
|
||||
def add_parser_arguments(cls, add):
|
||||
|
|
@ -90,11 +134,10 @@ And make it available on your web server at this URL:
|
|||
|
||||
def get_chall_pref(self, domain):
|
||||
# pylint: disable=missing-docstring,no-self-use,unused-argument
|
||||
return [challenges.HTTP01, challenges.DNS01]
|
||||
return [challenges.HTTP01, challenges.DNS01, challenges.TLSSNI01]
|
||||
|
||||
def perform(self, achalls): # pylint: disable=missing-docstring
|
||||
self._verify_ip_logging_ok()
|
||||
|
||||
if self.conf('auth-hook'):
|
||||
perform_achall = self._perform_achall_with_script
|
||||
else:
|
||||
|
|
@ -102,6 +145,12 @@ And make it available on your web server at this URL:
|
|||
|
||||
responses = []
|
||||
for achall in achalls:
|
||||
if isinstance(achall.chall, challenges.TLSSNI01):
|
||||
# Make a new ManualTlsSni01 instance for each challenge
|
||||
# because the manual plugin deals with one challenge at a time.
|
||||
self.tls_sni_01 = ManualTlsSni01(self)
|
||||
self.tls_sni_01.add_chall(achall)
|
||||
self.tls_sni_01.perform()
|
||||
perform_achall(achall)
|
||||
responses.append(achall.response(achall.account_key))
|
||||
return responses
|
||||
|
|
@ -127,6 +176,16 @@ And make it available on your web server at this URL:
|
|||
env['CERTBOT_TOKEN'] = achall.chall.encode('token')
|
||||
else:
|
||||
os.environ.pop('CERTBOT_TOKEN', None)
|
||||
if isinstance(achall.chall, challenges.TLSSNI01):
|
||||
env['CERTBOT_CERT_PATH'] = self.tls_sni_01.get_cert_path(achall)
|
||||
env['CERTBOT_KEY_PATH'] = self.tls_sni_01.get_key_path(achall)
|
||||
env['CERTBOT_SNI_DOMAIN'] = self.tls_sni_01.get_z_domain(achall)
|
||||
os.environ.pop('CERTBOT_VALIDATION', None)
|
||||
env.pop('CERTBOT_VALIDATION')
|
||||
else:
|
||||
os.environ.pop('CERTBOT_CERT_PATH', None)
|
||||
os.environ.pop('CERTBOT_KEY_PATH', None)
|
||||
os.environ.pop('CERTBOT_SNI_DOMAIN', None)
|
||||
os.environ.update(env)
|
||||
_, out = hooks.execute(self.conf('auth-hook'))
|
||||
env['CERTBOT_AUTH_OUTPUT'] = out.strip()
|
||||
|
|
@ -139,11 +198,17 @@ And make it available on your web server at this URL:
|
|||
achall=achall, encoded_token=achall.chall.encode('token'),
|
||||
port=self.config.http01_port,
|
||||
uri=achall.chall.uri(achall.domain), validation=validation)
|
||||
else:
|
||||
assert isinstance(achall.chall, challenges.DNS01)
|
||||
elif isinstance(achall.chall, challenges.DNS01):
|
||||
msg = self._DNS_INSTRUCTIONS.format(
|
||||
domain=achall.validation_domain_name(achall.domain),
|
||||
validation=validation)
|
||||
else:
|
||||
assert isinstance(achall.chall, challenges.TLSSNI01)
|
||||
msg = self._TLSSNI_INSTRUCTIONS.format(
|
||||
cert=self.tls_sni_01.get_cert_path(achall),
|
||||
key=self.tls_sni_01.get_key_path(achall),
|
||||
port=self.config.tls_sni_01_port,
|
||||
sni_domain=self.tls_sni_01.get_z_domain(achall))
|
||||
display = zope.component.getUtility(interfaces.IDisplay)
|
||||
display.notification(msg, wrap=False, force_interactive=True)
|
||||
|
||||
|
|
@ -155,3 +220,4 @@ And make it available on your web server at this URL:
|
|||
os.environ.pop('CERTBOT_TOKEN', None)
|
||||
os.environ.update(env)
|
||||
hooks.execute(self.conf('cleanup-hook'))
|
||||
self.reverter.recovery_routine()
|
||||
|
|
|
|||
|
|
@ -13,17 +13,31 @@ from certbot.tests import acme_util
|
|||
from certbot.tests import util as test_util
|
||||
|
||||
|
||||
class AuthenticatorTest(unittest.TestCase):
|
||||
class AuthenticatorTest(test_util.TempDirTestCase):
|
||||
"""Tests for certbot.plugins.manual.Authenticator."""
|
||||
|
||||
def setUp(self):
|
||||
super(AuthenticatorTest, self).setUp()
|
||||
self.http_achall = acme_util.HTTP01_A
|
||||
self.dns_achall = acme_util.DNS01_A
|
||||
self.achalls = [self.http_achall, self.dns_achall]
|
||||
self.tls_sni_achall = acme_util.TLSSNI01_A
|
||||
self.achalls = [self.http_achall, self.dns_achall, self.tls_sni_achall]
|
||||
for d in ["config_dir", "work_dir", "in_progress"]:
|
||||
os.mkdir(os.path.join(self.tempdir, d))
|
||||
# "backup_dir" and "temp_checkpoint_dir" get created in
|
||||
# certbot.util.make_or_verify_dir() during the Reverter
|
||||
# initialization.
|
||||
self.config = mock.MagicMock(
|
||||
http01_port=0, manual_auth_hook=None, manual_cleanup_hook=None,
|
||||
manual_public_ip_logging_ok=False, noninteractive_mode=False,
|
||||
validate_hooks=False)
|
||||
validate_hooks=False,
|
||||
config_dir=os.path.join(self.tempdir, "config_dir"),
|
||||
work_dir=os.path.join(self.tempdir, "work_dir"),
|
||||
backup_dir=os.path.join(self.tempdir, "backup_dir"),
|
||||
temp_checkpoint_dir=os.path.join(
|
||||
self.tempdir, "temp_checkpoint_dir"),
|
||||
in_progress_dir=os.path.join(self.tempdir, "in_progess"),
|
||||
tls_sni_01_port=5001)
|
||||
|
||||
from certbot.plugins.manual import Authenticator
|
||||
self.auth = Authenticator(self.config, name='manual')
|
||||
|
|
@ -42,7 +56,9 @@ class AuthenticatorTest(unittest.TestCase):
|
|||
|
||||
def test_get_chall_pref(self):
|
||||
self.assertEqual(self.auth.get_chall_pref('example.org'),
|
||||
[challenges.HTTP01, challenges.DNS01])
|
||||
[challenges.HTTP01,
|
||||
challenges.DNS01,
|
||||
challenges.TLSSNI01])
|
||||
|
||||
@test_util.patch_get_utility()
|
||||
def test_ip_logging_not_ok(self, mock_get_utility):
|
||||
|
|
@ -58,13 +74,19 @@ class AuthenticatorTest(unittest.TestCase):
|
|||
def test_script_perform(self):
|
||||
self.config.manual_public_ip_logging_ok = True
|
||||
self.config.manual_auth_hook = (
|
||||
'echo $CERTBOT_DOMAIN; echo ${CERTBOT_TOKEN:-notoken}; '
|
||||
'echo $CERTBOT_VALIDATION;')
|
||||
dns_expected = '{0}\n{1}\n{2}'.format(
|
||||
'echo ${CERTBOT_DOMAIN}; '
|
||||
'echo ${CERTBOT_TOKEN:-notoken}; '
|
||||
'echo ${CERTBOT_CERT_PATH:-nocert}; '
|
||||
'echo ${CERTBOT_KEY_PATH:-nokey}; '
|
||||
'echo ${CERTBOT_SNI_DOMAIN:-nosnidomain}; '
|
||||
'echo ${CERTBOT_VALIDATION:-novalidation};')
|
||||
dns_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}'.format(
|
||||
self.dns_achall.domain, 'notoken',
|
||||
'nocert', 'nokey', 'nosnidomain',
|
||||
self.dns_achall.validation(self.dns_achall.account_key))
|
||||
http_expected = '{0}\n{1}\n{2}'.format(
|
||||
http_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}'.format(
|
||||
self.http_achall.domain, self.http_achall.chall.encode('token'),
|
||||
'nocert', 'nokey', 'nosnidomain',
|
||||
self.http_achall.validation(self.http_achall.account_key))
|
||||
|
||||
self.assertEqual(
|
||||
|
|
@ -76,6 +98,17 @@ class AuthenticatorTest(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
self.auth.env[self.http_achall.domain]['CERTBOT_AUTH_OUTPUT'],
|
||||
http_expected)
|
||||
# tls_sni_01 challenge must be perform()ed above before we can
|
||||
# get the cert_path and key_path.
|
||||
tls_sni_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}'.format(
|
||||
self.tls_sni_achall.domain, 'notoken',
|
||||
self.auth.tls_sni_01.get_cert_path(self.tls_sni_achall),
|
||||
self.auth.tls_sni_01.get_key_path(self.tls_sni_achall),
|
||||
self.auth.tls_sni_01.get_z_domain(self.tls_sni_achall),
|
||||
'novalidation')
|
||||
self.assertEqual(
|
||||
self.auth.env[self.tls_sni_achall.domain]['CERTBOT_AUTH_OUTPUT'],
|
||||
tls_sni_expected)
|
||||
|
||||
@test_util.patch_get_utility()
|
||||
def test_manual_perform(self, mock_get_utility):
|
||||
|
|
@ -85,7 +118,13 @@ class AuthenticatorTest(unittest.TestCase):
|
|||
[achall.response(achall.account_key) for achall in self.achalls])
|
||||
for i, (args, kwargs) in enumerate(mock_get_utility().notification.call_args_list):
|
||||
achall = self.achalls[i]
|
||||
self.assertTrue(achall.validation(achall.account_key) in args[0])
|
||||
if isinstance(achall.chall, challenges.TLSSNI01):
|
||||
self.assertTrue(
|
||||
self.auth.tls_sni_01.get_cert_path(
|
||||
self.tls_sni_achall) in args[0])
|
||||
else:
|
||||
self.assertTrue(
|
||||
achall.validation(achall.account_key) in args[0])
|
||||
self.assertFalse(kwargs['wrap'])
|
||||
|
||||
def test_cleanup(self):
|
||||
|
|
@ -98,16 +137,29 @@ class AuthenticatorTest(unittest.TestCase):
|
|||
self.auth.cleanup([achall])
|
||||
self.assertEqual(os.environ['CERTBOT_AUTH_OUTPUT'], 'foo')
|
||||
self.assertEqual(os.environ['CERTBOT_DOMAIN'], achall.domain)
|
||||
self.assertEqual(
|
||||
os.environ['CERTBOT_VALIDATION'],
|
||||
achall.validation(achall.account_key))
|
||||
|
||||
if (isinstance(achall.chall, challenges.HTTP01) or
|
||||
isinstance(achall.chall, challenges.DNS01)):
|
||||
self.assertEqual(
|
||||
os.environ['CERTBOT_VALIDATION'],
|
||||
achall.validation(achall.account_key))
|
||||
if isinstance(achall.chall, challenges.HTTP01):
|
||||
self.assertEqual(
|
||||
os.environ['CERTBOT_TOKEN'],
|
||||
achall.chall.encode('token'))
|
||||
else:
|
||||
self.assertFalse('CERTBOT_TOKEN' in os.environ)
|
||||
if isinstance(achall.chall, challenges.TLSSNI01):
|
||||
self.assertEqual(
|
||||
os.environ['CERTBOT_CERT_PATH'],
|
||||
self.auth.tls_sni_01.get_cert_path(achall))
|
||||
self.assertEqual(
|
||||
os.environ['CERTBOT_KEY_PATH'],
|
||||
self.auth.tls_sni_01.get_key_path(achall))
|
||||
self.assertFalse(
|
||||
os.path.exists(os.environ['CERTBOT_CERT_PATH']))
|
||||
self.assertFalse(
|
||||
os.path.exists(os.environ['CERTBOT_KEY_PATH']))
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ standalone_ Y N | Uses a "standalone" webserver to obtain a certificate.
|
|||
| Requires port 80 or 443 to be available. This is useful on tls-sni-01_ (443)
|
||||
| systems with no webserver, or when direct integration with
|
||||
| the local webserver is not supported or not desired.
|
||||
manual_ Y N | Helps you obtain a certificate by giving you instructions to http-01_ (80) or
|
||||
| perform domain validation yourself. Additionally allows you dns-01_ (53)
|
||||
| to specify scripts to automate the validation task in a
|
||||
manual_ Y N | Helps you obtain a certificate by giving you instructions to http-01_ (80),
|
||||
| perform domain validation yourself. Additionally allows you dns-01_ (53) or
|
||||
| to specify scripts to automate the validation task in a tls-sni-01_ (443)
|
||||
| customized way.
|
||||
=========== ==== ==== =============================================================== =============================
|
||||
|
||||
|
|
@ -175,13 +175,15 @@ the UI, you can use the plugin to obtain a certificate by specifying
|
|||
to copy and paste commands into another terminal session, which may
|
||||
be on a different computer.
|
||||
|
||||
The manual plugin can use either the ``http`` or the ``dns`` challenge. You
|
||||
can use the ``--preferred-challenges`` option to choose the challenge of your
|
||||
preference.
|
||||
The manual plugin can use either the ``http``, ``dns`` or the
|
||||
``tls-sni`` challenge. You can use the ``--preferred-challenges`` option
|
||||
to choose the challenge of your preference.
|
||||
|
||||
The ``http`` challenge will ask you to place a file with a specific name and
|
||||
specific content in the ``/.well-known/acme-challenge/`` directory directly
|
||||
in the top-level directory (“web root”) containing the files served by your
|
||||
webserver. In essence it's the same as the webroot_ plugin, but not automated.
|
||||
|
||||
When using the ``dns`` challenge, ``certbot`` will ask you to place a TXT DNS
|
||||
record with specific contents under the domain name consisting of the hostname
|
||||
for which you want a certificate issued, prepended by ``_acme-challenge``.
|
||||
|
|
@ -192,10 +194,16 @@ For example, for the domain ``example.com``, a zone file entry would look like:
|
|||
|
||||
_acme-challenge.example.com. 300 IN TXT "gfj9Xq...Rg85nM"
|
||||
|
||||
Additionally you can specify scripts to prepare for validation and perform the
|
||||
authentication procedure and/or clean up after it by using the
|
||||
``--manual-auth-hook`` and ``--manual-cleanup-hook`` flags. This is described in
|
||||
more depth in the hooks_ section.
|
||||
When using the ``tls-sni`` challenge, ``certbot`` will prepare a self-signed
|
||||
SSL certificate for you with the challenge validation appropriately
|
||||
encoded into a subjectAlternatNames entry. You will need to configure
|
||||
your SSL server to present this challenge SSL certificate to the ACME
|
||||
server using SNI.
|
||||
|
||||
Additionally you can specify scripts to prepare for validation and
|
||||
perform the authentication procedure and/or clean up after it by using
|
||||
the ``--manual-auth-hook`` and ``--manual-cleanup-hook`` flags. This is
|
||||
described in more depth in the hooks_ section.
|
||||
|
||||
.. _third-party-plugins:
|
||||
|
||||
|
|
@ -606,12 +614,15 @@ and ``--manual-cleanup-hook`` respectively and can be used as follows:
|
|||
certbot certonly --manual --manual-auth-hook /path/to/http/authenticator.sh --manual-cleanup-hook /path/to/http/cleanup.sh -d secure.example.com
|
||||
|
||||
This will run the ``authenticator.sh`` script, attempt the validation, and then run
|
||||
the ``cleanup.sh`` script. Additionally certbot will pass three environment
|
||||
the ``cleanup.sh`` script. Additionally certbot will pass relevant environment
|
||||
variables to these scripts:
|
||||
|
||||
- ``CERTBOT_DOMAIN``: The domain being authenticated
|
||||
- ``CERTBOT_VALIDATION``: The validation string
|
||||
- ``CERTBOT_VALIDATION``: The validation string (HTTP-01 and DNS-01 only)
|
||||
- ``CERTBOT_TOKEN``: Resource name part of the HTTP-01 challenge (HTTP-01 only)
|
||||
- ``CERTBOT_CERT_PATH``: The challenge SSL certificate (TLS-SNI-01 only)
|
||||
- ``CERTBOT_KEY_PATH``: The private key associated with the aforementioned SSL certificate (TLS-SNI-01 only)
|
||||
- ``CERTBOT_SNI_DOMAIN``: The SNI name for which the ACME server expects to be presented the self-signed certificate located at ``$CERTBOT_CERT_PATH`` (TLS-SNI-01 only)
|
||||
|
||||
Additionally for cleanup:
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue