feat(client): autotls cert for IP without broker - #94
Draft
lidel wants to merge 10 commits into
Draft
Conversation
A node that listens on TCP port 443 and is dialable there can get a certificate for its own IP address straight from the CA (RFC 8738), so browsers reach it over Secure WebSockets with no broker, no DNS name and no third party in the path. Validation is the ACME TLS-ALPN-01 challenge, answered by the WebSocket TLS listener the node already runs on that port. A node on any other port, or one the CA cannot reach, keeps using the broker as before. - IP subjects get their own certmagic config over the shared cache: they need a different issuer and the CA's short-lived profile, while a single tls.Config still has to serve both kinds of certificate. The cache routes renewals by subject, so an IP certificate never renews through a broker that has no name to publish a record under. - the ACME ALPN is no longer stripped from the TLS config. Without it the CA's handshake is rejected before any certificate is offered, and the challenge cannot be answered at all. - issuance is paced to stay inside the CA's rate limits: one bounded attempt, then an hour of backoff doubling to a day, written to storage before each attempt so a crash loop cannot spend the budget one restart at a time. - a node whose public address is not on any of its interfaces, which is the normal case behind a router, is given time to learn that address from other peers before it is written off as needing the broker. - announcements prefer /ipX/<ip>/tcp/<port>/tls/ws once a certificate exists, and withhold a TLS listener that has none. A certificate already in storage is still served while the direct path is tried.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #94 +/- ##
==========================================
+ Coverage 68.69% 71.79% +3.10%
==========================================
Files 22 23 +1
Lines 1805 2241 +436
==========================================
+ Hits 1240 1609 +369
- Misses 437 481 +44
- Partials 128 151 +23 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Mirrors TestLibp2pACMEE2E for the WithIPCerts path. A node listening on the port the CA validates on certifies its own address against an in-process Pebble, announces it, and serves it to a second libp2p host that dials it. The certificate is checked on a handshake that carries no SNI, the way a client dialing an address makes it, and has to come back with one IP and no names on it. The registration endpoint is a stub that fails the test if anything reaches it, so "no broker" is asserted rather than assumed. The test caught a fallback the unit tests could not see. certmagic caches a certificate from its own goroutine, so for a moment after a successful issuance nothing was cached for the address yet and the node decided it needed the broker after all. Holding nothing is now distinct from holding a certificate that has run out, and only the latter sends a node back to the broker. The port is the one thing the test cannot reproduce: a CA only ever connects to 443, which no test can bind, so Pebble is pointed at the port the node did get.
Candidate addresses came only from what the host discovered about itself, so an address it was told to announce and could not have found on its own was invisible. That is the normal setup in a container, or on a host whose public address lives on a router, and such a node never qualified for a certificate: it sat waiting for an address it would never discover. Candidates now come from both sources. A discovered address is still dropped when libp2p reports it unreachable, since validating it would only spend the CA's failure budget. An announced one is left alone: it is a deliberate statement about reachability, and being wrong about it costs a backed-off retry.
lidel
added a commit
to ipfs/kubo
that referenced
this pull request
Jul 26, 2026
A node with a publicly dialable /tcp/443 listener now gets its TLS certificate for its own IP address, straight from Let's Encrypt, instead of registering a name with the libp2p.direct broker. Browsers reach it at /ip4/<ip>/tcp/443/tls/ws with no DNS lookup and nobody else in the path, and HTTPProvider serves the trustless gateway behind the same certificate on the same port. The only thing to configure is the listener. The ACME challenge that proves an address is defined on port 443 and nowhere else, so a node on any other port, or one the authority cannot reach, keeps using the broker exactly as before. - AutoTLS.IPCerts, on by default, opts back out to the broker path - AutoTLS.IPCertsPort is an advanced knob for a local ACME server; a public authority connects to 443 and ignores anything else p2p-forge and boxo are pinned to unreleased commits from ipshipyard/p2p-forge#94 and ipfs/boxo#1196 until those land.
8 tasks
Three ways a node could get stuck, none of them reachable from the tests
that existed.
A CA that does not implement the draft ACME profiles extension rejects
any order that names one, and the profile could not be turned off: an
empty string was replaced by the default. It is a pointer now, so
WithIPCertProfile("") asks for no profile, which is what a private or
self-hosted CA needs. Let's Encrypt still gets the short-lived profile
its address certificates require.
WithAllowPrivateForgeAddrs skipped the wait for an address to turn up,
not just the connectivity checks it is meant to skip. Since the decision
to use the broker is taken once and never revisited, a first pass that
happened to run before the host had addresses pinned such a node to the
broker for the rest of the process. The wait now applies to everyone; a
node with no listener on the port still falls back at once.
The wait recorded before an attempt was a two-minute placeholder rather
than the backoff the attempt would earn, so a node dying mid-attempt
came back and tried again every two minutes. It is the real backoff now,
and stopping cleanly puts back whatever wait the address had, so a
restart during an attempt costs nothing.
A host that listens where a certificate authority validates certifies its own address, and one that does not registers a name with the broker. The decision is taken once, from a fact the host knows about itself, and it is never revisited: when the direct path cannot deliver, that is an error in the log and a retry under the existing backoff, not a quiet switch to a brokered name nobody asked for. That deletes the whole fallback state machine, which had turned out to be the subtlest part of this: every bug found in it so far was in the timing of "is it time to give up yet", a question there is now no need to ask. The decision needs a listener that can actually answer the challenge, so it looks for one that terminates TLS on the port rather than any TCP listener on the right number, and it ignores loopback binds, which no public authority can reach. Also here, because removing the fallback makes each of them the difference between a certificate and none: - a certificate that ran out and was not renewed is taken back and asked for again. Obtaining would not have replaced it: certmagic treats a name whose files are in storage as settled however stale they are, so this asks for a renewal. - addresses libp2p confirmed reachable are matched on their IP and port rather than on the whole multiaddr, so one is not discarded for carrying a TLS WebSocket suffix our copy of it does not have. - while the direct path is in play, a TLS listener with no certificate yet is withheld from announcements, and a host that took the broker path announces its listeners as libp2p reports them. - the certificate callbacks fire for addresses too, not only for the brokered name. Reporting, since nothing else will now say it: a host that listens on the port but has no public address there logs an error naming the port and what to check, after a couple of minutes' grace and hourly from then on.
macOS has only 127.0.0.1 on lo0 unless somebody adds an alias, and CI runs there, so binding 127.0.0.2 would have failed outside Linux. The case that needed its own address now runs before the parallel ones instead, which gives it 127.0.0.1 to itself and keeps two cases from overwriting each other in certmagic's process-wide challenge map.
The previous commit described this change but only landed half of it: the field it keys on went in, the case still asked for 127.0.0.2 and still ran in parallel. macOS has only 127.0.0.1 on lo0, so CI caught it with a bind failure the moment the host started.
The factory holds back a TLS WebSocket address on the certify port while there is no certificate for it, and the manager read its candidates from the announced set, so a host whose only address there was that one could hide it from itself: dropped by the factory, never offered back, never certified. kubo happens to be safe, since it applies its announce override after this factory and replaces the set, but nothing should rest on a consumer's ordering. The factory now hands back what it held. The error that follows says which of the two situations a host is in. One with public addresses on other ports is told the public port has to be the certify port too, and which ports were seen; one with no public address at all is told to check that the port is open and forwarded. The first used to be advised to check a forward it had already made. Also: - a handshake that names something is left wholly to certmagic again. This selector sits on the brokered config too, where it had started refusing an expired certificate that certmagic would have served, and changing that path is not its business. - backoff and certificate lookups moved out of the locked region, since a storage backend can be slow or remote. - one certExpired, rather than two names for one body. - a changelog entry, which this has wanted since the first commit.
lidel
added a commit
to ipfs/kubo
that referenced
this pull request
Jul 28, 2026
Picks up the review round on ipshipyard/p2p-forge#94: an address the address factory holds back is still offered for certification, the error for a node that cannot be validated says whether the port is unreachable or merely the wrong one, and a named TLS handshake is left to certmagic as it was before this feature existed.
Merging main brought #95, which found that an expired certificate cannot be renewed at all: the authority rejects an order whose ARI 'replaces' names a certificate it no longer considers current, so certmagic retries a doomed renewal forever. The address path had exactly that bug, reaching for a renewal when the certificate in storage had lapsed. It discards and issues fresh now, through the helper #95 added.
lidel
added a commit
to ipfs/kubo
that referenced
this pull request
Jul 28, 2026
Picks up the review round on ipshipyard/p2p-forge#94: an address the address factory holds back is still offered for certification, the error for a node that cannot be validated says whether the port is unreachable or merely the wrong one, and a named TLS handshake is left to certmagic as it was before this feature existed. Also brings ipshipyard/p2p-forge#95 through that branch, so an expired certificate is discarded rather than renewed, on the address path as well as the brokered one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Warning
Still a draft. The flow has end-to-end coverage against an in-process Pebble, for IPv4, IPv6 and renewal, and the wiring is exercised from ipfs/kubo#11333, but nobody has pointed it at Let's Encrypt yet.
Problem
A host with a public IP and an open port 443 has no simple path to HTTPS. To be reachable from a browser it first has to get a name from the broker: register, wait for DNS, and hope that service is up. That is a lot of machinery for a node that already has an address of its own.
Fix
WithIPCerts()asks the CA for a certificate covering the host's own IP address, over the ACME TLS-ALPN-01 challenge./ipX/<ip>/tcp/443/tls/ws, so a browser dials the address with no name to resolveWithIPCertPortandWithIPCertProfilecover what a non-public CA needs: the port it validates on, and turning off theshortlivedprofile that Let's Encrypt requires and a private CA may not offerPort 443 is a requirement rather than a preference. TLS-ALPN-01 runs there and nowhere else1, and an address can use only that challenge or
http-01, never the DNS-01 the broker answers2.The listener picks the path, and there is no crossing over. With one on 443 a host only ever asks for its own address, and when that fails it says so and retries rather than quietly registering a name nobody asked for. Without one, it uses the broker as before.
Certificates covering an address last about six days, so a host has to stay online to keep renewing. Requests are paced to stay inside the CA's rate limits, and the pace is written to storage so a host that keeps restarting cannot spend its budget one restart at a time.
Footnotes
RFC 8737, section 3: the validation connection from the CA MUST use TCP port 443. ↩
RFC 8738, section 4: IP identifiers may be used with
http-01andtls-alpn-01, and nothing else. Let's Encrypt narrows it further, issuing for addresses only under its short-lived profile. ↩