From ffd5253f596e72128528e4dcfbb1d20e06eb482e Mon Sep 17 00:00:00 2001 From: mkelcik Date: Thu, 4 May 2023 11:44:27 +0200 Subject: [PATCH] Initial Notifiers implementation --- internal/config.go | 5 ++- internal/helpers.go | 6 +-- internal/helpers_test.go | 4 +- main.go | 32 +++++++++++++- notifications/types.go | 44 +++++++++++++++++++ notifications/webhook.go | 82 +++++++++++++++++++++++++++++++++++ notifications/webhook_test.go | 46 ++++++++++++++++++++ 7 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 notifications/types.go create mode 100644 notifications/webhook.go create mode 100644 notifications/webhook_test.go 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..e7061e4 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" ) @@ -17,6 +18,21 @@ type PublicIpResolver interface { ResolvePublicIp(ctx context.Context) (net.IP, error) } +func getNotifiers(tags []string) notifications.Notifiers { + out := notifications.Notifiers{} + for _, t := range tags { + if initFn, ok := notifications.Available[t]; ok { + notifier, err := initFn() + if err != nil { + log.Println(err) + continue + } + out = append(out, notifier) + } + } + return out +} + func getResolver(resolverName string) (PublicIpResolver, string) { switch resolverName { // HERE add another resolver if needed @@ -51,6 +67,8 @@ func main() { log.Fatal(err) } + notifiers := getNotifiers(config.Notifiers) + // public ip resolver publicIpResolver, resolverTag := getResolver(config.PublicIpResolverTag) @@ -85,9 +103,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..26357a6 --- /dev/null +++ b/notifications/types.go @@ -0,0 +1,44 @@ +package notifications + +import ( + "context" + "errors" + "log" + "net" + "net/http" + "time" +) + +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) + } + 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"` +} + +var Available = map[string]func() (Notifier, error){ + webhookTag: func() (Notifier, error) { + return NewWebhookNotification(NewWebhookConfigFromEnv(), &http.Client{ + Timeout: 10 * time.Second, + }), nil + }, +} + +type Notifier interface { + Tag() string + Notify(ctx context.Context, notification Notification) error +} diff --git a/notifications/webhook.go b/notifications/webhook.go new file mode 100644 index 0000000..1f0b34b --- /dev/null +++ b/notifications/webhook.go @@ -0,0 +1,82 @@ +package notifications + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +const ( + webhookTag = "webhook" + webhookRequestTypeJson = "JSON" + envWebhookUrl = "WEBHOOK_RL" + envWebhookRequestType = "WEBHOOK_REQ_TYPE" +) + +type Doer interface { + Do(*http.Request) (*http.Response, error) +} + +type WebhookConfig struct { + Url string + Json bool +} + +func NewWebhookConfigFromEnv() WebhookConfig { + return WebhookConfig{ + Url: os.Getenv(envWebhookUrl), + Json: strings.ToUpper(os.Getenv(envWebhookRequestType)) == webhookRequestTypeJson, + } +} + +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(notification.NewIp) + if w.config.Json { + if err := json.NewEncoder(out).Encode(notification); err != nil { + return nil, fmt.Errorf("error encoding notification body: %w", err) + } + return out, nil + } + 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 +} diff --git a/notifications/webhook_test.go b/notifications/webhook_test.go new file mode 100644 index 0000000..648e60e --- /dev/null +++ b/notifications/webhook_test.go @@ -0,0 +1,46 @@ +package notifications + +import ( + "io" + "reflect" + "testing" +) + +func TestWebhookNotification_getRequestBody(t *testing.T) { + type fields struct { + config WebhookConfig + } + type args struct { + notification Notification + } + tests := []struct { + name string + fields fields + args args + want io.Reader + wantErr bool + }{ + { + name: "text", + fields: fields{}, + args: args{}, + want: nil, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := WebhookNotification{ + config: tt.fields.config, + } + got, err := w.getRequestBody(tt.args.notification) + if (err != nil) != tt.wantErr { + t.Errorf("getRequestBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getRequestBody() got = %v, want %v", got, tt.want) + } + }) + } +}