From ec9ca7183cdd4925a8a9426aae0f294b0eb8e4f0 Mon Sep 17 00:00:00 2001 From: mkelcik Date: Fri, 28 Apr 2023 14:57:21 +0200 Subject: [PATCH] init --- .gitignore | 3 + Dockerfile | 11 ++++ go.mod | 14 +++++ internal/config.go | 62 +++++++++++++++++++ internal/helpers.go | 20 ++++++ main.go | 107 +++++++++++++++++++++++++++++++++ public_resolvers/ifconfigme.go | 53 ++++++++++++++++ 7 files changed, 270 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 internal/config.go create mode 100644 internal/helpers.go create mode 100644 main.go create mode 100644 public_resolvers/ifconfigme.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..985208e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/env +.idea +/vendor \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1fbdd89 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9d0f227 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..2cc4063 --- /dev/null +++ b/internal/config.go @@ -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, + } +} diff --git a/internal/helpers.go b/internal/helpers.go new file mode 100644 index 0000000..d2fa19e --- /dev/null +++ b/internal/helpers.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..394ec15 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/public_resolvers/ifconfigme.go b/public_resolvers/ifconfigme.go new file mode 100644 index 0000000..829d95d --- /dev/null +++ b/public_resolvers/ifconfigme.go @@ -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 +}