feat: mvp (#1)

MVP including features:
* Detect IP change
* Configurable/generic external IP provider
* Configurable/generic DNS provider
* Impl. of plain external IP provider
* Impl. of Ionos DNS provider

Reviewed-on: #1
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
This commit was merged in pull request #1.
This commit is contained in:
2024-04-01 11:50:27 +02:00
committed by t.behrendt
parent 90369bf147
commit 99361a5ccd
23 changed files with 1521 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
package changeDetector
import (
"realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider"
"realdnydns/pkg/externalIpProvider"
)
type ChangeDetector struct {
externalIpProvider externalIpProvider.ExternalIpProvider
dnsProvider dnsProvider.DNSProvider
domains []config.DomainConfig
}
func New(
externalIpProvider externalIpProvider.ExternalIpProvider,
dnsProvider dnsProvider.DNSProvider,
domains []config.DomainConfig,
) ChangeDetector {
return ChangeDetector{
externalIpProvider: externalIpProvider,
dnsProvider: dnsProvider,
domains: domains,
}
}
func (c *ChangeDetector) DetectAndApplyChanges() (int, error) {
externalIp, err := c.externalIpProvider.GetExternalIp()
if err != nil {
return 0, err
}
var numberUpdated int
for _, domain := range c.domains {
for _, subdomain := range domain.Subdomains {
currentRecord, err := c.dnsProvider.GetRecord(domain.TLD, subdomain)
if err != nil {
return numberUpdated, err
}
if currentRecord.IP != externalIp.String() {
_, err = c.dnsProvider.UpdateRecord(domain.TLD, subdomain, externalIp, currentRecord.TTL, currentRecord.Prio, currentRecord.Disabled)
numberUpdated++
if err != nil {
return numberUpdated, err
}
}
}
}
return numberUpdated, nil
}

View File

@@ -0,0 +1,105 @@
package changeDetector
import (
"net"
"realdnydns/model/common"
"realdnydns/pkg/config"
"testing"
)
type MockExternalIpProvider struct {
ExternalIp string
}
func (m *MockExternalIpProvider) GetExternalIp() (net.IP, error) {
return net.ParseIP(m.ExternalIp), nil
}
type MockDNSProvider interface {
UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
GetRecord(tld string, subdomain string) (*common.ARecord, error)
}
type MockDNSProviderImpl struct {
GetRecordIpResponse string
UpdateRecordIpResponse string
}
func (m *MockDNSProviderImpl) GetRecord(tld string, subdomain string) (*common.ARecord, error) {
return &common.ARecord{
IP: m.GetRecordIpResponse,
TTL: 10,
Prio: 20,
Disabled: false,
}, nil
}
func (m *MockDNSProviderImpl) UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error) {
return &common.ARecord{
IP: m.UpdateRecordIpResponse,
TTL: 10,
Prio: 20,
Disabled: false,
}, nil
}
func TestDetectAndApplyChanges(t *testing.T) {
t.Run("with changes", testDetectAndApplyChangesWithChanges())
t.Run("without changes", testDetectAndApplyChangesWithoutChanges())
}
func testDetectAndApplyChangesWithChanges() func(t *testing.T) {
return func(t *testing.T) {
changeDetector := New(&MockExternalIpProvider{
ExternalIp: "127.0.0.1",
}, &MockDNSProviderImpl{
GetRecordIpResponse: "127.0.0.2",
UpdateRecordIpResponse: "127.0.0.1",
}, []config.DomainConfig{
{
TLD: "example.com",
Subdomains: []string{
"test",
"@",
},
},
})
numberUpdated, err := changeDetector.DetectAndApplyChanges()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if numberUpdated != 2 {
t.Errorf("expected numberUpdated to be 2, got %v", numberUpdated)
}
}
}
func testDetectAndApplyChangesWithoutChanges() func(t *testing.T) {
return func(t *testing.T) {
changeDetector := New(&MockExternalIpProvider{
ExternalIp: "127.0.0.1",
}, &MockDNSProviderImpl{
GetRecordIpResponse: "127.0.0.1",
UpdateRecordIpResponse: "127.0.0.1",
}, []config.DomainConfig{
{
TLD: "example.com",
Subdomains: []string{
"test",
"@",
},
},
})
numberUpdated, err := changeDetector.DetectAndApplyChanges()
if err != nil {
t.Errorf("expected no error, got %v", err)
}
if numberUpdated != 0 {
t.Errorf("expected numberUpdated to be 0, got %v", numberUpdated)
}
}
}

View File

@@ -0,0 +1,16 @@
---
ip_provider:
type: plain
config:
url: https://ifconfig.me
dns_provider:
type: ionos
config:
api_key: exampleAPIKey
base_url: https://example.com
domains:
- tld: example.com
subdomains:
- "@"
- www
check_interval: 0 0 0/6 * * * *

43
pkg/config/config.go Normal file
View File

@@ -0,0 +1,43 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type DomainConfig struct {
TLD string `yaml:"tld"`
Subdomains []string `yaml:"subdomains"`
}
type Config struct {
ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"`
DNSProvider DNSProviderConfig `yaml:"dns_provider"`
Domains []DomainConfig `yaml:"domains"`
CheckInterval string `yaml:"check_interval"`
}
type ExternalIpProviderConfig struct {
Type string `yaml:"type"`
ProviderConfig yaml.Node `yaml:"config"`
}
type DNSProviderConfig struct {
Type string `yaml:"type"`
ProviderConfig yaml.Node `yaml:"config"`
}
func (c *Config) Load(filePath string) error {
err := yaml.Unmarshal([]byte(filePath), c)
if err != nil {
inputConfig, err := os.ReadFile(filePath)
if err != nil {
return err
}
return yaml.Unmarshal(inputConfig, c)
}
return err
}

65
pkg/config/config_test.go Normal file
View File

@@ -0,0 +1,65 @@
package config
import (
"testing"
)
func TestLoad(t *testing.T) {
t.Run("Can find file", testLoadCanFindFile())
t.Run("Cannot find file", testLoadCannotFindFile())
t.Run("Unmarshals from direct input", testLoadUnmarshalsFromDirectInput())
}
func testLoadCanFindFile() func(t *testing.T) {
return func(t *testing.T) {
c := Config{}
err := c.Load("./__mocks__/testLoadCanFindFile.yaml")
want := c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain"
if !want || err != nil {
t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want)
}
}
}
func testLoadCannotFindFile() func(t *testing.T) {
return func(t *testing.T) {
c := Config{}
err := c.Load("nonexistent.yaml")
want := err != nil
if !want {
t.Fatalf("Config didn't throw an error")
}
}
}
func testLoadUnmarshalsFromDirectInput() func(t *testing.T) {
return func(t *testing.T) {
c := Config{}
err := c.Load(`---
ip_provider:
type: plain
config:
url: https://ifconfig.me
dns_provider:
type: ionos
config:
api_key: exampleAPIKey
base_url: https://example.com
domains:
- tld: example.com
subdomains:
- "@"
- www
check_interval: 0 0 0/6 * * * *`)
want := c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain"
if !want || err != nil {
t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want)
}
}
}

View File

@@ -0,0 +1,16 @@
---
ip_provider:
type: plain
config:
url: https://ifconfig.me
dns_provider:
type: ionos
config:
api_key: exampleAPIKey
base_url: https://example.com
domains:
- tld: example.com
subdomains:
- "@"
- www
check_interval: 0 0 0/6 * * * *

View File

@@ -0,0 +1,11 @@
package dnsProvider
import (
"net"
"realdnydns/model/common"
)
type DNSProvider interface {
UpdateRecord(tld string, subdomain string, ip net.IP, ttl int, prio int, disabled bool) (*common.ARecord, error)
GetRecord(tld string, subdomain string) (*common.ARecord, error)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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")
}
}
}

View File

@@ -0,0 +1,9 @@
package externalIpProvider
import (
"net"
)
type ExternalIpProvider interface {
GetExternalIp() (net.IP, error)
}

View File

@@ -0,0 +1,56 @@
package externalIpProvider
import (
"errors"
"net"
"net/http"
"net/url"
externalIpProvider "realdnydns/pkg/externalIpProvider"
)
type ExternalIpProviderImplPlain struct {
IPProviderURL string
HTTPClient *http.Client
}
type PlainExternalIpProviderConfig struct {
Url string `yaml:"url"`
}
func New(config PlainExternalIpProviderConfig) (externalIpProvider.ExternalIpProvider, error) {
if config.Url == "" {
return nil, errors.New("url is required")
}
return &ExternalIpProviderImplPlain{
config.Url,
&http.Client{},
}, nil
}
func (p *ExternalIpProviderImplPlain) GetExternalIp() (net.IP, error) {
parsedUrl, err := url.Parse(p.IPProviderURL)
if err != nil {
return nil, err
}
res, err := p.HTTPClient.Get(parsedUrl.String())
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
return nil, errors.New("unexpected status code")
}
responseBody := make([]byte, res.ContentLength)
res.Body.Read(responseBody)
defer res.Body.Close()
parsedIp := net.ParseIP(string(responseBody))
if parsedIp == nil {
return nil, errors.New("unable to parse ip")
}
return parsedIp, nil
}

View File

@@ -0,0 +1,150 @@
package externalIpProvider
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNew(t *testing.T) {
t.Run("returns external ip provider", testNew())
t.Run("returns error when url is empty", testNewEmptyUrl())
}
func testNew() func(t *testing.T) {
return func(t *testing.T) {
provider, err := New(PlainExternalIpProviderConfig{
Url: "http://localhost",
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
if provider == nil {
t.Fatalf("NewPlain() returned nil provider")
}
}
}
func testNewEmptyUrl() func(t *testing.T) {
return func(t *testing.T) {
_, err := New(PlainExternalIpProviderConfig{})
if err == nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
}
}
func TestGetExternal(t *testing.T) {
t.Run("returns external ip", testGetExternalIp())
t.Run("returns error when unable to parse ip", testGetExternalIpUnparsableIp())
t.Run("returns error when unexpected status code", testGetExternalIpUnexpectedStatusCode())
t.Run("returns error when unable to parse url", testGetExternalIpUnableToParseUrl())
t.Run("returns error when unable to get", testGetExternalIpUnableToGet())
}
func testGetExternalIp() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("127.0.0.1"))
},
))
defer mockServer.Close()
provider, err := New(PlainExternalIpProviderConfig{
Url: mockServer.URL,
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
ip, err := provider.GetExternalIp()
if err != nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
if ip.String() != "127.0.0.1" {
t.Fatalf("GetExternalIp() returned unexpected ip: %v instead of 127.0.0.1", ip)
}
}
}
func testGetExternalIpUnparsableIp() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("not an ip"))
},
))
defer mockServer.Close()
provider, err := New(PlainExternalIpProviderConfig{
Url: mockServer.URL,
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}
func testGetExternalIpUnexpectedStatusCode() func(t *testing.T) {
return func(t *testing.T) {
mockServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
},
))
defer mockServer.Close()
provider, err := New(PlainExternalIpProviderConfig{
Url: mockServer.URL,
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}
func testGetExternalIpUnableToParseUrl() func(t *testing.T) {
return func(t *testing.T) {
provider, err := New(PlainExternalIpProviderConfig{
Url: "not a url !'§%&",
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}
func testGetExternalIpUnableToGet() func(t *testing.T) {
return func(t *testing.T) {
// force error by using a non-responding url, beware of side effects
provider, err := New(PlainExternalIpProviderConfig{
Url: "http://localhost:1234",
})
if err != nil {
t.Fatalf("NewPlain() returned unexpected error: %v", err)
}
_, err = provider.GetExternalIp()
if err == nil {
t.Fatalf("GetExternalIp() returned unexpected error: %v", err)
}
}
}