diff --git a/docs/api/plugins/webroot.rst b/docs/api/plugins/webroot.rst new file mode 100644 index 000000000..339d546a5 --- /dev/null +++ b/docs/api/plugins/webroot.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.plugins.webroot` +---------------------------------- + +.. automodule:: letsencrypt.plugins.webroot + :members: diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 88394f565..03761b4d2 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -46,6 +46,10 @@ class Plugin(object): """ArgumentParser options namespace (prefix of all options).""" return option_namespace(self.name) + def option_name(self, name): + """Option name (include plugin namespace).""" + return self.option_namespace + name + @property def dest_namespace(self): """ArgumentParser dest namespace (prefix of all destinations).""" diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py index f1eb19094..c67dbc570 100644 --- a/letsencrypt/plugins/common_test.py +++ b/letsencrypt/plugins/common_test.py @@ -51,6 +51,9 @@ class PluginTest(unittest.TestCase): def test_option_namespace(self): self.assertEqual("mock-", self.plugin.option_namespace) + def test_option_name(self): + self.assertEqual("mock-foo_bar", self.plugin.option_name("foo_bar")) + def test_dest_namespace(self): self.assertEqual("mock_", self.plugin.dest_namespace) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py new file mode 100644 index 000000000..ed8991bc5 --- /dev/null +++ b/letsencrypt/plugins/webroot.py @@ -0,0 +1,87 @@ +"""Webroot plugin.""" +import errno +import logging +import os + +import zope.interface + +from acme import challenges + +from letsencrypt import errors +from letsencrypt import interfaces +from letsencrypt.plugins import common + + +logger = logging.getLogger(__name__) + + +class Authenticator(common.Plugin): + """Webroot Authenticator.""" + zope.interface.implements(interfaces.IAuthenticator) + zope.interface.classProvides(interfaces.IPluginFactory) + + description = "Webroot Authenticator" + + MORE_INFO = """\ +Authenticator plugin that performs SimpleHTTP challenge by saving +necessary validation resources to appropriate paths on the file +system. It expects that there is some other HTTP server configured +to serve all files under specified web root ({0}).""" + + def more_info(self): # pylint: disable=missing-docstring,no-self-use + return self.MORE_INFO.format(self.conf("path")) + + @classmethod + def add_parser_arguments(cls, add): + add("path", help="public_html / webroot path") + + def get_chall_pref(self, domain): # pragma: no cover + # pylint: disable=missing-docstring,no-self-use,unused-argument + return [challenges.SimpleHTTP] + + def __init__(self, *args, **kwargs): + super(Authenticator, self).__init__(*args, **kwargs) + self.full_root = None + + def prepare(self): # pylint: disable=missing-docstring + path = self.conf("path") + if path is None: + raise errors.PluginError("--{0} must be set".format( + self.option_name("path"))) + if not os.path.isdir(path): + raise errors.PluginError( + path + " does not exist or is not a directory") + self.full_root = os.path.join( + path, challenges.SimpleHTTPResponse.URI_ROOT_PATH) + + logger.debug("Creating root challenges validation dir at %s", + self.full_root) + try: + os.makedirs(self.full_root) + except OSError as exception: + if exception.errno != errno.EEXIST: + raise errors.PluginError( + "Couldn't create root for SimpleHTTP " + "challenge responses: {0}", exception) + + def perform(self, achalls): # pylint: disable=missing-docstring + assert self.full_root is not None + return [self._perform_single(achall) for achall in achalls] + + def _path_for_achall(self, achall): + return os.path.join(self.full_root, achall.chall.encode("token")) + + def _perform_single(self, achall): + response, validation = achall.gen_response_and_validation( + tls=(not self.config.no_simple_http_tls)) + path = self._path_for_achall(achall) + logger.debug("Attempting to save validation to %s", path) + with open(path, "w") as validation_file: + validation_file.write(validation.json_dumps()) + return response + + def cleanup(self, achalls): # pylint: disable=missing-docstring + for achall in achalls: + path = self._path_for_achall(achall) + logger.debug("Removing %s", path) + os.remove(path) diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py new file mode 100644 index 000000000..d8c0e2aa2 --- /dev/null +++ b/letsencrypt/plugins/webroot_test.py @@ -0,0 +1,82 @@ +"""Tests for letsencrypt.plugins.webroot.""" +import os +import shutil +import tempfile +import unittest + +import mock + +from acme import jose + +from letsencrypt import achallenges +from letsencrypt import errors + +from letsencrypt.tests import acme_util +from letsencrypt.tests import test_util + + +KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) + + +class AuthenticatorTest(unittest.TestCase): + """Tests for letsencrypt.plugins.webroot.Authenticator.""" + + achall = achallenges.SimpleHTTP( + challb=acme_util.SIMPLE_HTTP_P, domain=None, account_key=KEY) + + def setUp(self): + from letsencrypt.plugins.webroot import Authenticator + self.path = tempfile.mkdtemp() + self.validation_path = os.path.join( + self.path, ".well-known", "acme-challenge", + "ZXZhR3hmQURzNnBTUmIyTEF2OUlaZjE3RHQzanV4R0orUEN0OTJ3citvQQ") + self.config = mock.MagicMock(webroot_path=self.path) + self.auth = Authenticator(self.config, "webroot") + self.auth.prepare() + + def tearDown(self): + shutil.rmtree(self.path) + + def test_more_info(self): + more_info = self.auth.more_info() + self.assertTrue(isinstance(more_info, str)) + self.assertTrue(self.path in more_info) + + def test_add_parser_arguments(self): + add = mock.MagicMock() + self.auth.add_parser_arguments(add) + self.assertEqual(1, add.call_count) + + def test_prepare_bad_root(self): + self.config.webroot_path = os.path.join(self.path, "null") + self.assertRaises(errors.PluginError, self.auth.prepare) + + def test_prepare_missing_root(self): + self.config.webroot_path = None + self.assertRaises(errors.PluginError, self.auth.prepare) + + def test_prepare_full_root_exists(self): + # prepare() has already been called once in setUp() + self.auth.prepare() # shouldn't raise any exceptions + + def test_prepare_reraises_other_errors(self): + self.auth.full_path = os.path.join(self.path, "null") + os.chmod(self.path, 0o000) + self.assertRaises(errors.PluginError, self.auth.prepare) + os.chmod(self.path, 0o700) + + def test_perform_cleanup(self): + responses = self.auth.perform([self.achall]) + self.assertEqual(1, len(responses)) + self.assertTrue(os.path.exists(self.validation_path)) + with open(self.validation_path) as validation_f: + validation = jose.JWS.json_loads(validation_f.read()) + self.assertTrue(responses[0].check_validation( + validation, self.achall.chall, KEY.public_key())) + + self.auth.cleanup([self.achall]) + self.assertFalse(os.path.exists(self.validation_path)) + + +if __name__ == "__main__": + unittest.main() # pragma: no cover diff --git a/setup.py b/setup.py index 6e709eeb7..d4fe1eda0 100644 --- a/setup.py +++ b/setup.py @@ -132,6 +132,7 @@ setup( 'manual = letsencrypt.plugins.manual:Authenticator', 'null = letsencrypt.plugins.null:Installer', 'standalone = letsencrypt.plugins.standalone:Authenticator', + 'webroot = letsencrypt.plugins.webroot:Authenticator', ], }, )