diff --git a/README.md b/README.md index 82faad0..9745d2e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/config.go b/internal/config.go index 2cc4063..3ca1cb8 100644 --- a/internal/config.go +++ b/internal/config.go @@ -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, } } diff --git a/internal/helpers.go b/internal/helpers.go index e287078..770fefe 100644 --- a/internal/helpers.go +++ b/internal/helpers.go @@ -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) } } diff --git a/internal/helpers_test.go b/internal/helpers_test.go index d13168d..cfee562 100644 --- a/internal/helpers_test.go +++ b/internal/helpers_test.go @@ -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) } }) } diff --git a/main.go b/main.go index 5a31e61..027c408 100644 --- a/main.go +++ b/main.go @@ -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) } } } diff --git a/notifications/types.go b/notifications/types.go new file mode 100644 index 0000000..b41a73e --- /dev/null +++ b/notifications/types.go @@ -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 +} diff --git a/notifications/webhook.go b/notifications/webhook.go new file mode 100644 index 0000000..6a5e192 --- /dev/null +++ b/notifications/webhook.go @@ -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 +}