diff --git a/README.md b/README.md index 44023aad..2d5976a1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ GoDNS is a dynamic DNS (DDNS) client tool, it is based on my early open source p Now I rewrite [DynDNS](https://github.com/TimothyYe/DynDNS) by Golang and call it [GoDNS](https://github.com/TimothyYe/godns). ## Supported DNS Provider +* Cloudflare ([https://cloudflare.com](https://cloudflare.com)) * DNSPod ([https://www.dnspod.cn/](https://www.dnspod.cn/)) * HE.net (Hurricane Electric) ([https://dns.he.net/](https://dns.he.net/)) @@ -95,6 +96,28 @@ Usage of ./godns: * Configure the SMTP options if you want, a mail notification will sent to your mailbox once the IP is changed. * Save it in the same directory of GoDNS, or use -c=your_conf_path command. +### Config example for Cloudflare + +For Cloudflare, you need to provide email & Global API Key as password, and config all the domains & subdomains. + +```json +{ + "provider": "Cloudflare", + "email": "you@example.com" + "password": "Global API Key", + "domains": [{ + "domain_name": "example.com", + "sub_domains": ["www","test"] + },{ + "domain_name": "example2.com", + "sub_domains": ["www","test"] + } + ], + "ip_url": "https://ifconfig.co/ip", + "socks5_proxy": "" +} +``` + ### Config example for DNSPod For DNSPod, you need to provide email & password, and config all the domains & subdomains. diff --git a/handler/cloudflare_handler.go b/handler/cloudflare_handler.go new file mode 100644 index 00000000..ba0346f9 --- /dev/null +++ b/handler/cloudflare_handler.go @@ -0,0 +1,245 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io" + "io/ioutil" + "log" + "net/http" + "runtime/debug" + "time" + + "github.com/TimothyYe/godns" + "golang.org/x/net/proxy" +) + +// CloudflareHandler struct definition +type CloudflareHandler struct { + Configuration *godns.Settings + API string +} + +// DNSRecordResponse struct +type DNSRecordResponse struct { + Records []DNSRecord `json:"result"` + Success bool `json:"success"` +} + +// DNSRecordUpdateResponse struct +type DNSRecordUpdateResponse struct { + Record DNSRecord `json:"result"` + Success bool `json:"success"` +} + +// DNSRecord for Cloudflare API +type DNSRecord struct { + ID string `json:"id"` + IP string `json:"content"` + Name string `json:"name"` + Proxied bool `json:"proxied"` + Type string `json:"type"` + ZoneID string `json:"zone_id"` +} + +// SetIP updates DNSRecord.IP +func (r *DNSRecord) SetIP(ip string) { + r.IP = ip +} + +// ZoneResponse is a wrapper for Zones +type ZoneResponse struct { + Zones []Zone `json:"result"` + Success bool `json:"success"` +} + +// Zone object with id and name +type Zone struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// SetConfiguration pass dns settings and store it to handler instance +func (handler *CloudflareHandler) SetConfiguration(conf *godns.Settings) { + handler.Configuration = conf + handler.API = "https://api.cloudflare.com/client/v4" +} + +// DomainLoop the main logic loop +func (handler *CloudflareHandler) DomainLoop(domain *godns.Domain, panicChan chan<- godns.Domain) { + defer func() { + if err := recover(); err != nil { + log.Printf("Recovered in %v: %v\n", err, debug.Stack()) + panicChan <- *domain + } + }() + + for { + currentIP, err := godns.GetCurrentIP(handler.Configuration) + if err != nil { + log.Println("Error in GetCurrentIP:", err) + continue + } + log.Println("Current IP is:", currentIP) + // TODO: check against locally cached IP, if no change, skip update + + log.Println("Checking IP for domain", domain.DomainName) + zoneID := handler.getZone(domain.DomainName) + if zoneID != "" { + records := handler.getDNSRecords(zoneID) + + // update records + for _, rec := range records { + if recordTracked(domain, &rec) != true { + log.Println("Skiping record:", rec.Name) + continue + } + if rec.IP != currentIP { + log.Printf("IP mismatch: Current(%+v) vs Cloudflare(%+v)\r\n", currentIP, rec.IP) + handler.updateRecord(rec, currentIP) + } else { + log.Printf("Record OK: %+v - %+v\r\n", rec.Name, rec.IP) + } + } + } else { + log.Println("Failed to find zone for domain:", domain.DomainName) + } + + // Interval is 5 minutes + log.Printf("Going to sleep, will start next checking in %d minutes...\r\n", godns.INTERVAL) + time.Sleep(time.Minute * godns.INTERVAL) + } +} + +// Check if record is present in domain conf +func recordTracked(domain *godns.Domain, record *DNSRecord) bool { + + if record.Name == domain.DomainName { + return true + } + + for _, subDomain := range domain.SubDomains { + sd := subDomain + "." + domain.DomainName + if record.Name == sd { + return true + } + } + + return false +} + +// Create a new request with auth in place and optional proxy +func (handler *CloudflareHandler) newRequest(method, url string, body io.Reader) (*http.Request, *http.Client) { + client := &http.Client{} + + if handler.Configuration.Socks5Proxy != "" { + log.Println("use socks5 proxy:" + handler.Configuration.Socks5Proxy) + dialer, err := proxy.SOCKS5("tcp", handler.Configuration.Socks5Proxy, nil, proxy.Direct) + if err != nil { + log.Println("can't connect to the proxy:", err) + } else { + httpTransport := &http.Transport{} + client.Transport = httpTransport + httpTransport.Dial = dialer.Dial + } + } + + req, _ := http.NewRequest(method, handler.API+url, body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Auth-Email", handler.Configuration.Email) + req.Header.Set("X-Auth-Key", handler.Configuration.Password) + return req, client +} + +// Find the correct zone via domain name +func (handler *CloudflareHandler) getZone(domain string) string { + + var z ZoneResponse + + req, client := handler.newRequest("GET", "/zones", nil) + resp, err := client.Do(req) + if err != nil { + log.Println("Request error:", err.Error()) + return "" + } + + body, _ := ioutil.ReadAll(resp.Body) + err = json.Unmarshal(body, &z) + if err != nil { + log.Printf("Decoder error: %+v\n", err) + log.Printf("Response body: %+v\n", string(body)) + return "" + } + if z.Success != true { + log.Printf("Response failed: %+v\n", string(body)) + return "" + } + + for _, zone := range z.Zones { + if zone.Name == domain { + return zone.ID + } + } + return "" +} + +// Get all DNS A records for a zone +func (handler *CloudflareHandler) getDNSRecords(zoneID string) []DNSRecord { + + var empty []DNSRecord + var r DNSRecordResponse + + req, client := handler.newRequest("GET", "/zones/"+zoneID+"/dns_records?type=A", nil) + resp, err := client.Do(req) + if err != nil { + log.Println("Request error:", err.Error()) + return empty + } + + body, _ := ioutil.ReadAll(resp.Body) + err = json.Unmarshal(body, &r) + if err != nil { + log.Printf("Decoder error: %+v\n", err) + log.Printf("Response body: %+v\n", string(body)) + return empty + } + if r.Success != true { + body, _ := ioutil.ReadAll(resp.Body) + log.Printf("Response failed: %+v\n", string(body)) + return empty + + } + return r.Records +} + +// Update DNS A Record with new IP +func (handler *CloudflareHandler) updateRecord(record DNSRecord, newIP string) { + + var r DNSRecordUpdateResponse + record.SetIP(newIP) + + j, _ := json.Marshal(record) + req, client := handler.newRequest("PUT", + "/zones/"+record.ZoneID+"/dns_records/"+record.ID, + bytes.NewBuffer(j), + ) + resp, err := client.Do(req) + if err != nil { + log.Println("Request error:", err.Error()) + return + } + + body, _ := ioutil.ReadAll(resp.Body) + err = json.Unmarshal(body, &r) + if err != nil { + log.Printf("Decoder error: %+v\n", err) + log.Printf("Response body: %+v\n", string(body)) + return + } + if r.Success != true { + body, _ := ioutil.ReadAll(resp.Body) + log.Printf("Response failed: %+v\n", string(body)) + } else { + log.Printf("Record updated: %+v - %+v", record.Name, record.IP) + } +} diff --git a/handler/cloudflare_handler_test.go b/handler/cloudflare_handler_test.go new file mode 100644 index 00000000..5b06237c --- /dev/null +++ b/handler/cloudflare_handler_test.go @@ -0,0 +1,167 @@ +package handler + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/TimothyYe/godns" +) + +func TestResponseToJSON(t *testing.T) { + s := strings.NewReader(` + { + "errors": [], + "messages": [], + "result": [ + { + "id": "mk2b6fa491c12445a4376666a32429e1", + "name": "example.com", + "status": "active" + } + ], + "result_info": { + "count": 1, + "page": 1, + "per_page": 20, + "total_count": 1, + "total_pages": 1 + }, + "success": true + }`) + + var resp ZoneResponse + err := json.NewDecoder(s).Decode(&resp) + if err != nil { + t.Error(err.Error()) + } + if resp.Success != true { + t.Errorf("Success Error: %#v != true ", resp.Success) + } + if resp.Zones[0].ID != "mk2b6fa491c12445a4376666a32429e1" { + t.Errorf("ID Error: %#v != mk2b6fa491c12445a4376666a32429e1 ", resp.Zones[0].ID) + } + if resp.Zones[0].Name != "example.com" { + t.Errorf("Name Error: %#v != example.com", resp.Zones[0].Name) + } +} + +func TestDNSResponseToJSON(t *testing.T) { + s := strings.NewReader(` + { + "errors": [], + "messages": [], + "result": [ + { + "content": "127.0.0.1", + "id": "F11cc63e02a42d38174b8e7c548a7b6f", + "name": "example.com", + "type": "A", + "zone_id": "mk2b6fa491c12445a4376666a32429e1", + "zone_name": "example.com" + } + ], + "success": true + }`) + + var resp DNSRecordResponse + err := json.NewDecoder(s).Decode(&resp) + if err != nil { + t.Error(err.Error()) + } + if resp.Success != true { + t.Errorf("Success Error: %#v != true ", resp.Success) + } + if resp.Records[0].ID != "F11cc63e02a42d38174b8e7c548a7b6f" { + t.Errorf("ID Error: %#v != F11cc63e02a42d38174b8e7c548a7b6f ", resp.Records[0].ID) + } + if resp.Records[0].Name != "example.com" { + t.Errorf("Name Error: %#v != example.com", resp.Records[0].Name) + } +} +func TestDNSUpdateResponseToJSON(t *testing.T) { + s := strings.NewReader(` + { + "result": { + "id": "F11cc63e02a42d38174b8e7c548a7b6f", + "type": "A", + "name": "example.com", + "content": "127.0.0.1", + "proxiable": true, + "proxied": true, + "ttl": 1, + "locked": false, + "zone_id": "mk2b6fa491c12445a4376666a32429e1", + "zone_name": "example.com", + "modified_on": "2018-10-12T14:29:53.205191Z", + "created_on": "2018-10-12T14:29:53.205191Z", + "meta": { + "auto_added": false, + "managed_by_apps": false, + "managed_by_argo_tunnel": false + } + }, + "success": true, + "errors": [], + "messages": [] + }`) + + var resp DNSRecordUpdateResponse + err := json.NewDecoder(s).Decode(&resp) + if err != nil { + t.Error(err.Error()) + } + if resp.Success != true { + t.Errorf("Success Error: %#v != true ", resp.Success) + } + if resp.Record.ID != "F11cc63e02a42d38174b8e7c548a7b6f" { + t.Errorf("ID Error: %#v != F11cc63e02a42d38174b8e7c548a7b6f ", resp.Record.ID) + } + if resp.Record.Name != "example.com" { + t.Errorf("Name Error: %#v != example.com", resp.Record.Name) + } +} + +func TestRecordTracked(t *testing.T) { + s := strings.NewReader(` + { + "errors": [], + "messages": [], + "result": [ + { + "content": "127.0.0.1", + "id": "F11cc63e02a42d38174b8e7c548a7b6f", + "name": "example.com", + "type": "A", + "zone_id": "mk2b6fa491c12445a4376666a32429e1", + "zone_name": "example.com" + }, + { + "content": "127.0.0.1", + "id": "G00cc63e02a42d38174b8e7c548a7b6f", + "name": "www.example.com", + "type": "A", + "zone_id": "mk2b6fa491c12445a4376666a32429e1", + "zone_name": "www.example.com" + } + ], + "success": true + }`) + + var resp DNSRecordResponse + err := json.NewDecoder(s).Decode(&resp) + if err != nil { + t.Error(err.Error()) + } + + domain := &godns.Domain{ + DomainName: "example.com", + SubDomains: []string{"www"}, + } + + for _, rec := range resp.Records { + if recordTracked(domain, &rec) != true { + t.Errorf("invalid record skip: %+v\r\n", rec.Name) + } + } +} diff --git a/handler/handler.go b/handler/handler.go index 1bc202e8..604c034d 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -13,6 +13,8 @@ func CreateHandler(provider string) IHandler { var handler IHandler switch provider { + case godns.CLOUDFLARE: + handler = IHandler(&CloudflareHandler{}) case godns.DNSPOD: handler = IHandler(&DNSPodHandler{}) case godns.HE: diff --git a/settings.go b/settings.go index bc60de26..c287628d 100644 --- a/settings.go +++ b/settings.go @@ -25,6 +25,7 @@ type Notify struct { // Settings struct type Settings struct { Provider string `json:"provider"` + Email string `json:"email"` Password string `json:"password"` LoginToken string `json:"login_token"` Domains []Domain `json:"domains"` diff --git a/utils.go b/utils.go index 65ea705d..af69132e 100644 --- a/utils.go +++ b/utils.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "log" "net/http" + "strings" "golang.org/x/net/proxy" "gopkg.in/gomail.v2" @@ -38,6 +39,8 @@ const ( DNSPOD = "DNSPod" // HE for he.net HE = "HE" + // CLOUDFLARE for cloudflare.com + CLOUDFLARE = "Cloudflare" ) // GetCurrentIP gets public IP from internet @@ -68,7 +71,7 @@ func GetCurrentIP(configuration *Settings) (string, error) { defer response.Body.Close() body, _ := ioutil.ReadAll(response.Body) - return string(body), nil + return strings.Trim(string(body), "\n"), nil } // CheckSettings check the format of settings @@ -81,6 +84,13 @@ func CheckSettings(config *Settings) error { if config.Password == "" { return errors.New("password cannot be empty") } + } else if config.Provider == CLOUDFLARE { + if config.Email == "" { + return errors.New("email cannot be empty") + } + if config.Password == "" { + return errors.New("password cannot be empty") + } } else { return errors.New("please provide supported DNS provider: DNSPod/HE") }