diff --git a/.gitignore b/.gitignore index adf8f72..e0bfe9a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ # Go workspace file go.work +.vscode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ead14e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.21-alpine + +# Set the Current Working Directory inside the container +WORKDIR /app + +# Copy go mod and sum files +COPY go.mod go.sum ./ + +# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed +RUN go mod download + +# Copy the source from the current directory to the Working Directory inside the container +COPY . . + +# Build the Go app +RUN go build -o main . + + +# Command to run the executable + +CMD ["./main"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..142723b --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +test: + go test ./pkg/... -coverprofile=coverage.out + +build: + go build + +lint: + golint ./... diff --git a/README.md b/README.md index 922e846..bf1a38b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,55 @@ # realDynDNS +RealDynDNS aims to be a replacement to "classical" dynDNS solutions that offer a subdomain. Instead realDynDns actually changes your DNS entries. +This service requires your DNS provider to expose an API that allows your DNS entries to be changed. +A service that provides your current external IP is also required. + +## Configuration +The configuration is done via a YAML file called `config.yaml`. The following example shows the configuration for a domain with two subdomains. + +Configuration of the IP provider and the DNS provider is mandatory. + +The `check_interval` is mandatory and follows the cron syntax including seconds. It defines how often the IP is checked and the DNS entries are updated. If you don't own the external ip provider, please be nice and don't set the interval too low. + +You may configure multiple top level domains with multiple subdomains. If you also want the top level domain to be updated, add it to the subdomains list with an `@`. + +```yaml +ip_provider: + type: plain + config: + url: https://ifconfig.me +dns_provider: + type: ionos + config: + api_key: + base_url: https://api.hosting.ionos.com/dns +domains: + - tld: example.com + subdomains: + - "@" + - www +check_interval: 0 0 0/6 * * * * +``` + +## DNS Providers +The DNS provider abstracts the API of your DNS provider. Currently the following providers are supported: + +### IONOS +IONOS requires two configuration parameters. You can get your API key [here](https://developer.hosting.ionos.com/docs/getstarted). +```yaml +api_key: +base_url: https://api.hosting.ionos.com/dns +``` + +## External IP Providers +The external IP provider is used to get your current external IP. Currently the following providers are supported: + +### Plain +Any provider that returns your IP as plain text can be used. The following configuration is required: +```yaml +url: +``` + +Examples for providers are: +- https://ifconfig.me +- https://api.ipify.org \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..9fe438e --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,16 @@ +--- +ip_provider: + type: plain + config: + url: https://ifconfig.me +dns_provider: + type: ionos + config: + api_key: + base_url: https://api.hosting.ionos.com/dns +domains: + - tld: example.com + subdomains: + - "@" + - www +check_interval: 0 0 0/6 * * * * diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..275c57c --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module realdnydns + +go 1.20 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-co-op/gocron v1.31.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + go.uber.org/atomic v1.9.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..45d646f --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-co-op/gocron v1.31.2 h1:tAUW64bxYc5QlzEy2t30TnHX2+uInNDajKXxWi4SACA= +github.com/go-co-op/gocron v1.31.2/go.mod h1:39f6KNSGVOU1LO/ZOoZfcSxwlsJDQOKSu8erN0SH48Y= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..14fbefe --- /dev/null +++ b/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "realdnydns/pkg/changeDetector" + "realdnydns/pkg/config" + "realdnydns/pkg/dnsProvider" + ionos "realdnydns/pkg/dnsProvider/ionos" + "realdnydns/pkg/externalIpProvider" + plainExternalIpProvider "realdnydns/pkg/externalIpProvider/plain" + "time" + + "github.com/go-co-op/gocron" +) + +func main() { + configClient := config.Config{} + err := configClient.Load("config.yaml") + if err != nil { + panic(err) + } + + var externalIpProvider externalIpProvider.ExternalIpProvider + switch configClient.ExternalIPProvider.Type { + case "plain": + var plainConfig plainExternalIpProvider.PlainExternalIpProviderConfig + err := configClient.ExternalIPProvider.ProviderConfig.Decode(&plainConfig) + if err != nil { + panic(err) + } + + externalIpProvider, err = plainExternalIpProvider.New(plainConfig) + default: + panic(fmt.Errorf("unknown external IP provider: %s", configClient.ExternalIPProvider.Type)) + } + + var dnsProvider dnsProvider.DNSProvider + switch configClient.DNSProvider.Type { + case "ionos": + var ionosConfig ionos.IONOSConfig + err := configClient.DNSProvider.ProviderConfig.Decode(&ionosConfig) + if err != nil { + panic(err) + } + dnsProvider, err = ionos.NewIonos(&ionosConfig) + if err != nil { + panic(err) + } + default: + panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type)) + } + + changeDetector := changeDetector.New(externalIpProvider, dnsProvider, configClient.Domains) + + s := gocron.NewScheduler(time.UTC) + s.SingletonMode() + job, err := s.CronWithSeconds(configClient.CheckInterval).DoWithJobDetails(func(in string, job gocron.Job) { + numberChanged, err := changeDetector.DetectAndApplyChanges() + if err != nil { + panic(err) + } + fmt.Printf("Number of changes: %d\n", numberChanged) + fmt.Println("Next run:", job.NextRun()) + }) + if err != nil { + panic(err) + } + + fmt.Println("Starting scheduler") + fmt.Println("Next run:", job.NextRun()) + s.StartBlocking() +} diff --git a/model/common/common.go b/model/common/common.go new file mode 100644 index 0000000..20575df --- /dev/null +++ b/model/common/common.go @@ -0,0 +1,9 @@ +package common + +type ARecord struct { + Domain string + IP string + TTL int + Prio int + Disabled bool +} diff --git a/pkg/changeDetector/changeDetector.go b/pkg/changeDetector/changeDetector.go new file mode 100644 index 0000000..3cc9fab --- /dev/null +++ b/pkg/changeDetector/changeDetector.go @@ -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 +} diff --git a/pkg/changeDetector/changeDetector_test.go b/pkg/changeDetector/changeDetector_test.go new file mode 100644 index 0000000..ffe0123 --- /dev/null +++ b/pkg/changeDetector/changeDetector_test.go @@ -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) + } + } +} diff --git a/pkg/config/__mocks__/testLoadCanFindFile.yaml b/pkg/config/__mocks__/testLoadCanFindFile.yaml new file mode 100644 index 0000000..48007ce --- /dev/null +++ b/pkg/config/__mocks__/testLoadCanFindFile.yaml @@ -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 * * * * diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..c0f30a5 --- /dev/null +++ b/pkg/config/config.go @@ -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 +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..dfd3e71 --- /dev/null +++ b/pkg/config/config_test.go @@ -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) + } + } +} diff --git a/pkg/dnsProvider/__mocks__/testNewCanCreateIonosProvider.yaml b/pkg/dnsProvider/__mocks__/testNewCanCreateIonosProvider.yaml new file mode 100644 index 0000000..48007ce --- /dev/null +++ b/pkg/dnsProvider/__mocks__/testNewCanCreateIonosProvider.yaml @@ -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 * * * * diff --git a/pkg/dnsProvider/dnsProvider.go b/pkg/dnsProvider/dnsProvider.go new file mode 100644 index 0000000..0f65172 --- /dev/null +++ b/pkg/dnsProvider/dnsProvider.go @@ -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) +} 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") + } + } +} diff --git a/pkg/externalIpProvider/externalIpProvider.go b/pkg/externalIpProvider/externalIpProvider.go new file mode 100644 index 0000000..a6c81ca --- /dev/null +++ b/pkg/externalIpProvider/externalIpProvider.go @@ -0,0 +1,9 @@ +package externalIpProvider + +import ( + "net" +) + +type ExternalIpProvider interface { + GetExternalIp() (net.IP, error) +} diff --git a/pkg/externalIpProvider/plain/plain.go b/pkg/externalIpProvider/plain/plain.go new file mode 100644 index 0000000..3fed9ad --- /dev/null +++ b/pkg/externalIpProvider/plain/plain.go @@ -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 +} diff --git a/pkg/externalIpProvider/plain/plain_test.go b/pkg/externalIpProvider/plain/plain_test.go new file mode 100644 index 0000000..edc7032 --- /dev/null +++ b/pkg/externalIpProvider/plain/plain_test.go @@ -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) + } + } +}