diff --git a/README.markdown b/README.markdown index 1d4bc894..847a67e8 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). +* ๐Ÿค The Docker images are small (less than 4 MB after compression). * ๐Ÿ” The Go runtime will re-use existing HTTP connections. * ๐Ÿ—ƒ๏ธ Cloudflare API responses are cached to reduce the API usage. @@ -55,6 +55,8 @@ By default, public IP addresses are obtained using the [Cloudflare debugging pag Parsing of Cron expressions. - [go-cache](https://github.com/patrickmn/go-cache):\ Essentially `map[string]any` with expiration times. + - [jet](https://github.com/CloudyKit/jet):\ + Fast and small template engines. - [mock](https://github.com/golang/mock) (for testing only):\ A comprehensive, semi-official framework for mocking. - [testify](https://github.com/stretchr/testify) (for testing only):\ @@ -348,19 +350,19 @@ In most cases, `CF_ACCOUNT_ID` is not needed. >
> ๐Ÿงช Experimental support of Go templates: > -> Both `PROXIED` and `TTL` can be [Go templates](https://pkg.go.dev/text/template) for per-domain settings. For example, `PROXIED={{not (suffix "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: -> - `domain(patterns ...string) bool` +> 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` > -> 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. -> - `suffix(patterns ...string) bool` +> 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` > > 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`. > > Some examples: -> - `TTL={{if suffix "b.c"}} 60 {{else if domain "d.e.f" "a.bb.c"}} 90 {{else}} 120 {{end}}` +> - `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={{and (suffix "b.c") (not (domain "a.b.c"))}}` +> - `PROXIED={{hasSuffix("b.c") && ! inDomains("a.b.c"))}}` > > Proxy the domain `b.c` and its descendants except for the domain `a.b.c`. >
diff --git a/go.mod b/go.mod index 3120b235..ed945d26 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/favonia/cloudflare-ddns go 1.19 require ( + github.com/CloudyKit/jet/v6 v6.1.0 github.com/cloudflare/cloudflare-go v0.50.0 github.com/golang/mock v1.6.0 github.com/patrickmn/go-cache v2.1.0+incompatible @@ -13,6 +14,7 @@ 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 df822200..823d202e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +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.50.0 h1:RS4tttMecD1rYCiMMfJeW8s9OEhCm85Y+70RJuOoxNA= github.com/cloudflare/cloudflare-go v0.50.0/go.mod h1:4+j2gGo6xyrFiYmpa2y4mNzu7pPPN42kyv1b2EqiZGQ= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b94d5a7b..8e279529 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -756,8 +756,8 @@ 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 suffix "b.c"}} 60 {{else if domain "d.e.f" "a.bb.c" }} 90 {{else}} 120 {{end}}`, - ProxiedTemplate: ` {{not (domain "a.bb.c")}} `, + 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")}} `, }, ok: true, expected: &config.Config{ //nolint:exhaustruct @@ -767,13 +767,13 @@ 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 suffix "b.c"}} 60 {{else if domain "d.e.f" "a.bb.c" }} 90 {{else}} 120 {{end}}`, + 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: ` {{not (domain "a.bb.c")}} `, + ProxiedTemplate: ` {{true && !inDomains("a.bb.c")}} `, Proxied: map[domain.Domain]bool{ domain.FQDN("a.b.c"): true, domain.FQDN("a.bb.c"): false, @@ -797,7 +797,7 @@ func TestNormalize(t *testing.T) { ipnet.IP6: {domain.FQDN("a.b.c"), domain.FQDN("a.bb.c"), domain.FQDN("a.d.e.f")}, }, TTLTemplate: `{{if}}`, - ProxiedTemplate: ` {{not (domain "a.b.c")}} `, + ProxiedTemplate: ` {{!inDomains("a.b.c")}} `, }, ok: false, expected: nil, @@ -806,7 +806,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, "%q is not a valid template: %v", "{{if}}", gomock.Any()), + m.EXPECT().Errorf(pp.EmojiUserError, "Could not parse the template %q: %v", "{{if}}", gomock.Any()), ) }, }, @@ -828,7 +828,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, "%q is not a valid template: %v", `{{range}}`, gomock.Any()), + m.EXPECT().Errorf(pp.EmojiUserError, "Could not parse the template %q: %v", `{{range}}`, gomock.Any()), ) }, }, @@ -841,7 +841,7 @@ func TestNormalize(t *testing.T) { ipnet.IP6: {domain.FQDN("a.b.c")}, }, TTLTemplate: `not a number`, - ProxiedTemplate: `{{not (domain "a.b.c")}}`, + ProxiedTemplate: `{{!inDomans("a.b.c")}}`, }, ok: false, expected: nil, @@ -862,8 +862,8 @@ func TestNormalize(t *testing.T) { Domains: map[ipnet.Type][]domain.Domain{ ipnet.IP6: {domain.FQDN("a.b.c")}, }, - TTLTemplate: `{{if (domain "a.b.c")}} 2 {{end}}`, - ProxiedTemplate: `{{not (domain "a.b.c")}}`, + TTLTemplate: `{{if inDomains("a.b.c")}} 2 {{end}}`, + ProxiedTemplate: `{{!inDomains("a.b.c")}}`, }, ok: false, expected: nil, @@ -907,7 +907,7 @@ func TestNormalize(t *testing.T) { ipnet.IP6: {domain.FQDN("a.b.c")}, }, TTLTemplate: `1`, - ProxiedTemplate: `{{domain 12345}}`, + ProxiedTemplate: `{{inDomains(12345)}}`, }, ok: false, expected: nil, @@ -916,7 +916,8 @@ 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, "Could not execute the template %q: %v", "{{domain 12345}}", gomock.Any()), //nolint:lll + 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 ) }, }, diff --git a/internal/domain/template.go b/internal/domain/template.go index c5f5a604..2fd47f84 100644 --- a/internal/domain/template.go +++ b/internal/domain/template.go @@ -1,8 +1,10 @@ package domain import ( + "reflect" "strings" - "text/template" + + jet "github.com/CloudyKit/jet/v6" "github.com/favonia/cloudflare-ddns/internal/pp" ) @@ -12,29 +14,48 @@ func hasSuffix(s, suffix string) bool { } 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 - funcMap := template.FuncMap{ - "domain": func(rawDomains ...string) bool { - for _, rawDomain := range rawDomains { - if targetASCII == toASCII(rawDomain) { - return true - } + + 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) } - return false - }, - "suffix": func(rawSuffixes ...string) bool { - for _, rawSuffix := range rawSuffixes { - if hasSuffix(targetASCII, toASCII(rawSuffix)) { - return true - } + + if targetASCII == toASCII(rawDomain.String()) { + return reflect.ValueOf(true) } - return false - }, - } + } + 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 := template.New("").Funcs(funcMap).Parse(tmpl) + t, err := set.GetTemplate("self") if err != nil { - ppfmt.Errorf(pp.EmojiUserError, "%q is not a valid template: %v", tmpl, err) + ppfmt.Errorf(pp.EmojiUserError, "Could not parse the template %q: %v", tmpl, err) return nil, false } @@ -42,7 +63,7 @@ func ParseTemplate(ppfmt pp.PP, tmpl string) (func(target Domain) (string, bool) targetASCII = target.DNSNameASCII() var output strings.Builder - if err = t.Execute(&output, nil); err != nil { + 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 } diff --git a/internal/domain/template_test.go b/internal/domain/template_test.go new file mode 100644 index 00000000..fa527663 --- /dev/null +++ b/internal/domain/template_test.go @@ -0,0 +1,83 @@ +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) + } + } + }) + } +}