diff --git a/config.example.yaml b/config.example.yaml index eb37f59..b8fa980 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,7 +2,7 @@ ip_provider: type: plain config: - url: https://ifconfig.me + url: https://api.ipify.org dns_provider: type: ionos config: @@ -20,3 +20,4 @@ domains: - "@" - www check_interval: 0 0 0/6 * * * * +mode: Scheduled diff --git a/go.mod b/go.mod index 1b66a14..6df1654 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,8 @@ require ( ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // 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.11.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index e32c7ce..4d6bd32 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -11,9 +9,11 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 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= @@ -21,10 +21,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= 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= @@ -32,16 +32,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO 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= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 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 index 6295be0..477ce94 100644 --- a/main.go +++ b/main.go @@ -2,9 +2,7 @@ package main import ( "fmt" - "time" - "realdnydns/pkg/changeDetector" "realdnydns/pkg/config" "realdnydns/pkg/dnsProvider" ionos "realdnydns/pkg/dnsProvider/ionos" @@ -13,8 +11,7 @@ import ( "realdnydns/pkg/notificationProvider" notificationProviderConsole "realdnydns/pkg/notificationProvider/console" gotify "realdnydns/pkg/notificationProvider/gotify" - - "github.com/go-co-op/gocron" + "realdnydns/pkg/realDynDns" ) func main() { @@ -71,27 +68,26 @@ func main() { panic(err) } default: - // Use default console notification provider notificationProvider = notificationProviderConsole.New() } - changeDetector := changeDetector.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains) + rdd := realDynDns.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains) - s := gocron.NewScheduler(time.UTC) - s.SingletonMode() - job, err := s.CronWithSeconds(configClient.CheckInterval).DoWithJobDetails(func(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) + switch configClient.Mode { + case config.ScheduledMode: + schedule, job, err := rdd.RunWithSchedule(configClient.CheckInterval) + if err != nil { + panic(err) + } + + fmt.Println("Starting scheduler") + fmt.Println("Next run:", job.NextRun()) + schedule.StartBlocking() + case config.RunOnceMode: + numberOfChanges, err := rdd.RunOnce() + if err != nil { + panic(err) + } + fmt.Println("Number of changes:", numberOfChanges) } - - fmt.Println("Starting scheduler") - fmt.Println("Next run:", job.NextRun()) - s.StartBlocking() } diff --git a/pkg/config/__mocks__/testLoadCanFindFile.yaml b/pkg/config/__mocks__/testLoadCanFindFile.yaml index 48007ce..9aa504b 100644 --- a/pkg/config/__mocks__/testLoadCanFindFile.yaml +++ b/pkg/config/__mocks__/testLoadCanFindFile.yaml @@ -1,16 +1,15 @@ ---- ip_provider: type: plain config: - url: https://ifconfig.me + url: https://example.com dns_provider: type: ionos config: - api_key: exampleAPIKey + api_key: exampleapikey base_url: https://example.com domains: - tld: example.com subdomains: - "@" - - www -check_interval: 0 0 0/6 * * * * +check_interval: 0 0 * * * * +mode: RunOnce diff --git a/pkg/config/__mocks__/testLoadInvalidMode.yaml b/pkg/config/__mocks__/testLoadInvalidMode.yaml new file mode 100644 index 0000000..7eb8dc2 --- /dev/null +++ b/pkg/config/__mocks__/testLoadInvalidMode.yaml @@ -0,0 +1,2 @@ +mode: "InvalidMode" +check_interval: "5m" diff --git a/pkg/config/__mocks__/testLoadInvalidYAML.yaml b/pkg/config/__mocks__/testLoadInvalidYAML.yaml new file mode 100644 index 0000000..80984cc --- /dev/null +++ b/pkg/config/__mocks__/testLoadInvalidYAML.yaml @@ -0,0 +1,3 @@ +mode: "Scheduled" +check_interval: "5m" +- invalid_content diff --git a/pkg/config/__mocks__/testLoadMissingCheckInterval.yaml b/pkg/config/__mocks__/testLoadMissingCheckInterval.yaml new file mode 100644 index 0000000..ecd177b --- /dev/null +++ b/pkg/config/__mocks__/testLoadMissingCheckInterval.yaml @@ -0,0 +1,3 @@ +mode: "Scheduled" +ip_provider: + type: "plain" diff --git a/pkg/config/config.go b/pkg/config/config.go index 28b9488..00560ee 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,17 +1,15 @@ package config import ( + "errors" + "fmt" "os" "gopkg.in/yaml.v3" ) -type DomainConfig struct { - TLD string `yaml:"tld"` - Subdomains []string `yaml:"subdomains"` -} - type Config struct { + Mode string `yaml:"mode"` ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"` DNSProvider DNSProviderConfig `yaml:"dns_provider"` NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"` @@ -19,6 +17,16 @@ type Config struct { CheckInterval string `yaml:"check_interval"` } +const ( + RunOnceMode = "RunOnce" + ScheduledMode = "Scheduled" +) + +type DomainConfig struct { + TLD string `yaml:"tld"` + Subdomains []string `yaml:"subdomains"` +} + type ExternalIpProviderConfig struct { Type string `yaml:"type"` ProviderConfig yaml.Node `yaml:"config"` @@ -35,15 +43,30 @@ type NotificationProviderConfig struct { } func (c *Config) Load(filePath string) error { - err := yaml.Unmarshal([]byte(filePath), c) + inputConfig, err := os.ReadFile(filePath) if err != nil { - inputConfig, err := os.ReadFile(filePath) - if err != nil { - return err - } - - return yaml.Unmarshal(inputConfig, c) + return fmt.Errorf("failed to read config file: %w", err) } - return err + if err := yaml.Unmarshal(inputConfig, c); err != nil { + return fmt.Errorf("failed to unmarshal config file: %w", err) + } + + if err := c.validate(); err != nil { + return fmt.Errorf("failed to validate config: %w", err) + } + + return nil; } + +func (c *Config) validate() error { + if c.Mode != RunOnceMode && c.Mode != ScheduledMode { + return errors.New("mode must be one of 'RunOnce' or 'Scheduled'") + } + + if c.Mode == ScheduledMode && c.CheckInterval == "" { + return errors.New("check interval must be set when mode is 'Scheduled'") + } + + return nil; +} \ No newline at end of file diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4e60fbd..5f5f5e9 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,69 +1,52 @@ package config import ( + "fmt" "testing" ) +func testFactoryFileRelatedError(fileName string, expectedErrorText string) func(t *testing.T) { + return func(t *testing.T) { + c := Config{} + err := c.Load(fmt.Sprintf("./__mocks__/%s", fileName)) + + want := err != nil && err.Error() == expectedErrorText + + if !want { + t.Fatalf("Expected error message %s, but got %s", expectedErrorText, err.Error()) + } + } +} + func TestLoad(t *testing.T) { t.Run("Can find file", testLoadCanFindFile()) - t.Run("Cannot find file", testLoadCannotFindFile()) - t.Run("Unmarshals from direct input", testLoadUnmarshalsFromDirectInput()) + t.Run("Cannot find file", testFactoryFileRelatedError( + "nonexistent.yaml", + "failed to read config file: open ./__mocks__/nonexistent.yaml: no such file or directory", + )) + t.Run("Missing CheckInterval in Scheduled mode", testFactoryFileRelatedError( + "testLoadMissingCheckInterval.yaml", + "failed to validate config: check interval must be set when mode is 'Scheduled'", + )) + t.Run("Invalid mode", testFactoryFileRelatedError( + "testLoadInvalidMode.yaml", + "failed to validate config: mode must be one of 'RunOnce' or 'Scheduled'", + )) + t.Run("Invalid YAML", testFactoryFileRelatedError( + "testLoadInvalidYAML.yaml", + "failed to unmarshal config file: yaml: line 2: did not find expected key", + )) } 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" + want := err == nil && c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" && c.Mode == "RunOnce" 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 -notification_provider: - type: gotify - config: - 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" && c.NotificationProvider.Type == "gotify" - - if !want || err != nil { - t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want) + t.Fatalf("Failed to load config file, expected no errors but got: %s", err) } } } diff --git a/pkg/dnsProvider/ionos/api/ionosAPI.go b/pkg/dnsProvider/ionos/api/ionosAPI.go index d7e263b..351dd0d 100644 --- a/pkg/dnsProvider/ionos/api/ionosAPI.go +++ b/pkg/dnsProvider/ionos/api/ionosAPI.go @@ -83,6 +83,8 @@ func (i *IonosAPIImpl) HttpCall(method string, url string, body io.Reader) (*htt } req.Header.Add("X-API-Key", i.APIKey) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") return i.HTTPClient.Do(req) } @@ -121,10 +123,10 @@ func (i *IonosAPIImpl) GetRecordId(zoneId string, tld string, subdomain string, json.Unmarshal(responseBody, &zone) var domain string - if subdomain != "" { - domain = subdomain + "." + tld - } else { + if subdomain == "@" || subdomain == "" { domain = tld + } else { + domain = subdomain + "." + tld } for _, record := range zone.Records { @@ -162,12 +164,14 @@ func (i *IonosAPIImpl) SetARecord(tld string, subdomain string, ip net.IP, ttl i return nil, err } + responseBody := make([]byte, res.ContentLength) + res.Body.Read(responseBody) + 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) diff --git a/pkg/notificationProvider/gotify/gotify.go b/pkg/notificationProvider/gotify/gotify.go index 4966c05..78d60a6 100644 --- a/pkg/notificationProvider/gotify/gotify.go +++ b/pkg/notificationProvider/gotify/gotify.go @@ -64,8 +64,10 @@ func (p *NotificationProviderImplGotify) SendNotification(title string, message } messageUrl := p.Url - messageUrl.JoinPath("message") - messageUrl.Query().Add("token", p.Token) + messageUrl = *messageUrl.JoinPath("message") + queryParams := messageUrl.Query() + queryParams.Add("token", p.Token) + messageUrl.RawQuery = queryParams.Encode() req, err := http.NewRequest("POST", messageUrl.String(), bytes.NewBuffer(messageJson)) if err != nil { diff --git a/pkg/changeDetector/changeDetector.go b/pkg/realDynDns/realDynDns.go similarity index 70% rename from pkg/changeDetector/changeDetector.go rename to pkg/realDynDns/realDynDns.go index 10d0c8e..c08c800 100644 --- a/pkg/changeDetector/changeDetector.go +++ b/pkg/realDynDns/realDynDns.go @@ -1,11 +1,15 @@ -package changeDetector +package realDynDns import ( "fmt" + "time" + "realdnydns/pkg/config" "realdnydns/pkg/dnsProvider" "realdnydns/pkg/externalIpProvider" "realdnydns/pkg/notificationProvider" + + "github.com/go-co-op/gocron" ) type ChangeDetector struct { @@ -29,7 +33,31 @@ func New( } } -func (c *ChangeDetector) DetectAndApplyChanges() (int, error) { +func (c *ChangeDetector) RunWithSchedule(checkInterval string) (*gocron.Scheduler, *gocron.Job, error) { + s := gocron.NewScheduler(time.UTC) + s.SingletonMode() + s.CronWithSeconds(checkInterval) + + job, err := s.DoWithJobDetails(func(job gocron.Job) { + numberChanged, err := c.detectAndApplyChanges() + if err != nil { + panic(err) + } + fmt.Printf("Number of changes: %d\n", numberChanged) + fmt.Println("Next run:", job.NextRun()) + }) + if err != nil { + return nil, nil, err + } + + return s, job, nil +} + +func (c *ChangeDetector) RunOnce() (int, error) { + return c.detectAndApplyChanges() +} + +func (c *ChangeDetector) detectAndApplyChanges() (int, error) { externalIp, err := c.externalIpProvider.GetExternalIp() if err != nil { return 0, err diff --git a/pkg/changeDetector/changeDetector_test.go b/pkg/realDynDns/realDynDns_test.go similarity index 94% rename from pkg/changeDetector/changeDetector_test.go rename to pkg/realDynDns/realDynDns_test.go index 87e6988..22eb603 100644 --- a/pkg/changeDetector/changeDetector_test.go +++ b/pkg/realDynDns/realDynDns_test.go @@ -1,4 +1,4 @@ -package changeDetector +package realDynDns import ( "net" @@ -74,7 +74,7 @@ func testDetectAndApplyChangesWithChanges() func(t *testing.T) { }, }) - numberUpdated, err := changeDetector.DetectAndApplyChanges() + numberUpdated, err := changeDetector.RunOnce() if err != nil { t.Errorf("expected no error, got %v", err) } @@ -103,7 +103,7 @@ func testDetectAndApplyChangesWithoutChanges() func(t *testing.T) { }, }) - numberUpdated, err := changeDetector.DetectAndApplyChanges() + numberUpdated, err := changeDetector.RunOnce() if err != nil { t.Errorf("expected no error, got %v", err) }