From feafcf47a7b1bad8be44235d04c3804babb67c51 Mon Sep 17 00:00:00 2001 From: favonia Date: Mon, 18 Oct 2021 02:40:26 -0500 Subject: [PATCH] feat(api): support wildcard domains (#94) * feat(api): support wildcard domains * fix(api): revert the complicated domain name splitting --- README.markdown | 7 +- internal/api/base.go | 20 +++- internal/api/cloudflare.go | 40 ++++---- internal/api/cloudflare_test.go | 147 +++++++++++++++++++++------- internal/api/domain.go | 57 +++++++++++ internal/api/domain_test.go | 143 +++++++++++++++++++++++++++ internal/api/fqdn.go | 58 ++--------- internal/api/fqdn_test.go | 167 ++++++++------------------------ internal/api/wildcard.go | 54 +++++++++++ internal/api/wildcard_test.go | 97 +++++++++++++++++++ internal/config/config.go | 16 +-- internal/config/config_test.go | 32 +++--- internal/config/env.go | 6 +- internal/config/env_test.go | 30 ++++-- internal/updator/updator.go | 2 +- 15 files changed, 602 insertions(+), 274 deletions(-) create mode 100644 internal/api/domain.go create mode 100644 internal/api/domain_test.go create mode 100644 internal/api/wildcard.go create mode 100644 internal/api/wildcard_test.go diff --git a/README.markdown b/README.markdown index 79fa6b8e..ca76bc0a 100644 --- a/README.markdown +++ b/README.markdown @@ -25,6 +25,7 @@ A small and fast DDNS updater for Cloudflare. * Ability to update multiple domains across different zones. * Ability to enable or disable IPv4 and IPv6 individually. * Support of internationalized domain names. +* Support of wildcard domain names (_e.g._, `*.example.org`). * Ability to remove stale records or choose to remove records on exit/stop. * Ability to obtain IP addresses from Cloudflare, ipify, or local network interfaces. * Support of timezone and Cron expressions. @@ -273,10 +274,10 @@ In most cases, `CF_ACCOUNT_ID` is not needed. | Name | Valid Values | Meaning | Required? | Default Value | | ---- | ------------ | ------- | --------- | ------------- | -| `DOMAINS` | Comma-separated fully qualified domain names | The domains this tool should manage | (See below) | N/A -| `IP4_DOMAINS` | Comma-separated fully qualified domain names | The domains this tool should manage for `A` records | (See below) | N/A +| `DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains this tool should manage | (See below) | N/A +| `IP4_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains this tool should manage for `A` records | (See below) | N/A | `IP4_POLICY` | `cloudflare`, `ipify`, `local`, and `unmanaged` | How to detect IPv4 addresses. (See below) | No | `cloudflare` -| `IP6_DOMAINS` | Comma-separated fully qualified domain names | The domains this tool should manage for `AAAA` records | (See below) | N/A +| `IP6_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains this tool should manage for `AAAA` records | (See below) | N/A | `IP6_POLICY` | `cloudflare`, `ipify`, `local`, and `unmanaged` | How to detect IPv6 addresses. (See below) | No | `cloudflare` >
diff --git a/internal/api/base.go b/internal/api/base.go index 1fcf6cc1..74012e5c 100644 --- a/internal/api/base.go +++ b/internal/api/base.go @@ -8,13 +8,25 @@ import ( "github.com/favonia/cloudflare-ddns/internal/pp" ) +type DomainSplitter interface { + IsValid() bool + ZoneNameASCII() string + Next() +} + +type Domain interface { + DNSNameASCII() string + Describe() string + Split() DomainSplitter +} + //go:generate mockgen -destination=../mocks/mock_api.go -package=mocks . Handle type Handle interface { - ListRecords(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type) (map[string]net.IP, bool) - DeleteRecord(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type, id string) bool - UpdateRecord(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type, id string, ip net.IP) bool - CreateRecord(ctx context.Context, ppfmt pp.PP, domain FQDN, ipNet ipnet.Type, + ListRecords(ctx context.Context, ppfmt pp.PP, domain Domain, ipNet ipnet.Type) (map[string]net.IP, bool) + DeleteRecord(ctx context.Context, ppfmt pp.PP, domain Domain, ipNet ipnet.Type, id string) bool + UpdateRecord(ctx context.Context, ppfmt pp.PP, domain Domain, ipNet ipnet.Type, id string, ip net.IP) bool + CreateRecord(ctx context.Context, ppfmt pp.PP, domain Domain, ipNet ipnet.Type, ip net.IP, ttl TTL, proxied bool) (string, bool) FlushCache() } diff --git a/internal/api/cloudflare.go b/internal/api/cloudflare.go index cf82ad74..ea68fc54 100644 --- a/internal/api/cloudflare.go +++ b/internal/api/cloudflare.go @@ -102,14 +102,14 @@ func (h *CloudflareHandle) ActiveZones(ctx context.Context, ppfmt pp.PP, name st return ids, true } -func (h *CloudflareHandle) ZoneOfDomain(ctx context.Context, ppfmt pp.PP, domain FQDN) (string, bool) { - if id, found := h.cache.zoneOfDomain.Get(domain.ToASCII()); found { +func (h *CloudflareHandle) ZoneOfDomain(ctx context.Context, ppfmt pp.PP, domain Domain) (string, bool) { + if id, found := h.cache.zoneOfDomain.Get(domain.DNSNameASCII()); found { return id.(string), true } zoneSearch: - for s := NewFQDNSplitter(domain); s.IsValid(); s.Next() { - zoneName := s.Suffix() + for s := domain.Split(); s.IsValid(); s.Next() { + zoneName := s.ZoneNameASCII() zones, ok := h.ActiveZones(ctx, ppfmt, zoneName) if !ok { return "", false @@ -119,7 +119,7 @@ zoneSearch: case 0: // len(zones) == 0 continue zoneSearch case 1: // len(zones) == 1 - h.cache.zoneOfDomain.SetDefault(domain.ToASCII(), zones[0]) + h.cache.zoneOfDomain.SetDefault(domain.DNSNameASCII(), zones[0]) return zones[0], true default: // len(zones) > 1 ppfmt.Warningf(pp.EmojiImpossible, @@ -133,8 +133,8 @@ zoneSearch: } func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP, - domain FQDN, ipNet ipnet.Type) (map[string]net.IP, bool) { - if rmap, found := h.cache.listRecords[ipNet].Get(domain.ToASCII()); found { + domain Domain, ipNet ipnet.Type) (map[string]net.IP, bool) { + if rmap, found := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); found { return rmap.(map[string]net.IP), true } @@ -145,7 +145,7 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP, //nolint:exhaustivestruct // Other fields are intentionally unspecified rs, err := h.cf.DNSRecords(ctx, zone, cloudflare.DNSRecord{ - Name: domain.ToASCII(), + Name: domain.DNSNameASCII(), Type: ipNet.RecordType(), }) if err != nil { @@ -158,13 +158,13 @@ func (h *CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP, rmap[rs[i].ID] = net.ParseIP(rs[i].Content) } - h.cache.listRecords[ipNet].SetDefault(domain.ToASCII(), rmap) + h.cache.listRecords[ipNet].SetDefault(domain.DNSNameASCII(), rmap) return rmap, true } func (h *CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP, - domain FQDN, ipNet ipnet.Type, id string) bool { + domain Domain, ipNet ipnet.Type, id string) bool { zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain) if !ok { return false @@ -174,12 +174,12 @@ func (h *CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP, ppfmt.Warningf(pp.EmojiError, "Failed to delete a stale %s record of %q (ID: %s): %v", ipNet.RecordType(), domain.Describe(), id, err) - h.cache.listRecords[ipNet].Delete(domain.ToASCII()) + h.cache.listRecords[ipNet].Delete(domain.DNSNameASCII()) return false } - if rmap, found := h.cache.listRecords[ipNet].Get(domain.ToASCII()); found { + if rmap, found := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); found { delete(rmap.(map[string]net.IP), id) } @@ -187,7 +187,7 @@ func (h *CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP, } func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP, - domain FQDN, ipNet ipnet.Type, id string, ip net.IP) bool { + domain Domain, ipNet ipnet.Type, id string, ip net.IP) bool { zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain) if !ok { return false @@ -195,7 +195,7 @@ func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP, //nolint:exhaustivestruct // Other fields are intentionally omitted payload := cloudflare.DNSRecord{ - Name: domain.ToASCII(), + Name: domain.DNSNameASCII(), Type: ipNet.RecordType(), Content: ip.String(), } @@ -204,12 +204,12 @@ func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP, ppfmt.Warningf(pp.EmojiError, "Failed to update a stale %s record of %q (ID: %s): %v", ipNet.RecordType(), domain.Describe(), id, err) - h.cache.listRecords[ipNet].Delete(domain.ToASCII()) + h.cache.listRecords[ipNet].Delete(domain.DNSNameASCII()) return false } - if rmap, found := h.cache.listRecords[ipNet].Get(domain.ToASCII()); found { + if rmap, found := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); found { rmap.(map[string]net.IP)[id] = ip } @@ -217,7 +217,7 @@ func (h *CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP, } func (h *CloudflareHandle) CreateRecord(ctx context.Context, ppfmt pp.PP, - domain FQDN, ipNet ipnet.Type, ip net.IP, ttl TTL, proxied bool) (string, bool) { + domain Domain, ipNet ipnet.Type, ip net.IP, ttl TTL, proxied bool) (string, bool) { zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain) if !ok { return "", false @@ -225,7 +225,7 @@ func (h *CloudflareHandle) CreateRecord(ctx context.Context, ppfmt pp.PP, //nolint:exhaustivestruct // Other fields are intentionally omitted payload := cloudflare.DNSRecord{ - Name: domain.ToASCII(), + Name: domain.DNSNameASCII(), Type: ipNet.RecordType(), Content: ip.String(), TTL: ttl.Int(), @@ -237,12 +237,12 @@ func (h *CloudflareHandle) CreateRecord(ctx context.Context, ppfmt pp.PP, ppfmt.Warningf(pp.EmojiError, "Failed to add a new %s record of %q: %v", ipNet.RecordType(), domain.Describe(), err) - h.cache.listRecords[ipNet].Delete(domain.ToASCII()) + h.cache.listRecords[ipNet].Delete(domain.DNSNameASCII()) return "", false } - if rmap, found := h.cache.listRecords[ipNet].Get(domain.ToASCII()); found { + if rmap, found := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); found { rmap.(map[string]net.IP)[res.Result.ID] = ip } diff --git a/internal/api/cloudflare_test.go b/internal/api/cloudflare_test.go index 805e0f6c..2d98c356 100644 --- a/internal/api/cloudflare_test.go +++ b/internal/api/cloudflare_test.go @@ -340,25 +340,34 @@ func TestZoneOfDomain(t *testing.T) { for name, tc := range map[string]struct { zone string - domain api.FQDN + domain api.Domain numZones map[string]int accessCount int expected string ok bool prepareMockPP func(*mocks.MockPP) }{ - "root": {"test.org", "test.org", map[string]int{"test.org": 1}, 1, mockID("test.org", 0), true, nil}, - "one": {"test.org", "sub.test.org", map[string]int{"test.org": 1}, 2, mockID("test.org", 0), true, nil}, + "root": {"test.org", api.FQDN("test.org"), map[string]int{"test.org": 1}, 1, mockID("test.org", 0), true, nil}, + "wildcard": {"test.org", api.Wildcard("test.org"), map[string]int{"test.org": 1}, 1, mockID("test.org", 0), true, nil}, + "one": {"test.org", api.FQDN("sub.test.org"), map[string]int{"test.org": 1}, 2, mockID("test.org", 0), true, nil}, "none": { - "test.org", "sub.test.org", + "test.org", api.FQDN("sub.test.org"), map[string]int{}, 3, "", false, func(m *mocks.MockPP) { m.EXPECT().Warningf(pp.EmojiError, "Failed to find the zone of %q", "sub.test.org") }, }, + "none/wildcard": { + "test.org", api.Wildcard("test.org"), + map[string]int{}, + 2, "", false, + func(m *mocks.MockPP) { + m.EXPECT().Warningf(pp.EmojiError, "Failed to find the zone of %q", "*.test.org") + }, + }, "multiple": { - "test.org", "sub.test.org", + "test.org", api.FQDN("sub.test.org"), map[string]int{"test.org": 2}, 2, "", false, func(m *mocks.MockPP) { @@ -369,6 +378,18 @@ func TestZoneOfDomain(t *testing.T) { ) }, }, + "multiple/wildcard": { + "test.org", api.Wildcard("test.org"), + map[string]int{"test.org": 2}, + 1, "", false, + func(m *mocks.MockPP) { + m.EXPECT().Warningf( + pp.EmojiImpossible, + "Found multiple active zones named %q. Specifying CF_ACCOUNT_ID might help", + "test.org", + ) + }, + }, } { tc := tc t.Run(name, func(t *testing.T) { @@ -416,29 +437,28 @@ func TestZoneOfDomainInvalid(t *testing.T) { "sub.test.org", gomock.Any(), ) - zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), mockPP, "sub.test.org") + zoneID, ok := h.(*api.CloudflareHandle).ZoneOfDomain(context.Background(), mockPP, api.FQDN("sub.test.org")) require.False(t, ok) require.Equal(t, "", zoneID) } -func mockDNSRecord(id string, ipNet ipnet.Type, domain api.FQDN, ip net.IP) *cloudflare.DNSRecord { +func mockDNSRecord(id string, ipNet ipnet.Type, name string, ip net.IP) *cloudflare.DNSRecord { return &cloudflare.DNSRecord{ //nolint:exhaustivestruct ID: id, Type: ipNet.RecordType(), - Name: domain.ToASCII(), + Name: name, Content: ip.String(), } } -//nolint:unparam // domain always = "test.org" for now -func mockDNSListResponse(ipNet ipnet.Type, domain api.FQDN, ips map[string]net.IP) *cloudflare.DNSListResponse { +func mockDNSListResponse(ipNet ipnet.Type, name string, ips map[string]net.IP) *cloudflare.DNSListResponse { if len(ips) > 100 { panic("mockDNSResponse got too many IPs") } rs := make([]cloudflare.DNSRecord, 0, len(ips)) for id, ip := range ips { - rs = append(rs, *mockDNSRecord(id, ipNet, domain, ip)) + rs = append(rs, *mockDNSRecord(id, ipNet, name, ip)) } return &cloudflare.DNSListResponse{ @@ -460,6 +480,7 @@ func mockDNSListResponse(ipNet ipnet.Type, domain api.FQDN, ips map[string]net.I } } +//nolint:dupl func TestListRecords(t *testing.T) { t.Parallel() mockCtrl := gomock.NewController(t) @@ -499,14 +520,66 @@ func TestListRecords(t *testing.T) { expected := map[string]net.IP{"record1": net.ParseIP("::1"), "record2": net.ParseIP("::2")} ipNet, ips, accessCount = ipnet.IP6, expected, 1 mockPP := mocks.NewMockPP(mockCtrl) - ips, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) + ips, ok := h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) + require.True(t, ok) + require.Equal(t, expected, ips) + require.Equal(t, 0, accessCount) + + // testing the caching + mockPP = mocks.NewMockPP(mockCtrl) + ips, ok = h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) + require.True(t, ok) + require.Equal(t, expected, ips) +} + +//nolint:dupl +func TestListRecordsWildcard(t *testing.T) { + t.Parallel() + mockCtrl := gomock.NewController(t) + + mux, h := newHandle(t) + + zh := newZonesHandler(t, mux) + zh.set(map[string]int{"test.org": 1}, 1) + + var ( + ipNet ipnet.Type + ips map[string]net.IP + accessCount int + ) + + mux.HandleFunc(fmt.Sprintf("/zones/%s/dns_records", mockID("test.org", 0)), + func(w http.ResponseWriter, r *http.Request) { + if accessCount <= 0 { + return + } + accessCount-- + + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, []string{fmt.Sprintf("Bearer %s", mockToken)}, r.Header["Authorization"]) + assert.Equal(t, url.Values{ + "name": {"*.test.org"}, + "page": {"1"}, + "per_page": {"100"}, + "type": {ipNet.RecordType()}, + }, r.URL.Query()) + + w.Header().Set("content-type", "application/json") + err := json.NewEncoder(w).Encode(mockDNSListResponse(ipNet, "*.test.org", ips)) + assert.NoError(t, err) + }) + + expected := map[string]net.IP{"record1": net.ParseIP("::1"), "record2": net.ParseIP("::2")} + ipNet, ips, accessCount = ipnet.IP6, expected, 1 + mockPP := mocks.NewMockPP(mockCtrl) + ips, ok := h.ListRecords(context.Background(), mockPP, api.Wildcard("test.org"), ipnet.IP6) require.True(t, ok) require.Equal(t, expected, ips) require.Equal(t, 0, accessCount) // testing the caching mockPP = mocks.NewMockPP(mockCtrl) - ips, ok = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) + ips, ok = h.ListRecords(context.Background(), mockPP, api.Wildcard("test.org"), ipnet.IP6) require.True(t, ok) require.Equal(t, expected, ips) } @@ -522,13 +595,13 @@ func TestListRecordsInvalidDomain(t *testing.T) { mockPP := mocks.NewMockPP(mockCtrl) mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to retrieve records of %q: %v", "sub.test.org", gomock.Any()) - ips, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP4) + ips, ok := h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP4) require.False(t, ok) require.Nil(t, ips) mockPP = mocks.NewMockPP(mockCtrl) mockPP.EXPECT().Warningf(pp.EmojiError, "Failed to retrieve records of %q: %v", "sub.test.org", gomock.Any()) - ips, ok = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) + ips, ok = h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) require.False(t, ok) require.Nil(t, ips) } @@ -546,7 +619,7 @@ func TestListRecordsInvalidZone(t *testing.T) { "sub.test.org", gomock.Any(), ) - ips, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP4) + ips, ok := h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP4) require.False(t, ok) require.Nil(t, ips) @@ -557,7 +630,7 @@ func TestListRecordsInvalidZone(t *testing.T) { "sub.test.org", gomock.Any(), ) - ips, ok = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) + ips, ok = h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) require.False(t, ok) require.Nil(t, ips) } @@ -582,8 +655,8 @@ func envelopDNSRecordResponse(record *cloudflare.DNSRecord) *cloudflare.DNSRecor } } -func mockDNSRecordResponse(id string, ipNet ipnet.Type, domain api.FQDN, ip net.IP) *cloudflare.DNSRecordResponse { - return envelopDNSRecordResponse(mockDNSRecord(id, ipNet, domain, ip)) +func mockDNSRecordResponse(id string, ipNet ipnet.Type, name string, ip net.IP) *cloudflare.DNSRecordResponse { + return envelopDNSRecordResponse(mockDNSRecord(id, ipNet, name, ip)) } func TestDeleteRecordValid(t *testing.T) { @@ -630,14 +703,14 @@ func TestDeleteRecordValid(t *testing.T) { deleteAccessCount = 1 mockPP := mocks.NewMockPP(mockCtrl) - ok := h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1") + ok := h.DeleteRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1") require.True(t, ok) listAccessCount, deleteAccessCount = 1, 1 mockPP = mocks.NewMockPP(mockCtrl) - _, _ = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) - _ = h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1") - rs, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) + _, _ = h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) + _ = h.DeleteRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1") + rs, ok := h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) require.True(t, ok) require.Empty(t, rs) } @@ -658,7 +731,7 @@ func TestDeleteRecordInvalid(t *testing.T) { "record1", gomock.Any(), ) - ok := h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1") + ok := h.DeleteRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1") require.False(t, ok) } @@ -673,7 +746,7 @@ func TestDeleteRecordZoneInvalid(t *testing.T) { "sub.test.org", gomock.Any(), ) - ok := h.DeleteRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1") + ok := h.DeleteRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1") require.False(t, ok) } @@ -732,14 +805,14 @@ func TestUpdateRecordValid(t *testing.T) { updateAccessCount = 1 mockPP := mocks.NewMockPP(mockCtrl) - ok := h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2")) + ok := h.UpdateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1", net.ParseIP("::2")) require.True(t, ok) listAccessCount, updateAccessCount = 1, 1 mockPP = mocks.NewMockPP(mockCtrl) - _, _ = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) - _ = h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::2")) - rs, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) + _, _ = h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) + _ = h.UpdateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1", net.ParseIP("::2")) + rs, ok := h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) require.True(t, ok) require.Equal(t, map[string]net.IP{"record1": net.ParseIP("::2")}, rs) } @@ -760,7 +833,7 @@ func TestUpdateRecordInvalid(t *testing.T) { "record1", gomock.Any(), ) - ok := h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1")) + ok := h.UpdateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1", net.ParseIP("::1")) require.False(t, ok) } @@ -775,7 +848,7 @@ func TestUpdateRecordInvalidZone(t *testing.T) { "sub.test.org", gomock.Any(), ) - ok := h.UpdateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, "record1", net.ParseIP("::1")) + ok := h.UpdateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, "record1", net.ParseIP("::1")) require.False(t, ok) } @@ -835,15 +908,15 @@ func TestCreateRecordValid(t *testing.T) { createAccessCount = 1 mockPP := mocks.NewMockPP(mockCtrl) - actualID, ok := h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll + actualID, ok := h.CreateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll require.True(t, ok) require.Equal(t, "record1", actualID) listAccessCount, createAccessCount = 1, 1 mockPP = mocks.NewMockPP(mockCtrl) - _, _ = h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) - _, _ = h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false) - rs, ok := h.ListRecords(context.Background(), mockPP, "sub.test.org", ipnet.IP6) + _, _ = h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) + _, _ = h.CreateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll + rs, ok := h.ListRecords(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6) require.True(t, ok) require.Equal(t, map[string]net.IP{"record1": net.ParseIP("::1")}, rs) } @@ -863,7 +936,7 @@ func TestCreateRecordInvalid(t *testing.T) { "sub.test.org", gomock.Any(), ) - actualID, ok := h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll + actualID, ok := h.CreateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll require.False(t, ok) require.Equal(t, "", actualID) } @@ -879,7 +952,7 @@ func TestCreateRecordInvalidZone(t *testing.T) { "sub.test.org", gomock.Any(), ) - actualID, ok := h.CreateRecord(context.Background(), mockPP, "sub.test.org", ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll + actualID, ok := h.CreateRecord(context.Background(), mockPP, api.FQDN("sub.test.org"), ipnet.IP6, net.ParseIP("::1"), 100, false) //nolint:lll require.False(t, ok) require.Equal(t, "", actualID) } diff --git a/internal/api/domain.go b/internal/api/domain.go new file mode 100644 index 00000000..2049c3a2 --- /dev/null +++ b/internal/api/domain.go @@ -0,0 +1,57 @@ +package api + +import ( + "sort" + "strings" + + "golang.org/x/net/idna" +) + +//nolint:gochecknoglobals +// profile does C2 in UTS#46 with all checks on + removing leading dots. +// This is the main conversion profile in use. +var profile = idna.New( + idna.MapForLookup(), + idna.BidiRule(), + // idna.Transitional(false), // https://go-review.googlesource.com/c/text/+/317729/ + idna.RemoveLeadingDots(true), +) + +// safelyToUnicode takes an ASCII form and returns the Unicode form +// when the round trip gives the same ASCII form back without errors. +// Otherwise, the input ASCII form is returned. +func safelyToUnicode(ascii string) (string, bool) { + unicode, errToA := profile.ToUnicode(ascii) + roundTrip, errToU := profile.ToASCII(unicode) + if errToA != nil || errToU != nil || roundTrip != ascii { + return ascii, false + } + + return unicode, true +} + +// NewDomain normalizes a domain to its ASCII form and then stores +// the normalized domain in its Unicode form when the round trip +// gives back the same ASCII form without errors. Otherwise, +// the ASCII form (possibly using Punycode) is stored to avoid ambiguity. +func NewDomain(domain string) (Domain, error) { + normalized, err := profile.ToASCII(domain) + + // Remove the final dot for consistency + normalized = strings.TrimRight(normalized, ".") + + switch { + case normalized == "*": + return Wildcard(""), nil + case strings.HasPrefix(normalized, "*."): + // redo the normalization after removing the offending "*" + normalized, err := profile.ToASCII(strings.TrimPrefix(normalized, "*.")) + return Wildcard(normalized), err + default: + return FQDN(normalized), err + } +} + +func SortDomains(s []Domain) { + sort.Slice(s, func(i, j int) bool { return s[i].DNSNameASCII() < s[j].DNSNameASCII() }) +} diff --git a/internal/api/domain_test.go b/internal/api/domain_test.go new file mode 100644 index 00000000..b562ee35 --- /dev/null +++ b/internal/api/domain_test.go @@ -0,0 +1,143 @@ +package api_test + +import ( + "sort" + "testing" + "testing/quick" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/favonia/cloudflare-ddns/internal/api" +) + +//nolint:funlen +func TestNewDomain(t *testing.T) { + t.Parallel() + type f = api.FQDN + type w = api.Wildcard + for _, tc := range [...]struct { + input string + expected api.Domain + ok bool + errString string + }{ + // The following examples were adapted from https://unicode.org/cldr/utility/idna.jsp + {"fass.de", f("fass.de"), true, ""}, + {"faß.de", f("xn--fa-hia.de"), true, ""}, + {"fäß.de", f("xn--f-qfao.de"), true, ""}, + {"xn--fa-hia.de", f("xn--fa-hia.de"), true, ""}, + {"₹.com", f("xn--yzg.com"), true, ""}, + {"𑀓.com", f("xn--n00d.com"), true, ""}, + {"\u0080.com", f("xn--a.com"), false, "idna: disallowed rune U+0080"}, + {"xn--a.com", f("xn--a.com"), false, `idna: invalid label "\u0080"`}, + {"a\u200Cb", f("xn--ab-j1t"), false, `idna: invalid label "a\u200cb"`}, + {"xn--ab-j1t", f("xn--ab-j1t"), false, `idna: invalid label "a\u200cb"`}, + {"\u00F6bb.at", f("xn--bb-eka.at"), true, ""}, + {"o\u0308bb.at", f("xn--bb-eka.at"), true, ""}, + {"\u00D6BB.at", f("xn--bb-eka.at"), true, ""}, + {"O\u0308BB.at", f("xn--bb-eka.at"), true, ""}, + {"ȡog.de", f("xn--og-09a.de"), true, ""}, + {"☕.de", f("xn--53h.de"), true, ""}, + {"I♥NY.de", f("xn--iny-zx5a.de"), true, ""}, + {"ABC・日本.co.jp", f("xn--abc-rs4b422ycvb.co.jp"), true, ""}, + {"日本。co。jp", f("xn--wgv71a.co.jp"), true, ""}, + {"日本。co.jp", f("xn--wgv71a.co.jp"), true, ""}, + {"日本⒈co.jp", f("xn--co-wuw5954azlb.jp"), false, "idna: disallowed rune U+2488"}, + {"x\u0327\u0301.de", f("xn--x-xbb7i.de"), true, ""}, + {"x\u0301\u0327.de", f("xn--x-xbb7i.de"), true, ""}, + {"σόλος.gr", f("xn--wxaijb9b.gr"), true, ""}, + {"Σόλος.gr", f("xn--wxaijb9b.gr"), true, ""}, + {"ΣΌΛΟΣ.grﻋﺮﺑﻲ.de", f("xn--wxaikc6b.xn--gr-gtd9a1b0g.de"), false, `idna: invalid label "σόλοσ.grعربي.de"`}, + {"عربي.de", f("xn--ngbrx4e.de"), true, ""}, + {"نامهای.de", f("xn--mgba3gch31f.de"), true, ""}, + {"نامه\u200Cای.de", f("xn--mgba3gch31f060k.de"), true, ""}, + // wildcards + {"*.fass.de", w("fass.de"), true, ""}, + {"*.faß.de", w("xn--fa-hia.de"), true, ""}, + {"*.fäß.de", w("xn--f-qfao.de"), true, ""}, + {"*.xn--fa-hia.de", w("xn--fa-hia.de"), true, ""}, + {"*.₹.com", w("xn--yzg.com"), true, ""}, + {"*.𑀓.com", w("xn--n00d.com"), true, ""}, + {"*.\u0080.com", w("xn--a.com"), false, `idna: invalid label "\u0080"`}, + {"*.xn--a.com", w("xn--a.com"), false, `idna: invalid label "\u0080"`}, + {"*.a\u200Cb", w("xn--ab-j1t"), false, `idna: invalid label "a\u200cb"`}, + {"*.xn--ab-j1t", w("xn--ab-j1t"), false, `idna: invalid label "a\u200cb"`}, + {"*.\u00F6bb.at", w("xn--bb-eka.at"), true, ""}, + {"*.o\u0308bb.at", w("xn--bb-eka.at"), true, ""}, + {"*.\u00D6BB.at", w("xn--bb-eka.at"), true, ""}, + {"*.O\u0308BB.at", w("xn--bb-eka.at"), true, ""}, + {"*.ȡog.de", w("xn--og-09a.de"), true, ""}, + {"*.☕.de", w("xn--53h.de"), true, ""}, + {"*.I♥NY.de", w("xn--iny-zx5a.de"), true, ""}, + {"*.ABC・日本.co.jp", w("xn--abc-rs4b422ycvb.co.jp"), true, ""}, + {"*。日本。co。jp", w("xn--wgv71a.co.jp"), true, ""}, + {"*。日本。co.jp", w("xn--wgv71a.co.jp"), true, ""}, + {"*.日本。co.jp", w("xn--wgv71a.co.jp"), true, ""}, + {"*.日本⒈co.jp", w("xn--co-wuw5954azlb.jp"), false, `idna: invalid label "日本⒈co"`}, + {"*.x\u0327\u0301.de", w("xn--x-xbb7i.de"), true, ""}, + {"*.x\u0301\u0327.de", w("xn--x-xbb7i.de"), true, ""}, + {"*.σόλος.gr", w("xn--wxaijb9b.gr"), true, ""}, + {"*.Σόλος.gr", w("xn--wxaijb9b.gr"), true, ""}, + { + "*.ΣΌΛΟΣ.grﻋﺮﺑﻲ.de", w("xn--wxaikc6b.xn--gr-gtd9a1b0g.de"), + false, + `idna: invalid label "xn--wxaikc6b.xn--gr-gtd9a1b0g.de"`, + }, + {"*.عربي.de", w("xn--ngbrx4e.de"), true, ""}, + {"*.نامهای.de", w("xn--mgba3gch31f.de"), true, ""}, + {"*.نامه\u200Cای.de", w("xn--mgba3gch31f060k.de"), true, ""}, + // some other test cases + {"xn--a.xn--a.xn--a.com", f("xn--a.xn--a.xn--a.com"), false, `idna: invalid label "\u0080"`}, + {"a.com...。", f("a.com"), true, ""}, + {"..。..a.com", f("a.com"), true, ""}, + {"*.xn--a.xn--a.xn--a.com", w("xn--a.xn--a.xn--a.com"), false, `idna: invalid label "\u0080"`}, + {"*.a.com...。", w("a.com"), true, ""}, + {"*...。..a.com", w("a.com"), true, ""}, + {"*......", w(""), true, ""}, + {"*。。。。。。", w(""), true, ""}, + } { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + normalized, err := api.NewDomain(tc.input) + require.Equal(t, tc.expected, normalized) + if tc.ok { + require.NoError(t, err) + require.Empty(t, tc.errString) + } else { + require.EqualError(t, err, tc.errString) + } + }) + } +} + +func TestSortDomains(t *testing.T) { + t.Parallel() + + require.NoError(t, quick.Check( + func(fs []api.FQDN, ws []api.Wildcard) bool { + merged := make([]api.Domain, 0, len(fs)+len(ws)) + + for _, f := range fs { + merged = append(merged, f) + } + for _, w := range ws { + merged = append(merged, w) + } + + copied := make([]api.Domain, len(merged)) + copy(copied, merged) + api.SortDomains(merged) + switch { + case !assert.ElementsMatch(t, copied, merged): + return false + case !sort.SliceIsSorted(merged, func(i, j int) bool { return merged[i].DNSNameASCII() < merged[j].DNSNameASCII() }): + return false + default: + return true + } + }, + nil, + )) +} diff --git a/internal/api/fqdn.go b/internal/api/fqdn.go index 759167a3..ca6303cc 100644 --- a/internal/api/fqdn.go +++ b/internal/api/fqdn.go @@ -1,85 +1,43 @@ package api -import ( - "sort" - "strings" - - "golang.org/x/net/idna" -) - -//nolint:gochecknoglobals -// profile does C2 in UTS#46 with all checks on + removing leading dots. -// This is the main conversion profile in use. -var profile = idna.New( - idna.MapForLookup(), - idna.BidiRule(), - // idna.Transitional(false), // https://go-review.googlesource.com/c/text/+/317729/ - idna.RemoveLeadingDots(true), -) +import "strings" // FQDN is a fully qualified domain in its ASCII or Unicode (when unambiguous) form. type FQDN string -// safelyToUnicode takes an ASCII form and returns the Unicode form -// when the round trip gives the same ASCII form back without errors. -// Otherwise, the input ASCII form is returned. -func safelyToUnicode(ascii string) (string, bool) { - unicode, errToA := profile.ToUnicode(ascii) - roundTrip, errToU := profile.ToASCII(unicode) - if errToA != nil || errToU != nil || roundTrip != ascii { - return ascii, false - } - - return unicode, true -} - -func (f FQDN) ToASCII() string { return string(f) } +func (f FQDN) DNSNameASCII() string { return string(f) } func (f FQDN) Describe() string { best, ok := safelyToUnicode(string(f)) if !ok { + // use the unconverted string if the conversation failed return string(f) } return best } -// NewFQDN normalizes a domain to its ASCII form and then stores -// the normalized domain in its Unicode form when the round trip -// gives back the same ASCII form without errors. Otherwise, -// the ASCII form (possibly using Punycode) is stored to avoid ambiguity. -func NewFQDN(domain string) (FQDN, error) { - normalized, err := profile.ToASCII(domain) - - // Remove the final dot for consistency - normalized = strings.TrimRight(normalized, ".") - - return FQDN(normalized), err -} - -func SortFQDNs(s []FQDN) { sort.Slice(s, func(i, j int) bool { return s[i] < s[j] }) } - type FQDNSplitter struct { domain string cursor int exhausted bool } -func NewFQDNSplitter(domain FQDN) *FQDNSplitter { +func (f FQDN) Split() DomainSplitter { return &FQDNSplitter{ - domain: domain.ToASCII(), + domain: string(f), cursor: 0, exhausted: false, } } -func (s *FQDNSplitter) IsValid() bool { return !s.exhausted } -func (s *FQDNSplitter) Suffix() string { return s.domain[s.cursor:] } +func (s *FQDNSplitter) IsValid() bool { return !s.exhausted } +func (s *FQDNSplitter) ZoneNameASCII() string { return s.domain[s.cursor:] } func (s *FQDNSplitter) Next() { if s.cursor == len(s.domain) { s.exhausted = true } else { - shift := strings.IndexRune(s.Suffix(), '.') + shift := strings.IndexRune(s.ZoneNameASCII(), '.') if shift == -1 { s.cursor = len(s.domain) } else { diff --git a/internal/api/fqdn_test.go b/internal/api/fqdn_test.go index 2d7e0b70..ec7c817a 100644 --- a/internal/api/fqdn_test.go +++ b/internal/api/fqdn_test.go @@ -1,67 +1,63 @@ package api_test import ( - "sort" "testing" "testing/quick" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/favonia/cloudflare-ddns/internal/api" ) -func TestFQDNToASCII(t *testing.T) { +func TestFQDNString(t *testing.T) { t.Parallel() require.NoError(t, quick.Check( func(s string) bool { - return api.FQDN(s).ToASCII() == s + return api.FQDN(s).DNSNameASCII() == s }, nil, )) } +//nolint:dupl func TestFQDNDescribe(t *testing.T) { t.Parallel() for _, tc := range [...]struct { - input string - expected string - ok bool - errString string + input string + expected string }{ // The following examples were adapted from https://unicode.org/cldr/utility/idna.jsp - {"fass.de", "fass.de", true, ""}, - {"xn--fa-hia.de", "faß.de", true, ""}, - {"xn--f-qfao.de", "fäß.de", true, ""}, - {"xn--fa-hia.de", "faß.de", true, ""}, - {"xn--yzg.com", "₹.com", true, ""}, - {"xn--n00d.com", "𑀓.com", true, ""}, - {"xn--a.com", "xn--a.com", false, "idna: disallowed rune U+0080"}, - {"xn--a.com", "xn--a.com", false, "idna: invalid label \"\\u0080\""}, - {"xn--ab-j1t", "xn--ab-j1t", false, "idna: invalid label \"a\\u200cb\""}, - {"xn--ab-j1t", "xn--ab-j1t", false, "idna: invalid label \"a\\u200cb\""}, - {"xn--bb-eka.at", "öbb.at", true, ""}, - {"xn--og-09a.de", "ȡog.de", true, ""}, - {"xn--53h.de", "☕.de", true, ""}, - {"xn--iny-zx5a.de", "i♥ny.de", true, ""}, - {"xn--abc-rs4b422ycvb.co.jp", "abc・日本.co.jp", true, ""}, - {"xn--wgv71a.co.jp", "日本.co.jp", true, ""}, - {"xn--co-wuw5954azlb.jp", "xn--co-wuw5954azlb.jp", false, "idna: disallowed rune U+2488"}, - {"xn--x-xbb7i.de", "x̧́.de", true, ""}, - {"xn--wxaijb9b.gr", "σόλος.gr", true, ""}, + {"fass.de", "fass.de"}, + {"xn--fa-hia.de", "faß.de"}, + {"xn--f-qfao.de", "fäß.de"}, + {"xn--fa-hia.de", "faß.de"}, + {"xn--yzg.com", "₹.com"}, + {"xn--n00d.com", "𑀓.com"}, + {"xn--a.com", "xn--a.com"}, + {"xn--a.com", "xn--a.com"}, + {"xn--ab-j1t", "xn--ab-j1t"}, + {"xn--ab-j1t", "xn--ab-j1t"}, + {"xn--bb-eka.at", "öbb.at"}, + {"xn--og-09a.de", "ȡog.de"}, + {"xn--53h.de", "☕.de"}, + {"xn--iny-zx5a.de", "i♥ny.de"}, + {"xn--abc-rs4b422ycvb.co.jp", "abc・日本.co.jp"}, + {"xn--wgv71a.co.jp", "日本.co.jp"}, + {"xn--co-wuw5954azlb.jp", "xn--co-wuw5954azlb.jp"}, + {"xn--x-xbb7i.de", "x̧́.de"}, + {"xn--wxaijb9b.gr", "σόλος.gr"}, { "xn--wxaikc6b.xn--gr-gtd9a1b0g.de", "xn--wxaikc6b.xn--gr-gtd9a1b0g.de", - false, "idna: invalid label \"σόλοσ.grعربي.de\"", }, - {"xn--ngbrx4e.de", "عربي.de", true, ""}, - {"xn--mgba3gch31f.de", "نامهای.de", true, ""}, - {"xn--mgba3gch31f060k.de", "نامه\u200cای.de", true, ""}, + {"xn--ngbrx4e.de", "عربي.de"}, + {"xn--mgba3gch31f.de", "نامهای.de"}, + {"xn--mgba3gch31f060k.de", "نامه\u200cای.de"}, // some other test cases - {"xn--a.xn--a.xn--a.com", "xn--a.xn--a.xn--a.com", false, "idna: invalid label \"\\u0080\""}, - {"a.com....", "a.com....", true, ""}, - {"a.com", "a.com", true, ""}, + {"xn--a.xn--a.xn--a.com", "xn--a.xn--a.xn--a.com"}, + {"a.com....", "a.com...."}, + {"a.com", "a.com"}, } { tc := tc t.Run(tc.input, func(t *testing.T) { @@ -71,105 +67,28 @@ func TestFQDNDescribe(t *testing.T) { } } -func TestNewFQDN(t *testing.T) { - t.Parallel() - for _, tc := range [...]struct { - input string - expected api.FQDN - ok bool - errString string - }{ - // The following examples were adapted from https://unicode.org/cldr/utility/idna.jsp - {"fass.de", "fass.de", true, ""}, - {"faß.de", "xn--fa-hia.de", true, ""}, - {"fäß.de", "xn--f-qfao.de", true, ""}, - {"xn--fa-hia.de", "xn--fa-hia.de", true, ""}, - {"₹.com", "xn--yzg.com", true, ""}, - {"𑀓.com", "xn--n00d.com", true, ""}, - {"\u0080.com", "xn--a.com", false, "idna: disallowed rune U+0080"}, - {"xn--a.com", "xn--a.com", false, "idna: invalid label \"\\u0080\""}, - {"a\u200Cb", "xn--ab-j1t", false, "idna: invalid label \"a\\u200cb\""}, - {"xn--ab-j1t", "xn--ab-j1t", false, "idna: invalid label \"a\\u200cb\""}, - {"\u00F6bb.at", "xn--bb-eka.at", true, ""}, - {"o\u0308bb.at", "xn--bb-eka.at", true, ""}, - {"\u00D6BB.at", "xn--bb-eka.at", true, ""}, - {"O\u0308BB.at", "xn--bb-eka.at", true, ""}, - {"ȡog.de", "xn--og-09a.de", true, ""}, - {"☕.de", "xn--53h.de", true, ""}, - {"I♥NY.de", "xn--iny-zx5a.de", true, ""}, - {"ABC・日本.co.jp", "xn--abc-rs4b422ycvb.co.jp", true, ""}, - {"日本。co。jp", "xn--wgv71a.co.jp", true, ""}, - {"日本。co.jp", "xn--wgv71a.co.jp", true, ""}, - {"日本⒈co.jp", "xn--co-wuw5954azlb.jp", false, "idna: disallowed rune U+2488"}, - {"x\u0327\u0301.de", "xn--x-xbb7i.de", true, ""}, - {"x\u0301\u0327.de", "xn--x-xbb7i.de", true, ""}, - {"σόλος.gr", "xn--wxaijb9b.gr", true, ""}, - {"Σόλος.gr", "xn--wxaijb9b.gr", true, ""}, - {"ΣΌΛΟΣ.grﻋﺮﺑﻲ.de", "xn--wxaikc6b.xn--gr-gtd9a1b0g.de", false, "idna: invalid label \"σόλοσ.grعربي.de\""}, - {"عربي.de", "xn--ngbrx4e.de", true, ""}, - {"نامهای.de", "xn--mgba3gch31f.de", true, ""}, - {"نامه\u200Cای.de", "xn--mgba3gch31f060k.de", true, ""}, - // some other test cases - {"xn--a.xn--a.xn--a.com", "xn--a.xn--a.xn--a.com", false, "idna: invalid label \"\\u0080\""}, - {"a.com...。", "a.com", true, ""}, - {"..。..a.com", "a.com", true, ""}, - } { - tc := tc - t.Run(tc.input, func(t *testing.T) { - t.Parallel() - normalized, err := api.NewFQDN(tc.input) - require.Equal(t, tc.expected, normalized) - if tc.ok { - require.NoError(t, err) - } else { - require.EqualError(t, err, tc.errString) - } - }) - } -} - -func TestSortFQDNs(t *testing.T) { - t.Parallel() - - require.NoError(t, quick.Check( - func(s []api.FQDN) bool { - copied := make([]api.FQDN, len(s)) - copy(copied, s) - api.SortFQDNs(s) - switch { - case !assert.ElementsMatch(t, copied, s): - return false - case !sort.SliceIsSorted(s, func(i, j int) bool { return s[i] < s[j] }): - return false - default: - return true - } - }, - nil, - )) -} - -func TestSortFQDNSplitter(t *testing.T) { +func TestFQDNSplitter(t *testing.T) { t.Parallel() - type ss = []string + type r = string for _, tc := range [...]struct { input string - expected []string + expected []r }{ - {"...", ss{"...", "..", ".", ""}}, - {"aaa...", ss{"aaa...", "..", ".", ""}}, - {".aaa..", ss{".aaa..", "aaa..", ".", ""}}, - {"..aaa.", ss{"..aaa.", ".aaa.", "aaa.", ""}}, - {"...aaa", ss{"...aaa", "..aaa", ".aaa", "aaa", ""}}, + {"a.b.c", []r{"a.b.c", "b.c", "c", ""}}, + {"...", []r{"...", "..", ".", ""}}, + {"aaa...", []r{"aaa...", "..", ".", ""}}, + {".aaa..", []r{".aaa..", "aaa..", ".", ""}}, + {"..aaa.", []r{"..aaa.", ".aaa.", "aaa.", ""}}, + {"...aaa", []r{"...aaa", "..aaa", ".aaa", "aaa", ""}}, } { tc := tc t.Run(tc.input, func(t *testing.T) { t.Parallel() - var ss []string - for s := api.NewFQDNSplitter(api.FQDN(tc.input)); s.IsValid(); s.Next() { - ss = append(ss, s.Suffix()) + var rs []r + for s := api.FQDN(tc.input).Split(); s.IsValid(); s.Next() { + rs = append(rs, s.ZoneNameASCII()) } - require.Equal(t, tc.expected, ss) + require.Equal(t, tc.expected, rs) }) } } diff --git a/internal/api/wildcard.go b/internal/api/wildcard.go new file mode 100644 index 00000000..db4230ec --- /dev/null +++ b/internal/api/wildcard.go @@ -0,0 +1,54 @@ +package api + +import "strings" + +// Wildcard is a fully qualified zone name in its ASCII or Unicode (when unambiguous) form, +// represnting the wildcard domain name under the zone. +type Wildcard string + +func (w Wildcard) DNSNameASCII() string { + if string(w) == "" { + return "*" + } + + return "*." + string(w) +} + +func (w Wildcard) Describe() string { + best, ok := safelyToUnicode(string(w)) + if !ok { + // use the unconverted string if the conversation failed + return "*." + string(w) + } + + return "*." + best +} + +type WildcardSplitter struct { + domain string + cursor int + exhausted bool +} + +func (w Wildcard) Split() DomainSplitter { + return &WildcardSplitter{ + domain: string(w), + cursor: 0, + exhausted: false, + } +} + +func (s *WildcardSplitter) IsValid() bool { return !s.exhausted } +func (s *WildcardSplitter) ZoneNameASCII() string { return s.domain[s.cursor:] } +func (s *WildcardSplitter) Next() { + if s.cursor == len(s.domain) { + s.exhausted = true + } else { + shift := strings.IndexRune(s.ZoneNameASCII(), '.') + if shift == -1 { + s.cursor = len(s.domain) + } else { + s.cursor += shift + 1 + } + } +} diff --git a/internal/api/wildcard_test.go b/internal/api/wildcard_test.go new file mode 100644 index 00000000..06e7088d --- /dev/null +++ b/internal/api/wildcard_test.go @@ -0,0 +1,97 @@ +package api_test + +import ( + "testing" + "testing/quick" + + "github.com/stretchr/testify/require" + + "github.com/favonia/cloudflare-ddns/internal/api" +) + +func TestWildcardString(t *testing.T) { + t.Parallel() + + require.NoError(t, quick.Check( + func(s string) bool { + if s == "" { + return api.Wildcard(s).DNSNameASCII() == "*" + } + return api.Wildcard(s).DNSNameASCII() == "*."+s + }, + nil, + )) +} + +//nolint:dupl +func TestWildcardDescribe(t *testing.T) { + t.Parallel() + for _, tc := range [...]struct { + input string + expected string + }{ + // The following examples were adapted from https://unicode.org/cldr/utility/idna.jsp + {"fass.de", "*.fass.de"}, + {"xn--fa-hia.de", "*.faß.de"}, + {"xn--f-qfao.de", "*.fäß.de"}, + {"xn--fa-hia.de", "*.faß.de"}, + {"xn--yzg.com", "*.₹.com"}, + {"xn--n00d.com", "*.𑀓.com"}, + {"xn--a.com", "*.xn--a.com"}, + {"xn--a.com", "*.xn--a.com"}, + {"xn--ab-j1t", "*.xn--ab-j1t"}, + {"xn--ab-j1t", "*.xn--ab-j1t"}, + {"xn--bb-eka.at", "*.öbb.at"}, + {"xn--og-09a.de", "*.ȡog.de"}, + {"xn--53h.de", "*.☕.de"}, + {"xn--iny-zx5a.de", "*.i♥ny.de"}, + {"xn--abc-rs4b422ycvb.co.jp", "*.abc・日本.co.jp"}, + {"xn--wgv71a.co.jp", "*.日本.co.jp"}, + {"xn--co-wuw5954azlb.jp", "*.xn--co-wuw5954azlb.jp"}, + {"xn--x-xbb7i.de", "*.x̧́.de"}, + {"xn--wxaijb9b.gr", "*.σόλος.gr"}, + { + "xn--wxaikc6b.xn--gr-gtd9a1b0g.de", + "*.xn--wxaikc6b.xn--gr-gtd9a1b0g.de", + }, + {"xn--ngbrx4e.de", "*.عربي.de"}, + {"xn--mgba3gch31f.de", "*.نامهای.de"}, + {"xn--mgba3gch31f060k.de", "*.نامه\u200cای.de"}, + // some other test cases + {"xn--a.xn--a.xn--a.com", "*.xn--a.xn--a.xn--a.com"}, + {"a.com....", "*.a.com...."}, + {"a.com", "*.a.com"}, + } { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.expected, api.Wildcard(tc.input).Describe()) + }) + } +} + +func TestWildcardSplitter(t *testing.T) { + t.Parallel() + type r = string + for _, tc := range [...]struct { + input string + expected []r + }{ + {"a.b.c", []r{"a.b.c", "b.c", "c", ""}}, + {"...", []r{"...", "..", ".", ""}}, + {"aaa...", []r{"aaa...", "..", ".", ""}}, + {".aaa..", []r{".aaa..", "aaa..", ".", ""}}, + {"..aaa.", []r{"..aaa.", ".aaa.", "aaa.", ""}}, + {"...aaa", []r{"...aaa", "..aaa", ".aaa", "aaa", ""}}, + } { + tc := tc + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + var rs []r + for s := api.Wildcard(tc.input).Split(); s.IsValid(); s.Next() { + rs = append(rs, s.ZoneNameASCII()) + } + require.Equal(t, tc.expected, rs) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c618bdc8..3ed8f695 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,7 @@ import ( type Config struct { Auth api.Auth Policy map[ipnet.Type]detector.Policy - Domains map[ipnet.Type][]api.FQDN + Domains map[ipnet.Type][]api.Domain UpdateCron cron.Schedule UpdateOnStart bool DeleteOnStop bool @@ -33,7 +33,7 @@ func Default() *Config { ipnet.IP4: detector.NewCloudflare(), ipnet.IP6: detector.NewCloudflare(), }, - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: nil, ipnet.IP6: nil, }, @@ -98,8 +98,8 @@ func ReadAuth(ppfmt pp.PP, field *api.Auth) bool { // deduplicate always sorts and deduplicates the input list, // returning true if elements are already distinct. -func deduplicate(list *[]api.FQDN) { - api.SortFQDNs(*list) +func deduplicate(list *[]api.Domain) { + api.SortDomains(*list) if len(*list) == 0 { return @@ -121,8 +121,8 @@ func deduplicate(list *[]api.FQDN) { *list = (*list)[:j+1] } -func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]api.FQDN) bool { - var domains, ip4Domains, ip6Domains []api.FQDN +func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]api.Domain) bool { + var domains, ip4Domains, ip6Domains []api.Domain if !ReadDomains(ppfmt, "DOMAINS", &domains) || !ReadDomains(ppfmt, "IP4_DOMAINS", &ip4Domains) || @@ -136,7 +136,7 @@ func ReadDomainMap(ppfmt pp.PP, field *map[ipnet.Type][]api.FQDN) bool { deduplicate(&ip4Domains) deduplicate(&ip6Domains) - *field = map[ipnet.Type][]api.FQDN{ + *field = map[ipnet.Type][]api.Domain{ ipnet.IP4: ip4Domains, ipnet.IP6: ip6Domains, } @@ -220,7 +220,7 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool { //nolint:cyclop } func (c *Config) checkUselessDomains(ppfmt pp.PP) { - count := map[api.FQDN]int{} + count := map[api.Domain]int{} for _, domains := range c.Domains { for _, domain := range domains { count[domain]++ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2de639b8..a575e931 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -156,22 +156,22 @@ func TestReadDomainMap(t *testing.T) { domains string ip4Domains string ip6Domains string - expected map[ipnet.Type][]api.FQDN + expected map[ipnet.Type][]api.Domain ok bool prepareMockPP func(*mocks.MockPP) }{ "full": { " a1, a2", "b1, b2,b2", "c1,c2", - map[ipnet.Type][]api.FQDN{ - ipnet.IP4: {"a1", "a2", "b1", "b2"}, - ipnet.IP6: {"a1", "a2", "c1", "c2"}, + map[ipnet.Type][]api.Domain{ + ipnet.IP4: {api.FQDN("a1"), api.FQDN("a2"), api.FQDN("b1"), api.FQDN("b2")}, + ipnet.IP6: {api.FQDN("a1"), api.FQDN("a2"), api.FQDN("c1"), api.FQDN("c2")}, }, true, nil, }, "empty": { " ", " ", "", - map[ipnet.Type][]api.FQDN{ + map[ipnet.Type][]api.Domain{ ipnet.IP4: {}, ipnet.IP6: {}, }, @@ -187,7 +187,7 @@ func TestReadDomainMap(t *testing.T) { store(t, "IP4_DOMAINS", tc.ip4Domains) store(t, "IP6_DOMAINS", tc.ip6Domains) - field := map[ipnet.Type][]api.FQDN{} + field := map[ipnet.Type][]api.Domain{} mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { tc.prepareMockPP(mockPP) @@ -306,9 +306,9 @@ func TestPrintDefault(t *testing.T) { mockPP.EXPECT().IncIndent().Return(innerMockPP), mockPP.EXPECT().Infof(pp.EmojiConfig, "Policies:"), innerMockPP.EXPECT().Infof(pp.EmojiBullet, "IPv4 policy: %s", "cloudflare"), - innerMockPP.EXPECT().Infof(pp.EmojiBullet, "IPv4 domains: %v", []api.FQDN(nil)), + innerMockPP.EXPECT().Infof(pp.EmojiBullet, "IPv4 domains: %v", []api.Domain(nil)), innerMockPP.EXPECT().Infof(pp.EmojiBullet, "IPv6 policy: %s", "cloudflare"), - innerMockPP.EXPECT().Infof(pp.EmojiBullet, "IPv6 domains: %v", []api.FQDN(nil)), + innerMockPP.EXPECT().Infof(pp.EmojiBullet, "IPv6 domains: %v", []api.Domain(nil)), mockPP.EXPECT().Infof(pp.EmojiConfig, "Scheduling:"), innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Timezone: %s", "UTC (UTC+00 now)"), innerMockPP.EXPECT().Infof(pp.EmojiBullet, "Update frequency: %v", cron.MustNew("@every 5m")), @@ -450,14 +450,14 @@ func TestNormalize(t *testing.T) { }, "empty": { input: &config.Config{ //nolint:exhaustivestruct - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {}, ipnet.IP6: {}, }, }, ok: false, expected: &config.Config{ //nolint:exhaustivestruct - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {}, ipnet.IP6: {}, }, @@ -472,7 +472,7 @@ func TestNormalize(t *testing.T) { ipnet.IP4: detector.NewCloudflare(), ipnet.IP6: detector.NewCloudflare(), }, - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c")}, ipnet.IP6: {}, }, @@ -483,7 +483,7 @@ func TestNormalize(t *testing.T) { ipnet.IP4: detector.NewCloudflare(), ipnet.IP6: nil, }, - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c")}, ipnet.IP6: {}, }, @@ -500,7 +500,7 @@ func TestNormalize(t *testing.T) { ipnet.IP4: nil, ipnet.IP6: detector.NewCloudflare(), }, - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c")}, ipnet.IP6: {}, }, @@ -511,7 +511,7 @@ func TestNormalize(t *testing.T) { ipnet.IP4: nil, ipnet.IP6: nil, }, - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c")}, ipnet.IP6: {}, }, @@ -531,7 +531,7 @@ func TestNormalize(t *testing.T) { ipnet.IP4: nil, ipnet.IP6: detector.NewCloudflare(), }, - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c"), api.FQDN("d.e.f")}, ipnet.IP6: {api.FQDN("a.b.c")}, }, @@ -542,7 +542,7 @@ func TestNormalize(t *testing.T) { ipnet.IP4: nil, ipnet.IP6: detector.NewCloudflare(), }, - Domains: map[ipnet.Type][]api.FQDN{ + Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c"), api.FQDN("d.e.f")}, ipnet.IP6: {api.FQDN("a.b.c")}, }, diff --git a/internal/config/env.go b/internal/config/env.go index 95863adc..ebeaaec0 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -81,17 +81,17 @@ func ReadNonnegInt(ppfmt pp.PP, key string, field *int) bool { // ReadDomains reads an environment variable as a comma-separated list of domains. // Spaces are trimed. -func ReadDomains(ppfmt pp.PP, key string, field *[]api.FQDN) bool { +func ReadDomains(ppfmt pp.PP, key string, field *[]api.Domain) bool { rawList := strings.Split(Getenv(key), ",") - *field = make([]api.FQDN, 0, len(rawList)) + *field = make([]api.Domain, 0, len(rawList)) for _, item := range rawList { item = strings.TrimSpace(item) if item == "" { continue } - item, err := api.NewFQDN(item) + item, err := api.NewDomain(item) if err != nil { ppfmt.Warningf(pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", item.Describe(), err) } diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 45eabbfa..2537ff05 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -250,7 +250,9 @@ func TestReadNonnegInt(t *testing.T) { //nolint:paralleltest // environment vars are global func TestReadDomains(t *testing.T) { key := keyPrefix + "DOMAINS" - type ds = []api.FQDN + type ds = []api.Domain + type f = api.FQDN + type w = api.Wildcard for name, tc := range map[string]struct { set bool val string @@ -259,19 +261,31 @@ func TestReadDomains(t *testing.T) { ok bool prepareMockPP func(*mocks.MockPP) }{ - "nil": {false, "", ds{"test.org"}, ds{}, true, nil}, - "empty": {true, "", ds{"test.org"}, ds{}, true, nil}, - "test1": {true, "書.org , Bücher.org ", ds{"random.org"}, ds{"xn--rov.org", "xn--bcher-kva.org"}, true, nil}, - "test2": {true, " \txn--rov.org , xn--Bcher-kva.org ", ds{"random.org"}, ds{"xn--rov.org", "xn--bcher-kva.org"}, true, nil}, //nolint:lll - "illformed": { + "nil": {false, "", ds{f("test.org")}, ds{}, true, nil}, + "empty": {true, "", ds{f("test.org")}, ds{}, true, nil}, + "star": {true, "*", ds{}, ds{w("")}, true, nil}, + "wildcard1": {true, "*.a", ds{}, ds{w("a")}, true, nil}, + "wildcard2": {true, "*.a.b", ds{}, ds{w("a.b")}, true, nil}, + "test1": {true, "書.org , Bücher.org ", ds{f("random.org")}, ds{f("xn--rov.org"), f("xn--bcher-kva.org")}, true, nil}, //nolint:lll + "test2": {true, " \txn--rov.org , xn--Bcher-kva.org ", ds{f("random.org")}, ds{f("xn--rov.org"), f("xn--bcher-kva.org")}, true, nil}, //nolint:lll + "illformed1": { true, "xn--:D.org", - ds{"random.org"}, - ds{"xn--:d.org"}, + ds{f("random.org")}, + ds{f("xn--:d.org")}, true, func(m *mocks.MockPP) { m.EXPECT().Warningf(pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", "xn--:d.org", gomock.Any()) //nolint:lll }, }, + "illformed2": { + true, "*.xn--:D.org", + ds{f("random.org")}, + ds{w("xn--:d.org")}, + true, + func(m *mocks.MockPP) { + m.EXPECT().Warningf(pp.EmojiUserError, "Domain %q was added but it is ill-formed: %v", "*.xn--:d.org", gomock.Any()) //nolint:lll + }, + }, } { tc := tc t.Run(name, func(t *testing.T) { diff --git a/internal/updator/updator.go b/internal/updator/updator.go index 7ac5b60c..147f95a5 100644 --- a/internal/updator/updator.go +++ b/internal/updator/updator.go @@ -15,7 +15,7 @@ type Args struct { Handle api.Handle IPNetwork ipnet.Type IP net.IP - Domain api.FQDN + Domain api.Domain TTL api.TTL Proxied bool }