From 430ae7225b6a281eae52217eb41a474f7a2002c3 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 16 May 2026 15:28:41 +0200 Subject: [PATCH 1/2] feat(client): add WithHTTPClient option Test harnesses that run an in-process forge on a loopback address need to override resolution or trust a different CA for the registration endpoint, without losing the PeerID-auth signature scope. The new WithHTTPClient option lets callers thread a configured *http.Client through the DNS-01 challenge POST. SendChallenge keeps its existing signature and forwards to a new exported SendChallengeWithClient that takes the *http.Client; downstream callers are unaffected. --- CHANGELOG.md | 5 +++++ client/acme.go | 26 +++++++++++++++++++++----- client/challenge.go | 19 +++++++++++++++++-- version.json | 2 +- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ddd763..39d96fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +## [v0.8.1] - 2026-05-16 + +### Added +- ✨ `client.WithHTTPClient(*http.Client)` option on `P2PForgeCertMgr` and a matching `client.SendChallengeWithClient` exported function. Lets callers supply a custom `*http.Client` (with a custom `Transport`, resolver, or root CAs) for the DNS-01 challenge POST to the forge registration endpoint. Useful for test harnesses that run an in-process forge on a loopback address while the PeerID-auth signature must stay scoped to the production registration hostname. The existing `client.SendChallenge` signature is preserved and now forwards to `SendChallengeWithClient` with `http.DefaultClient`, so existing callers are unaffected. ([#87](https://github.com/ipshipyard/p2p-forge/pull/87)) + ## [v0.8.0] - 2026-04-14 ### Changed diff --git a/client/acme.go b/client/acme.go index ccd06be..4552073 100644 --- a/client/acme.go +++ b/client/acme.go @@ -98,6 +98,7 @@ type P2PForgeCertMgrConfig struct { trustedRoots *x509.CertPool storage certmagic.Storage modifyForgeRequest func(r *http.Request) error + httpClient *http.Client onCertLoaded func() onCertRenewed func() log *zap.SugaredLogger @@ -169,14 +170,26 @@ func WithUserAgent(userAgent string) P2PForgeCertMgrOptions { } } -/* -// WithHTTPClient sets a custom HTTP Client to be used when talking to registration endpoint. -func WithHTTPClient(h httpClient) error { +// WithHTTPClient sets the *http.Client used when talking to the forge +// registration endpoint. The default is http.DefaultClient. +// +// This is the lowest-level seam: callers can swap in a client with a custom +// Transport (custom resolver, rewritten dial address, alternate root CAs for +// the registration endpoint itself, etc.). Useful for test harnesses that run +// an in-process forge on a loopback address while the PeerID-auth signature +// must still be scoped to the production registration hostname. +// +// The client's Timeout, Transport, CheckRedirect, and Jar are honored as-is; +// PeerID auth is layered on top via httppeeridauth.ClientPeerIDAuth. +func WithHTTPClient(c *http.Client) P2PForgeCertMgrOptions { return func(config *P2PForgeCertMgrConfig) error { + if c == nil { + return fmt.Errorf("WithHTTPClient: client must not be nil") + } + config.httpClient = c return nil } } -*/ // WithModifiedForgeRequest enables modifying how the ACME DNS challenges are sent to the forge, such as to enable // custom HTTP headers, etc. @@ -363,6 +376,7 @@ func NewP2PForgeCertMgr(opts ...P2PForgeCertMgrOptions) (*P2PForgeCertMgr, error forgeAuth: mgrCfg.forgeAuth, hostFn: mgr.hostFn, modifyForgeRequest: mgrCfg.modifyForgeRequest, + httpClient: mgrCfg.httpClient, userAgent: mgrCfg.userAgent, allowPrivateForgeAddresses: mgrCfg.allowPrivateForgeAddresses, log: acmeLog.Named("dns01solver"), @@ -623,6 +637,7 @@ type dns01P2PForgeSolver struct { forgeAuth string hostFn func() host.Host modifyForgeRequest func(r *http.Request) error + httpClient *http.Client userAgent string allowPrivateForgeAddresses bool log *zap.SugaredLogger @@ -701,7 +716,7 @@ func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challe d.log.Debugw("advertised libp2p addrs for p2p-forge broker to try", "addrs", advertisedAddrs) d.log.Debugw("asking p2p-forge broker to set DNS-01 TXT record", "url", d.forgeRegistrationEndpoint, "dns01_value", dns01value) - err := SendChallenge(ctx, + err := SendChallengeWithClient(ctx, d.forgeRegistrationEndpoint, h.Peerstore().PrivKey(h.ID()), dns01value, @@ -709,6 +724,7 @@ func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challe d.forgeAuth, d.userAgent, d.modifyForgeRequest, + d.httpClient, ) if err != nil { return fmt.Errorf("p2p-forge broker registration error: %w", err) diff --git a/client/challenge.go b/client/challenge.go index 305e841..f82558c 100644 --- a/client/challenge.go +++ b/client/challenge.go @@ -17,7 +17,19 @@ import ( // SendChallenge submits value for DNS-01 challenge to the p2p-forge HTTP server for the given peerID. // It requires the corresponding private key and a list of multiaddresses that the peerID is listening on using // publicly reachable IP addresses. +// +// SendChallenge uses http.DefaultClient; callers that need a custom Transport +// (e.g. to override resolution, rewrite the dial address, or trust an +// alternate CA for the registration endpoint itself) should use +// SendChallengeWithClient. func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, challenge string, addrs []multiaddr.Multiaddr, forgeAuth string, userAgent string, modifyForgeRequest func(r *http.Request) error) error { + return SendChallengeWithClient(ctx, baseURL, privKey, challenge, addrs, forgeAuth, userAgent, modifyForgeRequest, nil) +} + +// SendChallengeWithClient is SendChallenge with a caller-supplied *http.Client. +// If httpClient is nil, http.DefaultClient is used. PeerID auth is layered on +// top via httppeeridauth.ClientPeerIDAuth. +func SendChallengeWithClient(ctx context.Context, baseURL string, privKey crypto.PrivKey, challenge string, addrs []multiaddr.Multiaddr, forgeAuth string, userAgent string, modifyForgeRequest func(r *http.Request) error, httpClient *http.Client) error { // Create request registrationURL := fmt.Sprintf("%s/v1/_acme-challenge", baseURL) req, err := ChallengeRequest(ctx, registrationURL, challenge, addrs) @@ -39,9 +51,12 @@ func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, } } + if httpClient == nil { + httpClient = http.DefaultClient + } // Execute request wrapped in ClientPeerIDAuth - client := &httppeeridauth.ClientPeerIDAuth{PrivKey: privKey} - _, resp, err := client.AuthenticatedDo(http.DefaultClient, req) + authClient := &httppeeridauth.ClientPeerIDAuth{PrivKey: privKey} + _, resp, err := authClient.AuthenticatedDo(httpClient, req) if err != nil { return fmt.Errorf("libp2p HTTP ClientPeerIDAuth error at %s: %w", registrationURL, err) } diff --git a/version.json b/version.json index 0ad79e3..8047016 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "v0.8.0" + "version": "v0.8.1" } From e0f52a70d0b9406d8c691a076d93cb5f2f68dc70 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Sat, 16 May 2026 15:43:33 +0200 Subject: [PATCH 2/2] refactor(client): SendChallenge functional options Replace the SendChallengeWithClient variant with a trailing variadic opts ...SendChallengeOption on SendChallenge. Existing positional-only callers compile unchanged. The first option, WithChallengeHTTPClient, threads a caller-supplied *http.Client through the DNS-01 registration POST. Add tests covering option validation, the transport wiring through SendChallenge, and the positional-only backward-compat path. --- CHANGELOG.md | 2 +- client/acme.go | 18 ++++--- client/challenge.go | 51 +++++++++++++++---- client/challenge_test.go | 107 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 client/challenge_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d96fe..4fb7f35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [v0.8.1] - 2026-05-16 ### Added -- ✨ `client.WithHTTPClient(*http.Client)` option on `P2PForgeCertMgr` and a matching `client.SendChallengeWithClient` exported function. Lets callers supply a custom `*http.Client` (with a custom `Transport`, resolver, or root CAs) for the DNS-01 challenge POST to the forge registration endpoint. Useful for test harnesses that run an in-process forge on a loopback address while the PeerID-auth signature must stay scoped to the production registration hostname. The existing `client.SendChallenge` signature is preserved and now forwards to `SendChallengeWithClient` with `http.DefaultClient`, so existing callers are unaffected. ([#87](https://github.com/ipshipyard/p2p-forge/pull/87)) +- ✨ `client.WithHTTPClient(*http.Client)` option on `P2PForgeCertMgr` and a matching `client.WithChallengeHTTPClient(*http.Client)` option for `client.SendChallenge`. Lets callers supply a custom `*http.Client` (with a custom `Transport`, resolver, or root CAs) for the DNS-01 challenge POST to the forge registration endpoint. Useful for test harnesses that run an in-process forge on a loopback address while the PeerID-auth signature must stay scoped to the production registration hostname. `client.SendChallenge` gains a trailing variadic `opts ...SendChallengeOption` parameter; existing positional-only callers compile unchanged. ([#87](https://github.com/ipshipyard/p2p-forge/pull/87)) ## [v0.8.0] - 2026-04-14 diff --git a/client/acme.go b/client/acme.go index 4552073..9a41ab2 100644 --- a/client/acme.go +++ b/client/acme.go @@ -173,11 +173,11 @@ func WithUserAgent(userAgent string) P2PForgeCertMgrOptions { // WithHTTPClient sets the *http.Client used when talking to the forge // registration endpoint. The default is http.DefaultClient. // -// This is the lowest-level seam: callers can swap in a client with a custom -// Transport (custom resolver, rewritten dial address, alternate root CAs for -// the registration endpoint itself, etc.). Useful for test harnesses that run -// an in-process forge on a loopback address while the PeerID-auth signature -// must still be scoped to the production registration hostname. +// Callers can supply a client with a custom Transport (custom resolver, +// rewritten dial address, alternate root CAs for the registration endpoint +// itself, etc.). Useful for test harnesses that run an in-process forge on a +// loopback address while the PeerID-auth signature must still be scoped to +// the production registration hostname. // // The client's Timeout, Transport, CheckRedirect, and Jar are honored as-is; // PeerID auth is layered on top via httppeeridauth.ClientPeerIDAuth. @@ -716,7 +716,11 @@ func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challe d.log.Debugw("advertised libp2p addrs for p2p-forge broker to try", "addrs", advertisedAddrs) d.log.Debugw("asking p2p-forge broker to set DNS-01 TXT record", "url", d.forgeRegistrationEndpoint, "dns01_value", dns01value) - err := SendChallengeWithClient(ctx, + var sendOpts []SendChallengeOption + if d.httpClient != nil { + sendOpts = append(sendOpts, WithChallengeHTTPClient(d.httpClient)) + } + err := SendChallenge(ctx, d.forgeRegistrationEndpoint, h.Peerstore().PrivKey(h.ID()), dns01value, @@ -724,7 +728,7 @@ func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challe d.forgeAuth, d.userAgent, d.modifyForgeRequest, - d.httpClient, + sendOpts..., ) if err != nil { return fmt.Errorf("p2p-forge broker registration error: %w", err) diff --git a/client/challenge.go b/client/challenge.go index f82558c..4b99d75 100644 --- a/client/challenge.go +++ b/client/challenge.go @@ -14,22 +14,49 @@ import ( "github.com/multiformats/go-multiaddr" ) +// SendChallengeOption configures a SendChallenge call. Options are applied +// in order; later options override earlier ones for the same field. +type SendChallengeOption func(*sendChallengeOptions) error + +type sendChallengeOptions struct { + httpClient *http.Client +} + +// WithChallengeHTTPClient sets the *http.Client used to issue the registration +// POST. The default is http.DefaultClient. +// +// Callers can supply a client with a custom Transport (custom resolver, +// rewritten dial address, alternate root CAs for the registration endpoint +// itself, etc.). Useful for test harnesses that run an in-process forge on a +// loopback address while the PeerID-auth signature must still be scoped to +// the production registration hostname. +// +// The client's Timeout, Transport, CheckRedirect, and Jar are honored as-is; +// PeerID auth is layered on top via httppeeridauth.ClientPeerIDAuth. +func WithChallengeHTTPClient(c *http.Client) SendChallengeOption { + return func(o *sendChallengeOptions) error { + if c == nil { + return fmt.Errorf("WithChallengeHTTPClient: client must not be nil") + } + o.httpClient = c + return nil + } +} + // SendChallenge submits value for DNS-01 challenge to the p2p-forge HTTP server for the given peerID. // It requires the corresponding private key and a list of multiaddresses that the peerID is listening on using // publicly reachable IP addresses. // -// SendChallenge uses http.DefaultClient; callers that need a custom Transport -// (e.g. to override resolution, rewrite the dial address, or trust an -// alternate CA for the registration endpoint itself) should use -// SendChallengeWithClient. -func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, challenge string, addrs []multiaddr.Multiaddr, forgeAuth string, userAgent string, modifyForgeRequest func(r *http.Request) error) error { - return SendChallengeWithClient(ctx, baseURL, privKey, challenge, addrs, forgeAuth, userAgent, modifyForgeRequest, nil) -} +// Optional SendChallengeOption values configure transport-level behavior such +// as the *http.Client used for the registration POST (see WithChallengeHTTPClient). +func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, challenge string, addrs []multiaddr.Multiaddr, forgeAuth string, userAgent string, modifyForgeRequest func(r *http.Request) error, opts ...SendChallengeOption) error { + o := sendChallengeOptions{} + for _, opt := range opts { + if err := opt(&o); err != nil { + return err + } + } -// SendChallengeWithClient is SendChallenge with a caller-supplied *http.Client. -// If httpClient is nil, http.DefaultClient is used. PeerID auth is layered on -// top via httppeeridauth.ClientPeerIDAuth. -func SendChallengeWithClient(ctx context.Context, baseURL string, privKey crypto.PrivKey, challenge string, addrs []multiaddr.Multiaddr, forgeAuth string, userAgent string, modifyForgeRequest func(r *http.Request) error, httpClient *http.Client) error { // Create request registrationURL := fmt.Sprintf("%s/v1/_acme-challenge", baseURL) req, err := ChallengeRequest(ctx, registrationURL, challenge, addrs) @@ -51,9 +78,11 @@ func SendChallengeWithClient(ctx context.Context, baseURL string, privKey crypto } } + httpClient := o.httpClient if httpClient == nil { httpClient = http.DefaultClient } + // Execute request wrapped in ClientPeerIDAuth authClient := &httppeeridauth.ClientPeerIDAuth{PrivKey: privKey} _, resp, err := authClient.AuthenticatedDo(httpClient, req) diff --git a/client/challenge_test.go b/client/challenge_test.go new file mode 100644 index 0000000..2d0315b --- /dev/null +++ b/client/challenge_test.go @@ -0,0 +1,107 @@ +package client + +import ( + "crypto/rand" + "errors" + "net/http" + "sync/atomic" + "testing" + + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestWithHTTPClient(t *testing.T) { + t.Run("rejects nil client", func(t *testing.T) { + cfg := &P2PForgeCertMgrConfig{} + err := WithHTTPClient(nil)(cfg) + require.Error(t, err) + require.Nil(t, cfg.httpClient) + }) + + t.Run("stores client on config", func(t *testing.T) { + cfg := &P2PForgeCertMgrConfig{} + c := &http.Client{} + require.NoError(t, WithHTTPClient(c)(cfg)) + require.Same(t, c, cfg.httpClient) + }) +} + +func TestWithChallengeHTTPClient(t *testing.T) { + t.Run("rejects nil client", func(t *testing.T) { + o := &sendChallengeOptions{} + err := WithChallengeHTTPClient(nil)(o) + require.Error(t, err) + require.Nil(t, o.httpClient) + }) + + t.Run("stores client on options", func(t *testing.T) { + o := &sendChallengeOptions{} + c := &http.Client{} + require.NoError(t, WithChallengeHTTPClient(c)(o)) + require.Same(t, c, o.httpClient) + }) +} + +// TestSendChallengeUsesProvidedClient locks down the wiring: the *http.Client +// supplied via WithChallengeHTTPClient must be the one that actually issues +// the registration request. Without this, a future refactor could silently +// drop the client and fall back to http.DefaultClient. +func TestSendChallengeUsesProvidedClient(t *testing.T) { + sk, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + + sentinel := errors.New("transport invoked") + var called atomic.Bool + var gotURL atomic.Value // string + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + called.Store(true) + gotURL.Store(req.URL.String()) + return nil, sentinel + }), + } + + err = SendChallenge( + t.Context(), + "http://forge.example.invalid", + sk, + "test-challenge-value", + nil, // no advertised addresses needed for this wiring test + "", // forgeAuth + "", // userAgent (falls back to default) + nil, // modifyForgeRequest + WithChallengeHTTPClient(httpClient), + ) + require.Error(t, err, "transport returns sentinel, so SendChallenge must fail") + require.True(t, called.Load(), "custom transport was not invoked; SendChallenge is ignoring the supplied client") + require.Equal(t, "http://forge.example.invalid/v1/_acme-challenge", gotURL.Load()) +} + +// TestSendChallengeBackwardCompatible verifies the legacy positional-only +// signature still compiles and runs when no options are supplied. +func TestSendChallengeBackwardCompatible(t *testing.T) { + sk, _, err := crypto.GenerateEd25519Key(rand.Reader) + require.NoError(t, err) + + // Unroutable address fails fast at dial time; we only care that the + // no-options call path still works (no panic, no compile change). + err = SendChallenge( + t.Context(), + "http://127.0.0.1:1", // reserved port, connection refused + sk, + "test-challenge-value", + nil, + "", + "", + nil, + ) + require.Error(t, err) +}