4 Commits

Author SHA1 Message Date
4a97f6f710 Merge branch 'main' into docs-document-config-placement
All checks were successful
CI / test (pull_request) Successful in 1m38s
2024-08-11 12:14:46 +02:00
1a25992f03 fix: cicd dependency between workflow (#12)
All checks were successful
CI / test (push) Successful in 38s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 12:14:38 +02:00
62a05d5e1e refactor: cicd cd workflow depends on ci workflow success (#11) 2024-08-11 12:03:10 +02:00
7bb1e9ca08 feat: notification provider (#8)
All checks were successful
CD / test (push) Successful in 48s
CD / Build and push (push) Successful in 3m48s
Co-authored-by: Timo Behrendt <t.behrendt@t00n.de>
Co-committed-by: Timo Behrendt <t.behrendt@t00n.de>
2024-08-11 11:52:51 +02:00
13 changed files with 318 additions and 76 deletions

View File

@@ -1,76 +1,42 @@
name: CD name: CD
on: on:
push: workflow_run:
branches: workflows: ["CI"]
- main branches: [main]
types:
- completed
env: env:
DOCKER_REGISTRY: gitea.t000-n.de DOCKER_REGISTRY: gitea.t000-n.de
jobs: 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: build_and_push:
name: Build and push name: Build and push
requires:
- test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
- name: Login to Registry - name: Login to Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
registry: ${{ env.DOCKER_REGISTRY }} registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.REGISTRY_USER }} username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }} password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Get Metadata - name: Get Metadata
id: meta id: meta
run: | run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}' | tr '[:upper:]' '[:lower:]') >> $GITHUB_OUTPUT 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 echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:

View File

@@ -1,6 +1,9 @@
name: CI name: CI
on: on:
pull_request: pull_request:
push:
branches:
- main
env: env:
GOPATH: /go_path GOPATH: /go_path
@@ -35,7 +38,7 @@ jobs:
/go_cache /go_cache
key: go_path-${{ steps.hash-go.outputs.hash }} key: go_path-${{ steps.hash-go.outputs.hash }}
restore-keys: |- restore-keys: |-
go_cache-${{ steps.hash-go.outputs.hash }} go_cache-${{ steps.hash-go.outputs.hash }}
- name: build - name: build
run: make build run: make build
- name: test - name: test

View File

@@ -23,6 +23,12 @@ dns_provider:
config: config:
api_key: <your-api-key> api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns base_url: https://api.hosting.ionos.com/dns
notification_provider:
type: gotify
config:
url: <your-gotify-host>
token: <your-token>
priority: 0
domains: domains:
- tld: example.com - tld: example.com
subdomains: subdomains:
@@ -54,4 +60,21 @@ url: <your-providers-URL>
Examples for providers are: Examples for providers are:
- https://ifconfig.me - https://ifconfig.me
- https://api.ipify.org - 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: <your-gotify-host>
token: <your-token>
priority: 0
```
The priority must be between 0 and 4.

View File

@@ -2,12 +2,18 @@
ip_provider: ip_provider:
type: plain type: plain
config: config:
url: https://ifconfig.me url: https://ifconfig.me
dns_provider: dns_provider:
type: ionos type: ionos
config: config:
api_key: <your-api-key> api_key: <your-api-key>
base_url: https://api.hosting.ionos.com/dns base_url: https://api.hosting.ionos.com/dns
notification_provider:
type: gotify
config:
url: <your-gotify-host>
token: <your-token>
priority: 0
domains: domains:
- tld: example.com - tld: example.com
subdomains: subdomains:

26
main.go
View File

@@ -2,13 +2,17 @@ package main
import ( import (
"fmt" "fmt"
"time"
"realdnydns/pkg/changeDetector" "realdnydns/pkg/changeDetector"
"realdnydns/pkg/config" "realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider" "realdnydns/pkg/dnsProvider"
ionos "realdnydns/pkg/dnsProvider/ionos" ionos "realdnydns/pkg/dnsProvider/ionos"
"realdnydns/pkg/externalIpProvider" "realdnydns/pkg/externalIpProvider"
plainExternalIpProvider "realdnydns/pkg/externalIpProvider/plain" 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" "github.com/go-co-op/gocron"
) )
@@ -50,7 +54,25 @@ func main() {
panic(fmt.Errorf("unknown DNS provider: %s", configClient.DNSProvider.Type)) 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 := gocron.NewScheduler(time.UTC)
s.SingletonMode() s.SingletonMode()

View File

@@ -1,26 +1,31 @@
package changeDetector package changeDetector
import ( import (
"fmt"
"realdnydns/pkg/config" "realdnydns/pkg/config"
"realdnydns/pkg/dnsProvider" "realdnydns/pkg/dnsProvider"
"realdnydns/pkg/externalIpProvider" "realdnydns/pkg/externalIpProvider"
"realdnydns/pkg/notificationProvider"
) )
type ChangeDetector struct { type ChangeDetector struct {
externalIpProvider externalIpProvider.ExternalIpProvider externalIpProvider externalIpProvider.ExternalIpProvider
dnsProvider dnsProvider.DNSProvider dnsProvider dnsProvider.DNSProvider
domains []config.DomainConfig notificationProvider notificationProvider.NotificationProvider
domains []config.DomainConfig
} }
func New( func New(
externalIpProvider externalIpProvider.ExternalIpProvider, externalIpProvider externalIpProvider.ExternalIpProvider,
dnsProvider dnsProvider.DNSProvider, dnsProvider dnsProvider.DNSProvider,
notificationProvider notificationProvider.NotificationProvider,
domains []config.DomainConfig, domains []config.DomainConfig,
) ChangeDetector { ) ChangeDetector {
return ChangeDetector{ return ChangeDetector{
externalIpProvider: externalIpProvider, externalIpProvider: externalIpProvider,
dnsProvider: dnsProvider, dnsProvider: dnsProvider,
domains: domains, notificationProvider: notificationProvider,
domains: domains,
} }
} }
@@ -40,6 +45,14 @@ func (c *ChangeDetector) DetectAndApplyChanges() (int, error) {
} }
if currentRecord.IP != externalIp.String() { 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) _, err = c.dnsProvider.UpdateRecord(domain.TLD, subdomain, externalIp, currentRecord.TTL, currentRecord.Prio, currentRecord.Disabled)
numberUpdated++ numberUpdated++
if err != nil { if err != nil {

View File

@@ -43,6 +43,14 @@ func (m *MockDNSProviderImpl) UpdateRecord(tld string, subdomain string, ip net.
}, nil }, nil
} }
type MockedNotificationProvider struct{}
type MockedNotificationProviderImpl struct{}
func (m *MockedNotificationProviderImpl) SendNotification(title string, message string) error {
return nil
}
func TestDetectAndApplyChanges(t *testing.T) { func TestDetectAndApplyChanges(t *testing.T) {
t.Run("with changes", testDetectAndApplyChangesWithChanges()) t.Run("with changes", testDetectAndApplyChangesWithChanges())
t.Run("without changes", testDetectAndApplyChangesWithoutChanges()) t.Run("without changes", testDetectAndApplyChangesWithoutChanges())
@@ -55,15 +63,16 @@ func testDetectAndApplyChangesWithChanges() func(t *testing.T) {
}, &MockDNSProviderImpl{ }, &MockDNSProviderImpl{
GetRecordIpResponse: "127.0.0.2", GetRecordIpResponse: "127.0.0.2",
UpdateRecordIpResponse: "127.0.0.1", UpdateRecordIpResponse: "127.0.0.1",
}, []config.DomainConfig{ }, &MockedNotificationProviderImpl{},
{ []config.DomainConfig{
TLD: "example.com", {
Subdomains: []string{ TLD: "example.com",
"test", Subdomains: []string{
"@", "test",
"@",
},
}, },
}, })
})
numberUpdated, err := changeDetector.DetectAndApplyChanges() numberUpdated, err := changeDetector.DetectAndApplyChanges()
if err != nil { if err != nil {
@@ -83,15 +92,16 @@ func testDetectAndApplyChangesWithoutChanges() func(t *testing.T) {
}, &MockDNSProviderImpl{ }, &MockDNSProviderImpl{
GetRecordIpResponse: "127.0.0.1", GetRecordIpResponse: "127.0.0.1",
UpdateRecordIpResponse: "127.0.0.1", UpdateRecordIpResponse: "127.0.0.1",
}, []config.DomainConfig{ }, &MockedNotificationProviderImpl{},
{ []config.DomainConfig{
TLD: "example.com", {
Subdomains: []string{ TLD: "example.com",
"test", Subdomains: []string{
"@", "test",
"@",
},
}, },
}, })
})
numberUpdated, err := changeDetector.DetectAndApplyChanges() numberUpdated, err := changeDetector.DetectAndApplyChanges()
if err != nil { if err != nil {

View File

@@ -12,10 +12,11 @@ type DomainConfig struct {
} }
type Config struct { type Config struct {
ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"` ExternalIPProvider ExternalIpProviderConfig `yaml:"ip_provider"`
DNSProvider DNSProviderConfig `yaml:"dns_provider"` DNSProvider DNSProviderConfig `yaml:"dns_provider"`
Domains []DomainConfig `yaml:"domains"` NotificationProvider NotificationProviderConfig `yaml:"notification_provider,omitempty"`
CheckInterval string `yaml:"check_interval"` Domains []DomainConfig `yaml:"domains"`
CheckInterval string `yaml:"check_interval"`
} }
type ExternalIpProviderConfig struct { type ExternalIpProviderConfig struct {
@@ -28,6 +29,11 @@ type DNSProviderConfig struct {
ProviderConfig yaml.Node `yaml:"config"` ProviderConfig yaml.Node `yaml:"config"`
} }
type NotificationProviderConfig struct {
Type string `yaml:"type"`
ProviderConfig yaml.Node `yaml:"config"`
}
func (c *Config) Load(filePath string) error { func (c *Config) Load(filePath string) error {
err := yaml.Unmarshal([]byte(filePath), c) err := yaml.Unmarshal([]byte(filePath), c)
if err != nil { if err != nil {

View File

@@ -49,6 +49,10 @@ dns_provider:
config: config:
api_key: exampleAPIKey api_key: exampleAPIKey
base_url: https://example.com base_url: https://example.com
notification_provider:
type: gotify
config:
url: https://example.com
domains: domains:
- tld: example.com - tld: example.com
subdomains: subdomains:
@@ -56,7 +60,7 @@ domains:
- www - www
check_interval: 0 0 0/6 * * * *`) 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 { if !want || err != nil {
t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want) t.Fatalf("DnsProviderName couldn't be properly loaded or unmarshaled, Load() = %v, want %v", err, want)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package notificationProvider
type NotificationProvider interface {
SendNotification(title string, message string) error
}