5 Commits

Author SHA1 Message Date
mkelcik
c7646cbf63 Refactor 2023-05-04 17:48:03 +02:00
mkelcik
6a028ead30 Merge pull request #9 from mkelcik/notifications
Initial Notifiers implementation
2023-05-04 17:43:19 +02:00
mkelcik
fa2e4426f4 Refactor 2023-05-04 17:39:59 +02:00
mkelcik
f911b9ff16 Refactor 2023-05-04 17:28:25 +02:00
mkelcik
ffd5253f59 Initial Notifiers implementation 2023-05-04 11:44:27 +02:00
7 changed files with 170 additions and 9 deletions

View File

@@ -5,7 +5,7 @@ DNS records are static, and it does not play well with dynamic IP addresses. Now
To set up a Cloudflare dynamic DNS, youll need to run a process on a client inside your network that does two main actions: get your networks current public IP address and automatically update the corresponding DNS record.
This simple updater do the job.
This simple updater do the job, and send notifications, if change happen.
## How to run
### Environment variables
@@ -18,6 +18,9 @@ Before run, you need configure this environment variables.
- `ON_CHANGE_COMMENT` - (optional) in the event that the ip address of the dns record changes, this comment will be added to the record
- `CHECK_INTERVAL_SECONDS` - (optional) how often will the ip address of the records be checked (default: `300`)
- `PUBLIC_IP_RESOLVER` - (optional) public ip address resolver. (default: `ifconfig.me`) Available: `ifconfig.me`, `v4.ident.me`, `1.1.1.1`
- `NOTIFIERS` - (optional) setting the notifier in case of an update of the dns record. Multiple entries are separated by commas. (default none). Example: `webhook@http://localhost/cloudflare-notification`
- Available
- `webhook` - Call defined webhook. Example: `webhook@http://localhost/cloudflare-notification`
### Building from source

View File

@@ -17,6 +17,7 @@ const (
envKeyCloudflareZone = "CLOUDFLARE_ZONE"
envKeyOnChangeComment = "ON_CHANGE_COMMENT"
envKeyCheckIntervalSeconds = "CHECK_INTERVAL_SECONDS"
envKeyNotifiers = "NOTIFIERS"
)
type Config struct {
@@ -25,6 +26,7 @@ type Config struct {
ApiToken string
CloudflareZone string
OnChangeComment string
Notifiers []string
CheckInterval time.Duration
}
@@ -52,11 +54,12 @@ func NewConfig() Config {
}
return Config{
DnsRecordsToCheck: parseDNSToCheck(os.Getenv(envKeyDnsToCheck)),
DnsRecordsToCheck: parseCommaDelimited(os.Getenv(envKeyDnsToCheck)),
PublicIpResolverTag: os.Getenv(envKeyPublicIpResolverTag),
ApiToken: os.Getenv(envKeyCloudflareApiKey),
CloudflareZone: os.Getenv(envKeyCloudflareZone),
OnChangeComment: os.Getenv(envKeyOnChangeComment),
Notifiers: parseCommaDelimited(os.Getenv(envKeyNotifiers)),
CheckInterval: time.Duration(checkInterval) * time.Second,
}
}

View File

@@ -2,10 +2,10 @@ package internal
import "strings"
func parseDNSToCheck(data string) []string {
func parseCommaDelimited(data string) []string {
out := make([]string, 0, strings.Count(data, ",")+1)
for _, dns := range strings.Split(data, ",") {
if w := strings.TrimSpace(dns); w != "" {
for _, item := range strings.Split(data, ",") {
if w := strings.TrimSpace(item); w != "" {
out = append(out, w)
}
}

View File

@@ -38,8 +38,8 @@ func Test_parseDNSToCheck(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseDNSToCheck(tt.args.data); !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseDNSToCheck() = %v, want %v", got, tt.want)
if got := parseCommaDelimited(tt.args.data); !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseCommaDelimited() = %v, want %v", got, tt.want)
}
})
}

17
main.go
View File

@@ -10,6 +10,7 @@ import (
"github.com/cloudflare/cloudflare-go"
"github.com/mkelcik/cloudflare-ddns-update/internal"
"github.com/mkelcik/cloudflare-ddns-update/notifications"
"github.com/mkelcik/cloudflare-ddns-update/public_resolvers"
)
@@ -51,6 +52,8 @@ func main() {
log.Fatal(err)
}
notifiers := notifications.GetNotifiers(config.Notifiers)
// public ip resolver
publicIpResolver, resolverTag := getResolver(config.PublicIpResolverTag)
@@ -85,9 +88,19 @@ func main() {
if _, err := api.UpdateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), update); err != nil {
log.Printf("error updating dns record: %s", err)
} else {
log.Printf("Updated to `%s`", currentPublicIP)
continue
}
if err := notifiers.NotifyWithLog(ctx, notifications.Notification{
OldIp: net.ParseIP(dnsRecord.Content),
NewIp: currentPublicIP,
CheckedAt: time.Now(),
ResolverTag: resolverTag,
Domain: dnsRecord.Name,
}); err != nil {
log.Printf("errors in notifications: %s", err)
}
log.Printf("Updated to `%s`", currentPublicIP)
}
}
}

76
notifications/types.go Normal file
View File

@@ -0,0 +1,76 @@
package notifications
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
)
const (
configDelimiter = "@"
)
type Notifiers []Notifier
func (n Notifiers) NotifyWithLog(ctx context.Context, notification Notification) error {
var outErr error
for _, notifier := range n {
if err := notifier.Notify(ctx, notification); err != nil {
outErr = errors.Join(outErr, err)
continue
}
log.Printf("Notification sent via %s\n", notifier.Tag())
}
return outErr
}
type Notification struct {
OldIp net.IP `json:"old_ip,omitempty"`
NewIp net.IP `json:"new_ip"`
CheckedAt time.Time `json:"checked_at"`
ResolverTag string `json:"resolver_tag"`
Domain string `json:"domain"`
}
func (n Notification) ToSlice() []string {
return []string{n.OldIp.String(), n.NewIp.String(), n.CheckedAt.Format(time.RFC3339), n.ResolverTag, n.Domain}
}
var Available = map[string]func(string) (Notifier, error){
webhookTag: func(config string) (Notifier, error) {
parts := strings.Split(config, configDelimiter)
if len(parts) < 2 {
return nil, fmt.Errorf("wrong webhook config, missing url part")
}
return NewWebhookNotification(WebhookConfig{Url: parts[1]}, &http.Client{
Timeout: 10 * time.Second,
}), nil
},
}
type Notifier interface {
Tag() string
Notify(ctx context.Context, notification Notification) error
}
func GetNotifiers(tags []string) Notifiers {
out := Notifiers{}
for _, t := range tags {
if initFn, ok := Available[strings.Split(t, configDelimiter)[0]]; ok {
notifier, err := initFn(t)
if err != nil {
log.Println(err)
continue
}
out = append(out, notifier)
}
}
return out
}

66
notifications/webhook.go Normal file
View File

@@ -0,0 +1,66 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
webhookTag = "webhook"
)
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
type WebhookConfig struct {
Url string
}
type WebhookNotification struct {
config WebhookConfig
client Doer
}
func (w WebhookNotification) Tag() string {
return webhookTag
}
func NewWebhookNotification(config WebhookConfig, client Doer) *WebhookNotification {
return &WebhookNotification{config: config, client: client}
}
func (w WebhookNotification) getRequestBody(notification Notification) (io.Reader, error) {
out := bytes.NewBuffer(nil)
if err := json.NewEncoder(out).Encode(notification); err != nil {
return nil, fmt.Errorf("error encoding json notification body: %w", err)
}
return out, nil
}
func (w WebhookNotification) Notify(ctx context.Context, notification Notification) error {
body, err := w.getRequestBody(notification)
if err != nil {
return fmt.Errorf("WebhookNotification::NotifyWithLog error: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.config.Url, body)
if err != nil {
return fmt.Errorf("WebhookNotification::NotifyWithLog error creating request: %w", err)
}
resp, err := w.client.Do(req)
if err != nil {
return fmt.Errorf("WebhookNotification::NotifyWithLog error while sending notification: %w", err)
}
_ = resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("WebhookNotification::NotifyWithLog unexpected non 2xx code %d returned", resp.StatusCode)
}
return nil
}