Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
favonia committed Jan 29, 2025
1 parent 7212ad4 commit 9efa147
Show file tree
Hide file tree
Showing 8 changed files with 314 additions and 64 deletions.
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type Config struct {
Auth api.Auth
Provider map[ipnet.Type]provider.Provider
Domains map[ipnet.Type][]domain.Domain
IP6PrefixLen int
IP6HostID map[domain.Domain]ipnet.HostID
WAFLists []api.WAFList
UpdateCron cron.Schedule
UpdateOnStart bool
Expand Down Expand Up @@ -47,6 +49,8 @@ func Default() *Config {
ipnet.IP4: nil,
ipnet.IP6: nil,
},
IP6PrefixLen: 128,
IP6HostID: map[domain.Domain]ipnet.HostID{},
WAFLists: nil,
UpdateCron: cron.MustNew("@every 5m"),
UpdateOnStart: true,
Expand Down
3 changes: 2 additions & 1 deletion internal/config/config_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool {

if !ReadAuth(ppfmt, &c.Auth) ||
!ReadProviderMap(ppfmt, &c.Provider) ||
!ReadDomainMap(ppfmt, &c.Domains) ||
!ReadIP6PrefixLen(ppfmt, "IP6_PREFIX_LEN", &c.IP6PrefixLen) ||
!ReadDomainMap(ppfmt, &c.Domains, &c.IP6HostID) ||
!ReadAndAppendWAFListNames(ppfmt, "WAF_LISTS", &c.WAFLists) ||
!ReadCron(ppfmt, "UPDATE_CRON", &c.UpdateCron) ||
!ReadBool(ppfmt, "UPDATE_ON_START", &c.UpdateOnStart) ||
Expand Down
28 changes: 28 additions & 0 deletions internal/config/env_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,34 @@ func ReadTTL(ppfmt pp.PP, key string, field *api.TTL) bool {
}
}

// ReadIP6PrefixLen reads a valid length of IPv6 prefixes (1 to 128).
func ReadIP6PrefixLen(ppfmt pp.PP, key string, field *int) bool {
val := Getenv(key)
if val == "" {
ppfmt.Infof(pp.EmojiBullet, "Use default %s=%d", key, *field)
return true
}

res, err := strconv.Atoi(val)
switch {
case err != nil:
ppfmt.Noticef(pp.EmojiUserError, "%s (%q) is not a number: %v", key, val, err)
return false

case res < 0 || res > 128:
ppfmt.Noticef(pp.EmojiUserError, "%s (%d) should be in the range 0 to 128 (inclusive)", key, res)
return false

case res > 64:
ppfmt.Noticef(pp.EmojiUserWarning, "%s (%d) is usually less than 64 for IPv6 prefix delegation", key, res)
return false

default:
*field = res
return true
}
}

// ReadNonnegDuration reads an environment variable and parses it as a time duration.
func ReadNonnegDuration(ppfmt pp.PP, key string, field *time.Duration) bool {
val := Getenv(key)
Expand Down
64 changes: 57 additions & 7 deletions internal/config/env_domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ import (

// 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, key, Getenv(key)); ok {
if list, ok := domainexp.ParseDomainList(ppfmt, key, Getenv(key)); ok {
*field = list
return true
}
return false
}

// ReadDomainHostIDs reads an environment variable as a comma-separated list of domains.
func ReadDomainHostIDs(ppfmt pp.PP, key string, field *[]domainexp.DomainHostID) bool {
if list, ok := domainexp.ParseDomainHostIDList(ppfmt, key, Getenv(key)); ok {
*field = list
return true
}
Expand All @@ -25,24 +34,65 @@ func deduplicate(list []domain.Domain) []domain.Domain {
return slices.Compact(list)
}

func processDomainHostIDMap(ppfmt pp.PP,
hostID map[domain.Domain]ipnet.HostID,
domainHostIDs []domainexp.DomainHostID,
) ([]domain.Domain, bool) {
domains := make([]domain.Domain, 0, len(domainHostIDs))
for _, dh := range domainHostIDs {
if dh.HostID == nil {
continue
}

if val, ok := hostID[dh.Domain]; ok && val != dh.HostID {
ppfmt.Noticef(pp.EmojiUserError,
"Domain %q is associated with inconsistent host IDs %s and %s",
dh.Domain, val, dh.HostID,
)
return nil, false
}
domains = append(domains, dh.Domain)
}
return domains, true
}

// ReadDomainMap reads environment variables DOMAINS, IP4_DOMAINS, and IP6_DOMAINS
// and consolidate the domains into a map.
func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]domain.Domain) bool {
var domains, ip4Domains, ip6Domains []domain.Domain

if !ReadDomains(ppfmt, "DOMAINS", &domains) ||
func ReadDomainMap(ppfmt pp.PP,
fieldDomains *map[ipnet.Type][]domain.Domain,
fieldHostID *map[domain.Domain]ipnet.HostID,
) bool {
var (
domainHostIDs []domainexp.DomainHostID
ip4Domains []domain.Domain
ip6DomainHostIDs []domainexp.DomainHostID
)
if !ReadDomainHostIDs(ppfmt, "DOMAINS", &domainHostIDs) ||
!ReadDomains(ppfmt, "IP4_DOMAINS", &ip4Domains) ||
!ReadDomains(ppfmt, "IP6_DOMAINS", &ip6Domains) {
!ReadDomainHostIDs(ppfmt, "IP6_DOMAINS", &ip6DomainHostIDs) {
return false
}

hostID := map[domain.Domain]ipnet.HostID{}

domains, ok := processDomainHostIDMap(ppfmt, hostID, domainHostIDs)
if !ok {
return false
}

ip6Domains, ok := processDomainHostIDMap(ppfmt, hostID, ip6DomainHostIDs)
if !ok {
return false
}

ip4Domains = deduplicate(append(ip4Domains, domains...))
ip6Domains = deduplicate(append(ip6Domains, domains...))

*field = map[ipnet.Type][]domain.Domain{
*fieldDomains = map[ipnet.Type][]domain.Domain{
ipnet.IP4: ip4Domains,
ipnet.IP6: ip6Domains,
}
*fieldHostID = hostID

return true
}
12 changes: 12 additions & 0 deletions internal/domainexp/host.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package domainexp

import (
"github.com/favonia/cloudflare-ddns/internal/domain"
"github.com/favonia/cloudflare-ddns/internal/ipnet"
)

// DomainHostID is a domain with an (optional) host ID.
type DomainHostID struct {
domain.Domain
ipnet.HostID
}
106 changes: 58 additions & 48 deletions internal/domainexp/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package domainexp

import (
"errors"
"net/netip"
"strings"

"github.com/favonia/cloudflare-ddns/internal/domain"
Expand Down Expand Up @@ -109,40 +108,52 @@ func scanASCIIDomainList(ppfmt pp.PP, key string, input string, tokens []string)
return domains, tokens
}

// DomainWithHostID is a domain with an (optional) host ID.
type DomainWithHostID struct {
domain.Domain
HostID netip.Addr
func parseDomain(ppfmt pp.PP, key string, input string, s string) (domain.Domain, bool) {
d, err := domain.New(s)
if err != nil {
if errors.Is(err, domain.ErrNotFQDN) {
ppfmt.Noticef(pp.EmojiUserError,
`%s (%q) contains a domain %q that is probably not fully qualified; a fully qualified domain name (FQDN) would look like "*.example.org" or "sub.example.org"`, //nolint:lll
key, input, d.Describe())
return nil, false
}
ppfmt.Noticef(pp.EmojiUserError,
"%s (%q) contains an ill-formed domain %q: %v",
key, input, d.Describe(), err)
return nil, false
}
return d, true
}

func scanDomainList(ppfmt pp.PP, key string, input string, tokens []string) ([]domain.Domain, []string) {
list, tokens := scanList(ppfmt, key, input, tokens)
domains := make([]domain.Domain, 0, len(list))
for _, raw := range list {
d, ok := parseDomain(ppfmt, key, input, raw)
if !ok {
return nil, nil
}
domains = append(domains, d)
}
return domains, tokens
}

func scanDomainList(ppfmt pp.PP, key string, input string, tokens []string) ([]DomainWithHostID, []string) {
func scanDomainHostIDList(ppfmt pp.PP, key string, input string, tokens []string) ([]DomainHostID, []string) {
list, tokens := scanTaggedList(ppfmt, key, input, tokens)
domains := make([]DomainWithHostID, 0, len(list))
domains := make([]DomainHostID, 0, len(list))
for _, raw := range list {
d, err := domain.New(raw.Element)
d, ok := parseDomain(ppfmt, key, input, raw.Element)
if !ok {
return nil, nil
}
h, err := ipnet.ParseHost(raw.Tag)

Check failure on line 149 in internal/domainexp/parser.go

View workflow job for this annotation

GitHub Actions / Lint

not enough arguments in call to ipnet.ParseHost

Check failure on line 149 in internal/domainexp/parser.go

View workflow job for this annotation

GitHub Actions / Lint

not enough arguments in call to ipnet.ParseHost

Check failure on line 149 in internal/domainexp/parser.go

View workflow job for this annotation

GitHub Actions / Lint

not enough arguments in call to ipnet.ParseHost

Check failure on line 149 in internal/domainexp/parser.go

View workflow job for this annotation

GitHub Actions / Lint

not enough arguments in call to ipnet.ParseHost

Check failure on line 149 in internal/domainexp/parser.go

View workflow job for this annotation

GitHub Actions / Test

not enough arguments in call to ipnet.ParseHost

Check failure on line 149 in internal/domainexp/parser.go

View workflow job for this annotation

GitHub Actions / Fuzz

not enough arguments in call to ipnet.ParseHost
if err != nil {
if errors.Is(err, domain.ErrNotFQDN) {
ppfmt.Noticef(pp.EmojiUserError,
`%s (%q) contains a domain %q that is probably not fully qualified; a fully qualified domain name (FQDN) would look like "*.example.org" or "sub.example.org"`, //nolint:lll
key, input, d.Describe())
return nil, nil
}
ppfmt.Noticef(pp.EmojiUserError,
"%s (%q) contains an ill-formed domain %q: %v",
key, input, d.Describe(), err)
"%s (%q) contains an ill-formed host ID %q: %v",
key, input, raw.Tag, err)
return nil, nil
}
h := netip.Addr{}
if raw.Tag != "" {
h, err = ipnet.ParseHost(raw.Tag)
if err != nil {
ppfmt.Noticef(pp.EmojiUserError,
"%s (%q) contains an ill-formed host ID %q: %v",
key, input, raw.Tag, err)
return nil, nil
}
}
domains = append(domains, DomainWithHostID{Domain: d, HostID: h})
domains = append(domains, DomainHostID{Domain: d, HostID: h})
}
return domains, tokens
}
Expand Down Expand Up @@ -312,22 +323,34 @@ func scanExpression(ppfmt pp.PP, key string, input string, tokens []string) (pre
return nil, nil
}

// ParseList parses a list of comma-separated domains. Internationalized domain names are fully supported.
func ParseList(ppfmt pp.PP, key string, input string) ([]DomainWithHostID, bool) {
// Parse takes a scanner and return the result
func Parse[T any](ppfmt pp.PP, key string, input string, scan func(pp.PP, string, string, []string) (T, []string)) (T, bool) {
var zero T

tokens, ok := tokenize(ppfmt, key, input)
if !ok {
return nil, false
return zero, false
}

list, tokens := scanDomainList(ppfmt, key, input, tokens)
result, tokens := scan(ppfmt, key, input, tokens)
if tokens == nil {
return nil, false
return zero, false
} else if len(tokens) > 0 {
ppfmt.Noticef(pp.EmojiUserError, `%s (%q) has unexpected token %q`, key, input, tokens[0])
return nil, false
return zero, false
}

return list, true
return result, true
}

// ParseDomainHostIDList parses a list of comma-separated domains. Internationalized domain names are fully supported.
func ParseDomainHostIDList(ppfmt pp.PP, key string, input string) ([]DomainHostID, bool) {
return Parse(ppfmt, key, input, scanDomainHostIDList)
}

// ParseDomainList parses a list of comma-separated domains. Internationalized domain names are fully supported.
func ParseDomainList(ppfmt pp.PP, key string, input string) ([]domain.Domain, bool) {
return Parse(ppfmt, key, input, scanDomainList)
}

// ParseExpression parses a boolean expression containing domains. Internationalized domain names are fully supported.
Expand All @@ -345,18 +368,5 @@ func ParseList(ppfmt pp.PP, key string, input string) ([]DomainWithHostID, bool)
//
// One can use parentheses to group expressions, such as !(is(hello.org) && (is(hello.io) || is(hello.me))).
func ParseExpression(ppfmt pp.PP, key string, input string) (predicate, bool) {
tokens, ok := tokenize(ppfmt, key, input)
if !ok {
return nil, false
}

pred, tokens := scanExpression(ppfmt, key, input, tokens)
if tokens == nil {
return nil, false
} else if len(tokens) > 0 {
ppfmt.Noticef(pp.EmojiUserError, "%s (%q) has unexpected token %q", key, input, tokens[0])
return nil, false
}

return pred, true
return Parse(ppfmt, key, input, scanExpression)
}
16 changes: 8 additions & 8 deletions internal/domainexp/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,27 @@ import (
"github.com/favonia/cloudflare-ddns/internal/pp"
)

func TestParseList(t *testing.T) {
func TestParseDomainHostIDList(t *testing.T) {
t.Parallel()
key := "key"
noHost := netip.Addr{}
type f = domain.FQDN
type w = domain.Wildcard
type ds = []domainexp.DomainWithHostID
type ds = []domainexp.DomainHostID
for name, tc := range map[string]struct {
input string
ok bool
expected ds
prepareMockPP func(m *mocks.MockPP)
}{
"a.a": {"a.a", true, ds{{f("a.a"), noHost}}, nil},
"a.a,a.b": {" a.a , a.b ", true, ds{{f("a.a"), noHost}, {f("a.b"), noHost}}, nil},
"a.a,a.b,a.c": {" a.a , a.b ,,,,,, a.c ", true, ds{{f("a.a"), noHost}, {f("a.b"), noHost}, {f("a.c"), noHost}}, nil},
"wildcard": {" a.a , a.b ,,,,,, *.c ", true, ds{{f("a.a"), noHost}, {f("a.b"), noHost}, {w("c"), noHost}}, nil},
"a.a": {"a.a", true, ds{{f("a.a"), nil}}, nil},
"a.a,a.b": {" a.a , a.b ", true, ds{{f("a.a"), nil}, {f("a.b"), nil}}, nil},
"a.a,a.b,a.c": {" a.a , a.b ,,,,,, a.c ", true, ds{{f("a.a"), nil}, {f("a.b"), nil}, {f("a.c"), nil}}, nil},
"wildcard": {" a.a , a.b ,,,,,, *.c ", true, ds{{f("a.a"), nil}, {f("a.b"), nil}, {w("c"), nil}}, nil},
"hosts": {
" a.a [ :: ],,,,,, *.c [aa:bb:cc:dd:ee:ff] ", true,
ds{
{f("a.a"), netip.MustParseAddr("::")},
{f("a.a"), },
{w("c"), netip.MustParseAddr("::a8bb:ccff:fedd:eeff")},
},
nil,
Expand Down Expand Up @@ -67,7 +67,7 @@ func TestParseList(t *testing.T) {
tc.prepareMockPP(mockPP)
}

list, ok := domainexp.ParseList(mockPP, key, tc.input)
list, ok := domainexp.ParseDomainHostIDList(mockPP, key, tc.input)
require.Equal(t, tc.ok, ok)
require.Equal(t, tc.expected, list)
})
Expand Down
Loading

0 comments on commit 9efa147

Please sign in to comment.