Repository: favonia/cloudflare-ddns
Pull request #293 (2022-11-25 22:49:52)

docs: documenting internal packages

codecov[bot]: # [Codecov](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
> Merging [#293](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (b862e4a) into [main](https://codecov.io/gh/favonia/cloudflare-ddns/commit/236f1b264d81da3eb63e1f03f74f71258427669a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (236f1b2) will **decrease** coverage by `0.51%`.
> The diff coverage is `73.63%`.

```diff
@@            Coverage Diff             @@
##             main     #293      +/-   ##
==========================================
- Coverage   86.32%   85.81%   -0.52%     
==========================================
  Files          28       28              
  Lines        2040     2072      +32     
==========================================
+ Hits         1761     1778      +17     
- Misses        268      280      +12     
- Partials       11       14       +3     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `85.81% <73.63%> (-0.52%)` | :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/293?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | Coverage Δ | |
|---|---|---|
| [cmd/ddns/ddns.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-Y21kL2RkbnMvZGRucy5nbw==) | `0.00% <0.00%> (ø)` | |
| [internal/api/ttl.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvYXBpL3R0bC5nbw==) | `100.00% <ø> (ø)` | |
| [internal/cron/printer.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY3Jvbi9wcmludGVyLmdv) | `100.00% <ø> (ø)` | |
| [internal/domain/domain.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvZG9tYWluL2RvbWFpbi5nbw==) | `100.00% <ø> (ø)` | |
| [internal/domainexp/lexer.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvZG9tYWluZXhwL2xleGVyLmdv) | `87.50% <ø> (ø)` | |
| [internal/domainexp/parser.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvZG9tYWluZXhwL3BhcnNlci5nbw==) | `100.00% <ø> (ø)` | |
| [internal/file/file.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvZmlsZS9maWxlLmdv) | `73.33% <ø> (ø)` | |
| [internal/pp/fmt.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHAvZm10Lmdv) | `100.00% <ø> (ø)` | |
| [internal/provider/base.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/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/cloudflare\_doh.go](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvY2xvdWRmbGFyZV9kb2guZ28=) | `100.00% <ø> (ø)` | |
| ... and [18 more](https://codecov.io/gh/favonia/cloudflare-ddns/pull/293/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) | |

: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/.github/workflows/cflite_pr.yml b/.github/workflows/cflite_pr.yml
index 35c00a6..7106881 100644
--- a/.github/workflows/cflite_pr.yml
+++ b/.github/workflows/cflite_pr.yml
@@ -1,4 +1,4 @@
-name: ClusterFuzzLite (CI)
+name: ClusterFuzzLite (PR)
 on: pull_request
 permissions: read-all
 jobs:
@@ -35,7 +35,7 @@ jobs:
         uses: google/clusterfuzzlite/actions/run_fuzzers@1e163f06cba7820da5154ac9fe1a32d7fe6f73a3
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
-          fuzz-seconds: 600
+          fuzz-seconds: 200
           mode: "code-change"
           sanitizer: ${{ matrix.sanitizer }}
           storage-repo: https://${{ secrets.PERSONAL_ACCESS_TOKEN }}@github.com/favonia/cloudflare-ddns-fuzzing-corpus.git
diff --git a/cmd/ddns/ddns.go b/cmd/ddns/ddns.go
index 6fc4d99..4b9aa89 100644
--- a/cmd/ddns/ddns.go
+++ b/cmd/ddns/ddns.go
@@ -10,15 +10,14 @@ import (
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/config"
-	"github.com/favonia/cloudflare-ddns/internal/monitor"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 	"github.com/favonia/cloudflare-ddns/internal/updater"
 )
 
 const (
-	IntervalUnit     = time.Second
-	IntervalLargeGap = time.Second * 10
+	intervalUnit     = time.Second
+	intervalLargeGap = time.Second * 10
 )
 
 // signalWait returns false if the alarm is triggered before other signals come.
@@ -32,6 +31,8 @@ func signalWait(signal chan os.Signal, d time.Duration) (os.Signal, bool) {
 	}
 }
 
+// Version is the version of the updater that will be shown in the output.
+// This is to be overwritten by the linker argument -X main.Version=version.
 var Version string //nolint:gochecknoglobals
 
 func formatName() string {
@@ -46,10 +47,10 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, api.Handle, s
 	bye := func() {
 		// Usually, this is called only after initConfig,
 		// but we are exiting early.
-		monitor.StartAll(ctx, ppfmt, c.Monitors, formatName())
+		c.Monitor.Start(ctx, ppfmt, formatName())
 
 		ppfmt.Noticef(pp.EmojiBye, "Bye!")
-		monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "configuration errors")
+		c.Monitor.ExitStatus(ctx, ppfmt, 1, "configuration errors")
 		os.Exit(1)
 	}
 
@@ -80,9 +81,9 @@ func stopUpdating(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.S
 	if c.DeleteOnStop {
 		ppfmt.Noticef(pp.EmojiClearRecord, "Deleting all managed records . . .")
 		if ok, msg := updater.ClearIPs(ctx, ppfmt, c, s); ok {
-			monitor.LogAll(ctx, ppfmt, c.Monitors, msg)
+			c.Monitor.Log(ctx, ppfmt, msg)
 		} else {
-			monitor.FailureAll(ctx, ppfmt, c.Monitors, msg)
+			c.Monitor.Failure(ctx, ppfmt, msg)
 		}
 	}
 }
@@ -116,7 +117,7 @@ func main() { //nolint:funlen
 	c, h, s := initConfig(ctx, ppfmt)
 
 	// Start the tool now
-	monitor.StartAll(ctx, ppfmt, c.Monitors, formatName())
+	c.Monitor.Start(ctx, ppfmt, formatName())
 
 	first := true
 mainLoop:
@@ -128,12 +129,12 @@ mainLoop:
 		// Update the IP
 		if !first || c.UpdateOnStart {
 			if ok, msg := updater.UpdateIPs(ctx, ppfmt, c, s); ok {
-				monitor.SuccessAll(ctx, ppfmt, c.Monitors, msg)
+				c.Monitor.Success(ctx, ppfmt, msg)
 			} else {
-				monitor.FailureAll(ctx, ppfmt, c.Monitors, msg)
+				c.Monitor.Failure(ctx, ppfmt, msg)
 			}
 		} else {
-			monitor.SuccessAll(ctx, ppfmt, c.Monitors, "")
+			c.Monitor.Success(ctx, ppfmt, "")
 		}
 		first = false
 
@@ -142,22 +143,22 @@ mainLoop:
 			ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
 			stopUpdating(ctx, ppfmt, c, s)
 			ppfmt.Noticef(pp.EmojiBye, "Bye!")
-			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, "Not scheduled updates")
+			c.Monitor.ExitStatus(ctx, ppfmt, 0, "Not scheduled updates")
 			break mainLoop
 		}
 
 		// Display the remaining time interval
 		interval := time.Until(next)
 		switch {
-		case interval < -IntervalLargeGap:
+		case interval < -intervalLargeGap:
 			ppfmt.Infof(pp.EmojiNow, "Checking the IP addresses now (running behind by %v) . . .",
-				-interval.Round(IntervalUnit))
-		case interval < IntervalUnit:
+				-interval.Round(intervalUnit))
+		case interval < intervalUnit:
 			ppfmt.Infof(pp.EmojiNow, "Checking the IP addresses now . . .")
-		case interval < IntervalLargeGap:
-			ppfmt.Infof(pp.EmojiNow, "Checking the IP addresses in less than %v . . .", IntervalLargeGap)
+		case interval < intervalLargeGap:
+			ppfmt.Infof(pp.EmojiNow, "Checking the IP addresses in less than %v . . .", intervalLargeGap)
 		default:
-			ppfmt.Infof(pp.EmojiAlarm, "Checking the IP addresses in about %v . . .", interval.Round(IntervalUnit))
+			ppfmt.Infof(pp.EmojiAlarm, "Checking the IP addresses in about %v . . .", interval.Round(intervalUnit))
 		}
 
 		// Wait for the next signal or the alarm, whichever comes first
@@ -173,14 +174,14 @@ mainLoop:
 
 			ppfmt.Noticef(pp.EmojiRepeatOnce, "Restarting . . .")
 			c, h, s = initConfig(ctx, ppfmt)
-			monitor.LogAll(ctx, ppfmt, c.Monitors, "Restarted")
+			c.Monitor.Log(ctx, ppfmt, "Restarted")
 			continue mainLoop
 
 		case syscall.SIGINT, syscall.SIGTERM:
 			ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
 			stopUpdating(ctx, ppfmt, c, s)
 			ppfmt.Noticef(pp.EmojiBye, "Bye!")
-			monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, fmt.Sprintf("Signal: %v", sig))
+			c.Monitor.ExitStatus(ctx, ppfmt, 0, fmt.Sprintf("Signal: %v", sig))
 			break mainLoop
 
 		default:
diff --git a/go.mod b/go.mod
index ea546d4..5d367b2 100644
--- a/go.mod
+++ b/go.mod
@@ -2,8 +2,6 @@ module github.com/favonia/cloudflare-ddns
 
 go 1.19
 
-retract [v0.0.0, v1.99.0] // Prevent publication in pkg.go.dev for now.
-
 require (
 	github.com/cloudflare/cloudflare-go v0.55.0
 	github.com/golang/mock v1.6.0
diff --git a/internal/api/base.go b/internal/api/base.go
index 67085f3..c523c5f 100644
--- a/internal/api/base.go
+++ b/internal/api/base.go
@@ -1,3 +1,4 @@
+// Package api implements protocols to update DNS records.
 package api
 
 import (
@@ -14,21 +15,25 @@ import (
 
 // A Handle represents a generic API to update DNS records. Currently, the only implementation is Cloudflare.
 type Handle interface {
-	// List DNS records.
+	// ListRecords lists all matching DNS records.
 	ListRecords(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type) (map[string]netip.Addr, bool)
-	// Delete one DNS record.
+
+	// DeleteRecord deletes one DNS record.
 	DeleteRecord(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type, id string) bool
-	// Update one DNS record.
+
+	// UpdateRecord updates one DNS record.
 	UpdateRecord(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type, id string, ip netip.Addr) bool
-	// Create one DNS record.
+
+	// CreateRecord creates one DNS record.
 	CreateRecord(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type,
 		ip netip.Addr, ttl TTL, proxied bool) (string, bool)
-	// Flush the API cache.
+
+	// FlushCache flushes the API cache. Flushing should automatically happen when other operations encounter errors.
 	FlushCache()
 }
 
 // An Auth contains authentication information.
 type Auth interface {
-	// Use the authentication information to create a Handle.
+	// New uses the authentication information to create a Handle.
 	New(context.Context, pp.PP, time.Duration) (Handle, bool)
 }
diff --git a/internal/api/cloudflare.go b/internal/api/cloudflare.go
index bf34b4c..fbc4b57 100644
--- a/internal/api/cloudflare.go
+++ b/internal/api/cloudflare.go
@@ -13,7 +13,8 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-type Cache = struct {
+// CloudflareCache holds the previous repsonses from the Cloudflare API.
+type CloudflareCache = struct {
 	listRecords  map[ipnet.Type]*ttlcache.Cache[string, map[string]netip.Addr]
 	activeZones  *ttlcache.Cache[string, []string]
 	zoneOfDomain *ttlcache.Cache[string, string]
@@ -30,18 +31,21 @@ func newCache[K comparable, V any](cacheExpiration time.Duration) *ttlcache.Cach
 	return cache
 }
 
+// A CloudflareHandle implements the [Handle] interface with the Cloudflare API.
 type CloudflareHandle struct {
 	cf        *cloudflare.API
 	accountID string
-	cache     Cache
+	cache     CloudflareCache
 }
 
+// A CloudflareAuth implements the [Auth] interface, holding the authentication data to create a [CloudflareHandle].
 type CloudflareAuth struct {
 	Token     string
 	AccountID string
 	BaseURL   string
 }
 
+// New creates a [CloudflareHandle] from the authentication data.
 func (t *CloudflareAuth) New(ctx context.Context, ppfmt pp.PP, cacheExpiration time.Duration) (Handle, bool) {
 	handle, err := cloudflare.NewWithAPIToken(t.Token)
 	if err != nil {
@@ -57,14 +61,14 @@ func (t *CloudflareAuth) New(ctx context.Context, ppfmt pp.PP, cacheExpiration t
 	// this is not needed, but is helpful for diagnosing the problem
 	if _, err := handle.VerifyAPIToken(ctx); err != nil {
 		ppfmt.Errorf(pp.EmojiUserError, "The Cloudflare API token could not be verified: %v", err)
-		ppfmt.Errorf(pp.EmojiUserError, "Please double-check CF_API_TOKEN or CF_API_TOKEN_FILE")
+		ppfmt.Errorf(pp.EmojiUserError, "Please double-check the value of CF_API_TOKEN or CF_API_TOKEN_FILE")
 		return nil, false
 	}
 
 	return &CloudflareHandle{
 		cf:        handle,
 		accountID: t.AccountID,
-		cache: Cache{
+		cache: CloudflareCache{
 			listRecords: map[ipnet.Type]*ttlcache.Cache[string, map[string]netip.Addr]{
 				ipnet.IP4: newCache[string, map[string]netip.Addr](cacheExpiration),
 				ipnet.IP6: newCache[string, map[string]netip.Addr](cacheExpiration),
@@ -75,6 +79,7 @@ func (t *CloudflareAuth) New(ctx context.Context, ppfmt pp.PP, cacheExpiration t
 	}, true
 }
 
+// FlushCache flushes the API cache.
 func (h *CloudflareHandle) FlushCache() {
 	for _, cache := range h.cache.listRecords {
 		cache.DeleteAll()
@@ -83,7 +88,7 @@ func (h *CloudflareHandle) FlushCache() {
 	h.cache.zoneOfDomain.DeleteAll()
 }
 
-// ActiveZones lists all active zones of the given name.
+// ActiveZones returns a list of zone IDs with the zone name.
 func (h *CloudflareHandle) ActiveZones(ctx context.Context, ppfmt pp.PP, name string) ([]string, bool) {
 	// WithZoneFilters does not work with the empty zone name,
 	// and the owner of the DNS root zone will not be managed by Cloudflare anyways!
@@ -131,6 +136,7 @@ func (h *CloudflareHandle) ActiveZones(ctx context.Context, ppfmt pp.PP, name st
 	return ids, true
 }
 
+// ZoneOfDomain finds the active zone ID governing a particular domain.
 func (h *CloudflareHandle) ZoneOfDomain(ctx context.Context, ppfmt pp.PP, domain domain.Domain) (string, bool) {
 	if id := h.cache.zoneOfDomain.Get(domain.DNSNameASCII()); id != nil {
 		return id.Value(), true
@@ -161,6 +167,7 @@ zoneSearch:
 	return "", false
 }
 
+// ListRecords lists all matching DNS records.
 func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP,
 	domain domain.Domain, ipNet ipnet.Type,
 ) (map[string]netip.Addr, bool) {
@@ -197,6 +204,7 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP,
 	return rmap, true
 }
 
+// DeleteRecord deletes one DNS record.
 func (h *CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP,
 	domain domain.Domain, ipNet ipnet.Type, id string,
 ) bool {
@@ -221,6 +229,7 @@ func (h *CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP,
 	return true
 }
 
+// UpdateRecord updates one DNS record.
 func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP,
 	domain domain.Domain, ipNet ipnet.Type, id string, ip netip.Addr,
 ) bool {
@@ -252,6 +261,7 @@ func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP,
 	return true
 }
 
+// CreateRecord creates one DNS record.
 func (h *CloudflareHandle) CreateRecord(ctx context.Context, ppfmt pp.PP,
 	domain domain.Domain, ipNet ipnet.Type, ip netip.Addr, ttl TTL, proxied bool,
 ) (string, bool) {
diff --git a/internal/api/ttl.go b/internal/api/ttl.go
index 2f1e148..1ec3f1c 100644
--- a/internal/api/ttl.go
+++ b/internal/api/ttl.go
@@ -2,18 +2,24 @@ package api
 
 import "strconv"
 
+// A TTL represents a time-to-live value of a DNS record.
 type TTL int
 
+// TTLAuto represents the "auto" value for Cloudflare servers.
 const TTLAuto TTL = 1
 
+// Int converts a TTL into its raw integer value.
 func (t TTL) Int() int {
 	return int(t)
 }
 
+// String converts a TTL into the string representation of its raw integer value.
 func (t TTL) String() string {
 	return strconv.Itoa(t.Int())
 }
 
+// Describe converts a TTL into a human-readable, user-friendly description
+// that is suitable for printing.
 func (t TTL) Describe() string {
 	if t == TTLAuto {
 		return "1 (auto)"
diff --git a/internal/config/config.go b/internal/config/config.go
index 09e6341..7be49c0 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,3 +1,4 @@
+// Package config reads and parses configurations.
 package config
 
 import (
@@ -16,6 +17,8 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/provider"
 )
 
+// Config holds the configuration of the updater except for the timezone.
+// (The timezone is handled directly by the standard library reading the TZ environment variable.)
 type Config struct {
 	Auth             api.Auth
 	Provider         map[ipnet.Type]provider.Provider
@@ -29,10 +32,10 @@ type Config struct {
 	Proxied          map[domain.Domain]bool
 	DetectionTimeout time.Duration
 	UpdateTimeout    time.Duration
-	Monitors         []monitor.Monitor
+	Monitor          monitor.Monitor
 }
 
-// Default gives default values.
+// Default gives the default configuration.
 func Default() *Config {
 	return &Config{
 		Auth: nil,
@@ -53,7 +56,7 @@ func Default() *Config {
 		Proxied:          map[domain.Domain]bool{},
 		UpdateTimeout:    time.Second * 30, //nolint:gomnd
 		DetectionTimeout: time.Second * 5,  //nolint:gomnd
-		Monitors:         nil,
+		Monitor:          monitor.Monitors{},
 	}
 }
 
@@ -93,6 +96,8 @@ func readAuthToken(ppfmt pp.PP) (string, bool) {
 	}
 }
 
+// ReadAuth reads environment variables CF_API_TOKEN, CF_API_TOKEN_FILE, and CF_ACCOUNT_ID
+// and creates an [api.CloudflareAuth].
 func ReadAuth(ppfmt pp.PP, field *api.Auth) bool {
 	token, ok := readAuthToken(ppfmt)
 	if !ok {
@@ -130,6 +135,8 @@ func deduplicate(list []domain.Domain) []domain.Domain {
 	return list[:j+1]
 }
 
+// ReadDomainMap reads environment variables DOMAINS, IP4_DOMAINS, and IP6_DOMAINS
+// and consolidate the domains into a map.
 func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]domain.Domain) bool {
 	var domains, ip4Domains, ip6Domains []domain.Domain
 
@@ -150,6 +157,8 @@ func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]domain.Domain) bool {
 	return true
 }
 
+// ReadProviderMap reads the environment variables IP4_PROVIDER and IP6_PROVIDER,
+// with support of deprecated environment variables IP4_POLICY and IP6_POLICY.
 func ReadProviderMap(ppfmt pp.PP, field *map[ipnet.Type]provider.Provider) bool {
 	ip4Provider := (*field)[ipnet.IP4]
 	ip6Provider := (*field)[ipnet.IP6]
@@ -196,6 +205,7 @@ func getInverseMap[V comparable](m map[domain.Domain]V) ([]V, map[V][]domain.Dom
 
 const itemTitleWidth = 24
 
+// Print prints the Config on the screen.
 func (c *Config) Print(ppfmt pp.PP) {
 	if !ppfmt.IsEnabledFor(pp.Info) {
 		return
@@ -239,14 +249,23 @@ func (c *Config) Print(ppfmt pp.PP) {
 	item("IP detection:", "%v", c.DetectionTimeout)
 	item("Record updating:", "%v", c.UpdateTimeout)
 
-	if len(c.Monitors) > 0 {
+	var monitors [][2]string
+	if c.Monitor != nil {
+		c.Monitor.Describe(func(service, params string) {
+			monitors = append(monitors, [2]string{service, params})
+		})
+	}
+	if len(monitors) > 0 {
 		section("Monitors:")
-		for _, m := range c.Monitors {
-			item(m.DescribeService()+":", "%s", "(URL redacted)")
+		for _, m := range monitors {
+			item(m[0]+":", "%s", m[1])
 		}
 	}
 }
 
+// ReadEnv calls the relevant readers to read all relevant environment variables except TZ
+// and update relevant fields. One should subsequently call [Config.NormalizeDomains] to maintain
+// invariants across different fields.
 func (c *Config) ReadEnv(ppfmt pp.PP) bool {
 	if ppfmt.IsEnabledFor(pp.Info) {
 		ppfmt.Infof(pp.EmojiEnvVars, "Reading settings . . .")
@@ -264,14 +283,14 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool {
 		!ReadString(ppfmt, "PROXIED", &c.ProxiedTemplate) ||
 		!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) ||
 		!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) ||
-		!ReadHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) {
+		!ReadHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitor) {
 		return false
 	}
 
 	return true
 }
 
-// NormalizeDomains normalizes the fields Provider, TTL and Proxied.
+// NormalizeDomains updates and normalizes the fields [Config.Provider] and [Config.Proxied].
 // When errors are reported, the original configuration remain unchanged.
 //
 //nolint:funlen
diff --git a/internal/config/env.go b/internal/config/env.go
index 1c067ab..34092c4 100644
--- a/internal/config/env.go
+++ b/internal/config/env.go
@@ -20,6 +20,7 @@ func Getenv(key string) string {
 	return strings.TrimSpace(os.Getenv(key))
 }
 
+// ReadEmoji reads an environment variable as a plain string.
 func ReadString(ppfmt pp.PP, key string, field *string) bool {
 	val := Getenv(key)
 	if val == "" {
@@ -117,8 +118,8 @@ func ReadNonnegInt(ppfmt pp.PP, key string, field *int) bool {
 // According to [DNS documentation], the valid range is "Auto" and [30, 86400].
 // We thus accept the union of both ranges---1 (auto) and [30, 86400].
 //
-// [API documentation] https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record
-// [DNS documentation] https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl
+// [API documentation]: https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record
+// [DNS documentation]: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl
 func ReadTTL(ppfmt pp.PP, key string, field *api.TTL) bool {
 	val := Getenv(key)
 	if val == "" {
@@ -300,8 +301,8 @@ func ReadCron(ppfmt pp.PP, key string, field *cron.Schedule) bool {
 	return true
 }
 
-// ReadHealthchecksURL reads the base URL of the healthcheck.io endpoint.
-func ReadHealthchecksURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool {
+// ReadHealthchecksURL reads the base URL of the Healthchecks endpoint.
+func ReadHealthchecksURL(ppfmt pp.PP, key string, field *monitor.Monitor) bool {
 	val := Getenv(key)
 
 	if val == "" {
@@ -313,6 +314,7 @@ func ReadHealthchecksURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool
 		return false
 	}
 
-	*field = append(*field, h)
+	// Append the new monitor to the existing list
+	*field = h
 	return true
 }
diff --git a/internal/cron/printer.go b/internal/cron/printer.go
index 2100282..8965581 100644
--- a/internal/cron/printer.go
+++ b/internal/cron/printer.go
@@ -6,8 +6,8 @@ import (
 )
 
 const (
-	minutesPerHour   = 60
-	secondsPerMinute = 60
+	minutesPerHour   = int(time.Hour / time.Minute)
+	secondsPerMinute = int(time.Minute / time.Second)
 )
 
 func describeOffset(offset int) string {
diff --git a/internal/cron/schedule.go b/internal/cron/schedule.go
index 30ce7d5..5447eb6 100644
--- a/internal/cron/schedule.go
+++ b/internal/cron/schedule.go
@@ -31,16 +31,18 @@ func New(spec string) (Schedule, error) {
 func MustNew(spec string) Schedule {
 	cron, err := New(spec)
 	if err != nil {
-		panic(fmt.Errorf(`🤯 schedule.MustNew failed: %w`, err))
+		panic(fmt.Errorf(`schedule.MustNew failed: %w`, err))
 	}
 
 	return cron
 }
 
+// Next tells the next scheduled time.
 func (s *cronSchedule) Next() time.Time {
 	return s.schedule.Next(time.Now())
 }
 
+// String gives back the original cron string.
 func (s *cronSchedule) String() string {
 	return s.spec
 }
diff --git a/internal/domain/base.go b/internal/domain/base.go
index fea78bb..360173c 100644
--- a/internal/domain/base.go
+++ b/internal/domain/base.go
@@ -1,12 +1,16 @@
+// Package domain parses DNS domain names.
 package domain
 
-// A Splitter enumerates all possible zones from a domain.
+// A Splitter enumerates all possible zones of a domain by moving a cursor
+// from the start of the domain's name to its end.
 type Splitter interface {
-	// IsValid checks whether the current splitting point is still valid
+	// IsValid checks whether the cursor is still valid.
 	IsValid() bool
-	// ZoneNameASCII gives the suffix (the zone), when it is still valid
+
+	// ZoneNameASCII gives the suffix after the cursor as a possible zone, if the cursor is still valid.
 	ZoneNameASCII() string
-	// Next moves to the next possible splitting point, which might end up being invalid
+
+	// Next moves the cursor to the next possible splitting point, which might end up being invalid.
 	Next()
 }
 
@@ -14,8 +18,10 @@ type Splitter interface {
 type Domain interface {
 	// DNSNameASCII gives a name suitable for accessing the Cloudflare API
 	DNSNameASCII() string
+
 	// Describe gives the most human-readable domain name that is still unambiguous
 	Describe() string
+
 	// Split gives a Splitter that can be used to find zones
 	Split() Splitter
 }
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
index d4095a5..7b5a9da 100644
--- a/internal/domain/domain.go
+++ b/internal/domain/domain.go
@@ -71,6 +71,7 @@ func New(domain string) (Domain, error) {
 	}
 }
 
+// SortDomains sorts a list of domains according to their ASCII representations.
 func SortDomains(s []Domain) {
 	sort.Slice(s, func(i, j int) bool { return s[i].DNSNameASCII() < s[j].DNSNameASCII() })
 }
diff --git a/internal/domain/fqdn.go b/internal/domain/fqdn.go
index 9a52408..c963e31 100644
--- a/internal/domain/fqdn.go
+++ b/internal/domain/fqdn.go
@@ -5,18 +5,22 @@ import "strings"
 // FQDN is a fully qualified domain in its ASCII form.
 type FQDN string
 
+// DNSNameASCII retruns the ASCII form of the FQDN.
 func (f FQDN) DNSNameASCII() string { return string(f) }
 
+// Describe gives a human-readible representation of the FQDN.
 func (f FQDN) Describe() string {
 	return safelyToUnicode(string(f))
 }
 
+// FQDNSplitter implements [Splitter] for a [FQDN].
 type FQDNSplitter struct {
 	domain    string
 	cursor    int
 	exhausted bool
 }
 
+// Split constructs a [FQDNSplitter] for the FQDN.
 func (f FQDN) Split() Splitter {
 	return &FQDNSplitter{
 		domain:    string(f),
@@ -25,8 +29,14 @@ func (f FQDN) Split() Splitter {
 	}
 }
 
-func (s *FQDNSplitter) IsValid() bool         { return !s.exhausted }
+// IsValid checks whether the cursor is still valid.
+func (s *FQDNSplitter) IsValid() bool { return !s.exhausted }
+
+// ZoneNameASCII gives the ASCII form of the current zone suffix.
 func (s *FQDNSplitter) ZoneNameASCII() string { return s.domain[s.cursor:] }
+
+// Next moves the cursor to the next spltting point.
+// Call [IsValid] to check whether the resulting cursor is valid.
 func (s *FQDNSplitter) Next() {
 	if s.cursor == len(s.domain) {
 		s.exhausted = true
diff --git a/internal/domain/wildcard.go b/internal/domain/wildcard.go
index 5406549..f450568 100644
--- a/internal/domain/wildcard.go
+++ b/internal/domain/wildcard.go
@@ -3,9 +3,10 @@ package domain
 import "strings"
 
 // Wildcard is a fully qualified zone name in its ASCII form, represnting the wildcard domain name
-// under the zone. For example, Wildcard("example.org") represents "*.example.org".
+// under the zone. For example, Wildcard("example.org") represents *.example.org.
 type Wildcard string
 
+// DNSNameASCII retruns the ASCII form of the wildcard domain.
 func (w Wildcard) DNSNameASCII() string {
 	if string(w) == "" {
 		return "*"
@@ -14,6 +15,7 @@ func (w Wildcard) DNSNameASCII() string {
 	return "*." + string(w)
 }
 
+// Describe gives a human-readible representation of the wildcard domain.
 func (w Wildcard) Describe() string {
 	if string(w) == "" {
 		return "*"
@@ -22,12 +24,14 @@ func (w Wildcard) Describe() string {
 	return "*." + safelyToUnicode(string(w))
 }
 
+// WildcardSplitter implements [Splitter] for a wildcard domain.
 type WildcardSplitter struct {
 	domain    string
 	cursor    int
 	exhausted bool
 }
 
+// Split constructs a [Splitter] for a wildcard domain.
 func (w Wildcard) Split() Splitter {
 	return &WildcardSplitter{
 		domain:    string(w),
@@ -36,8 +40,14 @@ func (w Wildcard) Split() Splitter {
 	}
 }
 
-func (s *WildcardSplitter) IsValid() bool         { return !s.exhausted }
+// IsValid checks whether the cursor is still valid.
+func (s *WildcardSplitter) IsValid() bool { return !s.exhausted }
+
+// ZoneNameASCII gives the ASCII form of the current zone suffix.
 func (s *WildcardSplitter) ZoneNameASCII() string { return s.domain[s.cursor:] }
+
+// Next moves the cursor to the next spltting point.
+// Call [WildcardSplitter.IsValid] to check whether the resulting cursor is valid.
 func (s *WildcardSplitter) Next() {
 	if s.cursor == len(s.domain) {
 		s.exhausted = true
diff --git a/internal/domainexp/lexer.go b/internal/domainexp/lexer.go
index 4016233..c4bcf4a 100644
--- a/internal/domainexp/lexer.go
+++ b/internal/domainexp/lexer.go
@@ -12,8 +12,11 @@ import (
 )
 
 var (
+	// ErrSingleAnd is triggered by single & (which should have been &&).
 	ErrSingleAnd = fmt.Errorf(`use "&&" instead of "&"`)
-	ErrSingleOr  = fmt.Errorf(`use "||" instead of "|"`)
+
+	// ErrSingleOr is triggered by single | (which should have been ||).
+	ErrSingleOr = fmt.Errorf(`use "||" instead of "|"`)
 )
 
 //nolint:funlen
diff --git a/internal/domainexp/parser.go b/internal/domainexp/parser.go
index d7c40bf..5243870 100644
--- a/internal/domainexp/parser.go
+++ b/internal/domainexp/parser.go
@@ -1,3 +1,4 @@
+// Package domainexp parses expressions containing domains.
 package domainexp
 
 import (
@@ -223,6 +224,7 @@ func scanExpression(ppfmt pp.PP, input string, tokens []string) (predicate, []st
 	return nil, nil
 }
 
+// ParseList parses a list of comma-separated domains. Internationalized domain names are fully supported.
 func ParseList(ppfmt pp.PP, input string) ([]domain.Domain, bool) {
 	tokens, ok := tokenize(ppfmt, input)
 	if !ok {
@@ -240,6 +242,20 @@ func ParseList(ppfmt pp.PP, input string) ([]domain.Domain, bool) {
 	return list, true
 }
 
+// ParseExpression parses a boolean expression containing domains. Internationalized domain names are fully supported.
+// A boolean expression must have one of the following forms:
+//
+//   - A boolean value accepted by [strconv.ParseBool], such as t as true or FALSE as false.
+//   - is(example.org), which matches the domain example.org. Note that is(*.example.org)
+//     only matches the wildcard domain *.example.org; use sub(example.org) to match
+//     all subdomains of example.org (including *.example.org).
+//   - sub(example.org), which matches subdomains of example.org, such as www.example.org and *.example.org.
+//     It does not match the domain example.org itself.
+//   - ! exp, where exp is a boolean expression, representing logical negation of exp.
+//   - exp1 || exp2, where exp1 and exp2 are boolean expressions, representing logical disjunction of exp1 and exp2.
+//   - exp1 && exp2, where exp1 and exp2 are boolean expressions, representing logical conjunction of exp1 and exp2.
+//
+// One can use parentheses to group expressions, such as !(is(hello.org) && (is(hello.io) || is(hello.me))).
 func ParseExpression(ppfmt pp.PP, input string) (predicate, bool) {
 	tokens, ok := tokenize(ppfmt, input)
 	if !ok {
diff --git a/internal/file/file.go b/internal/file/file.go
index 83c19ed..e380ae9 100644
--- a/internal/file/file.go
+++ b/internal/file/file.go
@@ -1,3 +1,4 @@
+// Package file virtualizes file systems for mock testing.
 package file
 
 import (
@@ -9,10 +10,15 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
+// LinuxRoot is the root in Linux.
 const LinuxRoot string = "/"
 
+// FS represents the file system in use. By default, it points to the actual file system,
+// and can be modified to a virtual file system for testing.
 var FS = os.DirFS(LinuxRoot) //nolint:gochecknoglobals
 
+// ReadString reads the content of the file at path. It treats an absolute path as
+// a path relative to the root of [FS].
 func ReadString(ppfmt pp.PP, path string) (string, bool) {
 	// os.DirFS(...).Open() does not accept absolute paths
 	if filepath.IsAbs(path) {
diff --git a/internal/ipnet/ipnet.go b/internal/ipnet/ipnet.go
index d4aa3f6..3b6b0bd 100644
--- a/internal/ipnet/ipnet.go
+++ b/internal/ipnet/ipnet.go
@@ -1,4 +1,4 @@
-// Package ipnet contains utility functions for IP network versions
+// Package ipnet contains utility functions for IPv4 and IPv6 networks.
 package ipnet
 
 import (
@@ -14,11 +14,12 @@ type Type int
 const (
 	// IP4 is IP version 4.
 	IP4 Type = 4
+
 	// IP6 is IP version 6.
 	IP6 Type = 6
 )
 
-// Describe returns a description of the IP network.
+// Describe returns a human-readable description of the IP network.
 func (t Type) Describe() string {
 	switch t {
 	case IP4, IP6:
@@ -28,7 +29,7 @@ func (t Type) Describe() string {
 	}
 }
 
-// RecordType prints out the type of DNS records for the IP network.
+// RecordType prints out the type of DNS records for the IP network. For IPv4, it is A; for IPv6, it is AAAA.
 func (t Type) RecordType() string {
 	switch t {
 	case IP4:
@@ -50,26 +51,51 @@ func (t Type) Int() int {
 	}
 }
 
-// NormalizeIP normalizes an IP into an IPv4 or IPv6 address.
-func (t Type) NormalizeIP(ppfmt pp.PP, ip netip.Addr) (netip.Addr, bool) {
+// NormalizeDetectedIP normalizes an IP into an IPv4 or IPv6 address.
+func (t Type) NormalizeDetectedIP(ppfmt pp.PP, ip netip.Addr) (netip.Addr, bool) {
+	if !ip.IsValid() {
+		ppfmt.Warningf(
+			pp.EmojiImpossible,
+			`Detected IP address is not valid`,
+		)
+		return netip.Addr{}, false
+	}
+
+	if ip.IsUnspecified() {
+		ppfmt.Warningf(
+			pp.EmojiImpossible,
+			`Detected IP address %s is an unspicifed %s address`,
+			ip.String(),
+			t.Describe(),
+		)
+		return netip.Addr{}, false
+	}
+
 	switch t {
 	case IP4:
 		if !ip.Is4() && !ip.Is4In6() {
-			ppfmt.Warningf(pp.EmojiError, "%q is not a valid %s address", ip, t.Describe())
+			ppfmt.Warningf(pp.EmojiError, "Detected IP address %s is not a valid %s address", ip.String(), t.Describe())
 			return netip.Addr{}, false
 		}
 		// Turns an IPv4-mapped IPv6 address back to an IPv4 address
-		return ip.Unmap(), true
+		ip = ip.Unmap()
 	case IP6:
-		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
+		ip = netip.AddrFrom16(ip.As16())
 	default:
-		ppfmt.Warningf(pp.EmojiError, "%q is not a valid %s address", ip, t.Describe())
+		ppfmt.Warningf(pp.EmojiImpossible, "Detected IP address %s is not a valid %s address", ip.String(), t.Describe())
+		ppfmt.Warningf(pp.EmojiImpossible, "Please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new") //nolint:lll
 		return netip.Addr{}, false
 	}
+
+	if !ip.IsGlobalUnicast() {
+		ppfmt.Warningf(
+			pp.EmojiUserWarning,
+			`Detected IP address %s does not look like a global unicast IP address. Please double-check.`,
+			ip.String(),
+		)
+	}
+
+	return ip, true
 }
 
 // UDPNetwork gives the network name for net.Dial.
diff --git a/internal/mocks/mock_monitor.go b/internal/mocks/mock_monitor.go
index 07cb726..7702a8b 100644
--- a/internal/mocks/mock_monitor.go
+++ b/internal/mocks/mock_monitor.go
@@ -35,18 +35,16 @@ func (m *MockMonitor) EXPECT() *MockMonitorMockRecorder {
 	return m.recorder
 }
 
-// DescribeService mocks base method.
-func (m *MockMonitor) DescribeService() string {
+// Describe mocks base method.
+func (m *MockMonitor) Describe(arg0 func(string, string)) {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "DescribeService")
-	ret0, _ := ret[0].(string)
-	return ret0
+	m.ctrl.Call(m, "Describe", arg0)
 }
 
-// DescribeService indicates an expected call of DescribeService.
-func (mr *MockMonitorMockRecorder) DescribeService() *gomock.Call {
+// Describe indicates an expected call of Describe.
+func (mr *MockMonitorMockRecorder) Describe(arg0 interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeService", reflect.TypeOf((*MockMonitor)(nil).DescribeService))
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Describe", reflect.TypeOf((*MockMonitor)(nil).Describe), arg0)
 }
 
 // ExitStatus mocks base method.
diff --git a/internal/monitor/base.go b/internal/monitor/base.go
index 33b8a95..56a487c 100644
--- a/internal/monitor/base.go
+++ b/internal/monitor/base.go
@@ -1,3 +1,4 @@
+// Package monitor implements dead man's switches.
 package monitor
 
 import (
@@ -8,11 +9,24 @@ import (
 
 //go:generate mockgen -destination=../mocks/mock_monitor.go -package=mocks . Monitor
 
+// Monitor is a dead man's switch, meaning that the user will be notified when the updater fails to
+// detect and update the public IP address. No notifications for IP changes.
 type Monitor interface {
-	DescribeService() string
+	// Describe a monitor in a human-readable format by calling callback with service names and params.
+	Describe(callback func(service, params string))
+
+	// Success pings the monitor to prevent notifications.
 	Success(context.Context, pp.PP, string) bool
+
+	// Start pings the monitor with the start signal.
 	Start(context.Context, pp.PP, string) bool
+
+	// Failure immediately signals the monitor to notify the user.
 	Failure(context.Context, pp.PP, string) bool
+
+	// Log provides additional inforamion without changing the state.
 	Log(context.Context, pp.PP, string) bool
+
+	// ExitStatus records the exit status (as an integer in the POSIX style).
 	ExitStatus(context.Context, pp.PP, int, string) bool
 }
diff --git a/internal/monitor/composite.go b/internal/monitor/composite.go
new file mode 100644
index 0000000..6908021
--- /dev/null
+++ b/internal/monitor/composite.go
@@ -0,0 +1,72 @@
+package monitor
+
+import (
+	"context"
+
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+// Monitors is the composite monitor that will notify a group of monitors simultaneously.
+type Monitors []Monitor
+
+// Describe calls [Monitor.Describe] for each monitor in the group with the callback.
+func (ms Monitors) Describe(callback func(service, params string)) {
+	for _, m := range ms {
+		m.Describe(callback)
+	}
+}
+
+// Success calls [Monitor.Success] for each monitor in the group.
+func (ms Monitors) Success(ctx context.Context, ppfmt pp.PP, message string) bool {
+	ok := true
+	for _, m := range ms {
+		if !m.Success(ctx, ppfmt, message) {
+			ok = false
+		}
+	}
+	return ok
+}
+
+// Start calls [Monitor.Start] for each monitor in the group.
+func (ms Monitors) Start(ctx context.Context, ppfmt pp.PP, message string) bool {
+	ok := true
+	for _, m := range ms {
+		if !m.Start(ctx, ppfmt, message) {
+			ok = false
+		}
+	}
+	return ok
+}
+
+// Failure calls [Monitor.Failure] for each monitor in the group.
+func (ms Monitors) Failure(ctx context.Context, ppfmt pp.PP, message string) bool {
+	ok := true
+	for _, m := range ms {
+		if !m.Failure(ctx, ppfmt, message) {
+			ok = false
+		}
+	}
+	return ok
+}
+
+// Log calls [Monitor.Log] for each monitor in the group.
+func (ms Monitors) Log(ctx context.Context, ppfmt pp.PP, message string) bool {
+	ok := true
+	for _, m := range ms {
+		if !m.Log(ctx, ppfmt, message) {
+			ok = false
+		}
+	}
+	return ok
+}
+
+// ExitStatus calls [Monitor.ExitStatus] for each monitor in the group.
+func (ms Monitors) ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool {
+	ok := true
+	for _, m := range ms {
+		if !m.ExitStatus(ctx, ppfmt, code, message) {
+			ok = false
+		}
+	}
+	return ok
+}
diff --git a/internal/monitor/healthchecks.go b/internal/monitor/healthchecks.go
index cfc5d8f..638a096 100644
--- a/internal/monitor/healthchecks.go
+++ b/internal/monitor/healthchecks.go
@@ -13,19 +13,31 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
+// Healthchecks represents a Healthchecks access point.
+// See https://healthchecks.io/docs/http_api/ for more information.
 type Healthchecks struct {
-	BaseURL    *url.URL
-	Timeout    time.Duration
+	// The success endpoint that can be used to derive all other endpoints.
+	BaseURL *url.URL
+
+	// Timeout for each ping.
+	Timeout time.Duration
+
+	// MaxRetries before giving up a ping.
 	MaxRetries int
 }
 
 const (
-	HealthchecksDefaultTimeout    = 10 * time.Second
-	HealthchecksDefaultMaxRetries = 5
+	// HealthchecksDefaultTimeout is the default timeout for a Healthchecks ping.
+	HealthchecksDefaultTimeout = 10 * time.Second
+
+	// HealthchecksDefaultMaxRetries is the maximum number of retries to ping Healthchecks.
+	HealthchecksDefaultMaxRetries int = 5
 )
 
+// HealthchecksOption is an option for [NewHealthchecks].
 type HealthchecksOption func(*Healthchecks)
 
+// SetHealthchecksMaxRetries sets the MaxRetries of a Healthchecks.
 func SetHealthchecksMaxRetries(maxRetries int) HealthchecksOption {
 	if maxRetries <= 0 {
 		panic("maxRetries <= 0")
@@ -35,6 +47,8 @@ func SetHealthchecksMaxRetries(maxRetries int) HealthchecksOption {
 	}
 }
 
+// NewHealthchecks creates a new Healthchecks monitor.
+// See https://healthchecks.io/docs/http_api/ for more information.
 func NewHealthchecks(ppfmt pp.PP, rawURL string, os ...HealthchecksOption) (Monitor, bool) {
 	url, err := url.Parse(rawURL)
 	if err != nil {
@@ -74,8 +88,9 @@ func NewHealthchecks(ppfmt pp.PP, rawURL string, os ...HealthchecksOption) (Moni
 	return h, true
 }
 
-func (h *Healthchecks) DescribeService() string {
-	return "Healthchecks"
+// Describe calles the callback with the service name "Healthchecks".
+func (h *Healthchecks) Describe(callback func(service, params string)) {
+	callback("Healthchecks", "(URL redacted)")
 }
 
 //nolint:funlen
@@ -158,22 +173,27 @@ func (h *Healthchecks) ping(ctx context.Context, ppfmt pp.PP, endpoint string, m
 	return false
 }
 
+// Success pings the root endpoint.
 func (h *Healthchecks) Success(ctx context.Context, ppfmt pp.PP, message string) bool {
 	return h.ping(ctx, ppfmt, "", message)
 }
 
+// Start pings the /start endpoint.
 func (h *Healthchecks) Start(ctx context.Context, ppfmt pp.PP, message string) bool {
 	return h.ping(ctx, ppfmt, "/start", message)
 }
 
+// Failure pings the /fail endpoint.
 func (h *Healthchecks) Failure(ctx context.Context, ppfmt pp.PP, message string) bool {
 	return h.ping(ctx, ppfmt, "/fail", message)
 }
 
+// Log pings the /log endpoint.
 func (h *Healthchecks) Log(ctx context.Context, ppfmt pp.PP, message string) bool {
 	return h.ping(ctx, ppfmt, "/log", message)
 }
 
+// ExitStatus pings the /number endpoint where number is the exit status.
 func (h *Healthchecks) ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool {
 	if code < 0 || code > 255 {
 		ppfmt.Errorf(pp.EmojiImpossible, "Exit code (%d) not within the range 0-255", code)
diff --git a/internal/monitor/util.go b/internal/monitor/util.go
deleted file mode 100644
index 2bf1eda..0000000
--- a/internal/monitor/util.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package monitor
-
-import (
-	"context"
-
-	"github.com/favonia/cloudflare-ddns/internal/pp"
-)
-
-func SuccessAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
-	ok := true
-	for _, m := range ms {
-		if !m.Success(ctx, ppfmt, message) {
-			ok = false
-		}
-	}
-	return ok
-}
-
-func StartAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
-	ok := true
-	for _, m := range ms {
-		if !m.Start(ctx, ppfmt, message) {
-			ok = false
-		}
-	}
-	return ok
-}
-
-func FailureAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
-	ok := true
-	for _, m := range ms {
-		if !m.Failure(ctx, ppfmt, message) {
-			ok = false
-		}
-	}
-	return ok
-}
-
-func LogAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, message string) bool {
-	ok := true
-	for _, m := range ms {
-		if !m.Log(ctx, ppfmt, message) {
-			ok = false
-		}
-	}
-	return ok
-}
-
-func ExitStatusAll(ctx context.Context, ppfmt pp.PP, ms []Monitor, code int, message string) bool {
-	ok := true
-	for _, m := range ms {
-		if !m.ExitStatus(ctx, ppfmt, code, message) {
-			ok = false
-		}
-	}
-	return ok
-}
diff --git a/internal/pp/base.go b/internal/pp/base.go
index 60f1e22..79b3ec8 100644
--- a/internal/pp/base.go
+++ b/internal/pp/base.go
@@ -1,14 +1,31 @@
+// Package pp handles pretty-printing.
 package pp
 
 //go:generate mockgen -destination=../mocks/mock_pp.go -package=mocks . PP
 
+// PP is the abstraction of a pretty printer.
 type PP interface {
+	// SetEmoji sets whether emojis should be used.
 	SetEmoji(bool) PP
+
+	// SetLevel sets the level under which messages will be hidden.
 	SetLevel(Level) PP
+
+	// IsEnabledFor checks whether a message of a certain level will be displayed.
 	IsEnabledFor(Level) bool
+
+	// IncIndent returns a new pretty-printer with more indentation.
 	IncIndent() PP
+
+	// Infof formats and prints a message at the info level.
 	Infof(Emoji, string, ...any)
+
+	// Noticef formats and prints a message at the notice level.
 	Noticef(Emoji, string, ...any)
+
+	// Warningf formats and prints a message at the warning level.
 	Warningf(Emoji, string, ...any)
+
+	// Errorf formats and prints a message at the error level.
 	Errorf(Emoji, string, ...any)
 }
diff --git a/internal/pp/emoji.go b/internal/pp/emoji.go
index cd457f6..312bb3f 100644
--- a/internal/pp/emoji.go
+++ b/internal/pp/emoji.go
@@ -1,5 +1,6 @@
 package pp
 
+// Emoji is the type of emoji strings.
 type Emoji string
 
 const (
@@ -35,4 +36,5 @@ const (
 	EmojiImpossible  Emoji = "🤯" // the impossible happened
 )
 
+// indentPrefix should be wider than an emoji to achieve visually pleasing results.
 const indentPrefix = "   "
diff --git a/internal/pp/fmt.go b/internal/pp/fmt.go
index 035f847..153297f 100644
--- a/internal/pp/fmt.go
+++ b/internal/pp/fmt.go
@@ -13,6 +13,7 @@ type formatter struct {
 	level  Level
 }
 
+// New creates a new pretty printer.
 func New(writer io.Writer) PP {
 	return &formatter{
 		writer: writer,
diff --git a/internal/pp/level.go b/internal/pp/level.go
index 78fcc13..c9baa6d 100644
--- a/internal/pp/level.go
+++ b/internal/pp/level.go
@@ -1,13 +1,14 @@
 package pp
 
+// Level is the type of message levels.
 type Level int
 
 const (
 	Debug        Level = iota // debugging info, currently not used
-	Info                      // information not about actual actions
-	Notice                    // information about actual actions, but not an error
-	Warning                   // non-fatal errors where the program should continue updating IP addresses
-	Error                     // fatal errors where the program should stop
+	Info                      // additional information that is not an action, a warning, or an error
+	Notice                    // an action (e.g., changing the IP) has happened and it is not an error
+	Warning                   // non-fatal errors where the updater should continue updating IP addresses
+	Error                     // fatal errors where the updater should stop
 	DefaultLevel = Info
 	Verbose      = Info
 	Quiet        = Notice
diff --git a/internal/provider/base.go b/internal/provider/base.go
index b225e02..a9bf96e 100644
--- a/internal/provider/base.go
+++ b/internal/provider/base.go
@@ -1,3 +1,4 @@
+// Package provider implements protocols to detect public IP addresses.
 package provider
 
 import (
@@ -10,11 +11,13 @@ import (
 
 //go:generate mockgen -destination=../mocks/mock_provider.go -package=mocks . Provider
 
+// Provider is the abstraction of a protocol to detect public IP addresses.
 type Provider interface {
-	Name() string
-	GetIP(context.Context, pp.PP, ipnet.Type) (netip.Addr, bool)
+	Name() string                                                // name of the protocol
+	GetIP(context.Context, pp.PP, ipnet.Type) (netip.Addr, bool) // get the IP
 }
 
+// Name gets the protocol name. It returns "none" for nil.
 func Name(p Provider) string {
 	if p == nil {
 		return "none"
diff --git a/internal/provider/cloudflare_doh.go b/internal/provider/cloudflare_doh.go
index a0d2f50..801cc91 100644
--- a/internal/provider/cloudflare_doh.go
+++ b/internal/provider/cloudflare_doh.go
@@ -6,6 +6,7 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 )
 
+// NewCloudflareDOH creates a new provider that queries whoami.cloudflare. via Cloudflare DNS over HTTPS.
 func NewCloudflareDOH() Provider {
 	return &DNSOverHTTPS{
 		ProviderName: "cloudflare.doh",
diff --git a/internal/provider/cloudflare_trace.go b/internal/provider/cloudflare_trace.go
index 8947c17..459fc8a 100644
--- a/internal/provider/cloudflare_trace.go
+++ b/internal/provider/cloudflare_trace.go
@@ -39,14 +39,16 @@ func getIPFromCloudflareTrace(ctx context.Context, ppfmt pp.PP, url string, fiel
 	return c.getIP(ctx, ppfmt)
 }
 
+// CloudflareTrace represents a generic detection protocol to parse an HTTP response.
 type CloudflareTrace struct {
-	ProviderName string
+	ProviderName string // name of the detection protocol
 	Param        map[ipnet.Type]struct {
-		URL   string
-		Field string
+		URL   string // URL of the detection page
+		Field string // name of the field holding the IP address
 	}
 }
 
+// NewCloudflareTrace creates a specialized CloudflareTrace provider that parses https://1.1.1.1/cdn-cgi/trace.
 func NewCloudflareTrace() Provider {
 	return &CloudflareTrace{
 		ProviderName: "cloudflare.trace",
@@ -60,10 +62,12 @@ func NewCloudflareTrace() Provider {
 	}
 }
 
+// Name of the detection protocol.
 func (p *CloudflareTrace) Name() string {
 	return p.ProviderName
 }
 
+// GetIP detects the IP address by parsing the HTTP response.
 func (p *CloudflareTrace) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	param, found := p.Param[ipNet]
 	if !found {
@@ -76,5 +80,5 @@ func (p *CloudflareTrace) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Ty
 		return netip.Addr{}, false
 	}
 
-	return ipNet.NormalizeIP(ppfmt, ip)
+	return ipNet.NormalizeDetectedIP(ppfmt, ip)
 }
diff --git a/internal/provider/dns.go b/internal/provider/dns.go
index fd1211e..1aabb6c 100644
--- a/internal/provider/dns.go
+++ b/internal/provider/dns.go
@@ -103,6 +103,7 @@ func parseDNSAnswers(ppfmt pp.PP, answers []dnsmessage.Resource,
 		)
 		return invalidIP, false
 	}
+
 	return ip, true
 }
 
@@ -163,19 +164,22 @@ func getIPFromDNS(ctx context.Context, ppfmt pp.PP,
 	return c.getIP(ctx, ppfmt)
 }
 
+// DNSOverHTTPS represents a generic detection protocol using DNS over HTTPS.
 type DNSOverHTTPS struct {
-	ProviderName string
+	ProviderName string // name of the protocol
 	Param        map[ipnet.Type]struct {
-		URL   string
-		Name  string
-		Class dnsmessage.Class
+		URL   string           // the DoH server
+		Name  string           // domain name to query
+		Class dnsmessage.Class // DNS class to query
 	}
 }
 
+// Name of the detection protocol.
 func (p *DNSOverHTTPS) Name() string {
 	return p.ProviderName
 }
 
+// GetIP detects the IP address by DNS over HTTPS.
 func (p *DNSOverHTTPS) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	param, found := p.Param[ipNet]
 	if !found {
@@ -188,5 +192,5 @@ func (p *DNSOverHTTPS) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type)
 		return netip.Addr{}, false
 	}
 
-	return ipNet.NormalizeIP(ppfmt, ip)
+	return ipNet.NormalizeDetectedIP(ppfmt, ip)
 }
diff --git a/internal/provider/http.go b/internal/provider/http.go
index d9e0ec2..ffca4cd 100644
--- a/internal/provider/http.go
+++ b/internal/provider/http.go
@@ -78,15 +78,18 @@ func getIPFromHTTP(ctx context.Context, ppfmt pp.PP, url string) (netip.Addr, bo
 	return c.getIP(ctx, ppfmt)
 }
 
+// HTTP represents a generic detection protocol to use an HTTP response directly.
 type HTTP struct {
-	ProviderName string
-	URL          map[ipnet.Type]string
+	ProviderName string                // name of the protocol
+	URL          map[ipnet.Type]string // URL of the detection page
 }
 
+// Name of the detection protocol.
 func (p *HTTP) Name() string {
 	return p.ProviderName
 }
 
+// GetIP detects the IP address by using the HTTP response directly.
 func (p *HTTP) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	url, found := p.URL[ipNet]
 	if !found {
@@ -99,5 +102,5 @@ func (p *HTTP) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.
 		return netip.Addr{}, false
 	}
 
-	return ipNet.NormalizeIP(ppfmt, ip)
+	return ipNet.NormalizeDetectedIP(ppfmt, ip)
 }
diff --git a/internal/provider/ipify.go b/internal/provider/ipify.go
index 2c9ceef..3e5e7fb 100644
--- a/internal/provider/ipify.go
+++ b/internal/provider/ipify.go
@@ -4,6 +4,7 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 )
 
+// NewIpify creates a specialized HTTP provider that uses the ipify service.
 func NewIpify() Provider {
 	return &HTTP{
 		ProviderName: "ipify",
diff --git a/internal/provider/local.go b/internal/provider/local.go
index c48ecf9..a1e1d7f 100644
--- a/internal/provider/local.go
+++ b/internal/provider/local.go
@@ -9,15 +9,25 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
+// Local detects the IP address by pretending to send out an UDP packet
+// and using the source IP address assigned by the system. In most cases
+// it will detect the IP address of the network interface toward the internet.
+// (No actual UDP packets will be sent out.)
 type Local struct {
-	ProviderName  string
+	// Name of the detection protocol.
+	ProviderName string
+
+	// The target IP address of the UDP packet to be sent.
 	RemoteUDPAddr map[ipnet.Type]string
 }
 
+// Name of the detection protocol.
 func (p *Local) Name() string {
 	return p.ProviderName
 }
 
+// GetIP detects the IP address by pretending to send an UDP packet.
+// (No actual UDP packets will be sent out.)
 func (p *Local) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, bool) {
 	var invalidIP netip.Addr
 
@@ -36,5 +46,5 @@ func (p *Local) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip
 
 	ip := conn.LocalAddr().(*net.UDPAddr).AddrPort().Addr() //nolint:forcetypeassert
 
-	return ipNet.NormalizeIP(ppfmt, ip)
+	return ipNet.NormalizeDetectedIP(ppfmt, ip)
 }
diff --git a/internal/provider/local_cloudflare.go b/internal/provider/local_cloudflare.go
index a712534..15bf9ff 100644
--- a/internal/provider/local_cloudflare.go
+++ b/internal/provider/local_cloudflare.go
@@ -4,6 +4,8 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 )
 
+// NewLocal creates a specialized Local provider that uses Cloudflare as the remote server.
+// (No actual UDP packets will be sent out.)
 func NewLocal() Provider {
 	return &Local{
 		ProviderName: "local",
diff --git a/internal/setter/base.go b/internal/setter/base.go
index 43661c4..57f12f8 100644
--- a/internal/setter/base.go
+++ b/internal/setter/base.go
@@ -1,3 +1,8 @@
+// Package setter implements the logic to update DNS records using [api.Handle].
+//
+// The idea is to reuse existing DNS records as much as possible, and only when
+// that fails, create new DNS records and remove stall ones. The complexity of
+// this package is due to the error handling of each API call.
 package setter
 
 import (
@@ -12,7 +17,9 @@ import (
 
 //go:generate mockgen -destination=../mocks/mock_setter.go -package=mocks . Setter
 
+// Setter uses [api.Handle] to update DNS records.
 type Setter interface {
+	// Set sets a particular domain to the given IP address.
 	Set(
 		ctx context.Context,
 		ppfmt pp.PP,
@@ -22,6 +29,8 @@ type Setter interface {
 		ttl api.TTL,
 		proxied bool,
 	) (bool, string)
+
+	// Clear removes DNS records of a particular domain.
 	Clear(
 		ctx context.Context,
 		ppfmt pp.PP,
diff --git a/internal/setter/setter.go b/internal/setter/setter.go
index 614a65e..12dfa19 100644
--- a/internal/setter/setter.go
+++ b/internal/setter/setter.go
@@ -44,9 +44,7 @@ func New(_ppfmt pp.PP, handle api.Handle) (Setter, bool) {
 	}, true
 }
 
-// Set calls the DNS service API to update the API of one domain.
-//
-// The ip must be non-zero.
+// Set updates the IP address of one domain to the given ip. The ip must be non-zero.
 //
 //nolint:funlen
 func (s *setter) Set(ctx context.Context, ppfmt pp.PP,
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index cf7ccdf..cab0477 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -1,3 +1,5 @@
+// Package updater implements the logic to detect and update IP addresses,
+// combining the packages setter and provider.
 package updater
 
 import (
@@ -24,7 +26,7 @@ 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.
+// setIP extracts relevant settings from the configuration and calls [setter.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,
@@ -46,8 +48,7 @@ func setIP(ctx context.Context, ppfmt pp.PP,
 	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.
+// clearIP extracts relevant settings from the configuration and calls [setter.Setter.Clear] with a deadline.
 func clearIP(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter, ipNet ipnet.Type) (bool, string) {
 	allOk := true
 	var msgs []string
@@ -66,7 +67,16 @@ func clearIP(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter
 	return allOk, strings.Join(msgs, "\n")
 }
 
-var MessageShouldDisplay = map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true} //nolint:gochecknoglobals
+// ShouldDisplayHelpMessages determines whether help messages should be displayed.
+// The help messages are to help beginners detect possible misconfiguration.
+// These messages should be displayed at most once, and thus the value of this map
+// will be changed to false after displaying the help messages.
+//
+//nolint:gochecknoglobals
+var ShouldDisplayHelpMessages = map[ipnet.Type]bool{
+	ipnet.IP4: true,
+	ipnet.IP6: true,
+}
 
 func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Type) (netip.Addr, bool) {
 	ctx, cancel := context.WithTimeout(ctx, c.DetectionTimeout)
@@ -77,7 +87,7 @@ func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Ty
 		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] {
+		if ShouldDisplayHelpMessages[ipNet] {
 			switch ipNet {
 			case ipnet.IP6:
 				ppfmt.Infof(pp.EmojiConfig, "If you are using Docker or Kubernetes, IPv6 often requires additional setups")     //nolint:lll
@@ -88,10 +98,11 @@ func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, ipNet ipnet.Ty
 			}
 		}
 	}
-	MessageShouldDisplay[ipNet] = false
+	ShouldDisplayHelpMessages[ipNet] = false
 	return ip, ok
 }
 
+// UpdateIPs detect IP addresses and update DNS records of managed domains.
 func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) (bool, string) {
 	allOk := true
 	var msgs []string
@@ -116,6 +127,7 @@ func UpdateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Sett
 	return allOk, strings.Join(msgs, "\n")
 }
 
+// ClearIPs removes all DNS records of managed domains.
 func ClearIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) (bool, string) {
 	allOk := true
 	var msgs []string


Test patch:
diff --git a/internal/api/cloudflare_test.go b/internal/api/cloudflare_test.go
index da00f45..3d86c4f 100644
--- a/internal/api/cloudflare_test.go
+++ b/internal/api/cloudflare_test.go
@@ -152,7 +152,7 @@ func TestNewInvalid(t *testing.T) {
 	mockPP := mocks.NewMockPP(mockCtrl)
 	gomock.InOrder(
 		mockPP.EXPECT().Errorf(pp.EmojiUserError, "The Cloudflare API token could not be verified: %v", gomock.Any()),
-		mockPP.EXPECT().Errorf(pp.EmojiUserError, "Please double-check CF_API_TOKEN or CF_API_TOKEN_FILE"),
+		mockPP.EXPECT().Errorf(pp.EmojiUserError, "Please double-check the value of CF_API_TOKEN or CF_API_TOKEN_FILE"),
 	)
 	h, ok := auth.New(context.Background(), mockPP, time.Second)
 	require.False(t, ok)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 332cfbd..fa1718f 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -420,7 +420,7 @@ func TestPrintMaps(t *testing.T) {
 
 	m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path")
 	require.True(t, ok)
-	c.Monitors = []monitor.Monitor{m}
+	c.Monitor = m
 
 	c.Print(mockPP)
 }
diff --git a/internal/config/env_test.go b/internal/config/env_test.go
index cde1363..2af1cc0 100644
--- a/internal/config/env_test.go
+++ b/internal/config/env_test.go
@@ -775,58 +775,58 @@ func TestReadHealthchecksURL(t *testing.T) {
 	for name, tc := range map[string]struct {
 		set           bool
 		val           string
-		oldField      []mon
-		newField      []mon
+		oldField      mon
+		newField      mon
 		ok            bool
 		prepareMockPP func(*mocks.MockPP)
 	}{
 		"unset": {
-			false, "", []mon{}, []mon{}, true, nil,
+			false, "", nil, nil, true, nil,
 		},
 		"empty": {
-			true, "", []mon{}, []mon{}, true, nil,
+			true, "", nil, nil, true, nil,
 		},
 		"example": {
 			true, "https://hi.org/1234",
-			[]mon{},
-			[]mon{&monitor.Healthchecks{
+			nil,
+			&monitor.Healthchecks{
 				BaseURL:    urlMustParse(t, "https://hi.org/1234"),
 				Timeout:    monitor.HealthchecksDefaultTimeout,
 				MaxRetries: monitor.HealthchecksDefaultMaxRetries,
-			}},
+			},
 			true,
 			nil,
 		},
 		"password": {
 			true, "https://me:pass@hi.org/1234",
-			[]mon{},
-			[]mon{&monitor.Healthchecks{
+			nil,
+			&monitor.Healthchecks{
 				BaseURL:    urlMustParse(t, "https://me:pass@hi.org/1234"),
 				Timeout:    monitor.HealthchecksDefaultTimeout,
 				MaxRetries: monitor.HealthchecksDefaultMaxRetries,
-			}},
+			},
 			true,
 			nil,
 		},
 		"fragment": {
 			true, "https://hi.org/1234#fragment",
-			[]mon{},
-			[]mon{&monitor.Healthchecks{
+			nil,
+			&monitor.Healthchecks{
 				BaseURL:    urlMustParse(t, "https://hi.org/1234#fragment"),
 				Timeout:    monitor.HealthchecksDefaultTimeout,
 				MaxRetries: monitor.HealthchecksDefaultMaxRetries,
-			}},
+			},
 			true,
 			nil,
 		},
 		"query": {
 			true, "https://hi.org/1234?hello=123",
-			[]mon{},
-			[]mon{&monitor.Healthchecks{
+			nil,
+			&monitor.Healthchecks{
 				BaseURL:    urlMustParse(t, "https://hi.org/1234?hello=123"),
 				Timeout:    monitor.HealthchecksDefaultTimeout,
 				MaxRetries: monitor.HealthchecksDefaultMaxRetries,
-			}},
+			},
 			true,
 			nil,
 		},
@@ -834,7 +834,7 @@ func TestReadHealthchecksURL(t *testing.T) {
 		tc := tc
 		t.Run(name, func(t *testing.T) {
 			set(t, key, tc.set, tc.val)
-			field := append([]mon{}, tc.oldField...)
+			field := tc.oldField
 			mockCtrl := gomock.NewController(t)
 			mockPP := mocks.NewMockPP(mockCtrl)
 			if tc.prepareMockPP != nil {
diff --git a/internal/ipnet/ipnet_test.go b/internal/ipnet/ipnet_test.go
index d305a98..76f0a91 100644
--- a/internal/ipnet/ipnet_test.go
+++ b/internal/ipnet/ipnet_test.go
@@ -70,7 +70,8 @@ func TestInt(t *testing.T) {
 	}
 }
 
-func TestNormalizeIP(t *testing.T) {
+//nolint:funlen
+func TestNormalizeDetectedIP(t *testing.T) {
 	t.Parallel()
 	for name, tc := range map[string]struct {
 		ipNet         ipnet.Type
@@ -84,7 +85,7 @@ func TestNormalizeIP(t *testing.T) {
 			netip.Addr{},
 			false,
 			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(pp.EmojiError, "%q is not a valid %s address", mustIP("1::2"), "IPv4")
+				m.EXPECT().Warningf(pp.EmojiError, "Detected IP address %s is not a valid %s address", "1::2", "IPv4")
 			},
 		},
 		"4-::ffff:0a0a:0a0a": {ipnet.IP4, mustIP("::ffff:0a0a:0a0a"), mustIP("10.10.10.10"), true, nil},
@@ -96,7 +97,7 @@ func TestNormalizeIP(t *testing.T) {
 			netip.Addr{},
 			false,
 			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(pp.EmojiError, "%q is not a valid %s address", netip.Addr{}, "IPv6")
+				m.EXPECT().Warningf(pp.EmojiImpossible, "Detected IP address is not valid")
 			},
 		},
 		"100-10.10.10.10": {
@@ -104,7 +105,18 @@ func TestNormalizeIP(t *testing.T) {
 			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
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiImpossible, "Detected IP address %s is not a valid %s address", "10.10.10.10", "<unrecognized IP network>"), //nolint:lll
+					m.EXPECT().Warningf(pp.EmojiImpossible, "Please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new"),               //nolint:lll
+				)
+			},
+		},
+		"4-0.0.0.0": {
+			ipnet.IP4, mustIP("0.0.0.0"),
+			netip.Addr{},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiImpossible, "Detected IP address %s is an unspicifed %s address", "0.0.0.0", "IPv4") //nolint:lll
 			},
 		},
 	} {
@@ -116,7 +128,7 @@ func TestNormalizeIP(t *testing.T) {
 			if tc.prepareMockPP != nil {
 				tc.prepareMockPP(mockPP)
 			}
-			ip, ok := tc.ipNet.NormalizeIP(mockPP, tc.ip)
+			ip, ok := tc.ipNet.NormalizeDetectedIP(mockPP, tc.ip)
 			require.Equal(t, tc.expected, ip)
 			require.Equal(t, tc.ok, ok)
 		})
diff --git a/internal/monitor/util_test.go b/internal/monitor/composite_test.go
similarity index 75%
rename from internal/monitor/util_test.go
rename to internal/monitor/composite_test.go
index 411e62a..6ee6109 100644
--- a/internal/monitor/util_test.go
+++ b/internal/monitor/composite_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
 )
 
-func TestSuccessAll(t *testing.T) {
+func TestMonitorsSuccess(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -26,10 +26,10 @@ func TestSuccessAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.SuccessAll(context.Background(), mockPP, ms, message)
+	monitor.Monitors(ms).Success(context.Background(), mockPP, message)
 }
 
-func TestStartAll(t *testing.T) {
+func TestMonitorsStart(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -45,10 +45,10 @@ func TestStartAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.StartAll(context.Background(), mockPP, ms, message)
+	monitor.Monitors(ms).Start(context.Background(), mockPP, message)
 }
 
-func TestFailureAll(t *testing.T) {
+func TestMonitorsFailure(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -64,10 +64,10 @@ func TestFailureAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.FailureAll(context.Background(), mockPP, ms, message)
+	monitor.Monitors(ms).Failure(context.Background(), mockPP, message)
 }
 
-func TestLogAll(t *testing.T) {
+func TestMonitorsLog(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -83,10 +83,10 @@ func TestLogAll(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.LogAll(context.Background(), mockPP, ms, message)
+	monitor.Monitors(ms).Log(context.Background(), mockPP, message)
 }
 
-func TestExitStatus(t *testing.T) {
+func TestMonitorsExitStatus(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -102,5 +102,5 @@ func TestExitStatus(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.ExitStatusAll(context.Background(), mockPP, ms, 42, message)
+	monitor.Monitors(ms).ExitStatus(context.Background(), mockPP, 42, message)
 }
diff --git a/internal/monitor/healthchecks_test.go b/internal/monitor/healthchecks_test.go
index 7ca8fdd..54ac803 100644
--- a/internal/monitor/healthchecks_test.go
+++ b/internal/monitor/healthchecks_test.go
@@ -95,14 +95,16 @@ func TestNewHealthchecksFail3(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestDescripbeService(t *testing.T) {
+func TestDescripbe(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
 	mockPP := mocks.NewMockPP(mockCtrl)
 	m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path")
 	require.True(t, ok)
-	require.Equal(t, "Healthchecks", m.DescribeService())
+	m.Describe(func(service, params string) {
+		require.Equal(t, "Healthchecks", service)
+	})
 }
 
 //nolint:funlen
diff --git a/internal/provider/cloudflare_trace_test.go b/internal/provider/cloudflare_trace_test.go
index 5d2dcaf..9907ef4 100644
--- a/internal/provider/cloudflare_trace_test.go
+++ b/internal/provider/cloudflare_trace_test.go
@@ -64,8 +64,8 @@ func TestCloudflareTraceGetIP(t *testing.T) {
 				ipnet.IP4, ip6Server.URL, "hello6", ipnet.IP4, invalidIP,
 				func(m *mocks.MockPP) {
 					m.EXPECT().Warningf(
-						pp.EmojiError, "%q is not a valid %s address",
-						ip6,
+						pp.EmojiError, "Detected IP address %s is not a valid %s address",
+						ip6.String(),
 						"IPv4",
 					)
 				},
diff --git a/internal/provider/dns_test.go b/internal/provider/dns_test.go
index 0d83782..038d9c8 100644
--- a/internal/provider/dns_test.go
+++ b/internal/provider/dns_test.go
@@ -86,7 +86,7 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 	t.Parallel()
 
 	ip4 := netip.MustParseAddr("1.2.3.4")
-	ip6 := netip.MustParseAddr("::1:2:3:4")
+	ip6 := netip.MustParseAddr("2606:4700:4700::1234")
 	invalidIP := netip.Addr{}
 
 	for name, tc := range map[string]struct {
@@ -168,8 +168,8 @@ func TestDNSOverHTTPSGetIP(t *testing.T) {
 			func(m *mocks.MockPP) {
 				m.EXPECT().Warningf(
 					pp.EmojiError,
-					"%q is not a valid %s address",
-					ip6,
+					"Detected IP address %s is not a valid %s address",
+					ip6.String(),
 					"IPv4",
 				)
 			},
diff --git a/internal/provider/http_test.go b/internal/provider/http_test.go
index 980c2c4..1670a8b 100644
--- a/internal/provider/http_test.go
+++ b/internal/provider/http_test.go
@@ -63,8 +63,8 @@ func TestHTTPGetIP(t *testing.T) {
 				ipnet.IP4, ip6Server.URL, ipnet.IP4, invalidIP,
 				func(m *mocks.MockPP) {
 					m.EXPECT().Warningf(
-						pp.EmojiError, "%q is not a valid %s address",
-						ip6,
+						pp.EmojiError, "Detected IP address %s is not a valid %s address",
+						ip6.String(),
 						"IPv4",
 					)
 				},
diff --git a/internal/provider/local_test.go b/internal/provider/local_test.go
index 1947b25..75def5e 100644
--- a/internal/provider/local_test.go
+++ b/internal/provider/local_test.go
@@ -40,8 +40,18 @@ func TestLocalGetIP(t *testing.T) {
 		expected      netip.Addr
 		prepareMockPP func(*mocks.MockPP)
 	}{
-		"4": {ipnet.IP4, "127.0.0.1:80", ipnet.IP4, ip4Loopback, nil},
-		"6": {ipnet.IP6, "[::1]:80", ipnet.IP6, ip6Loopback, nil},
+		"4": {
+			ipnet.IP4, "127.0.0.1:80", ipnet.IP4, ip4Loopback,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserWarning, "Detected IP address %s does not look like a global unicast IP address. Please double-check.", "127.0.0.1") //nolint:lll
+			},
+		},
+		"6": {
+			ipnet.IP6, "[::1]:80", ipnet.IP6, ip6Loopback,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserWarning, "Detected IP address %s does not look like a global unicast IP address. Please double-check.", "::1") //nolint:lll
+			},
+		},
 		"4-nil1": {
 			ipnet.IP4, "", ipnet.IP4, invalidIP,
 			func(m *mocks.MockPP) {
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index a5b2d05..6bd6955 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -51,14 +51,14 @@ func TestUpdateIPs(t *testing.T) {
 	proxiedBoth := mockproxied{domain4: true, domain6: true}
 
 	for name, tc := range map[string]struct {
-		ttl                  api.TTL
-		proxied              mockproxied
-		ok                   bool
-		msg                  string
-		MessageShouldDisplay map[ipnet.Type]bool
-		prepareMockPP        func(m *mocks.MockPP)
-		prepareMockProvider  mockproviders
-		prepareMockSetter    func(ppfmt pp.PP, m *mocks.MockSetter)
+		ttl                       api.TTL
+		proxied                   mockproxied
+		ok                        bool
+		msg                       string
+		ShouldDisplayHelpMessages map[ipnet.Type]bool
+		prepareMockPP             func(m *mocks.MockPP)
+		prepareMockProvider       mockproviders
+		prepareMockSetter         func(ppfmt pp.PP, m *mocks.MockSetter)
 	}{
 		"none": {
 			api.TTLAuto, proxiedBoth, true, ``, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, nil, mockproviders{}, nil,
@@ -286,7 +286,7 @@ func TestUpdateIPs(t *testing.T) {
 				tc.prepareMockPP(mockPP)
 			}
 			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
-				updater.MessageShouldDisplay[ipnet] = tc.MessageShouldDisplay[ipnet]
+				updater.ShouldDisplayHelpMessages[ipnet] = tc.ShouldDisplayHelpMessages[ipnet]
 				if tc.prepareMockProvider[ipnet] == nil {
 					conf.Provider[ipnet] = nil
 					continue
@@ -321,14 +321,14 @@ func TestClearIPs(t *testing.T) {
 	proxiedNone := mockproxied{domain4: false, domain6: false}
 
 	for name, tc := range map[string]struct {
-		ttl                  api.TTL
-		proxied              mockproxied
-		ok                   bool
-		msg                  string
-		MessageShouldDisplay map[ipnet.Type]bool
-		prepareMockPP        func(m *mocks.MockPP)
-		prepareMockProvider  mockproviders
-		prepareMockSetter    func(ppfmt pp.PP, m *mocks.MockSetter)
+		ttl                       api.TTL
+		proxied                   mockproxied
+		ok                        bool
+		msg                       string
+		ShouldDisplayHelpMessages map[ipnet.Type]bool
+		prepareMockPP             func(m *mocks.MockPP)
+		prepareMockProvider       mockproviders
+		prepareMockSetter         func(ppfmt pp.PP, m *mocks.MockSetter)
 	}{
 		"none": {
 			api.TTLAuto,
@@ -447,7 +447,7 @@ func TestClearIPs(t *testing.T) {
 				tc.prepareMockPP(mockPP)
 			}
 			for _, ipnet := range [...]ipnet.Type{ipnet.IP4, ipnet.IP6} {
-				updater.MessageShouldDisplay[ipnet] = tc.MessageShouldDisplay[ipnet]
+				updater.ShouldDisplayHelpMessages[ipnet] = tc.ShouldDisplayHelpMessages[ipnet]
 				if !tc.prepareMockProvider[ipnet] {
 					conf.Provider[ipnet] = nil
 					continue
diff --git a/test/fuzzer/fuzzer.go b/test/fuzzer/fuzzer.go
index 5414927..e1d1878 100644
--- a/test/fuzzer/fuzzer.go
+++ b/test/fuzzer/fuzzer.go
@@ -10,20 +10,21 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-type Reporter struct{}
+type reporter struct{}
 
-func (r Reporter) Errorf(format string, args ...any) {
+func (r reporter) Errorf(format string, args ...any) {
 	log.Fatalf(format, args...)
 }
 
-func (r Reporter) Fatalf(format string, args ...any) {
+func (r reporter) Fatalf(format string, args ...any) {
 	log.Fatalf(format, args...)
 }
 
+// ParseList fuzz test [domainexp.ParseList].
 func ParseList(bytes []byte) int {
 	input := string(bytes)
 
-	mockCtrl := gomock.NewController(Reporter{})
+	mockCtrl := gomock.NewController(reporter{})
 	defer mockCtrl.Finish()
 
 	mockPP := mocks.NewMockPP(mockCtrl)
@@ -39,10 +40,11 @@ func ParseList(bytes []byte) int {
 	return 0
 }
 
+// ParseExpression fuzz test [domainexp.ParseExpression].
 func ParseExpression(bytes []byte) int {
 	input := string(bytes)
 
-	mockCtrl := gomock.NewController(Reporter{})
+	mockCtrl := gomock.NewController(reporter{})
 	defer mockCtrl.Finish()
 
 	mockPP := mocks.NewMockPP(mockCtrl)