Repository: favonia/cloudflare-ddns
Pull request #939 (2024-09-21 20:00:27)

fix(provider): force ip4/6 for the `url:` provider

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/939?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 95.78%. Comparing base [(`651252d`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/651252d5b3b0f576d47a5b974d0cccf5a0587921?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) to head [(`fe4e164`)](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/fe4e1649bb2378256c2913409e4d11f8325e4f12?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #939      +/-   ##
==========================================
+ Coverage   95.72%   95.78%   +0.05%     
==========================================
  Files          59       60       +1     
  Lines        2669     2704      +35     
==========================================
+ Hits         2555     2590      +35     
  Misses        104      104              
  Partials       10       10              
```

| [Flag](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/939/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/939/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | `95.78% <100.00%> (+0.05%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/939?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia).


Patch:
diff --git a/README.markdown b/README.markdown
index f280e3c..8b5ddb3 100644
--- a/README.markdown
+++ b/README.markdown
@@ -27,7 +27,7 @@ A feature-rich and robust Cloudflare DDNS updater with a small footprint. The pr
 ### 🌥️ Cloudflare-specific Features
 
 - 😶‍🌫️ You can toggle [Cloudflare proxying](https://developers.cloudflare.com/dns/manage-dns-records/reference/proxied-dns-records/) for each domain.
-- 📝 You can set [DNS record comments](https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/) (and probably record tags soon).
+- 📝 You can set [comments](https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/) for new DNS records.
 - 📜 The updater can maintain [lists](https://developers.cloudflare.com/waf/tools/lists/custom-lists/) of detected IP addresses. These lists can then be referenced in any Cloudflare product that uses [Cloudflare’s Rules language](https://developers.cloudflare.com/ruleset-engine/), such as [Cloudflare Web Application Firewall (WAF)](https://developers.cloudflare.com/waf/) and [Cloudflare Rules](https://developers.cloudflare.com/rules/). (We call the lists “WAF lists”, but their use is not limited to Cloudflare WAF.)
 
 ### 👁️ Integration with Notification Services
@@ -306,13 +306,13 @@ _(Click to expand the following items.)_
 
 > 📡 Available IP address providers:
 >
-> | Provider Name      | Explanation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
-> | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-> | `cloudflare.doh`   | Get the IP address by querying `whoami.cloudflare.` against [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/dns-over-https). 🤖 The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
-> | `cloudflare.trace` | Get the IP address by parsing the [Cloudflare debugging page](https://one.one.one.one/cdn-cgi/trace). **This is the default provider.** 🤖 The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
-> | `local`            | Get the IP address via local network interfaces. When multiple local network interfaces or in general multiple IP addresses are present, the updater will use the address that _would have_ been used for outbound UDP connections to Cloudflare servers. (No data will be transmitted.) ⚠️ You need access to the host network (such as `network_mode: host` in Docker Compose) for this policy, for otherwise the updater will detect the addresses inside [the default bridge network in Docker](https://docs.docker.com/network/bridge/) instead of those in the host network.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
-> | `url:<URL>`        | Fetch the content at `URL` via the HTTP(S) protocol treat the content as the IP address. The provider format is `url:` followed by the URL. For example, `IP4_PROVIDER=url:https://api4.ipify.org` will fetch the IPv4 address from <https://api4.ipify.org>, a server maintained by [ipify](https://www.ipify.org). 🐞 **KNOWN ISSUE: Currently, the updater _will not_ force IPv4 or IPv6 when retrieving the IPv4 or IPv6 address at `URL`.** Therefore, for `IP4_PROVIDER=url:URL`, the updater might use IPv6 (instead of the expected IPv4) to connect to `URL`, and if the server returns an IPv6 address because of this, the updating will fail. The server at `URL` must either restrict its access to the expected IP network or return a valid IP address in the expected IP network regardless of what IP network is used for connection. As a working example, <https://api4.ipify.org> has restricted its access to IPv4, and thus it’s impossible to use the wrong IP network (IPv6) to connect to it. 🧪 This is a known issue and may be fixed in the future. |
-> | `none`             | Stop the DNS updating for the specified IP version completely. For example `IP4_PROVIDER=none` will disable IPv4 completely. Existing DNS records will not be removed. ⚠️ The IP addresses of the disabled IP version will be removed from WAF lists; so `IP4_PROVIDER=none` will remove all IPv4 addresses from all managed WAF lists. 🧪 As the support of WAF lists is experimental, this behavior is subject to changes and please [provide feedback](https://github.com/favonia/cloudflare-ddns/issues/new).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
+> | Provider Name      | Explanation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
+> | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+> | `cloudflare.doh`   | Get the IP address by querying `whoami.cloudflare.` against [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/dns-over-https). 🤖 The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay. |
+> | `cloudflare.trace` | Get the IP address by parsing the [Cloudflare debugging page](https://one.one.one.one/cdn-cgi/trace). **This is the default provider.** 🤖 The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay.                |
+> | `local`            | Get the IP address via local network interfaces. When multiple local network interfaces or in general multiple IP addresses are present, the updater will use the address that _would have_ been used for outbound UDP connections to Cloudflare servers. (No data will be transmitted.) ⚠️ You need access to the host network (such as `network_mode: host` in Docker Compose) for this policy, for otherwise the updater will detect the addresses inside [the default bridge network in Docker](https://docs.docker.com/network/bridge/) instead of those in the host network.     |
+> | `url:<URL>`        | Fetch the content at `URL` and treat it as the IP address. The provider format is `url:` followed by the URL. For example, `IP4_PROVIDER=url:https://api4.ipify.org` will fetch the IPv4 address from <https://api4.ipify.org>, a server maintained by [ipify](https://www.ipify.org). Note that the updater will only use IPv4 to connect to `URL` for fetching IPv4 addresses, and similarly only IPv6 for IPv6 addresses. Currently, only the HTTP(S) schema is supported.                                                                                                         |
+> | `none`             | Stop the DNS updating for the specified IP version completely. For example `IP4_PROVIDER=none` will disable IPv4 completely. Existing DNS records will not be removed. ⚠️ The IP addresses of the disabled IP version will be removed from WAF lists; so `IP4_PROVIDER=none` will remove all IPv4 addresses from all managed WAF lists. 🧪 As the support of WAF lists is experimental, this behavior is subject to changes and please [provide feedback](https://github.com/favonia/cloudflare-ddns/issues/new).                                                                      |
 
 </details>
 
diff --git a/internal/provider/base.go b/internal/provider/base.go
index 7efb874..45562d5 100644
--- a/internal/provider/base.go
+++ b/internal/provider/base.go
@@ -12,12 +12,22 @@ import (
 
 //go:generate mockgen -typed -destination=../mocks/mock_provider.go -package=mocks . Provider,SplitProvider
 
+// Method reexports [protocol.Method].
+type Method = protocol.Method
+
+// Re-exporting constants from protocol.
+const (
+	MethodPrimary     = protocol.MethodPrimary
+	MethodAlternative = protocol.MethodAlternative
+	MethodUnspecified = protocol.MethodUnspecified
+)
+
 // Provider is the abstraction of a protocol to detect public IP addresses.
 type Provider interface {
 	Name() string
 	// Name gives the name of the protocol.
 
-	GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, protocol.Method, bool)
+	GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool)
 	// GetIP gets the IP.
 }
 
@@ -32,7 +42,7 @@ type SplitProvider interface {
 	HasAlternative(ipNet ipnet.Type) bool
 	// HasAlternative checks whether there is a different alternative method.
 
-	GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, method protocol.Method) (netip.Addr, bool)
+	GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, method Method) (netip.Addr, bool)
 	// GetIP gets the IP.
 }
 
diff --git a/internal/provider/happy_eyeballs.go b/internal/provider/happy_eyeballs.go
index 87bf705..9b35dac 100644
--- a/internal/provider/happy_eyeballs.go
+++ b/internal/provider/happy_eyeballs.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
 )
 
 // HappyEyeballsAlternativeDelay is the delay to start the alternative
@@ -21,21 +20,21 @@ const Hint1111BlocakageText string = "Your IPv4 provider is using 1.1.1.1 to get
 func Hint1111Blockage(ppfmt pp.PP) { ppfmt.Hintf(pp.Hint1111Blockage, "%s", Hint1111BlocakageText) }
 
 type splitResult struct {
-	method protocol.Method
+	method Method
 	ip     netip.Addr
 	ok     bool
 }
 
 type happyEyeballs struct {
 	provider SplitProvider
-	chosen   map[ipnet.Type]protocol.Method
+	chosen   map[ipnet.Type]Method
 }
 
 // NewHappyEyeballs creates a new [Provider] by applying the Happy Eyeballs algorithm to [SplitProvider].
 func NewHappyEyeballs(provider SplitProvider) Provider {
 	return happyEyeballs{
 		provider: provider,
-		chosen:   map[ipnet.Type]protocol.Method{},
+		chosen:   map[ipnet.Type]Method{},
 	}
 }
 
@@ -51,12 +50,12 @@ func (p happyEyeballs) Name() string {
 //     by [HappyEyeballsAlternativeDelay], which is longer than
 //     what's recommended in RFC 6555.
 //  2. The state will not be flushed because 1.1.1.1 is not
-func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, protocol.Method, bool) {
+func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool) {
 	if !p.provider.HasAlternative(ipNet) {
-		p.chosen[ipNet] = protocol.MethodPrimary
+		p.chosen[ipNet] = MethodPrimary
 	}
 
-	if method := p.chosen[ipNet]; method != protocol.MethodUnspecified {
+	if method := p.chosen[ipNet]; method != MethodUnspecified {
 		ip, ok := p.provider.GetIP(ctx, ppfmt, ipNet, method)
 		return ip, method, ok
 	}
@@ -65,10 +64,10 @@ func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type)
 	defer close(finished)
 
 	splitResults := make(chan splitResult)
-	failed := map[protocol.Method]bool{}
-	queuedPP := map[protocol.Method]pp.QueuedPP{}
+	failed := map[Method]bool{}
+	queuedPP := map[Method]pp.QueuedPP{}
 
-	start := func(ctx context.Context, ppfmt pp.PP, method protocol.Method) {
+	start := func(ctx context.Context, ppfmt pp.PP, method Method) {
 		ip, ok := p.provider.GetIP(ctx, ppfmt, ipNet, method)
 
 		select {
@@ -77,7 +76,7 @@ func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type)
 		}
 	}
 
-	fork := func(method protocol.Method) func() {
+	fork := func(method Method) func() {
 		ctx, cancel := context.WithCancel(ctx)
 		queuedPP[method] = pp.NewQueued(ppfmt)
 		go start(ctx, queuedPP[method], method)
@@ -85,7 +84,7 @@ func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type)
 		return cancel
 	}
 
-	primaryCancel := fork(protocol.MethodPrimary)
+	primaryCancel := fork(MethodPrimary)
 	defer primaryCancel()
 
 	// Some delay for the alternative method so that
@@ -96,7 +95,7 @@ func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type)
 	for {
 		select {
 		case <-alternativeDelayTimer.C:
-			alternativeCancel := fork(protocol.MethodAlternative)
+			alternativeCancel := fork(MethodAlternative)
 			defer alternativeCancel()
 
 		case r := <-splitResults:
@@ -105,7 +104,7 @@ func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type)
 				p.chosen[ipNet] = r.method
 				queuedPP[r.method].Flush()
 
-				if r.method == protocol.MethodAlternative {
+				if r.method == MethodAlternative {
 					ppfmt.Infof(pp.EmojiNow, "The server 1.0.0.1 responded before 1.1.1.1 does and will be used from now on.")
 				}
 
@@ -116,15 +115,15 @@ func (p happyEyeballs) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type)
 			failed[r.method] = true
 
 			// If both methods fail, then the detection fails.
-			if failed[protocol.MethodPrimary] && failed[protocol.MethodAlternative] {
+			if failed[MethodPrimary] && failed[MethodAlternative] {
 				// Flush out the messages from the primary method.
-				queuedPP[protocol.MethodPrimary].Flush()
+				queuedPP[MethodPrimary].Flush()
 
-				return netip.Addr{}, protocol.MethodUnspecified, false
+				return netip.Addr{}, MethodUnspecified, false
 			}
 
 			// If the primary method fails, start the alternative method immediately.
-			if r.method == protocol.MethodPrimary {
+			if r.method == MethodPrimary {
 				if alternativeDelayTimer.Stop() {
 					alternativeDelayTimer.Reset(0)
 				}
diff --git a/internal/provider/protocol/doh.go b/internal/provider/protocol/doh.go
index 8c321ec..4d64d31 100644
--- a/internal/provider/protocol/doh.go
+++ b/internal/provider/protocol/doh.go
@@ -136,7 +136,7 @@ func parseDNSResponse(ppfmt pp.PP, r []byte, id uint16, name string, class dnsme
 	return parseDNSAnswers(ppfmt, msg.Answers, name, class)
 }
 
-func getIPFromDNS(ctx context.Context, ppfmt pp.PP, url string, name string, class dnsmessage.Class,
+func getIPFromDNS(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, url string, name string, class dnsmessage.Class,
 ) (netip.Addr, bool) {
 	var invalidIP netip.Addr
 
@@ -149,6 +149,7 @@ func getIPFromDNS(ctx context.Context, ppfmt pp.PP, url string, name string, cla
 	}
 
 	c := httpCore{
+		ipNet:  ipNet,
 		url:    url,
 		method: http.MethodPost,
 		additionalHeaders: map[string]string{
@@ -190,7 +191,7 @@ func (p DNSOverHTTPS) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type,
 		return netip.Addr{}, false
 	}
 
-	ip, ok := getIPFromDNS(ctx, ppfmt, param.URL.Switch(method), param.Name, param.Class)
+	ip, ok := getIPFromDNS(ctx, ppfmt, ipNet, param.URL.Switch(method), param.Name, param.Class)
 	if !ok {
 		return netip.Addr{}, false
 	}
diff --git a/internal/provider/protocol/http.go b/internal/provider/protocol/http.go
index 1eba335..5a1c688 100644
--- a/internal/provider/protocol/http.go
+++ b/internal/provider/protocol/http.go
@@ -10,8 +10,9 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-func getIPFromHTTP(ctx context.Context, ppfmt pp.PP, url string) (netip.Addr, bool) {
+func getIPFromHTTP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, url string) (netip.Addr, bool) {
 	c := httpCore{
+		ipNet:             ipNet,
 		url:               url,
 		method:            http.MethodGet,
 		additionalHeaders: nil,
@@ -20,7 +21,7 @@ func getIPFromHTTP(ctx context.Context, ppfmt pp.PP, url string) (netip.Addr, bo
 			ipString := strings.TrimSpace(string(body))
 			ip, err := netip.ParseAddr(ipString)
 			if err != nil {
-				ppfmt.Noticef(pp.EmojiImpossible, `Failed to parse the IP address in the response of %q: %s`, url, ipString)
+				ppfmt.Noticef(pp.EmojiError, `Failed to parse the IP address in the response of %q: %s`, url, ipString)
 				return netip.Addr{}, false
 			}
 			return ip, true
@@ -49,7 +50,7 @@ func (p HTTP) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, method M
 		return netip.Addr{}, false
 	}
 
-	ip, ok := getIPFromHTTP(ctx, ppfmt, url.Switch(method))
+	ip, ok := getIPFromHTTP(ctx, ppfmt, ipNet, url.Switch(method))
 	if !ok {
 		return netip.Addr{}, false
 	}
diff --git a/internal/provider/protocol/httpcore.go b/internal/provider/protocol/httpcore.go
index fd14f2e..531d197 100644
--- a/internal/provider/protocol/httpcore.go
+++ b/internal/provider/protocol/httpcore.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/hashicorp/go-retryablehttp"
 
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
@@ -14,6 +15,7 @@ import (
 const maxReadLength int64 = 102400
 
 type httpCore struct {
+	ipNet             ipnet.Type
 	url               string
 	method            string
 	additionalHeaders map[string]string
@@ -34,8 +36,7 @@ func (h httpCore) getIP(ctx context.Context, ppfmt pp.PP) (netip.Addr, bool) {
 		req.Header.Set(header, value)
 	}
 
-	c := retryablehttp.NewClient()
-	c.Logger = nil
+	c := SharedRetryableSplitClient(h.ipNet)
 
 	resp, err := c.Do(req)
 	if err != nil {
diff --git a/internal/provider/protocol/regexp.go b/internal/provider/protocol/regexp.go
index 5a1bd5a..9e74939 100644
--- a/internal/provider/protocol/regexp.go
+++ b/internal/provider/protocol/regexp.go
@@ -10,8 +10,10 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-func getIPFromRegexp(ctx context.Context, ppfmt pp.PP, url string, re *regexp.Regexp) (netip.Addr, bool) {
+func getIPFromRegexp(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, url string, re *regexp.Regexp,
+) (netip.Addr, bool) {
 	c := httpCore{
+		ipNet:             ipNet,
 		url:               url,
 		method:            http.MethodGet,
 		additionalHeaders: nil,
@@ -60,7 +62,7 @@ func (p Regexp) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, method
 		return netip.Addr{}, false
 	}
 
-	ip, ok := getIPFromRegexp(ctx, ppfmt, param.URL.Switch(method), param.Regexp)
+	ip, ok := getIPFromRegexp(ctx, ppfmt, ipNet, param.URL.Switch(method), param.Regexp)
 	if !ok {
 		return netip.Addr{}, false
 	}
diff --git a/internal/provider/protocol/split_dialer.go b/internal/provider/protocol/split_dialer.go
new file mode 100644
index 0000000..a7766e2
--- /dev/null
+++ b/internal/provider/protocol/split_dialer.go
@@ -0,0 +1,78 @@
+package protocol
+
+import (
+	"context"
+	"errors"
+	"net"
+	"net/http"
+	"syscall"
+	"time"
+
+	"github.com/hashicorp/go-retryablehttp"
+
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+)
+
+// This file contains mechanisms to limit connections to only IPv4 or IPv6.
+
+var errForbiddenIPFamily = errors.New("forbidden IP family")
+
+func filterIP6Only(_ context.Context, network, _ string, _ syscall.RawConn) error {
+	switch network {
+	case "tcp4", "udp4", "ip4":
+		return errForbiddenIPFamily
+	}
+	return nil
+}
+
+func filterIP4Only(_ context.Context, network, _ string, _ syscall.RawConn) error {
+	switch network {
+	case "tcp6", "udp6", "ip6":
+		return errForbiddenIPFamily
+	}
+	return nil
+}
+
+func newControlledDialer(control func(context.Context, string, string, syscall.RawConn) error) *net.Dialer {
+	return &net.Dialer{ //nolint:exhaustruct
+		Timeout:        30 * time.Second, //nolint:mnd
+		KeepAlive:      30 * time.Second, //nolint:mnd
+		ControlContext: control,
+	}
+}
+
+func newControlledTransport(control func(context.Context, string, string, syscall.RawConn) error) http.RoundTripper {
+	return &http.Transport{ //nolint:exhaustruct
+		Proxy:                 http.ProxyFromEnvironment,
+		DialContext:           newControlledDialer(control).DialContext,
+		ForceAttemptHTTP2:     true,
+		MaxIdleConns:          100,              //nolint:mnd
+		IdleConnTimeout:       90 * time.Second, //nolint:mnd
+		TLSHandshakeTimeout:   10 * time.Second, //nolint:mnd
+		ExpectContinueTimeout: 1 * time.Second,
+	}
+}
+
+func newControlledClient(control func(context.Context, string, string, syscall.RawConn) error) *http.Client {
+	return &http.Client{Transport: newControlledTransport(control)} //nolint:exhaustruct
+}
+
+//nolint:gochecknoglobals
+var sharedSplitClient = map[ipnet.Type]*http.Client{
+	ipnet.IP4: newControlledClient(filterIP4Only),
+	ipnet.IP6: newControlledClient(filterIP6Only),
+}
+
+// SharedSplitClient returns the shared [http.Client] that allows only the traffic of specified IP family.
+func SharedSplitClient(ipNet ipnet.Type) *http.Client {
+	return sharedSplitClient[ipNet]
+}
+
+// SharedRetryableSplitClient returns a [retryablehttp.Client] with the shared underlying [http.Client]
+// that allows only the traffic of specified IP family.
+func SharedRetryableSplitClient(ipNet ipnet.Type) *retryablehttp.Client {
+	c := retryablehttp.NewClient()
+	c.HTTPClient = SharedSplitClient(ipNet)
+	c.Logger = nil
+	return c
+}
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index d2b9488..de6885d 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -11,7 +11,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
-	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 )
 
@@ -30,7 +29,7 @@ func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Ty
 
 	if ok {
 		switch method {
-		case protocol.MethodAlternative:
+		case provider.MethodAlternative:
 			ppfmt.Infof(pp.EmojiInternet, "Detected the %s address %v (using 1.0.0.1)", ipNet.Describe(), ip)
 			provider.Hint1111Blockage(ppfmt)
 		default:


Test patch:
diff --git a/internal/provider/happy_eyeballs_test.go b/internal/provider/happy_eyeballs_test.go
index 98d5fcc..f08b0c2 100644
--- a/internal/provider/happy_eyeballs_test.go
+++ b/internal/provider/happy_eyeballs_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
-	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
 )
 
 func sleepCtx(ctx context.Context, d time.Duration) {
@@ -34,19 +33,19 @@ func TestHappyEyeballs(t *testing.T) {
 
 	for name, tc := range map[string]struct {
 		ip           netip.Addr
-		method       protocol.Method
+		method       provider.Method
 		ok           bool
 		timeout      time.Duration
 		elapsed      time.Duration
 		prepareMocks func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider)
 	}{
 		"no-alternative": {
-			ip1111, protocol.MethodPrimary, true,
+			ip1111, provider.MethodPrimary, true,
 			3 * time.Second, 0,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(false)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.1.1.1!")
 						return ip1111, true
 					}).Times(2)
@@ -56,12 +55,12 @@ func TestHappyEyeballs(t *testing.T) {
 			},
 		},
 		"primary-instant": {
-			ip1111, protocol.MethodPrimary, true,
+			ip1111, provider.MethodPrimary, true,
 			3 * time.Second, 0,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.1.1.1!")
 						return ip1111, true
 					}).Times(2)
@@ -71,18 +70,18 @@ func TestHappyEyeballs(t *testing.T) {
 			},
 		},
 		"primary-delayed/alternative-instant": {
-			ip1001, protocol.MethodAlternative, true,
+			ip1001, provider.MethodAlternative, true,
 			3 * time.Second, provider.HappyEyeballsAlternativeDelay,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.1.1.1!")
 						sleepCtx(ctx, time.Second)
 						return ip1111, true
 					}).Times(1)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodAlternative).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodAlternative).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.0.0.1!")
 						return ip1001, true
 					}).Times(2)
@@ -93,18 +92,18 @@ func TestHappyEyeballs(t *testing.T) {
 			},
 		},
 		"primary-delayed/alternative-instant-fails": {
-			ip1111, protocol.MethodPrimary, true,
+			ip1111, provider.MethodPrimary, true,
 			3 * time.Second, time.Second,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.1.1.1!")
 						sleepCtx(ctx, time.Second)
 						return ip1111, true
 					}).Times(2)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodAlternative).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodAlternative).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Noticef(pp.EmojiError, "Can't get 1.0.0.1")
 						return netip.Addr{}, false
 					}).Times(1)
@@ -114,18 +113,18 @@ func TestHappyEyeballs(t *testing.T) {
 			},
 		},
 		"primary-delayed/alternative-timeout": {
-			ip1111, protocol.MethodPrimary, true,
+			ip1111, provider.MethodPrimary, true,
 			3 * time.Second, time.Second,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.1.1.1!")
 						sleepCtx(ctx, time.Second)
 						return ip1111, true
 					}).Times(2)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodAlternative).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodAlternative).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.0.0.1!")
 						sleepCtx(ctx, forever)
 						return ip1001, true
@@ -136,17 +135,17 @@ func TestHappyEyeballs(t *testing.T) {
 			},
 		},
 		"primary-instant-fails/alternative-instant": {
-			ip1001, protocol.MethodAlternative, true,
+			ip1001, provider.MethodAlternative, true,
 			3 * time.Second, 0,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Noticef(pp.EmojiError, "Can't get 1.1.1.1")
 						return netip.Addr{}, false
 					}).Times(1)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodAlternative).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodAlternative).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.0.0.1!")
 						return ip1001, true
 					}).Times(2)
@@ -158,17 +157,17 @@ func TestHappyEyeballs(t *testing.T) {
 		},
 		"primary-instant-fails/alternative-instant-fails": {
 			netip.Addr{},
-			protocol.MethodUnspecified, false,
+			provider.MethodUnspecified, false,
 			3 * time.Second, 0,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Noticef(pp.EmojiError, "Can't get 1.1.1.1")
 						return netip.Addr{}, false
 					}).Times(1)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodAlternative).DoAndReturn(
-					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodAlternative).DoAndReturn(
+					func(_ context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Noticef(pp.EmojiError, "Can't get 1.0.0.1")
 						return netip.Addr{}, false
 					}).Times(1)
@@ -176,18 +175,18 @@ func TestHappyEyeballs(t *testing.T) {
 			},
 		},
 		"primary-timeout/alternative-delayed": {
-			ip1001, protocol.MethodAlternative, true,
+			ip1001, provider.MethodAlternative, true,
 			3 * time.Second, provider.HappyEyeballsAlternativeDelay + time.Second,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Noticef(pp.EmojiError, "Can't get 1.1.1.1")
 						sleepCtx(ctx, forever)
 						return netip.Addr{}, false
 					}).Times(1)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodAlternative).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodAlternative).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Infof(pp.EmojiGood, "Got 1.0.0.1!")
 						sleepCtx(ctx, time.Second)
 						return ip1001, true
@@ -200,18 +199,18 @@ func TestHappyEyeballs(t *testing.T) {
 		},
 		"primary-timeout/alternative-timeout": {
 			netip.Addr{},
-			protocol.MethodUnspecified, false,
+			provider.MethodUnspecified, false,
 			3 * time.Second, 3 * time.Second,
 			func(ppfmt *mocks.MockPP, p *mocks.MockSplitProvider) {
 				p.EXPECT().HasAlternative(ipNet).Return(true)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodPrimary).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodPrimary).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Noticef(pp.EmojiError, "Can't get 1.1.1.1")
 						sleepCtx(ctx, forever)
 						return netip.Addr{}, false
 					}).Times(1)
-				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, protocol.MethodAlternative).DoAndReturn(
-					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ protocol.Method) (netip.Addr, bool) {
+				p.EXPECT().GetIP(gomock.Any(), gomock.Any(), ipNet, provider.MethodAlternative).DoAndReturn(
+					func(ctx context.Context, ppfmt pp.PP, _ ipnet.Type, _ provider.Method) (netip.Addr, bool) {
 						ppfmt.Noticef(pp.EmojiError, "Can't get 1.0.0.1")
 						sleepCtx(ctx, forever)
 						return netip.Addr{}, false
diff --git a/internal/provider/protocol/http_test.go b/internal/provider/protocol/http_test.go
index 00e1da0..a420206 100644
--- a/internal/provider/protocol/http_test.go
+++ b/internal/provider/protocol/http_test.go
@@ -4,9 +4,9 @@ import (
 	"context"
 	"fmt"
 	"net/http"
-	"net/http/httptest"
 	"net/netip"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
@@ -29,130 +29,158 @@ func TestHTTPName(t *testing.T) {
 }
 
 func TestHTTPGetIP(t *testing.T) {
+	t.Parallel()
+
 	ip4 := netip.MustParseAddr("1.2.3.4")
 	ip6 := netip.MustParseAddr("::1:2:3:4:5:6")
 	invalidIP := netip.Addr{}
 
-	ip4Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+	ip4Writer := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, ip4.String())
-	}))
-	defer ip4Server.Close()
-	ip6Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+	})
+	ip6Writer := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, ip6.String())
-	}))
-	defer ip6Server.Close()
-	dummy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		fmt.Fprint(w, "not an ip")
-	}))
-	defer dummy.Close()
-
-	t.Run("group", func(t *testing.T) {
-		for name, tc := range map[string]struct {
-			nilCtx        bool
-			urlKey        ipnet.Type
-			url           string
-			ipNet         ipnet.Type
-			expected      netip.Addr
-			prepareMockPP func(*mocks.MockPP)
-		}{
-			"4": {false, ipnet.IP4, ip4Server.URL, ipnet.IP4, ip4, nil},
-			"6": {false, ipnet.IP6, ip6Server.URL, ipnet.IP6, ip6, nil},
-			"nilctx": {
-				true, ipnet.IP4, ip4Server.URL, ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiImpossible, "Failed to prepare HTTP(S) request to %q: %v",
-						ip4Server.URL,
-						gomock.Any(),
-					)
-				},
+	})
+	helloWriter := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		fmt.Fprint(w, "hello")
+	})
+
+	server4 := newSplitServer(ipnet.IP4, ip4Writer)
+	t.Cleanup(server4.Close)
+	server6 := newSplitServer(ipnet.IP6, ip6Writer)
+	t.Cleanup(server6.Close)
+	server6via4 := newSplitServer(ipnet.IP4, ip6Writer)
+	t.Cleanup(server6via4.Close)
+	server4via6 := newSplitServer(ipnet.IP6, ip4Writer)
+	t.Cleanup(server4via6.Close)
+	illformed4 := newSplitServer(ipnet.IP4, helloWriter)
+	t.Cleanup(illformed4.Close)
+	illformed6 := newSplitServer(ipnet.IP6, helloWriter)
+	t.Cleanup(illformed6.Close)
+
+	for name, tc := range map[string]struct {
+		nilCtx        bool
+		urlKey        ipnet.Type
+		url           string
+		ipNet         ipnet.Type
+		expected      netip.Addr
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"nilctx": {
+			true, ipnet.IP4, server4.URL, ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiImpossible, "Failed to prepare HTTP(S) request to %q: %v",
+					server4.URL,
+					gomock.Any(),
+				)
 			},
-			"6to4": {
-				false, ipnet.IP4, ip6Server.URL, ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", ip6.String())
-				},
+		},
+		"4": {false, ipnet.IP4, server4.URL, ipnet.IP4, ip4, nil},
+		"6": {false, ipnet.IP6, server6.URL, ipnet.IP6, ip6, nil},
+		"4to6": {
+			false,
+			ipnet.IP6, server4via6.URL, ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv6 address", ip4.String())
 			},
-			"4-nil1": {
-				false, ipnet.IP4, dummy.URL, ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiImpossible,
-						`Failed to parse the IP address in the response of %q: %s`,
-						dummy.URL,
-						"not an ip")
-				},
+		},
+		"6to4": {
+			false,
+			ipnet.IP4, server6via4.URL, ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", ip6.String())
 			},
-			"6-nil1": {
-				false, ipnet.IP6, dummy.URL, ipnet.IP6, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiImpossible,
-						`Failed to parse the IP address in the response of %q: %s`,
-						dummy.URL,
-						"not an ip")
-				},
+		},
+		"4/illformed": {
+			false,
+			ipnet.IP4, illformed4.URL, ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					`Failed to parse the IP address in the response of %q: %s`,
+					illformed4.URL,
+					"hello")
 			},
-			"4-nil2": {
-				false, ipnet.IP4, "", ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiError,
-						"Failed to send HTTP(S) request to %q: %v",
-						"",
-						gomock.Any(),
-					)
-				},
+		},
+		"6/illformed": {
+			false,
+			ipnet.IP6, illformed6.URL, ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					`Failed to parse the IP address in the response of %q: %s`,
+					illformed6.URL,
+					"hello")
 			},
-			"6-nil2": {
-				false, ipnet.IP6, "", ipnet.IP6, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiError,
-						"Failed to send HTTP(S) request to %q: %v",
-						"",
-						gomock.Any(),
-					)
-				},
+		},
+		"4/request-fail": {
+			false,
+			ipnet.IP4, "", ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					"Failed to send HTTP(S) request to %q: %v",
+					"",
+					gomock.Any(),
+				)
 			},
-			"4-nil3": {
-				false, ipnet.IP4, ip4Server.URL, ipnet.IP6, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6")
-				},
+		},
+		"6/request-fail": {
+			false,
+			ipnet.IP6, "", ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					"Failed to send HTTP(S) request to %q: %v",
+					"",
+					gomock.Any(),
+				)
 			},
-			"6-nil3": {
-				false, ipnet.IP6, ip6Server.URL, ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4")
-				},
+		},
+		"4/not-handled": {
+			false,
+			ipnet.IP4, server4.URL, ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6")
 			},
-		} {
-			t.Run(name, func(t *testing.T) {
-				t.Parallel()
-				mockCtrl := gomock.NewController(t)
-
-				provider := &protocol.HTTP{
-					ProviderName: "secret name",
-					URL: map[ipnet.Type]protocol.Switch{
-						tc.urlKey: protocol.Constant(tc.url),
-					},
-				}
-				ctx := context.Background()
-				if tc.nilCtx {
-					ctx = nil
-				}
-
-				mockPP := mocks.NewMockPP(mockCtrl)
-				if tc.prepareMockPP != nil {
-					tc.prepareMockPP(mockPP)
-				}
-				ip, ok := provider.GetIP(ctx, mockPP, tc.ipNet, protocol.MethodPrimary)
-				require.Equal(t, tc.expected, ip)
-				require.Equal(t, tc.expected.IsValid(), ok)
-			})
-		}
-	})
+		},
+		"6/not-handled": {
+			false,
+			ipnet.IP6, server6.URL, ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4")
+			},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			mockCtrl := gomock.NewController(t)
+
+			provider := &protocol.HTTP{
+				ProviderName: "secret name",
+				URL: map[ipnet.Type]protocol.Switch{
+					tc.urlKey: protocol.Constant(tc.url),
+				},
+			}
+
+			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+			defer cancel()
+
+			if tc.nilCtx {
+				ctx = nil
+			}
+
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
+			ip, ok := provider.GetIP(ctx, mockPP, tc.ipNet, protocol.MethodPrimary)
+			require.Equal(t, tc.expected, ip)
+			require.Equal(t, tc.expected.IsValid(), ok)
+		})
+	}
 }
 
 func TestHTTPHasAlternative(t *testing.T) {
diff --git a/internal/provider/protocol/regexp_test.go b/internal/provider/protocol/regexp_test.go
index 1e02b64..e330579 100644
--- a/internal/provider/protocol/regexp_test.go
+++ b/internal/provider/protocol/regexp_test.go
@@ -4,10 +4,10 @@ import (
 	"context"
 	"fmt"
 	"net/http"
-	"net/http/httptest"
 	"net/netip"
 	"regexp"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
@@ -30,137 +30,157 @@ func TestRegexpName(t *testing.T) {
 }
 
 func TestRegexpGetIP(t *testing.T) {
+	t.Parallel()
+
 	ip4 := netip.MustParseAddr("1.2.3.4")
 	ip6 := netip.MustParseAddr("::1:2:3:4:5:6")
 	invalidIP := netip.Addr{}
 
-	ip4Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+	ip4Writer := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, "<<"+ip4.String()+">>")
-	}))
-	defer ip4Server.Close()
-	ip6Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+	})
+	ip6Writer := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, "<<"+ip6.String()+">>")
-	}))
-	defer ip6Server.Close()
-	dummy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+	})
+	helloWriter := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 		fmt.Fprint(w, "<<hello>>")
-	}))
-	defer dummy.Close()
-
-	t.Run("group", func(t *testing.T) {
-		for name, tc := range map[string]struct {
-			urlKey        ipnet.Type
-			url           string
-			regexp        *regexp.Regexp
-			ipNet         ipnet.Type
-			expected      netip.Addr
-			prepareMockPP func(*mocks.MockPP)
-		}{
-			"4": {ipnet.IP4, ip4Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, ip4, nil},
-			"6": {ipnet.IP6, ip6Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, ip6, nil},
-			"6to4": {
-				ipnet.IP4, ip6Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", ip6.String())
-				},
+	})
+
+	server4 := newSplitServer(ipnet.IP4, ip4Writer)
+	t.Cleanup(server4.Close)
+	server6 := newSplitServer(ipnet.IP6, ip6Writer)
+	t.Cleanup(server6.Close)
+	server6via4 := newSplitServer(ipnet.IP4, ip6Writer)
+	t.Cleanup(server6via4.Close)
+	server4via6 := newSplitServer(ipnet.IP6, ip4Writer)
+	t.Cleanup(server4via6.Close)
+	illformed4 := newSplitServer(ipnet.IP4, helloWriter)
+	t.Cleanup(illformed4.Close)
+	illformed6 := newSplitServer(ipnet.IP6, helloWriter)
+	t.Cleanup(illformed6.Close)
+
+	for name, tc := range map[string]struct {
+		urlKey        ipnet.Type
+		url           string
+		regexp        *regexp.Regexp
+		ipNet         ipnet.Type
+		expected      netip.Addr
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"4": {ipnet.IP4, server4.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, ip4, nil},
+		"6": {ipnet.IP6, server6.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, ip6, nil},
+		"4to6": {
+			ipnet.IP6, server4via6.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv6 address", ip4.String())
 			},
-			"4-nil1": {
-				ipnet.IP4, dummy.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiError,
-						`Failed to parse the IP address in the response of %q: %s`,
-						dummy.URL,
-						"hello")
-				},
+		},
+		"6to4": {
+			ipnet.IP4, server6via4.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", ip6.String())
 			},
-			"6-nil1": {
-				ipnet.IP6, dummy.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiError,
-						`Failed to parse the IP address in the response of %q: %s`,
-						dummy.URL,
-						"hello")
-				},
+		},
+		"4/illformed": {
+			ipnet.IP4, illformed4.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					`Failed to parse the IP address in the response of %q: %s`,
+					illformed4.URL,
+					"hello")
 			},
-			"4-nil2": {
-				ipnet.IP4, "", regexp.MustCompile(``), ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiError,
-						"Failed to send HTTP(S) request to %q: %v",
-						"",
-						gomock.Any(),
-					)
-				},
+		},
+		"6/illformed": {
+			ipnet.IP6, illformed6.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					`Failed to parse the IP address in the response of %q: %s`,
+					illformed6.URL,
+					"hello")
 			},
-			"6-nil2": {
-				ipnet.IP6, "", regexp.MustCompile(``), ipnet.IP6, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(
-						pp.EmojiError,
-						"Failed to send HTTP(S) request to %q: %v",
-						"",
-						gomock.Any(),
-					)
-				},
+		},
+		"4/request-fail": {
+			ipnet.IP4, "", regexp.MustCompile(``), ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					"Failed to send HTTP(S) request to %q: %v",
+					"",
+					gomock.Any(),
+				)
 			},
-			"4-nil3": {
-				ipnet.IP4, ip4Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6")
-				},
+		},
+		"6/request-fail": {
+			ipnet.IP6, "", regexp.MustCompile(``), ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(
+					pp.EmojiError,
+					"Failed to send HTTP(S) request to %q: %v",
+					"",
+					gomock.Any(),
+				)
 			},
-			"6-nil3": {
-				ipnet.IP6, ip6Server.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4")
-				},
+		},
+		"4/not-handled": {
+			ipnet.IP4, server4.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6")
 			},
-			"4-nil4": {
-				ipnet.IP4, dummy.URL, regexp.MustCompile(`some random string`), ipnet.IP4, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiError,
-						`Failed to find the IP address in the response of %q: %s`,
-						dummy.URL,
-						[]byte("<<hello>>"))
-				},
+		},
+		"6/not-handled": {
+			ipnet.IP6, server6.URL, regexp.MustCompile(`<<(.*)>>`), ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4")
 			},
-			"6-nil4": {
-				ipnet.IP6, dummy.URL, regexp.MustCompile(`some random string`), ipnet.IP6, invalidIP,
-				func(m *mocks.MockPP) {
-					m.EXPECT().Noticef(pp.EmojiError,
-						`Failed to find the IP address in the response of %q: %s`,
-						dummy.URL,
-						[]byte("<<hello>>"))
-				},
+		},
+		"4/no-match": {
+			ipnet.IP4, illformed4.URL, regexp.MustCompile(`some random string`), ipnet.IP4, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiError,
+					`Failed to find the IP address in the response of %q: %s`,
+					illformed4.URL,
+					[]byte("<<hello>>"))
+			},
+		},
+		"6/no-match": {
+			ipnet.IP6, illformed6.URL, regexp.MustCompile(`some random string`), ipnet.IP6, invalidIP,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiError,
+					`Failed to find the IP address in the response of %q: %s`,
+					illformed6.URL,
+					[]byte("<<hello>>"))
 			},
-		} {
-			t.Run(name, func(t *testing.T) {
-				t.Parallel()
-				mockCtrl := gomock.NewController(t)
-
-				provider := &protocol.Regexp{
-					ProviderName: "secret name",
-					Param: map[ipnet.Type]protocol.RegexpParam{
-						tc.urlKey: {
-							URL:    protocol.Constant(tc.url),
-							Regexp: tc.regexp,
-						},
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			mockCtrl := gomock.NewController(t)
+
+			provider := &protocol.Regexp{
+				ProviderName: "secret name",
+				Param: map[ipnet.Type]protocol.RegexpParam{
+					tc.urlKey: {
+						URL:    protocol.Constant(tc.url),
+						Regexp: tc.regexp,
 					},
-				}
-
-				mockPP := mocks.NewMockPP(mockCtrl)
-				if tc.prepareMockPP != nil {
-					tc.prepareMockPP(mockPP)
-				}
-				ip, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet, protocol.MethodPrimary)
-				require.Equal(t, tc.expected, ip)
-				require.Equal(t, tc.expected.IsValid(), ok)
-			})
-		}
-	})
+				},
+			}
+
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
+			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+			defer cancel()
+
+			ip, ok := provider.GetIP(ctx, mockPP, tc.ipNet, protocol.MethodPrimary)
+			require.Equal(t, tc.expected, ip)
+			require.Equal(t, tc.expected.IsValid(), ok)
+		})
+	}
 }
 
 func TestRegexpHasAlternative(t *testing.T) {
diff --git a/internal/provider/protocol/split_dialer_test.go b/internal/provider/protocol/split_dialer_test.go
new file mode 100644
index 0000000..cbaeae4
--- /dev/null
+++ b/internal/provider/protocol/split_dialer_test.go
@@ -0,0 +1,97 @@
+package protocol_test
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
+)
+
+func mustListen(ipNet ipnet.Type) net.Listener {
+	switch ipNet {
+	case ipnet.IP4:
+		l, err := net.Listen("tcp4", "127.0.0.1:0")
+		if err != nil {
+			panic(err)
+		}
+		return l
+	case ipnet.IP6:
+		l, err := net.Listen("tcp6", "[::1]:0")
+		if err != nil {
+			panic(err)
+		}
+		return l
+	default:
+		return nil
+	}
+}
+
+func newSplitServer(ipNet ipnet.Type, h http.HandlerFunc) *httptest.Server {
+	s := &httptest.Server{ //nolint:exhaustruct
+		Listener: mustListen(ipNet),
+		Config:   &http.Server{Handler: h, ReadHeaderTimeout: time.Minute}, //nolint:exhaustruct
+	}
+	s.Start()
+	return s
+}
+
+func TestSharedSplitClient(t *testing.T) {
+	t.Parallel()
+
+	server := map[ipnet.Type]*httptest.Server{
+		ipnet.IP4: newSplitServer(ipnet.IP4, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+			fmt.Fprint(w, "ip4")
+		})),
+		ipnet.IP6: newSplitServer(ipnet.IP6, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+			fmt.Fprint(w, "ip6")
+		})),
+	}
+	t.Cleanup(server[ipnet.IP4].Close)
+	t.Cleanup(server[ipnet.IP6].Close)
+
+	for name, tc := range map[string]struct {
+		dialerNet ipnet.Type
+		serverNet ipnet.Type
+		ok        bool
+		output    []byte
+	}{
+		"4":    {ipnet.IP4, ipnet.IP4, true, []byte("ip4")},
+		"6":    {ipnet.IP6, ipnet.IP6, true, []byte("ip6")},
+		"4to6": {ipnet.IP4, ipnet.IP6, false, []byte{}},
+		"6to4": {ipnet.IP6, ipnet.IP4, false, []byte{}},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			client := protocol.SharedSplitClient(tc.dialerNet)
+
+			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+			defer cancel()
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, server[tc.serverNet].URL, nil)
+			require.NoError(t, err)
+
+			resp, err := client.Do(req)
+			output := []byte{}
+			if err == nil {
+				output, _ = io.ReadAll(resp.Body)
+				defer resp.Body.Close()
+			}
+
+			if tc.ok {
+				require.NoError(t, err)
+			} else {
+				require.Error(t, err)
+			}
+			require.Equal(t, tc.output, output)
+		})
+	}
+}
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index 61b7913..3375438 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -18,7 +18,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/notifier"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
-	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 	"github.com/favonia/cloudflare-ddns/internal/updater"
 )
@@ -101,7 +100,7 @@ func TestUpdateIPsMultiple(t *testing.T) {
 			providerEnablers{ipnet.IP4: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodPrimary, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv4", ip4),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello1"), ip4, api.TTLAuto, false, recordComment).
@@ -137,7 +136,7 @@ func TestUpdateIPsMultiple(t *testing.T) {
 			providerEnablers{ipnet.IP4: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodPrimary, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv4", ip4),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello1"), ip4, api.TTLAuto, false, recordComment).
@@ -324,7 +323,7 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodPrimary, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv4", ip4),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
@@ -344,7 +343,7 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodPrimary, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv4", ip4),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
@@ -364,7 +363,7 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodPrimary, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv4", ip4),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
@@ -384,7 +383,7 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, protocol.MethodPrimary, true),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv6", ip6),
 					p.EXPECT().SuppressHint(pp.HintIP6DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP6, domain.FQDN("ip6.hello"), ip6, api.TTLAuto, false, recordComment).
@@ -404,7 +403,7 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, protocol.MethodPrimary, true),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv6", ip6),
 					p.EXPECT().SuppressHint(pp.HintIP6DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP6, domain.FQDN("ip6.hello"), ip6, api.TTLAuto, false, recordComment).
@@ -419,13 +418,13 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true, ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodAlternative, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodAlternative, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v (using 1.0.0.1)", "IPv4", ip4),
 					expectHint1111Blockage(p),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
 						Return(setter.ResponseNoop),
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, protocol.MethodPrimary, true),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv6", ip6),
 					p.EXPECT().SuppressHint(pp.HintIP6DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP6, domain.FQDN("ip6.hello"), ip6, api.TTLAuto, false, recordComment).
@@ -443,13 +442,13 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true, ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodAlternative, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodAlternative, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v (using 1.0.0.1)", "IPv4", ip4),
 					expectHint1111Blockage(p),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
 						Return(setter.ResponseFailed),
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, protocol.MethodPrimary, true),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv6", ip6),
 					p.EXPECT().SuppressHint(pp.HintIP6DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP6, domain.FQDN("ip6.hello"), ip6, api.TTLAuto, false, recordComment).
@@ -467,13 +466,13 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true, ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodAlternative, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodAlternative, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v (using 1.0.0.1)", "IPv4", ip4),
 					expectHint1111Blockage(p),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
 						Return(setter.ResponseNoop),
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, protocol.MethodPrimary, true),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv6", ip6),
 					p.EXPECT().SuppressHint(pp.HintIP6DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP6, domain.FQDN("ip6.hello"), ip6, api.TTLAuto, false, recordComment).
@@ -491,13 +490,13 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true, ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodAlternative, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodAlternative, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v (using 1.0.0.1)", "IPv4", ip4),
 					expectHint1111Blockage(p),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
 						Return(setter.ResponseNoop),
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, protocol.MethodPrimary, true),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv6", ip6),
 					p.EXPECT().SuppressHint(pp.HintIP6DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP6, domain.FQDN("ip6.hello"), ip6, api.TTLAuto, false, recordComment).
@@ -515,10 +514,10 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true, ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(netip.Addr{}, protocol.MethodUnspecified, false),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(netip.Addr{}, provider.MethodUnspecified, false),
 					p.EXPECT().Noticef(pp.EmojiError, "Failed to detect the %s address", "IPv4"),
 					p.EXPECT().Hintf(pp.HintIP4DetectionFails, "If your network does not support IPv4, you can disable it with IP4_PROVIDER=none"), //nolint:lll
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, protocol.MethodPrimary, true),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(ip6, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv6", ip6),
 					p.EXPECT().SuppressHint(pp.HintIP6DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP6, domain.FQDN("ip6.hello"), ip6, api.TTLAuto, false, recordComment).
@@ -535,13 +534,13 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true, ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodAlternative, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodAlternative, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v (using 1.0.0.1)", "IPv4", ip4),
 					expectHint1111Blockage(p),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
 						Return(setter.ResponseNoop),
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(netip.Addr{}, protocol.MethodUnspecified, false),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(netip.Addr{}, provider.MethodUnspecified, false),
 					p.EXPECT().Noticef(pp.EmojiError, "Failed to detect the %s address", "IPv6"),
 					hintIP6DetectionFails(p),
 					s.EXPECT().SetWAFList(gomock.Any(), p, list, wafListDescription,
@@ -556,10 +555,10 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true, ipnet.IP6: true},
 			func(p *mocks.MockPP, pv mockProviders, _ *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(netip.Addr{}, protocol.MethodUnspecified, false),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(netip.Addr{}, provider.MethodUnspecified, false),
 					p.EXPECT().Noticef(pp.EmojiError, "Failed to detect the %s address", "IPv4"),
 					p.EXPECT().Hintf(pp.HintIP4DetectionFails, "If your network does not support IPv4, you can disable it with IP4_PROVIDER=none"), //nolint:lll
-					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(netip.Addr{}, protocol.MethodUnspecified, false),
+					pv[ipnet.IP6].EXPECT().GetIP(gomock.Any(), p, ipnet.IP6).Return(netip.Addr{}, provider.MethodUnspecified, false),
 					p.EXPECT().Noticef(pp.EmojiError, "Failed to detect the %s address", "IPv6"),
 					hintIP6DetectionFails(p),
 				)
@@ -574,9 +573,9 @@ func TestUpdateIPs(t *testing.T) {
 				gomock.InOrder(
 					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).
 						DoAndReturn(
-							func(context.Context, pp.PP, ipnet.Type) (netip.Addr, protocol.Method, bool) {
+							func(context.Context, pp.PP, ipnet.Type) (netip.Addr, provider.Method, bool) {
 								time.Sleep(2 * time.Second)
-								return netip.Addr{}, protocol.MethodUnspecified, false
+								return netip.Addr{}, provider.MethodUnspecified, false
 							},
 						),
 					p.EXPECT().Noticef(pp.EmojiError, "Failed to detect the %s address", "IPv4"),
@@ -594,7 +593,7 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodPrimary, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv4", ip4),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).
@@ -616,7 +615,7 @@ func TestUpdateIPs(t *testing.T) {
 			providerEnablers{ipnet.IP4: true},
 			func(p *mocks.MockPP, pv mockProviders, s *mocks.MockSetter) {
 				gomock.InOrder(
-					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, protocol.MethodPrimary, true),
+					pv[ipnet.IP4].EXPECT().GetIP(gomock.Any(), p, ipnet.IP4).Return(ip4, provider.MethodPrimary, true),
 					p.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address %v", "IPv4", ip4),
 					p.EXPECT().SuppressHint(pp.HintIP4DetectionFails),
 					s.EXPECT().Set(gomock.Any(), p, ipnet.IP4, domain.FQDN("ip4.hello"), ip4, api.TTLAuto, false, recordComment).