Repository: favonia/cloudflare-ddns
Pull request #491 (2023-05-19 13:59:12)

feat(config): use 1.0.0.1 when 1.1.1.1 is blocked

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) Report
> Merging [#491](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (0d4a57b) into [main](https://app.codecov.io/gh/favonia/cloudflare-ddns/commit/0cc67e0a24c21b2af2e2014b16236a984ba26134?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia) (0cc67e0) will **decrease** coverage by `0.44%`.
> The diff coverage is `95.69%`.

```diff
@@            Coverage Diff             @@
##             main     #491      +/-   ##
==========================================
- Coverage   87.82%   87.39%   -0.44%     
==========================================
  Files          35       41       +6     
  Lines        2095     2134      +39     
==========================================
+ Hits         1840     1865      +25     
- Misses        247      260      +13     
- Partials        8        9       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `87.39% <95.69%> (-0.44%)` | :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://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?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://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?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/config/env\_base.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2Vudl9iYXNlLmdv) | `100.00% <ø> (ø)` | |
| [internal/config/network\_probe.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL25ldHdvcmtfcHJvYmUuZ28=) | `50.00% <50.00%> (ø)` | |
| [internal/config/config.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2NvbmZpZy5nbw==) | `100.00% <100.00%> (ø)` | |
| [internal/config/config\_print.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2NvbmZpZ19wcmludC5nbw==) | `100.00% <100.00%> (ø)` | |
| [internal/config/config\_read.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2NvbmZpZ19yZWFkLmdv) | `100.00% <100.00%> (ø)` | |
| [internal/config/env\_auth.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2Vudl9hdXRoLmdv) | `100.00% <100.00%> (ø)` | |
| [internal/config/env\_domain.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2Vudl9kb21haW4uZ28=) | `100.00% <100.00%> (ø)` | |
| [internal/config/env\_provider.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvY29uZmlnL2Vudl9wcm92aWRlci5nbw==) | `100.00% <100.00%> (ø)` | |
| [internal/provider/cloudflare\_doh.go](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=favonia#diff-aW50ZXJuYWwvcHJvdmlkZXIvY2xvdWRmbGFyZV9kb2guZ28=) | `100.00% <100.00%> (ø)` | |
| ... and [2 more](https://app.codecov.io/gh/favonia/cloudflare-ddns/pull/491?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/cmd/ddns/ddns.go b/cmd/ddns/ddns.go
index 3cda667..f7c43f2 100644
--- a/cmd/ddns/ddns.go
+++ b/cmd/ddns/ddns.go
@@ -28,10 +28,11 @@ func formatName() string {
 }
 
 func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, setter.Setter, bool) {
-	c := config.Default()
+	use1001 := config.ShouldWeUse1001(ctx, ppfmt)
+	c := config.Default(use1001)
 
 	// Read the config
-	if !c.ReadEnv(ppfmt) || !c.NormalizeConfig(ppfmt) {
+	if !c.ReadEnv(ppfmt, use1001) || !c.NormalizeConfig(ppfmt) {
 		return c, nil, false
 	}
 
diff --git a/internal/config/config.go b/internal/config/config.go
index a0ad905..d45a519 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -2,20 +2,13 @@
 package config
 
 import (
-	"fmt"
-	"strings"
 	"time"
 
-	"golang.org/x/exp/slices"
-
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/cron"
 	"github.com/favonia/cloudflare-ddns/internal/domain"
-	"github.com/favonia/cloudflare-ddns/internal/domainexp"
-	"github.com/favonia/cloudflare-ddns/internal/file"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
-	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
 )
 
@@ -37,13 +30,13 @@ type Config struct {
 	Monitor          monitor.Monitor
 }
 
-// Default gives the default configuration.
-func Default() *Config {
+// Default gives the default configuration. If use1001 is true, 1.0.0.1 is used instead of 1.1.1.1.
+func Default(use1001 bool) *Config {
 	return &Config{
 		Auth: nil,
 		Provider: map[ipnet.Type]provider.Provider{
-			ipnet.IP4: provider.NewCloudflareTrace(),
-			ipnet.IP6: provider.NewCloudflareTrace(),
+			ipnet.IP4: provider.NewCloudflareTrace(use1001),
+			ipnet.IP6: provider.NewCloudflareTrace(use1001),
 		},
 		Domains: map[ipnet.Type][]domain.Domain{
 			ipnet.IP4: nil,
@@ -61,303 +54,3 @@ func Default() *Config {
 		Monitor:          monitor.Monitors{},
 	}
 }
-
-func readAuthToken(ppfmt pp.PP) (string, bool) {
-	var (
-		token     = Getenv("CF_API_TOKEN")
-		tokenFile = Getenv("CF_API_TOKEN_FILE")
-	)
-
-	// foolproof checks
-	if token == "YOUR-CLOUDFLARE-API-TOKEN" {
-		ppfmt.Errorf(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
-		return "", false
-	}
-
-	switch {
-	case token != "" && tokenFile != "":
-		ppfmt.Errorf(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
-		return "", false
-	case token != "":
-		return token, true
-	case tokenFile != "":
-		token, ok := file.ReadString(ppfmt, tokenFile)
-		if !ok {
-			return "", false
-		}
-
-		if token == "" {
-			ppfmt.Errorf(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
-			return "", false
-		}
-
-		return token, true
-	default:
-		ppfmt.Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
-		return "", false
-	}
-}
-
-// 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 {
-		return false
-	}
-
-	accountID := Getenv("CF_ACCOUNT_ID")
-
-	*field = &api.CloudflareAuth{Token: token, AccountID: accountID, BaseURL: ""}
-	return true
-}
-
-// deduplicate always sorts and deduplicates the input list,
-// returning true if elements are already distinct.
-func deduplicate(list []domain.Domain) []domain.Domain {
-	domain.SortDomains(list)
-	return slices.Compact(list)
-}
-
-// 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
-
-	if !ReadDomains(ppfmt, "DOMAINS", &domains) ||
-		!ReadDomains(ppfmt, "IP4_DOMAINS", &ip4Domains) ||
-		!ReadDomains(ppfmt, "IP6_DOMAINS", &ip6Domains) {
-		return false
-	}
-
-	ip4Domains = deduplicate(append(ip4Domains, domains...))
-	ip6Domains = deduplicate(append(ip6Domains, domains...))
-
-	*field = map[ipnet.Type][]domain.Domain{
-		ipnet.IP4: ip4Domains,
-		ipnet.IP6: ip6Domains,
-	}
-
-	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]
-
-	if !ReadProvider(ppfmt, "IP4_PROVIDER", "IP4_POLICY", &ip4Provider) ||
-		!ReadProvider(ppfmt, "IP6_PROVIDER", "IP6_POLICY", &ip6Provider) {
-		return false
-	}
-
-	*field = map[ipnet.Type]provider.Provider{
-		ipnet.IP4: ip4Provider,
-		ipnet.IP6: ip6Provider,
-	}
-	return true
-}
-
-func describeDomains(domains []domain.Domain) string {
-	if len(domains) == 0 {
-		return "(none)"
-	}
-
-	descriptions := make([]string, 0, len(domains))
-	for _, domain := range domains {
-		descriptions = append(descriptions, domain.Describe())
-	}
-	return strings.Join(descriptions, ", ")
-}
-
-func getInverseMap[V comparable](m map[domain.Domain]V) ([]V, map[V][]domain.Domain) {
-	inverse := map[V][]domain.Domain{}
-
-	for dom, val := range m {
-		inverse[val] = append(inverse[val], dom)
-	}
-
-	vals := make([]V, 0, len(inverse))
-	for val := range inverse {
-		domain.SortDomains(inverse[val])
-		vals = append(vals, val)
-	}
-
-	return vals, inverse
-}
-
-const itemTitleWidth = 24
-
-// Print prints the Config on the screen.
-func (c *Config) Print(ppfmt pp.PP) {
-	if !ppfmt.IsEnabledFor(pp.Info) {
-		return
-	}
-
-	ppfmt.Infof(pp.EmojiEnvVars, "Current settings:")
-	ppfmt = ppfmt.IncIndent()
-	inner := ppfmt.IncIndent()
-
-	section := func(title string) { ppfmt.Infof(pp.EmojiConfig, title) }
-	item := func(title string, format string, values ...any) {
-		inner.Infof(pp.EmojiBullet, "%-*s %s", itemTitleWidth, title, fmt.Sprintf(format, values...))
-	}
-
-	section("Domains and IP providers:")
-	if c.Provider[ipnet.IP4] != nil {
-		item("IPv4 domains:", "%s", describeDomains(c.Domains[ipnet.IP4]))
-		item("IPv4 provider:", "%s", provider.Name(c.Provider[ipnet.IP4]))
-	}
-	if c.Provider[ipnet.IP6] != nil {
-		item("IPv6 domains:", "%s", describeDomains(c.Domains[ipnet.IP6]))
-		item("IPv6 provider:", "%s", provider.Name(c.Provider[ipnet.IP6]))
-	}
-
-	section("Scheduling:")
-	item("Timezone:", "%s", cron.DescribeLocation(time.Local))
-	item("Update frequency:", "%s", cron.DescribeSchedule(c.UpdateCron))
-	item("Update on start?", "%t", c.UpdateOnStart)
-	item("Delete on stop?", "%t", c.DeleteOnStop)
-	item("Cache expiration:", "%v", c.CacheExpiration)
-
-	section("New DNS records:")
-	item("TTL:", "%s", c.TTL.Describe())
-	if len(c.Proxied) > 0 {
-		_, inverseMap := getInverseMap(c.Proxied)
-		item("Proxied domains:", "%s", describeDomains(inverseMap[true]))
-		item("Unproxied domains:", "%s", describeDomains(inverseMap[false]))
-	}
-
-	section("Timeouts:")
-	item("IP detection:", "%v", c.DetectionTimeout)
-	item("Record updating:", "%v", c.UpdateTimeout)
-
-	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 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.NormalizeConfig] 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 . . .")
-		ppfmt = ppfmt.IncIndent()
-	}
-
-	if !ReadAuth(ppfmt, &c.Auth) ||
-		!ReadProviderMap(ppfmt, &c.Provider) ||
-		!ReadDomainMap(ppfmt, &c.Domains) ||
-		!ReadCron(ppfmt, "UPDATE_CRON", &c.UpdateCron) ||
-		!ReadBool(ppfmt, "UPDATE_ON_START", &c.UpdateOnStart) ||
-		!ReadBool(ppfmt, "DELETE_ON_STOP", &c.DeleteOnStop) ||
-		!ReadNonnegDuration(ppfmt, "CACHE_EXPIRATION", &c.CacheExpiration) ||
-		!ReadTTL(ppfmt, "TTL", &c.TTL) ||
-		!ReadString(ppfmt, "PROXIED", &c.ProxiedTemplate) ||
-		!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) ||
-		!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) ||
-		!ReadHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitor) {
-		return false
-	}
-
-	return true
-}
-
-// NormalizeConfig checks and normalizes the fields [Config.Provider], [Config.Proxied], and [Config.DeleteOnStop].
-// When any error is reported, the original configuration remain unchanged.
-//
-//nolint:funlen
-func (c *Config) NormalizeConfig(ppfmt pp.PP) bool {
-	if ppfmt.IsEnabledFor(pp.Info) {
-		ppfmt.Infof(pp.EmojiEnvVars, "Checking settings . . .")
-		ppfmt = ppfmt.IncIndent()
-	}
-
-	// Part 1: check DELETE_ON_STOP
-	if c.UpdateCron == nil && c.DeleteOnStop {
-		ppfmt.Errorf(
-			pp.EmojiUserError,
-			"DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled")
-		return false
-	}
-
-	// Part 2: normalize domain maps
-	// New domain maps
-	providerMap := map[ipnet.Type]provider.Provider{}
-	proxiedMap := map[domain.Domain]bool{}
-	activeDomainSet := map[domain.Domain]bool{}
-
-	if len(c.Domains[ipnet.IP4]) == 0 && len(c.Domains[ipnet.IP6]) == 0 {
-		ppfmt.Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS")
-		return false
-	}
-
-	// fill in providerMap and activeDomainSet
-	for ipNet, domains := range c.Domains {
-		if c.Provider[ipNet] == nil {
-			continue
-		}
-
-		if len(domains) == 0 {
-			ppfmt.Warningf(pp.EmojiUserWarning, "IP%d_PROVIDER was changed to %q because no domains were set for %s",
-				ipNet.Int(), provider.Name(nil), ipNet.Describe())
-
-			continue
-		}
-
-		providerMap[ipNet] = c.Provider[ipNet]
-		for _, domain := range domains {
-			activeDomainSet[domain] = true
-		}
-	}
-
-	// check if all providers are nil
-	if providerMap[ipnet.IP4] == nil && providerMap[ipnet.IP6] == nil {
-		ppfmt.Errorf(pp.EmojiUserError, "Nothing to update because both IP4_PROVIDER and IP6_PROVIDER are %q",
-			provider.Name(nil))
-		return false
-	}
-
-	// check if some domains are unused
-	for ipNet, domains := range c.Domains {
-		if providerMap[ipNet] != nil {
-			continue
-		}
-
-		for _, domain := range domains {
-			if activeDomainSet[domain] {
-				continue
-			}
-
-			ppfmt.Warningf(pp.EmojiUserWarning,
-				"Domain %q is ignored because it is only for %s but %s is disabled",
-				domain.Describe(), ipNet.Describe(), ipNet.Describe())
-		}
-	}
-
-	// fill in proxyMap
-	proxiedPred, ok := domainexp.ParseExpression(ppfmt, "PROXIED", c.ProxiedTemplate)
-	if !ok {
-		return false
-	}
-	for dom := range activeDomainSet {
-		proxiedMap[dom] = proxiedPred(dom)
-	}
-
-	// Part 3: override the old values
-	c.Provider = providerMap
-	c.Proxied = proxiedMap
-
-	return true
-}
diff --git a/internal/config/config_print.go b/internal/config/config_print.go
new file mode 100644
index 0000000..8ea8127
--- /dev/null
+++ b/internal/config/config_print.go
@@ -0,0 +1,102 @@
+// Package config reads and parses configurations.
+package config
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/favonia/cloudflare-ddns/internal/cron"
+	"github.com/favonia/cloudflare-ddns/internal/domain"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/provider"
+)
+
+const itemTitleWidth = 24
+
+func describeDomains(domains []domain.Domain) string {
+	if len(domains) == 0 {
+		return "(none)"
+	}
+
+	descriptions := make([]string, 0, len(domains))
+	for _, domain := range domains {
+		descriptions = append(descriptions, domain.Describe())
+	}
+	return strings.Join(descriptions, ", ")
+}
+
+func getInverseMap[V comparable](m map[domain.Domain]V) ([]V, map[V][]domain.Domain) {
+	inverse := map[V][]domain.Domain{}
+
+	for dom, val := range m {
+		inverse[val] = append(inverse[val], dom)
+	}
+
+	vals := make([]V, 0, len(inverse))
+	for val := range inverse {
+		domain.SortDomains(inverse[val])
+		vals = append(vals, val)
+	}
+
+	return vals, inverse
+}
+
+// Print prints the Config on the screen.
+func (c *Config) Print(ppfmt pp.PP) {
+	if !ppfmt.IsEnabledFor(pp.Info) {
+		return
+	}
+
+	ppfmt.Infof(pp.EmojiEnvVars, "Current settings:")
+	ppfmt = ppfmt.IncIndent()
+	inner := ppfmt.IncIndent()
+
+	section := func(title string) { ppfmt.Infof(pp.EmojiConfig, title) }
+	item := func(title string, format string, values ...any) {
+		inner.Infof(pp.EmojiBullet, "%-*s %s", itemTitleWidth, title, fmt.Sprintf(format, values...))
+	}
+
+	section("Domains and IP providers:")
+	if c.Provider[ipnet.IP4] != nil {
+		item("IPv4 domains:", "%s", describeDomains(c.Domains[ipnet.IP4]))
+		item("IPv4 provider:", "%s", provider.Name(c.Provider[ipnet.IP4]))
+	}
+	if c.Provider[ipnet.IP6] != nil {
+		item("IPv6 domains:", "%s", describeDomains(c.Domains[ipnet.IP6]))
+		item("IPv6 provider:", "%s", provider.Name(c.Provider[ipnet.IP6]))
+	}
+
+	section("Scheduling:")
+	item("Timezone:", "%s", cron.DescribeLocation(time.Local))
+	item("Update frequency:", "%s", cron.DescribeSchedule(c.UpdateCron))
+	item("Update on start?", "%t", c.UpdateOnStart)
+	item("Delete on stop?", "%t", c.DeleteOnStop)
+	item("Cache expiration:", "%v", c.CacheExpiration)
+
+	section("New DNS records:")
+	item("TTL:", "%s", c.TTL.Describe())
+	if len(c.Proxied) > 0 {
+		_, inverseMap := getInverseMap(c.Proxied)
+		item("Proxied domains:", "%s", describeDomains(inverseMap[true]))
+		item("Unproxied domains:", "%s", describeDomains(inverseMap[false]))
+	}
+
+	section("Timeouts:")
+	item("IP detection:", "%v", c.DetectionTimeout)
+	item("Record updating:", "%v", c.UpdateTimeout)
+
+	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 monitors {
+			item(m[0]+":", "%s", m[1])
+		}
+	}
+}
diff --git a/internal/config/config_read.go b/internal/config/config_read.go
new file mode 100644
index 0000000..17c7e01
--- /dev/null
+++ b/internal/config/config_read.go
@@ -0,0 +1,124 @@
+package config
+
+import (
+	"github.com/favonia/cloudflare-ddns/internal/domain"
+	"github.com/favonia/cloudflare-ddns/internal/domainexp"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/provider"
+)
+
+// ReadEnv calls the relevant readers to read all relevant environment variables except TZ
+// and update relevant fields. One should subsequently call [Config.NormalizeConfig] to maintain
+// invariants across different fields. If use1001 is true, 1.0.0.1 is used instead of 1.1.1.1.
+func (c *Config) ReadEnv(ppfmt pp.PP, use1001 bool) bool {
+	if ppfmt.IsEnabledFor(pp.Info) {
+		ppfmt.Infof(pp.EmojiEnvVars, "Reading settings . . .")
+		ppfmt = ppfmt.IncIndent()
+	}
+
+	if !ReadAuth(ppfmt, &c.Auth) ||
+		!ReadProviderMap(ppfmt, use1001, &c.Provider) ||
+		!ReadDomainMap(ppfmt, &c.Domains) ||
+		!ReadCron(ppfmt, "UPDATE_CRON", &c.UpdateCron) ||
+		!ReadBool(ppfmt, "UPDATE_ON_START", &c.UpdateOnStart) ||
+		!ReadBool(ppfmt, "DELETE_ON_STOP", &c.DeleteOnStop) ||
+		!ReadNonnegDuration(ppfmt, "CACHE_EXPIRATION", &c.CacheExpiration) ||
+		!ReadTTL(ppfmt, "TTL", &c.TTL) ||
+		!ReadString(ppfmt, "PROXIED", &c.ProxiedTemplate) ||
+		!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) ||
+		!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) ||
+		!ReadHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitor) {
+		return false
+	}
+
+	return true
+}
+
+// NormalizeConfig checks and normalizes the fields [Config.Provider], [Config.Proxied], and [Config.DeleteOnStop].
+// When any error is reported, the original configuration remain unchanged.
+//
+//nolint:funlen
+func (c *Config) NormalizeConfig(ppfmt pp.PP) bool {
+	if ppfmt.IsEnabledFor(pp.Info) {
+		ppfmt.Infof(pp.EmojiEnvVars, "Checking settings . . .")
+		ppfmt = ppfmt.IncIndent()
+	}
+
+	// Part 1: check DELETE_ON_STOP
+	if c.UpdateCron == nil && c.DeleteOnStop {
+		ppfmt.Errorf(
+			pp.EmojiUserError,
+			"DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled")
+		return false
+	}
+
+	// Part 2: normalize domain maps
+	// New domain maps
+	providerMap := map[ipnet.Type]provider.Provider{}
+	proxiedMap := map[domain.Domain]bool{}
+	activeDomainSet := map[domain.Domain]bool{}
+
+	if len(c.Domains[ipnet.IP4]) == 0 && len(c.Domains[ipnet.IP6]) == 0 {
+		ppfmt.Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS")
+		return false
+	}
+
+	// fill in providerMap and activeDomainSet
+	for ipNet, domains := range c.Domains {
+		if c.Provider[ipNet] == nil {
+			continue
+		}
+
+		if len(domains) == 0 {
+			ppfmt.Warningf(pp.EmojiUserWarning, "IP%d_PROVIDER was changed to %q because no domains were set for %s",
+				ipNet.Int(), provider.Name(nil), ipNet.Describe())
+
+			continue
+		}
+
+		providerMap[ipNet] = c.Provider[ipNet]
+		for _, domain := range domains {
+			activeDomainSet[domain] = true
+		}
+	}
+
+	// check if all providers are nil
+	if providerMap[ipnet.IP4] == nil && providerMap[ipnet.IP6] == nil {
+		ppfmt.Errorf(pp.EmojiUserError, "Nothing to update because both IP4_PROVIDER and IP6_PROVIDER are %q",
+			provider.Name(nil))
+		return false
+	}
+
+	// check if some domains are unused
+	for ipNet, domains := range c.Domains {
+		if providerMap[ipNet] != nil {
+			continue
+		}
+
+		for _, domain := range domains {
+			if activeDomainSet[domain] {
+				continue
+			}
+
+			ppfmt.Warningf(pp.EmojiUserWarning,
+				"Domain %q is ignored because it is only for %s but %s is disabled",
+				domain.Describe(), ipNet.Describe(), ipNet.Describe())
+		}
+	}
+
+	// fill in proxyMap
+	proxiedPred, ok := domainexp.ParseExpression(ppfmt, "PROXIED", c.ProxiedTemplate)
+	if !ok {
+		return false
+	}
+	for dom := range activeDomainSet {
+		proxiedMap[dom] = proxiedPred(dom)
+	}
+
+	// Part 3: override the old values
+	c.Provider = providerMap
+	c.Proxied = proxiedMap
+
+	return true
+}
diff --git a/internal/config/env_auth.go b/internal/config/env_auth.go
new file mode 100644
index 0000000..45ad5cd
--- /dev/null
+++ b/internal/config/env_auth.go
@@ -0,0 +1,57 @@
+package config
+
+import (
+	"github.com/favonia/cloudflare-ddns/internal/api"
+	"github.com/favonia/cloudflare-ddns/internal/file"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+func readAuthToken(ppfmt pp.PP) (string, bool) {
+	var (
+		token     = Getenv("CF_API_TOKEN")
+		tokenFile = Getenv("CF_API_TOKEN_FILE")
+	)
+
+	// foolproof checks
+	if token == "YOUR-CLOUDFLARE-API-TOKEN" {
+		ppfmt.Errorf(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
+		return "", false
+	}
+
+	switch {
+	case token != "" && tokenFile != "":
+		ppfmt.Errorf(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
+		return "", false
+	case token != "":
+		return token, true
+	case tokenFile != "":
+		token, ok := file.ReadString(ppfmt, tokenFile)
+		if !ok {
+			return "", false
+		}
+
+		if token == "" {
+			ppfmt.Errorf(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
+			return "", false
+		}
+
+		return token, true
+	default:
+		ppfmt.Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
+		return "", false
+	}
+}
+
+// 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 {
+		return false
+	}
+
+	accountID := Getenv("CF_ACCOUNT_ID")
+
+	*field = &api.CloudflareAuth{Token: token, AccountID: accountID, BaseURL: ""}
+	return true
+}
diff --git a/internal/config/env.go b/internal/config/env_base.go
similarity index 63%
rename from internal/config/env.go
rename to internal/config/env_base.go
index ed5c3f1..0921974 100644
--- a/internal/config/env.go
+++ b/internal/config/env_base.go
@@ -8,11 +8,8 @@ import (
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/cron"
-	"github.com/favonia/cloudflare-ddns/internal/domain"
-	"github.com/favonia/cloudflare-ddns/internal/domainexp"
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/provider"
 )
 
 // Getenv reads an environment variable and trim the space.
@@ -173,124 +170,6 @@ func ReadTTL(ppfmt pp.PP, key string, field *api.TTL) bool {
 	}
 }
 
-// ReadDomains reads an environment variable as a comma-separated list of domains.
-func ReadDomains(ppfmt pp.PP, key string, field *[]domain.Domain) bool {
-	if list, ok := domainexp.ParseList(ppfmt, key, Getenv(key)); ok {
-		*field = list
-		return true
-	}
-	return false
-}
-
-// ReadProvider reads an environment variable and parses it as a provider.
-//
-// policyKey was the name of the deprecated parameters IP4/6_POLICY.
-//
-//nolint:funlen
-func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provider) bool {
-	if val := Getenv(key); val == "" {
-		// parsing of the deprecated parameter
-		switch valPolicy := Getenv(keyDeprecated); valPolicy {
-		case "":
-			ppfmt.Infof(pp.EmojiBullet, "Use default %s=%s", key, provider.Name(*field))
-			return true
-		case "cloudflare":
-			ppfmt.Warningf(
-				pp.EmojiUserWarning,
-				`%s=cloudflare is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
-				keyDeprecated, key, key,
-			)
-			*field = provider.NewCloudflareTrace()
-			return true
-		case "cloudflare.trace":
-			ppfmt.Warningf(
-				pp.EmojiUserWarning,
-				`%s is deprecated; use %s=%s`,
-				keyDeprecated, key, valPolicy,
-			)
-			*field = provider.NewCloudflareTrace()
-			return true
-		case "cloudflare.doh":
-			ppfmt.Warningf(
-				pp.EmojiUserWarning,
-				`%s is deprecated; use %s=%s`,
-				keyDeprecated, key, valPolicy,
-			)
-			*field = provider.NewCloudflareDOH()
-			return true
-		case "ipify":
-			ppfmt.Warningf(
-				pp.EmojiUserWarning,
-				`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
-				keyDeprecated, key, key,
-			)
-			*field = provider.NewIpify()
-			return true
-		case "local":
-			ppfmt.Warningf(
-				pp.EmojiUserWarning,
-				`%s is deprecated; use %s=%s`,
-				keyDeprecated, key, valPolicy,
-			)
-			*field = provider.NewLocal()
-			return true
-		case "unmanaged":
-			ppfmt.Warningf(
-				pp.EmojiUserWarning,
-				`%s is deprecated; use %s=none`,
-				keyDeprecated, key,
-			)
-			*field = nil
-			return true
-		default:
-			ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", keyDeprecated, valPolicy)
-			return false
-		}
-	} else {
-		if Getenv(keyDeprecated) != "" {
-			ppfmt.Errorf(
-				pp.EmojiUserError,
-				`Cannot have both %s and %s set`,
-				key, keyDeprecated,
-			)
-			return false
-		}
-
-		switch val {
-		case "cloudflare":
-			ppfmt.Errorf(
-				pp.EmojiUserError,
-				`%s=cloudflare is invalid; use %s=cloudflare.trace or %s=cloudflare.doh`,
-				key, key, key,
-			)
-			return false
-		case "cloudflare.trace":
-			*field = provider.NewCloudflareTrace()
-			return true
-		case "cloudflare.doh":
-			*field = provider.NewCloudflareDOH()
-			return true
-		case "ipify":
-			ppfmt.Warningf(
-				pp.EmojiUserWarning,
-				`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
-				key, key, key,
-			)
-			*field = provider.NewIpify()
-			return true
-		case "local":
-			*field = provider.NewLocal()
-			return true
-		case "none":
-			*field = nil
-			return true
-		default:
-			ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", key, val)
-			return false
-		}
-	}
-}
-
 // ReadNonnegDuration reads an environment variable and parses it as a time duration.
 func ReadNonnegDuration(ppfmt pp.PP, key string, field *time.Duration) bool {
 	val := Getenv(key)
diff --git a/internal/config/env_domain.go b/internal/config/env_domain.go
new file mode 100644
index 0000000..c0dda6d
--- /dev/null
+++ b/internal/config/env_domain.go
@@ -0,0 +1,48 @@
+package config
+
+import (
+	"golang.org/x/exp/slices"
+
+	"github.com/favonia/cloudflare-ddns/internal/domain"
+	"github.com/favonia/cloudflare-ddns/internal/domainexp"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+// ReadDomains reads an environment variable as a comma-separated list of domains.
+func ReadDomains(ppfmt pp.PP, key string, field *[]domain.Domain) bool {
+	if list, ok := domainexp.ParseList(ppfmt, key, Getenv(key)); ok {
+		*field = list
+		return true
+	}
+	return false
+}
+
+// deduplicate always sorts and deduplicates the input list,
+// returning true if elements are already distinct.
+func deduplicate(list []domain.Domain) []domain.Domain {
+	domain.SortDomains(list)
+	return slices.Compact(list)
+}
+
+// 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
+
+	if !ReadDomains(ppfmt, "DOMAINS", &domains) ||
+		!ReadDomains(ppfmt, "IP4_DOMAINS", &ip4Domains) ||
+		!ReadDomains(ppfmt, "IP6_DOMAINS", &ip6Domains) {
+		return false
+	}
+
+	ip4Domains = deduplicate(append(ip4Domains, domains...))
+	ip6Domains = deduplicate(append(ip6Domains, domains...))
+
+	*field = map[ipnet.Type][]domain.Domain{
+		ipnet.IP4: ip4Domains,
+		ipnet.IP6: ip6Domains,
+	}
+
+	return true
+}
diff --git a/internal/config/env_provider.go b/internal/config/env_provider.go
new file mode 100644
index 0000000..d79cd22
--- /dev/null
+++ b/internal/config/env_provider.go
@@ -0,0 +1,135 @@
+package config
+
+import (
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/provider"
+)
+
+// ReadProvider reads an environment variable and parses it as a provider.
+//
+// policyKey was the name of the deprecated parameters IP4/6_POLICY.
+// use1001 indicates whether 1.0.0.1 should be used instead of 1.1.1.1.
+//
+//nolint:funlen
+func ReadProvider(ppfmt pp.PP, use1001 bool, key, keyDeprecated string, field *provider.Provider) bool {
+	if val := Getenv(key); val == "" {
+		// parsing of the deprecated parameter
+		switch valPolicy := Getenv(keyDeprecated); valPolicy {
+		case "":
+			ppfmt.Infof(pp.EmojiBullet, "Use default %s=%s", key, provider.Name(*field))
+			return true
+		case "cloudflare":
+			ppfmt.Warningf(
+				pp.EmojiUserWarning,
+				`%s=cloudflare is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
+				keyDeprecated, key, key,
+			)
+			*field = provider.NewCloudflareTrace(use1001)
+			return true
+		case "cloudflare.trace":
+			ppfmt.Warningf(
+				pp.EmojiUserWarning,
+				`%s is deprecated; use %s=%s`,
+				keyDeprecated, key, valPolicy,
+			)
+			*field = provider.NewCloudflareTrace(use1001)
+			return true
+		case "cloudflare.doh":
+			ppfmt.Warningf(
+				pp.EmojiUserWarning,
+				`%s is deprecated; use %s=%s`,
+				keyDeprecated, key, valPolicy,
+			)
+			*field = provider.NewCloudflareDOH(use1001)
+			return true
+		case "ipify":
+			ppfmt.Warningf(
+				pp.EmojiUserWarning,
+				`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
+				keyDeprecated, key, key,
+			)
+			*field = provider.NewIpify()
+			return true
+		case "local":
+			ppfmt.Warningf(
+				pp.EmojiUserWarning,
+				`%s is deprecated; use %s=%s`,
+				keyDeprecated, key, valPolicy,
+			)
+			*field = provider.NewLocal(use1001)
+			return true
+		case "unmanaged":
+			ppfmt.Warningf(
+				pp.EmojiUserWarning,
+				`%s is deprecated; use %s=none`,
+				keyDeprecated, key,
+			)
+			*field = nil
+			return true
+		default:
+			ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", keyDeprecated, valPolicy)
+			return false
+		}
+	} else {
+		if Getenv(keyDeprecated) != "" {
+			ppfmt.Errorf(
+				pp.EmojiUserError,
+				`Cannot have both %s and %s set`,
+				key, keyDeprecated,
+			)
+			return false
+		}
+
+		switch val {
+		case "cloudflare":
+			ppfmt.Errorf(
+				pp.EmojiUserError,
+				`%s=cloudflare is invalid; use %s=cloudflare.trace or %s=cloudflare.doh`,
+				key, key, key,
+			)
+			return false
+		case "cloudflare.trace":
+			*field = provider.NewCloudflareTrace(use1001)
+			return true
+		case "cloudflare.doh":
+			*field = provider.NewCloudflareDOH(use1001)
+			return true
+		case "ipify":
+			ppfmt.Warningf(
+				pp.EmojiUserWarning,
+				`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
+				key, key, key,
+			)
+			*field = provider.NewIpify()
+			return true
+		case "local":
+			*field = provider.NewLocal(use1001)
+			return true
+		case "none":
+			*field = nil
+			return true
+		default:
+			ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", key, val)
+			return false
+		}
+	}
+}
+
+// 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, use1001 bool, field *map[ipnet.Type]provider.Provider) bool {
+	ip4Provider := (*field)[ipnet.IP4]
+	ip6Provider := (*field)[ipnet.IP6]
+
+	if !ReadProvider(ppfmt, use1001, "IP4_PROVIDER", "IP4_POLICY", &ip4Provider) ||
+		!ReadProvider(ppfmt, use1001, "IP6_PROVIDER", "IP6_POLICY", &ip6Provider) {
+		return false
+	}
+
+	*field = map[ipnet.Type]provider.Provider{
+		ipnet.IP4: ip4Provider,
+		ipnet.IP6: ip6Provider,
+	}
+	return true
+}
diff --git a/internal/config/network_probe.go b/internal/config/network_probe.go
new file mode 100644
index 0000000..3213919
--- /dev/null
+++ b/internal/config/network_probe.go
@@ -0,0 +1,44 @@
+package config
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"time"
+
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+// ProbeURL quickly checks whether one can send a HEAD request to the url.
+func ProbeURL(ctx context.Context, url string) bool {
+	ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Second))
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
+	if err != nil {
+		return false
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return false
+	}
+	defer resp.Body.Close()
+
+	_, err = io.ReadAll(resp.Body)
+	return err == nil
+}
+
+// ShouldWeUse1001 quickly checks 1.1.1.1 and 1.0.0.1 and return whether 1.0.0.1 should be used.
+func ShouldWeUse1001(ctx context.Context, ppfmt pp.PP) bool {
+	good1111 := ProbeURL(ctx, "https://1.1.1.1")
+	good1001 := ProbeURL(ctx, "https://1.0.0.1")
+
+	if !good1111 && good1001 {
+		ppfmt.Warningf(pp.EmojiError, "1.1.1.1 might have been blocked or intercepted by your ISP or your router")
+		ppfmt.Warningf(pp.EmojiError, "1.0.0.1 seems to work and will be used instead")
+		return true
+	}
+
+	return false
+}
diff --git a/internal/provider/cloudflare_doh.go b/internal/provider/cloudflare_doh.go
index ed04e6b..06abff4 100644
--- a/internal/provider/cloudflare_doh.go
+++ b/internal/provider/cloudflare_doh.go
@@ -8,7 +8,13 @@ import (
 )
 
 // NewCloudflareDOH creates a new provider that queries whoami.cloudflare. via Cloudflare DNS over HTTPS.
-func NewCloudflareDOH() Provider {
+// If use1001 is true, 1.0.0.1 is used instead of 1.1.1.1.
+func NewCloudflareDOH(use1001 bool) Provider {
+	ip4URL := "https://1.1.1.1/dns-query"
+	if use1001 {
+		ip4URL = "https://1.0.0.1/dns-query"
+	}
+
 	return &protocol.DNSOverHTTPS{
 		ProviderName: "cloudflare.doh",
 		Param: map[ipnet.Type]struct {
@@ -16,7 +22,7 @@ func NewCloudflareDOH() Provider {
 			Name  string
 			Class dnsmessage.Class
 		}{
-			ipnet.IP4: {"https://1.1.1.1/dns-query", "whoami.cloudflare.", dnsmessage.ClassCHAOS},
+			ipnet.IP4: {ip4URL, "whoami.cloudflare.", dnsmessage.ClassCHAOS},
 			ipnet.IP6: {"https://[2606:4700:4700::1111]/dns-query", "whoami.cloudflare.", dnsmessage.ClassCHAOS},
 		},
 	}
diff --git a/internal/provider/cloudflare_trace.go b/internal/provider/cloudflare_trace.go
index c501528..7020b53 100644
--- a/internal/provider/cloudflare_trace.go
+++ b/internal/provider/cloudflare_trace.go
@@ -6,14 +6,20 @@ import (
 )
 
 // NewCloudflareTrace creates a specialized CloudflareTrace provider that parses https://1.1.1.1/cdn-cgi/trace.
-func NewCloudflareTrace() Provider {
+// If use1001 is true, 1.0.0.1 is used instead of 1.1.1.1.
+func NewCloudflareTrace(use1001 bool) Provider {
+	ip4URL := "https://1.1.1.1/cdn-cgi/trace"
+	if use1001 {
+		ip4URL = "https://1.0.0.1/cdn-cgi/trace"
+	}
+
 	return &protocol.Field{
 		ProviderName: "cloudflare.trace",
 		Param: map[ipnet.Type]struct {
 			URL   string
 			Field string
 		}{
-			ipnet.IP4: {"https://1.1.1.1/cdn-cgi/trace", "ip"},
+			ipnet.IP4: {ip4URL, "ip"},
 			ipnet.IP6: {"https://[2606:4700:4700::1111]/cdn-cgi/trace", "ip"},
 		},
 	}
diff --git a/internal/provider/local_cloudflare.go b/internal/provider/local_cloudflare.go
index 7a771c9..21352a2 100644
--- a/internal/provider/local_cloudflare.go
+++ b/internal/provider/local_cloudflare.go
@@ -6,12 +6,18 @@ import (
 )
 
 // NewLocal creates a specialized Local provider that uses Cloudflare as the remote server.
+// If use1001 is true, 1.0.0.1 is used instead of 1.1.1.1.
 // (No actual UDP packets will be sent out.)
-func NewLocal() Provider {
+func NewLocal(use1001 bool) Provider {
+	ip4Host := "1.1.1.1:443"
+	if use1001 {
+		ip4Host = "1.0.0.1:443"
+	}
+
 	return &protocol.Local{
 		ProviderName: "local",
 		RemoteUDPAddr: map[ipnet.Type]string{
-			ipnet.IP4: "1.1.1.1:443",
+			ipnet.IP4: ip4Host,
 			ipnet.IP6: "[2606:4700:4700::1111]:443",
 		},
 	}


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 16b4dbf..2a92ac1 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -19,6 +19,8 @@ jobs:
           disable-sudo: true
           egress-policy: block
           allowed-endpoints: >
+            1.0.0.1:443
+            1.1.1.1:443
             api.github.com:443
             codecov.io:443
             github.com:443
diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go
new file mode 100644
index 0000000..3739800
--- /dev/null
+++ b/internal/config/config_print_test.go
@@ -0,0 +1,180 @@
+package config_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/config"
+	"github.com/favonia/cloudflare-ddns/internal/domain"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+type someMatcher struct {
+	matchers []gomock.Matcher
+}
+
+func (sm someMatcher) Matches(x any) bool {
+	for _, m := range sm.matchers {
+		if m.Matches(x) {
+			return true
+		}
+	}
+	return false
+}
+
+func (sm someMatcher) String() string {
+	ss := make([]string, 0, len(sm.matchers))
+	for _, matcher := range sm.matchers {
+		ss = append(ss, matcher.String())
+	}
+	return strings.Join(ss, " | ")
+}
+
+func Some(xs ...any) gomock.Matcher {
+	ms := make([]gomock.Matcher, 0, len(xs))
+	for _, x := range xs {
+		if m, ok := x.(gomock.Matcher); ok {
+			ms = append(ms, m)
+		} else {
+			ms = append(ms, gomock.Eq(x))
+		}
+	}
+	return someMatcher{ms}
+}
+
+//nolint:paralleltest // changing the environment variable TZ
+func TestPrintDefault(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+
+	store(t, "TZ", "UTC")
+
+	mockPP := mocks.NewMockPP(mockCtrl)
+	innerMockPP := mocks.NewMockPP(mockCtrl)
+	gomock.InOrder(
+		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
+		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Current settings:"),
+		mockPP.EXPECT().IncIndent().Return(mockPP),
+		mockPP.EXPECT().IncIndent().Return(innerMockPP),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 domains:", "(none)"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 provider:", "cloudflare.trace"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 domains:", "(none)"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 provider:", "cloudflare.trace"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@every 5m"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "true"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "6h0m0s"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "New DNS records:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL:", "1 (auto)"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Timeouts:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IP detection:", "5s"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Record updating:", "30s"),
+	)
+	config.Default(false).Print(mockPP)
+}
+
+//nolint:paralleltest // changing the environment variable TZ
+func TestPrintMaps(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+
+	store(t, "TZ", "UTC")
+
+	mockPP := mocks.NewMockPP(mockCtrl)
+	innerMockPP := mocks.NewMockPP(mockCtrl)
+	gomock.InOrder(
+		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
+		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Current settings:"),
+		mockPP.EXPECT().IncIndent().Return(mockPP),
+		mockPP.EXPECT().IncIndent().Return(innerMockPP),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 domains:", "test4.org, *.test4.org"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 provider:", "cloudflare.trace"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 domains:", "test6.org, *.test6.org"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 provider:", "cloudflare.trace"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@every 5m"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "true"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "6h0m0s"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "New DNS records:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL:", "30000"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Proxied domains:", "a, b"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Unproxied domains:", "c, d"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Timeouts:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IP detection:", "5s"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Record updating:", "30s"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Monitors:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Healthchecks:", "(URL redacted)"),
+	)
+
+	c := config.Default(false)
+
+	c.Domains[ipnet.IP4] = []domain.Domain{domain.FQDN("test4.org"), domain.Wildcard("test4.org")}
+	c.Domains[ipnet.IP6] = []domain.Domain{domain.FQDN("test6.org"), domain.Wildcard("test6.org")}
+
+	c.TTL = 30000
+
+	c.Proxied = map[domain.Domain]bool{}
+	c.Proxied[domain.FQDN("a")] = true
+	c.Proxied[domain.FQDN("b")] = true
+	c.Proxied[domain.FQDN("c")] = false
+	c.Proxied[domain.FQDN("d")] = false
+
+	m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path")
+	require.True(t, ok)
+	c.Monitor = m
+
+	c.Print(mockPP)
+}
+
+//nolint:paralleltest // changing the environment variable TZ
+func TestPrintEmpty(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+
+	store(t, "TZ", "UTC")
+
+	mockPP := mocks.NewMockPP(mockCtrl)
+	innerMockPP := mocks.NewMockPP(mockCtrl)
+	gomock.InOrder(
+		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
+		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Current settings:"),
+		mockPP.EXPECT().IncIndent().Return(mockPP),
+		mockPP.EXPECT().IncIndent().Return(innerMockPP),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@disabled"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "false"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "0s"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "New DNS records:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL:", "0"),
+		mockPP.EXPECT().Infof(pp.EmojiConfig, "Timeouts:"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IP detection:", "0s"),
+		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Record updating:", "0s"),
+	)
+	var cfg config.Config
+	cfg.Print(mockPP)
+}
+
+//nolint:paralleltest // environment vars are global
+func TestPrintHidden(t *testing.T) {
+	mockCtrl := gomock.NewController(t)
+
+	store(t, "TZ", "UTC")
+
+	mockPP := mocks.NewMockPP(mockCtrl)
+	mockPP.EXPECT().IsEnabledFor(pp.Info).Return(false)
+
+	var cfg config.Config
+	cfg.Print(mockPP)
+}
diff --git a/internal/config/config_read_test.go b/internal/config/config_read_test.go
new file mode 100644
index 0000000..74d8d75
--- /dev/null
+++ b/internal/config/config_read_test.go
@@ -0,0 +1,397 @@
+package config_test
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/api"
+	"github.com/favonia/cloudflare-ddns/internal/config"
+	"github.com/favonia/cloudflare-ddns/internal/cron"
+	"github.com/favonia/cloudflare-ddns/internal/domain"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/provider"
+)
+
+//nolint:paralleltest // environment variables are global
+func TestReadEnvWithOnlyToken(t *testing.T) {
+	for _, use1001 := range []bool{true, false} {
+		t.Run(fmt.Sprintf("use1001=%t", use1001), func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
+			unset(t,
+				"CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID",
+				"IP4_PROVIDER", "IP6_PROVIDER",
+				"DOMAINS", "IP4_DOMAINS", "IP6_DOMAINS",
+				"UPDATE_CRON", "UPDATE_ON_START", "DELETE_ON_STOP", "CACHE_EXPIRATION", "TTL", "PROXIED", "DETECTION_TIMEOUT")
+
+			store(t, "CF_API_TOKEN", "deadbeaf")
+
+			var cfg config.Config
+			mockPP := mocks.NewMockPP(mockCtrl)
+			innerMockPP := mocks.NewMockPP(mockCtrl)
+			gomock.InOrder(
+				mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
+				mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Reading settings . . ."),
+				mockPP.EXPECT().IncIndent().Return(innerMockPP),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none"),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "none"),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "UPDATE_CRON", "@disabled"),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "UPDATE_ON_START", false),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "DELETE_ON_STOP", false),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "CACHE_EXPIRATION", time.Duration(0)),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%d", "TTL", api.TTL(0)),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "PROXIED", ""),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "DETECTION_TIMEOUT", time.Duration(0)),
+				innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "UPDATE_TIMEOUT", time.Duration(0)),
+			)
+			ok := cfg.ReadEnv(mockPP, use1001)
+			require.True(t, ok)
+		})
+	}
+}
+
+//nolint:paralleltest // environment variables are global
+func TestReadEnvEmpty(t *testing.T) {
+	for _, use1001 := range []bool{true, false} {
+		t.Run(fmt.Sprintf("use1001=%t", use1001), func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
+			unset(t,
+				"CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID",
+				"IP4_PROVIDER", "IP6_PROVIDER",
+				"IP4_POLICY", "IP6_POLICY",
+				"DOMAINS", "IP4_DOMAINS", "IP6_DOMAINS",
+				"UPDATE_CRON", "UPDATE_ON_START", "DELETE_ON_STOP", "CACHE_EXPIRATION", "TTL", "PROXIED", "DETECTION_TIMEOUT")
+
+			var cfg config.Config
+			mockPP := mocks.NewMockPP(mockCtrl)
+			innerMockPP := mocks.NewMockPP(mockCtrl)
+			gomock.InOrder(
+				mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
+				mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Reading settings . . ."),
+				mockPP.EXPECT().IncIndent().Return(innerMockPP),
+				innerMockPP.EXPECT().Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE"),
+			)
+			ok := cfg.ReadEnv(mockPP, use1001)
+			require.False(t, ok)
+		})
+	}
+}
+
+//nolint:funlen
+func TestNormalizeConfig(t *testing.T) {
+	t.Parallel()
+
+	keyProxied := "PROXIED"
+	var empty config.Config
+
+	for name, tc := range map[string]struct {
+		input         *config.Config
+		ok            bool
+		expected      *config.Config
+		prepareMockPP func(m *mocks.MockPP)
+	}{
+		"nil": {
+			input:    &empty,
+			ok:       false,
+			expected: &empty,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS"),
+				)
+			},
+		},
+		"empty": {
+			input: &config.Config{ //nolint:exhaustruct
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP4: {},
+					ipnet.IP6: {},
+				},
+			},
+			ok:       false,
+			expected: nil,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS"),
+				)
+			},
+		},
+		"empty-ip6": {
+			input: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP4: provider.NewCloudflareTrace(true),
+					ipnet.IP6: provider.NewCloudflareTrace(false),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP4: {domain.FQDN("a.b.c")},
+					ipnet.IP6: {},
+				},
+				ProxiedTemplate: "false",
+			},
+			ok: true,
+			expected: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP4: provider.NewCloudflareTrace(true),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP4: {domain.FQDN("a.b.c")},
+					ipnet.IP6: {},
+				},
+				ProxiedTemplate: "false",
+				Proxied: map[domain.Domain]bool{
+					domain.FQDN("a.b.c"): false,
+				},
+			},
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Warningf(pp.EmojiUserWarning,
+						"IP%d_PROVIDER was changed to %q because no domains were set for %s",
+						6, "none", "IPv6"),
+				)
+			},
+		},
+		"empty-ip6-none-ip4": {
+			input: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(true),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP4: {domain.FQDN("a.b.c")},
+					ipnet.IP6: {},
+				},
+			},
+			ok:       false,
+			expected: nil,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Warningf(pp.EmojiUserWarning,
+						"IP%d_PROVIDER was changed to %q because no domains were set for %s",
+						6, "none", "IPv6"),
+					m.EXPECT().Errorf(pp.EmojiUserError,
+						"Nothing to update because both IP4_PROVIDER and IP6_PROVIDER are %q",
+						"none"),
+				)
+			},
+		},
+		"ignored-ip4-domains": {
+			input: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(true),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP4: {domain.FQDN("a.b.c"), domain.FQDN("d.e.f")},
+					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("g.h.i")},
+				},
+				ProxiedTemplate: "false",
+			},
+			ok: true,
+			expected: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(true),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP4: {domain.FQDN("a.b.c"), domain.FQDN("d.e.f")},
+					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("g.h.i")},
+				},
+				ProxiedTemplate: "false",
+				Proxied: map[domain.Domain]bool{
+					domain.FQDN("a.b.c"): false,
+					domain.FQDN("g.h.i"): false,
+				},
+			},
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Warningf(pp.EmojiUserWarning,
+						"Domain %q is ignored because it is only for %s but %s is disabled",
+						"d.e.f", "IPv4", "IPv4"),
+				)
+			},
+		},
+		"template": {
+			input: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(false),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")},
+				},
+				ProxiedTemplate: ` true && !is(a.bb.c) `,
+			},
+			ok: true,
+			expected: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(false),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")},
+				},
+				ProxiedTemplate: ` true && !is(a.bb.c) `,
+				Proxied: map[domain.Domain]bool{
+					domain.FQDN("a.b.c"):   true,
+					domain.FQDN("a.bb.c"):  false,
+					domain.FQDN("a.d.e.f"): true,
+				},
+			},
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+				)
+			},
+		},
+		"template/invalid/proxied": {
+			input: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(true),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")},
+				},
+				ProxiedTemplate: `range`,
+			},
+			ok:       false,
+			expected: nil,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a boolean expression: got unexpected token %q", keyProxied, `range`, `range`), //nolint:lll
+				)
+			},
+		},
+		"template/error/proxied": {
+			input: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(false),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP6: {domain.FQDN("a.b.c")},
+				},
+				ProxiedTemplate: `999`,
+			},
+			ok:       false,
+			expected: nil,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a boolean expression: got unexpected token %q", keyProxied, `999`, `999`), //nolint:lll
+				)
+			},
+		},
+		"template/error/proxied/ill-formed": {
+			input: &config.Config{ //nolint:exhaustruct
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(true),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP6: {domain.FQDN("a.b.c")},
+				},
+				ProxiedTemplate: `is(12345`,
+			},
+			ok:       false,
+			expected: nil,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Errorf(pp.EmojiUserError, `%s (%q) is missing %q at the end`, keyProxied, `is(12345`, ")"),
+				)
+			},
+		},
+		"delete-on-stop/without-cron": {
+			input: &config.Config{ //nolint:exhaustruct
+				DeleteOnStop: true,
+			},
+			ok:       false,
+			expected: nil,
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+					m.EXPECT().Errorf(pp.EmojiUserError, "DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled"), //nolint:lll
+				)
+			},
+		},
+		"delete-on-stop/with-cron": {
+			input: &config.Config{ //nolint:exhaustruct
+				DeleteOnStop: true,
+				UpdateCron:   cron.MustNew("@every 5m"),
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(false),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP6: {domain.FQDN("a.b.c")},
+				},
+				ProxiedTemplate: "false",
+			},
+			ok: true,
+			expected: &config.Config{ //nolint:exhaustruct
+				DeleteOnStop: true,
+				UpdateCron:   cron.MustNew("@every 5m"),
+				Provider: map[ipnet.Type]provider.Provider{
+					ipnet.IP6: provider.NewCloudflareTrace(false),
+				},
+				Domains: map[ipnet.Type][]domain.Domain{
+					ipnet.IP6: {domain.FQDN("a.b.c")},
+				},
+				ProxiedTemplate: "false",
+				Proxied: map[domain.Domain]bool{
+					domain.FQDN("a.b.c"): false,
+				},
+			},
+			prepareMockPP: func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
+					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
+					m.EXPECT().IncIndent().Return(m),
+				)
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			mockCtrl := gomock.NewController(t)
+
+			cfg := tc.input
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := cfg.NormalizeConfig(mockPP)
+			require.Equal(t, tc.ok, ok)
+			if tc.ok {
+				require.Equal(t, tc.expected, cfg)
+			} else {
+				require.Equal(t, tc.input, cfg)
+			}
+		})
+	}
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5342313..d1379cd 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,847 +1,16 @@
 package config_test
 
 import (
-	"strings"
 	"testing"
-	"testing/fstest"
-	"time"
 
-	"github.com/golang/mock/gomock"
 	"github.com/stretchr/testify/require"
 
-	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/config"
-	"github.com/favonia/cloudflare-ddns/internal/cron"
-	"github.com/favonia/cloudflare-ddns/internal/domain"
-	"github.com/favonia/cloudflare-ddns/internal/file"
-	"github.com/favonia/cloudflare-ddns/internal/ipnet"
-	"github.com/favonia/cloudflare-ddns/internal/mocks"
-	"github.com/favonia/cloudflare-ddns/internal/monitor"
-	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/provider"
 )
 
 func TestDefaultConfigNotNil(t *testing.T) {
 	t.Parallel()
 
-	require.NotNil(t, config.Default())
-}
-
-//nolint:paralleltest // environment vars are global
-func TestReadAuth(t *testing.T) {
-	unset(t, "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID")
-
-	for name, tc := range map[string]struct {
-		token         string
-		account       string
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"full":      {"123456789", "secret account", true, nil},
-		"noaccount": {"123456789", "", true, nil},
-		"notoken": {
-			"", "account", false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
-			},
-		},
-		"copycat": {
-			"YOUR-CLOUDFLARE-API-TOKEN", "", false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			mockCtrl := gomock.NewController(t)
-
-			store(t, "CF_API_TOKEN", tc.token)
-			store(t, "CF_ACCOUNT_ID", tc.account)
-
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-
-			var field api.Auth
-			ok := config.ReadAuth(mockPP, &field)
-			require.Equal(t, tc.ok, ok)
-			if tc.ok {
-				require.Equal(t, &api.CloudflareAuth{Token: tc.token, AccountID: tc.account, BaseURL: ""}, field)
-			} else {
-				require.Nil(t, field)
-			}
-		})
-	}
-}
-
-func useMemFS(memfs fstest.MapFS) {
-	file.FS = memfs
-}
-
-//nolint:funlen,paralleltest // environment vars and file system are global
-func TestReadAuthWithFile(t *testing.T) {
-	unset(t, "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID")
-
-	for name, tc := range map[string]struct {
-		token         string
-		tokenFile     string
-		account       string
-		actualPath    string
-		actualContent string
-		expected      string
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"ok": {"", "test.txt", "secret account", "test.txt", "hello", "hello", true, nil},
-		"both": {
-			"123456789", "test.txt", "secret account", "test.txt", "hello", "", false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
-			},
-		},
-		"wrong.path": {
-			"", "wrong.txt", "secret account", "actual.txt", "hello", "", false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", "wrong.txt", gomock.Any())
-			},
-		},
-		"empty": {
-			"", "test.txt", "secret account", "test.txt", "", "", false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
-			},
-		},
-		"invalid path": {
-			"", "dir", "secret account", "dir/test.txt", "hello", "", false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", "dir", gomock.Any())
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			mockCtrl := gomock.NewController(t)
-
-			store(t, "CF_API_TOKEN", tc.token)
-			store(t, "CF_API_TOKEN_FILE", tc.tokenFile)
-			store(t, "CF_ACCOUNT_ID", tc.account)
-
-			useMemFS(fstest.MapFS{
-				tc.actualPath: &fstest.MapFile{
-					Data:    []byte(tc.actualContent),
-					Mode:    0o644,
-					ModTime: time.Unix(1234, 5678),
-					Sys:     nil,
-				},
-			})
-
-			var field api.Auth
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-			ok := config.ReadAuth(mockPP, &field)
-			require.Equal(t, tc.ok, ok)
-			if tc.expected != "" {
-				require.Equal(t, &api.CloudflareAuth{Token: tc.expected, AccountID: tc.account, BaseURL: ""}, field)
-			} else {
-				require.Nil(t, field)
-			}
-		})
-	}
-}
-
-//nolint:funlen,paralleltest // environment vars are global
-func TestReadProviderMap(t *testing.T) {
-	var (
-		none            provider.Provider
-		cloudflareTrace = provider.NewCloudflareTrace()
-		cloudflareDOH   = provider.NewCloudflareDOH()
-		local           = provider.NewLocal()
-	)
-
-	for name, tc := range map[string]struct {
-		ip4Provider   string
-		ip6Provider   string
-		expected      map[ipnet.Type]provider.Provider
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"full": {
-			"cloudflare.trace", "local",
-			map[ipnet.Type]provider.Provider{
-				ipnet.IP4: cloudflareTrace,
-				ipnet.IP6: local,
-			},
-			true,
-			nil,
-		},
-		"4": {
-			"local", "  ",
-			map[ipnet.Type]provider.Provider{
-				ipnet.IP4: local,
-				ipnet.IP6: local,
-			},
-			true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "local")
-			},
-		},
-		"6": {
-			"    ", "cloudflare.doh",
-			map[ipnet.Type]provider.Provider{
-				ipnet.IP4: none,
-				ipnet.IP6: cloudflareDOH,
-			},
-			true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none")
-			},
-		},
-		"empty": {
-			" ", "   ",
-			map[ipnet.Type]provider.Provider{
-				ipnet.IP4: none,
-				ipnet.IP6: local,
-			},
-			true,
-			func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none"),
-					m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "local"),
-				)
-			},
-		},
-		"illformed": {
-			" flare", "   ",
-			map[ipnet.Type]provider.Provider{
-				ipnet.IP4: none,
-				ipnet.IP6: local,
-			},
-			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", "IP4_PROVIDER", "flare")
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			mockCtrl := gomock.NewController(t)
-
-			store(t, "IP4_PROVIDER", tc.ip4Provider)
-			store(t, "IP6_PROVIDER", tc.ip6Provider)
-
-			field := map[ipnet.Type]provider.Provider{ipnet.IP4: none, ipnet.IP6: local}
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-			ok := config.ReadProviderMap(mockPP, &field)
-			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.expected, field)
-		})
-	}
-}
-
-//nolint:paralleltest,funlen // environment vars are global
-func TestReadDomainMap(t *testing.T) {
-	for name, tc := range map[string]struct {
-		domains       string
-		ip4Domains    string
-		ip6Domains    string
-		expected      map[ipnet.Type][]domain.Domain
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"full": {
-			"  a1, a2", "b1,  b2,b2", "c1,c2",
-			map[ipnet.Type][]domain.Domain{
-				ipnet.IP4: {domain.FQDN("a1"), domain.FQDN("a2"), domain.FQDN("b1"), domain.FQDN("b2")},
-				ipnet.IP6: {domain.FQDN("a1"), domain.FQDN("a2"), domain.FQDN("c1"), domain.FQDN("c2")},
-			},
-			true,
-			nil,
-		},
-		"duplicate": {
-			"  a1, a1", "a1,  a1,a1", "*.a1,a1,*.a1,*.a1",
-			map[ipnet.Type][]domain.Domain{
-				ipnet.IP4: {domain.FQDN("a1")},
-				ipnet.IP6: {domain.FQDN("a1"), domain.Wildcard("a1")},
-			},
-			true,
-			nil,
-		},
-		"empty": {
-			" ", "   ", "",
-			map[ipnet.Type][]domain.Domain{
-				ipnet.IP4: {},
-				ipnet.IP6: {},
-			},
-			true,
-			nil,
-		},
-		"ill-formed": {
-			" ", "   ", "*.*", nil, false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError,
-					"%s (%q) contains an ill-formed domain %q: %v",
-					"IP6_DOMAINS", "*.*", "*.*", gomock.Any())
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			mockCtrl := gomock.NewController(t)
-
-			store(t, "DOMAINS", tc.domains)
-			store(t, "IP4_DOMAINS", tc.ip4Domains)
-			store(t, "IP6_DOMAINS", tc.ip6Domains)
-
-			var field map[ipnet.Type][]domain.Domain
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-			ok := config.ReadDomainMap(mockPP, &field)
-			require.Equal(t, tc.ok, ok)
-			require.ElementsMatch(t, tc.expected[ipnet.IP4], field[ipnet.IP4])
-			require.ElementsMatch(t, tc.expected[ipnet.IP6], field[ipnet.IP6])
-		})
-	}
-}
-
-type someMatcher struct {
-	matchers []gomock.Matcher
-}
-
-func (sm someMatcher) Matches(x any) bool {
-	for _, m := range sm.matchers {
-		if m.Matches(x) {
-			return true
-		}
-	}
-	return false
-}
-
-func (sm someMatcher) String() string {
-	ss := make([]string, 0, len(sm.matchers))
-	for _, matcher := range sm.matchers {
-		ss = append(ss, matcher.String())
-	}
-	return strings.Join(ss, " | ")
-}
-
-func Some(xs ...any) gomock.Matcher {
-	ms := make([]gomock.Matcher, 0, len(xs))
-	for _, x := range xs {
-		if m, ok := x.(gomock.Matcher); ok {
-			ms = append(ms, m)
-		} else {
-			ms = append(ms, gomock.Eq(x))
-		}
-	}
-	return someMatcher{ms}
-}
-
-//nolint:paralleltest // changing the environment variable TZ
-func TestPrintDefault(t *testing.T) {
-	mockCtrl := gomock.NewController(t)
-
-	store(t, "TZ", "UTC")
-
-	mockPP := mocks.NewMockPP(mockCtrl)
-	innerMockPP := mocks.NewMockPP(mockCtrl)
-	gomock.InOrder(
-		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
-		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Current settings:"),
-		mockPP.EXPECT().IncIndent().Return(mockPP),
-		mockPP.EXPECT().IncIndent().Return(innerMockPP),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 domains:", "(none)"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 provider:", "cloudflare.trace"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 domains:", "(none)"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 provider:", "cloudflare.trace"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@every 5m"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "true"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "6h0m0s"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "New DNS records:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL:", "1 (auto)"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Timeouts:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IP detection:", "5s"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Record updating:", "30s"),
-	)
-	config.Default().Print(mockPP)
-}
-
-//nolint:paralleltest // changing the environment variable TZ
-func TestPrintMaps(t *testing.T) {
-	mockCtrl := gomock.NewController(t)
-
-	store(t, "TZ", "UTC")
-
-	mockPP := mocks.NewMockPP(mockCtrl)
-	innerMockPP := mocks.NewMockPP(mockCtrl)
-	gomock.InOrder(
-		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
-		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Current settings:"),
-		mockPP.EXPECT().IncIndent().Return(mockPP),
-		mockPP.EXPECT().IncIndent().Return(innerMockPP),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 domains:", "test4.org, *.test4.org"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv4 provider:", "cloudflare.trace"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 domains:", "test6.org, *.test6.org"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IPv6 provider:", "cloudflare.trace"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@every 5m"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "true"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "6h0m0s"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "New DNS records:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL:", "30000"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Proxied domains:", "a, b"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Unproxied domains:", "c, d"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Timeouts:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IP detection:", "5s"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Record updating:", "30s"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Monitors:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Healthchecks:", "(URL redacted)"),
-	)
-
-	c := config.Default()
-
-	c.Domains[ipnet.IP4] = []domain.Domain{domain.FQDN("test4.org"), domain.Wildcard("test4.org")}
-	c.Domains[ipnet.IP6] = []domain.Domain{domain.FQDN("test6.org"), domain.Wildcard("test6.org")}
-
-	c.TTL = 30000
-
-	c.Proxied = map[domain.Domain]bool{}
-	c.Proxied[domain.FQDN("a")] = true
-	c.Proxied[domain.FQDN("b")] = true
-	c.Proxied[domain.FQDN("c")] = false
-	c.Proxied[domain.FQDN("d")] = false
-
-	m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path")
-	require.True(t, ok)
-	c.Monitor = m
-
-	c.Print(mockPP)
-}
-
-//nolint:paralleltest // changing the environment variable TZ
-func TestPrintEmpty(t *testing.T) {
-	mockCtrl := gomock.NewController(t)
-
-	store(t, "TZ", "UTC")
-
-	mockPP := mocks.NewMockPP(mockCtrl)
-	innerMockPP := mocks.NewMockPP(mockCtrl)
-	gomock.InOrder(
-		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
-		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Current settings:"),
-		mockPP.EXPECT().IncIndent().Return(mockPP),
-		mockPP.EXPECT().IncIndent().Return(innerMockPP),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Domains and IP providers:"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Timezone:", Some("UTC (UTC+00 now)", "Local (UTC+00 now)")), //nolint:lll
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update frequency:", "@disabled"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Update on start?", "false"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Delete on stop?", "false"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Cache expiration:", "0s"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "New DNS records:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL:", "0"),
-		mockPP.EXPECT().Infof(pp.EmojiConfig, "Timeouts:"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IP detection:", "0s"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Record updating:", "0s"),
-	)
-	var cfg config.Config
-	cfg.Print(mockPP)
-}
-
-//nolint:paralleltest // environment vars are global
-func TestPrintHidden(t *testing.T) {
-	mockCtrl := gomock.NewController(t)
-
-	store(t, "TZ", "UTC")
-
-	mockPP := mocks.NewMockPP(mockCtrl)
-	mockPP.EXPECT().IsEnabledFor(pp.Info).Return(false)
-
-	var cfg config.Config
-	cfg.Print(mockPP)
-}
-
-//nolint:paralleltest // environment variables are global
-func TestReadEnvWithOnlyToken(t *testing.T) {
-	mockCtrl := gomock.NewController(t)
-
-	unset(t,
-		"CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID",
-		"IP4_PROVIDER", "IP6_PROVIDER",
-		"DOMAINS", "IP4_DOMAINS", "IP6_DOMAINS",
-		"UPDATE_CRON", "UPDATE_ON_START", "DELETE_ON_STOP", "CACHE_EXPIRATION", "TTL", "PROXIED", "DETECTION_TIMEOUT")
-
-	store(t, "CF_API_TOKEN", "deadbeaf")
-
-	var cfg config.Config
-	mockPP := mocks.NewMockPP(mockCtrl)
-	innerMockPP := mocks.NewMockPP(mockCtrl)
-	gomock.InOrder(
-		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
-		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Reading settings . . ."),
-		mockPP.EXPECT().IncIndent().Return(innerMockPP),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "none"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "UPDATE_CRON", "@disabled"),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "UPDATE_ON_START", false),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%t", "DELETE_ON_STOP", false),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "CACHE_EXPIRATION", time.Duration(0)),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%d", "TTL", api.TTL(0)),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "PROXIED", ""),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "DETECTION_TIMEOUT", time.Duration(0)),
-		innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%v", "UPDATE_TIMEOUT", time.Duration(0)),
-	)
-	ok := cfg.ReadEnv(mockPP)
-	require.True(t, ok)
-}
-
-//nolint:paralleltest // environment variables are global
-func TestReadEnvEmpty(t *testing.T) {
-	mockCtrl := gomock.NewController(t)
-
-	unset(t,
-		"CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID",
-		"IP4_PROVIDER", "IP6_PROVIDER",
-		"IP4_POLICY", "IP6_POLICY",
-		"DOMAINS", "IP4_DOMAINS", "IP6_DOMAINS",
-		"UPDATE_CRON", "UPDATE_ON_START", "DELETE_ON_STOP", "CACHE_EXPIRATION", "TTL", "PROXIED", "DETECTION_TIMEOUT")
-
-	var cfg config.Config
-	mockPP := mocks.NewMockPP(mockCtrl)
-	innerMockPP := mocks.NewMockPP(mockCtrl)
-	gomock.InOrder(
-		mockPP.EXPECT().IsEnabledFor(pp.Info).Return(true),
-		mockPP.EXPECT().Infof(pp.EmojiEnvVars, "Reading settings . . ."),
-		mockPP.EXPECT().IncIndent().Return(innerMockPP),
-		innerMockPP.EXPECT().Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE"),
-	)
-	ok := cfg.ReadEnv(mockPP)
-	require.False(t, ok)
-}
-
-//nolint:funlen
-func TestNormalizeConfig(t *testing.T) {
-	t.Parallel()
-
-	keyProxied := "PROXIED"
-	var empty config.Config
-
-	for name, tc := range map[string]struct {
-		input         *config.Config
-		ok            bool
-		expected      *config.Config
-		prepareMockPP func(m *mocks.MockPP)
-	}{
-		"nil": {
-			input:    &empty,
-			ok:       false,
-			expected: &empty,
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS"),
-				)
-			},
-		},
-		"empty": {
-			input: &config.Config{ //nolint:exhaustruct
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP4: {},
-					ipnet.IP6: {},
-				},
-			},
-			ok:       false,
-			expected: nil,
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, "No domains were specified in DOMAINS, IP4_DOMAINS, or IP6_DOMAINS"),
-				)
-			},
-		},
-		"empty-ip6": {
-			input: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP4: provider.NewCloudflareTrace(),
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP4: {domain.FQDN("a.b.c")},
-					ipnet.IP6: {},
-				},
-				ProxiedTemplate: "false",
-			},
-			ok: true,
-			expected: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP4: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP4: {domain.FQDN("a.b.c")},
-					ipnet.IP6: {},
-				},
-				ProxiedTemplate: "false",
-				Proxied: map[domain.Domain]bool{
-					domain.FQDN("a.b.c"): false,
-				},
-			},
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Warningf(pp.EmojiUserWarning,
-						"IP%d_PROVIDER was changed to %q because no domains were set for %s",
-						6, "none", "IPv6"),
-				)
-			},
-		},
-		"empty-ip6-none-ip4": {
-			input: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP4: {domain.FQDN("a.b.c")},
-					ipnet.IP6: {},
-				},
-			},
-			ok:       false,
-			expected: nil,
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Warningf(pp.EmojiUserWarning,
-						"IP%d_PROVIDER was changed to %q because no domains were set for %s",
-						6, "none", "IPv6"),
-					m.EXPECT().Errorf(pp.EmojiUserError,
-						"Nothing to update because both IP4_PROVIDER and IP6_PROVIDER are %q",
-						"none"),
-				)
-			},
-		},
-		"ignored-ip4-domains": {
-			input: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP4: {domain.FQDN("a.b.c"), domain.FQDN("d.e.f")},
-					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("g.h.i")},
-				},
-				ProxiedTemplate: "false",
-			},
-			ok: true,
-			expected: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP4: {domain.FQDN("a.b.c"), domain.FQDN("d.e.f")},
-					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("g.h.i")},
-				},
-				ProxiedTemplate: "false",
-				Proxied: map[domain.Domain]bool{
-					domain.FQDN("a.b.c"): false,
-					domain.FQDN("g.h.i"): false,
-				},
-			},
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Warningf(pp.EmojiUserWarning,
-						"Domain %q is ignored because it is only for %s but %s is disabled",
-						"d.e.f", "IPv4", "IPv4"),
-				)
-			},
-		},
-		"template": {
-			input: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")},
-				},
-				ProxiedTemplate: ` true && !is(a.bb.c) `,
-			},
-			ok: true,
-			expected: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")},
-				},
-				ProxiedTemplate: ` true && !is(a.bb.c) `,
-				Proxied: map[domain.Domain]bool{
-					domain.FQDN("a.b.c"):   true,
-					domain.FQDN("a.bb.c"):  false,
-					domain.FQDN("a.d.e.f"): true,
-				},
-			},
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-				)
-			},
-		},
-		"template/invalid/proxied": {
-			input: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")},
-				},
-				ProxiedTemplate: `range`,
-			},
-			ok:       false,
-			expected: nil,
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a boolean expression: got unexpected token %q", keyProxied, `range`, `range`), //nolint:lll
-				)
-			},
-		},
-		"template/error/proxied": {
-			input: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP6: {domain.FQDN("a.b.c")},
-				},
-				ProxiedTemplate: `999`,
-			},
-			ok:       false,
-			expected: nil,
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a boolean expression: got unexpected token %q", keyProxied, `999`, `999`), //nolint:lll
-				)
-			},
-		},
-		"template/error/proxied/ill-formed": {
-			input: &config.Config{ //nolint:exhaustruct
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP6: {domain.FQDN("a.b.c")},
-				},
-				ProxiedTemplate: `is(12345`,
-			},
-			ok:       false,
-			expected: nil,
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, `%s (%q) is missing %q at the end`, keyProxied, `is(12345`, ")"),
-				)
-			},
-		},
-		"delete-on-stop/without-cron": {
-			input: &config.Config{ //nolint:exhaustruct
-				DeleteOnStop: true,
-			},
-			ok:       false,
-			expected: nil,
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-					m.EXPECT().Errorf(pp.EmojiUserError, "DELETE_ON_STOP=true will immediately delete all DNS records when UPDATE_CRON=@disabled"), //nolint:lll
-				)
-			},
-		},
-		"delete-on-stop/with-cron": {
-			input: &config.Config{ //nolint:exhaustruct
-				DeleteOnStop: true,
-				UpdateCron:   cron.MustNew("@every 5m"),
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP6: {domain.FQDN("a.b.c")},
-				},
-				ProxiedTemplate: "false",
-			},
-			ok: true,
-			expected: &config.Config{ //nolint:exhaustruct
-				DeleteOnStop: true,
-				UpdateCron:   cron.MustNew("@every 5m"),
-				Provider: map[ipnet.Type]provider.Provider{
-					ipnet.IP6: provider.NewCloudflareTrace(),
-				},
-				Domains: map[ipnet.Type][]domain.Domain{
-					ipnet.IP6: {domain.FQDN("a.b.c")},
-				},
-				ProxiedTemplate: "false",
-				Proxied: map[domain.Domain]bool{
-					domain.FQDN("a.b.c"): false,
-				},
-			},
-			prepareMockPP: func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().IsEnabledFor(pp.Info).Return(true),
-					m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."),
-					m.EXPECT().IncIndent().Return(m),
-				)
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			t.Parallel()
-			mockCtrl := gomock.NewController(t)
-
-			cfg := tc.input
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-			ok := cfg.NormalizeConfig(mockPP)
-			require.Equal(t, tc.ok, ok)
-			if tc.ok {
-				require.Equal(t, tc.expected, cfg)
-			} else {
-				require.Equal(t, tc.input, cfg)
-			}
-		})
-	}
+	require.NotNil(t, config.Default(true))
+	require.NotNil(t, config.Default(false))
 }
diff --git a/internal/config/env_auth_test.go b/internal/config/env_auth_test.go
new file mode 100644
index 0000000..db5f218
--- /dev/null
+++ b/internal/config/env_auth_test.go
@@ -0,0 +1,142 @@
+package config_test
+
+import (
+	"testing"
+	"testing/fstest"
+	"time"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/api"
+	"github.com/favonia/cloudflare-ddns/internal/config"
+	"github.com/favonia/cloudflare-ddns/internal/file"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+//nolint:paralleltest // environment vars are global
+func TestReadAuth(t *testing.T) {
+	unset(t, "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID")
+
+	for name, tc := range map[string]struct {
+		token         string
+		account       string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"full":      {"123456789", "secret account", true, nil},
+		"noaccount": {"123456789", "", true, nil},
+		"notoken": {
+			"", "account", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Needs either CF_API_TOKEN or CF_API_TOKEN_FILE")
+			},
+		},
+		"copycat": {
+			"YOUR-CLOUDFLARE-API-TOKEN", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "You need to provide a real API token as CF_API_TOKEN")
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
+			store(t, "CF_API_TOKEN", tc.token)
+			store(t, "CF_ACCOUNT_ID", tc.account)
+
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
+			var field api.Auth
+			ok := config.ReadAuth(mockPP, &field)
+			require.Equal(t, tc.ok, ok)
+			if tc.ok {
+				require.Equal(t, &api.CloudflareAuth{Token: tc.token, AccountID: tc.account, BaseURL: ""}, field)
+			} else {
+				require.Nil(t, field)
+			}
+		})
+	}
+}
+
+func useMemFS(memfs fstest.MapFS) {
+	file.FS = memfs
+}
+
+//nolint:funlen,paralleltest // environment vars and file system are global
+func TestReadAuthWithFile(t *testing.T) {
+	unset(t, "CF_API_TOKEN", "CF_API_TOKEN_FILE", "CF_ACCOUNT_ID")
+
+	for name, tc := range map[string]struct {
+		token         string
+		tokenFile     string
+		account       string
+		actualPath    string
+		actualContent string
+		expected      string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"ok": {"", "test.txt", "secret account", "test.txt", "hello", "hello", true, nil},
+		"both": {
+			"123456789", "test.txt", "secret account", "test.txt", "hello", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Cannot have both CF_API_TOKEN and CF_API_TOKEN_FILE set")
+			},
+		},
+		"wrong.path": {
+			"", "wrong.txt", "secret account", "actual.txt", "hello", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", "wrong.txt", gomock.Any())
+			},
+		},
+		"empty": {
+			"", "test.txt", "secret account", "test.txt", "", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "The token in the file specified by CF_API_TOKEN_FILE is empty")
+			},
+		},
+		"invalid path": {
+			"", "dir", "secret account", "dir/test.txt", "hello", "", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "Failed to read %q: %v", "dir", gomock.Any())
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
+			store(t, "CF_API_TOKEN", tc.token)
+			store(t, "CF_API_TOKEN_FILE", tc.tokenFile)
+			store(t, "CF_ACCOUNT_ID", tc.account)
+
+			useMemFS(fstest.MapFS{
+				tc.actualPath: &fstest.MapFile{
+					Data:    []byte(tc.actualContent),
+					Mode:    0o644,
+					ModTime: time.Unix(1234, 5678),
+					Sys:     nil,
+				},
+			})
+
+			var field api.Auth
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadAuth(mockPP, &field)
+			require.Equal(t, tc.ok, ok)
+			if tc.expected != "" {
+				require.Equal(t, &api.CloudflareAuth{Token: tc.expected, AccountID: tc.account, BaseURL: ""}, field)
+			} else {
+				require.Nil(t, field)
+			}
+		})
+	}
+}
diff --git a/internal/config/env_test.go b/internal/config/env_base_test.go
similarity index 70%
rename from internal/config/env_test.go
rename to internal/config/env_base_test.go
index da1f015..0426e81 100644
--- a/internal/config/env_test.go
+++ b/internal/config/env_base_test.go
@@ -12,11 +12,9 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/cron"
-	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
-	"github.com/favonia/cloudflare-ddns/internal/provider"
 )
 
 const keyPrefix = "TEST-11D39F6A9A97AFAFD87CCEB-"
@@ -461,257 +459,6 @@ func TestReadTTL(t *testing.T) {
 	}
 }
 
-//nolint:paralleltest,funlen // environment vars are global
-func TestReadDomains(t *testing.T) {
-	key := keyPrefix + "DOMAINS"
-	type ds = []domain.Domain
-	type f = domain.FQDN
-	type w = domain.Wildcard
-	for name, tc := range map[string]struct {
-		set           bool
-		val           string
-		oldField      ds
-		newField      ds
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"nil":       {false, "", ds{f("test.org")}, ds{}, true, nil},
-		"empty":     {true, "", ds{f("test.org")}, ds{}, true, nil},
-		"star":      {true, "*", ds{}, ds{w("")}, true, nil},
-		"wildcard1": {true, "*.a", ds{}, ds{w("a")}, true, nil},
-		"wildcard2": {true, "*.a.b", ds{}, ds{w("a.b")}, true, nil},
-		"test1":     {true, "書.org ,  Bücher.org  ", ds{f("random.org")}, ds{f("xn--rov.org"), f("xn--bcher-kva.org")}, true, nil},                      //nolint:lll
-		"test2":     {true, "  \txn--rov.org    ,   xn--Bcher-kva.org  ", ds{f("random.org")}, ds{f("xn--rov.org"), f("xn--bcher-kva.org")}, true, nil}, //nolint:lll
-		"illformed1": {
-			true, "xn--:D.org,a.org",
-			ds{f("random.org")},
-			ds{f("random.org")},
-			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) contains an ill-formed domain %q: %v", key, "xn--:D.org,a.org", "xn--:d.org", gomock.Any()) //nolint:lll
-			},
-		},
-		"illformed2": {
-			true, "*.xn--:D.org,a.org",
-			ds{f("random.org")},
-			ds{f("random.org")},
-			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) contains an ill-formed domain %q: %v", key, "*.xn--:D.org,a.org", "*.xn--:d.org", gomock.Any()) //nolint:lll
-			},
-		},
-		"illformed3": {
-			true, "hi.org,(",
-			ds{},
-			ds{},
-			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) has unexpected token %q", key, "hi.org,(", "(")
-			},
-		},
-		"illformed4": {
-			true, ")",
-			ds{},
-			ds{},
-			false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) has unexpected token %q", key, ")", ")")
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			set(t, key, tc.set, tc.val)
-			field := tc.oldField
-			mockCtrl := gomock.NewController(t)
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-
-			ok := config.ReadDomains(mockPP, key, &field)
-			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.newField, field)
-		})
-	}
-}
-
-//nolint:paralleltest,funlen // paralleltest should not be used because environment vars are global
-func TestReadProvider(t *testing.T) {
-	key := keyPrefix + "PROVIDER"
-	keyDeprecated := keyPrefix + "DEPRECATED"
-
-	var (
-		none            provider.Provider
-		cloudflareDOH   = provider.NewCloudflareDOH()
-		cloudflareTrace = provider.NewCloudflareTrace()
-		local           = provider.NewLocal()
-		ipify           = provider.NewIpify()
-	)
-
-	for name, tc := range map[string]struct {
-		set           bool
-		val           string
-		setDeprecated bool
-		valDeprecated string
-		oldField      provider.Provider
-		newField      provider.Provider
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"nil": {
-			false, "", false, "", none, none, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", key, "none")
-			},
-		},
-		"deprecated/empty": {
-			false, "", true, "", local, local, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", key, "local")
-			},
-		},
-		"deprecated/cloudflare": {
-			false, "", true, "    cloudflare\t   ", none, cloudflareTrace, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(
-					pp.EmojiUserWarning,
-					`%s=cloudflare is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
-					keyDeprecated, key, key,
-				)
-			},
-		},
-		"deprecated/cloudflare.trace": {
-			false, "", true, " cloudflare.trace", none, cloudflareTrace, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(
-					pp.EmojiUserWarning,
-					`%s is deprecated; use %s=%s`,
-					keyDeprecated,
-					key,
-					"cloudflare.trace",
-				)
-			},
-		},
-		"deprecated/cloudflare.doh": {
-			false, "", true, "    \tcloudflare.doh   ", none, cloudflareDOH, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(
-					pp.EmojiUserWarning,
-					`%s is deprecated; use %s=%s`,
-					keyDeprecated,
-					key,
-					"cloudflare.doh",
-				)
-			},
-		},
-		"deprecated/unmanaged": {
-			false, "", true, "   unmanaged   ", cloudflareTrace, none, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(
-					pp.EmojiUserWarning,
-					`%s is deprecated; use %s=none`,
-					keyDeprecated,
-					key,
-				)
-			},
-		},
-		"deprecated/local": {
-			false, "", true, "   local   ", cloudflareTrace, local, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(
-					pp.EmojiUserWarning,
-					`%s is deprecated; use %s=%s`,
-					keyDeprecated,
-					key,
-					"local",
-				)
-			},
-		},
-		"deprecated/ipify": {
-			false, "", true, "     ipify  ", cloudflareTrace, ipify, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(
-					pp.EmojiUserWarning,
-					`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
-					keyDeprecated,
-					key,
-					key,
-				)
-			},
-		},
-		"deprecated/others": {
-			false, "", true, "   something-else ", ipify, ipify, false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", keyDeprecated, "something-else")
-			},
-		},
-		"conflicts": {
-			true, "cloudflare.doh", true, "cloudflare.doh", ipify, ipify, false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(
-					pp.EmojiUserError,
-					`Cannot have both %s and %s set`,
-					key, keyDeprecated,
-				)
-			},
-		},
-		"empty": {
-			false, "", false, "", local, local, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", key, "local")
-			},
-		},
-		"cloudflare": {
-			true, "    cloudflare\t   ", false, "", none, none, false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(
-					pp.EmojiUserError,
-					`%s=cloudflare is invalid; use %s=cloudflare.trace or %s=cloudflare.doh`,
-					key, key, key,
-				)
-			},
-		},
-		"cloudflare.trace": {true, " cloudflare.trace", false, "", none, cloudflareTrace, true, nil},
-		"cloudflare.doh":   {true, "    \tcloudflare.doh   ", false, "", none, cloudflareDOH, true, nil},
-		"none":             {true, "   none   ", false, "", cloudflareTrace, none, true, nil},
-		"local":            {true, "   local   ", false, "", cloudflareTrace, local, true, nil},
-		"ipify": {
-			true, "     ipify  ", false, "", cloudflareTrace, ipify, true,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Warningf(
-					pp.EmojiUserWarning,
-					`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
-					key,
-					key,
-					key,
-				)
-			},
-		},
-		"others": {
-			true, "   something-else ", false, "", ipify, ipify, false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", key, "something-else")
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			set(t, key, tc.set, tc.val)
-			set(t, keyDeprecated, tc.setDeprecated, tc.valDeprecated)
-			field := tc.oldField
-			mockCtrl := gomock.NewController(t)
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-			ok := config.ReadProvider(mockPP, key, keyDeprecated, &field)
-			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.newField, field)
-		})
-	}
-}
-
 //nolint:paralleltest // environment vars are global
 func TestReadNonnegDuration(t *testing.T) {
 	key := keyPrefix + "DURATION"
diff --git a/internal/config/env_domain_test.go b/internal/config/env_domain_test.go
new file mode 100644
index 0000000..caf730a
--- /dev/null
+++ b/internal/config/env_domain_test.go
@@ -0,0 +1,156 @@
+package config_test
+
+import (
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/config"
+	"github.com/favonia/cloudflare-ddns/internal/domain"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+//nolint:paralleltest,funlen // environment vars are global
+func TestReadDomains(t *testing.T) {
+	key := keyPrefix + "DOMAINS"
+	type ds = []domain.Domain
+	type f = domain.FQDN
+	type w = domain.Wildcard
+	for name, tc := range map[string]struct {
+		set           bool
+		val           string
+		oldField      ds
+		newField      ds
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"nil":       {false, "", ds{f("test.org")}, ds{}, true, nil},
+		"empty":     {true, "", ds{f("test.org")}, ds{}, true, nil},
+		"star":      {true, "*", ds{}, ds{w("")}, true, nil},
+		"wildcard1": {true, "*.a", ds{}, ds{w("a")}, true, nil},
+		"wildcard2": {true, "*.a.b", ds{}, ds{w("a.b")}, true, nil},
+		"test1":     {true, "書.org ,  Bücher.org  ", ds{f("random.org")}, ds{f("xn--rov.org"), f("xn--bcher-kva.org")}, true, nil},                      //nolint:lll
+		"test2":     {true, "  \txn--rov.org    ,   xn--Bcher-kva.org  ", ds{f("random.org")}, ds{f("xn--rov.org"), f("xn--bcher-kva.org")}, true, nil}, //nolint:lll
+		"illformed1": {
+			true, "xn--:D.org,a.org",
+			ds{f("random.org")},
+			ds{f("random.org")},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) contains an ill-formed domain %q: %v", key, "xn--:D.org,a.org", "xn--:d.org", gomock.Any()) //nolint:lll
+			},
+		},
+		"illformed2": {
+			true, "*.xn--:D.org,a.org",
+			ds{f("random.org")},
+			ds{f("random.org")},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) contains an ill-formed domain %q: %v", key, "*.xn--:D.org,a.org", "*.xn--:d.org", gomock.Any()) //nolint:lll
+			},
+		},
+		"illformed3": {
+			true, "hi.org,(",
+			ds{},
+			ds{},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) has unexpected token %q", key, "hi.org,(", "(")
+			},
+		},
+		"illformed4": {
+			true, ")",
+			ds{},
+			ds{},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) has unexpected token %q", key, ")", ")")
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			set(t, key, tc.set, tc.val)
+			field := tc.oldField
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
+			ok := config.ReadDomains(mockPP, key, &field)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.newField, field)
+		})
+	}
+}
+
+//nolint:paralleltest,funlen // environment vars are global
+func TestReadDomainMap(t *testing.T) {
+	for name, tc := range map[string]struct {
+		domains       string
+		ip4Domains    string
+		ip6Domains    string
+		expected      map[ipnet.Type][]domain.Domain
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"full": {
+			"  a1, a2", "b1,  b2,b2", "c1,c2",
+			map[ipnet.Type][]domain.Domain{
+				ipnet.IP4: {domain.FQDN("a1"), domain.FQDN("a2"), domain.FQDN("b1"), domain.FQDN("b2")},
+				ipnet.IP6: {domain.FQDN("a1"), domain.FQDN("a2"), domain.FQDN("c1"), domain.FQDN("c2")},
+			},
+			true,
+			nil,
+		},
+		"duplicate": {
+			"  a1, a1", "a1,  a1,a1", "*.a1,a1,*.a1,*.a1",
+			map[ipnet.Type][]domain.Domain{
+				ipnet.IP4: {domain.FQDN("a1")},
+				ipnet.IP6: {domain.FQDN("a1"), domain.Wildcard("a1")},
+			},
+			true,
+			nil,
+		},
+		"empty": {
+			" ", "   ", "",
+			map[ipnet.Type][]domain.Domain{
+				ipnet.IP4: {},
+				ipnet.IP6: {},
+			},
+			true,
+			nil,
+		},
+		"ill-formed": {
+			" ", "   ", "*.*", nil, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError,
+					"%s (%q) contains an ill-formed domain %q: %v",
+					"IP6_DOMAINS", "*.*", "*.*", gomock.Any())
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
+			store(t, "DOMAINS", tc.domains)
+			store(t, "IP4_DOMAINS", tc.ip4Domains)
+			store(t, "IP6_DOMAINS", tc.ip6Domains)
+
+			var field map[ipnet.Type][]domain.Domain
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadDomainMap(mockPP, &field)
+			require.Equal(t, tc.ok, ok)
+			require.ElementsMatch(t, tc.expected[ipnet.IP4], field[ipnet.IP4])
+			require.ElementsMatch(t, tc.expected[ipnet.IP6], field[ipnet.IP6])
+		})
+	}
+}
diff --git a/internal/config/env_provider_test.go b/internal/config/env_provider_test.go
new file mode 100644
index 0000000..6fbb24a
--- /dev/null
+++ b/internal/config/env_provider_test.go
@@ -0,0 +1,313 @@
+package config_test
+
+import (
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/config"
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+	"github.com/favonia/cloudflare-ddns/internal/provider"
+)
+
+//nolint:paralleltest,funlen // paralleltest should not be used because environment vars are global
+func TestReadProvider(t *testing.T) {
+	key := keyPrefix + "PROVIDER"
+	keyDeprecated := keyPrefix + "DEPRECATED"
+
+	var (
+		none  provider.Provider
+		doh   = provider.NewCloudflareDOH
+		trace = provider.NewCloudflareTrace
+		local = provider.NewLocal
+		ipify = provider.NewIpify()
+	)
+
+	for name, tc := range map[string]struct {
+		use1001       bool
+		set           bool
+		val           string
+		setDeprecated bool
+		valDeprecated string
+		oldField      provider.Provider
+		newField      provider.Provider
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"nil": {
+			true,
+			false, "", false, "", none, none, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", key, "none")
+			},
+		},
+		"deprecated/empty": {
+			false,
+			false, "", true, "", local(true), local(true), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", key, "local")
+			},
+		},
+		"deprecated/cloudflare": {
+			true,
+			false, "", true, "    cloudflare\t   ", none, trace(true), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiUserWarning,
+					`%s=cloudflare is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
+					keyDeprecated, key, key,
+				)
+			},
+		},
+		"deprecated/cloudflare.trace": {
+			false,
+			false, "", true, " cloudflare.trace", none, trace(false), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiUserWarning,
+					`%s is deprecated; use %s=%s`,
+					keyDeprecated,
+					key,
+					"cloudflare.trace",
+				)
+			},
+		},
+		"deprecated/cloudflare.doh": {
+			true,
+			false, "", true, "    \tcloudflare.doh   ", none, doh(true), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiUserWarning,
+					`%s is deprecated; use %s=%s`,
+					keyDeprecated,
+					key,
+					"cloudflare.doh",
+				)
+			},
+		},
+		"deprecated/unmanaged": {
+			false,
+			false, "", true, "   unmanaged   ", trace(false), none, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiUserWarning,
+					`%s is deprecated; use %s=none`,
+					keyDeprecated,
+					key,
+				)
+			},
+		},
+		"deprecated/local": {
+			true,
+			false, "", true, "   local   ", trace(false), local(true), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiUserWarning,
+					`%s is deprecated; use %s=%s`,
+					keyDeprecated,
+					key,
+					"local",
+				)
+			},
+		},
+		"deprecated/ipify": {
+			false,
+			false, "", true, "     ipify  ", trace(false), ipify, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiUserWarning,
+					`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
+					keyDeprecated,
+					key,
+					key,
+				)
+			},
+		},
+		"deprecated/others": {
+			true,
+			false, "", true, "   something-else ", ipify, ipify, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", keyDeprecated, "something-else")
+			},
+		},
+		"conflicts": {
+			false,
+			true, "cloudflare.doh", true, "cloudflare.doh", ipify, ipify, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(
+					pp.EmojiUserError,
+					`Cannot have both %s and %s set`,
+					key, keyDeprecated,
+				)
+			},
+		},
+		"empty": {
+			true,
+			false, "", false, "", local(false), local(false), true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", key, "local")
+			},
+		},
+		"cloudflare": {
+			false,
+			true, "    cloudflare\t   ", false, "", none, none, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(
+					pp.EmojiUserError,
+					`%s=cloudflare is invalid; use %s=cloudflare.trace or %s=cloudflare.doh`,
+					key, key, key,
+				)
+			},
+		},
+		"cloudflare.trace": {true, true, " cloudflare.trace", false, "", none, trace(true), true, nil},
+		"cloudflare.doh":   {false, true, "    \tcloudflare.doh   ", false, "", none, doh(false), true, nil},
+		"none":             {true, true, "   none   ", false, "", trace(true), none, true, nil},
+		"local":            {false, true, "   local   ", false, "", trace(true), local(false), true, nil},
+		"ipify": {
+			true,
+			true, "     ipify  ", false, "", trace(false), ipify, true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(
+					pp.EmojiUserWarning,
+					`%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`,
+					key,
+					key,
+					key,
+				)
+			},
+		},
+		"others": {
+			false,
+			true, "   something-else ", false, "", ipify, ipify, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", key, "something-else")
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			set(t, key, tc.set, tc.val)
+			set(t, keyDeprecated, tc.setDeprecated, tc.valDeprecated)
+			field := tc.oldField
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadProvider(mockPP, tc.use1001, key, keyDeprecated, &field)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.newField, field)
+		})
+	}
+}
+
+//nolint:funlen,paralleltest // environment vars are global
+func TestReadProviderMap(t *testing.T) {
+	var (
+		none  provider.Provider
+		trace = provider.NewCloudflareTrace
+		doh   = provider.NewCloudflareDOH
+		local = provider.NewLocal
+	)
+
+	for name, tc := range map[string]struct {
+		use1001       bool
+		ip4Provider   string
+		ip6Provider   string
+		expected      map[ipnet.Type]provider.Provider
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"full/true": {
+			true,
+			"cloudflare.trace", "local",
+			map[ipnet.Type]provider.Provider{
+				ipnet.IP4: trace(true),
+				ipnet.IP6: local(true),
+			},
+			true,
+			nil,
+		},
+		"full/false": {
+			false,
+			"cloudflare.trace", "local",
+			map[ipnet.Type]provider.Provider{
+				ipnet.IP4: trace(false),
+				ipnet.IP6: local(false),
+			},
+			true,
+			nil,
+		},
+		"4": {
+			true,
+			"local", "  ",
+			map[ipnet.Type]provider.Provider{
+				ipnet.IP4: local(true),
+				ipnet.IP6: local(true),
+			},
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "local")
+			},
+		},
+		"6": {
+			false,
+			"    ", "cloudflare.doh",
+			map[ipnet.Type]provider.Provider{
+				ipnet.IP4: none,
+				ipnet.IP6: doh(false),
+			},
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none")
+			},
+		},
+		"empty": {
+			true,
+			" ", "   ",
+			map[ipnet.Type]provider.Provider{
+				ipnet.IP4: none,
+				ipnet.IP6: local(true),
+			},
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP4_PROVIDER", "none"),
+					m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "IP6_PROVIDER", "local"),
+				)
+			},
+		},
+		"illformed": {
+			false,
+			" flare", "   ",
+			map[ipnet.Type]provider.Provider{
+				ipnet.IP4: none,
+				ipnet.IP6: local(false),
+			},
+			false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", "IP4_PROVIDER", "flare")
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			mockCtrl := gomock.NewController(t)
+
+			store(t, "IP4_PROVIDER", tc.ip4Provider)
+			store(t, "IP6_PROVIDER", tc.ip6Provider)
+
+			field := map[ipnet.Type]provider.Provider{ipnet.IP4: none, ipnet.IP6: local(tc.use1001)}
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadProviderMap(mockPP, tc.use1001, &field)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.expected, field)
+		})
+	}
+}
diff --git a/internal/config/network_probe_test.go b/internal/config/network_probe_test.go
new file mode 100644
index 0000000..16902a7
--- /dev/null
+++ b/internal/config/network_probe_test.go
@@ -0,0 +1,40 @@
+package config_test
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/config"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+)
+
+func TestProbeURLTrue(t *testing.T) {
+	t.Parallel()
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+	defer server.Close()
+	require.True(t, config.ProbeURL(context.Background(), server.URL))
+}
+
+func TestProbeURLFalse(t *testing.T) {
+	t.Parallel()
+	require.False(t, config.ProbeURL(context.Background(), "http://127.0.0.1:0"))
+}
+
+func TestProbeURLInvalidURL(t *testing.T) {
+	t.Parallel()
+	require.False(t, config.ProbeURL(context.Background(), "://"))
+}
+
+func TestProbeCloudflareIPs(t *testing.T) {
+	t.Parallel()
+	mockCtrl := gomock.NewController(t)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	// config.ShouldWeUse1001 must return false on GitHub.
+	require.False(t, config.ShouldWeUse1001(context.Background(), mockPP))
+}
diff --git a/internal/provider/cloudflare_doh_test.go b/internal/provider/cloudflare_doh_test.go
index 392c3a8..5e4290e 100644
--- a/internal/provider/cloudflare_doh_test.go
+++ b/internal/provider/cloudflare_doh_test.go
@@ -11,5 +11,6 @@ import (
 func TestCloudflareName(t *testing.T) {
 	t.Parallel()
 
-	require.Equal(t, "cloudflare.doh", provider.Name(provider.NewCloudflareDOH()))
+	require.Equal(t, "cloudflare.doh", provider.Name(provider.NewCloudflareDOH(true)))
+	require.Equal(t, "cloudflare.doh", provider.Name(provider.NewCloudflareDOH(false)))
 }
diff --git a/internal/provider/cloudflare_trace_test.go b/internal/provider/cloudflare_trace_test.go
index 16bbb6f..ea0916a 100644
--- a/internal/provider/cloudflare_trace_test.go
+++ b/internal/provider/cloudflare_trace_test.go
@@ -11,5 +11,6 @@ import (
 func TestCloudflareTraceName(t *testing.T) {
 	t.Parallel()
 
-	require.Equal(t, "cloudflare.trace", provider.Name(provider.NewCloudflareTrace()))
+	require.Equal(t, "cloudflare.trace", provider.Name(provider.NewCloudflareTrace(true)))
+	require.Equal(t, "cloudflare.trace", provider.Name(provider.NewCloudflareTrace(false)))
 }
diff --git a/internal/provider/local_cloudflare_test.go b/internal/provider/local_cloudflare_test.go
index 0705294..53d8715 100644
--- a/internal/provider/local_cloudflare_test.go
+++ b/internal/provider/local_cloudflare_test.go
@@ -11,5 +11,6 @@ import (
 func TestLocalCloudflareName(t *testing.T) {
 	t.Parallel()
 
-	require.Equal(t, "local", provider.Name(provider.NewLocal()))
+	require.Equal(t, "local", provider.Name(provider.NewLocal(true)))
+	require.Equal(t, "local", provider.Name(provider.NewLocal(false)))
 }
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index 6bd6955..e1c42c5 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -277,7 +277,7 @@ func TestUpdateIPs(t *testing.T) {
 		t.Run(name, func(t *testing.T) {
 			mockCtrl := gomock.NewController(t)
 			ctx := context.Background()
-			conf := config.Default()
+			conf := config.Default(false)
 			conf.Domains = domains
 			conf.TTL = tc.ttl
 			conf.Proxied = tc.proxied
@@ -438,7 +438,7 @@ func TestClearIPs(t *testing.T) {
 		t.Run(name, func(t *testing.T) {
 			mockCtrl := gomock.NewController(t)
 			ctx := context.Background()
-			conf := config.Default()
+			conf := config.Default(false)
 			conf.Domains = domains
 			conf.TTL = tc.ttl
 			conf.Proxied = tc.proxied