Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 7 additions & 7 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
)...,
)
Expand Down
16 changes: 16 additions & 0 deletions internal/testutil/helper.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
155 changes: 155 additions & 0 deletions notifier/discord/discord.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading