Repository: certbot/certbot

Issue:
Allow letsencrypt client to pass the web root path on cmd for creation of SimplyHTTP/ACME challenge file
Related to https://community.letsencrypt.org/t/manual-authenticator-way-to-automate-the-acme-challenge-file-creation/583

Passing the webroot via the command line it would help for those sites that have non-standard web roots i.e. frameworks like Laravel and node.js web apps would likely have their /path/to/projectname/public as their web root. WHM/Cpanel, Directadmin, Webmin etc all would have their own web root paths. 

If you can pass this as a variable in Letsencrypt client, it will probably be easier for respective control panels and distros to come up with their own integration methods ?



Discussion:
Is the existing --server-root not sufficient?
On Aug 31, 2015 10:41 PM, "George Liu (eva2000)" notifications@github.com
wrote:

> Related to
> https://community.letsencrypt.org/t/manual-authenticator-way-to-automate-the-acme-challenge-file-creation/583
> 
> Passing the webroot via the command line it would help for those sites
> that have non-standard web roots i.e. frameworks like Laravel would likely
> have their /path/to/projectname/public as their web root. WHM/Cpanel,
> Directadmin, Webmin etc all would have their own web root paths.
> 
> If you can pass this as a variable in Letsencrypt client probably be
> easier for respective control panels and distros to could up with their own
> integration methods ?
> 
> —
> Reply to this email directly or view it on GitHub
> https://github.com/letsencrypt/letsencrypt/issues/742.

I don't see that option only  --nginx-server-root which which for me would be `/usr/local/nginx` but my vhost web root are in format `/home/nginx/domains/domain.com/public`

```
 ./venv/bin/letsencrypt --help nginx
usage: 
  letsencrypt [SUBCOMMAND] [options] [domains]

The Let's Encrypt agent can obtain and install HTTPS/TLS/SSL certificates.  By
default, it will attempt to use a webserver both for obtaining and installing
the cert.  

positional arguments:
  SUBCOMMAND
    run                 Obtain a certificate and install.
    auth                Authenticate & obtain cert, but do not install it.
    install             Install a previously obtained cert in a server.
    revoke              Revoke a previously obtained certificate.
    rollback            Rollback server configuration changes made during
                        install.
    config_changes      Show changes made to server config during installation
    plugins             List server software plugins.

optional arguments:
  -h, --help            show this help message and exit
  -c CONFIG_FILE, --config CONFIG_FILE
                        config file path (default: None)

nginx:
  Nginx Web Server

  --nginx-server-root NGINX_SERVER_ROOT
                        Nginx server root directory. (default: /etc/nginx)
  --nginx-ctl NGINX_CTL
                        Path to the 'nginx' binary, used for 'configtest' and
                        retrieving nginx version number. (default: nginx)
```

It doesn't matter where your webroot is. Certificates and keys are never copied to your `public_html`! Let's Encrypt doesn't care what you have in your `public_html`. Certificates and keys are copied (or symlinked) to the configuration directory, which you should specify using `--nginx-server-root`.

Ah, you want to specify directory where SimplyHTTP challenge response files should be written (aka, automatic execution of manual plugin, without executing Python's builtin webserver)...

Please update the title of this ticket to make this more clear.

ok changed the title.. yup @kuba for creation of the well-known uris for verification :)

right now I have to manually do it via

```
cd /home/nginx/domains/le1.http2ssl.xyz/public
mkdir -p .well-known/acme-challenge
echo -n '{"header": {"alg": "RS256", "jwk": {"e": "AQAB", "kty": "RSA", "n": "vnWsLw
```

where `/home/nginx/domains/le1.http2ssl.xyz/public` is my web root path

being able to do it on cmd line

```
./venv/bin/letsencrypt -d domain.com -webroot /home/nginx/domains/domain.com/public auth
```

with auto creation of the .well-known file would bring manual authentication one step closer to automation that can be scripted by end user

Mind, you can always stop your current web server and use fully automated `standalone` plugin...

Yeah unfortunately that would be a problem for other non-443 port sites running on the server. The aim is to integrate Letsencrypt into my LEMP stack's Nginx vhost auto generation routine which currently auto creates the nginx vhost for http and https (self-signed ssl) so hoping it can do that fully from command line for invoking letsencrypt client as I already have the fully running Nginx vhost for https/443 running and setup.

Unfortunately, I am not a python programmer so can't do that myself for adding public web root command line support. Not sure if it's as simple as making the expected Ubuntu Apache/Nginx public web root a variable that changes depending on what's passed to the cmd line flag for web root ? If no web root flag is passed it defaults to expected Ubuntu Apache/Nginx public web roots


Resolving patch:
diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py
--- a/letsencrypt/plugins/common.py
+++ b/letsencrypt/plugins/common.py
@@ -45,6 +45,10 @@ def option_namespace(self):
         """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/webroot.py b/letsencrypt/plugins/webroot.py
new file mode 100644
--- /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/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -119,6 +119,7 @@ def read_file(filename, encoding='utf8'):
         'letsencrypt.plugins': [
             'manual = letsencrypt.plugins.manual:Authenticator',
             'null = letsencrypt.plugins.null:Installer',
+            'webroot = letsencrypt.plugins.webroot:Authenticator',
             'standalone = letsencrypt.plugins.standalone.authenticator'
             ':StandaloneAuthenticator',
         ],


Test patch:
diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py
--- a/letsencrypt/plugins/common_test.py
+++ b/letsencrypt/plugins/common_test.py
@@ -50,6 +50,9 @@ def test_init(self):
     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_test.py b/letsencrypt/plugins/webroot_test.py
new file mode 100644
--- /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