Skip to content

Initial IP address support: use san.SAN types internally#10468

Merged
ohemorange merged 29 commits intocertbot:mainfrom
jsha:ip-addr
Nov 20, 2025
Merged

Initial IP address support: use san.SAN types internally#10468
ohemorange merged 29 commits intocertbot:mainfrom
jsha:ip-addr

Conversation

@jsha
Copy link
Copy Markdown
Contributor

@jsha jsha commented Sep 26, 2025

In #10478 we added a san.SAN class, with two subclasses san.DNSName and san.IPAddress, so we can carry type information about identifiers through the Certbot code. This PR plumbs through those types in most Certbot-internal code. Note that this does not change the acme module, which uses messages.Identifier. It also tries to leave alone the code paths into plugins.

This does not add a CLI flag to request an IP address certificate. That will be in a followup PR.

Part of #10346

@ohemorange
Copy link
Copy Markdown
Contributor

Took a look. What makes you say that combining and re-separating them will make the change more contained? It looks to me like this PR modifies each spot where domains might be in the first place, if only in the comments. From what I'm seeing, maybe just in storage.py having only the one names? Calling ipaddress.ip_address to separate them in the two times it's done isn't actually that bad, but I worry we'll forget to call it in new code in the future whereas for instance names (identifiers?) could combine two internal variables.

Actually, I'm tempted to suggest spinning up a few new types -- Domain = str, could even by automatically checked by our enforce domain sanity function, IPAddress or whatever as Union[ipaddress.IPv4Address, ipaddress.IPv6Address], both of which inherit from Identifier or whatever. Might be overengineering, but we've had bugs with ports being treated as int vs. str before, and this is literally what typechecking was made for. I think it's fine even if we have modify/add/deprecate public functions. What do you think?

@jsha
Copy link
Copy Markdown
Contributor Author

jsha commented Oct 7, 2025

What makes you say that combining and re-separating them will make the change more contained? It looks to me like this PR modifies each spot where domains might be in the first place, if only in the comments.

On first pass, it seemed daunting. In particular the public type signature of acme.crypto_util.make_csr is:

def make_csr(
    private_key_pem: bytes,
    domains: Optional[Union[set[str], list[str]]] = None,
    must_staple: bool = False,
    ipaddrs: Optional[list[Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]] = None,

And I didn't relish propagating that last big type everywhere. However, as you point out, using some custom types and adding/modifying/deprecating public functions can make this easier. I can take another pass and see how this looks.

I'm thinking something like class SAN. I'd want to avoiding the use of the term Identifier, to avoid confusing with the JSON-typed ACME concept. Then subclasses DNSName and IPAddress, the latter of which would be a union of IPv4Address and IPv6Address.

@jsha
Copy link
Copy Markdown
Contributor Author

jsha commented Oct 7, 2025

As I start this, I'm remembering the other thing that seemed daunting: updating all the mock setup in all the tests seems likely to be a pretty big change. I'll see how it goes.

@jsha jsha marked this pull request as ready for review October 10, 2025 22:04
@jsha
Copy link
Copy Markdown
Contributor Author

jsha commented Oct 10, 2025

Alright, I added new types in san.py: san.SAN, san.DNSName, san.IPAddress. The changes wound up touching a lot of places, but I think they made good sense sense. And indeed, mypy helped my find a bunch of places that I didn't realize needed updating.

I wound up drawing a boundary at the interface with installer plugins: they still provide / receive strings, for now. We'll likely want to change that, but that seemed like a good place to stop.

I like the idea of making san.DNSName represent a syntactically-valid, lowercased, whitespace-stripped domain name. I didn't do that in this PR because there is error handling around invalid domain name inputs, and I want to make sure those errors are still handled in a place that gives a nice message to the user.

I'm marking this "ready for review" even though I still only have a single integration test for IP address certs. I'd like to get feedback on all the san.SAN changes before I base too much work on top of it.

@bmw
Copy link
Copy Markdown
Member

bmw commented Oct 14, 2025

ohemorange asked me to take a high level look at this so we could all get on the same page before we got deep into the weeds of review here

my biggest concerns are around API changes and trying to ensure that all references to things like "domains" are properly updated if the value can now be an IP address

API changes

for API changes, everything in certbot/src/certbot outside of the certbot/src/certbot/_internal directory is technically considered part of our public API. (the backstory here is in the early days of certbot, we didn't make a clear distinction between public and private, we'd occasionally change code that broke things for 3rd parties, so we drew a clear line largely based around what our own separately packaged plugins use and wrote up our backwards compatibility expectations for users.)

for this PR as it sits right now, all changes to files like crypto_util.py, display/ops.py, interfaces.py, and util.py should ideally be made in a backwards compatible way or with warnings about upcoming changes in behavior that we then implement in a new major version of certbot. i'm not sure the best way to handle this and many of the decisions will probably have to be made on a case by case basis

one idea that unfortunately may be kind of frustrating to consider now is continuing to keep SANs as a list of strings. right now, many of the changes to type signature of public APIs just change the return value of a function from list[str] to list[SAN]. based on things like our standalone plugin, it seems like if some of these strings are suddenly IP addresses, a good chunk of code will just continue to work. these values will also only be IP addresses instead of strings if the user requests IP certs. with these things in mind, i personally think we could get away with saying that the list of strings possibly containing an IP address now isn't a breaking change. do we want to:

a) continue to use a list of strings which may (greatly?) simplify the transition, allow any code that can continue to work without changes, and let users who try to use IP addresses report bugs to us or 3rd parties as they come up
b) make everyone preemptively change their code to handle the new SANs type

i personally lean towards (a). i think the end result of (b) is cleaner, but it feels like significantly more work to me and i'm not sure it's worth it

i think similar things could be done by not renaming names to sans on lineages. i kinda hate it, but a SAN is a subject alternative NAME anyway so it's not wrong

i'm very curious what the two of you think though. if y'all both prefer (b), i'm not against it, i'm just not looking forward to the migration involved here

updating variable names

with all that said about maybe trying to avoid unnecessary changes, i do basically want us to never have a variable named something like domain or domains that actually now refers to an IP address in our own code. i think that's just a bug waiting to happen

i think that currently happens in code like this where identifier.value is assumed to be a domain because it always was before. this value then gets passed through our plugin API either directly or as an achall which (sometimes) has domain attributes

i think we should fix all these up as part of this IP address work. unfortunately the way i'd probably do it is looking through all references in our acme and certbot code for the string "domain", but one of y'all may have a cleverer idea

@jsha, you probably already know this but just to make sure, i'd recommend waiting until the 3 of us are on the same page here before you do much more work implementing this

@jsha
Copy link
Copy Markdown
Contributor Author

jsha commented Oct 14, 2025

all changes to files like crypto_util.py, display/ops.py, interfaces.py, and util.py should ideally be made in a backwards compatible way or with warnings about upcoming changes in behavior that we then implement in a new major version of certbot. i'm not sure the best way to handle this and many of the decisions will probably have to be made on a case by case basis

Oop, yes, I got carried away and made incompatible changes to some functions in these files. Still, that was useful in terms of forcing errors when things called the old names / passed the old types.

If we go forward with the re-names / applying types, my proposal for these "externally facing" items would be to keep the new item with the new name (since most have been renamed), and restore the old one with the old name, marking it as deprecated. I think that'll be better than trying to keep old function names and adding optional / defaulted parameters. Defaulted parameters can wind up creating a lot of complexity that's hard to reason about, and then more difficult cleanup after a deprecation period (if we do ever clean up).

one idea that unfortunately may be kind of frustrating to consider now is continuing to keep SANs as a list of strings.

No worries about being frustrating. That's basically the first version of this PR, and we can revert to that commit easily. I knew when I set out on this type journey that the work was speculative.

if some of these strings are suddenly IP addresses, a good chunk of code will just continue to work

Yes, this is true. Although I was surprised about some of the places I stumbled on, like this sort function that sorts by second-level domain:

def _sort_names(FQDNs: Iterable[str]) -> list[str]:
"""Sort FQDNs by SLD (and if many, by their subdomains)
:param list FQDNs: list of domain names
:returns: Sorted list of domain names
:rtype: list
"""
return sorted(FQDNs, key=lambda fqdn: fqdn.split('.')[::-1][1:])
. Well, maybe that was the only one that actually depended on domain-name semantics.

b) make everyone preemptively change their code to handle the new SANs type

I think we're in agreement that we should not break the public API without a deprecation period. That requires some further diffs to this PR but nothing complicated.

For plugins and hooks, I've tried to keep the interfaces be strings containing domain names. The new san.SAN typing allows us to prevent IP addresses from reaching the plugins until we've decided how we want that to work.

i think similar things could be done by not renaming names to sans on lineages. i kinda hate it, but a SAN is a subject alternative NAME anyway so it's not wrong

Based on my proposal above I'd re-add RenewableCert.names, still returning a list of strings (containing both domain names and IP addresses) and keep RenewableCert.sans as the new thing. FWIW, renaming .names() was really helpful in making sure I found all the spots that called it and assumed domain names.

i do basically want us to never have a variable named something like domain or domains that actually now refers to an IP address in our own code.

The changes here to have a specific named type for "could be a domain name or an IP address" really helped a lot with this. I won't say I caught every instance (e.g. I missed that achall thing you pointed out), but I caught a lot more than I did with the first pass of this PR.

IMO, this is an argument in favor of using the new san.SAN type. If we're already taking the code churn of renaming so many variables (and function names), I'd like to also update type information to help us keep those renames straight. "Use mypy" is my best clever idea here. :D Also the __eq__ implementations in san.py that error on comparisons between san.DNSName and str helped catch a lot of things. Though probably mypy would have caught those first if my workflow had been "fix mypy, then fix tests" instead of "fix tests, then fix mypy".

@bmw
Copy link
Copy Markdown
Member

bmw commented Oct 14, 2025

If we go forward with the re-names / applying types, my proposal for these "externally facing" items would be to keep the new item with the new name (since most have been renamed), and restore the old one with the old name, marking it as deprecated. I think that'll be better than trying to keep old function names and adding optional / defaulted parameters.

i agree. this works for me!

i do basically want us to never have a variable named something like domain or domains that actually now refers to an IP address in our own code.

The changes here to have a specific named type for "could be a domain name or an IP address" really helped a lot with this. I won't say I caught every instance (e.g. I missed that achall thing you pointed out), but I caught a lot more than I did with the first pass of this PR.

that's helpful to know. after i found domain variables that now are now sometimes IPs that slipped thru, i just assumed it wasn't helping that much. i'm glad to hear that's wrong

in that case, if you and/or ohemorange are still up for going the route of the bigger cleanup which will almost certainly be nicer for us in the end, this high level approach sounds good to me

@jsha
Copy link
Copy Markdown
Contributor Author

jsha commented Oct 14, 2025

By the way, looking again at the example you pointed out, of AuthHandler and handle_authorizations / _choose_challenges, the reason adding the san.SAN type everywhere didn't catch those is that the internally-typed information from Certbot has passed through the acme module, losing its own types in favor of acme types. So perhaps a good takeaway is that the acme module also has some hidden assumptions about exclusively handling domain names, and those should be comprehensively addressed as well. If so, should it be as a precursor PR, follow-on, included in this PR, or completely independent?

@bmw
Copy link
Copy Markdown
Member

bmw commented Oct 15, 2025

i personally think it should be a separate PR if splitting it up isn't too difficult. i don't expect splitting it up to be too bad, but i could certainly be wrong

i don't have a strong preference on the ordering though

@zoracon zoracon requested review from a team and bmw and removed request for a team October 16, 2025 21:30
@bmw bmw self-assigned this Oct 16, 2025
@jsha jsha mentioned this pull request Oct 16, 2025
@bmw bmw requested review from a team and bmw and removed request for a team November 15, 2025 00:19
bmw
bmw previously approved these changes Nov 15, 2025
Copy link
Copy Markdown
Member

@bmw bmw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm! let's get a 2nd reviewer in here

@bmw bmw requested review from a team and ohemorange and removed request for a team November 15, 2025 00:20
@bmw
Copy link
Copy Markdown
Member

bmw commented Nov 15, 2025

ohemorange, just in case it helps as it initially tripped me up a bit for whatever reason, i think our change to certbot/src/certbot/configuration.py is ok here because config.namespace.domains are now san.DNSName objects which already had their sanity enforced as part of their __init__ process

Copy link
Copy Markdown
Contributor

@ohemorange ohemorange left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow, appreciate you taking the time to do such thorough changes!

_handle_unexpected_key_type_migration(config, lineage)
_ask_user_to_confirm_new_names(config, domains, certname,
lineage.names()) # raises if no
_ask_user_to_confirm_new_sans(config, sans, certname,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just noting that something you switch "names" to "sans" and sometimes you leave it as "names." I think it's fine either way, but noting it in case you had an opinion and didn't notice you missed some, or had a preference here. see _find_lineage_for_sans for more names-keeping.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _find_lineage_for_sans, you're talking about this line?

    ident_names_cert, subset_names_cert = cert_manager.find_duplicative_certs(config, sans)

That makes sense; I mostly was updating function names that contained names to instead contain sans, when I updated those functions to have different parameter types or return values. Since these variables didn't have a type involving san.SAN I would have missed them.

I note that you say "more names-keeping", but I can't find the names-keeping on this line. Is there some?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the line I meant, there were others as well -- for example find_duplicative_certs keeps variables with names in them. config.allow_subset_of_names is another big one.

No, that's a mistype, this line is just what made me think of it. It should have said "some names-keeping."

Anyway, sounds like you didn't intend to change every instance of names to sans and just missed some accidentally, in which case I'm find with using names and sans largely interchangeably.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you want to switch everything over to sans or nah? if not I'll go ahead and merge

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna leave remaining places that say "names" as-is, at least for now. Thanks for checking.

@ohemorange ohemorange merged commit d638200 into certbot:main Nov 20, 2025
13 checks passed
ohemorange pushed a commit that referenced this pull request Nov 26, 2025
if you dislike the general idea of this PR, feel free to just close it,
but i'm scheduled to release the next version of certbot a week from
today and i personally didn't like how
[newsfragments](https://github.com/certbot/certbot/tree/main/newsfragments)
is so empty despite us having done a lot of work on certbot lately

this PR just adds a simple newsfragment highlighting/teasing the work
jsha has been leading on support for IP address certificates which i
imagine would be of interest to some people in the community

```
$ towncrier build --draft --version 5.2.0
Loading template...
Finding news fragments...
Rendering news fragments...
Draft only -- nothing has been written.
What is seen below is what would be written.

## 5.2.0 - 2025-11-25

### Added

- Support for Python 3.14 was added.
  ([#10477](#10477))

### Changed

- While nothing significant should have changed from the user's perspective,
  we've been doing a lot of internal refactoring in preparation for soon adding
  support for IP address certificates to Certbot.
  ([#10468](#10468),
  [#10478](#10478))
```
ohemorange pushed a commit that referenced this pull request Dec 3, 2025
see the thread at
https://opensource.eff.org/eff-open-source/pl/f5yx4a4q4j8zjyqpmath494jge
for details

since it's only the `v5.2.0` github tag that's borked, we could in
theory try and use like `v5.2.0-2` or something, but there are
[places](https://github.com/certbot/certbot/blob/259dfadb43cc52cb06ba57883766497faaf72b59/.azure-pipelines/release.yml#L10)
in the release pipeline that use the GH tag as input to the assets they
build, so i think just skipping 5.2.0 altogether is simpler, easier, and
safer

with this change, here's the proposed changelog
```
$ towncrier build --draft --version 5.2.1
Loading template...
Finding news fragments...
Rendering news fragments...
Draft only -- nothing has been written.
What is seen below is what would be written.

## 5.2.1 - 2025-12-02

### Added

- Support for Python 3.14 was added.
  ([#10477](#10477))

### Changed

- While nothing significant should have changed from the user's perspective,
  we've been doing a lot of internal refactoring in preparation for soon adding
  support for IP address certificates to Certbot.
  ([#10468](#10468),
  [#10478](#10478))

### Fixed

- Removed `vhost_combined` and `vhost_common` log formats from included Apache
  configuration file. ([#9769](#9769))
- Due to a mistake on our end playing with GitHub's new [immutable
  releases](https://github.blog/changelog/2025-10-28-immutable-releases-are-now-generally-available/)
  feature that prevented our CI from uploading additional release assets,
  Certbot 5.2.0 was not and will not be uploaded to most platforms. Instead,
  that version number will be skipped and we'll go straight to 5.2.1.
  ([#10501](#10501))
ohemorange added a commit that referenced this pull request Dec 5, 2025
This field is optional to maintain backwards compatibility. Note that
`AnnotatedChallenge` inherits from `jose.ImmutableMap`, which has a
[check in
__init__](https://github.com/certbot/josepy/blob/4b747476703fe4fff1aaccd76ebe570698bbf4f0/src/josepy/util.py#L125-L131)
that all slots are provided. That check would not allow us to do a
backwards-compatible addition, so I implemented an `__init__` for each
of these subclasses that fills the fields without calling the parent
`__init__`, and so doesn't hit an error when `identifier` is absent.

I chose to use `acme.messages.Identifier` rather than
`certbot._internal.san.SAN` here because these are wrapped ACME types,
so they should use the ACME representation. Also, `AnnotatedChallenge`
is passed to plugins, so we need to pass a type that the plugins can
understand.

Additionally, `domain` is marked as deprecated.

Part of #10346

/cc @bmw, who noticed the issue with `AnnotatedChallenge`
[here](#10468 (comment))
and provided additional feedback
[here](jsha#2 (comment)).
Note that there's still some work to do to finish excising `domain`
assumptions from this portion of the code.

---------

Co-authored-by: ohemorange <ebportnoy@gmail.com>
bmw added a commit that referenced this pull request Dec 8, 2025
@jsha jsha mentioned this pull request Dec 9, 2025
ohemorange pushed a commit that referenced this pull request Dec 9, 2025
Fixes #10506.

When --webroot-path was specified multiple times, Certbot was erroring
with `DNSName SAN compared to non-SAN`. That's because, in the
_WebrootPathAction that builds `namespace.webroot_path`, we were passing
`domain` (type `san.DNSName`) as the keys. The other code that modifies
or accesses `namespace.webroot_path` expects the keys to be of type
`str`. In particular `webroot.Authenticator._set_webroots` does:

```python
            for achall in achalls:
                self.conf("map").setdefault(achall.domain, webroot_path)
```

Where `achall.domain` is a `str`.

Two existing unittests would have caught this: `test_multiwebroot` and
`test_webroot_map_partial_without_perform`. However, they faked out the
parsing of the `--domains` flag, and that faked out code was not updated
in #10468. Since this bug is caused by an interaction between the types
produced by the `--domains` flag and those produced by the
`--webroot-path` flag, the tests failed to catch the problem. I've
updated the tests and confirmed that they fail before the fix is
applied.
wgreenberg pushed a commit that referenced this pull request Dec 10, 2025
Fixes #10506.

When --webroot-path was specified multiple times, Certbot was erroring
with `DNSName SAN compared to non-SAN`. That's because, in the
_WebrootPathAction that builds `namespace.webroot_path`, we were passing
`domain` (type `san.DNSName`) as the keys. The other code that modifies
or accesses `namespace.webroot_path` expects the keys to be of type
`str`. In particular `webroot.Authenticator._set_webroots` does:

```python
            for achall in achalls:
                self.conf("map").setdefault(achall.domain, webroot_path)
```

Where `achall.domain` is a `str`.

Two existing unittests would have caught this: `test_multiwebroot` and
`test_webroot_map_partial_without_perform`. However, they faked out the
parsing of the `--domains` flag, and that faked out code was not updated
in #10468. Since this bug is caused by an interaction between the types
produced by the `--domains` flag and those produced by the
`--webroot-path` flag, the tests failed to catch the problem. I've
updated the tests and confirmed that they fail before the fix is
applied.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants