diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 0b9311c..b4669bb 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1,76 +1,42 @@ name: CD on: - push: - branches: - - main + workflow_run: + workflows: ["CI"] + branches: [main] + types: + - completed env: DOCKER_REGISTRY: gitea.t000-n.de jobs: - test: - name: test - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - check-latest: true - - name: Create cache key - uses: https://gitea.com/actions/go-hashfiles@v0.0.1 - id: hash-go - with: - patterns: | - go.mod - go.sum - - name: cache go - id: cache-go - uses: actions/cache@v4 - with: - path: | - /go_path - /go_cache - key: go_path-${{ steps.hash-go.outputs.hash }} - restore-keys: |- - go_cache-${{ steps.hash-go.outputs.hash }} - - name: build - run: make build - - name: test - run: make test - - build_and_push: name: Build and push - requires: - - test runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - + - name: Set up QEMU uses: docker/setup-qemu-action@v2 - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - + - name: Login to Registry uses: docker/login-action@v2 with: registry: ${{ env.DOCKER_REGISTRY }} username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - + - name: Get Metadata id: meta run: | echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT - + - name: Build and push uses: docker/build-push-action@v4 with: diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index dac1a20..ac848d3 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -1,6 +1,9 @@ name: CI on: pull_request: + push: + branches: + - main env: GOPATH: /go_path @@ -35,7 +38,7 @@ jobs: /go_cache key: go_path-${{ steps.hash-go.outputs.hash }} restore-keys: |- - go_cache-${{ steps.hash-go.outputs.hash }} + go_cache-${{ steps.hash-go.outputs.hash }} - name: build run: make build - name: test diff --git a/README.md b/README.md index b63389f..2a0794c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,12 @@ dns_provider: config: api_key: base_url: https://api.hosting.ionos.com/dns +notification_provider: + type: gotify + config: + url: + token: + priority: 0 domains: - tld: example.com subdomains: @@ -54,4 +60,21 @@ url: Examples for providers are: - https://ifconfig.me -- https://api.ipify.org \ No newline at end of file +- https://api.ipify.org + +## Notification Providers +The notification provider is used to send notifications whena IP address changes and a DNS record is updated. + +### Console +The console notification provider is used to print the notification to the console. This is the default notification provider. + +### Gotify +The Gotify notification provider is used to send notifications to a Gotify server. + +```yaml +url: +token: +priority: 0 +``` + +The priority must be between 0 and 4. \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml index 9fe438e..eb37f59 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,12 +2,18 @@ ip_provider: type: plain config: - url: https://ifconfig.me + url: https://ifconfig.me dns_provider: type: ionos config: api_key: base_url: https://api.hosting.ionos.com/dns +notification_provider: + type: gotify + config: + url: + token: + priority: 0 domains: - tld: example.com subdomains: diff --git a/main.go b/main.go index 1df93ba..9202ca6 100644 --- a/main.go +++ b/main.go @@ -2,13 +2,17 @@ package main import ( "fmt" + "time" + "realdnydns/pkg/changeDetector" "realdnydns/pkg/config" "realdnydns/pkg/dnsProvider" ionos "realdnydns/pkg/dnsProvider/ionos" "realdnydns/pkg/externalIpProvider" plainExternalIpProvider "realdnydns/pkg/externalIpProvider/plain" - "time" + "realdnydns/pkg/notificationProvider" + notificationProviderConsole "realdnydns/pkg/notificationProvider/console" + gotify "realdnydns/pkg/notificationProvider/gotify" "github.com/go-co-op/gocron" ) @@ -50,7 +54,25 @@ func main() { panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type)) } - changeDetector := changeDetector.New(externalIpProvider, dnsProvider, configClient.Domains) + var notificationProvider notificationProvider.NotificationProvider + switch configClient.NotificationProvider.Type { + case "gotify": + var gotifyConfig gotify.NotificationProviderImplGotifyConfig + err := configClient.NotificationProvider.ProviderConfig.Decode(&gotifyConfig) + if err != nil { + panic(err) + } + + notificationProvider, err = gotify.New(gotifyConfig) + if err != nil { + panic(err) + } + default: + // Use default console notification provider + notificationProvider = notificationProviderConsole.New() + } + + changeDetector := changeDetector.New(externalIpProvider, dnsProvider, notificationProvider, configClient.Domains) s := gocron.NewScheduler(time.UTC) s.SingletonMode() diff --git a/pkg/changeDetector/changeDetector.go b/pkg/changeDetector/changeDetector.go index 3cc9fab..10d0c8e 100644 --- a/pkg/changeDetector/changeDetector.go +++ b/pkg/changeDetector/changeDetector.go @@ -1,26 +1,31 @@ package changeDetector import ( + "fmt" "realdnydns/pkg/config" "realdnydns/pkg/dnsProvider" "realdnydns/pkg/externalIpProvider" + "realdnydns/pkg/notificationProvider" ) type ChangeDetector struct { - externalIpProvider externalIpProvider.ExternalIpProvider - dnsProvider dnsProvider.DNSProvider - domains []config.DomainConfig + externalIpProvider externalIpProvider.ExternalIpProvider + dnsProvider dnsProvider.DNSProvider + notificationProvider notificationProvider.NotificationProvider + domains []config.DomainConfig } func New( externalIpProvider externalIpProvider.ExternalIpProvider, dnsProvider dnsProvider.DNSProvider, + notificationProvider notificationProvider.NotificationProvider, domains []config.DomainConfig, ) ChangeDetector { return ChangeDetector{ - externalIpProvider: externalIpProvider, - dnsProvider: dnsProvider, - domains: domains, + externalIpProvider: externalIpProvider, + dnsProvider: dnsProvider, + notificationProvider: notificationProvider, + domains: domains, } } @@ -40,6 +45,14 @@ func (c *ChangeDetector) DetectAndApplyChanges() (int, error) { } if currentRecord.IP != externalIp.String() { + err = c.notificationProvider.SendNotification( + fmt.Sprintf("Update %s.%s", subdomain, domain.TLD), + fmt.Sprintf("The IP of %s has changed from %s to %s", domain.TLD, currentRecord.IP, externalIp.String()), + ) + if err != nil { + return numberUpdated, err + } + _, err = c.dnsProvider.UpdateRecord(domain.TLD, subdomain, externalIp, currentRecord.TTL, currentRecord.Prio, currentRecord.Disabled) numberUpdated++ if err != nil { diff --git a/pkg/changeDetector/changeDetector_test.go b/pkg/changeDetector/changeDetector_test.go index ffe0123..87e6988 100644 --- a/pkg/changeDetector/changeDetector_test.go +++ b/pkg/changeDetector/changeDetector_test.go @@ -43,6 +43,14 @@ func (m *MockDNSProviderImpl) UpdateRecord(tld string, subdomain string, ip net. }, nil } +type MockedNotificationProvider struct{} + +type MockedNotificationProviderImpl struct{} + +func (m *MockedNotificationProviderImpl) SendNotification(title string, message string) error { + return nil +} + func TestDetectAndApplyChanges(t *testing.T) { t.Run("with changes", testDetectAndApplyChangesWithChanges()) t.Run("without changes", testDetectAndApplyChangesWithoutChanges()) @@ -55,15 +63,16 @@ func testDetectAndApplyChangesWithChanges() func(t *testing.T) { }, &MockDNSProviderImpl{ GetRecordIpResponse: "127.0.0.2", UpdateRecordIpResponse: "127.0.0.1", - }, []config.DomainConfig{ - { - TLD: "example.com", - Subdomains: []string{ - "test", - "@", + }, &MockedNotificationProviderImpl{}, + []config.DomainConfig{ + { + TLD: "example.com", + Subdomains: []string{ + "test", + "@", + }, }, - }, - }) + }) numberUpdated, err := changeDetector.DetectAndApplyChanges() if err != nil { @@ -83,15 +92,16 @@ func testDetectAndApplyChangesWithoutChanges() func(t *testing.T) { }, &MockDNSProviderImpl{ GetRecordIpResponse: "127.0.0.1", UpdateRecordIpResponse: "127.0.0.1", - }, []config.DomainConfig{ - { - TLD: "example.com", - Subdomains: []string{ - "test", - "@", + }, &MockedNotificationProviderImpl{}, + []config.DomainConfig{ + { + TLD: "example.com", + Subdomains: []string{ + "test", + "@", + }, }, - }, - }) + }) numberUpdated, err := changeDetector.DetectAndApplyChanges() if err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index c0f30a5..28b9488 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -12,10 +12,11 @@ type DomainConfig struct { } type Config struct { - ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"` - DNSProvider DNSProviderConfig `yaml:"dns_provider"` - Domains []DomainConfig `yaml:"domains"` - CheckInterval string `yaml:"check_interval"` + ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"` + DNSProvider DNSProviderConfig `yaml:"dns_provider"` + NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"` + Domains []DomainConfig `yaml:"domains"` + CheckInterval string `yaml:"check_interval"` } type ExternalIpProviderConfig struct { @@ -28,6 +29,11 @@ type DNSProviderConfig struct { ProviderConfig yaml.Node `yaml:"config"` } +type NotificationProviderConfig 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 { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index dfd3e71..4e60fbd 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -49,6 +49,10 @@ dns_provider: config: api_key: exampleAPIKey base_url: https://example.com +notification_provider: + type: gotify + config: + url: https://example.com domains: - tld: example.com subdomains: @@ -56,7 +60,7 @@ domains: - www check_interval: 0 0 0/6 * * * *`) - want := c.DNSProvider.Type == "ionos" && c.ExternalIPProvider.Type == "plain" + 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) diff --git a/pkg/notificationProvider/console/console.go b/pkg/notificationProvider/console/console.go new file mode 100644 index 0000000..bfac9c3 --- /dev/null +++ b/pkg/notificationProvider/console/console.go @@ -0,0 +1,16 @@ +package notificationProviderConsole + +import ( + "fmt" +) + +type NotificationProviderImplConsole struct{} + +func New() *NotificationProviderImplConsole { + return &NotificationProviderImplConsole{} +} + +func (p *NotificationProviderImplConsole) SendNotification(title string, message string) error { + fmt.Printf("%s: %s\n", title, message) + return nil +} diff --git a/pkg/notificationProvider/gotify/gotify.go b/pkg/notificationProvider/gotify/gotify.go new file mode 100644 index 0000000..4966c05 --- /dev/null +++ b/pkg/notificationProvider/gotify/gotify.go @@ -0,0 +1,87 @@ +package notificationProviderGotify + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type NotificationProviderImplGotifyConfig struct { + Url string `yaml:"url"` + Token string `yaml:"token"` + Priority int `yaml:"priority"` +} + +type NotificationProviderImplGotify struct { + Url url.URL + Token string + Priority int + HTTPClient *http.Client +} + +func New(config NotificationProviderImplGotifyConfig) (*NotificationProviderImplGotify, error) { + if config.Url == "" { + return nil, fmt.Errorf("url is required") + } + + url, err := url.Parse(config.Url) + if err != nil { + return nil, err + } + + if config.Token == "" { + return nil, fmt.Errorf("token is required") + } + + if config.Priority < 0 || config.Priority > 4 { + return nil, fmt.Errorf("priority must be between 0 and 4") + } + + return &NotificationProviderImplGotify{ + *url, + config.Token, + config.Priority, + &http.Client{}, + }, nil +} + +func (p *NotificationProviderImplGotify) SendNotification(title string, message string) error { + type GotifyMessage struct { + Message string `json:"message"` + Title string `json:"title"` + Priority int `json:"priority"` + } + + messageJson, err := json.Marshal(GotifyMessage{ + message, + title, + p.Priority, + }) + if err != nil { + return err + } + + messageUrl := p.Url + messageUrl.JoinPath("message") + messageUrl.Query().Add("token", p.Token) + + req, err := http.NewRequest("POST", messageUrl.String(), bytes.NewBuffer(messageJson)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + + res, err := p.HTTPClient.Do(req) + if err != nil { + return err + } + + if res.StatusCode != 200 { + return fmt.Errorf("unexpected status code: %d", res.StatusCode) + } + + return nil +} diff --git a/pkg/notificationProvider/gotify/gotify_test.go b/pkg/notificationProvider/gotify/gotify_test.go new file mode 100644 index 0000000..70b3661 --- /dev/null +++ b/pkg/notificationProvider/gotify/gotify_test.go @@ -0,0 +1,81 @@ +package notificationProviderGotify_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + gotify "realdnydns/pkg/notificationProvider/gotify" +) + +func TestNew(t *testing.T) { + t.Run("URL is required", testNewEmptyUrl()) + t.Run("Token is required", testNewEmptyToken()) + t.Run("Priority must be between 0 and 4", testNewInvalidPriority()) + t.Run("Sends POST request to url", testNewSendsPostRequest()) +} + +func testNewEmptyUrl() func(t *testing.T) { + return func(t *testing.T) { + _, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{ + Token: "1234", + Priority: 1, + }) + + if err.Error() != "url is required" { + t.Errorf("Expected error 'url is required', got %v", err) + } + } +} + +func testNewEmptyToken() func(t *testing.T) { + return func(t *testing.T) { + _, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{ + Url: "http://localhost:1234", + Priority: 0, + }) + + if err.Error() != "token is required" { + t.Errorf("Expected error 'token is required', got %v", err) + } + } +} + +func testNewInvalidPriority() func(t *testing.T) { + return func(t *testing.T) { + _, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{ + Url: "http://localhost:1234", + Token: "token", + Priority: 5, + }) + + if err.Error() != "priority must be between 0 and 4" { + t.Errorf("Expected error 'priority must be between 0 and 4', got %v", err) + } + } +} + +func testNewSendsPostRequest() func(t *testing.T) { + return func(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + }, + )) + defer mockServer.Close() + + provider, err := gotify.New(gotify.NotificationProviderImplGotifyConfig{ + Url: mockServer.URL, + Token: "1234", + Priority: 0, + }) + if err != nil { + t.Fatalf("New() returned unexpected error: %v", err) + } + + err = provider.SendNotification("title", "message") + if err != nil { + t.Fatalf("SendNotification() returned unexpected error: %v", err) + } + } +} diff --git a/pkg/notificationProvider/notificationProvider.go b/pkg/notificationProvider/notificationProvider.go new file mode 100644 index 0000000..3956566 --- /dev/null +++ b/pkg/notificationProvider/notificationProvider.go @@ -0,0 +1,5 @@ +package notificationProvider + +type NotificationProvider interface { + SendNotification(title string, message string) error +}