Skip to content

Commit

Permalink
fix(api): remove global account ID and sanity checking (#877)
Browse files Browse the repository at this point in the history
  • Loading branch information
favonia committed Aug 16, 2024
1 parent 0fa1085 commit 5a40ea7
Show file tree
Hide file tree
Showing 29 changed files with 758 additions and 1,353 deletions.
17 changes: 1 addition & 16 deletions cmd/ddns/ddns.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func realMain() int { //nolint:funlen
}

// Show the name and the version of the updater
ppfmt.Infof(pp.EmojiStar, formatName())
ppfmt.Infof(pp.EmojiStar, "%s", formatName())

// Warn about root privileges
config.CheckRoot(ppfmt)
Expand Down Expand Up @@ -124,21 +124,6 @@ func realMain() int { //nolint:funlen
if first && !c.UpdateOnStart {
monitor.SuccessAll(ctx, ppfmt, c.Monitors, "Started (no action)")
} else {
if c.UpdateCron != nil { // no need to do sanity check if it's a one-time update
if ok, certain := s.SanityCheck(ctxWithSignals, ppfmt); !ok && certain {
monitor.FailureAll(ctx, ppfmt, c.Monitors, "Invalid Cloudflare API token or account ID")
notifier.SendAll(ctx, ppfmt, c.Notifiers,
"The Cloudflare API token or account ID is invalid. "+
"Please check the values of CF_API_TOKEN, CF_ACCOUNT_ID, and CF_API_TOKEN_FILE.",
)
return 1
}
}

if ctxWithSignals.Err() != nil {
goto signaled
}

msg := updater.UpdateIPs(ctxWithSignals, ppfmt, c, s)
monitor.PingMessageAll(ctx, ppfmt, c.Monitors, msg)
notifier.SendMessageAll(ctx, ppfmt, c.Notifiers, msg)
Expand Down
51 changes: 28 additions & 23 deletions internal/api/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api

import (
"context"
"fmt"
"net/netip"
"time"

Expand All @@ -13,69 +14,73 @@ import (

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

// ID is a new type representing identifiers to avoid programming mistakes.
type ID string

// Describe converts an ID to a string.
func (id ID) Describe() string { return string(id) }

// WAFList represents a WAF list to update.
type WAFList struct {
AccountID ID
ListName string
}

// Describe formats WAFList as a string.
func (l WAFList) Describe() string { return fmt.Sprintf("%s/%s", string(l.AccountID), l.ListName) }

// Record bundles an ID and an IP address, representing a DNS record.
type Record struct {
ID string
ID ID
IP netip.Addr
}

// WAFListItem bundles an ID and an IP range, representing an item in a WAF list.
type WAFListItem struct {
ID string
ID ID
Prefix netip.Prefix
}

// 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 (e.g., the validity of tokens).
// The first return value indicates whether it passes the test.
// The second return value indicates whether we are certain about the result.
SanityCheck(ctx context.Context, ppfmt pp.PP) (bool, bool)

// ListRecords lists all matching DNS records.
//
// The second return value indicates whether the list was cached.
ListRecords(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type) ([]Record, bool, bool)
ListRecords(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, domain domain.Domain) ([]Record, bool, bool)

// DeleteRecord deletes one DNS record.
DeleteRecord(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type, id string) bool
DeleteRecord(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, domain domain.Domain, id ID) bool

// UpdateRecord updates one DNS record.
UpdateRecord(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type, id string, ip netip.Addr) bool
UpdateRecord(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, domain domain.Domain, id ID, ip netip.Addr) bool

// CreateRecord creates one DNS record. It returns the ID of the new record.
CreateRecord(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type,
ip netip.Addr, ttl TTL, proxied bool, recordComment string) (string, bool)
CreateRecord(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, domain domain.Domain,
ip netip.Addr, ttl TTL, proxied bool, recordComment string) (ID, bool)

// EnsureWAFList creates an empty WAF list with IP ranges if it does not already exist yet.
// The first return value is the ID of the list.
// The second return value indicates whether the list already exists.
EnsureWAFList(ctx context.Context, ppfmt pp.PP, listName string, description string) (string, bool, bool)
EnsureWAFList(ctx context.Context, ppfmt pp.PP, list WAFList, description string) (ID, bool, bool)

// DeleteWAFList deletes a WAF list with IP ranges.
DeleteWAFList(ctx context.Context, ppfmt pp.PP, listName string) bool
DeleteWAFList(ctx context.Context, ppfmt pp.PP, list WAFList) bool

// ListWAFListItems retrieves a WAF list with IP rages.
//
// The second return value indicates whether the list was cached.
ListWAFListItems(ctx context.Context, ppfmt pp.PP, listName string) ([]WAFListItem, bool, bool)
ListWAFListItems(ctx context.Context, ppfmt pp.PP, list WAFList) ([]WAFListItem, bool, bool)

// DeleteWAFListItems deletes IP ranges from a WAF list.
DeleteWAFListItems(ctx context.Context, ppfmt pp.PP, listName string, ids []string) bool
DeleteWAFListItems(ctx context.Context, ppfmt pp.PP, list WAFList, ids []ID) bool

// CreateWAFListItems adds IP ranges to a WAF list.
CreateWAFListItems(ctx context.Context, ppfmt pp.PP, listName string, items []netip.Prefix, comment string) bool
CreateWAFListItems(ctx context.Context, ppfmt pp.PP, list WAFList, items []netip.Prefix, comment string) bool
}

// An Auth contains authentication information.
type Auth interface {
// New uses the authentication information to create a Handle.
New(ppfmt pp.PP, cacheExpiration time.Duration) (Handle, bool)

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

// Check whether WAF lists are supported.
SupportsWAFLists() bool
}
191 changes: 19 additions & 172 deletions internal/api/cloudflare.go
Original file line number Diff line number Diff line change
@@ -1,37 +1,31 @@
package api

import (
"context"
"errors"
"time"

"github.com/cloudflare/cloudflare-go"
"github.com/jellydator/ttlcache/v3"

"github.com/favonia/cloudflare-ddns/internal/cron"
"github.com/favonia/cloudflare-ddns/internal/ipnet"
"github.com/favonia/cloudflare-ddns/internal/pp"
)

type sanityCheckType int

const (
sanityCheckToken sanityCheckType = iota
sanityCheckAccount
)
// globalListID pairs up an account ID and a list ID.
type globalListID struct {
Account ID
List ID
}

// CloudflareCache holds the previous repsonses from the Cloudflare API.
type CloudflareCache = struct {
// sanity check
sanityCheck *ttlcache.Cache[sanityCheckType, bool] // whether token or account is valid
// domains to zones
listZones *ttlcache.Cache[string, []string] // zone names to zone IDs
zoneOfDomain *ttlcache.Cache[string, string] // domain names to the zone ID
listZones *ttlcache.Cache[string, []ID] // zone names to zone IDs
zoneOfDomain *ttlcache.Cache[string, ID] // domains to their zone IDs
// records of domains
listRecords map[ipnet.Type]*ttlcache.Cache[string, *[]Record] // domain names to records.
listRecords map[ipnet.Type]*ttlcache.Cache[string, *[]Record] // domains to records.
// lists
listLists *ttlcache.Cache[struct{}, map[string]string] // list names to list IDs
listListItems *ttlcache.Cache[string, []WAFListItem] // list IDs to list items
listLists *ttlcache.Cache[ID, map[string]ID] // account IDs to list names to list IDs
listListItems *ttlcache.Cache[globalListID, []WAFListItem] // list IDs to list items
}

func newCache[K comparable, V any](cacheExpiration time.Duration) *ttlcache.Cache[K, V] {
Expand All @@ -47,16 +41,14 @@ func newCache[K comparable, V any](cacheExpiration time.Duration) *ttlcache.Cach

// A CloudflareHandle implements the [Handle] interface with the Cloudflare API.
type CloudflareHandle struct {
cf *cloudflare.API
accountID string
cache CloudflareCache
cf *cloudflare.API
cache CloudflareCache
}

// A CloudflareAuth implements the [Auth] interface, holding the authentication data to create a [CloudflareHandle].
type CloudflareAuth struct {
Token string
AccountID string
BaseURL string
Token string
BaseURL string
}

// New creates a [CloudflareHandle] from the authentication data.
Expand All @@ -73,37 +65,24 @@ func (t CloudflareAuth) New(ppfmt pp.PP, cacheExpiration time.Duration) (Handle,
}

h := CloudflareHandle{
cf: handle,
accountID: t.AccountID,
cf: handle,
cache: CloudflareCache{
sanityCheck: newCache[sanityCheckType, bool](cacheExpiration),
listZones: newCache[string, []string](cacheExpiration),
zoneOfDomain: newCache[string, string](cacheExpiration),
listZones: newCache[string, []ID](cacheExpiration),
zoneOfDomain: newCache[string, ID](cacheExpiration),
listRecords: map[ipnet.Type]*ttlcache.Cache[string, *[]Record]{
ipnet.IP4: newCache[string, *[]Record](cacheExpiration),
ipnet.IP6: newCache[string, *[]Record](cacheExpiration),
},
listLists: newCache[struct{}, map[string]string](cacheExpiration),
listListItems: newCache[string, []WAFListItem](cacheExpiration),
listLists: newCache[ID, map[string]ID](cacheExpiration),
listListItems: newCache[globalListID, []WAFListItem](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()
h.cache.listZones.DeleteAll()
h.cache.zoneOfDomain.DeleteAll()
for _, cache := range h.cache.listRecords {
Expand All @@ -112,135 +91,3 @@ func (h CloudflareHandle) FlushCache() {
h.cache.listLists.DeleteAll()
h.cache.listListItems.DeleteAll()
}

// errTimeout for checking if it's timeout.
var errTimeout = errors.New("timeout")

func (h CloudflareHandle) skipSanityCheckToken() {
h.cache.sanityCheck.Set(sanityCheckToken, true, ttlcache.DefaultTTL)
}

func (h CloudflareHandle) skipSanityCheck() {
h.skipSanityCheckToken()
h.cache.sanityCheck.Set(sanityCheckAccount, true, ttlcache.DefaultTTL)
}

// SanityCheckToken verifies the Cloudflare token.
func (h CloudflareHandle) SanityCheckToken(ctx context.Context, ppfmt pp.PP) (bool, bool) {
if valid := h.cache.sanityCheck.Get(sanityCheckToken); valid != nil {
return valid.Value(), true
}

quickCtx, cancel := context.WithTimeoutCause(ctx, time.Second, errTimeout)
defer cancel()

res, err := h.cf.VerifyAPIToken(quickCtx)
if err != nil {
if quickCtx.Err() != nil {
return true, false
}

var requestError *cloudflare.RequestError
var authorizationError *cloudflare.AuthorizationError

// known error messages
// 400:6003:"Invalid request headers"
// 400:6111:"Invalid format for Authorization header"
// 401:1000:"Invalid API Token"

switch {
case errors.As(err, &requestError), errors.As(err, &authorizationError):
ppfmt.Errorf(pp.EmojiUserError,
"The Cloudflare API token is invalid; "+
"please check the value of CF_API_TOKEN or CF_API_TOKEN_FILE")
return false, true

default:
// We will try again later.
return true, false
}
}

// The API call succeeded, but the token might be in a bad status.
switch res.Status {
case "active":
case "disabled", "expired":
ppfmt.Errorf(pp.EmojiUserError, "The Cloudflare API token is %s", res.Status)
return false, true
default:
ppfmt.Warningf(pp.EmojiImpossible,
"The Cloudflare API token is in an undocumented state %q; please report this at %s",
res.Status, pp.IssueReportingURL)
return true, false
}

if !res.ExpiresOn.IsZero() {
now := time.Now()
remainingLifespan := max(res.ExpiresOn.Sub(now), 0)

ppfmt.Warningf(pp.EmojiAlarm, "The Cloudflare API token will expire at %s (%v left)",
cron.DescribeIntuitively(now, res.ExpiresOn), remainingLifespan)
}

h.cache.sanityCheck.Set(sanityCheckToken, true, ttlcache.DefaultTTL)
return true, true
}

// SanityCheck verifies both the Cloudflare API token and account ID.
// It returns false only when the token or the account ID is certainly bad.
func (h CloudflareHandle) SanityCheck(ctx context.Context, ppfmt pp.PP) (bool, bool) {
tokenOK, tokenCertain := h.SanityCheckToken(ctx, ppfmt)

if !tokenOK {
return false, tokenCertain
}

// If the account ID is empty, nothing to check other than the token!
if h.accountID == "" {
return true, tokenCertain
}

if valid := h.cache.sanityCheck.Get(sanityCheckAccount); valid != nil {
return valid.Value(), tokenCertain
}

quickCtx, cancel := context.WithTimeoutCause(ctx, time.Second, errTimeout)
defer cancel()

// Checking the account ID
_, _, err := h.cf.Account(quickCtx, h.accountID)
if err != nil {
if quickCtx.Err() != nil {
return true, false
}

var requestError *cloudflare.RequestError
var notFoundError *cloudflare.NotFoundError

// known ambiguous cases
// 403:9109:"Unauthorized to access requested resource": this might actually be okay

// known error messages
// 403:9109:"Invalid account identifier"
// 400:7003:"Could not route to ..., perhaps your object identifier is invalid?"
// 403:7003:"Invalid account identifier"
// 404:7003:"Could not route to ..., perhaps your object identifier is invalid?"

switch {
case errors.As(err, &requestError), errors.As(err, &notFoundError):
ppfmt.Errorf(pp.EmojiUserError,
"The Cloudflare account ID is invalid; "+
"please check the value of CF_ACCOUNT_ID")
return false, true

default:
// We will try again later.
return true, false
}
}

h.skipSanityCheckToken()
h.cache.sanityCheck.Set(sanityCheckAccount, true, ttlcache.DefaultTTL)

return true, true
}
Loading

0 comments on commit 5a40ea7

Please sign in to comment.