~humaid/cloudflare-ddns-client

8bec6f83da5bbd2209e22cb1019a9c42fc9f0551 — Humaid AlQassimi 1 year, 1 month ago
Initial commit
4 files changed, 203 insertions(+), 0 deletions(-)

A .gitignore
A LICENSE
A README.md
A main.go
A  => .gitignore +2 -0
@@ 1,2 @@
cloudflare-ddns
run.sh

A  => LICENSE +24 -0
@@ 1,24 @@
BSD 2-Clause License

Copyright (c) 2020, Humaid AlQassimi. All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
  list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
  this list of conditions and the following disclaimer in the documentation
  and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

A  => README.md +28 -0
@@ 1,28 @@
# Cloudflare DDNS Client

## 1. Description

This is a simple Cloudflare Dynamic DNS client, which updates a record to the
current public IP address. I have created this as I couldn't find a very simple
solution to this very simple problem.

## 2. Requirements

The following packages must be installed on your system.

- Go *(tested with 1.14)*

## 3. Installation and Usage

```
$ go install git.sr.ht/~humaid/cloudflare-ddns-client
$ export APIKey=<API KEY here>
$ export Zone=<Zone ID here>
$ export RecordName=<Full record name here, e.g. foo.example.com>
$ cloudflare-ddns-client
```

You will have to [create an API token](https://dash.cloudflare.com/profile/api-tokens)
which has the permission to edit zone DNS (`Zone.DNS`).

This program will check every minute unless interrupted.

A  => main.go +149 -0
@@ 1,149 @@
package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

var (
	APIKey     string = os.Getenv("APIKey")
	Zone       string = os.Getenv("Zone")
	RecordName string = os.Getenv("RecordName")

	tr = &http.Transport{
		MaxIdleConns:       10,
		IdleConnTimeout:    5 * time.Second,
		DisableCompression: true,
	}

	previousIP string
)

func main() {
	log.Printf("Getting the ID of the record (%s)\n", Zone)
	recID, err := getRecordID()
	if err != nil {
		os.Exit(-1)
	}
	log.Printf("Got record ID: %s\n", recID)
	for {
		ip, err := getIP()
		if err == nil {
			if previousIP == ip {
				log.Println("No change to public IP")
			} else {
				log.Printf("Current public IP: %s\n", ip)
				err = setIP(ip, recID)
				if err != nil {
					log.Printf("Error setting IP: %v\n", err)
				} else {
					log.Println("Updated!")
				}
			}
		}
		time.Sleep(1 * time.Minute)
	}
}

// DNSRecords holds the result of Cloudflare's DNS record listing.
type DNSRecords 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 setHeaders(req *http.Request) {
	req.Header.Set("Authorization", "Bearer "+APIKey)
	req.Header.Set("Content-Type", "application/json")
}

func newClient() *http.Client {
	return &http.Client{
		Transport: tr,
		Timeout:   time.Second * 10,
	}
}

// getRecordID gets the record ID of a DNS record based on the record name
func getRecordID() (string, error) {
	client := newClient()
	url := "https://api.cloudflare.com/client/v4/zones/" + Zone + "/dns_records?type=A&name=" + RecordName
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return "", fmt.Errorf("cannot create request: %v", err)
	}
	setHeaders(req)
	resp, err := client.Do(req)
	s, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("cloudflare returned non-OK status: %s", resp.Status)
	}
	var recs DNSRecords
	err = json.Unmarshal(s, &recs)
	if len(recs.Result) == 0 {
		return "", fmt.Errorf("cannot find record: %s", RecordName)
	}

	return recs.Result[0].ID, nil
}

// setIP sets the IP address based on record ID.
func setIP(ip string, recID string) error {
	client := newClient()
	body := `{"type":"A","name":"` + RecordName + `","content":"` + ip + `","ttl":120,"proxied":false}`
	url := "https://api.cloudflare.com/client/v4/zones/" + Zone + "/dns_records/" + recID
	req, err := http.NewRequest("PUT", url, strings.NewReader(body))
	if err != nil {
		return fmt.Errorf("cannot create request: %v", err)
	}
	setHeaders(req)
	resp, err := client.Do(req)
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("cloudflare returned non-OK status: %s", resp.Status)
	}
	return nil
}

// getIP returns the public IP address.
func getIP() (string, error) {
	client := newClient()
	req, err := http.NewRequest("GET", "https://icanhazip.com", nil)
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	s, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(s)), nil
}