Skip to content

Commit

Permalink
feat(api): recheck tokens if the network was temporarily down (#790)
Browse files Browse the repository at this point in the history
  • Loading branch information
favonia committed Jul 11, 2024
1 parent d1850b1 commit 15d1a5a
Show file tree
Hide file tree
Showing 9 changed files with 445 additions and 97 deletions.
9 changes: 9 additions & 0 deletions cmd/ddns/ddns.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ func realMain() int { //nolint:funlen
if first && !c.UpdateOnStart {
monitor.SuccessAll(ctx, ppfmt, c.Monitors, "Started (no action)")
} else {
if c.UpdateCron != nil && !s.SanityCheck(ctx, ppfmt) {
monitor.SuccessAll(ctx, ppfmt, c.Monitors, "Invalid Cloudflare API token")
notifier.SendAll(ctx, ppfmt, c.Notifiers,
"The Cloudflare API token is invalid. "+
"Please check the value of CF_API_TOKEN or CF_API_TOKEN_FILE.",
)
return 1
}

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

// A Handle represents a generic API to update DNS records. Currently, the only implementation is Cloudflare.
type Handle interface {
// Perform basic checking. 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.
//
// The second return value means whether the list is cached.
ListRecords(ctx context.Context, ppfmt pp.PP, domain domain.Domain, ipNet ipnet.Type) (map[string]netip.Addr, bool, bool) //nolint:lll

// DeleteRecord deletes one DNS record.
Expand Down
94 changes: 76 additions & 18 deletions internal/api/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

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

Expand Down Expand Up @@ -33,9 +34,11 @@ 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
sanityPermChecked bool
sanityPermPassed bool
accountID string
cache CloudflareCache
}

// A CloudflareAuth implements the [Auth] interface, holding the authentication data to create a [CloudflareHandle].
Expand All @@ -46,7 +49,7 @@ type CloudflareAuth struct {
}

// New creates a [CloudflareHandle] from the authentication data.
func (t *CloudflareAuth) New(ctx context.Context, ppfmt pp.PP, cacheExpiration time.Duration) (Handle, bool) {
func (t *CloudflareAuth) New(_ context.Context, ppfmt pp.PP, cacheExpiration time.Duration) (Handle, bool) {
handle, err := cloudflare.NewWithAPIToken(t.Token)
if err != nil {
ppfmt.Errorf(pp.EmojiUserError, "Failed to prepare the Cloudflare authentication: %v", err)
Expand All @@ -58,19 +61,11 @@ func (t *CloudflareAuth) New(ctx context.Context, ppfmt pp.PP, cacheExpiration t
handle.BaseURL = t.BaseURL
}

// verify Cloudflare token
//
// ideally, we should also verify accountID here, but that is impossible without
// more permissions included in the API token.
if _, err := handle.VerifyAPIToken(ctx); err != nil {
ppfmt.Errorf(pp.EmojiUserError, "The Cloudflare API token could not be verified: %v", err)
ppfmt.Errorf(pp.EmojiUserError, "Please double-check the value of CF_API_TOKEN or CF_API_TOKEN_FILE")
return nil, false
}

return &CloudflareHandle{
cf: handle,
accountID: t.AccountID,
h := &CloudflareHandle{
cf: handle,
sanityPermChecked: false,
sanityPermPassed: false,
accountID: t.AccountID,
cache: CloudflareCache{
listRecords: map[ipnet.Type]*ttlcache.Cache[string, map[string]netip.Addr]{
ipnet.IP4: newCache[string, map[string]netip.Addr](cacheExpiration),
Expand All @@ -79,7 +74,9 @@ func (t *CloudflareAuth) New(ctx context.Context, ppfmt pp.PP, cacheExpiration t
activeZones: newCache[string, []string](cacheExpiration),
zoneOfDomain: newCache[string, string](cacheExpiration),
},
}, true
}

return h, true
}

// FlushCache flushes the API cache.
Expand All @@ -91,6 +88,59 @@ func (h *CloudflareHandle) FlushCache() {
h.cache.zoneOfDomain.DeleteAll()
}

// SanityCheck verifies Cloudflare tokens.
//
// Ideally, we should also verify accountID here, but that is impossible without
// more permissions included in the API token.
func (h *CloudflareHandle) SanityCheck(ctx context.Context, ppfmt pp.PP) bool {
if h.sanityPermChecked {
return h.sanityPermPassed
}

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

ok := true
res, err := h.cf.VerifyAPIToken(quickCtx)
if err != nil {
// Check if the token is permanently invalid...
var aerr *cloudflare.AuthorizationError
var rerr *cloudflare.RequestError
if errors.As(err, &aerr) || errors.As(err, &rerr) {
ppfmt.Errorf(pp.EmojiUserError, "The Cloudflare API token is invalid: %v", err)
ok = false
goto permanently
}
ppfmt.Warningf(pp.EmojiWarning, "Could not verify the Cloudflare API token: %v", err)
return true // It could be that the network times out.
}
switch res.Status {
case "active":
case "disabled", "expired":
ppfmt.Errorf(pp.EmojiUserError, "The Cloudflare API token is %s", res.Status)
ok = false
goto permanently
default:
ppfmt.Errorf(pp.EmojiImpossible, "The Cloudflare API token is in an undocumented state: %s", res.Status)
ppfmt.Errorf(pp.EmojiImpossible, "Please report the bug at https://github.com/favonia/cloudflare-ddns/issues/new") //nolint:lll
ok = false
goto permanently
}

if !res.ExpiresOn.IsZero() {
ppfmt.Warningf(pp.EmojiAlarm, "The token will expire at %s",
res.ExpiresOn.In(time.Local).Format(time.RFC1123Z))
}

permanently:
if !ok {
ppfmt.Errorf(pp.EmojiUserError, "Please double-check the value of CF_API_TOKEN or CF_API_TOKEN_FILE")
}
h.sanityPermChecked = true
h.sanityPermPassed = ok
return ok
}

// ActiveZones returns a list of zone IDs with the zone name.
func (h *CloudflareHandle) ActiveZones(ctx context.Context, ppfmt pp.PP, name string) ([]string, bool) {
// WithZoneFilters does not work with the empty zone name,
Expand All @@ -109,6 +159,14 @@ func (h *CloudflareHandle) ActiveZones(ctx context.Context, ppfmt pp.PP, name st
return nil, false
}

// No need to perform any sanity checking in future. ;-)
//
// This is the best place to force pass the sanity check
// because ListZonesContext will be the first real
// API call.
h.sanityPermChecked = true
h.sanityPermPassed = true

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
Expand Down
Loading

0 comments on commit 15d1a5a

Please sign in to comment.