package main import ( "bytes" "encoding/hex" "encoding/json" "flag" "fmt" "io/ioutil" "log" "net" "net/http" "net/url" "os" "strconv" homedir "github.com/mitchellh/go-homedir" ) func checkError(err error) { if err != nil { log.Fatal(err) } } 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"` IPv4CheckURL string `json:"ipv4CheckUrl"` IPv6CheckURL string `json:"ipv6CheckUrl"` AllowIPv4InIPv6 bool `json:"allowIPv4InIPv6"` Domains []Domain `json:"domains"` } // Domain : domains to be changed type Domain struct { Domain string `json:"domain"` Records []DNSRecord `json:"records"` } // DNSRecord : Modifyiable DNS record type DNSRecord struct { ID int64 `json:"id"` Type string `json:"type"` Name string `json:"name"` Priority *int `json:"priority"` Port *int `json:"port"` Weight *int `json:"weight"` TTL int `json:"ttl"` Flags *uint8 `json:"flags"` Tag *string `json:"tag"` Data string `json:"data"` } // DOResponse : DigitalOcean DNS Records response. type DOResponse struct { DomainRecords []DNSRecord `json:"domain_records"` Meta struct { Total int `json:"total"` } `json:"meta"` Links struct { Pages struct { First string `json:"first"` Previous string `json:"prev"` Next string `json:"next"` Last string `json:"last"` } `json:"pages"` } `json:"links"` } //GetConfig : get configuration file ~/.digitalocean-dynamic-ip.json func GetConfig() ClientConfig { cmdHelp := flag.Bool("h", false, "Show the help message") cmdHelp2 := flag.Bool("help", false, "Show the help message") flag.Parse() if *cmdHelp || *cmdHelp2 { usage() os.Exit(1) } configFile := "" if len(flag.Args()) == 0 { var err error configFile, err = homedir.Dir() checkError(err) configFile += "/.digitalocean-dynamic-ip.json" } else { configFile = flag.Args()[0] } getfile, err := ioutil.ReadFile(configFile) checkError(err) var config ClientConfig json.Unmarshal(getfile, &config) checkError(err) return config } func usage() { os.Stdout.WriteString(fmt.Sprintf("To use this program you can specify the following command options:\n"+ "-h | -help\n\tShow this help message\n"+ "[config_file]\n\tlocation of the configuration file\n\n"+ "If the [config_file] parameter is not passed, then the default\n"+ "config location of ~/.digitalocean-dynamic-ip.json will be used.\n\n"+ "example usages:\n\t%[1]s -help\n"+ "\t%[1]s\n"+ "\t%[1]s %[2]s\n"+ "", os.Args[0], "/path/to/my/config.json", )) } //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 ipv4CheckURL := "https://ipv4bot.whatismyipaddress.com" ipv6CheckURL := "https://ipv6bot.whatismyipaddress.com" if len(config.IPv4CheckURL) > 0 { ipv4CheckURL = config.IPv4CheckURL } if len(config.IPv6CheckURL) > 0 { ipv6CheckURL = config.IPv6CheckURL } if config.UseIPv4 == nil || *(config.UseIPv4) { ipv4String, _ = getURLBody(ipv4CheckURL) 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() log.Printf("Discovered IPv4 address `%s`", ipv4.String()) } if ipv4 == nil { log.Printf("Unable to parse `%s` as an IPv4 address", ipv4String) } } } if config.UseIPv6 == nil || *(config.UseIPv6) { ipv6String, _ = getURLBody(ipv6CheckURL) 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 IPv6 address", ipv6String) } else { log.Printf("Discovered IPv6 address `%s`", ipv6.String()) } } } return ipv4, ipv6 } func getURLBody(url string) (string, error) { request, err := http.Get(url) checkError(err) defer request.Body.Close() body, err := ioutil.ReadAll(request.Body) checkError(err) return string(body), nil } //GetDomainRecords : Get DNS records of current domain. func GetDomainRecords(domain string) []DNSRecord { ret := make([]DNSRecord, 0) var page DOResponse pageParam := "" // 20 is the default page size if config.DOPageSize > 0 && config.DOPageSize != 20 { 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) ret = append(ret, page.DomainRecords...) } return ret } func getPage(url string) DOResponse { log.Println(url) client := &http.Client{} request, err := http.NewRequest("GET", url, nil) checkError(err) request.Header.Add("Content-type", "Application/json") request.Header.Add("Authorization", "Bearer "+config.APIKey) response, err := client.Do(request) checkError(err) defer response.Body.Close() body, err := ioutil.ReadAll(response.Body) // log.Println(string(body)) var jsonDOResponse DOResponse e := json.Unmarshal(body, &jsonDOResponse) checkError(e) return jsonDOResponse } // UpdateRecords : Update DNS records of domain func UpdateRecords(domain Domain, ipv4, ipv6 net.IP) { log.Printf("%s: %d to update", domain.Domain, len(domain.Records)) updated := 0 doRecords := GetDomainRecords(domain.Domain) // look for the item to update if len(doRecords) < 1 { log.Printf("%s: No DNS records found in DigitalOcean", domain.Domain) return } log.Printf("%s: %d DNS records found in DigitalOcean", 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.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.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.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.Domain, toUpdateRecord.Type, toUpdateRecord.Name) for _, doRecord := range doRecords { //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.Domain, doRecord) continue } log.Printf("%s: updating %+v", domain.Domain, doRecord) // set the IP address doRecord.Data = currentIP if toUpdateRecord.TTL >= 30 && doRecord.TTL != toUpdateRecord.TTL { doRecord.TTL = toUpdateRecord.TTL } update, err := json.Marshal(doRecord) checkError(err) client := &http.Client{} request, err := http.NewRequest("PUT", "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") request.Header.Add("Authorization", "Bearer "+config.APIKey) response, err := client.Do(request) checkError(err) defer response.Body.Close() body, err := ioutil.ReadAll(response.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", 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.Fatal("Current IP addresses are not valid, or both are disabled in the config. Check your configuration and internet connection.") } for _, domain := range config.Domains { log.Printf("%s: START", domain.Domain) UpdateRecords(domain, currentIPv4, currentIPv6) log.Printf("%s: END", domain.Domain) } }