From 04207bacc613b7a02bed72e88b010230d746b27b Mon Sep 17 00:00:00 2001 From: Joakim Hedlund Date: Thu, 2 May 2024 11:48:04 +0200 Subject: [PATCH] add DigitalOcean provider (#240) * add DigitalOcean provider * linting --- README.md | 30 +++ .../digitalocean/digitalocean_provider.go | 244 ++++++++++++++++++ .../digitalocean_provider_test.go | 141 ++++++++++ internal/provider/factory.go | 3 + internal/utils/constants.go | 2 + internal/utils/settings.go | 4 + 6 files changed, 424 insertions(+) create mode 100644 internal/provider/digitalocean/digitalocean_provider.go create mode 100644 internal/provider/digitalocean/digitalocean_provider_test.go diff --git a/README.md b/README.md index 524c136a..9581df0b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ - [Update root domain](#update-root-domain) - [Configuration examples](#configuration-examples) - [Cloudflare](#cloudflare) + - [DigitalOcean](#digitalocean) - [DNSPod](#dnspod) - [Dreamhost](#dreamhost) - [Dynv6](#dynv6) @@ -91,6 +92,7 @@ | Provider | IPv4 support | IPv6 support | Root Domain | Subdomains | | ------------------------------------- | :----------------: | :----------------: | :----------------: | :----------------: | | [Cloudflare][cloudflare] | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | +| [DigitalOcean][digitalocean] | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | [Google Domains][google.domains] | :white_check_mark: | :white_check_mark: | :x: | :white_check_mark: | | [DNSPod][dnspod] | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | | [Dynv6][dynv6] | :white_check_mark: | :white_check_mark: | :x: | :white_check_mark: | @@ -110,6 +112,7 @@ | [IONOS][ionos] | :white_check_mark: | :white_check_mark: | :x: | :white_check_mark: | [cloudflare]: https://cloudflare.com +[digitalocean]: https://digitalocean.com [google.domains]: https://domains.google [dnspod]: https://www.dnspod.cn [dynv6]: https://dynv6.com @@ -322,6 +325,33 @@ For DNSPod, you need to provide your API Token(you can create it [here](https:// +#### DigitalOcean + +For DigitalOcean, you need to provide a API Token with the `domain` scopes (you can create it [here](https://cloud.digitalocean.com/account/api/tokens/new)), and config all the domains & subdomains. + +
+Example + +```json +{ + "provider": "DigitalOcean", + "login_token": "dop_v1_00112233445566778899aabbccddeeff", + "domains": [ + { + "domain_name": "example.com", + "sub_domains": ["@", "www"] + } + ], + "resolver": "8.8.8.8", + "ip_urls": ["https://api.ip.sb/ip"], + "ip_type": "IPv4", + "interval": 300 +} + +``` + +
+ #### Dreamhost For Dreamhost, you need to provide your API Token(you can create it [here](https://panel.dreamhost.com/?tree=home.api)), and config all the domains & subdomains. diff --git a/internal/provider/digitalocean/digitalocean_provider.go b/internal/provider/digitalocean/digitalocean_provider.go new file mode 100644 index 00000000..fceb6e12 --- /dev/null +++ b/internal/provider/digitalocean/digitalocean_provider.go @@ -0,0 +1,244 @@ +package digitalocean + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/TimothyYe/godns/internal/settings" + "github.com/TimothyYe/godns/internal/utils" + log "github.com/sirupsen/logrus" +) + +const ( + // URL is the endpoint for the DigitalOcean API. + URL = "https://api.digitalocean.com/v2" +) + +// DNSProvider struct definition. +type DNSProvider struct { + configuration *settings.Settings + API string +} + +type DomainRecordsResponse struct { + Records []DNSRecord `json:"domain_records"` +} + +// DNSRecord for DigitalOcean API. +type DNSRecord struct { + ID int32 `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + IP string `json:"data"` + TTL int32 `json:"ttl"` +} + +// SetIP updates DNSRecord.IP. +func (r *DNSRecord) SetIP(ip string) { + r.IP = ip +} + +// Init passes DNS settings and store it to the provider instance. +func (provider *DNSProvider) Init(conf *settings.Settings) { + provider.configuration = conf + provider.API = URL +} + +func (provider *DNSProvider) UpdateIP(domainName, subdomainName, ip string) error { + log.Infof("Checking IP for domain %s", domainName) + + records := provider.getDNSRecords(domainName) + matched := false + + // update records + for _, rec := range records { + rec := rec + if !recordTracked(provider.getCurrentDomain(domainName), &rec) { + log.Debug("Skipping record:", rec.Name) + continue + } + + if strings.Contains(rec.Name, subdomainName) || rec.Name == domainName { + if rec.IP != ip { + log.Infof("IP mismatch: Current(%+v) vs DigitalOcean(%+v)", ip, rec.IP) + provider.updateRecord(domainName, rec, ip) + } else { + log.Infof("Record OK: %+v - %+v", rec.Name, rec.IP) + } + + matched = true + } + } + + if !matched { + log.Debugf("Record %s not found, will create it.", subdomainName) + if err := provider.createRecord(domainName, subdomainName, ip); err != nil { + return err + } + log.Infof("Record [%s] created with IP address: %s", subdomainName, ip) + } + + return nil +} + +func (provider *DNSProvider) getRecordType() string { + var recordType string = utils.IPTypeA + if provider.configuration.IPType == "" || strings.ToUpper(provider.configuration.IPType) == utils.IPV4 { + recordType = utils.IPTypeA + } else if strings.ToUpper(provider.configuration.IPType) == utils.IPV6 { + recordType = utils.IPTypeAAAA + } + + return recordType +} + +func (provider *DNSProvider) getCurrentDomain(domainName string) *settings.Domain { + for _, domain := range provider.configuration.Domains { + domain := domain + if domain.DomainName == domainName { + return &domain + } + } + + return nil +} + +// Check if record is present in domain conf. +func recordTracked(domain *settings.Domain, record *DNSRecord) bool { + for _, subDomain := range domain.SubDomains { + if record.Name == subDomain { + return true + } + } + + return false +} + +// Create a new request with auth in place and optional proxy. +func (provider *DNSProvider) newRequest(method, url string, body io.Reader) (*http.Request, *http.Client) { + client := utils.GetHTTPClient(provider.configuration) + if client == nil { + log.Info("cannot create HTTP client") + } + + req, _ := http.NewRequest(method, provider.API+url, body) + req.Header.Set("Content-Type", "application/json") + + if provider.configuration.Email != "" && provider.configuration.Password != "" { + req.Header.Set("X-Auth-Email", provider.configuration.Email) + req.Header.Set("X-Auth-Key", provider.configuration.Password) + } else if provider.configuration.LoginToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.configuration.LoginToken)) + } + log.Debugf("Created %+v request for %+v", string(method), string(url)) + + return req, client +} + +// Get all DNS A(AAA) records for a zone. +func (provider *DNSProvider) getDNSRecords(domainName string) []DNSRecord { + + var empty []DNSRecord + var r DomainRecordsResponse + recordType := provider.getRecordType() + + log.Infof("Querying records with type: %s", recordType) + req, client := provider.newRequest("GET", fmt.Sprintf("/domains/"+domainName+"/records?type=%s&page=1&per_page=200", recordType), nil) + resp, err := client.Do(req) + if err != nil { + log.Error("Request error:", err) + return empty + } + + body, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(body, &r) + if err != nil { + log.Infof("Decoder error: %+v", err) + log.Debugf("Response body: %+v", string(body)) + return empty + } + + return r.Records +} + +func (provider *DNSProvider) createRecord(domain, subDomain, ip string) error { + recordType := provider.getRecordType() + + newRecord := DNSRecord{ + Type: recordType, + IP: ip, + TTL: int32(provider.configuration.Interval), + } + + if subDomain == utils.RootDomain { + newRecord.Name = utils.RootDomain + } else { + newRecord.Name = subDomain + } + + content, err := json.Marshal(newRecord) + if err != nil { + log.Errorf("Encoder error: %+v", err) + return err + } + + req, client := provider.newRequest("POST", fmt.Sprintf("/domains/%s/records", domain), bytes.NewBuffer(content)) + resp, err := client.Do(req) + if err != nil { + log.Error("Request error:", err) + return err + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Errorf("Failed to read request body: %+v", err) + return err + } + + var r DNSRecord + err = json.Unmarshal(body, &r) + if err != nil { + log.Errorf("Response decoder error: %+v", err) + log.Debugf("Response body: %+v", string(body)) + return err + } + + return nil +} + +// Update DNS Record with new IP. +func (provider *DNSProvider) updateRecord(domainName string, record DNSRecord, newIP string) string { + + var r DNSRecord + record.SetIP(newIP) + var lastIP string + + j, _ := json.Marshal(record) + req, client := provider.newRequest("PUT", + fmt.Sprintf("/domains/%s/records/%d", domainName, record.ID), + bytes.NewBuffer(j), + ) + resp, err := client.Do(req) + if err != nil { + log.Error("Request error:", err) + return "" + } + + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + err = json.Unmarshal(body, &r) + if err != nil { + log.Errorf("Decoder error: %+v", err) + log.Debugf("Response body: %+v", string(body)) + return "" + } + log.Infof("Record updated: %+v - %+v", record.Name, record.IP) + lastIP = record.IP + + return lastIP +} diff --git a/internal/provider/digitalocean/digitalocean_provider_test.go b/internal/provider/digitalocean/digitalocean_provider_test.go new file mode 100644 index 00000000..6c44ba9a --- /dev/null +++ b/internal/provider/digitalocean/digitalocean_provider_test.go @@ -0,0 +1,141 @@ +package digitalocean + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/TimothyYe/godns/internal/settings" +) + +func TestDNSResponseToJSON(t *testing.T) { + s := strings.NewReader(` + { + "domain_records": [ + { + "id": 12345678, + "type": "A", + "name": "potato", + "data": "127.0.0.1", + "priority": null, + "port": null, + "ttl": 3600, + "weight": null, + "flags": null, + "tag": null + } + ], + "links": {}, + "meta": { + "total": 1 + } + }`) + + var resp DomainRecordsResponse + err := json.NewDecoder(s).Decode(&resp) + if err != nil { + t.Error(err.Error()) + } + if resp.Records[0].ID != 12345678 { + t.Errorf("ID Error: %#v != 12345678 ", resp.Records[0].ID) + } + if resp.Records[0].Name != "potato" { + t.Errorf("Name Error: %#v != potato", resp.Records[0].Name) + } +} +func TestDNSUpdateResponseToJSON(t *testing.T) { + s := strings.NewReader(` + { + "id": 12345678, + "type": "A", + "name": "@", + "data": "127.0.0.1", + "priority": null, + "port": null, + "ttl": 3600, + "weight": null, + "flags": null, + "tag": null + }`) + + var resp DNSRecord + err := json.NewDecoder(s).Decode(&resp) + if err != nil { + t.Error(err.Error()) + } + if resp.ID != 12345678 { + t.Errorf("ID Error: %#v != 12345678 ", resp.ID) + } + if resp.Name != "@" { + t.Errorf("Name Error: %#v != @", resp.Name) + } +} + +func TestRecordTracked(t *testing.T) { + s := strings.NewReader(` + { + "domain_records": [ + { + "id": 12345678, + "type": "A", + "name": "@", + "data": "127.0.0.1", + "priority": null, + "port": null, + "ttl": 3600, + "weight": null, + "flags": null, + "tag": null + }, + { + "id": 12345678, + "type": "A", + "name": "swordfish", + "data": "127.0.0.1", + "priority": null, + "port": null, + "ttl": 3600, + "weight": null, + "flags": null, + "tag": null + }, + { + "id": 12345678, + "type": "A", + "name": "www", + "data": "127.0.0.1", + "priority": null, + "port": null, + "ttl": 3600, + "weight": null, + "flags": null, + "tag": null + } + ], + "links": {}, + "meta": { + "total": 3 + } + }`) + + var resp DomainRecordsResponse + err := json.NewDecoder(s).Decode(&resp) + if err != nil { + t.Error(err.Error()) + } + var matchedDomains int + domain := &settings.Domain{ + DomainName: "example.com", + SubDomains: []string{"www", "@"}, + } + + for _, rec := range resp.Records { + if recordTracked(domain, &rec) { + t.Logf("Record founded: %+v", rec.Name) + matchedDomains++ + } + } + if matchedDomains != 2 { + t.Errorf("Unexpected amount of domains matched: %#v != 2", matchedDomains) + } +} diff --git a/internal/provider/factory.go b/internal/provider/factory.go index 57bd99ab..54ac8750 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -5,6 +5,7 @@ import ( "github.com/TimothyYe/godns/internal/provider/alidns" "github.com/TimothyYe/godns/internal/provider/cloudflare" + "github.com/TimothyYe/godns/internal/provider/digitalocean" "github.com/TimothyYe/godns/internal/provider/dnspod" "github.com/TimothyYe/godns/internal/provider/dreamhost" "github.com/TimothyYe/godns/internal/provider/duck" @@ -31,6 +32,8 @@ func GetProvider(conf *settings.Settings) (IDNSProvider, error) { switch conf.Provider { case utils.CLOUDFLARE: provider = &cloudflare.DNSProvider{} + case utils.DIGITALOCEAN: + provider = &digitalocean.DNSProvider{} case utils.DNSPOD: provider = &dnspod.DNSProvider{} case utils.DREAMHOST: diff --git a/internal/utils/constants.go b/internal/utils/constants.go index af581a61..f2723fe7 100644 --- a/internal/utils/constants.go +++ b/internal/utils/constants.go @@ -15,6 +15,8 @@ const ( ALIDNS = "AliDNS" // GOOGLE for Google Domains. GOOGLE = "Google" + // DIGITALOCEAN for DigitalOcean. + DIGITALOCEAN = "DigitalOcean" // DUCK for Duck DNS. DUCK = "DuckDNS" // DREAMHOST for Dreamhost. diff --git a/internal/utils/settings.go b/internal/utils/settings.go index a73ca831..5a3e58ff 100644 --- a/internal/utils/settings.go +++ b/internal/utils/settings.go @@ -34,6 +34,10 @@ func CheckSettings(config *settings.Settings) error { if config.Password == "" { return errors.New("password cannot be empty") } + case DIGITALOCEAN: + if config.LoginToken == "" { + return errors.New("login token cannot be empty") + } case DUCK: if config.LoginToken == "" { return errors.New("login token cannot be empty")