From 5482ac4e41d1204f84eefd678c65640947b06cd0 Mon Sep 17 00:00:00 2001 From: Timo Behrendt Date: Mon, 1 Apr 2024 11:45:26 +0200 Subject: [PATCH] feat: dns provider ionos --- pkg/dnsProvider/ionos/api/ionosAPI.go | 213 ++++++++++++++++ pkg/dnsProvider/ionos/api/ionosAPI_test.go | 284 +++++++++++++++++++++ pkg/dnsProvider/ionos/ionos.go | 40 +++ pkg/dnsProvider/ionos/ionos_test.go | 225 ++++++++++++++++ 4 files changed, 762 insertions(+) create mode 100644 pkg/dnsProvider/ionos/api/ionosAPI.go create mode 100644 pkg/dnsProvider/ionos/api/ionosAPI_test.go create mode 100644 pkg/dnsProvider/ionos/ionos.go create mode 100644 pkg/dnsProvider/ionos/ionos_test.go diff --git a/pkg/dnsProvider/ionos/api/ionosAPI.go b/pkg/dnsProvider/ionos/api/ionosAPI.go new file mode 100644 index 0000000..d7e263b --- /dev/null +++ b/pkg/dnsProvider/ionos/api/ionosAPI.go @@ -0,0 +1,213 @@ +package ionosAPI + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "realdnydns/model/common" +) + +/** + * Docs: https://developer.hosting.ionos.com/docs/dns + */ +type IonosAPI interface { + GetARecord(tld string, subdomain string) (*common.ARecord, error) + SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) + GetZoneId(tld string) (string, error) + GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) + HttpCall(method string, url string, body io.Reader) (*http.Response, error) +} + +type IonosAPIImpl struct { + APIKey string + BaseURL string + HTTPClient *http.Client +} + +type ZonesResponse []struct { + Name string `json:"name"` + Id string `json:"id"` + Type string `json:"type"` +} + +type ZoneResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Records []RecordResponse `json:"records"` +} + +type RecordResponse struct { + Id string `json:"id"` + Name string `json:"name"` + RootName string `json:"rootName"` + Type string `json:"type"` + Content string `json:"content"` + ChangeDate string `json:"changeDate"` + TTL int `json:"ttl"` + Prio int `json:"prio"` + Disabled bool `json:"disabled"` +} + +type ChangeRecord struct { + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` + Prio int `json:"prio"` + Disabled bool `json:"disabled"` +} + +type ChangeRecordRequest struct { + Content string `json:"content"` + TTL int `json:"ttl"` + Prio int `json:"prio"` + Disabled bool `json:"disabled"` +} + +func New(APIKey string, BaseURL string) IonosAPI { + return &IonosAPIImpl{ + APIKey: APIKey, + BaseURL: BaseURL, + HTTPClient: &http.Client{}, + } +} + +func (i *IonosAPIImpl) HttpCall(method string, url string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(method, i.BaseURL+url, body) + if err != nil { + return nil, err + } + + req.Header.Add("X-API-Key", i.APIKey) + + return i.HTTPClient.Do(req) +} + +func (i *IonosAPIImpl) GetZoneId(tld string) (string, error) { + res, err := i.HttpCall("GET", "/v1/zones", nil) + if err != nil { + return "", err + } + + responseBody := make([]byte, res.ContentLength) + res.Body.Read(responseBody) + + zones := []ZoneResponse{} + json.Unmarshal(responseBody, &zones) + + for _, z := range zones { + if z.Name == tld { + return z.Id, nil + } + } + + return "", errors.New("zone not found") +} + +func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) { + res, err := i.HttpCall("GET", "/v1/zones/"+zoneId, nil) + if err != nil { + return "", err + } + + responseBody := make([]byte, res.ContentLength) + res.Body.Read(responseBody) + + zone := ZoneResponse{} + json.Unmarshal(responseBody, &zone) + + var domain string + if subdomain != "" { + domain = subdomain + "." + tld + } else { + domain = tld + } + + for _, record := range zone.Records { + if record.Type == recordType && record.Name == domain { + return record.Id, nil + } + } + + return "", errors.New("record not found") +} + +func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { + zoneId, err := i.GetZoneId(tld) + if err != nil { + return nil, err + } + + recordId, err := i.GetRecordId(zoneId, tld, subdomain, "A") + if err != nil { + return nil, err + } + + changeRecordRequest, err := json.Marshal(ChangeRecordRequest{ + Content: ip.String(), + TTL: ttl, + Prio: prio, + Disabled: false, + }) + if err != nil { + return nil, err + } + + res, err := i.HttpCall("PUT", "/v1/zones/"+zoneId+"/records/"+recordId, bytes.NewReader(changeRecordRequest)) + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + return nil, errors.New("error updating record") + } + + responseBody := make([]byte, res.ContentLength) + res.Body.Read(responseBody) + + changeRecord := ChangeRecord{} + json.Unmarshal(responseBody, &changeRecord) + + return &common.ARecord{ + Domain: changeRecord.Name, + IP: changeRecord.Content, + TTL: changeRecord.TTL, + Prio: changeRecord.Prio, + Disabled: changeRecord.Disabled, + }, nil +} + +func (ionos *IonosAPIImpl) GetARecord(tld string, subdomain string) (*common.ARecord, error) { + zoneId, err := ionos.GetZoneId(tld) + if err != nil { + return nil, err + } + + recordId, err := ionos.GetRecordId(zoneId, tld, subdomain, "A") + if err != nil { + return nil, err + } + + res, err := ionos.HttpCall("GET", "/v1/zones/"+zoneId+"/records/"+recordId, nil) + if err != nil { + return nil, err + } + + responseBody := make([]byte, res.ContentLength) + res.Body.Read(responseBody) + + record := RecordResponse{} + json.Unmarshal(responseBody, &record) + + return &common.ARecord{ + Domain: record.Name, + IP: record.Content, + TTL: record.TTL, + Prio: record.Prio, + Disabled: record.Disabled, + }, nil +} diff --git a/pkg/dnsProvider/ionos/api/ionosAPI_test.go b/pkg/dnsProvider/ionos/api/ionosAPI_test.go new file mode 100644 index 0000000..f53fe0b --- /dev/null +++ b/pkg/dnsProvider/ionos/api/ionosAPI_test.go @@ -0,0 +1,284 @@ +package ionosAPI + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +func utilMockServerImpl() func(w http.ResponseWriter, r *http.Request) { + zonesResponse := []ZoneResponse{ + { + Name: "example.com", + Id: "1234567890", + Type: "NATIVE", + }, + { + Name: "notTheExample.org", + Id: "0987654321", + Type: "SLAVE", + }, + } + zonesResponseJson, _ := json.Marshal(zonesResponse) + + recordResponseSub := RecordResponse{ + Id: "abcdefghij", + Name: "example.com", + RootName: "example.com", + Type: "A", + Content: "127.0.0.1", + ChangeDate: "2019-12-09T13:04:25.772Z", + TTL: 300, + Prio: 0, + Disabled: false, + } + recordResponseSubJson, _ := json.Marshal(recordResponseSub) + + recordResponseTLD := RecordResponse{ + Id: "jihgfedcba", + Name: "sub.example.com", + RootName: "example.com", + Type: "A", + Content: "127.0.0.2", + ChangeDate: "2019-12-09T13:04:25.772Z", + TTL: 300, + Prio: 0, + Disabled: false, + } + recordResponseTLDJson, _ := json.Marshal(recordResponseTLD) + + zoneResponse := ZoneResponse{ + Id: "1234567890", + Name: "example.com", + Type: "NATIVE", + Records: []RecordResponse{ + recordResponseSub, + recordResponseTLD, + }, + } + zoneResponseJson, _ := json.Marshal(zoneResponse) + + changeRecord := ChangeRecord{ + Name: "sub.example.com", + Type: "A", + Content: "127.0.0.1", + TTL: 300, + Prio: 0, + Disabled: false, + } + changeRecordJson, _ := json.Marshal(changeRecord) + + return func(w http.ResponseWriter, r *http.Request) { + var response []byte + + if r.Method == "GET" { + if r.RequestURI == "/v1/zones" { + response = zonesResponseJson + } else if r.RequestURI == "/v1/zones/1234567890" { + response = zoneResponseJson + } else if r.RequestURI == "/v1/zones/1234567890/records/abcdefghij" { + response = recordResponseSubJson + } else if r.RequestURI == "/v1/zones/1234567890/records/jihgfedcba" { + response = recordResponseTLDJson + } + } else if r.Method == "PUT" { + response = changeRecordJson + } else { + response = []byte("404") + } + + w.Write(response) + } +} + +func TestHttpCall(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) + defer mockServer.Close() + + ionosAPI := New("dummyKey", mockServer.URL) + + t.Run("returns response for GET request", testHttpCallGet(ionosAPI)) + t.Run("returns response for PUT request", testHttpCallPut(ionosAPI)) + t.Run("returns error for non existing endpoint", testHttpCallNonExistingEndpoint()) +} + +func testHttpCallGet(api IonosAPI) func(t *testing.T) { + return func(t *testing.T) { + res, err := api.HttpCall("GET", "/v1/zones", nil) + + if err != nil { + t.Fatalf("HttpCall() returned unexpected error: %v", err) + } + + if res.StatusCode != 200 { + t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode) + } + } +} + +func testHttpCallPut(api IonosAPI) func(t *testing.T) { + return func(t *testing.T) { + res, err := api.HttpCall("PUT", "/v1/zones/1234567890/records/abcdefghij", nil) + + if err != nil { + t.Fatalf("HttpCall() returned unexpected error: %v", err) + } + + if res.StatusCode != 200 { + t.Fatalf("HttpCall() returned unexpected status code: %v instead of 200", res.StatusCode) + } + } +} + +func testHttpCallNonExistingEndpoint() func(t *testing.T) { + return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer mockServer.Close() + + api := New("dummyKey", mockServer.URL) + + res, err := api.HttpCall("GET", "/v1/nonExistingEndpoint", nil) + + if err != nil { + t.Fatalf("HttpCall() returned unexpected error: %v", err) + } + + if res.StatusCode != 404 { + t.Fatalf("HttpCall() returned unexpected status code: %v instead of 404", res.StatusCode) + } + } +} + +func TestGetZoneId(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) + + ionosAPI := New("dummyKey", mockServer.URL) + + t.Run("returns zoneId for tracked domain", testGetZoneIdSuccess(ionosAPI)) + t.Run("returns error for non tracked domain", testGetZoneIdNoMatch(ionosAPI)) +} + +func testGetZoneIdSuccess(api IonosAPI) func(t *testing.T) { + return func(t *testing.T) { + zoneId, err := api.GetZoneId("example.com") + + if err != nil { + t.Fatalf("GetZoneId() returned unexpected error: %v", err) + } + + if zoneId != "1234567890" { + t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of 1234567890", zoneId) + } + } +} + +func testGetZoneIdNoMatch(api IonosAPI) func(t *testing.T) { + return func(t *testing.T) { + zoneId, err := api.GetZoneId("nonTrackedDomain.com") + + if err == nil || zoneId != "" { + t.Fatalf("GetZoneId() did not return an error for a non tracked domain") + } + } +} + +func TestGetRecordId(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) + defer mockServer.Close() + + ionosAPI := New("dummyKey", mockServer.URL) + + t.Run("returns zoneId for top level domain", testGetRecordIdForTopLevelDomain(ionosAPI)) + t.Run("returns zoneId for subdomain", testGetRecordIdForSubdomain(ionosAPI)) + t.Run("returns error for untracked subdomain", testGetRecordIdWithUntrackedSubdomain(ionosAPI)) +} + +func testGetRecordIdForTopLevelDomain(api IonosAPI) func(t *testing.T) { + return func(t *testing.T) { + recordId, err := api.GetRecordId("1234567890", "example.com", "", "A") + + if err != nil { + t.Fatalf("GetZoneId() returned unexpected error: %v", err) + } + + if recordId != "abcdefghij" { + t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of abcdefghij", recordId) + } + } +} + +func testGetRecordIdForSubdomain(api IonosAPI) func(t *testing.T) { + return func(t *testing.T) { + recordId, err := api.GetRecordId("1234567890", "example.com", "sub", "A") + + if err != nil { + t.Fatalf("GetZoneId() returned unexpected error: %v", err) + } + + if recordId != "jihgfedcba" { + t.Fatalf("GetZoneId() returned unexpected zoneId: %v instead of jihgfedcba", recordId) + } + } +} + +func testGetRecordIdWithUntrackedSubdomain(api IonosAPI) func(t *testing.T) { + return func(t *testing.T) { + recordId, err := api.GetRecordId("1234567890", "example.com", "untrackedSub", "A") + + if err == nil && recordId == "" { + t.Fatalf("GetZoneId() did not return an error for a non tracked domain") + } + } +} + +func TestGetARecord(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) + defer mockServer.Close() + + ionosAPI := New("dummyKey", mockServer.URL) + + t.Run("returns A record for top level domain", testGetARecordFor(ionosAPI, "example.com", "")) + t.Run("returns A record for subdomain", testGetARecordFor(ionosAPI, "example.com", "sub")) +} + +func testGetARecordFor(api IonosAPI, tld string, subdomain string) func(t *testing.T) { + return func(t *testing.T) { + var domain string + if subdomain == "" { + domain = tld + } else { + domain = subdomain + "." + tld + } + + record, err := api.GetARecord(tld, subdomain) + + if err != nil { + t.Fatalf("GetARecord() returned unexpected error: %v", err) + } + + if record.Domain != domain { + t.Fatalf("GetARecord() returned unexpected record for: %v instead of %v", record.Domain, domain) + } + } +} + +func TestSetARecord(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(utilMockServerImpl())) + defer mockServer.Close() + + ionosAPI := New("dummyKey", mockServer.URL) + + changedARecord, err := ionosAPI.SetARecord("example.com", "sub", net.ParseIP("127.0.0.1"), 300, 0, false) + + if err != nil { + t.Fatalf("SetARecord() returned unexpected error: %v", err) + } + + if changedARecord.Domain != "sub.example.com" { + t.Fatalf("SetARecord() returned unexpected record for: %v instead of sub.example.com", changedARecord.Domain) + } +} diff --git a/pkg/dnsProvider/ionos/ionos.go b/pkg/dnsProvider/ionos/ionos.go new file mode 100644 index 0000000..11249a4 --- /dev/null +++ b/pkg/dnsProvider/ionos/ionos.go @@ -0,0 +1,40 @@ +package ionosDnsProvider + +import ( + "errors" + "net" + "realdnydns/model/common" + "realdnydns/pkg/dnsProvider" + ionosAPI "realdnydns/pkg/dnsProvider/ionos/api" +) + +type IONOS struct { + API ionosAPI.IonosAPI +} + +type IONOSConfig struct { + APIKey string `yaml:"api_key"` + BaseURL string `yaml:"base_url"` +} + +func NewIonos(config *IONOSConfig) (dnsProvider.DNSProvider, error) { + if config.APIKey == "" { + return nil, errors.New("api_key is required") + } + + if config.BaseURL == "" { + return nil, errors.New("base_url is required") + } + + return &IONOS{ + ionosAPI.New(config.APIKey, config.BaseURL), + }, nil +} + +func (ionos *IONOS) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { + return ionos.API.SetARecord(tld, subdomain, ip, ttl, prio, disabled) +} + +func (ionos *IONOS) GetRecord(tld string, subdomain string) (*common.ARecord, error) { + return ionos.API.GetARecord(tld, subdomain) +} diff --git a/pkg/dnsProvider/ionos/ionos_test.go b/pkg/dnsProvider/ionos/ionos_test.go new file mode 100644 index 0000000..d9b5448 --- /dev/null +++ b/pkg/dnsProvider/ionos/ionos_test.go @@ -0,0 +1,225 @@ +package ionosDnsProvider_test + +import ( + "errors" + "io" + "net" + "net/http" + common "realdnydns/model/common" + ionosDnsProvider "realdnydns/pkg/dnsProvider/ionos" + "testing" +) + +func TestNew(t *testing.T) { + //t.Run("Config cannot be decoded", testNewConfigCannotBeDecoded()) + t.Run("API key is required", testNewAPIKeyIsRequired()) + t.Run("Base URL is required", testNewBaseURLIsRequired()) + t.Run("Valid config", testNewValidConfig()) +} + +func testNewAPIKeyIsRequired() func(*testing.T) { + return func(t *testing.T) { + config := ionosDnsProvider.IONOSConfig{ + BaseURL: "https://api.ionos.com", + } + + _, err := ionosDnsProvider.NewIonos(&config) + if err == nil { + t.Error("Expected error, got nil") + } + } +} + +func testNewBaseURLIsRequired() func(*testing.T) { + return func(t *testing.T) { + config := ionosDnsProvider.IONOSConfig{ + APIKey: "1234", + } + + _, err := ionosDnsProvider.NewIonos(&config) + if err == nil { + t.Error("Expected error, got nil") + } + + } +} + +func testNewValidConfig() func(*testing.T) { + return func(t *testing.T) { + config := ionosDnsProvider.IONOSConfig{ + APIKey: "1234", + BaseURL: "https://api.ionos.com", + } + + _, err := ionosDnsProvider.NewIonos(&config) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + } +} + +type MockIonosAPI struct { + SetARecordFunc func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) + GetARecordFunc func(tld string, subdomain string) (*common.ARecord, error) + GetRecordIdFunc func(zoneId string, tld string, subdomain string, recordType string) (string, error) + GetZoneIdFunc func(tld string) (string, error) + HttpCallFunc func(method string, url string, body io.Reader) (*http.Response, error) +} + +func (m *MockIonosAPI) SetARecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { + return m.SetARecordFunc(tld, subdomain, ip, ttl, prio, disabled) +} + +func (m *MockIonosAPI) GetARecord(tld string, subdomain string) (*common.ARecord, error) { + return m.GetARecordFunc(tld, subdomain) +} + +func (m *MockIonosAPI) GetRecordId(zoneId string, tld string, subdomain string, recordType string) (string, error) { + return m.GetRecordIdFunc(zoneId, tld, subdomain, recordType) +} + +func (m *MockIonosAPI) GetZoneId(tld string) (string, error) { + return m.GetZoneIdFunc(tld) +} + +func (m *MockIonosAPI) HttpCall(method string, url string, body io.Reader) (*http.Response, error) { + return m.HttpCallFunc(method, url, body) +} + +func TestUpdateRecord(t *testing.T) { + t.Run("API error", testUpdateRecordAPIError()) + t.Run("API success", testUpdateRecordAPISuccess()) +} + +func testUpdateRecordAPIError() func(*testing.T) { + return func(t *testing.T) { + api := &MockIonosAPI{ + SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { + return nil, errors.New("API error") + }, + } + + provider := ionosDnsProvider.IONOS{ + API: api, + } + + _, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false) + if err == nil { + t.Error("Expected error, got nil") + } + } +} + +func testUpdateRecordAPISuccess() func(*testing.T) { + return func(t *testing.T) { + api := &MockIonosAPI{ + SetARecordFunc: func(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) { + return &common.ARecord{ + Domain: "sub", + IP: "127.0.0.1", + TTL: 60, + Prio: 0, + Disabled: false, + }, nil + }, + } + + provider := ionosDnsProvider.IONOS{ + API: api, + } + + record, err := provider.UpdateRecord("example.com", "sub", net.ParseIP("127.0.0.1"), 60, 0, false) + if err != nil { + t.Errorf("Expected nil, got %v", err) + } + + if record.Domain != "sub" { + t.Errorf("Expected sub, got %s", record.Domain) + } + + if record.IP != "127.0.0.1" { + t.Errorf("Expected 127.0.0.1, got %s", record.IP) + } + + if record.TTL != 60 { + t.Errorf("Expected 60, got %d", record.TTL) + } + + if record.Prio != 0 { + t.Errorf("Expected 0, got %d", record.Prio) + } + + if record.Disabled { + t.Error("Expected false, got true") + } + } +} + +func TestGetRecord(t *testing.T) { + t.Run("API error", testGetRecordAPIError()) + t.Run("API success", testGetRecordAPISuccess()) +} + +func testGetRecordAPIError() func(*testing.T) { + return func(t *testing.T) { + api := &MockIonosAPI{ + GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) { + return nil, errors.New("API error") + }, + } + + provider := ionosDnsProvider.IONOS{ + API: api, + } + + _, err := provider.GetRecord("example.com", "sub") + if err == nil { + t.Error("Expected error, got nil") + } + } +} + +func testGetRecordAPISuccess() func(*testing.T) { + return func(t *testing.T) { + api := &MockIonosAPI{ + GetARecordFunc: func(tld string, subdomain string) (*common.ARecord, error) { + return &common.ARecord{ + Domain: "sub", + IP: "127.0.0.1", + TTL: 60, + Prio: 0, + Disabled: false, + }, nil + }, + } + + provider := ionosDnsProvider.IONOS{ + API: api, + } + + record, err := provider.GetRecord("example.com", "sub") + if err != nil { + t.Errorf("expected nil, got %v", err) + } + + if record.Domain != "sub" { + t.Errorf("expected sub, got %s", record.Domain) + } + + if record.IP != "127.0.0.1" { + t.Errorf("expected 127.0.0.1, got %s", record.IP) + } + + if record.TTL != 60 { + t.Errorf("expected 60, got %d", record.TTL) + } + + if record.Prio != 0 { + t.Errorf("expected 0, got %d", record.Prio) + } + + if record.Disabled { + t.Error("expected false, got true") + } + } +}