Skip to content

feat(client): autotls cert for IP without broker - #94

Draft
lidel wants to merge 10 commits into
mainfrom
feat/ip-certs
Draft

feat(client): autotls cert for IP without broker#94
lidel wants to merge 10 commits into
mainfrom
feat/ip-certs

Conversation

@lidel

@lidel lidel commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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.

  • port 443 already carries libp2p, browser WebSockets and plain HTTPS at once (feat(websocket): share port with HTTP handler libp2p/go-libp2p#3509), so one listener does all of it
  • the CA validates the address by connecting back to that port and starting a TLS handshake, which the same listener answers, so nothing extra is exposed and no second port is opened
  • the TLS config keeps the ACME protocol name in its ALPN list, without which that handshake is refused and validation can never pass
  • the host announces /ipX/<ip>/tcp/443/tls/ws, so a browser dials the address with no name to resolve
  • WithIPCertPort and WithIPCertProfile cover what a non-public CA needs: the port it validates on, and turning off the shortlived profile that Let's Encrypt requires and a private CA may not offer

Port 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

  1. RFC 8737, section 3: the validation connection from the CA MUST use TCP port 443.

  2. RFC 8738, section 4: IP identifiers may be used with http-01 and tls-alpn-01, and nothing else. Let's Encrypt narrows it further, issuing for addresses only under its short-lived profile.

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

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.99548% with 84 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.79%. Comparing base (1743bb9) to head (75444c9).

Files with missing lines Patch % Lines
client/ipcert.go 81.25% 45 Missing and 27 partials ⚠️
client/acme.go 79.31% 10 Missing and 2 partials ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lidel lidel changed the title feat(client): certify the node's own IP address feat(client): autotls cert for IP without broken Jul 25, 2026
@lidel lidel changed the title feat(client): autotls cert for IP without broken feat(client): autotls cert for IP without broker Jul 25, 2026
lidel added 2 commits July 25, 2026 22:53
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.
lidel added 5 commits July 28, 2026 22:58
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.
lidel added 2 commits July 29, 2026 01:40
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.
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.

1 participant