From 03a9a2a89e039c607bb80ec2742d450ac8493165 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Wed, 2 Sep 2015 19:52:06 +0000 Subject: [PATCH 1/6] SimpleFS plugin (fixes #742) --- letsencrypt/plugins/common.py | 4 ++ letsencrypt/plugins/simplefs.py | 81 +++++++++++++++++++++++++++++++++ setup.py | 1 + 3 files changed, 86 insertions(+) create mode 100644 letsencrypt/plugins/simplefs.py diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index bef8b4d81..3ec1f1f7c 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -49,6 +49,10 @@ class Plugin(object): """ArgumentParser dest namespace (prefix of all destinations).""" return dest_namespace(self.name) + def option_name(self, name): + """Option name (include plugin namespace).""" + return self.option_namespace + name + def dest(self, var): """Find a destination for given variable ``var``.""" # this should do exactly the same what ArgumentParser(arg), diff --git a/letsencrypt/plugins/simplefs.py b/letsencrypt/plugins/simplefs.py new file mode 100644 index 000000000..8bff1946e --- /dev/null +++ b/letsencrypt/plugins/simplefs.py @@ -0,0 +1,81 @@ +"""SimpleFS 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): + """SimpleFS Authenticator.""" + zope.interface.implements(interfaces.IAuthenticator) + zope.interface.classProvides(interfaces.IPluginFactory) + + description = "SimpleFS Authenticator" + + def more_info(self): # pylint: disable=missing-docstring,no-self-use + return """\ +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}).""".format( + self.option_name("root")) + + @classmethod + def add_parser_arguments(cls, add): + add("root", help="public_html / webroot path") + + def get_chall_pref(self, domain): + # pylint: disable=missing-docstring,no-self-use + return [challenges.SimpleHTTP] + + def __init__(self, *args, **kwargs): + super(Authenticator, self).__init__(*args, **kwargs) + + root = self.conf("root") + if root is None: + raise errors.Error("--{0} must be set".format( + self.option_name("root"))) + if not os.path.isdir(root): + raise errors.Error(root + " does not exist or is not a directory") + self.full_root = os.path.join( + root, challenges.SimpleHTTPResponse.URI_ROOT_PATH) + + def prepare(self): # pylint: disable=missing-docstring + 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 + + def perform(self, achalls): # pylint: disable=missing-docstring + 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): + for achall in achalls: + path = self._path_for_achall(achall) + logger.debug("Removing %s", path) + os.remove(path) diff --git a/setup.py b/setup.py index f816c6c56..bd954a5d6 100644 --- a/setup.py +++ b/setup.py @@ -118,6 +118,7 @@ setup( 'manual = letsencrypt.plugins.manual:ManualAuthenticator', # TODO: null should probably not be presented to the user 'null = letsencrypt.plugins.null:Installer', + 'simplefs = letsencrypt.plugins.simplefs:Authenticator', 'standalone = letsencrypt.plugins.standalone.authenticator' ':StandaloneAuthenticator', ], From 058a85eafdb80d2a442ed07f8a24ed43e84c37f5 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 6 Sep 2015 13:17:29 +0000 Subject: [PATCH 2/6] satisfy lint --- letsencrypt/plugins/simplefs.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/letsencrypt/plugins/simplefs.py b/letsencrypt/plugins/simplefs.py index 8bff1946e..67e59983e 100644 --- a/letsencrypt/plugins/simplefs.py +++ b/letsencrypt/plugins/simplefs.py @@ -22,20 +22,21 @@ class Authenticator(common.Plugin): description = "SimpleFS Authenticator" - def more_info(self): # pylint: disable=missing-docstring,no-self-use - return """\ + 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}).""".format( - self.option_name("root")) +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("root")) @classmethod def add_parser_arguments(cls, add): add("root", help="public_html / webroot path") def get_chall_pref(self, domain): - # pylint: disable=missing-docstring,no-self-use + # pylint: disable=missing-docstring,no-self-use,unused-argument return [challenges.SimpleHTTP] def __init__(self, *args, **kwargs): @@ -74,7 +75,7 @@ to serve all files under specified web root ({0}).""".format( validation_file.write(validation.json_dumps()) return response - def cleanup(self, achalls): + def cleanup(self, achalls): # pylint: disable=missing-docstring for achall in achalls: path = self._path_for_achall(achall) logger.debug("Removing %s", path) From 57f6979f67a7f6e5765cb3d02c268853a90dd10d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 6 Sep 2015 14:03:21 +0000 Subject: [PATCH 3/6] Add tests for SimpleFS plugin. --- letsencrypt/plugins/common.py | 8 +-- letsencrypt/plugins/common_test.py | 3 + letsencrypt/plugins/simplefs.py | 15 +++-- letsencrypt/plugins/simplefs_test.py | 82 ++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 letsencrypt/plugins/simplefs_test.py diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index 3ec1f1f7c..8c4d618b8 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -44,15 +44,15 @@ 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).""" return dest_namespace(self.name) - def option_name(self, name): - """Option name (include plugin namespace).""" - return self.option_namespace + name - def dest(self, var): """Find a destination for given variable ``var``.""" # this should do exactly the same what ArgumentParser(arg), diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py index fa761839c..9c6df8c9e 100644 --- a/letsencrypt/plugins/common_test.py +++ b/letsencrypt/plugins/common_test.py @@ -50,6 +50,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/simplefs.py b/letsencrypt/plugins/simplefs.py index 67e59983e..ad83c13d7 100644 --- a/letsencrypt/plugins/simplefs.py +++ b/letsencrypt/plugins/simplefs.py @@ -35,32 +35,37 @@ to serve all files under specified web root ({0}).""" def add_parser_arguments(cls, add): add("root", help="public_html / webroot path") - def get_chall_pref(self, domain): + 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 root = self.conf("root") if root is None: - raise errors.Error("--{0} must be set".format( + raise errors.PluginError("--{0} must be set".format( self.option_name("root"))) if not os.path.isdir(root): - raise errors.Error(root + " does not exist or is not a directory") + raise errors.PluginError( + root + " does not exist or is not a directory") self.full_root = os.path.join( root, challenges.SimpleHTTPResponse.URI_ROOT_PATH) - def prepare(self): # pylint: disable=missing-docstring 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 + 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): diff --git a/letsencrypt/plugins/simplefs_test.py b/letsencrypt/plugins/simplefs_test.py new file mode 100644 index 000000000..f80e8b29a --- /dev/null +++ b/letsencrypt/plugins/simplefs_test.py @@ -0,0 +1,82 @@ +"""Tests for letsencrypt.plugins.simplefs.""" +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.simplefs.Authenticator.""" + + achall = achallenges.SimpleHTTP( + challb=acme_util.SIMPLE_HTTP_P, domain=None, account_key=KEY) + + def setUp(self): + from letsencrypt.plugins.simplefs import Authenticator + self.root = tempfile.mkdtemp() + self.validation_path = os.path.join( + self.root, ".well-known", "acme-challenge", + "ZXZhR3hmQURzNnBTUmIyTEF2OUlaZjE3RHQzanV4R0orUEN0OTJ3citvQQ") + self.config = mock.MagicMock(simplefs_root=self.root) + self.auth = Authenticator(self.config, "simplefs") + self.auth.prepare() + + def tearDown(self): + shutil.rmtree(self.root) + + def test_more_info(self): + more_info = self.auth.more_info() + self.assertTrue(isinstance(more_info, str)) + self.assertTrue(self.root 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.simplefs_root = os.path.join(self.root, "null") + self.assertRaises(errors.PluginError, self.auth.prepare) + + def test_prepare_missing_root(self): + self.config.simplefs_root = 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_root = os.path.join(self.root, "null") + os.chmod(self.root, 0o000) + self.assertRaises(errors.PluginError, self.auth.prepare) + os.chmod(self.root, 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 From 8c7b8b835117fe5cca92367d9244c4dd480c4ec6 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 7 Sep 2015 06:33:16 +0000 Subject: [PATCH 4/6] Add docs for SimpleFS plugin --- docs/api/plugins/simplefs.rst | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/api/plugins/simplefs.rst diff --git a/docs/api/plugins/simplefs.rst b/docs/api/plugins/simplefs.rst new file mode 100644 index 000000000..7165b6aca --- /dev/null +++ b/docs/api/plugins/simplefs.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.plugins.simplefs` +----------------------------------- + +.. automodule:: letsencrypt.plugins.simplefs + :members: From d88455a1b9529545318d113e82e6ab31fc7c72d7 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 4 Oct 2015 09:11:37 +0000 Subject: [PATCH 5/6] Rename simplefs to webroot --- docs/api/plugins/simplefs.rst | 5 ----- docs/api/plugins/webroot.rst | 5 +++++ letsencrypt/plugins/{simplefs.py => webroot.py} | 6 +++--- .../plugins/{simplefs_test.py => webroot_test.py} | 14 +++++++------- setup.py | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) delete mode 100644 docs/api/plugins/simplefs.rst create mode 100644 docs/api/plugins/webroot.rst rename letsencrypt/plugins/{simplefs.py => webroot.py} (96%) rename letsencrypt/plugins/{simplefs_test.py => webroot_test.py} (85%) diff --git a/docs/api/plugins/simplefs.rst b/docs/api/plugins/simplefs.rst deleted file mode 100644 index 7165b6aca..000000000 --- a/docs/api/plugins/simplefs.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.plugins.simplefs` ------------------------------------ - -.. automodule:: letsencrypt.plugins.simplefs - :members: 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/simplefs.py b/letsencrypt/plugins/webroot.py similarity index 96% rename from letsencrypt/plugins/simplefs.py rename to letsencrypt/plugins/webroot.py index ad83c13d7..d641855ae 100644 --- a/letsencrypt/plugins/simplefs.py +++ b/letsencrypt/plugins/webroot.py @@ -1,4 +1,4 @@ -"""SimpleFS plugin.""" +"""Webroot plugin.""" import errno import logging import os @@ -16,11 +16,11 @@ logger = logging.getLogger(__name__) class Authenticator(common.Plugin): - """SimpleFS Authenticator.""" + """Webroot Authenticator.""" zope.interface.implements(interfaces.IAuthenticator) zope.interface.classProvides(interfaces.IPluginFactory) - description = "SimpleFS Authenticator" + description = "Webroot Authenticator" MORE_INFO = """\ Authenticator plugin that performs SimpleHTTP challenge by saving diff --git a/letsencrypt/plugins/simplefs_test.py b/letsencrypt/plugins/webroot_test.py similarity index 85% rename from letsencrypt/plugins/simplefs_test.py rename to letsencrypt/plugins/webroot_test.py index f80e8b29a..abd12e152 100644 --- a/letsencrypt/plugins/simplefs_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.plugins.simplefs.""" +"""Tests for letsencrypt.plugins.webroot.""" import os import shutil import tempfile @@ -19,19 +19,19 @@ KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) class AuthenticatorTest(unittest.TestCase): - """Tests for letsencrypt.plugins.simplefs.Authenticator.""" + """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.simplefs import Authenticator + from letsencrypt.plugins.webroot import Authenticator self.root = tempfile.mkdtemp() self.validation_path = os.path.join( self.root, ".well-known", "acme-challenge", "ZXZhR3hmQURzNnBTUmIyTEF2OUlaZjE3RHQzanV4R0orUEN0OTJ3citvQQ") - self.config = mock.MagicMock(simplefs_root=self.root) - self.auth = Authenticator(self.config, "simplefs") + self.config = mock.MagicMock(webroot_root=self.root) + self.auth = Authenticator(self.config, "webroot") self.auth.prepare() def tearDown(self): @@ -48,11 +48,11 @@ class AuthenticatorTest(unittest.TestCase): self.assertEqual(1, add.call_count) def test_prepare_bad_root(self): - self.config.simplefs_root = os.path.join(self.root, "null") + self.config.webroot_root = os.path.join(self.root, "null") self.assertRaises(errors.PluginError, self.auth.prepare) def test_prepare_missing_root(self): - self.config.simplefs_root = None + self.config.webroot_root = None self.assertRaises(errors.PluginError, self.auth.prepare) def test_prepare_full_root_exists(self): diff --git a/setup.py b/setup.py index a4897aadb..92dd39d46 100644 --- a/setup.py +++ b/setup.py @@ -119,7 +119,7 @@ setup( 'letsencrypt.plugins': [ 'manual = letsencrypt.plugins.manual:Authenticator', 'null = letsencrypt.plugins.null:Installer', - 'simplefs = letsencrypt.plugins.simplefs:Authenticator', + 'webroot = letsencrypt.plugins.webroot:Authenticator', 'standalone = letsencrypt.plugins.standalone.authenticator' ':StandaloneAuthenticator', ], From 63c080b05f2040d3507526efa1cbe10a01bfa565 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sun, 4 Oct 2015 09:15:17 +0000 Subject: [PATCH 6/6] --webroot-root -> --webroot-path --- letsencrypt/plugins/webroot.py | 16 ++++++++-------- letsencrypt/plugins/webroot_test.py | 20 ++++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/letsencrypt/plugins/webroot.py b/letsencrypt/plugins/webroot.py index d641855ae..ed8991bc5 100644 --- a/letsencrypt/plugins/webroot.py +++ b/letsencrypt/plugins/webroot.py @@ -29,11 +29,11 @@ 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("root")) + return self.MORE_INFO.format(self.conf("path")) @classmethod def add_parser_arguments(cls, add): - add("root", help="public_html / webroot path") + 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 @@ -44,15 +44,15 @@ to serve all files under specified web root ({0}).""" self.full_root = None def prepare(self): # pylint: disable=missing-docstring - root = self.conf("root") - if root is None: + path = self.conf("path") + if path is None: raise errors.PluginError("--{0} must be set".format( - self.option_name("root"))) - if not os.path.isdir(root): + self.option_name("path"))) + if not os.path.isdir(path): raise errors.PluginError( - root + " does not exist or is not a directory") + path + " does not exist or is not a directory") self.full_root = os.path.join( - root, challenges.SimpleHTTPResponse.URI_ROOT_PATH) + path, challenges.SimpleHTTPResponse.URI_ROOT_PATH) logger.debug("Creating root challenges validation dir at %s", self.full_root) diff --git a/letsencrypt/plugins/webroot_test.py b/letsencrypt/plugins/webroot_test.py index abd12e152..d8c0e2aa2 100644 --- a/letsencrypt/plugins/webroot_test.py +++ b/letsencrypt/plugins/webroot_test.py @@ -26,21 +26,21 @@ class AuthenticatorTest(unittest.TestCase): def setUp(self): from letsencrypt.plugins.webroot import Authenticator - self.root = tempfile.mkdtemp() + self.path = tempfile.mkdtemp() self.validation_path = os.path.join( - self.root, ".well-known", "acme-challenge", + self.path, ".well-known", "acme-challenge", "ZXZhR3hmQURzNnBTUmIyTEF2OUlaZjE3RHQzanV4R0orUEN0OTJ3citvQQ") - self.config = mock.MagicMock(webroot_root=self.root) + self.config = mock.MagicMock(webroot_path=self.path) self.auth = Authenticator(self.config, "webroot") self.auth.prepare() def tearDown(self): - shutil.rmtree(self.root) + shutil.rmtree(self.path) def test_more_info(self): more_info = self.auth.more_info() self.assertTrue(isinstance(more_info, str)) - self.assertTrue(self.root in more_info) + self.assertTrue(self.path in more_info) def test_add_parser_arguments(self): add = mock.MagicMock() @@ -48,11 +48,11 @@ class AuthenticatorTest(unittest.TestCase): self.assertEqual(1, add.call_count) def test_prepare_bad_root(self): - self.config.webroot_root = os.path.join(self.root, "null") + 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_root = None + self.config.webroot_path = None self.assertRaises(errors.PluginError, self.auth.prepare) def test_prepare_full_root_exists(self): @@ -60,10 +60,10 @@ class AuthenticatorTest(unittest.TestCase): self.auth.prepare() # shouldn't raise any exceptions def test_prepare_reraises_other_errors(self): - self.auth.full_root = os.path.join(self.root, "null") - os.chmod(self.root, 0o000) + 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.root, 0o700) + os.chmod(self.path, 0o700) def test_perform_cleanup(self): responses = self.auth.perform([self.achall])