Skip to content

Commit

Permalink
feat: update WAF lists (WIP)
Browse files Browse the repository at this point in the history
  • Loading branch information
favonia committed Jul 19, 2024
1 parent 3febd1b commit f93c439
Show file tree
Hide file tree
Showing 18 changed files with 978 additions and 286 deletions.
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ linters:
- cyclop # can detect complicated code, but never leads to actual code changes
- funlen # can detect complicated code, but never leads to actual code changes
- gocognit # can detect complicated code, but never leads to actual code changes
- gocyclo # can detect complicated code, but never leads to actual code changes
- maintidx # can detect complicated code, but never leads to actual code changes

- depguard # useless; I do not introduce a dependency carelessly
Expand Down
29 changes: 26 additions & 3 deletions internal/api/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import (

//go:generate mockgen -typed -destination=../mocks/mock_api.go -package=mocks . Handle

// A Handle represents a generic API to update DNS records. Currently, the only implementation is Cloudflare.
// A Handle represents a generic API to update DNS records and WAF lists.
// Currently, the only implementation is Cloudflare.
type Handle interface {
// Perform basic checking. It returns false when we should give up
// all future operations.
// Perform basic checking (e.g., the validity of tokens).
// It returns false when we should give up all future operations.
SanityCheck(ctx context.Context, ppfmt pp.PP) bool

// ListRecords lists all matching DNS records.
Expand All @@ -34,6 +35,22 @@ type Handle interface {
CreateRecord(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type,
ip netip.Addr, ttl TTL, proxied bool, recordComment string) (string, bool)

// EnsureWAFList create an empty WAF list with IP addresses if it does not already exist yet.
EnsureWAFList(ctx context.Context, ppfmt pp.PP, name string, description string) bool

// ListWAFListItems retrieve a WAF list with IP addresses.
//
// The second return value indicates whether the list was cached.
ListWAFListItems(ctx context.Context, ppfmt pp.PP, name string) ([]netip.Prefix, bool, bool)

// ReplaceWAFList update an IP list.
ReplaceWAFList(ctx context.Context, ppfmt pp.PP, name string, items []netip.Prefix) bool

// DeleteWAFList deletes an IP list if it exists. If it does not exists, it is a no-op.
//
// The first return value indicates whether the list did not exist.
DeleteWAFList(ctx context.Context, ppfmt pp.PP, name string) (bool, bool)

// FlushCache flushes the API cache. Flushing should automatically happen when other operations encounter errors.
FlushCache()
}
Expand All @@ -42,4 +59,10 @@ type Handle interface {
type Auth interface {
// New uses the authentication information to create a Handle.
New(ctx context.Context, ppfmt pp.PP, cacheExpiration time.Duration) (Handle, bool)

// Check whether DNS records are supported.
SupportsRecords() bool

// Check whether WAF lists are supported.
SupportsWAFLists() bool
}
251 changes: 17 additions & 234 deletions internal/api/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"github.com/cloudflare/cloudflare-go"
"github.com/jellydator/ttlcache/v3"

"github.com/favonia/cloudflare-ddns/internal/domain"
"github.com/favonia/cloudflare-ddns/internal/ipnet"
"github.com/favonia/cloudflare-ddns/internal/pp"
)
Expand All @@ -23,6 +22,9 @@ type CloudflareCache = struct {
zoneOfDomain *ttlcache.Cache[string, string] // domain names to the zone ID
// records of domains
listRecords map[ipnet.Type]*ttlcache.Cache[string, map[string]netip.Addr] // domain names to IPs
// lists
listLists *ttlcache.Cache[struct{}, map[string][]string] // list names to list IDs
listListItems *ttlcache.Cache[string, []netip.Prefix] // list IDs to list items
}

func newCache[K comparable, V any](cacheExpiration time.Duration) *ttlcache.Cache[K, V] {
Expand Down Expand Up @@ -74,12 +76,24 @@ func (t CloudflareAuth) New(_ context.Context, ppfmt pp.PP, cacheExpiration time
ipnet.IP4: newCache[string, map[string]netip.Addr](cacheExpiration),
ipnet.IP6: newCache[string, map[string]netip.Addr](cacheExpiration),
},
listLists: newCache[struct{}, map[string][]string](cacheExpiration),
listListItems: newCache[string, []netip.Prefix](cacheExpiration),
},
}

return h, true
}

// SupportsRecords checks whether it's good for DNS records.
func (t CloudflareAuth) SupportsRecords() bool {
return t.Token != ""
}

// SupportsWAFLists checks whether it's good for DNS records.
func (t CloudflareAuth) SupportsWAFLists() bool {
return t.Token != "" && t.AccountID != ""
}

// FlushCache flushes the API cache.
func (h CloudflareHandle) FlushCache() {
h.cache.sanityCheck.DeleteAll()
Expand All @@ -88,6 +102,8 @@ func (h CloudflareHandle) FlushCache() {
for _, cache := range h.cache.listRecords {
cache.DeleteAll()
}
h.cache.listLists.DeleteAll()
h.cache.listListItems.DeleteAll()
}

// errTimeout for checking if it's timeout.
Expand Down Expand Up @@ -149,236 +165,3 @@ permanently:
func (h CloudflareHandle) forcePassSanityCheck() {
h.cache.sanityCheck.Set(struct{}{}, true, ttlcache.DefaultTTL)
}

// ListZones returns a list of zone IDs with the zone name.
func (h CloudflareHandle) ListZones(ctx context.Context, ppfmt pp.PP, name string) ([]string, bool) {
// WithZoneFilters does not work with the empty zone name,
// and the owner of the DNS root zone will not be managed by Cloudflare anyways!
if name == "" {
return []string{}, true
}

if ids := h.cache.listZones.Get(name); ids != nil {
return ids.Value(), true
}

res, err := h.cf.ListZonesContext(ctx, cloudflare.WithZoneFilters(name, h.accountID, ""))
if err != nil {
ppfmt.Warningf(pp.EmojiError, "Failed to check the existence of a zone named %q: %v", name, err)
return nil, false
}

// The operation went through. No need to perform any sanity checking in near future!
h.forcePassSanityCheck()

ids := make([]string, 0, len(res.Result))
for _, zone := range res.Result {
// The list of possible statuses was at https://api.cloudflare.com/#zone-list-zones
// but the documentation is missing now.
switch zone.Status {
case "active": // fully working
ids = append(ids, zone.ID)
case
"deactivated", // violating term of service, etc.
"initializing", // the setup was just started?
"moved", // domain registrar not pointing to Cloudflare
"pending": // the setup was not completed
ppfmt.Warningf(pp.EmojiWarning, "Zone %q is %q; your Cloudflare setup is incomplete; some features might not work as expected", name, zone.Status) //nolint:lll
ids = append(ids, zone.ID)
case
"deleted": // archived, pending/moved for too long
ppfmt.Infof(pp.EmojiWarning, "Zone %q is %q and thus skipped", name, zone.Status)
// skip these
default:
ppfmt.Warningf(pp.EmojiImpossible, "Zone %q is in an undocumented status %q", name, zone.Status)
ppfmt.Warningf(pp.EmojiImpossible, "Please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new") //nolint:lll
ids = append(ids, zone.ID)
}
}

h.cache.listZones.Set(name, ids, ttlcache.DefaultTTL)

return ids, true
}

// ZoneOfDomain finds the active zone ID governing a particular domain.
func (h CloudflareHandle) ZoneOfDomain(ctx context.Context, ppfmt pp.PP, domain domain.Domain) (string, bool) {
if id := h.cache.zoneOfDomain.Get(domain.DNSNameASCII()); id != nil {
return id.Value(), true
}

zoneSearch:
for s := domain.Split(); s.IsValid(); s = s.Next() {
zoneName := s.ZoneNameASCII()
zones, ok := h.ListZones(ctx, ppfmt, zoneName)
if !ok {
return "", false
}

// The operation went through. No need to perform any sanity checking in near future!
h.forcePassSanityCheck()

switch len(zones) {
case 0: // len(zones) == 0
continue zoneSearch
case 1: // len(zones) == 1
h.cache.zoneOfDomain.Set(domain.DNSNameASCII(), zones[0], ttlcache.DefaultTTL)
return zones[0], true
default: // len(zones) > 1
ppfmt.Warningf(pp.EmojiImpossible, "Found multiple active zones named %q. Specifying CF_ACCOUNT_ID might help", zoneName) //nolint:lll
ppfmt.Warningf(pp.EmojiImpossible, "Please consider reporting this rare situation at https://github.com/favonia/cloudflare-ddns/issues/new") //nolint:lll
return "", false
}
}

ppfmt.Warningf(pp.EmojiError, "Failed to find the zone of %q", domain.Describe())
if h.accountID != "" {
ppfmt.Infof(pp.EmojiHint, "Double-check the value of CF_ACCOUNT_ID; in most cases, you can leave it blank")
}

return "", false
}

// ListRecords lists all matching DNS records. The second return value indicates whether
// the lists are from cached responses.
func (h CloudflareHandle) ListRecords(ctx context.Context, ppfmt pp.PP,
domain domain.Domain, ipNet ipnet.Type,
) (map[string]netip.Addr, bool, bool) {
if rmap := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); rmap != nil {
return rmap.Value(), true, true
}

zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
if !ok {
return nil, false, false
}

// The operation went through. No need to perform any sanity checking in near future!
h.forcePassSanityCheck()

//nolint:exhaustruct // Other fields are intentionally unspecified
rs, _, err := h.cf.ListDNSRecords(ctx,
cloudflare.ZoneIdentifier(zone),
cloudflare.ListDNSRecordsParams{
Name: domain.DNSNameASCII(),
Type: ipNet.RecordType(),
})
if err != nil {
ppfmt.Warningf(pp.EmojiError, "Failed to retrieve records of %q: %v", domain.Describe(), err)
return nil, false, false
}

rmap := map[string]netip.Addr{}
for i := range rs {
rmap[rs[i].ID], err = netip.ParseAddr(rs[i].Content)
if err != nil {
ppfmt.Warningf(pp.EmojiImpossible, "Failed to parse the IP address in records of %q: %v", domain.Describe(), err)
return nil, false, false
}
}

h.cache.listRecords[ipNet].Set(domain.DNSNameASCII(), rmap, ttlcache.DefaultTTL)

return rmap, false, true
}

// DeleteRecord deletes one DNS record.
func (h CloudflareHandle) DeleteRecord(ctx context.Context, ppfmt pp.PP,
domain domain.Domain, ipNet ipnet.Type, id string,
) bool {
zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
if !ok {
return false
}

if err := h.cf.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(zone), id); err != nil {
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.DNSNameASCII())

return false
}

// The operation went through. No need to perform any sanity checking in near future!
h.forcePassSanityCheck()

if rmap := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); rmap != nil {
delete(rmap.Value(), id)
}

return true
}

// UpdateRecord updates one DNS record.
func (h CloudflareHandle) UpdateRecord(ctx context.Context, ppfmt pp.PP,
domain domain.Domain, ipNet ipnet.Type, id string, ip netip.Addr,
) bool {
zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
if !ok {
return false
}

//nolint:exhaustruct // Other fields are intentionally omitted
params := cloudflare.UpdateDNSRecordParams{
ID: id,
Content: ip.String(),
}

if _, err := h.cf.UpdateDNSRecord(ctx, cloudflare.ZoneIdentifier(zone), params); err != nil {
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.DNSNameASCII())

return false
}

// The operation went through. No need to perform any sanity checking in near future!
h.forcePassSanityCheck()

if rmap := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); rmap != nil {
rmap.Value()[id] = ip
}

return true
}

// CreateRecord creates one DNS record.
func (h CloudflareHandle) CreateRecord(ctx context.Context, ppfmt pp.PP,
domain domain.Domain, ipNet ipnet.Type, ip netip.Addr, ttl TTL, proxied bool, recordComment string,
) (string, bool) {
zone, ok := h.ZoneOfDomain(ctx, ppfmt, domain)
if !ok {
return "", false
}

//nolint:exhaustruct // Other fields are intentionally omitted
params := cloudflare.CreateDNSRecordParams{
Name: domain.DNSNameASCII(),
Type: ipNet.RecordType(),
Content: ip.String(),
TTL: ttl.Int(),
Proxied: &proxied,
Comment: recordComment,
}

res, err := h.cf.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(zone), params)
if err != nil {
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.DNSNameASCII())

return "", false
}

// The operation went through. No need to perform any sanity checking in near future!
h.forcePassSanityCheck()

if rmap := h.cache.listRecords[ipNet].Get(domain.DNSNameASCII()); rmap != nil {
rmap.Value()[res.ID] = ip
}

return res.ID, true
}
Loading

0 comments on commit f93c439

Please sign in to comment.