Repository: favonia/cloudflare-ddns
Pull request #277 (2022-11-17 07:32:08)

refactor(setter): split Set into Set and Clear

codecov[bot]: # [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
> Merging [#277](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (d3b10d4) into [main](https://codecov.io/gh/favonia/cloudflare-ddns/commit/8a4fbbd8c3fab33193fa5c32c2e0e6738d52c1d1?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (8a4fbbd) will **decrease** coverage by `0.12%`.
> The diff coverage is `94.21%`.

> :exclamation: Current head d3b10d4 differs from pull request most recent head 5342b1d. Consider uploading reports for the commit 5342b1d to get more accurate results

```diff
@@            Coverage Diff             @@
##             main     #277      +/-   ##
==========================================
- Coverage   85.95%   85.82%   -0.13%     
==========================================
  Files          29       29              
  Lines        1986     2018      +32     
==========================================
+ Hits         1707     1732      +25     
- Misses        268      273       +5     
- Partials       11       13       +2     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `85.82% <94.21%> (-0.13%)` | :arrow_down: |

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.

| [Impacted Files](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [internal/provider/base.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvYmFzZS5nbw==) | `100.00% <ø> (ø)` | |
| [internal/provider/ip.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvaXAuZ28=) | `75.00% <75.00%> (-25.00%)` | :arrow_down: |
| [internal/provider/http.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvaHR0cC5nbw==) | `85.71% <86.66%> (+1.09%)` | :arrow_up: |
| [internal/setter/setter.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvc2V0dGVyL3NldHRlci5nbw==) | `97.08% <87.50%> (-2.92%)` | :arrow_down: |
| [internal/provider/cloudflare\_trace.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvY2xvdWRmbGFyZV90cmFjZS5nbw==) | `100.00% <100.00%> (ø)` | |
| [internal/provider/dns.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvZG5zLmdv) | `95.93% <100.00%> (+0.13%)` | :arrow_up: |
| [internal/provider/local.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvbG9jYWwuZ28=) | `100.00% <100.00%> (ø)` | |
| [internal/updater/updater.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/277/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvdXBkYXRlci91cGRhdGVyLmdv) | `100.00% <100.00%> (ø)` | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia)


Patch:
diff --git a/internal/README.markdown b/internal/README.markdown
index b3020ae..5e0e72b 100644
--- a/internal/README.markdown
+++ b/internal/README.markdown
@@ -10,5 +10,5 @@
 - `monitor`: ping the monitoring API, currently only supporting Healthchecks
 - `pp`: pretty print messages with emojis
 - `provider`: find out the public IP
-- `setter`: set the IP of one domain using a DNS service API
+- `setter`: set the IP of one domain using a DNS service API; a setter cannot directly access the config struct
 - `updater`: detect and update the IP of all domains, using `provider` and `setter`
diff --git a/internal/ipnet/ipnet.go b/internal/ipnet/ipnet.go
index a1a845a..d4aa3f6 100644
--- a/internal/ipnet/ipnet.go
+++ b/internal/ipnet/ipnet.go
@@ -4,6 +4,8 @@ package ipnet
 import (
 	"fmt"
 	"net/netip"
+
+	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
 // Type is the type of IP networks.
@@ -48,21 +50,25 @@ func (t Type) Int() int {
 	}
 }
 
-func (t Type) NormalizeIP(ip netip.Addr) (netip.Addr, bool) {
-	if !ip.IsValid() {
-		return ip, false
-	}
-
+// NormalizeIP normalizes an IP into an IPv4 or IPv6 address.
+func (t Type) NormalizeIP(ppfmt pp.PP, ip netip.Addr) (netip.Addr, bool) {
 	switch t {
 	case IP4:
+		if !ip.Is4() && !ip.Is4In6() {
+			ppfmt.Warningf(pp.EmojiError, "%q is not a valid %s address", ip, t.Describe())
+			return netip.Addr{}, false
+		}
 		// Turns an IPv4-mapped IPv6 address back to an IPv4 address
-		ip = ip.Unmap()
-		return ip, ip.Is4()
+		return ip.Unmap(), true
 	case IP6:
-		ip = netip.AddrFrom16(ip.As16())
-		return ip, ip.Is6()
+		if !ip.IsValid() {
+			ppfmt.Warningf(pp.EmojiError, "%q is not a valid %s address", ip, t.Describe())
+			return netip.Addr{}, false
+		}
+		return netip.AddrFrom16(ip.As16()), true
 	default:
-		return ip, true
+		ppfmt.Warningf(pp.EmojiError, "%q is not a valid %s address", ip, t.Describe())
+		return netip.Addr{}, false
 	}
 }
 
diff --git a/internal/mocks/mock_provider.go b/internal/mocks/mock_provider.go
index 15dbad9..b375b60 100644
--- a/internal/mocks/mock_provider.go
+++ b/internal/mocks/mock_provider.go
@@ -38,11 +38,12 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
 }
 
 // GetIP mocks base method.
-func (m *MockProvider) GetIP(arg0 context.Context, arg1 pp.PP, arg2 ipnet.Type) netip.Addr {
+func (m *MockProvider) GetIP(arg0 context.Context, arg1 pp.PP, arg2 ipnet.Type) (netip.Addr, bool) {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetIP", arg0, arg1, arg2)
 	ret0, _ := ret[0].(netip.Addr)
-	return ret0
+	ret1, _ := ret[1].(bool)
+	return ret0, ret1
 }
 
 // GetIP indicates an expected call of GetIP.
diff --git a/internal/mocks/mock_setter.go b/internal/mocks/mock_setter.go
index 67c271f..cdbf1af 100644
--- a/internal/mocks/mock_setter.go
+++ b/internal/mocks/mock_setter.go
@@ -39,6 +39,21 @@ func (m *MockSetter) EXPECT() *MockSetterMockRecorder {
 	return m.recorder
 }
 
+// Clear mocks base method.
+func (m *MockSetter) Clear(arg0 context.Context, arg1 pp.PP, arg2 domain.Domain, arg3 ipnet.Type) (bool, string) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Clear", arg0, arg1, arg2, arg3)
+	ret0, _ := ret[0].(bool)
+	ret1, _ := ret[1].(string)
+	return ret0, ret1
+}
+
+// Clear indicates an expected call of Clear.
+func (mr *MockSetterMockRecorder) Clear(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Clear", reflect.TypeOf((*MockSetter)(nil).Clear), arg0, arg1, arg2, arg3)
+}
+
 // Set mocks base method.
 func (m *MockSetter) Set(arg0 context.Context, arg1 pp.PP, arg2 domain.Domain, arg3 ipnet.Type, arg4 netip.Addr, arg5 api.TTL, arg6 bool) (bool, string) {
 	m.ctrl.T.Helper()
diff --git a/internal/provider/base.go b/internal/provider/base.go
index a41bda5..b225e02 100644
--- a/internal/provider/base.go
+++ b/internal/provider/base.go
@@ -12,7 +12,7 @@ import (
 
 type Provider interface {
 	Name() string
-	GetIP(context.Context, pp.PP, ipnet.Type) netip.Addr
+	GetIP(context.Context, pp.PP, ipnet.Type) (netip.Addr, bool)
 }
 
 func Name(p Provider) string {
diff --git a/internal/provider/cloudflare_trace.go b/internal/provider/cloudflare_trace.go
index fe78a2d..8947c17 100644
--- a/internal/provider/cloudflare_trace.go
+++ b/internal/provider/cloudflare_trace.go
@@ -10,29 +10,29 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-func getIPFromCloudflareTrace(ctx context.Context, ppfmt pp.PP, url string, field string) netip.Addr {
+func getIPFromCloudflareTrace(ctx context.Context, ppfmt pp.PP, url string, field string) (netip.Addr, bool) {
 	c := httpConn{
 		url:         url,
 		method:      http.MethodGet,
 		contentType: "",
 		accept:      "",
 		reader:      nil,
-		extract: func(ppfmt pp.PP, body []byte) netip.Addr {
+		extract: func(ppfmt pp.PP, body []byte) (netip.Addr, bool) {
 			var invalidIP netip.Addr
 
 			re := regexp.MustCompile(`(?m:^` + regexp.QuoteMeta(field) + `=(.*)$)`)
 			matched := re.FindSubmatch(body)
 			if matched == nil {
 				ppfmt.Warningf(pp.EmojiError, `Failed to find the IP address in the response of %q: %s`, url, body)
-				return invalidIP
+				return invalidIP, false
 			}
 			ipString := string(matched[1])
 			ip, err := netip.ParseAddr(ipString)
 			if err != nil {
 				ppfmt.Warningf(pp.EmojiError, `Failed to parse the IP address in the response of %q: %s`, url, ipString)
-				return invalidIP
+				return invalidIP, false
 			}
-			return ip
+			return ip, true
 		},
 	}
 
@@ -64,12 +64,17 @@ func (p *CloudflareTrace) Name() string {
 	return p.ProviderName
 }
 
-func (p *CloudflareTrace) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) netip.Addr {
+func (p *CloudflareTrace) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	param, found := p.Param[ipNet]
 	if !found {
 		ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe())
-		return netip.Addr{}
+		return netip.Addr{}, false
 	}
 
-	return NormalizeIP(ppfmt, ipNet, getIPFromCloudflareTrace(ctx, ppfmt, param.URL, param.Field))
+	ip, ok := getIPFromCloudflareTrace(ctx, ppfmt, param.URL, param.Field)
+	if !ok {
+		return netip.Addr{}, false
+	}
+
+	return ipNet.NormalizeIP(ppfmt, ip)
 }
diff --git a/internal/provider/dns.go b/internal/provider/dns.go
index df6ab36..fd1211e 100644
--- a/internal/provider/dns.go
+++ b/internal/provider/dns.go
@@ -65,7 +65,7 @@ func newDNSQuery(ppfmt pp.PP, id uint16, name string, class dnsmessage.Class) ([
 
 func parseDNSAnswers(ppfmt pp.PP, answers []dnsmessage.Resource,
 	name string, class dnsmessage.Class,
-) netip.Addr {
+) (netip.Addr, bool) {
 	var invalidIP netip.Addr
 	var ipString string
 
@@ -82,7 +82,7 @@ func parseDNSAnswers(ppfmt pp.PP, answers []dnsmessage.Resource,
 
 			if ipString != "" {
 				ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: more than one string in TXT records")
-				return invalidIP
+				return invalidIP, false
 			}
 
 			ipString = s
@@ -91,7 +91,7 @@ func parseDNSAnswers(ppfmt pp.PP, answers []dnsmessage.Resource,
 
 	if ipString == "" {
 		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: no TXT records or all TXT records are empty")
-		return invalidIP
+		return invalidIP, false
 	}
 
 	ip, err := netip.ParseAddr(ipString)
@@ -101,36 +101,36 @@ func parseDNSAnswers(ppfmt pp.PP, answers []dnsmessage.Resource,
 			`Invalid DNS response: failed to parse the IP address in the TXT record: %s`,
 			ipString,
 		)
-		return invalidIP
+		return invalidIP, false
 	}
-	return ip
+	return ip, true
 }
 
-func parseDNSResponse(ppfmt pp.PP, r []byte, id uint16, name string, class dnsmessage.Class) netip.Addr {
+func parseDNSResponse(ppfmt pp.PP, r []byte, id uint16, name string, class dnsmessage.Class) (netip.Addr, bool) {
 	var invalidIP netip.Addr
 
 	var msg dnsmessage.Message
 	if err := msg.Unpack(r); err != nil {
 		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: %v", err)
-		return invalidIP
+		return invalidIP, false
 	}
 
 	switch {
 	case msg.ID != id:
 		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: mismatched transaction ID")
-		return invalidIP
+		return invalidIP, false
 
 	case !msg.Response:
 		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: QR was not set")
-		return invalidIP
+		return invalidIP, false
 
 	case msg.Truncated:
 		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: TC was set")
-		return invalidIP
+		return invalidIP, false
 
 	case msg.RCode != dnsmessage.RCodeSuccess:
 		ppfmt.Warningf(pp.EmojiImpossible, "Invalid DNS response: response code is %v", msg.RCode)
-		return invalidIP
+		return invalidIP, false
 	}
 
 	return parseDNSAnswers(ppfmt, msg.Answers, name, class)
@@ -138,7 +138,7 @@ func parseDNSResponse(ppfmt pp.PP, r []byte, id uint16, name string, class dnsme
 
 func getIPFromDNS(ctx context.Context, ppfmt pp.PP,
 	url string, name string, class dnsmessage.Class,
-) netip.Addr {
+) (netip.Addr, bool) {
 	var invalidIP netip.Addr
 
 	// message ID for the DNS payloads
@@ -146,7 +146,7 @@ func getIPFromDNS(ctx context.Context, ppfmt pp.PP,
 
 	q, ok := newDNSQuery(ppfmt, id, name, class)
 	if !ok {
-		return invalidIP
+		return invalidIP, false
 	}
 
 	c := httpConn{
@@ -155,7 +155,7 @@ func getIPFromDNS(ctx context.Context, ppfmt pp.PP,
 		contentType: "application/dns-message",
 		accept:      "application/dns-message",
 		reader:      bytes.NewReader(q),
-		extract: func(ppfmt pp.PP, body []byte) netip.Addr {
+		extract: func(ppfmt pp.PP, body []byte) (netip.Addr, bool) {
 			return parseDNSResponse(ppfmt, body, id, name, class)
 		},
 	}
@@ -176,12 +176,17 @@ func (p *DNSOverHTTPS) Name() string {
 	return p.ProviderName
 }
 
-func (p *DNSOverHTTPS) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) netip.Addr {
+func (p *DNSOverHTTPS) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	param, found := p.Param[ipNet]
 	if !found {
 		ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe())
-		return netip.Addr{}
+		return netip.Addr{}, false
 	}
 
-	return NormalizeIP(ppfmt, ipNet, getIPFromDNS(ctx, ppfmt, param.URL, param.Name, param.Class))
+	ip, ok := getIPFromDNS(ctx, ppfmt, param.URL, param.Name, param.Class)
+	if !ok {
+		return netip.Addr{}, false
+	}
+
+	return ipNet.NormalizeIP(ppfmt, ip)
 }
diff --git a/internal/provider/http.go b/internal/provider/http.go
index 191e19a..2c45faf 100644
--- a/internal/provider/http.go
+++ b/internal/provider/http.go
@@ -16,16 +16,16 @@ type httpConn struct {
 	contentType string
 	accept      string
 	reader      io.Reader
-	extract     func(pp.PP, []byte) netip.Addr
+	extract     func(pp.PP, []byte) (netip.Addr, bool)
 }
 
-func (d *httpConn) getIP(ctx context.Context, ppfmt pp.PP) netip.Addr {
+func (d *httpConn) getIP(ctx context.Context, ppfmt pp.PP) (netip.Addr, bool) {
 	var invalidIP netip.Addr
 
 	req, err := http.NewRequestWithContext(ctx, d.method, d.url, d.reader)
 	if err != nil {
 		ppfmt.Warningf(pp.EmojiImpossible, "Failed to prepare HTTP(S) request to %q: %v", d.url, err)
-		return invalidIP
+		return invalidIP, false
 	}
 
 	if d.contentType != "" {
@@ -39,34 +39,34 @@ func (d *httpConn) getIP(ctx context.Context, ppfmt pp.PP) netip.Addr {
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
 		ppfmt.Warningf(pp.EmojiError, "Failed to send HTTP(S) request to %q: %v", d.url, err)
-		return invalidIP
+		return invalidIP, false
 	}
 	defer resp.Body.Close()
 
 	body, err := io.ReadAll(resp.Body)
 	if err != nil {
 		ppfmt.Warningf(pp.EmojiError, "Failed to read HTTP(S) response from %q: %v", d.url, err)
-		return invalidIP
+		return invalidIP, false
 	}
 
 	return d.extract(ppfmt, body)
 }
 
-func getIPFromHTTP(ctx context.Context, ppfmt pp.PP, url string) netip.Addr {
+func getIPFromHTTP(ctx context.Context, ppfmt pp.PP, url string) (netip.Addr, bool) {
 	c := httpConn{
 		url:         url,
 		method:      http.MethodGet,
 		contentType: "",
 		accept:      "",
 		reader:      nil,
-		extract: func(_ pp.PP, body []byte) netip.Addr {
+		extract: func(_ pp.PP, body []byte) (netip.Addr, bool) {
 			ipString := string(body)
 			ip, err := netip.ParseAddr(ipString)
 			if err != nil {
 				ppfmt.Errorf(pp.EmojiImpossible, `Failed to parse the IP address in the response of %q: %s`, url, ipString)
-				return netip.Addr{}
+				return netip.Addr{}, false
 			}
-			return ip
+			return ip, true
 		},
 	}
 
@@ -82,12 +82,17 @@ func (p *HTTP) Name() string {
 	return p.ProviderName
 }
 
-func (p *HTTP) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) netip.Addr {
+func (p *HTTP) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	url, found := p.URL[ipNet]
 	if !found {
 		ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe())
-		return netip.Addr{}
+		return netip.Addr{}, false
 	}
 
-	return NormalizeIP(ppfmt, ipNet, getIPFromHTTP(ctx, ppfmt, url))
+	ip, ok := getIPFromHTTP(ctx, ppfmt, url)
+	if !ok {
+		return netip.Addr{}, false
+	}
+
+	return ipNet.NormalizeIP(ppfmt, ip)
 }
diff --git a/internal/provider/ip.go b/internal/provider/ip.go
deleted file mode 100644
index 1ec439b..0000000
--- a/internal/provider/ip.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package provider
-
-import (
-	"net/netip"
-
-	"github.com/favonia/cloudflare-ddns/internal/ipnet"
-	"github.com/favonia/cloudflare-ddns/internal/pp"
-)
-
-func NormalizeIP(ppfmt pp.PP, ipNet ipnet.Type, ip netip.Addr) netip.Addr {
-	var invalidIP netip.Addr
-
-	if !ip.IsValid() {
-		return invalidIP
-	}
-
-	ip, ok := ipNet.NormalizeIP(ip)
-	if !ok {
-		ppfmt.Warningf(pp.EmojiError, "%q is not a valid %s address", ip, ipNet.Describe())
-		return invalidIP
-	}
-
-	return ip
-}
diff --git a/internal/provider/local.go b/internal/provider/local.go
index 2abf21b..c48ecf9 100644
--- a/internal/provider/local.go
+++ b/internal/provider/local.go
@@ -18,23 +18,23 @@ func (p *Local) Name() string {
 	return p.ProviderName
 }
 
-func (p *Local) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) netip.Addr {
+func (p *Local) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	var invalidIP netip.Addr
 
 	remoteUDPAddr, found := p.RemoteUDPAddr[ipNet]
 	if !found {
 		ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe())
-		return invalidIP
+		return invalidIP, false
 	}
 
 	conn, err := net.Dial(ipNet.UDPNetwork(), remoteUDPAddr)
 	if err != nil {
 		ppfmt.Warningf(pp.EmojiError, "Failed to detect a local %s address: %v", ipNet.Describe(), err)
-		return invalidIP
+		return invalidIP, false
 	}
 	defer conn.Close()
 
 	ip := conn.LocalAddr().(*net.UDPAddr).AddrPort().Addr() //nolint:forcetypeassert
 
-	return NormalizeIP(ppfmt, ipNet, ip)
+	return ipNet.NormalizeIP(ppfmt, ip)
 }
diff --git a/internal/setter/base.go b/internal/setter/base.go
index 34c8e14..43661c4 100644
--- a/internal/setter/base.go
+++ b/internal/setter/base.go
@@ -22,4 +22,10 @@ type Setter interface {
 		ttl api.TTL,
 		proxied bool,
 	) (bool, string)
+	Clear(
+		ctx context.Context,
+		ppfmt pp.PP,
+		Domain domain.Domain,
+		IPNetwork ipnet.Type,
+	) (bool, string)
 }
diff --git a/internal/setter/setter.go b/internal/setter/setter.go
index 5b8b89b..614a65e 100644
--- a/internal/setter/setter.go
+++ b/internal/setter/setter.go
@@ -17,25 +17,20 @@ type setter struct {
 }
 
 // partitionRecords partitions record maps into matched and unmatched ones.
+//
+// The target ip must be non-zero.
 func partitionRecords(rmap map[string]netip.Addr, target netip.Addr) (matchedIDs, unmatchedIDs []string) {
-	if target.IsValid() {
-		for id, ip := range rmap {
-			if ip == target {
-				matchedIDs = append(matchedIDs, id)
-			} else {
-				unmatchedIDs = append(unmatchedIDs, id)
-			}
-		}
-	} else {
-		for id := range rmap {
+	for id, ip := range rmap {
+		if ip == target {
+			matchedIDs = append(matchedIDs, id)
+		} else {
 			unmatchedIDs = append(unmatchedIDs, id)
 		}
 	}
 
-	// This is to make Do deterministic so that this package is easier to test.
-	// Otherwise, sorting is not needed. The performance penality should be small
-	// because in most cases the total number of (matched and unmached) records
-	// would be zero or one.
+	// This is to make Set deterministic so that this package is easier to test.
+	// Otherwise, sorting is not needed. The performance penalty is small because in most cases
+	// the total number of (matched and unmatched) records would be zero or one.
 	sort.Strings(matchedIDs)
 	sort.Strings(unmatchedIDs)
 
@@ -51,6 +46,8 @@ func New(_ppfmt pp.PP, handle api.Handle) (Setter, bool) {
 
 // Set calls the DNS service API to update the API of one domain.
 //
+// The ip must be non-zero.
+//
 //nolint:funlen
 func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 	domain domain.Domain, ipnet ipnet.Type, ip netip.Addr, ttl api.TTL, proxied bool,
@@ -61,7 +58,7 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 	rs, ok := s.Handle.ListRecords(ctx, ppfmt, domain, ipnet)
 	if !ok {
 		ppfmt.Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", recordType, domainDescription)
-		return false, fmt.Sprintf("%s %s possibly inconsistent", recordType, domainDescription)
+		return false, fmt.Sprintf("Failed to set %s %s", recordType, domainDescription)
 	}
 
 	// The intention of these two lists is to find or create a good record and then delete everything else.
@@ -72,14 +69,6 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 	// uptodate remembers whether the correct DNS record is already present.
 	uptodate := false
 
-	// If ip is not valid, it is considered "up to date", but we have to delete all existing records.
-	// Setting uptodate to true will trigger the deletion logic later.
-	if !ip.IsValid() {
-		// matchedIDs must be empty, due to how partitionRecords works.
-		// All existing records will be in unmatchedIDsToUpdate.
-		uptodate = true
-	}
-
 	// duplicateMatchedIDs are to be deleted; this is set when uptodate becomes true.
 	var duplicateMatchedIDs []string
 
@@ -170,11 +159,6 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 		}
 	}
 
-	// We meant to clear all records, and we did it!
-	if numUndeletedUnmatched == 0 && !ip.IsValid() {
-		monitorMessage = fmt.Sprintf("Cleared %s %s", recordType, domainDescription)
-	}
-
 	// We should also delete all duplicate records even if they are up to date.
 	// This has lower priority than deleting the stale records.
 	for _, id := range duplicateMatchedIDs {
@@ -189,13 +173,50 @@ func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
 		ppfmt.Errorf(pp.EmojiError,
 			"Failed to complete updating of %s records of %q; records might be inconsistent",
 			recordType, domainDescription)
-		if ip.IsValid() {
-			monitorMessage = fmt.Sprintf("Failed to set %s %s", recordType, domainDescription)
-		} else {
-			monitorMessage = fmt.Sprintf("Failed to clear %s %s", recordType, domainDescription)
-		}
-		return false, monitorMessage
+		return false, fmt.Sprintf("Failed to set %s %s", recordType, domainDescription)
 	}
 
 	return true, monitorMessage
 }
+
+// Clear deletes all managed DNS records.
+func (s *setter) Clear(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipnet ipnet.Type) (bool, string) {
+	recordType := ipnet.RecordType()
+	domainDescription := domain.Describe()
+
+	rmap, ok := s.Handle.ListRecords(ctx, ppfmt, domain, ipnet)
+	if !ok {
+		ppfmt.Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", recordType, domainDescription)
+		return false, fmt.Sprintf("Failed to clear %s %s", recordType, domainDescription)
+	}
+
+	// Sorting is not needed for correctness, but it will make the function deterministic.
+	unmatchedIDs := make([]string, 0, len(rmap))
+	for id := range rmap {
+		unmatchedIDs = append(unmatchedIDs, id)
+	}
+	sort.Strings(unmatchedIDs)
+
+	if len(unmatchedIDs) == 0 {
+		ppfmt.Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", recordType, domainDescription)
+		return true, ""
+	}
+
+	allOk := true
+	for _, id := range unmatchedIDs {
+		if !s.Handle.DeleteRecord(ctx, ppfmt, domain, ipnet, id) {
+			allOk = false
+			continue
+		}
+
+		ppfmt.Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %s)", recordType, domainDescription, id)
+	}
+	if !allOk {
+		ppfmt.Errorf(pp.EmojiError,
+			"Failed to complete updating of %s records of %q; records might be inconsistent",
+			recordType, domainDescription)
+		return false, fmt.Sprintf("Failed to clear %s %s", recordType, domainDescription)
+	}
+
+	return true, fmt.Sprintf("Cleared %s %s", recordType, domainDescription)
+}
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index b73f45a..cf7ccdf 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -24,41 +24,60 @@ func getProxied(ppfmt pp.PP, c *config.Config, domain domain.Domain) bool {
 	return false
 }
 
+// setIP extracts relevant settings from the configuration and calls setter.Set with timeout.
+// ip must be non-zero.
 func setIP(ctx context.Context, ppfmt pp.PP,
 	c *config.Config, s setter.Setter, ipNet ipnet.Type, ip netip.Addr,
 ) (bool, string) {
-	ok := true
+	allOk := true
 	var msgs []string
 
 	for _, domain := range c.Domains[ipNet] {
 		ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout)
 		defer cancel()
 
-		setOk, msg := s.Set(ctx, ppfmt, domain, ipNet, ip, c.TTL, getProxied(ppfmt, c, domain))
-		ok = ok && setOk
+		ok, msg := s.Set(ctx, ppfmt, domain, ipNet, ip, c.TTL, getProxied(ppfmt, c, domain))
+		allOk = allOk && ok
 		if msg != "" {
 			msgs = append(msgs, msg)
 		}
 	}
 
-	return ok, strings.Join(msgs, "\n")
+	return allOk, strings.Join(msgs, "\n")
+}
+
+// clearIP extracts relevant settings from the config struct and calls setter.Clear with a deadline.
+// ip must be non-zero.
+func clearIP(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter, ipNet ipnet.Type) (bool, string) {
+	allOk := true
+	var msgs []string
+
+	for _, domain := range c.Domains[ipNet] {
+		ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout)
+		defer cancel()
+
+		ok, msg := s.Clear(ctx, ppfmt, domain, ipNet)
+		allOk = allOk && ok
+		if msg != "" {
+			msgs = append(msgs, msg)
+		}
+	}
+
+	return allOk, strings.Join(msgs, "\n")
 }
 
 var MessageShouldDisplay = map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true} //nolint:gochecknoglobals
 
-func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Type) netip.Addr {
+func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Type) (netip.Addr, bool) {
 	ctx, cancel := context.WithTimeout(ctx, c.DetectionTimeout)
 	defer cancel()
 
-	ip := c.Provider[ipNet].GetIP(ctx, ppfmt, ipNet)
-	if ip.IsValid() {
-		MessageShouldDisplay[ipNet] = false
+	ip, ok := c.Provider[ipNet].GetIP(ctx, ppfmt, ipNet)
+	if ok {
 		ppfmt.Infof(pp.EmojiInternet, "Detected the %s address: %v", ipNet.Describe(), ip)
 	} else {
 		ppfmt.Errorf(pp.EmojiError, "Failed to detect the %s address", ipNet.Describe())
-
 		if MessageShouldDisplay[ipNet] {
-			MessageShouldDisplay[ipNet] = false
 			switch ipNet {
 			case ipnet.IP6:
 				ppfmt.Infof(pp.EmojiConfig, "If you are using Docker or Kubernetes, IPv6 often requires additional setups")     //nolint:lll
@@ -69,46 +88,47 @@ func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Ty
 			}
 		}
 	}
-	return ip
+	MessageShouldDisplay[ipNet] = false
+	return ip, ok
 }
 
 func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) (bool, string) {
-	ok := true
+	allOk := true
 	var msgs []string
 
 	for _, ipNet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 		if c.Provider[ipNet] != nil {
-			ip := detectIP(ctx, ppfmt, c, ipNet)
-			if !ip.IsValid() {
+			ip, ok := detectIP(ctx, ppfmt, c, ipNet)
+			if !ok {
 				// We can't detect the new IP address. It's probably better to leave existing IP addresses alone.
-				ok = false
+				allOk = false
 				continue
 			}
 
-			setOk, msg := setIP(ctx, ppfmt, c, s, ipNet, ip)
-			ok = ok && setOk
+			ok, msg := setIP(ctx, ppfmt, c, s, ipNet, ip)
+			allOk = allOk && ok
 			if msg != "" {
 				msgs = append(msgs, msg)
 			}
 		}
 	}
 
-	return ok, strings.Join(msgs, "\n")
+	return allOk, strings.Join(msgs, "\n")
 }
 
 func ClearIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) (bool, string) {
-	ok := true
+	allOk := true
 	var msgs []string
 
 	for _, ipNet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
 		if c.Provider[ipNet] != nil {
-			setOk, msg := setIP(ctx, ppfmt, c, s, ipNet, netip.Addr{})
-			ok = ok && setOk
+			ok, msg := clearIP(ctx, ppfmt, c, s, ipNet)
+			allOk = allOk && ok
 			if msg != "" {
 				msgs = append(msgs, msg)
 			}
 		}
 	}
 
-	return ok, strings.Join(msgs, "\n")
+	return allOk, strings.Join(msgs, "\n")
 }


Test patch:
diff --git a/internal/ipnet/ipnet_test.go b/internal/ipnet/ipnet_test.go
index 62d2b2d..d305a98 100644
--- a/internal/ipnet/ipnet_test.go
+++ b/internal/ipnet/ipnet_test.go
@@ -4,9 +4,12 @@ import (
 	"net/netip"
 	"testing"
 
+	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
 func mustIP(ip string) netip.Addr {
@@ -70,22 +73,50 @@ func TestInt(t *testing.T) {
 func TestNormalizeIP(t *testing.T) {
 	t.Parallel()
 	for name, tc := range map[string]struct {
-		ipNet    ipnet.Type
-		ip       netip.Addr
-		expected netip.Addr
-		ok       bool
+		ipNet         ipnet.Type
+		ip            netip.Addr
+		expected      netip.Addr
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
 	}{
-		"4-1::2":             {ipnet.IP4, mustIP("1::2"), mustIP("1::2"), false},
-		"4-::ffff:0a0a:0a0a": {ipnet.IP4, mustIP("::ffff:0a0a:0a0a"), mustIP("10.10.10.10"), true},
-		"6-1::2":             {ipnet.IP6, mustIP("1::2"), mustIP("1::2"), true},
-		"6-10.10.10.10":      {ipnet.IP6, mustIP("10.10.10.10"), mustIP("::ffff:10.10.10.10"), true},
-		"100-nil":            {100, netip.Addr{}, netip.Addr{}, false},
-		"100-10.10.10.10":    {100, mustIP("10.10.10.10"), mustIP("10.10.10.10"), true},
+		"4-1::2": {
+			ipnet.IP4, mustIP("1::2"),
+			netip.Addr{},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiError, "%q is not a valid %s address", mustIP("1::2"), "IPv4")
+			},
+		},
+		"4-::ffff:0a0a:0a0a": {ipnet.IP4, mustIP("::ffff:0a0a:0a0a"), mustIP("10.10.10.10"), true, nil},
+		"6-1::2":             {ipnet.IP6, mustIP("1::2"), mustIP("1::2"), true, nil},
+		"6-10.10.10.10":      {ipnet.IP6, mustIP("10.10.10.10"), mustIP("::ffff:10.10.10.10"), true, nil},
+		"6-invalid": {
+			ipnet.IP6,
+			netip.Addr{},
+			netip.Addr{},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiError, "%q is not a valid %s address", netip.Addr{}, "IPv6")
+			},
+		},
+		"100-10.10.10.10": {
+			100, mustIP("10.10.10.10"),
+			netip.Addr{},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiError, "%q is not a valid %s address", mustIP("10.10.10.10"), "<unrecognized IP network>") //nolint:lll
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			t.Parallel()
-			ip, ok := tc.ipNet.NormalizeIP(tc.ip)
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ip, ok := tc.ipNet.NormalizeIP(mockPP, tc.ip)
 			require.Equal(t, tc.expected, ip)
 			require.Equal(t, tc.ok, ok)
 		})
diff --git a/internal/provider/cloudflare_trace_test.go b/internal/provider/cloudflare_trace_test.go
index 51576dd..5d2dcaf 100644
--- a/internal/provider/cloudflare_trace_test.go
+++ b/internal/provider/cloudflare_trace_test.go
@@ -165,8 +165,9 @@ func TestCloudflareTraceGetIP(t *testing.T) {
 				if tc.prepareMockPP != nil {
 					tc.prepareMockPP(mockPP)
 				}
-				ip := provider.GetIP(context.Background(), mockPP, tc.ipNet)
+				ip, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet)
 				require.Equal(t, tc.expected, ip)
+				require.Equal(t, tc.expected.IsValid(), ok)
 			})
 		}
 	})
diff --git a/internal/provider/dns_test.go b/internal/provider/dns_test.go
index a1650b3..0d83782 100644
--- a/internal/provider/dns_test.go
+++ b/internal/provider/dns_test.go
@@ -584,8 +584,9 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 			if tc.prepareMockPP != nil {
 				tc.prepareMockPP(mockPP)
 			}
-			ip := provider.GetIP(context.Background(), mockPP, tc.ipNet)
+			ip, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet)
 			require.Equal(t, tc.expected, ip)
+			require.Equal(t, tc.expected.IsValid(), ok)
 		})
 	}
 }
diff --git a/internal/provider/http_test.go b/internal/provider/http_test.go
index 8868f31..980c2c4 100644
--- a/internal/provider/http_test.go
+++ b/internal/provider/http_test.go
@@ -140,8 +140,9 @@ func TestHTTPGetIP(t *testing.T) {
 				if tc.prepareMockPP != nil {
 					tc.prepareMockPP(mockPP)
 				}
-				ip := provider.GetIP(context.Background(), mockPP, tc.ipNet)
+				ip, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet)
 				require.Equal(t, tc.expected, ip)
+				require.Equal(t, tc.expected.IsValid(), ok)
 			})
 		}
 	})
diff --git a/internal/provider/local_test.go b/internal/provider/local_test.go
index 511fde9..1947b25 100644
--- a/internal/provider/local_test.go
+++ b/internal/provider/local_test.go
@@ -25,6 +25,7 @@ func TestLocalName(t *testing.T) {
 	require.Equal(t, "very secret name", provider.Name(p))
 }
 
+//nolint:funlen
 func TestLocalGetIP(t *testing.T) {
 	t.Parallel()
 
@@ -82,8 +83,9 @@ func TestLocalGetIP(t *testing.T) {
 			if tc.prepareMockPP != nil {
 				tc.prepareMockPP(mockPP)
 			}
-			ip := provider.GetIP(context.Background(), mockPP, tc.ipNet)
+			ip, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet)
 			require.Equal(t, tc.expected, ip)
+			require.Equal(t, tc.expected.IsValid(), ok)
 		})
 	}
 }
diff --git a/internal/setter/setter_test.go b/internal/setter/setter_test.go
index 7ed3317..6c9a82f 100644
--- a/internal/setter/setter_test.go
+++ b/internal/setter/setter_test.go
@@ -28,9 +28,8 @@ func TestSet(t *testing.T) {
 		record3   = "record3"
 	)
 	var (
-		ip1       = netip.MustParseAddr("::1")
-		ip2       = netip.MustParseAddr("::2")
-		invalidIP = netip.Addr{}
+		ip1 = netip.MustParseAddr("::1")
+		ip2 = netip.MustParseAddr("::2")
 	)
 
 	for name, tc := range map[string]struct {
@@ -42,19 +41,6 @@ func TestSet(t *testing.T) {
 		prepareMockPP     func(m *mocks.MockPP)
 		prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle)
 	}{
-		"0-nil/1-true": {
-			invalidIP,
-			true,
-			"",
-			1,
-			true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", "AAAA", "sub.test.org")
-			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
-				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, true)
-			},
-		},
 		"0/1-false": {
 			ip1,
 			true,
@@ -113,38 +99,6 @@ func TestSet(t *testing.T) {
 				)
 			},
 		},
-		"1unmatched-nil/300-false": {
-			invalidIP,
-			true,
-			`Cleared AAAA sub.test.org`,
-			300,
-			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1) //nolint:lll
-			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
-				gomock.InOrder(
-					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true),
-					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
-				)
-			},
-		},
-		"1unmatched-nil/fail/300-false": {
-			invalidIP,
-			false,
-			`Failed to clear AAAA sub.test.org`,
-			300,
-			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiError, "Failed to complete updating of %s records of %q; records might be inconsistent", "AAAA", "sub.test.org") //nolint:lll
-			},
-			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
-				gomock.InOrder(
-					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true),
-					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(false),
-				)
-			},
-		},
 		"1matched/300-false": {
 			ip1,
 			true,
@@ -324,7 +278,7 @@ func TestSet(t *testing.T) {
 		"listfail/300-false": {
 			ip1,
 			false,
-			`AAAA sub.test.org possibly inconsistent`,
+			`Failed to set AAAA sub.test.org`,
 			300,
 			false,
 			func(m *mocks.MockPP) {
@@ -334,12 +288,94 @@ func TestSet(t *testing.T) {
 				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(nil, false)
 			},
 		},
-		"impossible-invalidIP-records/300-false": {
-			invalidIP,
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			mockCtrl := gomock.NewController(t)
+
+			ctx := context.Background()
+
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			mockHandle := mocks.NewMockHandle(mockCtrl)
+			if tc.prepareMockHandle != nil {
+				tc.prepareMockHandle(ctx, mockPP, mockHandle)
+			}
+
+			s, ok := setter.New(mockPP, mockHandle)
+			require.True(t, ok)
+
+			ok, msg := s.Set(ctx, mockPP, domain, ipNetwork, tc.ip, tc.ttl, tc.proxied)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.msg, msg)
+		})
+	}
+}
+
+//nolint:funlen
+func TestClear(t *testing.T) {
+	t.Parallel()
+
+	const (
+		domain    = domain.FQDN("sub.test.org")
+		ipNetwork = ipnet.IP6
+		record1   = "record1"
+		record2   = "record2"
+		record3   = "record3"
+	)
+	var (
+		ip1       = netip.MustParseAddr("::1")
+		invalidIP = netip.Addr{}
+	)
+
+	for name, tc := range map[string]struct {
+		ok                bool
+		msg               string
+		prepareMockPP     func(m *mocks.MockPP)
+		prepareMockHandle func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle)
+	}{
+		"0": {
+			true,
+			``,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiAlreadyDone, "The %s records of %q are already up to date", "AAAA", "sub.test.org")
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{}, true)
+			},
+		},
+		"1unmatched": {
 			true,
 			`Cleared AAAA sub.test.org`,
-			300,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1) //nolint:lll
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
+				)
+			},
+		},
+		"1unmatched/fail": {
 			false,
+			`Failed to clear AAAA sub.test.org`,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiError, "Failed to complete updating of %s records of %q; records might be inconsistent", "AAAA", "sub.test.org") //nolint:lll
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				gomock.InOrder(
+					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1}, true),
+					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(false),
+				)
+			},
+		},
+		"impossible-records": {
+			true,
+			`Cleared AAAA sub.test.org`,
 			func(m *mocks.MockPP) {
 				gomock.InOrder(
 					m.EXPECT().Noticef(pp.EmojiDeleteRecord, "Deleted a stale %s record of %q (ID: %s)", "AAAA", "sub.test.org", record1), //nolint:lll
@@ -349,12 +385,21 @@ func TestSet(t *testing.T) {
 			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
 				gomock.InOrder(
 					m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(map[string]netip.Addr{record1: ip1, record2: invalidIP}, true), //nolint:lll
-
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record1).Return(true),
 					m.EXPECT().DeleteRecord(ctx, ppfmt, domain, ipNetwork, record2).Return(true),
 				)
 			},
 		},
+		"listfail": {
+			false,
+			`Failed to clear AAAA sub.test.org`,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiError, "Failed to retrieve the current %s records of %q", "AAAA", "sub.test.org")
+			},
+			func(ctx context.Context, ppfmt pp.PP, m *mocks.MockHandle) {
+				m.EXPECT().ListRecords(ctx, ppfmt, domain, ipNetwork).Return(nil, false)
+			},
+		},
 	} {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
@@ -375,7 +420,7 @@ func TestSet(t *testing.T) {
 			s, ok := setter.New(mockPP, mockHandle)
 			require.True(t, ok)
 
-			ok, msg := s.Set(ctx, mockPP, domain, ipNetwork, tc.ip, tc.ttl, tc.proxied)
+			ok, msg := s.Clear(ctx, mockPP, domain, ipNetwork)
 			require.Equal(t, tc.ok, ok)
 			require.Equal(t, tc.msg, msg)
 		})
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index 31234f9..a5b2d05 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -39,8 +39,12 @@ func TestUpdateIPs(t *testing.T) {
 	}
 
 	type mockproviders = map[ipnet.Type]func(ppfmt pp.PP, m *mocks.MockProvider)
-	provider4 := func(ppfmt pp.PP, m *mocks.MockProvider) { m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4).Return(ip4) }
-	provider6 := func(ppfmt pp.PP, m *mocks.MockProvider) { m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(ip6) }
+	provider4 := func(ppfmt pp.PP, m *mocks.MockProvider) {
+		m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4).Return(ip4, true)
+	}
+	provider6 := func(ppfmt pp.PP, m *mocks.MockProvider) {
+		m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(ip6, true)
+	}
 
 	type mockproxied = map[domain.Domain]bool
 	proxiedNone := mockproxied{domain4: false, domain6: false}
@@ -167,7 +171,7 @@ func TestUpdateIPs(t *testing.T) {
 			},
 			mockproviders{
 				ipnet.IP4: func(ppfmt pp.PP, m *mocks.MockProvider) {
-					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4).Return(netip.Addr{})
+					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4).Return(netip.Addr{}, false)
 				},
 				ipnet.IP6: provider6,
 			},
@@ -193,7 +197,7 @@ func TestUpdateIPs(t *testing.T) {
 			mockproviders{
 				ipnet.IP4: provider4,
 				ipnet.IP6: func(ppfmt pp.PP, m *mocks.MockProvider) {
-					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(netip.Addr{})
+					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(netip.Addr{}, false)
 				},
 			},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
@@ -215,7 +219,7 @@ func TestUpdateIPs(t *testing.T) {
 			mockproviders{
 				ipnet.IP4: provider4,
 				ipnet.IP6: func(ppfmt pp.PP, m *mocks.MockProvider) {
-					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(netip.Addr{})
+					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(netip.Addr{}, false)
 				},
 			},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
@@ -240,10 +244,10 @@ func TestUpdateIPs(t *testing.T) {
 			},
 			mockproviders{
 				ipnet.IP4: func(ppfmt pp.PP, m *mocks.MockProvider) {
-					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4).Return(netip.Addr{})
+					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4).Return(netip.Addr{}, false)
 				},
 				ipnet.IP6: func(ppfmt pp.PP, m *mocks.MockProvider) {
-					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(netip.Addr{})
+					m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(netip.Addr{}, false)
 				},
 			},
 			nil,
@@ -345,7 +349,7 @@ func TestClearIPs(t *testing.T) {
 			nil,
 			mockproviders{ipnet.IP4: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain4, ipnet.IP4, netip.Addr{}, api.TTL(1), false).Return(true, "hello")
+				m.EXPECT().Clear(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(true, "hello")
 			},
 		},
 		"ip4only/setfail": {
@@ -357,7 +361,7 @@ func TestClearIPs(t *testing.T) {
 			nil,
 			mockproviders{ipnet.IP4: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain4, ipnet.IP4, netip.Addr{}, api.TTL(1), false).Return(false, "err")
+				m.EXPECT().Clear(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(false, "err")
 			},
 		},
 		"ip6only": {
@@ -369,7 +373,7 @@ func TestClearIPs(t *testing.T) {
 			nil,
 			mockproviders{ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain6, ipnet.IP6, netip.Addr{}, api.TTL(1), false).Return(true, "")
+				m.EXPECT().Clear(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(true, "")
 			},
 		},
 		"ip6only/setfail": {
@@ -381,7 +385,7 @@ func TestClearIPs(t *testing.T) {
 			nil,
 			mockproviders{ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
-				m.EXPECT().Set(gomock.Any(), ppfmt, domain6, ipnet.IP6, netip.Addr{}, api.TTL(1), false).Return(false, "test")
+				m.EXPECT().Clear(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(false, "test")
 			},
 		},
 		"both": {
@@ -394,8 +398,8 @@ func TestClearIPs(t *testing.T) {
 			mockproviders{ipnet.IP4: true, ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain4, ipnet.IP4, netip.Addr{}, api.TTL(1), false).Return(true, "both"),
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain6, ipnet.IP6, netip.Addr{}, api.TTL(1), false).Return(true, "neither"),
+					m.EXPECT().Clear(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(true, "both"),
+					m.EXPECT().Clear(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(true, "neither"),
 				)
 			},
 		},
@@ -409,8 +413,8 @@ func TestClearIPs(t *testing.T) {
 			mockproviders{ipnet.IP4: true, ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain4, ipnet.IP4, netip.Addr{}, api.TTL(1), false).Return(false, ""),
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain6, ipnet.IP6, netip.Addr{}, api.TTL(1), false).Return(true, "999"),
+					m.EXPECT().Clear(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(false, ""),
+					m.EXPECT().Clear(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(true, "999"),
 				)
 			},
 		},
@@ -424,8 +428,8 @@ func TestClearIPs(t *testing.T) {
 			mockproviders{ipnet.IP4: true, ipnet.IP6: true},
 			func(ppfmt pp.PP, m *mocks.MockSetter) {
 				gomock.InOrder(
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain4, ipnet.IP4, netip.Addr{}, api.TTL(1), false).Return(true, "1"),
-					m.EXPECT().Set(gomock.Any(), ppfmt, domain6, ipnet.IP6, netip.Addr{}, api.TTL(1), false).Return(false, "2"),
+					m.EXPECT().Clear(gomock.Any(), ppfmt, domain4, ipnet.IP4).Return(true, "1"),
+					m.EXPECT().Clear(gomock.Any(), ppfmt, domain6, ipnet.IP6).Return(false, "2"),
 				)
 			},
 		},