From 0b34720c1cddd537e1133b2d4f1f902e4c04821c Mon Sep 17 00:00:00 2001 From: favonia Date: Sun, 23 Oct 2022 04:15:40 -0500 Subject: [PATCH] feat: replace the template engine with an in-house parser (#233) * feat: replace the template engine with an in-house parser * style: change "could not" to "failed to" * refactor: remove unused api.SortTTL * refactor: remove unused config.ParseProxied * refactor: use the in-house parser in config.ReadDomains * fix: domainexp.tokenize should not return nil for empty strings * test(config): improve coverage * test(domainexp): improve coverage * test: improve coverage * docs(README): document the new template engine --- .golangci.yaml | 9 ++ README.markdown | 43 ++--- go.mod | 2 - go.sum | 4 - internal/api/cloudflare.go | 2 +- internal/api/cloudflare_test.go | 2 +- internal/api/ttl.go | 11 +- internal/config/config.go | 96 ++--------- internal/config/config_test.go | 191 +++------------------- internal/config/env.go | 50 ++++-- internal/config/env_test.go | 121 ++++++++++---- internal/domain/domain.go | 6 +- internal/domain/template.go | 74 --------- internal/domain/template_test.go | 83 ---------- internal/domainexp/lexer.go | 114 +++++++++++++ internal/domainexp/parser.go | 258 ++++++++++++++++++++++++++++++ internal/domainexp/parser_test.go | 195 ++++++++++++++++++++++ internal/updater/updater.go | 16 +- internal/updater/updater_test.go | 56 +++---- 19 files changed, 788 insertions(+), 545 deletions(-) delete mode 100644 internal/domain/template.go delete mode 100644 internal/domain/template_test.go create mode 100644 internal/domainexp/lexer.go create mode 100644 internal/domainexp/parser.go create mode 100644 internal/domainexp/parser_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 5fa58668..c26c79c0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -8,6 +8,15 @@ linters-settings: suggest-new: true exhaustive: default-signifies-exhaustive: true + govet: + settings: + printf: + funcs: + - (github.com/favonia/cloudflare-ddns/internal/pp).Infof + - (github.com/favonia/cloudflare-ddns/internal/pp).Noticef + - (github.com/favonia/cloudflare-ddns/internal/pp).Warningf + - (github.com/favonia/cloudflare-ddns/internal/pp).Errorf + - (github.com/favonia/cloudflare-ddns/internal/pp).printf issues: exclude-rules: diff --git a/README.markdown b/README.markdown index 70803b83..302c6c99 100644 --- a/README.markdown +++ b/README.markdown @@ -23,7 +23,7 @@ A small and fast DDNS updater for Cloudflare. ### ⚑ Efficiency -* 🀏 The Docker images are small (less than 4 MB after compression). +* 🀏 The Docker images are small (less than 3 MB after compression). * πŸ” The Go runtime will re-use existing HTTP connections. * πŸ—ƒοΈ Cloudflare API responses are cached to reduce the API usage. @@ -34,7 +34,7 @@ Simply list all the domain names and you are done! * 🌍 Internationalized domain names (_e.g._, `🐱.example.org`) are fully supported. _(The updater smooths out [some rough edges of the Cloudflare API](https://github.com/cloudflare/cloudflare-go/pull/690#issuecomment-911884832).)_ * πŸƒ Wildcard domain names (_e.g._, `*.example.org`) are also supported. * πŸ” This updater automatically finds the DNS zones for you, and it can handle multiple DNS zones. -* πŸ•ΉοΈ You can toggle IPv4 (`A` records), IPv6 (`AAAA` records) and Cloudflare proxying and change TTL on a per-domain basis. +* πŸ•ΉοΈ You can toggle IPv4 (`A` records), IPv6 (`AAAA` records) and Cloudflare proxying on a per-domain basis. ### πŸ•΅οΈ Privacy @@ -292,9 +292,9 @@ In most cases, `CF_ACCOUNT_ID` is not needed. | Name | Valid Values | Meaning | Required? | Default Value | | ---- | ------------ | ------- | --------- | ------------- | -| `DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains the updater should manage for both `A` and `AAAA` records | (See below) | N/A -| `IP4_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains the updater should manage for `A` records | (See below) | N/A -| `IP6_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains the updater should manage for `AAAA` records | (See below) | N/A +| `DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains the updater should manage for both `A` and `AAAA` records | (See below) | `""` (empty list) +| `IP4_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains the updater should manage for `A` records | (See below) | `""` (empty list) +| `IP6_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains the updater should manage for `AAAA` records | (See below) | `""` (empty list) | `IP4_PROVIDER` | `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `none` | How to detect IPv4 addresses. (See below) | No | `cloudflare.trace` | `IP6_PROVIDER` | `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `none` | How to detect IPv6 addresses. (See below) | No | `cloudflare.trace` @@ -344,27 +344,32 @@ In most cases, `CF_ACCOUNT_ID` is not needed. | Name | Valid Values | Meaning | Required? | Default Value | | ---- | ------------ | ------- | --------- | ------------- | -| `PROXIED` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether new DNS records should be proxied by Cloudflare | No | `false` +| `PROXIED` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool). See below for experimental support of per-domain proxy settings. | Whether new DNS records should be proxied by Cloudflare | No | `false` | `TTL` | Time-to-live (TTL) values in seconds | The TTL values used to create new DNS records | No | `1` (This means β€œautomatic” to Cloudflare) >
-> πŸ§ͺ Experimental support of templates (subject to changes): +> πŸ§ͺ Experimental per-domain proxy settings (subject to changes): > -> Both `PROXIED` and `TTL` can be [Jet Templates](https://github.com/CloudyKit/jet/blob/master/docs/syntax.md) for per-domain settings. For example, `PROXIED={{!hasSuffix("example.org")}}` means all domains should be proxied except domains like `www.example.org` and `example.org`. The Go templates are executed with the following two custom functions: -> - `inDomains(patterns ...string) bool` +> The `PROXIED` can be a boolean expression. Here are some examples: +> - `PROXIED=is(example.org)`: enable proxy only for the domain `example.org` +> - `PROXIED=is(example1.org) || sub(example2.org)`: enable proxy only for the domain `example1.org` and the subdomains of `example2.org` +> - `PROXIED=!is(example.org)`: enable proxy _except for_ the domain `example.org` +> - `PROXIED=is(example1.org) || is(example2.org) || is(example3.org)`: enable proxy only for the domains `example1.org`, `example2.org`, and `example3.org` > -> Returns `true` if and only if the target domain matches one of `patterns`. All domains are normalized before comparison. For example, internationalized domain names are converted to Punycode before comparing them. -> - `hasSuffix(patterns ...string) bool` +> More formally, a boolean expression has one of the following forms: +> - A boolean value accepted by [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool), such as `t` as `true`. +> - `is(d)` which matches the domain `d`. Note that `is(*.a)` only matches the wildcard domain `*.a`; use `sub(a)` to all subdomains of `a` (including `*.a`). +> - `sub(d)` which matches subdomains of `d` (not including `d` itself). +> - `! e` where `e` is a boolean expression, representing logical negation. +> - `e1 || e2` where `e1` and `e2` are boolean expressions, representing logical or. +> - `e1 && e2` where `e1` and `e2` are boolean expressions, representing logical and. > -> Returns `true` if and only if the target domain has one of `patterns` as itself or its parent (or ancestor). Note that labels in domains must fully match; for example, the suffix `b.org` will not match `www.bb.org` because `bb.org` and `b.org` are incomparable, while the suffix `bb.org` will match `www.bb.org`. +> One can use parentheses to group expressions, such as `!(is(a) && (is(b) || is(c)))`. +> For convenience, the engine also accepts these short forms: +> - `is(d1, d2, ..., dn) = is(d1) || is(d2) || ... || is(dn)` +> - `sub(d1, d2, ..., dn) = sub(d1) || sub(d2) || ... || sub(dn)` > -> Some examples: -> - `TTL={{if hasSuffix("b.c")}} 60 {{else if inDomains("d.e.f","a.bb.c")}} 90 {{else}} 120 {{end}}` -> -> For the domain `b.c` and its descendants, the TTL is 60, and for the domains `d.e.f` and `a.bb.c`, the TTL is 90, and then for all other domains, the TTL is 120. -> - `PROXIED={{hasSuffix("b.c") && ! inDomains("a.b.c"))}}` -> -> Proxy the domain `b.c` and its descendants except for the domain `a.b.c`. +> Using these short forms, `is(example1.org) || is(example2.org) || is(example3.org)` can be abbreviated as `is(example1.org,example2.org,example3.org)`. >
diff --git a/go.mod b/go.mod index 1360d153..2d2764c3 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/favonia/cloudflare-ddns go 1.19 require ( - github.com/CloudyKit/jet/v6 v6.1.0 github.com/cloudflare/cloudflare-go v0.52.0 github.com/golang/mock v1.6.0 github.com/patrickmn/go-cache v2.1.0+incompatible @@ -14,7 +13,6 @@ require ( ) require ( - github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/go.sum b/go.sum index fea9e20c..975e40d9 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,3 @@ -github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= -github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= -github.com/CloudyKit/jet/v6 v6.1.0 h1:hvO96X345XagdH1fAoBjpBYG4a1ghhL/QzalkduPuXk= -github.com/CloudyKit/jet/v6 v6.1.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= github.com/cloudflare/cloudflare-go v0.52.0 h1:9pa170sl8HBR2c/7I5konGwgDYzlQ4dy3evdG/my9xU= github.com/cloudflare/cloudflare-go v0.52.0/go.mod h1:JSdZSD4FjF220O9REnYf0IGx7gUdbWwRgCAv4TusaJc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/internal/api/cloudflare.go b/internal/api/cloudflare.go index 0d7e1881..1cd2e71b 100644 --- a/internal/api/cloudflare.go +++ b/internal/api/cloudflare.go @@ -181,7 +181,7 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP, for i := range rs { rmap[rs[i].ID], err = netip.ParseAddr(rs[i].Content) if err != nil { - ppfmt.Warningf(pp.EmojiImpossible, "Could not parse the IP address in records of %q: %v", domain.Describe(), err) + ppfmt.Warningf(pp.EmojiImpossible, "Failed to parse the IP address in records of %q: %v", domain.Describe(), err) return nil, false } } diff --git a/internal/api/cloudflare_test.go b/internal/api/cloudflare_test.go index 1838144c..45b6a8f5 100644 --- a/internal/api/cloudflare_test.go +++ b/internal/api/cloudflare_test.go @@ -633,7 +633,7 @@ func TestListRecordsInvalidIPAddress(t *testing.T) { mockPP := mocks.NewMockPP(mockCtrl) mockPP.EXPECT().Warningf( pp.EmojiImpossible, - "Could not parse the IP address in records of %q: %v", + "Failed to parse the IP address in records of %q: %v", "sub.test.org", gomock.Any(), ) diff --git a/internal/api/ttl.go b/internal/api/ttl.go index e896b771..2f1e1482 100644 --- a/internal/api/ttl.go +++ b/internal/api/ttl.go @@ -1,13 +1,10 @@ package api -import ( - "sort" - "strconv" -) +import "strconv" type TTL int -const TTLAuto = 1 +const TTLAuto TTL = 1 func (t TTL) Int() int { return int(t) @@ -23,7 +20,3 @@ func (t TTL) Describe() string { } return strconv.Itoa(t.Int()) } - -func SortTTLs(s []TTL) { - sort.Slice(s, func(i, j int) bool { return int(s[i]) < int(s[j]) }) -} diff --git a/internal/config/config.go b/internal/config/config.go index 2105f2d0..25107a1a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,13 +2,13 @@ package config import ( "fmt" - "strconv" "strings" "time" "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" @@ -24,8 +24,7 @@ type Config struct { UpdateOnStart bool DeleteOnStop bool CacheExpiration time.Duration - TTLTemplate string - TTL map[domain.Domain]api.TTL + TTL api.TTL ProxiedTemplate string Proxied map[domain.Domain]bool DetectionTimeout time.Duration @@ -49,8 +48,7 @@ func Default() *Config { UpdateOnStart: true, DeleteOnStop: false, CacheExpiration: time.Hour * 6, //nolint:gomnd - TTLTemplate: "1", - TTL: map[domain.Domain]api.TTL{}, + TTL: api.TTLAuto, ProxiedTemplate: "false", Proxied: map[domain.Domain]bool{}, UpdateTimeout: time.Second * 30, //nolint:gomnd @@ -168,42 +166,6 @@ func ReadProviderMap(ppfmt pp.PP, field *map[ipnet.Type]provider.Provider) bool return true } -func ParseProxied(ppfmt pp.PP, dom domain.Domain, val string) (bool, bool) { - val = strings.TrimSpace(val) - res, err := strconv.ParseBool(val) - if err != nil { - ppfmt.Errorf(pp.EmojiUserError, "Proxy setting of %s (%q) is not a boolean value: %v", dom.Describe(), val, err) - return false, false - } - - return res, true -} - -// ParseTTL turns a template into a valid TTL value. -// -// According to [API documentation], the valid range is 1 (auto) and [60, 86400]. -// According to [DNS documentation], the valid range is "Auto" and [30, 86400]. -// We thus accept the union of both ranges---1 (auto) and [30, 86400]. -// -// [API documentation] https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record -// [DNS documentation] https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl -func ParseTTL(ppfmt pp.PP, dom domain.Domain, val string) (api.TTL, bool) { - val = strings.TrimSpace(val) - res, err := strconv.Atoi(val) - switch { - case err != nil: - ppfmt.Errorf(pp.EmojiUserError, "TTL of %s (%q) is not a number: %v", dom.Describe(), val, err) - return 0, false - - case res != 1 && (res < 30 || res > 86400): - ppfmt.Errorf(pp.EmojiUserError, "TTL of %s (%d) should be 1 (auto) or between 30 and 86400", dom.Describe(), res) - return 0, false - - default: - return api.TTL(res), true - } -} - func describeDomains(domains []domain.Domain) string { if len(domains) == 0 { return "(none)" @@ -265,20 +227,12 @@ func (c *Config) Print(ppfmt pp.PP) { item("Delete on stop?", "%t", c.DeleteOnStop) item("Cache expiration:", "%v", c.CacheExpiration) - if len(c.TTL) > 0 { - section("TTL of new records:") - vals, inverseMap := getInverseMap(c.TTL) - api.SortTTLs(vals) - for _, val := range vals { - item(fmt.Sprintf("TTL is %s:", val.Describe()), describeDomains(inverseMap[val])) - } - } - + section("New DNS records:") + item("TTL:", "%s", c.TTL.Describe()) if len(c.Proxied) > 0 { - section("Proxy for new records:") _, inverseMap := getInverseMap(c.Proxied) - item("Proxied:", "%s", describeDomains(inverseMap[true])) - item("Unproxied (DNS only):", "%s", describeDomains(inverseMap[false])) + item("Proxied domains:", "%s", describeDomains(inverseMap[true])) + item("Unproxied domains:", "%s", describeDomains(inverseMap[false])) } section("Timeouts:") @@ -306,7 +260,7 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool { !ReadBool(ppfmt, "UPDATE_ON_START", &c.UpdateOnStart) || !ReadBool(ppfmt, "DELETE_ON_STOP", &c.DeleteOnStop) || !ReadNonnegDuration(ppfmt, "CACHE_EXPIRATION", &c.CacheExpiration) || - !ReadString(ppfmt, "TTL", &c.TTLTemplate) || + !ReadTTL(ppfmt, "TTL", &c.TTL) || !ReadString(ppfmt, "PROXIED", &c.ProxiedTemplate) || !ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) || !ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) || @@ -317,24 +271,6 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool { return true } -func assignMap[V any](ppfmt pp.PP, - m map[domain.Domain]V, - e func(domain.Domain) (string, bool), - p func(pp.PP, domain.Domain, string) (V, bool), - dom domain.Domain, -) bool { - str, ok := e(dom) - if !ok { - return false - } - val, ok := p(ppfmt, dom, str) - if !ok { - return false - } - m[dom] = val - return true -} - // NormalizeDomains normalizes the fields Provider, TTL and Proxied. // When errors are reported, the original configuration remain unchanged. // @@ -342,7 +278,6 @@ func assignMap[V any](ppfmt pp.PP, func (c *Config) NormalizeDomains(ppfmt pp.PP) bool { // New maps providerMap := map[ipnet.Type]provider.Provider{} - ttlMap := map[domain.Domain]api.TTL{} proxiedMap := map[domain.Domain]bool{} activeDomainSet := map[domain.Domain]bool{} @@ -398,25 +333,16 @@ func (c *Config) NormalizeDomains(ppfmt pp.PP) bool { } } - // fill in ttlMap and proxyMap - ttlExec, ok := domain.ParseTemplate(ppfmt, c.TTLTemplate) + // fill in proxyMap + proxiedPred, ok := domainexp.ParseExpression(ppfmt, c.ProxiedTemplate) if !ok { return false } - proxiedExec, ok := domain.ParseTemplate(ppfmt, c.ProxiedTemplate) - if !ok { - return false - } - for dom := range activeDomainSet { - if !assignMap(ppfmt, ttlMap, ttlExec, ParseTTL, dom) || - !assignMap(ppfmt, proxiedMap, proxiedExec, ParseProxied, dom) { - return false - } + proxiedMap[dom] = proxiedPred(dom) } c.Provider = providerMap - c.TTL = ttlMap c.Proxied = proxiedMap return true diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8e279529..730d4d01 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -305,71 +305,6 @@ func TestReadDomainMap(t *testing.T) { } } -//nolint:funlen -func TestParseTTL(t *testing.T) { - t.Parallel() - - domain, _ := domain.New("example.io") - for name, tc := range map[string]struct { - val string - ttl api.TTL - ok bool - prepareMockPP func(*mocks.MockPP) - }{ - "empty": { - "", 0, false, - func(m *mocks.MockPP) { - m.EXPECT().Errorf(pp.EmojiUserError, "TTL of %s (%q) is not a number: %v", domain.Describe(), "", gomock.Any()) - }, - }, - "0": { - "0 ", 0, false, - func(m *mocks.MockPP) { - m.EXPECT().Errorf(pp.EmojiUserError, "TTL of %s (%d) should be 1 (auto) or between 30 and 86400", domain.Describe(), 0) //nolint:lll - }, - }, - "-1": { - " -1", 0, false, - func(m *mocks.MockPP) { - m.EXPECT().Errorf(pp.EmojiUserError, "TTL of %s (%d) should be 1 (auto) or between 30 and 86400", domain.Describe(), -1) //nolint:lll - }, - }, - "1": {" 1 ", 1, true, nil}, - "20": { - " 20 ", 0, false, - func(m *mocks.MockPP) { - m.EXPECT().Errorf(pp.EmojiUserError, "TTL of %s (%d) should be 1 (auto) or between 30 and 86400", domain.Describe(), 20) //nolint:lll - }, - }, - "9999999": { - " 9999999 ", 0, false, - func(m *mocks.MockPP) { - m.EXPECT().Errorf(pp.EmojiUserError, "TTL of %s (%d) should be 1 (auto) or between 30 and 86400", domain.Describe(), 9999999) //nolint:lll - }, - }, - "words": { - " word ", 0, false, - func(m *mocks.MockPP) { - m.EXPECT().Errorf(pp.EmojiUserError, "TTL of %s (%q) is not a number: %v", domain.Describe(), "word", gomock.Any()) - }, - }, - } { - tc := tc - t.Run(name, func(t *testing.T) { - t.Parallel() - - mockCtrl := gomock.NewController(t) - mockPP := mocks.NewMockPP(mockCtrl) - if tc.prepareMockPP != nil { - tc.prepareMockPP(mockPP) - } - ttl, ok := config.ParseTTL(mockPP, domain, tc.val) - require.Equal(t, tc.ok, ok) - require.Equal(t, tc.ttl, ttl) - }) - } -} - type someMatcher struct { matchers []gomock.Matcher } @@ -427,6 +362,8 @@ func TestPrintDefault(t *testing.T) { 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"), @@ -458,12 +395,10 @@ func TestPrintMaps(t *testing.T) { 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, "TTL of new records:"), - innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL is 1 (auto):", "a, c"), - innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "TTL is 30000:", "b, d"), - mockPP.EXPECT().Infof(pp.EmojiConfig, "Proxy for new records:"), - innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Proxied:", "a, b"), - innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Unproxied (DNS only):", "c, d"), + 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"), @@ -476,11 +411,7 @@ func TestPrintMaps(t *testing.T) { 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 = map[domain.Domain]api.TTL{} - c.TTL[domain.FQDN("a")] = 1 - c.TTL[domain.FQDN("b")] = 30000 - c.TTL[domain.FQDN("c")] = 1 - c.TTL[domain.FQDN("d")] = 30000 + c.TTL = 30000 c.Proxied = map[domain.Domain]bool{} c.Proxied[domain.FQDN("a")] = true @@ -517,6 +448,8 @@ func TestPrintEmpty(t *testing.T) { 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"), @@ -563,7 +496,7 @@ func TestReadEnvWithOnlyToken(t *testing.T) { 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=%s", "TTL", ""), + 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)), @@ -649,7 +582,6 @@ func TestNormalize(t *testing.T) { ipnet.IP4: {domain.FQDN("a.b.c")}, ipnet.IP6: {}, }, - TTLTemplate: "1", ProxiedTemplate: "false", }, ok: true, @@ -661,10 +593,6 @@ func TestNormalize(t *testing.T) { ipnet.IP4: {domain.FQDN("a.b.c")}, ipnet.IP6: {}, }, - TTLTemplate: "1", - TTL: map[domain.Domain]api.TTL{ - domain.FQDN("a.b.c"): 1, - }, ProxiedTemplate: "false", Proxied: map[domain.Domain]bool{ domain.FQDN("a.b.c"): false, @@ -714,7 +642,6 @@ func TestNormalize(t *testing.T) { ipnet.IP4: {domain.FQDN("a.b.c"), domain.FQDN("d.e.f")}, ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("g.h.i")}, }, - TTLTemplate: "1", ProxiedTemplate: "false", }, ok: true, @@ -726,11 +653,6 @@ func TestNormalize(t *testing.T) { ipnet.IP4: {domain.FQDN("a.b.c"), domain.FQDN("d.e.f")}, ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("g.h.i")}, }, - TTLTemplate: "1", - TTL: map[domain.Domain]api.TTL{ - domain.FQDN("a.b.c"): 1, - domain.FQDN("g.h.i"): 1, - }, ProxiedTemplate: "false", Proxied: map[domain.Domain]bool{ domain.FQDN("a.b.c"): false, @@ -756,8 +678,7 @@ func TestNormalize(t *testing.T) { Domains: map[ipnet.Type][]domain.Domain{ ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")}, }, - TTLTemplate: `{{if hasSuffix("b.c")}} 60 {{else if inDomains("d.e.f","a.bb.c") }} 90 {{else}} 120 {{end}}`, - ProxiedTemplate: ` {{true && !inDomains("a.bb.c")}} `, + ProxiedTemplate: ` true && !is(a.bb.c) `, }, ok: true, expected: &config.Config{ //nolint:exhaustruct @@ -767,13 +688,7 @@ func TestNormalize(t *testing.T) { Domains: map[ipnet.Type][]domain.Domain{ ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")}, }, - TTLTemplate: `{{if hasSuffix("b.c")}} 60 {{else if inDomains("d.e.f","a.bb.c") }} 90 {{else}} 120 {{end}}`, - TTL: map[domain.Domain]api.TTL{ - domain.FQDN("a.b.c"): 60, - domain.FQDN("a.bb.c"): 90, - domain.FQDN("a.d.e.f"): 120, - }, - ProxiedTemplate: ` {{true && !inDomains("a.bb.c")}} `, + ProxiedTemplate: ` true && !is(a.bb.c) `, Proxied: map[domain.Domain]bool{ domain.FQDN("a.b.c"): true, domain.FQDN("a.bb.c"): false, @@ -788,28 +703,6 @@ func TestNormalize(t *testing.T) { ) }, }, - "template/invalid/ttl": { - 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")}, - }, - TTLTemplate: `{{if}}`, - ProxiedTemplate: ` {{!inDomains("a.b.c")}} `, - }, - 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, "Could not parse the template %q: %v", "{{if}}", gomock.Any()), - ) - }, - }, "template/invalid/proxied": { input: &config.Config{ //nolint:exhaustruct Provider: map[ipnet.Type]provider.Provider{ @@ -818,52 +711,7 @@ func TestNormalize(t *testing.T) { Domains: map[ipnet.Type][]domain.Domain{ ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")}, }, - TTLTemplate: `1`, - 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, "Could not parse the template %q: %v", `{{range}}`, gomock.Any()), - ) - }, - }, - "template/error/ttl": { - 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")}, - }, - TTLTemplate: `not a number`, - ProxiedTemplate: `{{!inDomans("a.b.c")}}`, - }, - 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, "TTL of %s (%q) is not a number: %v", "a.b.c", "not a number", gomock.Any()), - ) - }, - }, - "template/error/ttl/out-of-range": { - 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")}, - }, - TTLTemplate: `{{if inDomains("a.b.c")}} 2 {{end}}`, - ProxiedTemplate: `{{!inDomains("a.b.c")}}`, + ProxiedTemplate: `range`, }, ok: false, expected: nil, @@ -872,7 +720,7 @@ func TestNormalize(t *testing.T) { m.EXPECT().IsEnabledFor(pp.Info).Return(true), m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."), m.EXPECT().IncIndent().Return(m), - m.EXPECT().Errorf(pp.EmojiUserError, "TTL of %s (%d) should be 1 (auto) or between 30 and 86400", "a.b.c", 2), + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: wanted a boolean expression; got %q", `range`, `range`), ) }, }, @@ -884,8 +732,7 @@ func TestNormalize(t *testing.T) { Domains: map[ipnet.Type][]domain.Domain{ ipnet.IP6: {domain.FQDN("a.b.c")}, }, - TTLTemplate: `1`, - ProxiedTemplate: `{{12345}}`, + ProxiedTemplate: `999`, }, ok: false, expected: nil, @@ -894,7 +741,7 @@ func TestNormalize(t *testing.T) { m.EXPECT().IsEnabledFor(pp.Info).Return(true), m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."), m.EXPECT().IncIndent().Return(m), - m.EXPECT().Errorf(pp.EmojiUserError, "Proxy setting of %s (%q) is not a boolean value: %v", "a.b.c", "12345", gomock.Any()), //nolint:lll + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: wanted a boolean expression; got %q", "999", "999"), ) }, }, @@ -906,8 +753,7 @@ func TestNormalize(t *testing.T) { Domains: map[ipnet.Type][]domain.Domain{ ipnet.IP6: {domain.FQDN("a.b.c")}, }, - TTLTemplate: `1`, - ProxiedTemplate: `{{inDomains(12345)}}`, + ProxiedTemplate: `is(12345`, }, ok: false, expected: nil, @@ -916,8 +762,7 @@ func TestNormalize(t *testing.T) { m.EXPECT().IsEnabledFor(pp.Info).Return(true), m.EXPECT().Infof(pp.EmojiEnvVars, "Checking settings . . ."), m.EXPECT().IncIndent().Return(m), - m.EXPECT().Errorf(pp.EmojiUserError, "Value %v is not a string", gomock.Any()), - m.EXPECT().Errorf(pp.EmojiUserError, "Could not execute the template %q: %v", `{{inDomains(12345)}}`, gomock.Any()), //nolint:lll + m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse %q: wanted %q; reached end of string`, `is(12345`, ")"), ) }, }, diff --git a/internal/config/env.go b/internal/config/env.go index dd1baa57..cc10e72b 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -6,8 +6,10 @@ import ( "strings" "time" + "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" @@ -91,29 +93,45 @@ func ReadNonnegInt(ppfmt pp.PP, key string, field *int) bool { return true } -// ReadDomains reads an environment variable as a comma-separated list of domains. -// Spaces are trimed. -func ReadDomains(ppfmt pp.PP, key string, field *[]domain.Domain) bool { - rawList := strings.Split(Getenv(key), ",") - - *field = make([]domain.Domain, 0, len(rawList)) - for _, item := range rawList { - item = strings.TrimSpace(item) - if item == "" { - continue - } +// ReadTTL reads a valid TTL value. +// +// According to [API documentation], the valid range is 1 (auto) and [60, 86400]. +// According to [DNS documentation], the valid range is "Auto" and [30, 86400]. +// We thus accept the union of both ranges---1 (auto) and [30, 86400]. +// +// [API documentation] https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record +// [DNS documentation] https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl +func ReadTTL(ppfmt pp.PP, key string, field *api.TTL) bool { + val := Getenv(key) + if val == "" { + ppfmt.Infof(pp.EmojiBullet, "Use default %s=%d", key, *field) + return true + } - item, err := domain.New(item) - if err != nil { - ppfmt.Warningf(pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", item.Describe(), err) - } + res, err := strconv.Atoi(val) + switch { + case err != nil: + ppfmt.Errorf(pp.EmojiUserError, "TTL (%q) is not a number: %v", val, err) + return false - *field = append(*field, item) + case res != 1 && (res < 30 || res > 86400): + ppfmt.Errorf(pp.EmojiUserError, "TTL (%d) should be 1 (auto) or between 30 and 86400", res) + return false } + *field = api.TTL(res) return true } +// 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, 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. diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 880610c6..92911f17 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -9,6 +9,7 @@ import ( "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" @@ -94,11 +95,9 @@ func TestReadString(t *testing.T) { } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(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) @@ -142,10 +141,8 @@ func TestReadQuiet(t *testing.T) { } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(t) - set(t, key, tc.set, tc.val) - + mockCtrl := gomock.NewController(t) mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { tc.prepareMockPP(mockPP) @@ -213,11 +210,9 @@ func TestReadBool(t *testing.T) { } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(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) @@ -275,23 +270,86 @@ func TestReadNonnegInt(t *testing.T) { } { 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.ReadNonnegInt(mockPP, key, &field) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.newField, field) + }) + } +} +//nolint:funlen,paralleltest // environment vars are global +func TestReadTTL(t *testing.T) { + key := keyPrefix + "TTL" + for name, tc := range map[string]struct { + set bool + val string + oldField api.TTL + newField api.TTL + ok bool + prepareMockPP func(*mocks.MockPP) + }{ + "empty": { + true, "", api.TTLAuto, api.TTLAuto, true, + func(m *mocks.MockPP) { + m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%d", key, api.TTLAuto) + }, + }, + "0": { + true, "0 ", api.TTLAuto, api.TTLAuto, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "TTL (%d) should be 1 (auto) or between 30 and 86400", 0) + }, + }, + "-1": { + true, " -1", api.TTLAuto, api.TTLAuto, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "TTL (%d) should be 1 (auto) or between 30 and 86400", -1) + }, + }, + "1": {true, " 1 ", api.TTLAuto, api.TTLAuto, true, nil}, + "20": { + true, " 20 ", api.TTLAuto, api.TTLAuto, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "TTL (%d) should be 1 (auto) or between 30 and 86400", 20) + }, + }, + "9999999": { + true, " 9999999 ", api.TTLAuto, api.TTLAuto, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "TTL (%d) should be 1 (auto) or between 30 and 86400", 9999999) + }, + }, + "words": { + true, " word ", api.TTLAuto, api.TTLAuto, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "TTL (%q) is not a number: %v", "word", gomock.Any()) + }, + }, + } { + 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.ReadNonnegInt(mockPP, key, &field) + ok := config.ReadTTL(mockPP, key, &field) require.Equal(t, tc.ok, ok) require.Equal(t, tc.newField, field) }) } } -//nolint:paralleltest // environment vars are global +//nolint:paralleltest,funlen // environment vars are global func TestReadDomains(t *testing.T) { key := keyPrefix + "DOMAINS" type ds = []domain.Domain @@ -330,18 +388,35 @@ func TestReadDomains(t *testing.T) { m.EXPECT().Warningf(pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", "*.xn--:d.org", gomock.Any()) //nolint:lll }, }, + "illformed3": { + true, "hi.org,(", + ds{}, + ds{}, + false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: unexpected token %q", "hi.org,(", "(") + }, + }, + "illformed4": { + true, ")", + ds{}, + ds{}, + false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: unexpected token %q", ")", ")") + }, + }, } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(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) @@ -499,12 +574,10 @@ func TestReadProvider(t *testing.T) { } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(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) @@ -557,11 +630,9 @@ func TestReadNonnegDuration(t *testing.T) { } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(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) @@ -617,11 +688,9 @@ func TestReadCron(t *testing.T) { } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(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) @@ -707,11 +776,9 @@ func TestReadHealthChecksURL(t *testing.T) { } { tc := tc t.Run(name, func(t *testing.T) { - mockCtrl := gomock.NewController(t) - set(t, key, tc.set, tc.val) - field := append([]mon{}, tc.oldField...) + mockCtrl := gomock.NewController(t) mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { tc.prepareMockPP(mockPP) diff --git a/internal/domain/domain.go b/internal/domain/domain.go index ea56b84f..d4095a57 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -39,8 +39,8 @@ func safelyToUnicode(ascii string) string { return unicode } -// toASCII normalizes a domain with best efforts, ignoring errors. -func toASCII(domain string) string { +// StringToASCII normalizes a domain with best efforts, ignoring errors. +func StringToASCII(domain string) string { normalized, _ := profileDroppingLeadingDots.ToASCII(domain) // Remove the final dot for consistency @@ -63,7 +63,7 @@ func New(domain string) (Domain, error) { case normalized == "*": return Wildcard(""), nil case strings.HasPrefix(normalized, "*."): - // redo the normalization after removing the offending "*" + // redo the normalization after removing the offending "*" to get the true error (if any) normalized, err := profileKeepingLeadingDots.ToASCII(strings.TrimPrefix(normalized, "*.")) return Wildcard(normalized), err default: diff --git a/internal/domain/template.go b/internal/domain/template.go deleted file mode 100644 index 2fd47f84..00000000 --- a/internal/domain/template.go +++ /dev/null @@ -1,74 +0,0 @@ -package domain - -import ( - "reflect" - "strings" - - jet "github.com/CloudyKit/jet/v6" - - "github.com/favonia/cloudflare-ddns/internal/pp" -) - -func hasSuffix(s, suffix string) bool { - return len(suffix) == 0 || (strings.HasSuffix(s, suffix) && (len(s) == len(suffix) || s[len(s)-len(suffix)-1] == '.')) -} - -func ParseTemplate(ppfmt pp.PP, tmpl string) (func(target Domain) (string, bool), bool) { - loader := jet.NewInMemLoader() - loader.Set("self", tmpl) - - set := jet.NewSet(loader) - - var targetASCII string - - set.AddGlobalFunc("inDomains", func(args jet.Arguments) reflect.Value { - for i := 0; i < args.NumOfArguments(); i++ { - rawDomain := args.Get(i) - - if rawDomain.Kind() != reflect.String { - ppfmt.Errorf(pp.EmojiUserError, "Value %v is not a string", rawDomain) - args.Panicf("Value %v is not a string", rawDomain) - } - - if targetASCII == toASCII(rawDomain.String()) { - return reflect.ValueOf(true) - } - } - return reflect.ValueOf(false) - }) - - set.AddGlobalFunc("hasSuffix", func(args jet.Arguments) reflect.Value { - for i := 0; i < args.NumOfArguments(); i++ { - rawSuffix := args.Get(i) - - if rawSuffix.Kind() != reflect.String { - ppfmt.Errorf(pp.EmojiUserError, "Value %v is not a string", rawSuffix) - args.Panicf("Value %v is not a string", rawSuffix) - } - - if hasSuffix(targetASCII, toASCII(rawSuffix.String())) { - return reflect.ValueOf(true) - } - } - return reflect.ValueOf(false) - }) - - t, err := set.GetTemplate("self") - if err != nil { - ppfmt.Errorf(pp.EmojiUserError, "Could not parse the template %q: %v", tmpl, err) - return nil, false - } - - exec := func(target Domain) (string, bool) { - targetASCII = target.DNSNameASCII() - - var output strings.Builder - if err = t.Execute(&output, jet.VarMap{}, nil); err != nil { - ppfmt.Errorf(pp.EmojiUserError, "Could not execute the template %q: %v", tmpl, err) - return "", false - } - return output.String(), true - } - - return exec, true -} diff --git a/internal/domain/template_test.go b/internal/domain/template_test.go deleted file mode 100644 index fa527663..00000000 --- a/internal/domain/template_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package domain_test - -import ( - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/require" - - "github.com/favonia/cloudflare-ddns/internal/domain" - "github.com/favonia/cloudflare-ddns/internal/mocks" - "github.com/favonia/cloudflare-ddns/internal/pp" -) - -//nolint:funlen -func TestParseTemplate(t *testing.T) { - t.Parallel() - type f = domain.FQDN - type w = domain.Wildcard - for name, tc := range map[string]struct { - tmpl string - ok1 bool - domain domain.Domain - ok2 bool - expected string - prepareMockPP func(m *mocks.MockPP) - }{ - "empty": {"", true, f(""), true, "", nil}, - "constant": {`{{ "string" }}`, true, f(""), true, "string", nil}, - "nospace": {`! {{- "string" -}} !`, true, f(""), true, "!string!", nil}, - "comments": {`{* *}`, true, f(""), true, "", nil}, - "variables": {`{{cool := "cool"}} {{len(cool)}}`, true, f(""), true, " 4", nil}, - "concat": {`{{"cool" + "string"}}`, true, f(""), true, "coolstring", nil}, - "inDomains/true": {`{{inDomains("a")}}`, true, f("a"), true, "true", nil}, - "inDomains/false": {`{{inDomains("a.a")}}`, true, f("a"), true, "false", nil}, - "inDomains/ill-formed": { - `{{inDomains(}}`, false, f(""), false, "", - func(m *mocks.MockPP) { - m.EXPECT().Errorf(pp.EmojiUserError, "Could not parse the template %q: %v", `{{inDomains(}}`, gomock.Any()) - }, - }, - "inDomains/invalid-argument": { - `{{inDomains(123)}}`, true, f(""), false, "", - func(m *mocks.MockPP) { - gomock.InOrder( - m.EXPECT().Errorf(pp.EmojiUserError, "Value %v is not a string", gomock.Any()), - m.EXPECT().Errorf(pp.EmojiUserError, "Could not execute the template %q: %v", `{{inDomains(123)}}`, gomock.Any()), - ) - }, - }, - "hasSuffix/true": {`{{hasSuffix("a")}}`, true, f("a.a"), true, "true", nil}, - "hasSuffix/false": {`{{hasSuffix("a.a")}}`, true, f("a"), true, "false", nil}, - "hasSuffix/invalid-argument": { - `{{hasSuffix(123)}}`, true, f(""), false, "", - func(m *mocks.MockPP) { - gomock.InOrder( - m.EXPECT().Errorf(pp.EmojiUserError, "Value %v is not a string", gomock.Any()), - m.EXPECT().Errorf(pp.EmojiUserError, "Could not execute the template %q: %v", `{{hasSuffix(123)}}`, gomock.Any()), - ) - }, - }, - } { - tc := tc - t.Run(name, func(t *testing.T) { - t.Parallel() - - mockCtrl := gomock.NewController(t) - mockPP := mocks.NewMockPP(mockCtrl) - if tc.prepareMockPP != nil { - tc.prepareMockPP(mockPP) - } - - parsed, ok1 := domain.ParseTemplate(mockPP, tc.tmpl) - require.Equal(t, ok1, tc.ok1) - if ok1 { - result, ok2 := parsed(tc.domain) - require.Equal(t, ok2, tc.ok2) - if ok2 { - require.Equal(t, result, tc.expected) - } - } - }) - } -} diff --git a/internal/domainexp/lexer.go b/internal/domainexp/lexer.go new file mode 100644 index 00000000..40162339 --- /dev/null +++ b/internal/domainexp/lexer.go @@ -0,0 +1,114 @@ +package domainexp + +import ( + "bufio" + "bytes" + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +var ( + ErrSingleAnd = fmt.Errorf(`use "&&" instead of "&"`) + ErrSingleOr = fmt.Errorf(`use "||" instead of "|"`) +) + +//nolint:funlen +func splitter(data []byte, atEOF bool) (int, []byte, error) { + reader := bytes.NewReader(data) + startIndex := 0 + + const ( + StateInit = iota + StateAnd0 // && + StateOr0 // || + StateOther // others + ) + state := StateInit + + returnToken := func() (int, []byte, error) { + endIndex := len(data) - reader.Len() + return endIndex, data[startIndex:endIndex], nil + } + + for reader.Len() > 0 { + ch, size, err := reader.ReadRune() + if err != nil { + return startIndex, nil, fmt.Errorf("reader.ReadRune: %w", err) + } + if ch == utf8.RuneError && size == 1 && reader.Len() == 0 && !atEOF { + // special case: the UTF-8 decoding failed, + // but maybe more bytes will help + break + } + + switch state { + case StateInit: + switch { + case unicode.IsSpace(ch): + startIndex += size + case strings.ContainsRune("(),!", ch): + return returnToken() + case ch == '&': + state = StateAnd0 + case ch == '|': + state = StateOr0 + default: + state = StateOther + } + case StateAnd0: + if ch != '&' { + return 0, nil, ErrSingleAnd + } + return returnToken() + case StateOr0: + if ch != '|' { + return 0, nil, ErrSingleOr + } + return returnToken() + case StateOther: + if unicode.IsSpace(ch) || strings.ContainsRune("(),!&|", ch) { + if err = reader.UnreadRune(); err != nil { + return startIndex, nil, fmt.Errorf("reader.UnreadRune: %w", err) + } + + return returnToken() + } + } + } + + if !atEOF { + return startIndex, nil, nil + } + + switch state { + case StateInit: + return startIndex, nil, nil + case StateAnd0: + return startIndex, nil, ErrSingleAnd + case StateOr0: + return startIndex, nil, ErrSingleOr + default: + return returnToken() + } +} + +func tokenize(ppfmt pp.PP, input string) ([]string, bool) { + scanner := bufio.NewScanner(strings.NewReader(input)) + scanner.Split(splitter) + + tokens := []string{} + + for scanner.Scan() { + tokens = append(tokens, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: %v", input, err) + return nil, false + } + return tokens, true +} diff --git a/internal/domainexp/parser.go b/internal/domainexp/parser.go new file mode 100644 index 00000000..4ccab0aa --- /dev/null +++ b/internal/domainexp/parser.go @@ -0,0 +1,258 @@ +package domainexp + +import ( + "strings" + + "github.com/favonia/cloudflare-ddns/internal/domain" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +func scanList(ppfmt pp.PP, input string, tokens []string) ([]string, []string) { + var list []string + readyForNext := true + for len(tokens) > 0 { + switch tokens[0] { + case ",": + readyForNext = true + case ")": + return list, tokens + case "(", "&&", "||", "!": + ppfmt.Errorf(pp.EmojiUserError, `Failed to parse %q: unexpected token %q`, input, tokens[0]) + return nil, nil + default: + if !readyForNext { + ppfmt.Warningf(pp.EmojiUserError, `Please insert a comma "," before %q`, tokens[0]) + } + list = append(list, tokens[0]) + readyForNext = false + } + + tokens = tokens[1:] + } + return list, tokens +} + +func scanASCIIDomainList(ppfmt pp.PP, input string, tokens []string) ([]string, []string) { + list, tokens := scanList(ppfmt, input, tokens) + domains := make([]string, 0, len(list)) + for _, raw := range list { + domains = append(domains, domain.StringToASCII(raw)) + } + return domains, tokens +} + +func scanDomainList(ppfmt pp.PP, input string, tokens []string) ([]domain.Domain, []string) { + list, tokens := scanList(ppfmt, input, tokens) + domains := make([]domain.Domain, 0, len(list)) + for _, raw := range list { + domain, err := domain.New(raw) + if err != nil { + ppfmt.Warningf(pp.EmojiUserError, + "Domain %q was added but it is ill-formed: %v", + domain.Describe(), err) + } + domains = append(domains, domain) + } + return domains, tokens +} + +//nolint:unparam +func scanConstants(_ppfmt pp.PP, _input string, tokens []string, wanted []string) (string, []string) { + if len(tokens) == 0 { + return "", nil + } + for _, wanted := range wanted { + if wanted == tokens[0] { + return tokens[0], tokens[1:] + } + } + return "", nil +} + +func scanMustConstant(ppfmt pp.PP, input string, tokens []string, wanted string) []string { + if len(tokens) == 0 { + ppfmt.Errorf(pp.EmojiUserError, `Failed to parse %q: wanted %q; reached end of string`, input, wanted) + return nil + } + if wanted == tokens[0] { + return tokens[1:] + } + ppfmt.Errorf(pp.EmojiUserError, `Failed to parse %q: wanted %q; got %q`, input, wanted, tokens[0]) + return nil +} + +type predicate = func(domain.Domain) bool + +func hasStrictSuffix(s, suffix string) bool { + return strings.HasSuffix(s, suffix) && (len(s) > len(suffix) && s[len(s)-len(suffix)-1] == '.') +} + +// scanAtomic mimics ParseBool, call scanFunction, and then check parenthesized expressions. +// +// --> true | false | | ! | ( ) +// +//nolint:funlen +func scanFactor(ppfmt pp.PP, input string, tokens []string) (predicate, []string) { + // fmt.Printf("scanFactor(tokens = %#v)\n", tokens) + + if _, newTokens := scanConstants(ppfmt, input, tokens, + []string{"1", "t", "T", "TRUE", "true", "True"}); newTokens != nil { + return func(_ domain.Domain) bool { return true }, newTokens + } + + if _, newTokens := scanConstants(ppfmt, input, tokens, + []string{"0", "f", "F", "FALSE", "false", "False"}); newTokens != nil { + return func(_ domain.Domain) bool { return false }, newTokens + } + + { + //nolint:nestif + if funName, newTokens := scanConstants(ppfmt, input, tokens, []string{"is", "sub"}); newTokens != nil { + newTokens = scanMustConstant(ppfmt, input, newTokens, "(") + if newTokens == nil { + return nil, nil + } + ASCIIDomains, newTokens := scanASCIIDomainList(ppfmt, input, newTokens) + if newTokens == nil { + return nil, nil + } + newTokens = scanMustConstant(ppfmt, input, newTokens, ")") + if newTokens == nil { + return nil, nil + } + + return map[string]predicate{ + "is": func(d domain.Domain) bool { + asciiD := d.DNSNameASCII() + for _, pat := range ASCIIDomains { + if pat == asciiD { + return true + } + } + return false + }, + "sub": func(d domain.Domain) bool { + asciiD := d.DNSNameASCII() + for _, pat := range ASCIIDomains { + if hasStrictSuffix(asciiD, pat) { + return true + } + } + return false + }, + }[funName], newTokens + } + } + + { + _, newTokens := scanConstants(ppfmt, input, tokens, []string{"!"}) + if newTokens != nil { + if pred, newTokens := scanFactor(ppfmt, input, newTokens); newTokens != nil { + return func(d domain.Domain) bool { return !(pred(d)) }, newTokens + } + return nil, nil + } + } + + { + _, newTokens := scanConstants(ppfmt, input, tokens, []string{"("}) + if newTokens != nil { + pred, newTokens := scanExpression(ppfmt, input, newTokens) + if newTokens == nil { + return nil, nil + } + newTokens = scanMustConstant(ppfmt, input, newTokens, ")") + if newTokens == nil { + return nil, nil + } + return pred, newTokens + } + } + + if len(tokens) == 0 { + ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: wanted a boolean expression; reached end of string", input) + } else { + ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: wanted a boolean expression; got %q", input, tokens[0]) + } + return nil, nil +} + +// scanTerm scans a term with this grammar: +// +// --> "&&" | +func scanTerm(ppfmt pp.PP, input string, tokens []string) (predicate, []string) { + // fmt.Printf("scanTerm(tokens = %#v)\n", tokens) + + pred1, tokens := scanFactor(ppfmt, input, tokens) + if tokens == nil { + return nil, nil + } + + _, newTokens := scanConstants(ppfmt, input, tokens, []string{"&&"}) + if newTokens == nil { + return pred1, tokens + } + + pred2, newTokens := scanTerm(ppfmt, input, newTokens) + if newTokens != nil { + return func(d domain.Domain) bool { return pred1(d) && pred2(d) }, newTokens + } + + return nil, nil +} + +// scanExpression scans an expression with this grammar: +// +// --> "||" | +func scanExpression(ppfmt pp.PP, input string, tokens []string) (predicate, []string) { + pred1, tokens := scanTerm(ppfmt, input, tokens) + if tokens == nil { + return nil, nil + } + + _, newTokens := scanConstants(ppfmt, input, tokens, []string{"||"}) + if newTokens == nil { + return pred1, tokens + } + + pred2, newTokens := scanExpression(ppfmt, input, newTokens) + if newTokens != nil { + return func(d domain.Domain) bool { return pred1(d) || pred2(d) }, newTokens + } + + return nil, nil +} + +func ParseList(ppfmt pp.PP, input string) ([]domain.Domain, bool) { + tokens, ok := tokenize(ppfmt, input) + if !ok { + return nil, false + } + + list, tokens := scanDomainList(ppfmt, input, tokens) + if tokens == nil { + return nil, false + } else if len(tokens) > 0 { + ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: unexpected token %q", input, tokens[0]) + return nil, false + } + + return list, true +} + +func ParseExpression(ppfmt pp.PP, input string) (predicate, bool) { + tokens, ok := tokenize(ppfmt, input) + if !ok { + return nil, false + } + + pred, tokens := scanExpression(ppfmt, input, tokens) + if tokens == nil { + return nil, false + } else if len(tokens) > 0 { + ppfmt.Errorf(pp.EmojiUserError, "Failed to parse %q: unexpected token %q", input, tokens[0]) + return nil, false + } + + return pred, true +} diff --git a/internal/domainexp/parser_test.go b/internal/domainexp/parser_test.go new file mode 100644 index 00000000..2a59b2ad --- /dev/null +++ b/internal/domainexp/parser_test.go @@ -0,0 +1,195 @@ +package domainexp_test + +import ( + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + "github.com/favonia/cloudflare-ddns/internal/domain" + "github.com/favonia/cloudflare-ddns/internal/domainexp" + "github.com/favonia/cloudflare-ddns/internal/mocks" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +func TestParseList(t *testing.T) { + t.Parallel() + type f = domain.FQDN + type w = domain.Wildcard + type ds = []domain.Domain + for name, tc := range map[string]struct { + input string + ok bool + expected ds + prepareMockPP func(m *mocks.MockPP) + }{ + "1": {"a", true, ds{f("a")}, nil}, + "2": {" a , b ", true, ds{f("a"), f("b")}, nil}, + "3": {" a , b ,,,,,, c ", true, ds{f("a"), f("b"), f("c")}, nil}, + "4": { + " a b c d ", true, + ds{f("a"), f("b"), f("c"), f("d")}, + func(m *mocks.MockPP) { + gomock.InOrder( + m.EXPECT().Warningf(pp.EmojiUserError, `Please insert a comma "," before %q`, "b"), + m.EXPECT().Warningf(pp.EmojiUserError, `Please insert a comma "," before %q`, "c"), + m.EXPECT().Warningf(pp.EmojiUserError, `Please insert a comma "," before %q`, "d"), + ) + }, + }, + "illformed/1": { + "&", false, nil, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "&", domainexp.ErrSingleAnd) + }, + }, + } { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + if tc.prepareMockPP != nil { + tc.prepareMockPP(mockPP) + } + + list, ok := domainexp.ParseList(mockPP, tc.input) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.expected, list) + }) + } +} + +//nolint:funlen +func TestParseExpression(t *testing.T) { + t.Parallel() + type f = domain.FQDN + type w = domain.Wildcard + for name, tc := range map[string]struct { + input string + ok bool + domain domain.Domain + expected bool + prepareMockPP func(m *mocks.MockPP) + }{ + "empty": { + "", false, nil, true, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse %q: wanted a boolean expression; reached end of string`, "") + }, + }, + "const/1": {"true", true, nil, true, nil}, + "const/2": {"f", true, nil, false, nil}, + "&&/1": {"t && 0", true, nil, false, nil}, + "&&/2": { + "t &&", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse %q: wanted a boolean expression; reached end of string`, "t &&") //nolint:lll + }, + }, + "&&/&/1": { + "true & true", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "true & true", domainexp.ErrSingleAnd) + }, + }, + "&&/&/2": { + "true &", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "true &", domainexp.ErrSingleAnd) + }, + }, + "||/1": {"F || 1", true, nil, true, nil}, + "||/2": { + "F ||", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse %q: wanted a boolean expression; reached end of string`, "F ||") //nolint:lll + }, + }, + "||/|/1": { + "false | false", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "false | false", domainexp.ErrSingleOr) + }, + }, + "||/|/2": { + "false |", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: %v", "false |", domainexp.ErrSingleOr) + }, + }, + "is/1": {"is(example.com)", true, f("example.com"), true, nil}, + "is/2": {"is(example.com)", true, f("sub.example.com"), false, nil}, + "is/3": {"is(example.org)", true, f("example.com"), false, nil}, + "is/wildcard/1": {"is(example.com)", true, w("example.com"), false, nil}, + "is/wildcard/2": {"is(*.example.com)", true, w("example.com"), true, nil}, + "is/wildcard/3": {"is(*.example.com)", true, f("example.com"), false, nil}, + "is/idn/1": {"is(β˜•.de)", true, f("xn--53h.de"), true, nil}, + "is/idn/2": {"is(Xn--53H.de)", true, f("xn--53h.de"), true, nil}, + "is/idn/3": {"is(*.Xn--53H.de)", true, w("xn--53h.de"), true, nil}, + "is/error/1": { + "is)", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse %q: wanted %q; got %q`, "is)", "(", ")") + }, + }, + "is/error/2": { + "is(&&", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse %q: unexpected token %q`, "is(&&", "&&") + }, + }, + "sub/1": {"sub(example.com)", true, f("example.com"), false, nil}, + "sub/2": {"sub(example.com)", true, w("example.com"), true, nil}, + "sub/3": {"sub(example.com)", true, f("sub.example.com"), true, nil}, + "sub/4": {"sub(example.com)", true, f("subexample.com"), false, nil}, + "sub/idn/1": {"sub(β˜•.de)", true, f("www.xn--53h.de"), true, nil}, + "sub/idn/2": {"sub(Xn--53H.de)", true, f("www.xn--53h.de"), true, nil}, + "sub/idn/3": {"sub(Xn--53H.de)", true, w("xn--53h.de"), true, nil}, + "not/1": {"!0", true, nil, true, nil}, + "not/2": {"!!!!!!!!!!!0", true, nil, true, nil}, + "not/3": { + "!(", false, nil, true, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: wanted a boolean expression; reached end of string", "!(") + }, + }, + "nested/1": {"((true)||(false))&&((false)||(true))", true, nil, true, nil}, + "nested/2": { + "((", false, nil, true, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: wanted a boolean expression; reached end of string", "((") + }, + }, + "nested/3": { + "(true", false, nil, true, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: wanted %q; reached end of string", "(true", ")") + }, + }, + "error/extra": { + "0 1", false, nil, false, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiUserError, "Failed to parse %q: unexpected token %q", "0 1", "1") + }, + }, + } { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + if tc.prepareMockPP != nil { + tc.prepareMockPP(mockPP) + } + + pred, ok := domainexp.ParseExpression(mockPP, tc.input) + require.Equal(t, tc.ok, ok) + if ok { + require.Equal(t, tc.expected, pred(tc.domain)) + } + }) + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index d6f27d68..73dcd674 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -4,7 +4,6 @@ import ( "context" "net/netip" - "github.com/favonia/cloudflare-ddns/internal/api" "github.com/favonia/cloudflare-ddns/internal/config" "github.com/favonia/cloudflare-ddns/internal/domain" "github.com/favonia/cloudflare-ddns/internal/ipnet" @@ -12,18 +11,6 @@ import ( "github.com/favonia/cloudflare-ddns/internal/setter" ) -func getTTL(ppfmt pp.PP, c *config.Config, domain domain.Domain) api.TTL { - if ttl, ok := c.TTL[domain]; ok { - return ttl - } - - ppfmt.Warningf(pp.EmojiImpossible, - "TTL[%s] not initialized; please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new", - domain.Describe(), - ) - return api.TTLAuto -} - func getProxied(ppfmt pp.PP, c *config.Config, domain domain.Domain) bool { if proxied, ok := c.Proxied[domain]; ok { return proxied @@ -43,8 +30,7 @@ func setIP(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter, ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout) defer cancel() - if !s.Set(ctx, ppfmt, domain, ipNet, ip, - getTTL(ppfmt, c, domain), + if !s.Set(ctx, ppfmt, domain, ipNet, ip, c.TTL, getProxied(ppfmt, c, domain)) { ok = false } diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 44280d68..649976f3 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -42,15 +42,12 @@ func TestUpdateIPs(t *testing.T) { provider4 := func(ppfmt pp.PP, m *mocks.MockProvider) { m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP4).Return(ip4) } provider6 := func(ppfmt pp.PP, m *mocks.MockProvider) { m.EXPECT().GetIP(gomock.Any(), ppfmt, ipnet.IP6).Return(ip6) } - type mockttl = map[domain.Domain]api.TTL - ttlAuto := mockttl{domain4: api.TTLAuto, domain6: api.TTLAuto} - type mockproxied = map[domain.Domain]bool proxiedNone := mockproxied{domain4: false, domain6: false} proxiedBoth := mockproxied{domain4: true, domain6: true} for name, tc := range map[string]struct { - ttl mockttl + ttl api.TTL proxied mockproxied ok bool MessageShouldDisplay map[ipnet.Type]bool @@ -59,10 +56,10 @@ func TestUpdateIPs(t *testing.T) { prepareMockSetter func(ppfmt pp.PP, m *mocks.MockSetter) }{ "none": { - ttlAuto, proxiedBoth, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, nil, mockproviders{}, nil, + api.TTLAuto, proxiedBoth, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, nil, mockproviders{}, nil, }, "ip4only": { - ttlAuto, + api.TTLAuto, proxiedNone, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -73,7 +70,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "ip4only/setfail": { - ttlAuto, + api.TTLAuto, proxiedBoth, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -84,7 +81,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "ip6only": { - ttlAuto, + api.TTLAuto, proxiedNone, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -95,7 +92,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "ip6only/setfail": { - ttlAuto, + api.TTLAuto, proxiedBoth, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -106,7 +103,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "both": { - ttlAuto, + api.TTLAuto, proxiedNone, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -120,7 +117,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "both/setfail1": { - ttlAuto, + api.TTLAuto, proxiedBoth, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -134,7 +131,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "both/setfail2": { - ttlAuto, + api.TTLAuto, proxiedNone, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -148,7 +145,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "ip4fails": { - ttlAuto, + api.TTLAuto, proxiedBoth, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -170,7 +167,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "ip6fails": { - ttlAuto, + api.TTLAuto, proxiedNone, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -194,7 +191,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "ip6fails/again": { - ttlAuto, + api.TTLAuto, proxiedBoth, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: false}, @@ -215,7 +212,7 @@ func TestUpdateIPs(t *testing.T) { }, }, "bothfail": { - ttlAuto, + api.TTLAuto, proxiedNone, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -240,17 +237,13 @@ func TestUpdateIPs(t *testing.T) { nil, }, "ip4only-proxied-nil": { - mockttl{}, + api.TTLAuto, mockproxied{}, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, func(m *mocks.MockPP) { gomock.InOrder( m.EXPECT().Infof(pp.EmojiInternet, "Detected the %s address: %v", "IPv4", ip4), - m.EXPECT().Warningf(pp.EmojiImpossible, - "TTL[%s] not initialized; please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new", - "ip4.hello", - ), m.EXPECT().Warningf(pp.EmojiImpossible, "Proxied[%s] not initialized; please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new", "ip4.hello", @@ -306,14 +299,11 @@ func TestClearIPs(t *testing.T) { type mockproviders = map[ipnet.Type]bool - type mockttl = map[domain.Domain]api.TTL - ttlAuto := mockttl{domain4: api.TTLAuto, domain6: api.TTLAuto} - type mockproxied = map[domain.Domain]bool proxiedNone := mockproxied{domain4: false, domain6: false} for name, tc := range map[string]struct { - ttl mockttl + ttl api.TTL proxied mockproxied ok bool MessageShouldDisplay map[ipnet.Type]bool @@ -322,7 +312,7 @@ func TestClearIPs(t *testing.T) { prepareMockSetter func(ppfmt pp.PP, m *mocks.MockSetter) }{ "none": { - ttlAuto, + api.TTLAuto, proxiedNone, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -331,7 +321,7 @@ func TestClearIPs(t *testing.T) { nil, }, "ip4only": { - ttlAuto, + api.TTLAuto, proxiedNone, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -342,7 +332,7 @@ func TestClearIPs(t *testing.T) { }, }, "ip4only/setfail": { - ttlAuto, + api.TTLAuto, proxiedNone, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -353,7 +343,7 @@ func TestClearIPs(t *testing.T) { }, }, "ip6only": { - ttlAuto, + api.TTLAuto, proxiedNone, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -364,7 +354,7 @@ func TestClearIPs(t *testing.T) { }, }, "ip6only/setfail": { - ttlAuto, + api.TTLAuto, proxiedNone, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -375,7 +365,7 @@ func TestClearIPs(t *testing.T) { }, }, "both": { - ttlAuto, + api.TTLAuto, proxiedNone, true, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -389,7 +379,7 @@ func TestClearIPs(t *testing.T) { }, }, "both/setfail1": { - ttlAuto, + api.TTLAuto, proxiedNone, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true}, @@ -403,7 +393,7 @@ func TestClearIPs(t *testing.T) { }, }, "both/setfail2": { - ttlAuto, + api.TTLAuto, proxiedNone, false, map[ipnet.Type]bool{ipnet.IP4: true, ipnet.IP6: true},