Skip to content

Commit

Permalink
feat: replace the template engine with an in-house parser (#233)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
favonia committed Oct 23, 2022
1 parent f216a7c commit 0b34720
Show file tree
Hide file tree
Showing 19 changed files with 788 additions and 545 deletions.
9 changes: 9 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 24 additions & 19 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 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.

Expand All @@ -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

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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)

> <details>
> <summary>🧪 Experimental support of templates (subject to changes):</summary>
> <summary>🧪 Experimental per-domain proxy settings (subject to changes):</summary>
>
> 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)`.
> </details>
</details>
Expand Down
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/api/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/cloudflare_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
Expand Down
11 changes: 2 additions & 9 deletions internal/api/ttl.go
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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]) })
}
96 changes: 11 additions & 85 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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:")
Expand Down Expand Up @@ -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) ||
Expand All @@ -317,32 +271,13 @@ 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.
//
//nolint:funlen
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{}

Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 0b34720

Please sign in to comment.