From 58724f68ec46c57dd3c3c8ed4ae686c2d7ad893b Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 16 Jan 2026 13:23:41 -0800 Subject: [PATCH] Add CLI flag --ip-address (#10495) Co-authored-by: ohemorange Co-authored-by: Brad Warren --- .../certbot_apache/_internal/configurator.py | 2 +- .../certbot_tests/context.py | 1 + .../certbot_tests/test_main.py | 56 ++++++++++++++++++- .../utils/acme_server.py | 15 +++++ .../certbot_integration_tests/utils/misc.py | 5 ++ .../certbot_integration_tests/utils/proxy.py | 24 +++++--- .../configurators/common.py | 4 +- .../_internal/dns_route53.py | 2 +- .../certbot_nginx/_internal/configurator.py | 2 +- certbot/docs/contributing.rst | 2 +- certbot/src/certbot/_internal/auth_handler.py | 10 +++- certbot/src/certbot/_internal/cert_manager.py | 8 ++- certbot/src/certbot/_internal/cli/__init__.py | 9 +++ .../src/certbot/_internal/cli/cli_utils.py | 26 ++++++++- certbot/src/certbot/_internal/cli/helpful.py | 12 ++-- certbot/src/certbot/_internal/client.py | 8 ++- certbot/src/certbot/_internal/constants.py | 1 + certbot/src/certbot/_internal/main.py | 11 +++- .../src/certbot/_internal/plugins/manual.py | 2 +- .../certbot/_internal/plugins/standalone.py | 2 +- .../src/certbot/_internal/plugins/webroot.py | 2 +- certbot/src/certbot/_internal/renewal.py | 3 +- .../_internal/tests/cert_manager_test.py | 1 + .../certbot/_internal/tests/client_test.py | 12 ++-- .../src/certbot/_internal/tests/main_test.py | 2 +- .../tests/plugins/standalone_test.py | 2 +- certbot/src/certbot/crypto_util.py | 7 ++- certbot/src/certbot/interfaces.py | 4 +- certbot/src/certbot/plugins/dns_common.py | 2 +- newsfragments/10465.added | 1 + newsfragments/10495.changed | 1 + 31 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 newsfragments/10465.added create mode 100644 newsfragments/10495.changed diff --git a/certbot-apache/src/certbot_apache/_internal/configurator.py b/certbot-apache/src/certbot_apache/_internal/configurator.py index 6ec52c2fc..c82b6b3c9 100644 --- a/certbot-apache/src/certbot_apache/_internal/configurator.py +++ b/certbot-apache/src/certbot_apache/_internal/configurator.py @@ -2442,7 +2442,7 @@ class ApacheConfigurator(common.Configurator): ########################################################################### # Challenges Section ########################################################################### - def get_chall_pref(self, unused_domain: str) -> Sequence[type[challenges.HTTP01]]: + def get_chall_pref(self, unused_identifier: str) -> Sequence[type[challenges.HTTP01]]: """Return list of challenge preferences.""" return [challenges.HTTP01] diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py index 7c2672e6f..4547ac713 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py @@ -24,6 +24,7 @@ class IntegrationTestsContext: self.directory_url = acme_xdist['directory_url'] self.https_port = acme_xdist['https_port'][self.worker_id] + self.local_ip = str(acme_xdist['local_ip'][self.worker_id]) self.http_01_port = acme_xdist['http_port'][self.worker_id] self.other_port = acme_xdist['other_port'][self.worker_id] # Challtestsrv REST API, that exposes entrypoints to register new DNS entries, diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py index 65f70adac..a25c336cb 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py @@ -6,6 +6,7 @@ from os.path import join import re import shutil import subprocess +import sys from typing import Generator from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurve @@ -32,7 +33,7 @@ from certbot_integration_tests.certbot_tests.assertions import assert_world_no_p from certbot_integration_tests.certbot_tests.assertions import assert_world_read_permissions from certbot_integration_tests.certbot_tests.assertions import EVERYBODY_SID from certbot_integration_tests.certbot_tests.context import IntegrationTestsContext -from certbot_integration_tests.utils import misc +from certbot_integration_tests.utils import misc, constants @pytest.fixture(name='context') @@ -111,6 +112,59 @@ def test_http_01(context: IntegrationTestsContext) -> None: assert_saved_lineage_option(context.config_dir, certname, 'key_type', 'ecdsa') +@pytest.mark.skipif(sys.platform == 'darwin', + reason='macOS has one IPv4 loopback address by default') +def test_ipv4_address_standalone(context: IntegrationTestsContext) -> None: + """Test the HTTP-01 challenge with an IPv4 address using standalone authenticator. + + While Pebble will offer both HTTP-01 and TLS-ALPN-01 challenges, we will + only select HTTP-01 because TLS-ALPN-01 is not supported by the standalone + authenticator. + + This test relies on proxy.py being able to forward requests for, e.g. `127.0.0.2`, + (`local_ip`) to this test runner. That works on Linux because 127.0.0.0/8 all routes + to localhost. However, on macOS by default only 127.0.0.1 is routed, so this test is + skipped. If you want to run it, configure additional loopback addresses: + for n in $(seq 2 127) ; do sudo ifconfig lo0 alias "127.0.0.${n}" up ; done. + """ + context.certbot([ + 'certonly', '--ip-address', context.local_ip, '--standalone', + ]) + assert_cert_count_for_lineage(context.config_dir, context.local_ip, 1) + + +def test_ipv6_address_standalone(context: IntegrationTestsContext) -> None: + """Test the HTTP-01 challenge with an IPv6 address using standalone authenticator. + + This test relies on some tricks. Pebble is configured to do validations on port 5002. + Since multiple integration tests want to handle validation requests, and may run + concurrently, proxy.py handles HTTP traffic for port 5002 and sends it to the appropriate + integration test runner. However, it binds that port for IPv4 only. That is, + GracefulTCPServer doesn't specify `address_family = AF_INET6` when subclassing + socketserver.TCPServer. + + For IPv4 integration tests (above), we simply assign each integration test runner a unique + IP address under 127.0.0.0/8, and the proxy knows how to route those IP addresses. Pebble's + validation connects to the proxy because all of 127.0.0.0/8 is defined to be loopback. + + However, under IPv6 there is exactly one loopback address, so we can't use the same trick. + Instead, we ensure that this is the only test that cares about IPv6, and bind [::1]:5002 + for IPv6 (via `--http-01-address`). When Pebble reaches out to validate `::1`, it reaches + this test runner rather than the proxy. + + Note: This works because this is the only test case that binds [::1]:5002. If additional + IPv6 tests are added they could conflict. In that case we might try using the + @pytest.mark.xdist_group annotation along with --dist loadgroup. + https://pytest-xdist.readthedocs.io/en/stable/distribution.html + """ + context.certbot([ + 'certonly', '--ip-address', '::1', '--standalone', + '--http-01-address', '::1', + '--http-01-port', str(constants.DEFAULT_HTTP_01_PORT), + ]) + assert_cert_count_for_lineage(context.config_dir, '::1', 1) + + def test_manual_http_auth(context: IntegrationTestsContext) -> None: """Test the HTTP-01 challenge using manual plugin.""" with misc.create_http_server(context.http_01_port) as webroot,\ diff --git a/certbot-ci/src/certbot_integration_tests/utils/acme_server.py b/certbot-ci/src/certbot_integration_tests/utils/acme_server.py index bb1cec976..ee824c00b 100755 --- a/certbot-ci/src/certbot_integration_tests/utils/acme_server.py +++ b/certbot-ci/src/certbot_integration_tests/utils/acme_server.py @@ -115,6 +115,13 @@ class ACMEServer: acme_xdist['directory_url'] = PEBBLE_DIRECTORY_URL acme_xdist['challtestsrv_url'] = PEBBLE_CHALLTESTSRV_URL acme_xdist['http_port'] = dict(zip(nodes, range(5200, 5200 + len(nodes)))) + # When testing the standalone plugin with IP address certificates, we need a way for + # the proxy to map incoming requests to workers. Since on Linux all 127.* addresses are + # loopback, we give each worker a 127.0.0.n address. The proxy will route all requests for + # that IP address to the http_port assigned for that worker. + acme_xdist['local_ip'] = {} + for i, node in enumerate(nodes): + acme_xdist['local_ip'][node] = f"127.0.0.{i+2}" acme_xdist['https_port'] = dict(zip(nodes, range(5100, 5100 + len(nodes)))) acme_xdist['other_port'] = dict(zip(nodes, range(5300, 5300 + len(nodes)))) @@ -161,6 +168,14 @@ class ACMEServer: http_port_map = cast(dict[str, int], self.acme_xdist['http_port']) mapping = {r'.+\.{0}\.wtf'.format(node): 'http://127.0.0.1:{0}'.format(port) for node, port in http_port_map.items()} + + # Each worker gets a specific loopback IP address. All traffic to that IP address gets + # routed to that worker's http_port. + local_ip_map = cast(dict[str, str], self.acme_xdist['local_ip']) + for node, local_ip in local_ip_map.items(): + mapping[r'{0}:.+'.format(local_ip)] = \ + 'http://{0}:{1}'.format(local_ip, http_port_map[node]) + command = [sys.executable, proxy.__file__, str(self._http_01_port), json.dumps(mapping)] self._launch_process(command) print('=> Finished configuring the HTTP proxy.') diff --git a/certbot-ci/src/certbot_integration_tests/utils/misc.py b/certbot-ci/src/certbot_integration_tests/utils/misc.py index bd2efe98b..e65078c62 100644 --- a/certbot-ci/src/certbot_integration_tests/utils/misc.py +++ b/certbot-ci/src/certbot_integration_tests/utils/misc.py @@ -12,6 +12,7 @@ import json import os import re import shutil +import socket import socketserver import stat import sys @@ -74,6 +75,10 @@ class GracefulTCPServer(socketserver.TCPServer): just been released by another instance of TCPServer. """ allow_reuse_address = True + # AF_INET is the default, but make it explicit. We don't want to conflict with + # test_ipv6_address_standalone in the integration tests, which listens on [::1]:5002 + # (that is, AF_INET6). + address_family = socket.AF_INET @contextlib.contextmanager diff --git a/certbot-ci/src/certbot_integration_tests/utils/proxy.py b/certbot-ci/src/certbot_integration_tests/utils/proxy.py index 4960ec93e..9779b3eb4 100644 --- a/certbot-ci/src/certbot_integration_tests/utils/proxy.py +++ b/certbot-ci/src/certbot_integration_tests/utils/proxy.py @@ -11,22 +11,30 @@ import requests from certbot_integration_tests.utils.misc import GracefulTCPServer - def _create_proxy(mapping: Mapping[str, str]) -> type[BaseHTTPServer.BaseHTTPRequestHandler]: # pylint: disable=missing-function-docstring class ProxyHandler(BaseHTTPServer.BaseHTTPRequestHandler): # pylint: disable=missing-class-docstring def do_GET(self) -> None: headers = {key.lower(): value for key, value in self.headers.items()} - backend = [backend for pattern, backend in mapping.items() - if re.match(pattern, headers['host'])][0] - response = requests.get(backend + self.path, headers=headers, timeout=10) + host = headers['host'] + for pattern, backend in mapping.items(): + if re.match(pattern, host): + response = requests.get(backend + self.path, headers=headers, timeout=10) - self.send_response(response.status_code) - for key, value in response.headers.items(): - self.send_header(key, value) + self.send_response(response.status_code) + for key, value in response.headers.items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(response.content) + return + + # We should never hit this if the tests are written correctly, but if we do, this may + # be helpful debugging output. + print(f"proxy.py: do_GET for {host}: No backend") + self.send_response(200, "No backend") self.end_headers() - self.wfile.write(response.content) + self.wfile.write(bytes(f"No backend found for {host}\n", 'utf-8')) return ProxyHandler diff --git a/certbot-compatibility-test/src/certbot_compatibility_test/configurators/common.py b/certbot-compatibility-test/src/certbot_compatibility_test/configurators/common.py index d22422dc0..713a277f1 100644 --- a/certbot-compatibility-test/src/certbot_compatibility_test/configurators/common.py +++ b/certbot-compatibility-test/src/certbot_compatibility_test/configurators/common.py @@ -124,8 +124,8 @@ class Proxy(interfaces.ConfiguratorProxy): def get_all_names(self) -> Iterable[str]: return self._configurator.get_all_names() - def get_chall_pref(self, domain: str) -> Iterable[type[Challenge]]: - return self._configurator.get_chall_pref(domain) + def get_chall_pref(self, identifier: str) -> Iterable[type[Challenge]]: + return self._configurator.get_chall_pref(identifier) @classmethod def inject_parser_options(cls, parser: argparse.ArgumentParser, name: str) -> None: diff --git a/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py b/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py index 8a480f0fd..8fa35a5e5 100644 --- a/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py +++ b/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py @@ -61,7 +61,7 @@ class Authenticator(common.Plugin, interfaces.Authenticator): def prepare(self) -> None: pass - def get_chall_pref(self, unused_domain: str) -> Iterable[type[challenges.Challenge]]: + def get_chall_pref(self, unused_identifier: str) -> Iterable[type[challenges.Challenge]]: return [challenges.DNS01] def perform(self, achalls: list[AnnotatedChallenge]) -> list[challenges.ChallengeResponse]: diff --git a/certbot-nginx/src/certbot_nginx/_internal/configurator.py b/certbot-nginx/src/certbot_nginx/_internal/configurator.py index 4d0da638e..185ed0280 100644 --- a/certbot-nginx/src/certbot_nginx/_internal/configurator.py +++ b/certbot-nginx/src/certbot_nginx/_internal/configurator.py @@ -1204,7 +1204,7 @@ class NginxConfigurator(common.Configurator): ########################################################################### # Challenges Section for Authenticator ########################################################################### - def get_chall_pref(self, unused_domain: str) -> list[type[challenges.Challenge]]: + def get_chall_pref(self, unused_identifier: str) -> list[type[challenges.Challenge]]: """Return list of challenge preferences.""" return [challenges.HTTP01] diff --git a/certbot/docs/contributing.rst b/certbot/docs/contributing.rst index de3cab945..e60a7190f 100644 --- a/certbot/docs/contributing.rst +++ b/certbot/docs/contributing.rst @@ -269,7 +269,7 @@ challenges: HTTP and DNS, represented by classes in `acme.challenges`. An authenticator plugin should implement support for at least one challenge type. An Authenticator indicates which challenges it supports by implementing -`get_chall_pref(domain)` to return a sorted list of challenge types in +`get_chall_pref(identifier)` to return a sorted list of challenge types in preference order. An Authenticator must also implement `perform(achalls)`, which "performs" a list diff --git a/certbot/src/certbot/_internal/auth_handler.py b/certbot/src/certbot/_internal/auth_handler.py index e077d97b9..ad53064c4 100644 --- a/certbot/src/certbot/_internal/auth_handler.py +++ b/certbot/src/certbot/_internal/auth_handler.py @@ -237,15 +237,19 @@ class AuthHandler: return achalls - def _get_chall_pref(self, domain: str) -> list[type[challenges.Challenge]]: + def _get_chall_pref(self, identifier: str) -> list[type[challenges.Challenge]]: """Return list of challenge preferences. :param str domain: domain for which you are requesting preferences """ chall_prefs = [] - # Make sure to make a copy... - plugin_pref = self.auth.get_chall_pref(domain) + # The 'identifier' parameter of `get_chall_pref` used to be called `domain`. + # There may be plugins in the wild that name their parameter `domain`. To keep + # working with those plugins, make sure to continue to pass `identifier` as a + # positional parameter rather than a kwarg. + # Also, make sure to make a copy... + plugin_pref = self.auth.get_chall_pref(identifier) if self.pref_challs: plugin_pref_types = {chall.typ for chall in plugin_pref} for typ in self.pref_challs: diff --git a/certbot/src/certbot/_internal/cert_manager.py b/certbot/src/certbot/_internal/cert_manager.py index d93c2fe0c..ce1cbcf8a 100644 --- a/certbot/src/certbot/_internal/cert_manager.py +++ b/certbot/src/certbot/_internal/cert_manager.py @@ -252,7 +252,7 @@ def human_readable_cert_info(config: configuration.NamespaceConfig, cert: storag certinfo = [] checker = ocsp.RevocationChecker() - config_sans = set(config.domains) + config_sans = set(config.domains + config.ip_addresses) if config.certname and cert.lineagename != config.certname and not skip_filter_checks: return None @@ -358,8 +358,10 @@ def _describe_certs(config: configuration.NamespaceConfig, notify("No certificates found.") else: if parsed_certs: - match = "matching " if config.certname or config.domains else "" - notify("Found the following {0}certs:".format(match)) + if config.certname or config.domains or config.ip_addresses: + notify("Found the following matching certs:") + else: + notify("Found the following certs:") notify(_report_human_readable(config, parsed_certs)) if parse_failures: notify("\nThe following renewal configurations " diff --git a/certbot/src/certbot/_internal/cli/__init__.py b/certbot/src/certbot/_internal/cli/__init__.py index e9947b4e7..5e70c6cf7 100644 --- a/certbot/src/certbot/_internal/cli/__init__.py +++ b/certbot/src/certbot/_internal/cli/__init__.py @@ -32,6 +32,7 @@ from certbot._internal.cli.cli_utils import CaseInsensitiveList from certbot._internal.cli.cli_utils import config_help from certbot._internal.cli.cli_utils import CustomHelpFormatter from certbot._internal.cli.cli_utils import DomainsAction +from certbot._internal.cli.cli_utils import _IPAddressAction from certbot._internal.cli.cli_utils import flag_default from certbot._internal.cli.cli_utils import HelpfulArgumentGroup from certbot._internal.cli.cli_utils import nonnegative_int @@ -123,6 +124,14 @@ def prepare_and_parse_args(plugins: plugins_disco.PluginsRegistry, args: list[st "will be used as the certificate name, unless otherwise specified or if you " "already have a certificate with the same name. In the case of a name conflict, " "a number like -0001 will be appended to the certificate name. (default: Ask)") + helpful.add( + [None, "certonly", "certificates"], + "--ip-address", dest="ip_addresses", + action=_IPAddressAction, + default=flag_default("ip_addresses"), + help="IP addresses to include. For multiple IP addresses you can use multiple " + "--ip-address flags. All IP addresses will be included as Subject Alternative Names " + "on the certificate.") helpful.add( [None, "run", "certonly", "register"], "--eab-kid", dest="eab_kid", diff --git a/certbot/src/certbot/_internal/cli/cli_utils.py b/certbot/src/certbot/_internal/cli/cli_utils.py index 89a8acd6c..0efae8fff 100644 --- a/certbot/src/certbot/_internal/cli/cli_utils.py +++ b/certbot/src/certbot/_internal/cli/cli_utils.py @@ -96,10 +96,15 @@ class DomainsAction(argparse.Action): """Action class for parsing domains.""" def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, - domain: Union[str, Sequence[Any], None], - option_string: Optional[str] = None) -> None: + values: str | Sequence[Any] | None, + option_string: str | None = None) -> None: """Just wrap add_domains in argparseese.""" - add_domains(namespace, str(domain) if domain is not None else None) + match values: + case str(): + add_domains(namespace, str(values)) + case _: + # https://docs.python.org/3/library/argparse.html#nargs + raise TypeError("shouldn't happen: non-str passed by argparse when nargs=None") def add_domains(args_or_config: Union[argparse.Namespace, configuration.NamespaceConfig], @@ -131,6 +136,21 @@ def add_domains(args_or_config: Union[argparse.Namespace, configuration.Namespac return validated_domains +class _IPAddressAction(argparse.Action): + """Action class for parsing IP addresses.""" + + def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: Optional[str] = None) -> None: + match values: + case str(): + # This will throw an exception if the IP address doesn't parse. + namespace.ip_addresses.append(san.IPAddress(values)) + case _: + # https://docs.python.org/3/library/argparse.html#nargs + raise TypeError("shouldn't happen: non-str passed by argparse when nargs=None") + + class CaseInsensitiveList(list): """A list that will ignore case when searching. diff --git a/certbot/src/certbot/_internal/cli/helpful.py b/certbot/src/certbot/_internal/cli/helpful.py index d5dacf896..4c356123a 100644 --- a/certbot/src/certbot/_internal/cli/helpful.py +++ b/certbot/src/certbot/_internal/cli/helpful.py @@ -332,26 +332,26 @@ class HelpfulArgumentParser: csrfile, contents = config.csr[0:2] typ, util_csr, _ = crypto_util.import_csr_file(csrfile, contents) x509_req = x509.load_pem_x509_csr(util_csr.data) - domains, _ = san.from_x509(x509_req.subject, x509_req.extensions) + domains, ip_addresses = san.from_x509(x509_req.subject, x509_req.extensions) # The SANs from the CSR are added to the domains from command line flags as this config # setting is where main.certonly gets the list of identifiers to request. config.domains.extend(domains) - if not domains: + if not domains + ip_addresses: # TODO: add CN to domains instead: raise errors.Error( - "Unfortunately, your CSR %s needs to have a SubjectAltName for every domain" - % config.csr[0]) + f"Unfortunately, your CSR {config.csr[0]} needs to have a SubjectAltName for " + + "every domain or IP address") config.actual_csr = (util_csr, typ) - # Check that the original values for --domain set by the user were + # Check that the original values for --domain and --ip-address set by the user were # a subset of the domains listed in the CSR. if set(config.domains) != set(domains): raise errors.ConfigurationError( "Inconsistent requests:\nFrom the CSR: {0}\nFrom command line/config: {1}" - .format(san.display(domains), + .format(san.display(san.join(domains, ip_addresses)), san.display(config.domains))) diff --git a/certbot/src/certbot/_internal/client.py b/certbot/src/certbot/_internal/client.py index 1f98ae962..f2783d2b6 100644 --- a/certbot/src/certbot/_internal/client.py +++ b/certbot/src/certbot/_internal/client.py @@ -399,8 +399,9 @@ class Client: elif self.config.rsa_key_size and self.config.key_type.lower() == 'rsa': key_size = self.config.rsa_key_size - domains, _ = san.split(sans) + domains, ip_addresses = san.split(sans) domains_str = [d.dns_name for d in domains] + ip_addresses_typed = [i.ip_address for i in ip_addresses] # Create CSR from names if self.config.dry_run: @@ -416,7 +417,7 @@ class Client: csr = util.CSR(file=None, form="pem", data=acme_crypto_util.make_csr( key.pem, domains_str, self.config.must_staple, - )) + ipaddrs=ip_addresses_typed)) else: key = key or crypto_util.generate_key( key_size=key_size, @@ -426,7 +427,8 @@ class Client: strict_permissions=self.config.strict_permissions, ) csr = crypto_util.generate_csr( - key, domains_str, None, self.config.must_staple, self.config.strict_permissions) + key, domains_str, None, self.config.must_staple, self.config.strict_permissions, + ipaddrs=ip_addresses_typed) try: orderr = self._get_order_and_authorizations(csr.data, self.config.allow_subset_of_names) diff --git a/certbot/src/certbot/_internal/constants.py b/certbot/src/certbot/_internal/constants.py index 4a2172c3e..240a81898 100644 --- a/certbot/src/certbot/_internal/constants.py +++ b/certbot/src/certbot/_internal/constants.py @@ -32,6 +32,7 @@ CLI_DEFAULTS: dict[str, Any] = dict( # pylint: disable=use-dict-literal noninteractive_mode=False, force_interactive=False, domains=[], + ip_addresses=[], certname=None, dry_run=False, register_unsafely_without_email=False, diff --git a/certbot/src/certbot/_internal/main.py b/certbot/src/certbot/_internal/main.py index 6aea12f1f..6d1aec1df 100644 --- a/certbot/src/certbot/_internal/main.py +++ b/certbot/src/certbot/_internal/main.py @@ -500,12 +500,17 @@ def _find_sans_or_certname(config: configuration.NamespaceConfig, :raises errors.Error: Usage message, if parameters are not used correctly """ + domains: list[san.SAN] = [] + ip_addresses: list[san.SAN] = [] certname = config.certname - sans: Optional[list[san.SAN]] = None # first, try to get domains from the config if config.domains: - sans = config.domains + domains = config.domains + if config.ip_addresses: + ip_addresses = config.ip_addresses + + sans: Optional[list[san.SAN]] = domains + ip_addresses # if we can't do that but we have a certname, get the sans # by loading the latest certificate with that certname @@ -518,7 +523,7 @@ def _find_sans_or_certname(config: configuration.NamespaceConfig, sans = san.guess(display_ops.choose_names(installer, question)) if not sans: - raise errors.Error("Please specify --domains, or --installer that " + raise errors.Error("Please specify --domains, --ip-address, or --installer that " "will help in domain names autodiscovery, or " "--cert-name for an existing certificate name.") diff --git a/certbot/src/certbot/_internal/plugins/manual.py b/certbot/src/certbot/_internal/plugins/manual.py index e1ed2c482..e8ff43cce 100644 --- a/certbot/src/certbot/_internal/plugins/manual.py +++ b/certbot/src/certbot/_internal/plugins/manual.py @@ -163,7 +163,7 @@ permitted by DNS standards.) ) ) - def get_chall_pref(self, domain: str) -> Iterable[type[challenges.Challenge]]: + def get_chall_pref(self, identifier: str) -> Iterable[type[challenges.Challenge]]: # pylint: disable=unused-argument,missing-function-docstring return [challenges.HTTP01, challenges.DNS01] diff --git a/certbot/src/certbot/_internal/plugins/standalone.py b/certbot/src/certbot/_internal/plugins/standalone.py index 9546df0a0..83baa53ce 100644 --- a/certbot/src/certbot/_internal/plugins/standalone.py +++ b/certbot/src/certbot/_internal/plugins/standalone.py @@ -133,7 +133,7 @@ running. HTTP challenge only (wildcards not supported).""" def prepare(self) -> None: # pylint: disable=missing-function-docstring pass - def get_chall_pref(self, domain: str) -> Iterable[type[challenges.Challenge]]: + def get_chall_pref(self, identifier: str) -> Iterable[type[challenges.Challenge]]: # pylint: disable=unused-argument,missing-function-docstring return [challenges.HTTP01] diff --git a/certbot/src/certbot/_internal/plugins/webroot.py b/certbot/src/certbot/_internal/plugins/webroot.py index 5a3d3d8fd..7eacba32a 100644 --- a/certbot/src/certbot/_internal/plugins/webroot.py +++ b/certbot/src/certbot/_internal/plugins/webroot.py @@ -89,7 +89,7 @@ to serve all files under specified web root ({0}).""" "the provided --webroot-path/-w and that files created there can be downloaded " "from the internet.") - def get_chall_pref(self, domain: str) -> Iterable[type[challenges.Challenge]]: + def get_chall_pref(self, identifier: str) -> Iterable[type[challenges.Challenge]]: # pylint: disable=unused-argument,missing-function-docstring return [challenges.HTTP01] diff --git a/certbot/src/certbot/_internal/renewal.py b/certbot/src/certbot/_internal/renewal.py index 9b2dc2033..bc91d9359 100644 --- a/certbot/src/certbot/_internal/renewal.py +++ b/certbot/src/certbot/_internal/renewal.py @@ -148,8 +148,9 @@ def reconstitute(config: configuration.NamespaceConfig, return None try: - domains, _ = san.split(renewal_candidate.sans()) + domains, ip_addresses = san.split(renewal_candidate.sans()) config.domains = domains + config.ip_addresses = ip_addresses except errors.ConfigurationError as error: logger.error("Renewal configuration file %s references a certificate " "that contains an invalid domain name. The problem " diff --git a/certbot/src/certbot/_internal/tests/cert_manager_test.py b/certbot/src/certbot/_internal/tests/cert_manager_test.py index 00aed3293..7e14eb858 100644 --- a/certbot/src/certbot/_internal/tests/cert_manager_test.py +++ b/certbot/src/certbot/_internal/tests/cert_manager_test.py @@ -225,6 +225,7 @@ class CertificatesTest(BaseCertManagerTest): mock_config = mock.MagicMock(certname=None, lineagename=None) mock_config.domains = [] + mock_config.ip_addresses = [] # pylint: disable=protected-access # pylint: disable=protected-access diff --git a/certbot/src/certbot/_internal/tests/client_test.py b/certbot/src/certbot/_internal/tests/client_test.py index 6c6d72ccc..45877a3ff 100644 --- a/certbot/src/certbot/_internal/tests/client_test.py +++ b/certbot/src/certbot/_internal/tests/client_test.py @@ -380,7 +380,7 @@ class ClientTest(ClientTestCommon): strict_permissions=True, ) mock_crypto_util.generate_csr.assert_called_once_with( - mock.sentinel.key, self.eg_domains, None, False, True) + mock.sentinel.key, self.eg_domains, None, False, True, ipaddrs=[]) mock_crypto_util.cert_and_chain_from_fullchain.assert_called_once_with( self.eg_order.fullchain_pem) @@ -518,8 +518,8 @@ class ClientTest(ClientTestCommon): assert mock_crypto_util.generate_key.call_count == 2 successful_domains = [d for d in self.eg_domains if d != 'example.com'] mock_crypto_util.generate_csr.assert_has_calls([ - mock.call(key, self.eg_domains, None, self.config.must_staple, self.config.strict_permissions), - mock.call(key, successful_domains, None, self.config.must_staple, self.config.strict_permissions)]) + mock.call(key, self.eg_domains, None, self.config.must_staple, self.config.strict_permissions, ipaddrs=[]), + mock.call(key, successful_domains, None, self.config.must_staple, self.config.strict_permissions, ipaddrs=[])]) assert mock_crypto_util.cert_and_chain_from_fullchain.call_count == 1 @mock.patch("certbot._internal.client.crypto_util") @@ -610,8 +610,8 @@ class ClientTest(ClientTestCommon): successful_domains = [d for d in self.eg_domains if d != 'example.com'] assert mock_crypto_util.generate_key.call_count == 2 mock_crypto_util.generate_csr.assert_has_calls([ - mock.call(key, self.eg_domains, None, self.config.must_staple, self.config.strict_permissions), - mock.call(key, successful_domains, None, self.config.must_staple, self.config.strict_permissions)]) + mock.call(key, self.eg_domains, None, self.config.must_staple, self.config.strict_permissions, ipaddrs=[]), + mock.call(key, successful_domains, None, self.config.must_staple, self.config.strict_permissions, ipaddrs=[])]) assert mock_crypto_util.cert_and_chain_from_fullchain.call_count == 1 @mock.patch("certbot._internal.client.crypto_util") @@ -690,7 +690,7 @@ class ClientTest(ClientTestCommon): # Assumes all of eg_sans are DNSNames. eg_domains = list(map(str, self.eg_sans)) mock_acme_crypto.make_csr.assert_called_once_with( - mock.sentinel.key_pem, eg_domains, self.config.must_staple) + mock.sentinel.key_pem, eg_domains, self.config.must_staple, ipaddrs=[]) mock_crypto.generate_key.assert_not_called() mock_crypto.generate_csr.assert_not_called() assert mock_crypto.cert_and_chain_from_fullchain.call_count == 1 diff --git a/certbot/src/certbot/_internal/tests/main_test.py b/certbot/src/certbot/_internal/tests/main_test.py index 2329e4306..2bb8e859b 100644 --- a/certbot/src/certbot/_internal/tests/main_test.py +++ b/certbot/src/certbot/_internal/tests/main_test.py @@ -372,7 +372,7 @@ class FindSansOrCertnameTest(unittest.TestCase): @mock.patch('certbot.display.ops.choose_names') def test_display_ops(self, mock_choose_names): - mock_config = mock.Mock(domains=None, certname=None) + mock_config = mock.Mock(domains=None, ip_addresses=None, certname=None) mock_choose_names.return_value = ["example.com"] # pylint: disable=protected-access assert main._find_sans_or_certname(mock_config, None) == ([san.DNSName("example.com")], None) diff --git a/certbot/src/certbot/_internal/tests/plugins/standalone_test.py b/certbot/src/certbot/_internal/tests/plugins/standalone_test.py index 60d701baf..ab3cd2768 100644 --- a/certbot/src/certbot/_internal/tests/plugins/standalone_test.py +++ b/certbot/src/certbot/_internal/tests/plugins/standalone_test.py @@ -86,7 +86,7 @@ class AuthenticatorTest(unittest.TestCase): assert isinstance(self.auth.more_info(), str) def test_get_chall_pref(self): - assert self.auth.get_chall_pref(domain=None) == \ + assert self.auth.get_chall_pref(identifier=None) == \ [challenges.HTTP01] def test_perform(self): diff --git a/certbot/src/certbot/crypto_util.py b/certbot/src/certbot/crypto_util.py index fdeac5716..05f77e858 100644 --- a/certbot/src/certbot/crypto_util.py +++ b/certbot/src/certbot/crypto_util.py @@ -6,6 +6,7 @@ """ import datetime import hashlib +import ipaddress import logging import re from typing import Optional @@ -98,7 +99,9 @@ def generate_key(key_size: int, key_dir: Optional[str], key_type: str = "rsa", def generate_csr(privkey: util.Key, names: Union[list[str], set[str]], path: Optional[str], - must_staple: bool = False, strict_permissions: bool = True) -> util.CSR: + must_staple: bool = False, strict_permissions: bool = True, + ipaddrs: list[ipaddress.IPv4Address | ipaddress.IPv6Address] | None = None, + ) -> util.CSR: """Initialize a CSR with the given private key. :param privkey: Key to include in the CSR @@ -114,7 +117,7 @@ def generate_csr(privkey: util.Key, names: Union[list[str], set[str]], path: Opt """ csr_pem = acme_crypto_util.make_csr( - privkey.pem, names, must_staple=must_staple) + privkey.pem, names, must_staple=must_staple, ipaddrs=ipaddrs) # Save CSR, if requested csr_filename = None diff --git a/certbot/src/certbot/interfaces.py b/certbot/src/certbot/interfaces.py index 1451962f0..511b3c33f 100644 --- a/certbot/src/certbot/interfaces.py +++ b/certbot/src/certbot/interfaces.py @@ -162,10 +162,10 @@ class Authenticator(Plugin): """ @abstractmethod - def get_chall_pref(self, domain: str) -> Iterable[type[Challenge]]: + def get_chall_pref(self, identifier: str) -> Iterable[type[Challenge]]: """Return `collections.Iterable` of challenge preferences. - :param str domain: Domain for which challenge preferences are sought. + :param str identifier: Domain or IP address for which challenge preferences are sought. :returns: `collections.Iterable` of challenge types (subclasses of :class:`acme.challenges.Challenge`) with the most diff --git a/certbot/src/certbot/plugins/dns_common.py b/certbot/src/certbot/plugins/dns_common.py index 0dfefe720..60bd41379 100644 --- a/certbot/src/certbot/plugins/dns_common.py +++ b/certbot/src/certbot/plugins/dns_common.py @@ -54,7 +54,7 @@ class DNSAuthenticator(common.Plugin, interfaces.Authenticator, metaclass=abc.AB .format(name=self.name, secs=delay, suffix='s' if delay != 1 else '') ) - def get_chall_pref(self, unused_domain: str) -> Iterable[type[challenges.Challenge]]: # pylint: disable=missing-function-docstring + def get_chall_pref(self, unused_identifier: str) -> Iterable[type[challenges.Challenge]]: # pylint: disable=missing-function-docstring return [challenges.DNS01] def prepare(self) -> None: # pylint: disable=missing-function-docstring diff --git a/newsfragments/10465.added b/newsfragments/10465.added new file mode 100644 index 000000000..70bc3a922 --- /dev/null +++ b/newsfragments/10465.added @@ -0,0 +1 @@ +A new command line flag, --ip-address, has been added. This requests certificates with IP address SANs when using the standalone plugin. Note that for Let's Encrypt's implementation of IP address certificates, you'll also need to pass `--preferred-profile shortlived`. diff --git a/newsfragments/10495.changed b/newsfragments/10495.changed new file mode 100644 index 000000000..8fbfaba34 --- /dev/null +++ b/newsfragments/10495.changed @@ -0,0 +1 @@ +Authenticator.get_chall_pref's argument has been renamed from `domain` to `identifier`, and can now receive string-formatted IP addresses in addition to domain names.