Skip to content

Commit

Permalink
feat: replace text/template with CloudyKit/jet (#222)
Browse files Browse the repository at this point in the history
  • Loading branch information
favonia committed Sep 27, 2022
1 parent 4a1cc0f commit 21301de
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 39 deletions.
16 changes: 9 additions & 7 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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):\
Expand Down Expand Up @@ -348,19 +350,19 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
> <details>
> <summary>🧪 Experimental support of Go templates:</summary>
>
> 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`.
> </details>
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
25 changes: 13 additions & 12 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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()),
)
},
},
Expand All @@ -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()),
)
},
},
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
)
},
},
Expand Down
61 changes: 41 additions & 20 deletions internal/domain/template.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package domain

import (
"reflect"
"strings"
"text/template"

jet "github.com/CloudyKit/jet/v6"

"github.com/favonia/cloudflare-ddns/internal/pp"
)
Expand All @@ -12,37 +14,56 @@ 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
}

exec := 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
}
Expand Down
83 changes: 83 additions & 0 deletions internal/domain/template_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}

0 comments on commit 21301de

Please sign in to comment.