From ebf06395c341b97a9f2e3c8618cc21eed2365b3d Mon Sep 17 00:00:00 2001 From: favonia Date: Sun, 14 Nov 2021 19:58:44 -0600 Subject: [PATCH] feat(detector): re-implement the cdn-cgi/trace parser (#102) * feat(detector): re-implement the cdn-cgi/trace parser * feat(config): accept policies cloudflare.{trace,doh} * docs(README): new policies `cloudflare.doh` and `cloudflare.trace` --- README.markdown | 10 +- internal/config/config.go | 4 +- internal/config/config_test.go | 35 ++-- internal/config/env.go | 9 +- internal/config/env_test.go | 23 ++- .../{cloudflare.go => cloudflare_doh.go} | 4 +- ...udflare_test.go => cloudflare_doh_test.go} | 2 +- internal/detector/cloudflare_trace.go | 67 ++++++++ internal/detector/cloudflare_trace_test.go | 151 ++++++++++++++++++ 9 files changed, 272 insertions(+), 33 deletions(-) rename internal/detector/{cloudflare.go => cloudflare_doh.go} (87%) rename internal/detector/{cloudflare_test.go => cloudflare_doh_test.go} (71%) create mode 100644 internal/detector/cloudflare_trace.go create mode 100644 internal/detector/cloudflare_trace_test.go diff --git a/README.markdown b/README.markdown index ca76bc0a..4e33e4e7 100644 --- a/README.markdown +++ b/README.markdown @@ -35,7 +35,7 @@ A small and fast DDNS updater for Cloudflare. ## 🕵️ Privacy -By default, public IP addresses are obtained using [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/dns-over-https). This minimizes the impact on privacy because we are already using the Cloudflare API to update DNS records. Moreover, if Cloudflare servers are not reachable, chances are you could not update DNS records anyways. You can also configure the tool to use [ipify](https://www.ipify.org), which claims not to log any visitor information. +By default, public IP addresses are obtained using the [Cloudflare debugging page](https://1.1.1.1/cdn-cgi/trace). This minimizes the impact on privacy because we are already using the Cloudflare API to update DNS records. Moreover, if Cloudflare servers are not reachable, chances are you could not update DNS records anyways. You can also configure the tool to use [ipify](https://www.ipify.org), which claims not to log any visitor information. ## 🛡️ Security @@ -276,15 +276,19 @@ In most cases, `CF_ACCOUNT_ID` is not needed. | ---- | ------------ | ------- | --------- | ------------- | | `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` +| `IP4_POLICY` | `cloudflare`, `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `unmanaged` | How to detect IPv4 addresses. (See below) | No | `cloudflare.trace` | `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` +| `IP6_POLICY` | `cloudflare`, `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `unmanaged` | How to detect IPv6 addresses. (See below) | No | `cloudflare.trace` >
> 📜 Available policies for IP4_POLICY and IP6_POLICY > > - `cloudflare`\ +> Deprecated; currently an alias of `cloudflare.trace`. +> - `cloudflare.doh`\ > Get the public IP address by querying `whoami.cloudflare.` against [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/dns-over-https) and update DNS records accordingly. +> - `cloudflare.trace`\ +> Get the public IP address by parsing the [Cloudflare debugging page](https://1.1.1.1/cdn-cgi/trace) and update DNS records accordingly. > - `ipify`\ > Get the public IP address via [ipify’s public API](https://www.ipify.org/) and update DNS records accordingly. > - `local`\ diff --git a/internal/config/config.go b/internal/config/config.go index 3ed8f695..a4553d03 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,8 +30,8 @@ func Default() *Config { return &Config{ Auth: nil, Policy: map[ipnet.Type]detector.Policy{ - ipnet.IP4: detector.NewCloudflare(), - ipnet.IP6: detector.NewCloudflare(), + ipnet.IP4: detector.NewCloudflareTrace(), + ipnet.IP6: detector.NewCloudflareTrace(), }, Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: nil, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a575e931..7d9eca55 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -202,10 +202,11 @@ func TestReadDomainMap(t *testing.T) { //nolint:funlen,paralleltest // environment vars are global func TestReadPolicyMap(t *testing.T) { var ( - unmanaged detector.Policy - cloudflare = detector.NewCloudflare() - local = detector.NewLocal() - ipify = detector.NewIpify() + unmanaged detector.Policy + cloudflareTrace = detector.NewCloudflareTrace() + cloudflareDOH = detector.NewCloudflareDOH() + local = detector.NewLocal() + ipify = detector.NewIpify() ) for name, tc := range map[string]struct { @@ -218,11 +219,13 @@ func TestReadPolicyMap(t *testing.T) { "full": { "cloudflare", "ipify", map[ipnet.Type]detector.Policy{ - ipnet.IP4: cloudflare, + ipnet.IP4: cloudflareTrace, ipnet.IP6: ipify, }, true, - nil, + func(m *mocks.MockPP) { + m.EXPECT().Warningf(pp.EmojiUserWarning, `The policy "cloudflare" was deprecated; use "cloudflare.doh" or "cloudflare.trace" instead.`) + }, }, "4": { "local", " ", @@ -236,10 +239,10 @@ func TestReadPolicyMap(t *testing.T) { }, }, "6": { - " ", "ipify", + " ", "cloudflare.doh", map[ipnet.Type]detector.Policy{ ipnet.IP4: unmanaged, - ipnet.IP6: ipify, + ipnet.IP6: cloudflareDOH, }, true, func(m *mocks.MockPP) { @@ -305,9 +308,9 @@ func TestPrintDefault(t *testing.T) { mockPP.EXPECT().IncIndent().Return(mockPP), 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 policy: %s", "cloudflare.trace"), 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 policy: %s", "cloudflare.trace"), 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)"), @@ -469,8 +472,8 @@ func TestNormalize(t *testing.T) { "empty-ip6": { input: &config.Config{ //nolint:exhaustivestruct Policy: map[ipnet.Type]detector.Policy{ - ipnet.IP4: detector.NewCloudflare(), - ipnet.IP6: detector.NewCloudflare(), + ipnet.IP4: detector.NewCloudflareTrace(), + ipnet.IP6: detector.NewCloudflareTrace(), }, Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c")}, @@ -480,7 +483,7 @@ func TestNormalize(t *testing.T) { ok: true, expected: &config.Config{ //nolint:exhaustivestruct Policy: map[ipnet.Type]detector.Policy{ - ipnet.IP4: detector.NewCloudflare(), + ipnet.IP4: detector.NewCloudflareTrace(), ipnet.IP6: nil, }, Domains: map[ipnet.Type][]api.Domain{ @@ -498,7 +501,7 @@ func TestNormalize(t *testing.T) { input: &config.Config{ //nolint:exhaustivestruct Policy: map[ipnet.Type]detector.Policy{ ipnet.IP4: nil, - ipnet.IP6: detector.NewCloudflare(), + ipnet.IP6: detector.NewCloudflareTrace(), }, Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c")}, @@ -529,7 +532,7 @@ func TestNormalize(t *testing.T) { input: &config.Config{ //nolint:exhaustivestruct Policy: map[ipnet.Type]detector.Policy{ ipnet.IP4: nil, - ipnet.IP6: detector.NewCloudflare(), + ipnet.IP6: detector.NewCloudflareTrace(), }, Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c"), api.FQDN("d.e.f")}, @@ -540,7 +543,7 @@ func TestNormalize(t *testing.T) { expected: &config.Config{ //nolint:exhaustivestruct Policy: map[ipnet.Type]detector.Policy{ ipnet.IP4: nil, - ipnet.IP6: detector.NewCloudflare(), + ipnet.IP6: detector.NewCloudflareTrace(), }, Domains: map[ipnet.Type][]api.Domain{ ipnet.IP4: {api.FQDN("a.b.c"), api.FQDN("d.e.f")}, diff --git a/internal/config/env.go b/internal/config/env.go index ebeaaec0..256060ef 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -109,7 +109,14 @@ func ReadPolicy(ppfmt pp.PP, key string, field *detector.Policy) bool { ppfmt.Infof(pp.EmojiBullet, "Use default %s=%s", key, detector.Name(*field)) return true case "cloudflare": - *field = detector.NewCloudflare() + ppfmt.Warningf(pp.EmojiUserWarning, `The policy "cloudflare" was deprecated; use "cloudflare.doh" or "cloudflare.trace" instead.`) + *field = detector.NewCloudflareTrace() + return true + case "cloudflare.trace": + *field = detector.NewCloudflareTrace() + return true + case "cloudflare.doh": + *field = detector.NewCloudflareDOH() return true case "ipify": *field = detector.NewIpify() diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 2537ff05..6248b970 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -310,10 +310,11 @@ func TestReadPolicy(t *testing.T) { key := keyPrefix + "POLICY" var ( - unmanaged detector.Policy - cloudflare = detector.NewCloudflare() - local = detector.NewLocal() - ipify = detector.NewIpify() + unmanaged detector.Policy + cloudflareDOH = detector.NewCloudflareDOH() + cloudflareTrace = detector.NewCloudflareTrace() + local = detector.NewLocal() + ipify = detector.NewIpify() ) for name, tc := range map[string]struct { @@ -336,10 +337,16 @@ func TestReadPolicy(t *testing.T) { m.EXPECT().Infof(pp.EmojiBullet, "Use default %s=%s", "TEST-11D39F6A9A97AFAFD87CCEB-POLICY", "local") }, }, - "cloudflare": {true, " cloudflare\t ", unmanaged, cloudflare, true, nil}, - "unmanaged": {true, " unmanaged ", cloudflare, unmanaged, true, nil}, - "local": {true, " local ", cloudflare, local, true, nil}, - "ipify": {true, " ipify ", cloudflare, ipify, true, nil}, + "cloudflare": {true, " cloudflare\t ", unmanaged, cloudflareTrace, true, + func(m *mocks.MockPP) { + m.EXPECT().Warningf(pp.EmojiUserWarning, `The policy "cloudflare" was deprecated; use "cloudflare.doh" or "cloudflare.trace" instead.`) + }, + }, + "cloudflare.trace": {true, " cloudflare.trace", unmanaged, cloudflareTrace, true, nil}, + "cloudflare.doh": {true, " \tcloudflare.doh ", unmanaged, cloudflareDOH, true, nil}, + "unmanaged": {true, " unmanaged ", cloudflareTrace, unmanaged, true, nil}, + "local": {true, " local ", cloudflareTrace, local, true, nil}, + "ipify": {true, " ipify ", cloudflareTrace, ipify, true, nil}, "others": { true, " something-else ", ipify, ipify, false, func(m *mocks.MockPP) { diff --git a/internal/detector/cloudflare.go b/internal/detector/cloudflare_doh.go similarity index 87% rename from internal/detector/cloudflare.go rename to internal/detector/cloudflare_doh.go index cccbeeb8..64d6cd7c 100644 --- a/internal/detector/cloudflare.go +++ b/internal/detector/cloudflare_doh.go @@ -6,9 +6,9 @@ import ( "github.com/favonia/cloudflare-ddns/internal/ipnet" ) -func NewCloudflare() Policy { +func NewCloudflareDOH() Policy { return &DNSOverHTTPS{ - PolicyName: "cloudflare", + PolicyName: "cloudflare.doh", Param: map[ipnet.Type]struct { URL string Name string diff --git a/internal/detector/cloudflare_test.go b/internal/detector/cloudflare_doh_test.go similarity index 71% rename from internal/detector/cloudflare_test.go rename to internal/detector/cloudflare_doh_test.go index 7a8b1c3e..09be7505 100644 --- a/internal/detector/cloudflare_test.go +++ b/internal/detector/cloudflare_doh_test.go @@ -11,5 +11,5 @@ import ( func TestCloudflareName(t *testing.T) { t.Parallel() - require.Equal(t, "cloudflare", detector.Name(detector.NewCloudflare())) + require.Equal(t, "cloudflare.doh", detector.Name(detector.NewCloudflareDOH())) } diff --git a/internal/detector/cloudflare_trace.go b/internal/detector/cloudflare_trace.go new file mode 100644 index 00000000..42426790 --- /dev/null +++ b/internal/detector/cloudflare_trace.go @@ -0,0 +1,67 @@ +package detector + +import ( + "context" + "net" + "net/http" + "regexp" + + "github.com/favonia/cloudflare-ddns/internal/ipnet" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +func getIPFromCloudflareTrace(ctx context.Context, ppfmt pp.PP, url string, field string) net.IP { + c := httpConn{ + url: url, + method: http.MethodGet, + contentType: "", + accept: "", + reader: nil, + extract: func(ppfmt pp.PP, body []byte) net.IP { + re := regexp.MustCompile(`(?m:^` + regexp.QuoteMeta(field) + `=(.*)$)`) + matched := re.FindSubmatch(body) + if matched == nil { + ppfmt.Errorf(pp.EmojiImpossible, `Failed to find the IP address in the response of %q: %s`, url, body) + return nil + } + return net.ParseIP(string(matched[1])) + }, + } + + return c.getIP(ctx, ppfmt) +} + +type CloudflareTrace struct { + PolicyName string + Param map[ipnet.Type]struct { + URL string + Field string + } +} + +func NewCloudflareTrace() Policy { + return &CloudflareTrace{ + PolicyName: "cloudflare.trace", + Param: map[ipnet.Type]struct { + URL string + Field string + }{ + ipnet.IP4: {"https://1.1.1.1/cdn-cgi/trace", "ip"}, + ipnet.IP6: {"https://[2606:4700:4700::1111]/cdn-cgi/trace", "ip"}, + }, + } +} + +func (p *CloudflareTrace) name() string { + return p.PolicyName +} + +func (p *CloudflareTrace) GetIP(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type) net.IP { + param, found := p.Param[ipNet] + if !found { + ppfmt.Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe()) + return nil + } + + return NormalizeIP(ppfmt, ipNet, getIPFromCloudflareTrace(ctx, ppfmt, param.URL, param.Field)) +} diff --git a/internal/detector/cloudflare_trace_test.go b/internal/detector/cloudflare_trace_test.go new file mode 100644 index 00000000..8bf13355 --- /dev/null +++ b/internal/detector/cloudflare_trace_test.go @@ -0,0 +1,151 @@ +package detector_test + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + "github.com/favonia/cloudflare-ddns/internal/detector" + "github.com/favonia/cloudflare-ddns/internal/ipnet" + "github.com/favonia/cloudflare-ddns/internal/mocks" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +func TestCloudflareTraceName(t *testing.T) { + t.Parallel() + + policy := &detector.CloudflareTrace{ + PolicyName: "very secret name", + Param: nil, + } + + require.Equal(t, "very secret name", detector.Name(policy)) +} + +//nolint:funlen +func TestCloudflareTraceGetIP(t *testing.T) { + ip4 := net.ParseIP("1.2.3.4").To4() + ip6 := net.ParseIP("::1:2:3:4:5:6").To16() + + ip4Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "hi=123\nhello4="+ip4.String()+"\naloha=456") + })) + defer ip4Server.Close() + ip6Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "hi=123\nhello6="+ip6.String()+"\naloha=456") + })) + defer ip6Server.Close() + dummy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "ip=none") + })) + defer dummy.Close() + + t.Run("group", func(t *testing.T) { + for name, tc := range map[string]struct { + urlKey ipnet.Type + url string + field string + ipNet ipnet.Type + expected net.IP + prepareMockPP func(*mocks.MockPP) + }{ + "4": {ipnet.IP4, ip4Server.URL, "hello4", ipnet.IP4, ip4, nil}, + "6": {ipnet.IP6, ip6Server.URL, "hello6", ipnet.IP6, ip6, nil}, + "4to6": {ipnet.IP6, ip4Server.URL, "hello4", ipnet.IP6, ip4.To16(), nil}, + "6to4": { + ipnet.IP4, ip6Server.URL, "hello6", ipnet.IP4, nil, + func(m *mocks.MockPP) { + m.EXPECT().Warningf( + pp.EmojiError, "%q is not a valid %s address", + ip6, + "IPv4", + ) + }, + }, + "4-nil1": {ipnet.IP4, dummy.URL, "ip", ipnet.IP4, nil, nil}, + "6-nil1": {ipnet.IP6, dummy.URL, "ip", ipnet.IP6, nil, nil}, + "4-nil2": { + ipnet.IP4, "", "", ipnet.IP4, nil, + func(m *mocks.MockPP) { + m.EXPECT().Warningf( + pp.EmojiError, + "Failed to send HTTP(S) request to %q: %v", + "", + gomock.Any(), + ) + }, + }, + "6-nil2": { + ipnet.IP6, "", "", ipnet.IP6, nil, + func(m *mocks.MockPP) { + m.EXPECT().Warningf( + pp.EmojiError, + "Failed to send HTTP(S) request to %q: %v", + "", + gomock.Any(), + ) + }, + }, + "4-nil3": { + ipnet.IP4, ip4Server.URL, "hello4", ipnet.IP6, nil, + func(m *mocks.MockPP) { + m.EXPECT().Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6") + }, + }, + "6-nil3": { + ipnet.IP6, ip6Server.URL, "hello6", ipnet.IP4, nil, + func(m *mocks.MockPP) { + m.EXPECT().Warningf(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4") + }, + }, + "4-nil4": {ipnet.IP4, dummy.URL, "nonexisting4", ipnet.IP4, nil, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiImpossible, + `Failed to find the IP address in the response of %q: %s`, + dummy.URL, + []byte("ip=none")) + }, + }, + "6-nil4": {ipnet.IP6, dummy.URL, "nonexisting6", ipnet.IP6, nil, + func(m *mocks.MockPP) { + m.EXPECT().Errorf(pp.EmojiImpossible, + `Failed to find the IP address in the response of %q: %s`, + dummy.URL, + []byte("ip=none")) + }, + }, + } { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + mockCtrl := gomock.NewController(t) + + policy := &detector.CloudflareTrace{ + PolicyName: "", + Param: map[ipnet.Type]struct { + URL string + Field string + }{ + tc.urlKey: { + URL: tc.url, + Field: tc.field, + }, + }, + } + + mockPP := mocks.NewMockPP(mockCtrl) + if tc.prepareMockPP != nil { + tc.prepareMockPP(mockPP) + } + ip := policy.GetIP(context.Background(), mockPP, tc.ipNet) + require.Equal(t, tc.expected, ip) + }) + } + }) +}