From 9a990b48ceb8777b9e7087eac8e34b5f64ad3047 Mon Sep 17 00:00:00 2001 From: John Aylward Date: Sat, 20 Jul 2019 23:26:15 -0400 Subject: [PATCH 1/2] add dual stack support --- README.md | 6 +- digitalocean-dynamic-ip.go | 94 +++++++++++++++++++++-------- digitalocean-dynamic-ip.sample.json | 4 +- 3 files changed, 77 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 31bc51c..dabf953 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # DIGITAL OCEAN DYNAMIC IP API CLIENT A simple script in Go language to automatically update Digital ocean DNS records if you have a dynamic IP. Since it can be compiled on any platform, you can use it along with raspberrypi etc. -To find your Dynamic IP, this program will call out to https://diagnostic.opendns.com/myip. +To find your Dynamic IP, this program will call out to https://ipv4bot.whatismyipaddress.com for ipv4 addresses and https://ipv6bot.whatismyipaddress.com for ipv6 addresses. This is to support dual-stack environments. ## requirements Requires Git and Go for building. @@ -21,6 +21,8 @@ create a file ".digitalocean-dynamic-ip.json"(dot prefix to hide the file) and p { "apikey": "samplekeydasjkdhaskjdhrwofihsamplekey", "doPageSize" : 20, + "useIPv4": true, + "useIPv6": false, "domains": [ { "domain": "example.com", @@ -48,6 +50,8 @@ The TTL can optionally be updated if passed in the configuration. Digital Ocean If you want to reduce the number of calls made to the digital ocean API and have more than 20 DNS records in your domain, you can adjust the `doPageSize` parameter. By default, Digital Ocean returns 20 records per page. +By default, the configuration checks both IPv4 and IPv6 addresses assuming your provider set up your connection as dual stack. If you know you only have ipv4 or ipv6 you can disable using one or the other in the config. To disable one or the other, set the `useIPv4` or `useIPv6` settings to `false`. If the options aren't present, or are set to `null`, then the configuration assumes a value of `true`. + ```bash #run go build digitalocean-dynamic-ip.go diff --git a/digitalocean-dynamic-ip.go b/digitalocean-dynamic-ip.go index 593b91e..e85a7e0 100755 --- a/digitalocean-dynamic-ip.go +++ b/digitalocean-dynamic-ip.go @@ -28,6 +28,8 @@ var config ClientConfig type ClientConfig struct { APIKey string `json:"apiKey"` DOPageSize int `json:"doPageSize"` + UseIPv4 *bool `json:"useIPv4"` + UseIPv6 *bool `json:"useIPv6"` Domains []Domain `json:"domains"` } @@ -111,14 +113,49 @@ func usage() { )) } -//CheckLocalIP : get current IP of server. -func CheckLocalIP() string { - currentIPRequest, err := http.Get("https://diagnostic.opendns.com/myip") +//CheckLocalIPs : get current IP of server. checks both IPv4 and Ipv6 to support dual stack environments +func CheckLocalIPs() (ipv4, ipv6 net.IP) { + var ipv4String, ipv6String string + + if config.UseIPv4 == nil || *(config.UseIPv4) { + ipv4String, _ = getURLBody("https://ipv4bot.whatismyipaddress.com") + if ipv4String == "" { + log.Println("No IPv4 address found. Consider disabling IPv4 checks in the config `\"useIPv4\": false`") + } else { + ipv4 = net.ParseIP(ipv4String) + if ipv4 != nil { + // make sure we got back an actual ipv4 address + ipv4 = ipv4.To4() + } + if ipv4 == nil { + log.Printf("Unable to parse `%s` as an IPv4 address\n", ipv4String) + } + } + } + + if config.UseIPv6 == nil || *(config.UseIPv6) { + ipv6String, _ = getURLBody("https://ipv6bot.whatismyipaddress.com") + if ipv6String == "" { + log.Println("No IPv6 address found. Consider disabling IPv6 checks in the config `\"useIPv6\": false`") + } else { + ipv6 = net.ParseIP(ipv6String) + if ipv6 == nil { + log.Printf("Unable to parse `%s` as an IPv4 address\n", ipv6String) + } + } + } + return ipv4, ipv6 +} + +func getURLBody(url string) (string, error) { + request, err := http.Get(url) + if err != nil { + return "", err + } + defer request.Body.Close() + body, err := ioutil.ReadAll(request.Body) checkError(err) - defer currentIPRequest.Body.Close() - currentIPRequestParse, err := ioutil.ReadAll(currentIPRequest.Body) - checkError(err) - return string(currentIPRequestParse) + return string(body), nil } //GetDomainRecords : Get DNS records of current domain. @@ -156,13 +193,7 @@ func getPage(url string) DOResponse { } // UpdateRecords : Update DNS records of domain -func UpdateRecords(domain string, ip net.IP, toUpdateRecords []DNSRecord) { - currentIP := ip.String() - isIpv6 := ip.To4() == nil - if !isIpv6 { - // make sure we are using a v4 format - currentIP = ip.To4().String() - } +func UpdateRecords(domain string, ipv4, ipv6 net.IP, toUpdateRecords []DNSRecord) { log.Printf("%s: %d to update\n", domain, len(toUpdateRecords)) updated := 0 doRecords := GetDomainRecords(domain) @@ -174,17 +205,28 @@ func UpdateRecords(domain string, ip net.IP, toUpdateRecords []DNSRecord) { log.Printf("%s: %d DNS records found", domain, len(doRecords)) for _, toUpdateRecord := range toUpdateRecords { if toUpdateRecord.Type != "A" && toUpdateRecord.Type != "AAAA" { - log.Fatalf("%s: Unsupported type (Only A and AAAA records supported) for updates %+v", domain, toUpdateRecord) + log.Printf("%s: Unsupported type (Only A and AAAA records supported) for updates %+v", domain, toUpdateRecord) + continue } - if isIpv6 && toUpdateRecord.Type == "A" { - log.Fatalf("%s: You are trying to update an IPV4 A record with an IPV6 address: new ip: %s, config: %+v", domain, currentIP, toUpdateRecord) + if ipv4 == nil && toUpdateRecord.Type == "A" { + log.Printf("%s: You are trying to update an IPv4 A record with no IPv4 address: config: %+v", domain, toUpdateRecord) + continue } - if !isIpv6 && toUpdateRecord.Type == "AAAA" { - log.Fatalf("%s: You are trying to update an IPV6 A record with an IPV4 address: new ip: %s, config: %+v", domain, currentIP, toUpdateRecord) + if ipv6 == nil && toUpdateRecord.Type == "AAAA" { + log.Printf("%s: You are trying to update an IPv6 AAAA record with no IPv6 address: config: %+v", domain, toUpdateRecord) + continue } if toUpdateRecord.ID > 0 { // update the record directly. skip the extra search - log.Fatalf("%s: Unable to directly update records yet. Record: %+v", domain, toUpdateRecord) + log.Printf("%s: Unable to directly update records yet. Record: %+v", domain, toUpdateRecord) + continue + } + + var currentIP string + if toUpdateRecord.Type == "A" { + currentIP = ipv4.String() + } else { + currentIP = ipv6.String() } log.Printf("%s: trying to update `%s` : `%s`", domain, toUpdateRecord.Type, toUpdateRecord.Name) @@ -225,15 +267,17 @@ func UpdateRecords(domain string, ip net.IP, toUpdateRecords []DNSRecord) { func main() { config = GetConfig() - currentIP := CheckLocalIP() - ip := net.ParseIP(currentIP) - if ip == nil { - log.Fatalf("current IP address `%s` is not a valid IP address", currentIP) + currentIPv4, currentIPv6 := CheckLocalIPs() + if currentIPv4 == nil && currentIPv6 == nil { + log.Fatalf("current IP addresses are not a valid, or both are disabled in the config. Check you configuration and internet connection", + currentIPv4, + currentIPv6, + ) } for _, domain := range config.Domains { domainName := domain.Domain log.Printf("%s: START\n", domainName) - UpdateRecords(domainName, ip, domain.Records) + UpdateRecords(domainName, currentIPv4, currentIPv6, domain.Records) log.Printf("%s: END\n", domainName) } } diff --git a/digitalocean-dynamic-ip.sample.json b/digitalocean-dynamic-ip.sample.json index 5db66a9..a8b40b4 100755 --- a/digitalocean-dynamic-ip.sample.json +++ b/digitalocean-dynamic-ip.sample.json @@ -1,6 +1,8 @@ { "apikey": "samplekeydasjkdhaskjdhrwofihsamplekey", - "doPageSize" : 20, + "doPageSize": 20, + "useIPv4": true, + "useIPv6": true, "domains": [ { "domain": "example.com", From edf24ec90c93afba56c4ccc568f0470877e6a5e6 Mon Sep 17 00:00:00 2001 From: John Aylward Date: Sun, 21 Jul 2019 01:13:34 -0400 Subject: [PATCH 2/2] add support for ipv4 in ipv6 addresses --- README.md | 5 +- digitalocean-dynamic-ip.go | 128 ++++++++++++++++++++-------- digitalocean-dynamic-ip.sample.json | 1 + 3 files changed, 96 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index dabf953..14a9f0d 100755 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ create a file ".digitalocean-dynamic-ip.json"(dot prefix to hide the file) and p "doPageSize" : 20, "useIPv4": true, "useIPv6": false, + "allowIPv4InIPv6": false, "domains": [ { "domain": "example.com", @@ -48,10 +49,12 @@ create a file ".digitalocean-dynamic-ip.json"(dot prefix to hide the file) and p ``` The TTL can optionally be updated if passed in the configuration. Digital Ocean has a minimum TTL of 30 seconds. The `type` and the `name` must match existing records in the Digital Ocean DNS configuration. Only `types` of `A` and `AAAA` allowed at the moment. -If you want to reduce the number of calls made to the digital ocean API and have more than 20 DNS records in your domain, you can adjust the `doPageSize` parameter. By default, Digital Ocean returns 20 records per page. +If you want to reduce the number of calls made to the digital ocean API and have more than 20 DNS records in your domain, you can adjust the `doPageSize` parameter. By default, Digital Ocean returns 20 records per page. Digital Ocean has a max page size of 200 items. By default, the configuration checks both IPv4 and IPv6 addresses assuming your provider set up your connection as dual stack. If you know you only have ipv4 or ipv6 you can disable using one or the other in the config. To disable one or the other, set the `useIPv4` or `useIPv6` settings to `false`. If the options aren't present, or are set to `null`, then the configuration assumes a value of `true`. +The `allowIPv4InIPv6` configuration option will allow adding an IPv4 address to be used in a AAAA record for IPv6 lookups. + ```bash #run go build digitalocean-dynamic-ip.go diff --git a/digitalocean-dynamic-ip.go b/digitalocean-dynamic-ip.go index e85a7e0..b9ed4f0 100755 --- a/digitalocean-dynamic-ip.go +++ b/digitalocean-dynamic-ip.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/hex" "encoding/json" "flag" "fmt" @@ -26,11 +27,12 @@ var config ClientConfig // ClientConfig : configuration json type ClientConfig struct { - APIKey string `json:"apiKey"` - DOPageSize int `json:"doPageSize"` - UseIPv4 *bool `json:"useIPv4"` - UseIPv6 *bool `json:"useIPv6"` - Domains []Domain `json:"domains"` + APIKey string `json:"apiKey"` + DOPageSize int `json:"doPageSize"` + UseIPv4 *bool `json:"useIPv4"` + UseIPv6 *bool `json:"useIPv6"` + AllowIPv4InIPv6 bool `json:"allowIPv4InIPv6"` + Domains []Domain `json:"domains"` } // Domain : domains to be changed @@ -128,7 +130,7 @@ func CheckLocalIPs() (ipv4, ipv6 net.IP) { ipv4 = ipv4.To4() } if ipv4 == nil { - log.Printf("Unable to parse `%s` as an IPv4 address\n", ipv4String) + log.Printf("Unable to parse `%s` as an IPv4 address", ipv4String) } } } @@ -140,7 +142,7 @@ func CheckLocalIPs() (ipv4, ipv6 net.IP) { } else { ipv6 = net.ParseIP(ipv6String) if ipv6 == nil { - log.Printf("Unable to parse `%s` as an IPv4 address\n", ipv6String) + log.Printf("Unable to parse `%s` as an IPv6 address", ipv6String) } } } @@ -165,7 +167,12 @@ func GetDomainRecords(domain string) []DNSRecord { pageParam := "" // 20 is the default page size if config.DOPageSize > 0 && config.DOPageSize != 20 { - pageParam = "?per_page=" + strconv.Itoa(config.DOPageSize) + pageSize := config.DOPageSize + // don't let users set more than the max size + if pageSize > 200 { + pageSize = 200 + } + pageParam = "?per_page=" + strconv.Itoa(pageSize) } for url := "https://api.digitalocean.com/v2/domains/" + url.PathEscape(domain) + "/records" + pageParam; url != ""; url = page.Links.Pages.Next { page = getPage(url) @@ -185,7 +192,7 @@ func getPage(url string) DOResponse { checkError(err) defer response.Body.Close() body, err := ioutil.ReadAll(response.Body) - // log.Print(string(body)) + // log.Println(string(body)) var jsonDOResponse DOResponse e := json.Unmarshal(body, &jsonDOResponse) checkError(e) @@ -193,51 +200,63 @@ func getPage(url string) DOResponse { } // UpdateRecords : Update DNS records of domain -func UpdateRecords(domain string, ipv4, ipv6 net.IP, toUpdateRecords []DNSRecord) { - log.Printf("%s: %d to update\n", domain, len(toUpdateRecords)) +func UpdateRecords(domain Domain, ipv4, ipv6 net.IP) { + log.Printf("%s: %d to update", domain.Domain, len(domain.Records)) updated := 0 - doRecords := GetDomainRecords(domain) + doRecords := GetDomainRecords(domain.Domain) // look for the item to update if len(doRecords) < 1 { - log.Printf("%s: No DNS records found", domain) + log.Printf("%s: No DNS records found in Digital Ocean", domain.Domain) return } - log.Printf("%s: %d DNS records found", domain, len(doRecords)) - for _, toUpdateRecord := range toUpdateRecords { + log.Printf("%s: %d DNS records found in Digital Ocean", domain.Domain, len(doRecords)) + for _, toUpdateRecord := range domain.Records { if toUpdateRecord.Type != "A" && toUpdateRecord.Type != "AAAA" { - log.Printf("%s: Unsupported type (Only A and AAAA records supported) for updates %+v", domain, toUpdateRecord) + log.Printf("%s: Unsupported type (Only A and AAAA records supported) for updates %+v", domain.Domain, toUpdateRecord) continue } if ipv4 == nil && toUpdateRecord.Type == "A" { - log.Printf("%s: You are trying to update an IPv4 A record with no IPv4 address: config: %+v", domain, toUpdateRecord) - continue - } - if ipv6 == nil && toUpdateRecord.Type == "AAAA" { - log.Printf("%s: You are trying to update an IPv6 AAAA record with no IPv6 address: config: %+v", domain, toUpdateRecord) + log.Printf("%s: You are trying to update an IPv4 A record with no IPv4 address: config: %+v", domain.Domain, toUpdateRecord) continue } if toUpdateRecord.ID > 0 { // update the record directly. skip the extra search - log.Printf("%s: Unable to directly update records yet. Record: %+v", domain, toUpdateRecord) + log.Printf("%s: Unable to directly update records yet. Record: %+v", domain.Domain, toUpdateRecord) continue } var currentIP string if toUpdateRecord.Type == "A" { currentIP = ipv4.String() + } else if ipv6 == nil || ipv6.To4() != nil { + if ipv6 == nil { + ipv6 = ipv4 + } + + log.Printf("%s: You are trying to update an IPv6 AAAA record without an IPv6 address: ip: %s config: %+v", + domain.Domain, + ipv6, + toUpdateRecord, + ) + if config.AllowIPv4InIPv6 { + currentIP = toIPv6String(ipv6) + log.Printf("%s: Converting IPv4 `%s` to IPv6 `%s`", domain.Domain, ipv6.String(), currentIP) + } else { + continue + } } else { currentIP = ipv6.String() } - log.Printf("%s: trying to update `%s` : `%s`", domain, toUpdateRecord.Type, toUpdateRecord.Name) + log.Printf("%s: trying to update `%s` : `%s`", domain.Domain, toUpdateRecord.Type, toUpdateRecord.Name) for _, doRecord := range doRecords { - //log.Printf("%s: checking `%s` : `%s`", domain, doRecord.Type, doRecord.Name) + //log.Printf("%s: checking `%s` : `%s`", domain.Domain, doRecord.Type, doRecord.Name) if doRecord.Name == toUpdateRecord.Name && doRecord.Type == toUpdateRecord.Type { if doRecord.Data == currentIP && (toUpdateRecord.TTL < 30 || doRecord.TTL == toUpdateRecord.TTL) { - log.Printf("%s: IP/TTL did not change %+v", domain, doRecord) + log.Printf("%s: IP/TTL did not change %+v", domain.Domain, doRecord) continue } - log.Printf("%s: updating %+v", domain, doRecord) + log.Printf("%s: updating %+v", domain.Domain, doRecord) // set the IP address doRecord.Data = currentIP if toUpdateRecord.TTL >= 30 && doRecord.TTL != toUpdateRecord.TTL { @@ -247,7 +266,7 @@ func UpdateRecords(domain string, ipv4, ipv6 net.IP, toUpdateRecords []DNSRecord checkError(err) client := &http.Client{} request, err := http.NewRequest("PUT", - "https://api.digitalocean.com/v2/domains/"+url.PathEscape(domain)+"/records/"+strconv.FormatInt(int64(doRecord.ID), 10), + "https://api.digitalocean.com/v2/domains/"+url.PathEscape(domain.Domain)+"/records/"+strconv.FormatInt(int64(doRecord.ID), 10), bytes.NewBuffer(update)) checkError(err) request.Header.Set("Content-Type", "application/json") @@ -256,28 +275,63 @@ func UpdateRecords(domain string, ipv4, ipv6 net.IP, toUpdateRecords []DNSRecord checkError(err) defer response.Body.Close() body, err := ioutil.ReadAll(response.Body) - log.Printf("%s: DO update response for %s: %s\n", domain, doRecord.Name, string(body)) + log.Printf("%s: DO update response for %s: %s", domain.Domain, doRecord.Name, string(body)) updated++ } } } - log.Printf("%s: %d of %d records updated\n", domain, updated, len(toUpdateRecords)) + log.Printf("%s: %d of %d records updated", domain.Domain, updated, len(domain.Records)) +} + +// toIPv6String : net.IP.String will always output an IPv4 address in dot +// notation (127.0.0.1) even if we convert it using net.IP.To16(). +// For AAAA records, we can't have that. Instead, force the +// IP to have the IPv6 colon notation. +func toIPv6String(ip net.IP) (currentIP string) { + if ip == nil { + return "" + } + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } + l := len(ip) + if l < 16 { + // ensure "v4InV6Prefix" for IPv4 addresses + currentIP = "::ffff:" + } + // byte length of an ipv6 segment. + segSize := 2 + for i := 0; i < l; i += segSize { + end := i + segSize + bs := ip[i:end] + addColon := (end + 1) < l + currentIP += hex.EncodeToString(bs) + if addColon { + currentIP += ":" + } + } + return currentIP +} + +func areZero(bs []byte) bool { + for _, b := range bs { + if b != 0 { + return false + } + } + return true } func main() { config = GetConfig() currentIPv4, currentIPv6 := CheckLocalIPs() if currentIPv4 == nil && currentIPv6 == nil { - log.Fatalf("current IP addresses are not a valid, or both are disabled in the config. Check you configuration and internet connection", - currentIPv4, - currentIPv6, - ) + log.Fatal("current IP addresses are not a valid, or both are disabled in the config. Check you configuration and internet connection") } for _, domain := range config.Domains { - domainName := domain.Domain - log.Printf("%s: START\n", domainName) - UpdateRecords(domainName, currentIPv4, currentIPv6, domain.Records) - log.Printf("%s: END\n", domainName) + log.Printf("%s: START", domain.Domain) + UpdateRecords(domain, currentIPv4, currentIPv6) + log.Printf("%s: END", domain.Domain) } } diff --git a/digitalocean-dynamic-ip.sample.json b/digitalocean-dynamic-ip.sample.json index a8b40b4..e812158 100755 --- a/digitalocean-dynamic-ip.sample.json +++ b/digitalocean-dynamic-ip.sample.json @@ -3,6 +3,7 @@ "doPageSize": 20, "useIPv4": true, "useIPv6": true, + "allowIPv4InIPv6": false, "domains": [ { "domain": "example.com",