diff --git a/README.md b/README.md index 9c55a32..c8fa297 100644 --- a/README.md +++ b/README.md @@ -380,8 +380,13 @@ fails. - `token`: Telegram bot token. - `chat_id`: Telegram chat ID. - `proxy`: Optional HTTP or HTTPS proxy. +- `discord`: + - `url`: Discord webhook URL. + - `id`: Discord webhook ID. (Alternatively to `url`) + - `token`: Discord webhook token. (Alternatively to `url`) + - `proxy`: Optional HTTP or HTTPS proxy. -Cloudflare and Telegram proxy values must be absolute URLs with a host. URL +Cloudflare, Discord and Telegram proxy values must be absolute URLs with a host. URL userinfo credentials are supported; paths other than `/`, queries, and fragments are rejected. diff --git a/app/app.go b/app/app.go index 7951e7e..3a48172 100644 --- a/app/app.go +++ b/app/app.go @@ -296,13 +296,13 @@ func (a *App) runJob(ctx context.Context, job *Job) { ) if ipv4NotificationNeeded { - if a.notify(ctx, "ip_change", job, notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("IPv4 address changed to %s", ipResult.IPv4))}) { + if a.notify(ctx, job, notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("IPv4 address changed to %s", ipResult.IPv4)), Reason: notifier.ReasonIPChange}) { job.lastNotifiedIPv4 = ipResult.IPv4 } } if ipv6NotificationNeeded { - if a.notify(ctx, "ip_change", job, notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("IPv6 address changed to %s", ipResult.IPv6))}) { + if a.notify(ctx, job, notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("IPv6 address changed to %s", ipResult.IPv6)), Reason: notifier.ReasonIPChange}) { job.lastNotifiedIPv6 = ipResult.IPv6 } } @@ -330,8 +330,8 @@ func (a *App) runJob(ctx context.Context, job *Job) { "error", err, )..., ) - failureNotification := notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("DNS update failed for %s: %s", notificationIPSummary(ipResult), err))} - if failureNotification.Message != job.lastNotifiedUpdateFailure && a.notify(ctx, "update_failure", job, failureNotification) { + failureNotification := notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("DNS update failed for %s: %s", notificationIPSummary(ipResult), err)), Reason: notifier.ReasonUpdateFailure} + if failureNotification.Message != job.lastNotifiedUpdateFailure && a.notify(ctx, job, failureNotification) { job.lastNotifiedUpdateFailure = failureNotification.Message } return @@ -354,7 +354,7 @@ func (a *App) runJob(ctx context.Context, job *Job) { "ipv6", ipResult.IPv6, )..., ) - a.notify(ctx, "update_success", job, notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("DNS records updated for %s", notificationIPSummary(ipResult)))}) + a.notify(ctx, job, notifier.Notification{Message: jobNotificationMessage(job, fmt.Sprintf("DNS records updated for %s", notificationIPSummary(ipResult))), Reason: notifier.ReasonUpdateSuccess}) } func isBackoffFailure(status string) bool { @@ -548,13 +548,13 @@ func (job *Job) logAttrs(args ...any) []any { return append(attrs, args...) } -func (a *App) notify(ctx context.Context, reason string, job *Job, notification notifier.Notification) bool { +func (a *App) notify(ctx context.Context, job *Job, notification notifier.Notification) bool { if err := a.notifier.Notify(ctx, notification); err != nil { slog.Error( "failed to send notification", job.logAttrs( "notifier", a.notifierName, - "reason", reason, + "reason", notification.Reason, "error", err, )..., ) diff --git a/internal/testutil/helper.go b/internal/testutil/helper.go new file mode 100644 index 0000000..8b6b412 --- /dev/null +++ b/internal/testutil/helper.go @@ -0,0 +1,16 @@ +package testutil + +import ( + "net/url" + "strings" + "testing" +) + +func AssertTokenRedacted(t *testing.T, value, token string) { + t.Helper() + for _, sensitive := range []string{token, url.QueryEscape(token), url.PathEscape(token)} { + if strings.Contains(value, sensitive) { + t.Fatalf("error still contains token %q: %q", sensitive, value) + } + } +} diff --git a/main.go b/main.go index 8ff8fc9..4a33f27 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "github.com/we11adam/uddns/provider" "github.com/we11adam/uddns/updater" + _ "github.com/we11adam/uddns/notifier/discord" _ "github.com/we11adam/uddns/notifier/telegram" _ "github.com/we11adam/uddns/provider/ip_service" _ "github.com/we11adam/uddns/provider/netif" diff --git a/notifier/discord/discord.go b/notifier/discord/discord.go new file mode 100644 index 0000000..a9ada45 --- /dev/null +++ b/notifier/discord/discord.go @@ -0,0 +1,155 @@ +package discord + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/go-resty/resty/v2" + + "github.com/we11adam/uddns/internal/proxyurl" + "github.com/we11adam/uddns/internal/redact" + "github.com/we11adam/uddns/notifier" +) + +const ( + requestTimeout = 10 * time.Second + responseBodyLimit = 256 << 10 +) + +type Discord struct { + URL string `mapstructure:"url"` + // Optionally provide ID and Token instead of URL. If all are provided, an error will be returned. + ID string `mapstructure:"id"` + // Optionally provide ID and Token instead of URL. If all are provided, an error will be returned. + Token string `mapstructure:"token"` + Proxy string `mapstructure:"proxy"` + hc *resty.Client +} + +type discordEmbed struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` +} + +type webhookMessage struct { + Embeds []discordEmbed `json:"embeds"` +} + +type apiResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func init() { + notifier.Register("Discord", "notifiers.discord", func(v notifier.ConfigReader) (notifier.Notifier, error) { + if !v.IsSet("notifiers.discord") { + return nil, notifier.ErrNotConfigured + } + + discord := Discord{} + err := v.UnmarshalKey("notifiers.discord", &discord) + if err != nil { + return nil, err + } + + return New(&discord) + }) +} + +func New(config *Discord) (discord *Discord, err error) { + if config == nil { + return nil, fmt.Errorf("Discord config is nil") + } + if config.URL != "" && (config.ID != "" || config.Token != "") { + return nil, fmt.Errorf("Discord config must either provide url or id and token, not a combination") + } + if config.URL == "" { + if config.ID == "" || config.Token == "" { + return nil, fmt.Errorf("Discord config must provide url or id and token") + } + config.URL = fmt.Sprintf("https://discord.com/api/webhooks/%s/%s", config.ID, config.Token) + } + + discord = new(Discord) + *discord = *config + discord.hc, err = discord.newHTTPClient() + if err != nil { + return nil, err + } + return discord, nil +} + +func (d *Discord) newHTTPClient() (*resty.Client, error) { + webhookURL := d.URL + if webhookURL == "" { + webhookURL = fmt.Sprintf("https://discord.com/api/webhooks/%s/%s", url.PathEscape(d.ID), url.PathEscape(d.Token)) + } + client := resty.New(). + SetTimeout(requestTimeout). + SetResponseBodyLimit(responseBodyLimit). + SetHeader("Content-Type", "application/json"). + SetBaseURL(webhookURL) + + if d.Proxy != "" { + _, err := proxyurl.Parse(d.Proxy) + if err != nil { + return nil, fmt.Errorf("invalid Discord proxy configuration: %w", err) + } + client.SetProxy(d.Proxy) + } + + return client, nil +} + +func (d *Discord) Notify(ctx context.Context, notification notifier.Notification) error { + color := 0x000000 + switch notification.Reason { + case notifier.ReasonIPChange: + color = 0x3498DB + case notifier.ReasonUpdateFailure: + color = 0xFF0000 + case notifier.ReasonUpdateSuccess: + color = 0x00FF00 + } + + resp, err := d.hc.R().SetContext(ctx).SetBody(&webhookMessage{ + Embeds: []discordEmbed{ + { + Title: notification.Title, + Description: notification.Message, + Color: color, + }, + }, + }).Post("") + if err != nil { + return redact.Error(err, d.Token) + } + + switch resp.StatusCode() { + case http.StatusOK, http.StatusNoContent: + return nil + case http.StatusTooManyRequests: + return fmt.Errorf("Discord API request failed, rate limited, retry after %s", resp.Header().Get("Retry-After")) + } + apiResp := apiResponse{} + decodeErr := json.Unmarshal(resp.Body(), &apiResp) + if !resp.IsSuccess() { + return d.apiError(resp.StatusCode(), apiResp) + } + if decodeErr != nil { + return redact.Error(fmt.Errorf("failed to decode Discord API response: %w", decodeErr), d.Token) + } + return d.apiError(resp.StatusCode(), apiResp) +} + +func (d *Discord) apiError(statusCode int, response apiResponse) error { + if response.Message == "" { + return fmt.Errorf("Discord API request failed: HTTP status %d, code %d", statusCode, response.Code) + } + return fmt.Errorf("Discord API request failed: HTTP status %d, code %d, message %q", statusCode, response.Code, response.Message) +} diff --git a/notifier/discord/discord_test.go b/notifier/discord/discord_test.go new file mode 100644 index 0000000..cb54fdb --- /dev/null +++ b/notifier/discord/discord_test.go @@ -0,0 +1,191 @@ +package discord + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-resty/resty/v2" + + "github.com/we11adam/uddns/internal/testutil" + "github.com/we11adam/uddns/notifier" +) + +type failingTransport struct { + message string +} + +func (f failingTransport) RoundTrip(_ *http.Request) (*http.Response, error) { + return nil, errors.New(f.message) +} + +func TestClientBaseURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *Discord + wantBase string + wantErr bool + }{ + { + name: "valid URL", + config: &Discord{ + URL: "https://discord.com/api/webhooks/ID/TOKEN", + }, + wantBase: "https://discord.com/api/webhooks/ID/TOKEN", + }, + { + name: "valid ID and Token", + config: &Discord{ + ID: "ID", + Token: "TOKEN", + }, + wantBase: "https://discord.com/api/webhooks/ID/TOKEN", + }, + { + name: "missing URL and ID/Token", + config: &Discord{}, + wantErr: true, + }, + { + name: "URL, ID and Token provided", + config: &Discord{ + URL: "https://discord.com/api/webhooks/ID/TOKEN", + ID: "ID", + Token: "TOKEN", + }, + wantErr: true, + }, + { + name: "only ID provided", + config: &Discord{ + ID: "ID", + }, + wantErr: true, + }, + { + name: "only Token provided", + config: &Discord{ + Token: "TOKEN", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := New(tt.config) + if (err != nil) != tt.wantErr { + t.Fatalf("New() error = %v, wantErr %v", err, tt.wantErr) + } + if err == nil { + baseURL := client.hc.BaseURL + if baseURL != tt.wantBase { + t.Errorf("New() BaseURL = %v, want %v", baseURL, tt.wantBase) + } + } + }) + } +} + +func TestNotifyRedactTokenFromTransportError(t *testing.T) { + token := "discord+/token =secret" + discord, err := New(&Discord{ + ID: "123456", + Token: token, + }) + if err != nil { + t.Fatalf("failed to create Discord client: %v", err) + } + discord.hc.SetTransport(failingTransport{message: "request failed for " + url.QueryEscape(discord.Token)}) + + err = discord.Notify(context.Background(), notifier.Notification{Message: "test"}) + if err == nil { + t.Fatal("expected transport error") + } + testutil.AssertTokenRedacted(t, err.Error(), token) +} + +func TestNotifyDiscordAPIResponse(t *testing.T) { + token := "discord+/token =secret" + tests := []struct { + name string + statusCode int + body string + wantErr bool + }{ + { + name: "http 400", + statusCode: http.StatusBadRequest, + body: `{"code":0,"message":""}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + discord := &Discord{ + ID: "123456", + Token: token, + hc: resty.New().SetBaseURL(server.URL), + } + err := discord.Notify(context.Background(), notifier.Notification{Message: "test"}) + if (err != nil) != tt.wantErr { + t.Fatalf("expected wantErr=%v, got err=%v", tt.wantErr, err) + } + if err != nil { + testutil.AssertTokenRedacted(t, err.Error(), token) + } + }) + } +} + +func TestNotifyCancelsInFlightRequest(t *testing.T) { + requestStarted := make(chan struct{}) + releaseRequest := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(requestStarted) + <-releaseRequest + })) + defer server.Close() + defer close(releaseRequest) + + discord := &Discord{ + Token: "token", + ID: "123456", + hc: resty.New().SetBaseURL(server.URL), + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- discord.Notify(ctx, notifier.Notification{Message: "test"}) + }() + + select { + case <-requestStarted: + case <-time.After(time.Second): + t.Fatal("Discord request did not start") + } + cancel() + + select { + case err := <-done: + if err == nil { + t.Fatal("expected canceled Discord request to return an error") + } + case <-time.After(time.Second): + t.Fatal("Discord request did not return after context cancellation") + } +} diff --git a/notifier/notifier.go b/notifier/notifier.go index e127fe1..1221752 100644 --- a/notifier/notifier.go +++ b/notifier/notifier.go @@ -6,9 +6,18 @@ import ( "github.com/we11adam/uddns/internal/registry" ) +type Reason string + +const ( + ReasonIPChange Reason = "ip_change" + ReasonUpdateSuccess Reason = "update_success" + ReasonUpdateFailure Reason = "update_failure" +) + type Notification struct { Title string Message string + Reason Reason } type Notifier interface { diff --git a/notifier/telegram/telegram_test.go b/notifier/telegram/telegram_test.go index c6b2574..dac6d97 100644 --- a/notifier/telegram/telegram_test.go +++ b/notifier/telegram/telegram_test.go @@ -11,6 +11,8 @@ import ( "time" "github.com/go-resty/resty/v2" + + "github.com/we11adam/uddns/internal/testutil" "github.com/we11adam/uddns/notifier" ) @@ -70,7 +72,7 @@ func TestNotifyRedactsTokenFromTransportError(t *testing.T) { if err == nil { t.Fatal("expected transport error") } - assertTokenRedacted(t, err.Error(), token) + testutil.AssertTokenRedacted(t, err.Error(), token) } func TestNotifyChecksTelegramAPIResponse(t *testing.T) { @@ -119,7 +121,7 @@ func TestNotifyChecksTelegramAPIResponse(t *testing.T) { t.Fatalf("expected wantErr=%v, got err=%v", tt.wantErr, err) } if err != nil { - assertTokenRedacted(t, err.Error(), token) + testutil.AssertTokenRedacted(t, err.Error(), token) } }) } @@ -162,12 +164,3 @@ func TestNotifyCancelsInFlightRequest(t *testing.T) { t.Fatal("Telegram request did not return after context cancellation") } } - -func assertTokenRedacted(t *testing.T, value, token string) { - t.Helper() - for _, sensitive := range []string{token, url.QueryEscape(token), url.PathEscape(token)} { - if strings.Contains(value, sensitive) { - t.Fatalf("error still contains token %q: %q", sensitive, value) - } - } -}