Repository: certbot/certbot

Issue:
Implement an auth_hint for the standalone plugin
It looks like we forgot to do that, like we did for all the other built-in plugins. Right now it shows the default auth_hint, but we can do better.

I did a quick survey of standalone-related threads on the community forums and these are the problems that are cropping up:

| Forum Thread | What's the problem? |
|-|-|
| [Thread]( https://community.letsencrypt.org/t/certbot-failed-unauthorized-detail-invalid-response-from/133763 )| Choice of plugin: Running inside a k8s pod, certbot --standalone probably not suitable. 
| [Thread]( https://community.letsencrypt.org/t/unauthorized-domain-helloworld-dagoukeji-com/122347 )| Choice of plugin: The server was an Alibaba Object Bucket, certbot --standalone was not suitable. 
| [Thread]( https://community.letsencrypt.org/t/unauthorised-when-trying-to-use-standalone-certbot/141584 )| DNS issue: Domain was pointing to a domain masking service, breaking HTTP DCV. 
| [Thread]( https://community.letsencrypt.org/t/renewals-stopped-working-looking-for-debugging-advice/76649/7 )| DNS issue: Wrong DNS records. 
| [Thread]( https://community.letsencrypt.org/t/404-error-when-creating-certificate-dns-issue/51365 )| DNS issue: Wrong IPv6 address. 
| [Thread]( https://community.letsencrypt.org/t/urn-acme-error-unauthorized-the-client-lacks-sufficient-authorization/61097 )| DNS issue: Wrong IPv6 addresses. 
| [Thread]( https://community.letsencrypt.org/t/certbot-standalone-error/135543 )| Port forwarding: traffic going to wrong server. 
| [Thread]( https://community.letsencrypt.org/t/unable-to-renew-certificate-urnerror-unauthorized-the-client-lacks-sufficient-authorization/95198 )| Port forwarding: traffic going to wrong server. 
| [Thread]( https://community.letsencrypt.org/t/certbot-standalone-failure/153055 )| Proxy issue: haproxy was doing health checks and it wasn't seeing the Certbot standalone server come up quickly enough. 
| [Thread]( https://community.letsencrypt.org/t/certbot-mode-standalone-failed-authorization-procedure-www-mamediarrahbeye-ovh-http-01-urnparamserror-unauthorized-the-client-lacks-sufficient-authorization-the-key-authorization-file-from-the-server-did-not-match-this-challenge/114409 )| Proxy issue: web host intercepting /.well-known/acme-challenge requests. 
| [Thread]( https://community.letsencrypt.org/t/unauthorized-error-what-is-the-expected-response-from-the-certbot-challenge/95347 )| Unclear. Either nginx is already running, or Certbot is running on the wrong machine. 
| [Thread]( https://community.letsencrypt.org/t/challenge-failed-for-domain-domain-unauthorized/120584 )| Unclear.  Either wrong IP address or some exotic port forwarding. 
| [Thread]( https://community.letsencrypt.org/t/problem-creating-certificate-unauthorized/122743 )| Unclear. Either wrong IP address or some exotic port forwarding. 
| [Thread]( https://community.letsencrypt.org/t/unable-to-renew-certificate-urn-acme-error-unauthorized-the-client-lacks-sufficient-authorization/50244 )| Unclear. Either wrong IP address or some exotic port forwarding. 
| [Thread]( https://community.letsencrypt.org/t/standalone-certonly-http-01-insufficient-authorization/47148 )| Unclear. Possibly proxy problem with AWS ELB, or locally running nginx server. 
| [Thread]( https://community.letsencrypt.org/t/trying-to-install-in-tomcat-and-got-the-following-error/97483 )| Unclear. Tomcat was probably still running. 

The main things seem to be: 

1. Form a useful mental model. Convey that Certbot is starting up a temporary web server, and that the Certificate Authority is trying to download some challenge files from it via the user's domain name.
2. Get the user to confirm that the domain name is pointing to the machine where Certbot is actually running. That covers perhaps upto 75% of the issues listed above. I don't know how many of the others we should mention.
3. *Potentially*, change the language of `StandaloneBindError` to be like, "yo, something is definitely already running on `http01_port`. You need to make sure it's stopped, see [...]".


Discussion:

Resolving patch:
diff --git a/acme/acme/standalone.py b/acme/acme/standalone.py
--- a/acme/acme/standalone.py
+++ b/acme/acme/standalone.py
@@ -8,6 +8,7 @@
 import socketserver
 import threading
 from typing import List
+from typing import Optional
 
 from acme import challenges
 from acme import crypto_util
@@ -66,6 +67,9 @@ def __init__(self, ServerClass, server_address, *remaining_args, **kwargs):
         self.threads: List[threading.Thread] = []
         self.servers: List[socketserver.BaseServer] = []
 
+        # Preserve socket error for re-raising, if no servers can be started
+        last_socket_err: Optional[socket.error] = None
+
         # Must try True first.
         # Ubuntu, for example, will fail to bind to IPv4 if we've already bound
         # to IPv6. But that's ok, since it will accept IPv4 connections on the IPv6
@@ -82,7 +86,8 @@ def __init__(self, ServerClass, server_address, *remaining_args, **kwargs):
                 logger.debug(
                     "Successfully bound to %s:%s using %s", new_address[0],
                     new_address[1], "IPv6" if ip_version else "IPv4")
-            except socket.error:
+            except socket.error as e:
+                last_socket_err = e
                 if self.servers:
                     # Already bound using IPv6.
                     logger.debug(
@@ -101,7 +106,10 @@ def __init__(self, ServerClass, server_address, *remaining_args, **kwargs):
                 # bind to the same port for both servers.
                 port = server.socket.getsockname()[1]
         if not self.servers:
-            raise socket.error("Could not bind to IPv4 or IPv6.")
+            if last_socket_err:
+                raise last_socket_err
+            else: # pragma: no cover
+                raise socket.error("Could not bind to IPv4 or IPv6.")
 
     def serve_forever(self):
         """Wraps socketserver.TCPServer.serve_forever"""
diff --git a/certbot/certbot/_internal/plugins/standalone.py b/certbot/certbot/_internal/plugins/standalone.py
--- a/certbot/certbot/_internal/plugins/standalone.py
+++ b/certbot/certbot/_internal/plugins/standalone.py
@@ -5,6 +5,7 @@
 import socket
 from typing import DefaultDict
 from typing import Dict
+from typing import List
 from typing import Set
 from typing import Tuple
 from typing import TYPE_CHECKING
@@ -184,6 +185,14 @@ def cleanup(self, achalls):  # pylint: disable=missing-function-docstring
             if not self.served[servers]:
                 self.servers.stop(port)
 
+    def auth_hint(self, failed_achalls: List[achallenges.AnnotatedChallenge]) -> str:
+        port, addr = self.config.http01_port, self.config.http01_address
+        neat_addr = f"{addr}:{port}" if addr else f"port {port}"
+        return ("The Certificate Authority failed to download the challenge files from "
+                f"the temporary standalone webserver started by Certbot on {neat_addr}. "
+                "Ensure that the listed domains point to this machine and that it can "
+                "accept inbound connections from the internet.")
+
 
 def _handle_perform_error(error):
     if error.socket_error.errno == errno.EACCES:


Test patch:
diff --git a/acme/tests/standalone_test.py b/acme/tests/standalone_test.py
--- a/acme/tests/standalone_test.py
+++ b/acme/tests/standalone_test.py
@@ -190,12 +190,18 @@ def __init__(self, *args, **kwargs):
 
     @mock.patch("socket.socket.bind")
     def test_fail_to_bind(self, mock_bind):
-        mock_bind.side_effect = socket.error
+        from errno import EADDRINUSE
         from acme.standalone import BaseDualNetworkedServers
-        self.assertRaises(socket.error, BaseDualNetworkedServers,
-                          BaseDualNetworkedServersTest.SingleProtocolServer,
-                          ('', 0),
-                          socketserver.BaseRequestHandler)
+
+        mock_bind.side_effect = socket.error(EADDRINUSE, "Fake addr in use error")
+
+        with self.assertRaises(socket.error) as em:
+            BaseDualNetworkedServers(
+                BaseDualNetworkedServersTest.SingleProtocolServer,
+                ('', 0), socketserver.BaseRequestHandler)
+
+        self.assertEqual(em.exception.errno, EADDRINUSE)
+
 
     def test_ports_equal(self):
         from acme.standalone import BaseDualNetworkedServers
diff --git a/certbot/tests/plugins/standalone_test.py b/certbot/tests/plugins/standalone_test.py
--- a/certbot/tests/plugins/standalone_test.py
+++ b/certbot/tests/plugins/standalone_test.py
@@ -177,6 +177,13 @@ def test_cleanup(self):
             "server1": set(), "server2": set()})
         self.auth.servers.stop.assert_called_with(2)
 
+    def test_auth_hint(self):
+        self.config.http01_port = "80"
+        self.config.http01_address = None
+        self.assertIn("on port 80", self.auth.auth_hint([]))
+        self.config.http01_address = "127.0.0.1"
+        self.assertIn("on 127.0.0.1:80", self.auth.auth_hint([]))
+
 
 if __name__ == "__main__":
     unittest.main()  # pragma: no cover