7 Commits

Author SHA1 Message Date
mkelcik
2d52cbe920 Merge pull request #8 from mkelcik/new-1_1_1_1_resolver
Add 1.1.1.1 resolver
2023-05-03 23:17:20 +02:00
mkelcik
6f1b45cf8a Add 1.1.1.1 resolver 2023-05-03 23:06:57 +02:00
mkelcik
f859e86a08 Merge pull request #7 from mkelcik/badges
Badges
2023-05-01 12:45:12 +02:00
mkelcik
bbcc6eaa44 Update README.md 2023-05-01 12:44:40 +02:00
mkelcik
3222a6c54c Update README.md 2023-05-01 12:43:19 +02:00
mkelcik
796e7b53fa Merge pull request #6 from mkelcik/new_ident_me_resolver
Add v4.ident.me resolver
2023-05-01 10:04:04 +02:00
mkelcik
8412e68929 Add v4.ident.me resolver 2023-05-01 10:01:43 +02:00
10 changed files with 338 additions and 46 deletions

2
Makefile Normal file
View File

@@ -0,0 +1,2 @@
test:
go test --cover -covermode count -v ./...

View File

@@ -1,3 +1,5 @@
![Code and security checks](https://github.com/mkelcik/cloudflare-ddns-update/actions/workflows/quality-checks.yml/badge.svg)
## What is Cloudflare Dynamic DNS? ## What is Cloudflare Dynamic DNS?
DNS records are static, and it does not play well with dynamic IP addresses. Now, to solve that problem, youll need to set up dynamic DNS. Cloudflare provides an API that allows you to manage DNS records programmatically. DNS records are static, and it does not play well with dynamic IP addresses. Now, to solve that problem, youll need to set up dynamic DNS. Cloudflare provides an API that allows you to manage DNS records programmatically.
@@ -15,7 +17,7 @@ Before run, you need configure this environment variables.
- `CLOUDFLARE_ZONE` - (required) zone name with domain you want to check. See: [https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones](https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones) - `CLOUDFLARE_ZONE` - (required) zone name with domain you want to check. See: [https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones](https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones)
- `ON_CHANGE_COMMENT` - (optional) in the event that the ip address of the dns record changes, this comment will be added to the record - `ON_CHANGE_COMMENT` - (optional) in the event that the ip address of the dns record changes, this comment will be added to the record
- `CHECK_INTERVAL_SECONDS` - (optional) how often will the ip address of the records be checked (default: `300`) - `CHECK_INTERVAL_SECONDS` - (optional) how often will the ip address of the records be checked (default: `300`)
- `PUBLIC_IP_RESOLVER` - (optional) public ip address resolver. For now only resolving via `https://ifconfig.me` is implemented. (default: `ifconfig.me`) - `PUBLIC_IP_RESOLVER` - (optional) public ip address resolver. (default: `ifconfig.me`) Available: `ifconfig.me`, `v4.ident.me`, `1.1.1.1`
### Building from source ### Building from source
@@ -43,6 +45,7 @@ version: "3"
services: services:
cf-dns-updater: cf-dns-updater:
image: mkelcik/cloudflare-ddns-update:latest image: mkelcik/cloudflare-ddns-update:latest
restart: unless-stopped
environment: environment:
- CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com - CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com
- CLOUDFLARE_API_KEY=your_cloudflare_api_key - CLOUDFLARE_API_KEY=your_cloudflare_api_key

View File

@@ -2,6 +2,7 @@ version: "3"
services: services:
cf-dns-updater: cf-dns-updater:
image: mkelcik/cloudflare-ddns-update:latest image: mkelcik/cloudflare-ddns-update:latest
restart: unless-stopped
environment: environment:
- CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com - CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com
- CLOUDFLARE_API_KEY=your_cloudflare_api_key - CLOUDFLARE_API_KEY=your_cloudflare_api_key

12
main.go
View File

@@ -17,13 +17,17 @@ type PublicIpResolver interface {
ResolvePublicIp(ctx context.Context) (net.IP, error) ResolvePublicIp(ctx context.Context) (net.IP, error)
} }
func getResolver(resolverName string) PublicIpResolver { func getResolver(resolverName string) (PublicIpResolver, string) {
switch resolverName { switch resolverName {
// HERE add another resolver if needed // HERE add another resolver if needed
case public_resolvers.CloudflareTraceTag:
return public_resolvers.NewDefaultCloudflareTrace(), public_resolvers.CloudflareTraceTag
case public_resolvers.V4IdentMeTag:
return public_resolvers.NewV4IdentMeDefault(), public_resolvers.V4IdentMeTag
case public_resolvers.IfConfigMeTag: case public_resolvers.IfConfigMeTag:
fallthrough fallthrough
default: default:
return public_resolvers.NewDefaultIfConfigMe() return public_resolvers.NewDefaultIfConfigMe(), public_resolvers.IfConfigMeTag
} }
} }
@@ -48,14 +52,14 @@ func main() {
} }
// public ip resolver // public ip resolver
publicIpResolver := getResolver(config.PublicIpResolverTag) publicIpResolver, resolverTag := getResolver(config.PublicIpResolverTag)
checkFunc := func() { checkFunc := func() {
currentPublicIP, err := publicIpResolver.ResolvePublicIp(ctx) currentPublicIP, err := publicIpResolver.ResolvePublicIp(ctx)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
log.Printf("Current public ip `%s`", currentPublicIP) log.Printf("Current public ip `%s` (resolver: %s)", currentPublicIP, resolverTag)
dns, err := allDNSRecords(ctx, api, cloudflare.ZoneIdentifier(zoneID)) dns, err := allDNSRecords(ctx, api, cloudflare.ZoneIdentifier(zoneID))
if err != nil { if err != nil {

View File

@@ -0,0 +1,55 @@
package public_resolvers
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
)
var NoIPInResponseError = errors.New("no ip found in response")
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
type ipParserFunc func(reader io.Reader) (string, error)
func defaultIpParser(reader io.Reader) (string, error) {
out, err := io.ReadAll(reader)
return string(out), err
}
type baseResolver struct {
client Doer
url string
ipParser ipParserFunc
}
func (i baseResolver) ResolvePublicIp(ctx context.Context) (net.IP, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, i.url, nil)
if err != nil {
return net.IP{}, fmt.Errorf("error creating ifconfig request: %w", err)
}
resp, err := i.client.Do(req)
if err != nil {
return net.IP{}, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return net.IP{}, fmt.Errorf("unexpected response code %d", resp.StatusCode)
}
ipText, err := i.ipParser(resp.Body)
if err != nil {
return net.IP{}, fmt.Errorf("error reading body: %w", err)
}
return net.ParseIP(ipText), nil
}

View File

@@ -0,0 +1,100 @@
package public_resolvers
import (
"bytes"
"context"
"io"
"net"
"net/http"
"reflect"
"testing"
)
// RoundTripFunc .
type RoundTripFunc func(req *http.Request) *http.Response
// RoundTrip .
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
// NewTestClient returns *http.Client with Transport replaced to avoid making real calls
func NewTestClient(fn RoundTripFunc) *http.Client {
return &http.Client{
Transport: fn,
}
}
func Test_baseResolver_ResolvePublicIp(t *testing.T) {
testUrl := "http://my-test-url.url"
testIp := `192.168.0.100`
client := NewTestClient(func(req *http.Request) *http.Response {
if req.URL.String() != testUrl {
return &http.Response{
StatusCode: 500,
// Send response to be tested
Body: io.NopCloser(bytes.NewBufferString(`invalid url`)),
// Must be set to non-nil value or it panics
Header: make(http.Header),
}
}
return &http.Response{
StatusCode: 200,
// Send response to be tested
Body: io.NopCloser(bytes.NewBufferString(testIp)),
// Must be set to non-nil value or it panics
Header: make(http.Header),
}
})
type fields struct {
client Doer
url string
fn ipParserFunc
}
type args struct {
ctx context.Context
}
tests := []struct {
name string
fields fields
args args
want net.IP
wantErr bool
}{
{
name: "check parse ip4",
fields: fields{
client: client,
url: testUrl,
fn: defaultIpParser,
},
args: args{
ctx: context.Background(),
},
want: net.ParseIP(testIp),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := baseResolver{
client: tt.fields.client,
url: tt.fields.url,
ipParser: tt.fields.fn,
}
got, err := i.ResolvePublicIp(tt.args.ctx)
if (err != nil) != tt.wantErr {
t.Errorf("ResolvePublicIp() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ResolvePublicIp() got = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,49 @@
package public_resolvers
import (
"io"
"net/http"
"strings"
"time"
)
const (
CloudflareTraceTag = "1.1.1.1"
CloudflareTraceUrl = "https://1.1.1.1/cdn-cgi/trace"
ipPrefix = "ip="
)
type CloudflareTrace struct {
baseResolver
}
func NewDefaultCloudflareTrace() *CloudflareTrace {
return NewCloudflareTrace(&http.Client{
Timeout: 10 * time.Second,
})
}
func cloudflareTraceResponseParser(reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
for _, row := range strings.Split(string(data), "\n") {
if strings.Index(row, ipPrefix) == 0 {
return strings.TrimSpace(strings.ReplaceAll(row, ipPrefix, "")), nil
}
}
return "", NoIPInResponseError
}
func NewCloudflareTrace(client Doer) *CloudflareTrace {
return &CloudflareTrace{
baseResolver: baseResolver{
client: client,
url: CloudflareTraceUrl,
ipParser: cloudflareTraceResponseParser,
},
}
}

View File

@@ -0,0 +1,78 @@
package public_resolvers
import (
"bytes"
"io"
"testing"
)
func Test_cloudflareTraceResponseParser(t *testing.T) {
type args struct {
reader io.Reader
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "ok",
args: args{
reader: bytes.NewBuffer([]byte(`fl=31f118
h=1.1.1.1
ip=94.113.142.206
ts=1683145336.383
visit_scheme=https
uag=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36
colo=PRG
sliver=none
http=http/2
loc=CZ
tls=TLSv1.3
sni=off
warp=off
gateway=off
rbi=off
kex=X25519`)),
},
want: "94.113.142.206",
wantErr: false,
},
{
name: "no ip in response",
args: args{
reader: bytes.NewBuffer([]byte(`fl=31f118
h=1.1.1.1
ts=1683145336.383
visit_scheme=https
uag=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36
colo=PRG
sliver=none
http=http/2
loc=CZ
tls=TLSv1.3
sni=off
warp=off
gateway=off
rbi=off
kex=X25519`)),
},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := cloudflareTraceResponseParser(tt.args.reader)
if (err != nil) != tt.wantErr {
t.Errorf("cloudflareTraceResponseParser() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("cloudflareTraceResponseParser() got = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -1,28 +1,17 @@
package public_resolvers package public_resolvers
import ( import (
"context"
"fmt"
"io"
"net"
"net/http" "net/http"
"time" "time"
) )
const ( const (
IfConfigMeTag = "ifconfig.me" IfConfigMeTag = "ifconfig.me"
)
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
var (
ifConfigMeUrl = "https://ifconfig.me" ifConfigMeUrl = "https://ifconfig.me"
) )
type IfConfigMe struct { type IfConfigMe struct {
client Doer baseResolver
} }
func NewDefaultIfConfigMe() *IfConfigMe { func NewDefaultIfConfigMe() *IfConfigMe {
@@ -31,32 +20,12 @@ func NewDefaultIfConfigMe() *IfConfigMe {
}) })
} }
func NewIfConfigMe(c Doer) *IfConfigMe { func NewIfConfigMe(client Doer) *IfConfigMe {
return &IfConfigMe{client: c} return &IfConfigMe{
} baseResolver: baseResolver{
client: client,
func (i IfConfigMe) ResolvePublicIp(ctx context.Context) (net.IP, error) { url: ifConfigMeUrl,
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ifConfigMeUrl, nil) ipParser: defaultIpParser,
if err != nil { },
return net.IP{}, fmt.Errorf("error creating ifconfig request: %w", err) }
}
resp, err := i.client.Do(req)
if err != nil {
return net.IP{}, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return net.IP{}, fmt.Errorf("unexpected response code %d", resp.StatusCode)
}
ipText, err := io.ReadAll(resp.Body)
if err != nil {
return net.IP{}, fmt.Errorf("error reading body: %w", err)
}
return net.ParseIP(string(ipText)), nil
} }

View File

@@ -0,0 +1,31 @@
package public_resolvers
import (
"net/http"
"time"
)
const (
V4IdentMeTag = "v4.ident.me"
v4IdentMeUrl = "https://v4.ident.me/"
)
type V4IdentMe struct {
baseResolver
}
func NewV4IdentMeDefault() *V4IdentMe {
return NewV4IdentMe(&http.Client{
Timeout: 10 * time.Second,
})
}
func NewV4IdentMe(client Doer) *V4IdentMe {
return &V4IdentMe{
baseResolver: baseResolver{
client: client,
url: v4IdentMeUrl,
ipParser: defaultIpParser,
},
}
}