From 8b9d1603c92bf42995a9bd4febfa5506086ea190 Mon Sep 17 00:00:00 2001 From: favonia Date: Fri, 19 May 2023 11:22:31 -0500 Subject: [PATCH] feat(config): use 1.0.0.1 when 1.1.1.1 is blocked (#491) --- .github/workflows/test.yaml | 2 + cmd/ddns/ddns.go | 5 +- internal/config/config.go | 315 +------ internal/config/config_print.go | 102 +++ internal/config/config_print_test.go | 180 ++++ internal/config/config_read.go | 124 +++ internal/config/config_read_test.go | 397 +++++++++ internal/config/config_test.go | 835 +----------------- internal/config/env_auth.go | 57 ++ internal/config/env_auth_test.go | 142 +++ internal/config/{env.go => env_base.go} | 121 --- .../config/{env_test.go => env_base_test.go} | 253 ------ internal/config/env_domain.go | 48 + internal/config/env_domain_test.go | 156 ++++ internal/config/env_provider.go | 135 +++ internal/config/env_provider_test.go | 313 +++++++ internal/config/network_probe.go | 44 + internal/config/network_probe_test.go | 40 + internal/provider/cloudflare_doh.go | 10 +- internal/provider/cloudflare_doh_test.go | 3 +- internal/provider/cloudflare_trace.go | 10 +- internal/provider/cloudflare_trace_test.go | 3 +- internal/provider/local_cloudflare.go | 10 +- internal/provider/local_cloudflare_test.go | 3 +- internal/updater/updater_test.go | 4 +- 25 files changed, 1781 insertions(+), 1531 deletions(-) create mode 100644 internal/config/config_print.go create mode 100644 internal/config/config_print_test.go create mode 100644 internal/config/config_read.go create mode 100644 internal/config/config_read_test.go create mode 100644 internal/config/env_auth.go create mode 100644 internal/config/env_auth_test.go rename internal/config/{env.go => env_base.go} (63%) rename internal/config/{env_test.go => env_base_test.go} (70%) create mode 100644 internal/config/env_domain.go create mode 100644 internal/config/env_domain_test.go create mode 100644 internal/config/env_provider.go create mode 100644 internal/config/env_provider_test.go create mode 100644 internal/config/network_probe.go create mode 100644 internal/config/network_probe_test.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 16b4dbf1..2a92ac1c 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/cmd/ddns/ddns.go b/cmd/ddns/ddns.go index 3cda6674..f7c43f28 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 a0ad9051..d45a519a 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 00000000..8ea8127e --- /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_print_test.go b/internal/config/config_print_test.go new file mode 100644 index 00000000..37398004 --- /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.go b/internal/config/config_read.go new file mode 100644 index 00000000..17c7e019 --- /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/config_read_test.go b/internal/config/config_read_test.go new file mode 100644 index 00000000..74d8d75c --- /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 5342313b..d1379cdf 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.go b/internal/config/env_auth.go new file mode 100644 index 00000000..45ad5cdf --- /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_auth_test.go b/internal/config/env_auth_test.go new file mode 100644 index 00000000..db5f2186 --- /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.go b/internal/config/env_base.go similarity index 63% rename from internal/config/env.go rename to internal/config/env_base.go index ed5c3f18..0921974f 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_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 da1f0157..0426e810 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.go b/internal/config/env_domain.go new file mode 100644 index 00000000..c0dda6d9 --- /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_domain_test.go b/internal/config/env_domain_test.go new file mode 100644 index 00000000..caf730a2 --- /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.go b/internal/config/env_provider.go new file mode 100644 index 00000000..d79cd22e --- /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/env_provider_test.go b/internal/config/env_provider_test.go new file mode 100644 index 00000000..6fbb24ac --- /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.go b/internal/config/network_probe.go new file mode 100644 index 00000000..32139199 --- /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/config/network_probe_test.go b/internal/config/network_probe_test.go new file mode 100644 index 00000000..16902a79 --- /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.go b/internal/provider/cloudflare_doh.go index ed04e6b8..06abff4c 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_doh_test.go b/internal/provider/cloudflare_doh_test.go index 392c3a8d..5e4290ec 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.go b/internal/provider/cloudflare_trace.go index c501528b..7020b539 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/cloudflare_trace_test.go b/internal/provider/cloudflare_trace_test.go index 16bbb6f9..ea0916ae 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.go b/internal/provider/local_cloudflare.go index 7a771c9d..21352a26 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", }, } diff --git a/internal/provider/local_cloudflare_test.go b/internal/provider/local_cloudflare_test.go index 07052942..53d8715a 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 6bd69554..e1c42c54 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