Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: replace the template engine with an in-house parser #233

Merged
merged 10 commits into from
Oct 23, 2022
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
Loading