diff --git a/.github/workflows/docker.build.yaml b/.github/workflows/docker.build.yaml new file mode 100644 index 0000000..7b4e750 --- /dev/null +++ b/.github/workflows/docker.build.yaml @@ -0,0 +1,64 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + #schedule: + # - cron: '21 19 * * *' + push: + branches: [ master ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + #pull_request: + # branches: [ master ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + #images: ${{ env.REGISTRY }}/newsbot.worker + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 66fd13c..0df120c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ +.env + # Binaries for programs and plugins +ddns *.exe *.exe~ *.dll diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..175cc09 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.19 as build + +COPY . /app +WORKDIR /app +RUN go build . + +FROM alpine:latest as app + +RUN apk --no-cache add bash libc6-compat && \ + mkdir /app + +COPY --from=build /app/ddns /app + +CMD [ "/app/ddns" ] \ No newline at end of file diff --git a/README.md b/README.md index eb89e02..2e20835 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ # cloudflare-ddns -Golang based tool to maintain Cloudflare Dynamic DNS for your sites. + +Golang based tool to maintain Cloudflare Dynamic DNS for your sites. Run this locally or as a Docker image. + +When the application starts, it will run every 15 minutes and check your defined hosts and compare the IP Address listed. If they dont match, it will be updated to refect the public IP address of the server its running from. + +This does not have any UI elements so you need to check the logs to se how its going. + + +## Usage + +```yaml +# docker-compose.yaml +version: "3" + +services: + app: + image: ghcr.io/jtom38/cloudflare-ddns:master + container_name: cfddns + environment: + EMAIL: "yourcloudflareemailaddress" + API_TOKEN: "cloudflare-api-key" + DOMAIN: "exampledomain.com" + HOSTS: "example1,example2,www" + +``` + +## Credit + +The original post that gave me this idea can be found [here](https://adamtheautomator.com/cloudflare-dynamic-dns/). This was written in PowerShell and thought I could improve on it with Go and Docker. \ No newline at end of file diff --git a/cloudflare.go b/cloudflare.go new file mode 100644 index 0000000..018797a --- /dev/null +++ b/cloudflare.go @@ -0,0 +1,248 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "time" +) + +type CloudFlareClient struct { + apiToken string + email string + + httpClient http.Client +} + +func NewCloudFlareClient(ApiToken string, UserEmail string) *CloudFlareClient { + c := CloudFlareClient{ + apiToken: ApiToken, + email: UserEmail, + } + c.httpClient = http.Client{} + + return &c +} + +type listDomainZones struct { + Result []struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + Paused bool `json:"paused,omitempty"` + Type string `json:"type,omitempty"` + DevelopmentMode int `json:"development_mode,omitempty"` + NameServers []string `json:"name_servers,omitempty"` + OriginalNameServers []string `json:"original_name_servers,omitempty"` + OriginalRegistrar string `json:"original_registrar,omitempty"` + OriginalDnshost interface{} `json:"original_dnshost,omitempty"` + ModifiedOn time.Time `json:"modified_on,omitempty"` + CreatedOn time.Time `json:"created_on,omitempty"` + ActivatedOn time.Time `json:"activated_on,omitempty"` + Meta struct { + Step int `json:"step,omitempty"` + CustomCertificateQuota int `json:"custom_certificate_quota,omitempty"` + PageRuleQuota int `json:"page_rule_quota,omitempty"` + PhishingDetected bool `json:"phishing_detected,omitempty"` + MultipleRailgunsAllowed bool `json:"multiple_railguns_allowed,omitempty"` + } `json:"meta,omitempty"` + Owner struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Email string `json:"email,omitempty"` + } `json:"owner,omitempty"` + Account struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + } `json:"account,omitempty"` + Tenant struct { + ID interface{} `json:"id,omitempty"` + Name interface{} `json:"name,omitempty"` + } `json:"tenant,omitempty"` + TenantUnit struct { + ID interface{} `json:"id,omitempty"` + } `json:"tenant_unit,omitempty"` + Permissions []string `json:"permissions,omitempty"` + Plan struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Price int `json:"price,omitempty"` + Currency string `json:"currency,omitempty"` + Frequency string `json:"frequency,omitempty"` + IsSubscribed bool `json:"is_subscribed,omitempty"` + CanSubscribe bool `json:"can_subscribe,omitempty"` + LegacyID string `json:"legacy_id,omitempty"` + LegacyDiscount bool `json:"legacy_discount,omitempty"` + ExternallyManaged bool `json:"externally_managed,omitempty"` + } `json:"plan,omitempty"` + } `json:"result,omitempty"` + ResultInfo struct { + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + TotalPages int `json:"total_pages,omitempty"` + Count int `json:"count,omitempty"` + TotalCount int `json:"total_count,omitempty"` + } `json:"result_info,omitempty"` + Success bool `json:"success,omitempty"` + Errors []interface{} `json:"errors,omitempty"` + Messages []interface{} `json:"messages,omitempty"` +} + +// Lists out all the zones bound to an ac +// +// https://api.cloudflare.com/#zone-list-zones +func (c *CloudFlareClient) GetDomainByName(domain string) (*listDomainZones, error) { + var items listDomainZones + uri := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%v", domain) + + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return &items, err + } + req.Header.Set("X-Auth-Email", c.email) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", c.apiToken)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return &items, err + } + + if resp.StatusCode != 200 { + return &items, ErrInvalidStatusCode + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return &items, ErrWasNotJson + } + //log.Print(string(body)) + err = json.Unmarshal(body, &items) + if err != nil { + return &items, ErrFailedToDecodeJson + } + + if !items.Success { + log.Println("Failed to find the requested domain on Cloudflare.") + return &items, ErrDomainNotFound + } + + return &items, nil +} + +type DnsDetails struct { + Success bool `json:"success"` + Errors []interface{} `json:"errors"` + Messages []interface{} `json:"messages"` + Result []struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Proxiable bool `json:"proxiable"` + Proxied bool `json:"proxied"` + TTL int `json:"ttl"` + Locked bool `json:"locked"` + ZoneID string `json:"zone_id"` + ZoneName string `json:"zone_name"` + CreatedOn time.Time `json:"created_on"` + ModifiedOn time.Time `json:"modified_on"` + Data struct { + } `json:"data"` + Meta struct { + AutoAdded bool `json:"auto_added"` + Source string `json:"source"` + } `json:"meta"` + } `json:"result"` +} + +func (c *CloudFlareClient) GetDnsEntriesByDomain(DomainId string, Host string, Domain string) (*DnsDetails, error) { + var items DnsDetails + name := fmt.Sprintf("%v.%v", Host, Domain) + uri := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%v/dns_records?name=%v", DomainId, name) + + req, err := http.NewRequest("GET", uri, nil) + if err != nil { + return &items, err + } + req.Header.Set("X-Auth-Email", c.email) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", c.apiToken)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return &items, err + } + + if resp.StatusCode != 200 { + return &items, ErrInvalidStatusCode + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return &items, ErrWasNotJson + } + err = json.Unmarshal(body, &items) + if err != nil { + return &items, ErrFailedToDecodeJson + } + + if !items.Success { + log.Println("Failed to find the requested domain on Cloudflare.") + return &items, ErrDomainNotFound + } + + return &items, nil +} + +type dnsUpdate struct { + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Ttl int `json:"ttl"` + Proxied bool `json:"proxied"` +} + +func (c *CloudFlareClient) UpdateDnsEntry(DomainId string, DnsDetails *DnsDetails, NewIpAddress string) error { + param := dnsUpdate{ + Type: DnsDetails.Result[0].Type, + Name: DnsDetails.Result[0].Name, + Content: NewIpAddress, + Ttl: DnsDetails.Result[0].TTL, + Proxied: DnsDetails.Result[0].Proxied, + } + + endpoint := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%v/dns_records/%v", DomainId, DnsDetails.Result[0].ID) + + body, err := json.Marshal(param) + if err != nil { + return err + } + + req, err := http.NewRequest("GET", endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("X-Auth-Email", c.email) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", c.apiToken)) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != 200 { + log.Println(resp.Status) + return errors.New("failed to update the IP address") + } + + //log.Print(resp) + return nil +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..049204f --- /dev/null +++ b/config.go @@ -0,0 +1,72 @@ +package main + +import ( + "errors" + "fmt" + "log" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +const ( + ConfigEmail = "EMAIL" + ConfigToken = "API_TOKEN" + ConfigDomain = "DOMAIN" + ConfigHosts = "HOSTS" +) + +type ConfigClient struct{} + +func NewConfigClient() ConfigClient { + c := ConfigClient{} + c.RefreshEnv() + + return c +} + +func (cc *ConfigClient) GetConfig(key string) string { + res, filled := os.LookupEnv(key) + if !filled { + log.Printf("Missing the a value for '%v'. Could generate errors.", key) + } + return res +} + +func (cc *ConfigClient) GetFeature(flag string) (bool, error) { + cc.RefreshEnv() + + res, filled := os.LookupEnv(flag) + if !filled { + errorMessage := fmt.Sprintf("'%v' was not found", flag) + return false, errors.New(errorMessage) + } + + b, err := strconv.ParseBool(res) + if err != nil { + return false, err + } + return b, nil +} + +// Use this when your ConfigClient has been opened for awhile and you want to ensure you have the most recent env changes. +func (cc *ConfigClient) RefreshEnv() { + // Check to see if we have the env file on the system + _, err := os.Stat(".env") + + // We have the file, load it. + if err == nil { + _, err := os.Open(".env") + if err == nil { + loadEnvFile() + } + } +} + +func loadEnvFile() { + err := godotenv.Load() + if err != nil { + log.Fatalln(err) + } +} \ No newline at end of file diff --git a/cron.go b/cron.go new file mode 100644 index 0000000..bf5dc02 --- /dev/null +++ b/cron.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "log" + + "github.com/robfig/cron/v3" +) + +type cronClient struct{ + scheduler *cron.Cron +} + +func NewCron() cronClient { + c := cronClient{ + scheduler: cron.New(), + } + + return c +} + +func (c cronClient) RunCloudflareCheck(ApiToken string, Email string, Domain string, Hosts []string) { + log.Println("Starting check...") + log.Println("Checking the current IP Address") + currentIp, err := GetCurrentIpAddress() + if err != nil { + log.Println(err) + return + } + + cf := NewCloudFlareClient(ApiToken, Email) + log.Println("Checking domain information on CloudFlare") + domainDetails, err := cf.GetDomainByName(Domain) + if err != nil { + log.Println("Unable to get information from CloudFlare.") + log.Println("Double check the API Token to make sure it's valid.") + log.Println(err) + return + } + + for _, host := range Hosts { + hostname := fmt.Sprintf("%v.%v", host, Domain) + log.Printf("Reviewing '%v'", hostname) + dns, err := cf.GetDnsEntriesByDomain(domainDetails.Result[0].ID, host, Domain) + if err != nil { + log.Println("failed to collect dns entry") + return + } + + if dns.Result[0].Content != currentIp { + log.Println("IP Address no longer matches, sending an update") + err = cf.UpdateDnsEntry(domainDetails.Result[0].ID, dns, currentIp) + if err != nil { + log.Println("Failed to update the DNS record!") + } + } + } + log.Println("Done!") +} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..0060bdd --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,11 @@ +version: "3" + +services: + app: + image: ghcr.io/jtom38/cloudflare-ddns:master + container_name: cfddns + environment: + EMAIL: "yourcloudflareemailaddress" + API_TOKEN: "cloudflare-api-key" + DOMAIN: "exampledomain.com" + HOSTS: "example1,example2,www" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ea38488 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/jtom38/cloudflare/ddns + +go 1.19 + +require ( + github.com/joho/godotenv v1.4.0 + github.com/robfig/cron/v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d00a98e --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= diff --git a/main.go b/main.go new file mode 100644 index 0000000..80b7a0a --- /dev/null +++ b/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "errors" + "io" + "log" + "net/http" + "strings" + "time" +) + +var ( + ErrInvalidStatusCode error = errors.New("did not get an acceptiable status code from the server") + ErrFailedToDecodeBody error = errors.New("unable to decode the body") + ErrFailedToDecodeJson error = errors.New("unexpected json format was returned") + ErrWasNotJson error = errors.New("response from server was not json") + ErrDomainNotFound error = errors.New("unable to find requested domain on cloudflare") +) + +func GetCurrentIpAddress() (string, error) { + resp, err := http.Get("https://v4.ident.me") + if err != nil { + return "", err + } + + if resp.StatusCode != 200 { + return "", ErrInvalidStatusCode + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", ErrFailedToDecodeBody + } + + return string(body), nil +} + +func main() { + config := NewConfigClient() + email := config.GetConfig(ConfigEmail) + if email == "" { + log.Println("Unable to find 'EMAIL' env value.") + return + } + + token := config.GetConfig(ConfigToken) + if token == "" { + log.Println("Unable to find 'API_TOKEN' env value.") + } + + domain := config.GetConfig(ConfigDomain) + if token == "" { + log.Println("Unable to find 'DOMAIN' env value.") + } + + hosts := config.GetConfig(ConfigHosts) + if token == "" { + log.Println("Unable to find 'HOSTS' env value.") + } + hostsArray := strings.Split(hosts, ",") + log.Println("Env Check: OK") + + cron := NewCron() + log.Println("Cloudflare Check will run every 15 minutes.") + cron.scheduler.AddFunc("0,15,30,45 * * * *", func() { + cron.RunCloudflareCheck(token, email, domain, hostsArray) + }) + cron.scheduler.Start() + + log.Println("Application has started!") + for { + time.Sleep(30 * time.Second) + } + +} +