diff --git a/acme/api/handler.go b/acme/api/handler.go index 0722bd9b3..8d7c3b398 100644 --- a/acme/api/handler.go +++ b/acme/api/handler.go @@ -1,13 +1,14 @@ package api import ( - "context" - "crypto/x509" - "encoding/json" - "encoding/pem" - "fmt" - "net/http" - "time" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/url" + "time" "github.com/go-chi/chi/v5" @@ -120,17 +121,44 @@ func Route(r api.Router) { } func route(r api.Router, middleware func(next nextHTTP) nextHTTP) { - commonMiddleware := func(next nextHTTP) nextHTTP { - handler := func(w http.ResponseWriter, r *http.Request) { - // Linker middleware gets the provisioner and current url from the - // request and sets them in the context. - linker := acme.MustLinkerFromContext(r.Context()) - linker.Middleware(http.HandlerFunc(checkPrerequisites(next))).ServeHTTP(w, r) - } - if middleware != nil { - handler = middleware(handler) - } - return handler + // providerClient injecte un client ACME configuré selon le provisioner courant (proxy/DNS) + providerClient := func(next nextHTTP) nextHTTP { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // Le provisioner est fixé par linker.Middleware en amont + if acmeProv, err := acmeProvisionerFromContext(ctx); err == nil && acmeProv != nil { + var opts []acme.ClientOption + if acmeProv.ProxyURL != "" { + opts = append(opts, acme.WithProxyURL(acmeProv.ProxyURL)) + } + if acmeProv.DisableProxy { + // Désactive totalement le proxy (ignore les variables d'environnement) + opts = append(opts, acme.WithProxyFunc(func(req *http.Request) (*url.URL, error) { return nil, nil })) + } + if acmeProv.DNS != "" { + opts = append(opts, acme.WithDNS(acmeProv.DNS)) + } + if len(opts) > 0 { + c := acme.NewClient(opts...) + ctx = acme.NewClientContext(ctx, c) + } + } + next(w, r.WithContext(ctx)) + } + } + + commonMiddleware := func(next nextHTTP) nextHTTP { + handler := func(w http.ResponseWriter, r *http.Request) { + // Linker middleware gets the provisioner and current url from the + // request and sets them in the context. + linker := acme.MustLinkerFromContext(r.Context()) + // Après que linker.Middleware ait résolu le provisioner, injecter un client ACME spécifique au provider + linker.Middleware(http.HandlerFunc(providerClient(checkPrerequisites(next)))).ServeHTTP(w, r) + } + if middleware != nil { + handler = middleware(handler) + } + return handler } validatingMiddleware := func(next nextHTTP) nextHTTP { return commonMiddleware(addNonce(addDirLink(verifyContentType(parseJWS(validateJWS(next)))))) diff --git a/acme/client.go b/acme/client.go index 8f506ef95..23df161c2 100644 --- a/acme/client.go +++ b/acme/client.go @@ -1,11 +1,12 @@ package acme import ( - "context" - "crypto/tls" - "net" - "net/http" - "time" + "context" + "crypto/tls" + "net" + "net/http" + "net/url" + "time" ) // Client is the interface used to verify ACME challenges. @@ -45,27 +46,98 @@ func MustClientFromContext(ctx context.Context) Client { } type client struct { - http *http.Client - dialer *net.Dialer + http *http.Client + dialer *net.Dialer + // resolver is used for DNS lookups; defaults to net.DefaultResolver + resolver *net.Resolver } -// NewClient returns an implementation of Client for verifying ACME challenges. -func NewClient() Client { - return &client{ - http: &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{ - //nolint:gosec // used on tls-alpn-01 challenge - InsecureSkipVerify: true, // lgtm[go/disabled-certificate-check] - }, - }, - }, - dialer: &net.Dialer{ - Timeout: 30 * time.Second, - }, - } +// ClientOption configures the ACME client. +type ClientOption func(*client) + +// WithProxyURL configures the HTTP(S) proxy to use for ACME HTTP requests. +// Example: WithProxyURL("http://proxy.local:3128") or WithProxyURL("socks5://..."). +func WithProxyURL(proxyURL string) ClientOption { + return func(c *client) { + if tr, ok := c.http.Transport.(*http.Transport); ok { + if u, err := url.Parse(proxyURL); err == nil { + tr.Proxy = http.ProxyURL(u) + } + } + } +} + +// WithProxyFunc sets a custom proxy selection function, overriding environment variables. +func WithProxyFunc(fn func(*http.Request) (*url.URL, error)) ClientOption { + return func(c *client) { + if tr, ok := c.http.Transport.(*http.Transport); ok { + tr.Proxy = fn + } + } +} + +// WithResolver sets a custom DNS resolver to be used for both TXT lookups and dialing. +func WithResolver(r *net.Resolver) ClientOption { + return func(c *client) { + c.resolver = r + c.dialer.Resolver = r + } +} + +// WithDNS configures the client to use a specific DNS server for all lookups and dialing. +// The address should be in host:port form, e.g. "8.8.8.8:53". +func WithDNS(addr string) ClientOption { + return func(c *client) { + r := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := &net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, network, addr) + }, + } + c.resolver = r + c.dialer.Resolver = r + } +} + +// NewClientWithOptions returns an implementation of Client for verifying ACME challenges. +// It accepts optional ClientOptions to override proxy and DNS resolver behavior. +func NewClientWithOptions(opts ...ClientOption) Client { + d := &net.Dialer{Timeout: 30 * time.Second} + // Default transport uses environment proxy and our dialer so that custom resolver applies to HTTP too. + tr := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: d.DialContext, + TLSClientConfig: &tls.Config{ + //nolint:gosec // used on tls-alpn-01 challenge + InsecureSkipVerify: true, // lgtm[go/disabled-certificate-check] + }, + } + c := &client{ + http: &http.Client{ + Timeout: 30 * time.Second, + Transport: tr, + }, + dialer: d, + resolver: net.DefaultResolver, + } + + // Apply options + for _, opt := range opts { + opt(c) + } + // Ensure transport dialer is bound (in case options replaced dialer.resolver) + if tr2, ok := c.http.Transport.(*http.Transport); ok { + tr2.DialContext = c.dialer.DialContext + } + return c +} + +// NewClient returns an implementation of Client with default settings +// (proxy from environment and system DNS resolver). For custom configuration +// use NewClientWithOptions. +func NewClient(opts ...ClientOption) Client { // keep signature source-compatible for callers without options + return NewClientWithOptions(opts...) } func (c *client) Get(url string) (*http.Response, error) { @@ -73,7 +145,13 @@ func (c *client) Get(url string) (*http.Response, error) { } func (c *client) LookupTxt(name string) ([]string, error) { - return net.LookupTXT(name) + // Prefer custom resolver with a bounded timeout + if c.resolver != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return c.resolver.LookupTXT(ctx, name) + } + return net.LookupTXT(name) } func (c *client) TLSDial(network, addr string, config *tls.Config) (*tls.Conn, error) { diff --git a/authority/provisioner/acme.go b/authority/provisioner/acme.go index b014e6754..10255e2e1 100644 --- a/authority/provisioner/acme.go +++ b/authority/provisioner/acme.go @@ -84,14 +84,24 @@ func (f ACMEAttestationFormat) Validate() error { // ACME is the acme provisioner type, an entity that can authorize the ACME // provisioning flow. type ACME struct { - *base - ID string `json:"-"` - Type string `json:"type"` - Name string `json:"name"` - ForceCN bool `json:"forceCN,omitempty"` - // TermsOfService contains a URL pointing to the ACME server's - // terms of service. Defaults to empty. - TermsOfService string `json:"termsOfService,omitempty"` + *base + ID string `json:"-"` + Type string `json:"type"` + Name string `json:"name"` + ForceCN bool `json:"forceCN,omitempty"` + // ProxyURL enables configuring a custom HTTP(S) proxy for outbound + // ACME validation requests performed by the server (e.g., http-01 fetches). + // If empty, the default proxy from the environment is used. + ProxyURL string `json:"proxyURL,omitempty"` + // DisableProxy disables usage of any proxy (including environment variables) + // for outbound ACME validation requests. + DisableProxy bool `json:"disableProxy,omitempty"` + // DNS allows forcing a specific DNS resolver in the form "host:port" + // (e.g., "8.8.8.8:53") for DNS queries executed during ACME challenges. + DNS string `json:"dns,omitempty"` + // TermsOfService contains a URL pointing to the ACME server's + // terms of service. Defaults to empty. + TermsOfService string `json:"termsOfService,omitempty"` // Website contains an URL pointing to more information about // the ACME server. Defaults to empty. Website string `json:"website,omitempty"`