Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions client/acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand All @@ -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)
Expand Down
50 changes: 47 additions & 3 deletions client/challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
107 changes: 107 additions & 0 deletions client/challenge_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"version": "v0.8.0"
"version": "v0.8.1"
}
Loading