diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ddd763..4fb7f35 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.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 ### Changed diff --git a/client/acme.go b/client/acme.go index ccd06be..9a41ab2 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. +// +// 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 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,6 +716,10 @@ 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) + var sendOpts []SendChallengeOption + if d.httpClient != nil { + sendOpts = append(sendOpts, WithChallengeHTTPClient(d.httpClient)) + } err := SendChallenge(ctx, d.forgeRegistrationEndpoint, h.Peerstore().PrivKey(h.ID()), @@ -709,6 +728,7 @@ func (d *dns01P2PForgeSolver) Present(ctx context.Context, challenge acme.Challe d.forgeAuth, d.userAgent, d.modifyForgeRequest, + 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 305e841..4b99d75 100644 --- a/client/challenge.go +++ b/client/challenge.go @@ -14,10 +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. -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 { +// +// 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 + } + } + // Create request registrationURL := fmt.Sprintf("%s/v1/_acme-challenge", baseURL) req, err := ChallengeRequest(ctx, registrationURL, challenge, addrs) @@ -39,9 +78,14 @@ func SendChallenge(ctx context.Context, baseURL string, privKey crypto.PrivKey, } } + httpClient := o.httpClient + 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/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) +} 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" }