Skip to content

Commit

Permalink
feat(detector): re-implement the cdn-cgi/trace parser (#102)
Browse files Browse the repository at this point in the history
* 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`
  • Loading branch information
favonia committed Nov 15, 2021
1 parent ef50403 commit ebf0639
Show file tree
Hide file tree
Showing 9 changed files with 272 additions and 33 deletions.
10 changes: 7 additions & 3 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`

> <details>
> <summary>📜 Available policies for <code>IP4_POLICY</code> and <code>IP6_POLICY</code></summary>
>
> - `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`\
Expand Down
4 changes: 2 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 19 additions & 16 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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", " ",
Expand All @@ -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) {
Expand Down Expand Up @@ -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)"),
Expand Down Expand Up @@ -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")},
Expand All @@ -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{
Expand All @@ -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")},
Expand Down Expand Up @@ -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")},
Expand All @@ -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")},
Expand Down
9 changes: 8 additions & 1 deletion internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
23 changes: 15 additions & 8 deletions internal/config/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
67 changes: 67 additions & 0 deletions internal/detector/cloudflare_trace.go
Original file line number Diff line number Diff line change
@@ -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))
}

0 comments on commit ebf0639

Please sign in to comment.