This commit is contained in:
mkelcik
2023-04-28 14:57:21 +02:00
parent 531b3fba51
commit ec9ca7183c
7 changed files with 270 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/env
.idea
/vendor

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM golang:1.20 as build
# Copy project sources
COPY . /opt/project/
WORKDIR /opt/project
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /cloudflare-ddns-updater
FROM scratch
COPY --from=build /cloudflare-ddns-updater /cloudflare-ddns-updater
ENTRYPOINT ["/cloudflare-ddns-updater"]

14
go.mod Normal file
View File

@@ -0,0 +1,14 @@
module cloudflare-ddns
go 1.20
require github.com/cloudflare/cloudflare-go v0.66.0
require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.2 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/time v0.3.0 // indirect
)

62
internal/config.go Normal file
View File

@@ -0,0 +1,62 @@
package internal
import (
"fmt"
"log"
"os"
"strconv"
"time"
)
const (
defaultCheckInterval = 5 * 60
envKeyDnsToCheck = "CLOUDFLARE_DNS_TO_CHECK"
envKeyPublicIpResolverTag = "PUBLIC_IP_RESOLVER"
envKeyCloudflareApiKey = "CLOUDFLARE_API_KEY"
envKeyCloudflareZone = "CLOUDFLARE_ZONE"
envKeyOnChangeComment = "ON_CHANGE_COMMENT"
envKeyCheckIntervalSeconds = "CHECK_INTERVAL_SECONDS"
)
type Config struct {
DnsRecordsToCheck []string
PublicIpResolverTag string
ApiToken string
CloudflareZone string
OnChangeComment string
CheckInterval time.Duration
}
func (c Config) Validate() error {
if c.ApiToken == "" {
return fmt.Errorf("empty api token env key %s", envKeyCloudflareApiKey)
}
if c.CloudflareZone == "" {
return fmt.Errorf("empty zone in env key %s", envKeyCloudflareZone)
}
if len(c.DnsRecordsToCheck) == 0 {
return fmt.Errorf("no dns to check defined in env key %s", envKeyDnsToCheck)
}
return nil
}
func NewConfig() Config {
checkInterval, err := strconv.ParseInt(os.Getenv(envKeyCheckIntervalSeconds), 10, 64)
if err != nil {
log.Printf("wrong `%s` value. Check interval set default(%ds)", envKeyCheckIntervalSeconds, defaultCheckInterval)
checkInterval = defaultCheckInterval
}
return Config{
DnsRecordsToCheck: parseDNSToCheck(os.Getenv(envKeyDnsToCheck)),
PublicIpResolverTag: os.Getenv(envKeyPublicIpResolverTag),
ApiToken: os.Getenv(envKeyCloudflareApiKey),
CloudflareZone: os.Getenv(envKeyCloudflareZone),
OnChangeComment: os.Getenv(envKeyOnChangeComment),
CheckInterval: time.Duration(checkInterval) * time.Second,
}
}

20
internal/helpers.go Normal file
View File

@@ -0,0 +1,20 @@
package internal
import "strings"
func parseDNSToCheck(data string) []string {
out := make([]string, 0, strings.Count(data, ",")+1)
for _, dns := range strings.Split(data, ",") {
out = append(out, strings.TrimSpace(dns))
}
return out
}
func Contains[T comparable](haystack []T, needle T) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}

107
main.go Normal file
View File

@@ -0,0 +1,107 @@
package main
import (
"context"
"log"
"net"
"net/http"
"os/signal"
"syscall"
"time"
"cloudflare-ddns/internal"
"cloudflare-ddns/public_resolvers"
"github.com/cloudflare/cloudflare-go"
)
type PublicIpResolver interface {
ResolvePublicIp(ctx context.Context) (net.IP, error)
}
func getResolver(resolverName string) PublicIpResolver {
switch resolverName {
// HERE add another resolver if needed
case public_resolvers.IfConfigMeTag:
fallthrough
default:
return public_resolvers.NewIfConfigMe(&http.Client{
Timeout: 10 * time.Second,
})
}
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
config := internal.NewConfig()
if err := config.Validate(); err != nil {
log.Fatalln(err)
}
currentPublicIP, err := getResolver(config.PublicIpResolverTag).ResolvePublicIp(ctx)
if err != nil {
log.Fatal(err)
}
api, err := cloudflare.NewWithAPIToken(config.ApiToken)
if err != nil {
log.Fatal(err)
}
// Fetch user details on the account
zoneID, err := api.ZoneIDByName(config.CloudflareZone)
if err != nil {
log.Fatal(err)
}
dns, err := allDNSRecords(ctx, api, cloudflare.ZoneIdentifier(zoneID))
if err != nil {
log.Fatal(err)
}
for _, dnsRecord := range dns {
if internal.Contains(config.DnsRecordsToCheck, dnsRecord.Name) {
log.Printf("Checking record `%s` with current value `%s` ...", dnsRecord.Name, dnsRecord.Content)
if currentPublicIP.String() == dnsRecord.Content {
log.Println("OK")
continue // no update needed
}
update := cloudflare.UpdateDNSRecordParams{
ID: dnsRecord.ID,
Content: currentPublicIP.String(),
}
if config.OnChangeComment != "" {
update.Comment = config.OnChangeComment
}
if _, err := api.UpdateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), update); err != nil {
log.Printf("error updating dns record: %s", err)
} else {
log.Printf("Updated to `%s`", currentPublicIP)
}
}
}
}
func allDNSRecords(ctx context.Context, api *cloudflare.API, rc *cloudflare.ResourceContainer) ([]cloudflare.DNSRecord, error) {
out := make([]cloudflare.DNSRecord, 0, 100)
params := cloudflare.ListDNSRecordsParams{
ResultInfo: cloudflare.ResultInfo{Page: 1},
}
for {
page, res, err := api.ListDNSRecords(ctx, rc, params)
if err != nil {
return nil, err
}
out = append(out, page...)
if res.Page >= res.TotalPages {
break
}
params.Page++
}
return out, nil
}

View File

@@ -0,0 +1,53 @@
package public_resolvers
import (
"context"
"fmt"
"io"
"net"
"net/http"
)
const (
IfConfigMeTag = "ifconfig.me"
)
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
var (
ifConfigMeUrl = "https://ifconfig.me"
)
type IfConfigMe struct {
client Doer
}
func NewIfConfigMe(c Doer) *IfConfigMe {
return &IfConfigMe{client: c}
}
func (i IfConfigMe) ResolvePublicIp(ctx context.Context) (net.IP, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ifConfigMeUrl, 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 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
}