Repository: certbot/certbot

Issue:
In Windows deployment, add web.config file to acme-challenge folder so IIS can serve extensionless files when using the webroot authenticator for HTTP-01 challenges
This is such a constant issue in the Let's Encrypt Community that I can't believe it hasn't been formally  addressed yet.

In order for IIS to satisfy an HTTP-01 challenge when using the webroot authenticator, there needs to be a `web.config` file in the `acme-challenge` folder containing the following:
```
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.webServer>
      <staticContent>
          <mimeMap fileExtension="." mimeType="text/plain" />
      </staticContent>
  </system.webServer>
</configuration>
```


Discussion:
@alexzorin has helped many people with this issue in the Let's Encrypt Community, so I want to make him aware of the issue here in case he has any comments/contributions that he wants to make.

Resolving patch:
diff --git a/certbot/certbot/_internal/plugins/webroot.py b/certbot/certbot/_internal/plugins/webroot.py
--- a/certbot/certbot/_internal/plugins/webroot.py
+++ b/certbot/certbot/_internal/plugins/webroot.py
@@ -9,6 +9,7 @@
 from typing import Set
 
 from acme import challenges
+from certbot import crypto_util
 from certbot import errors
 from certbot import interfaces
 from certbot._internal import cli
@@ -24,6 +25,23 @@
 logger = logging.getLogger(__name__)
 
 
+_WEB_CONFIG_CONTENT = """\
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--Generated by Certbot-->
+<configuration>
+  <system.webServer>
+      <staticContent>
+          <mimeMap fileExtension="." mimeType="text/plain" />
+      </staticContent>
+  </system.webServer>
+</configuration>
+"""
+# This list references the hashes of all versions of the web.config files that Certbot could
+# have generated during an HTTP-01 challenge. If you modify _WEB_CONFIG_CONTENT, you MUST add
+# the new hash in this list.
+_WEB_CONFIG_SHA256SUMS = ["20c5ca1bd58fa8ad5f07a2f1be8b7cbb707c20fcb607a8fc8db9393952846a97"]
+
+
 class Authenticator(common.Plugin, interfaces.Authenticator):
     """Webroot Authenticator."""
 
@@ -192,6 +210,19 @@ def _create_challenge_dirs(self):
             finally:
                 filesystem.umask(old_umask)
 
+            # On Windows, generate a local web.config file that allows IIS to serve expose
+            # challenge files despite the fact they do not have a file extension.
+            if not filesystem.POSIX_MODE:
+                web_config_path = os.path.join(self.full_roots[name], "web.config")
+                if os.path.exists(web_config_path):
+                    logger.info("A web.config file has not been created in "
+                                "%s because another one already exists.", self.full_roots[name])
+                    return
+                logger.info("Creating a web.config file in %s to allow IIS "
+                            "to serve challenge files.", self.full_roots[name])
+                with safe_open(web_config_path, mode="w", chmod=0o644) as web_config:
+                    web_config.write(_WEB_CONFIG_CONTENT)
+
     def _get_validation_path(self, root_path, achall):
         return os.path.join(root_path, achall.chall.encode("token"))
 
@@ -223,6 +254,19 @@ def cleanup(self, achalls):  # pylint: disable=missing-function-docstring
                 os.remove(validation_path)
                 self.performed[root_path].remove(achall)
 
+                if not filesystem.POSIX_MODE:
+                    web_config_path = os.path.join(root_path, "web.config")
+                    if os.path.exists(web_config_path):
+                        sha256sum = crypto_util.sha256sum(web_config_path)
+                        if sha256sum in _WEB_CONFIG_SHA256SUMS:
+                            logger.info("Cleaning web.config file generated by Certbot in %s.",
+                                        root_path)
+                            os.remove(web_config_path)
+                        else:
+                            logger.info("Not cleaning up the web.config file in %s "
+                                        "because it is not generated by Certbot.", root_path)
+
+
         not_removed: List[str] = []
         while self._created_dirs:
             path = self._created_dirs.pop()


Test patch:
diff --git a/certbot/tests/plugins/webroot_test.py b/certbot/tests/plugins/webroot_test.py
--- a/certbot/tests/plugins/webroot_test.py
+++ b/certbot/tests/plugins/webroot_test.py
@@ -86,6 +86,35 @@ def test_webroot_from_list(self, mock_get_utility):
         self.assertEqual(self.config.webroot_map[self.achall.domain],
                          self.path)
 
+    @unittest.skipIf(filesystem.POSIX_MODE, reason='Test specific to Windows')
+    @test_util.patch_display_util()
+    def test_webconfig_file_generate_and_cleanup(self, mock_get_utility):
+        mock_display = mock_get_utility()
+        mock_display.menu.return_value = (display_util.OK, 1,)
+
+        self.auth.perform([self.achall])
+        self.assertTrue(os.path.exists(os.path.join(self.root_challenge_path, "web.config")))
+        self.auth.cleanup([self.achall])
+        self.assertFalse(os.path.exists(os.path.join(self.root_challenge_path, "web.config")))
+
+    @unittest.skipIf(filesystem.POSIX_MODE, reason='Test specific to Windows')
+    @test_util.patch_display_util()
+    def test_foreign_webconfig_file_handling(self, mock_get_utility):
+        mock_display = mock_get_utility()
+        mock_display.menu.return_value = (display_util.OK, 1,)
+
+        challenge_path = os.path.join(self.path, ".well-known", "acme-challenge")
+        filesystem.makedirs(challenge_path)
+
+        webconfig_path = os.path.join(challenge_path, "web.config")
+        with open(webconfig_path, "w") as file:
+            file.write("something")
+        self.auth.perform([self.achall])
+        from certbot import crypto_util
+        webconfig_hash = crypto_util.sha256sum(webconfig_path)
+        from certbot._internal.plugins.webroot import _WEB_CONFIG_SHA256SUMS
+        self.assertTrue(webconfig_hash not in _WEB_CONFIG_SHA256SUMS)
+
     @test_util.patch_display_util()
     def test_webroot_from_list_help_and_cancel(self, mock_get_utility):
         self.config.webroot_path = []