diff --git a/docs/src/content/docs/administration/security.md b/docs/src/content/docs/administration/security.md index b2579afa..025264f2 100644 --- a/docs/src/content/docs/administration/security.md +++ b/docs/src/content/docs/administration/security.md @@ -38,12 +38,14 @@ administrators and platform administrators. See ## Secrets and backups -Notification URLs are write-only in the API and encrypted in the database with -`notification.key`. TOTP seeds use the independent `auth.key`. Separate configured +Notification credentials, supplied as provider fields or a Shoutrrr URL, are +write-only in the API and saved as encrypted URLs with `notification.key`. +TOTP seeds use the independent `auth.key`. Separate configured key files must be regular files: the notification key with mode `0400` or `0600`, and the authentication key without group or other permissions. Back up the original keys with the corresponding database; the database alone cannot recover them. -Never commit keys, passwords, setup tokens, notification URLs, or runtime data. +Never commit keys, passwords, setup tokens, notification credentials or URLs, +or runtime data. After a successful legacy notification import, remove plaintext URLs and URL file mounts from the deployment. Rotate credentials through the console. diff --git a/docs/src/content/docs/getting-started/first-scan.md b/docs/src/content/docs/getting-started/first-scan.md index bba63481..8ba8b5be 100644 --- a/docs/src/content/docs/getting-started/first-scan.md +++ b/docs/src/content/docs/getting-started/first-scan.md @@ -8,9 +8,12 @@ administrator you created during setup. ## Set up notifications -Open **Notifications** and add a named Shoutrrr destination. Notification URLs -are write-only: the console does not return an existing URL after you save it. -Treat these URLs as secrets. +Open **Notifications** and choose Email (SMTP), Discord webhook, ntfy, or +**Advanced Shoutrrr URL**. Add a name, connection details, and confirm your +account password. Credentials are write-only and encrypted; the console does +not return them after you save them. Use **Test** and check that the message +arrives. When you create a job, select the destination in its notification +routing. The [notification guide](/user-guide/notifications/) covers routing, delivery health, and destinations imported from older deployments. diff --git a/docs/src/content/docs/reference/api-compatibility.md b/docs/src/content/docs/reference/api-compatibility.md index 054f605f..ec41c9f7 100644 --- a/docs/src/content/docs/reference/api-compatibility.md +++ b/docs/src/content/docs/reference/api-compatibility.md @@ -58,6 +58,34 @@ compared them with the current baseline once it had one. The scan objects of the scan and job scan endpoints also carry the recorded `comparison`; it is omitted for scans recorded before v0.26.0. +## Notification destination provider configuration + +v0.31.0 adds structured provider configuration to notification destination +create and update routes. They accept the existing `url` field or a structured +`config` object. A structured configuration has the +shape `{provider, fields}`; supported providers are `smtp`, `discord`, and +`ntfy`. For example, an ntfy destination can be created with: + +```json +{ + "name": "Operations", + "config": { + "provider": "ntfy", + "fields": { "topic": "edgewatch-alerts" } + }, + "password": "account password" +} +``` + +Unit destinations use `POST /api/v1/notifications/destinations` and +`PUT /api/v1/notifications/destinations/{id}`. Platform destinations use +`POST /api/v1/platform/notifications` and +`PATCH /api/v1/platform/notifications/{id}`. The update routes require the +current `revision`; omitting both `url` and `config` preserves credentials. +Providing either replaces them. Responses remain write-only and contain +provider metadata, never the URL or fields. Existing clients can continue to +send `url` unchanged. + ## Business units v0.20.0 adds business units to every installation. The routes and response diff --git a/docs/src/content/docs/user-guide/notifications.md b/docs/src/content/docs/user-guide/notifications.md index 9b700b60..49608cf7 100644 --- a/docs/src/content/docs/user-guide/notifications.md +++ b/docs/src/content/docs/user-guide/notifications.md @@ -8,6 +8,27 @@ Destinations are named and managed on the **Notifications** page. Their URLs are write-only and encrypted at rest with `notification.key`; their credentials are never returned by the API or written to logs. +## Add a destination + +Choose a built-in provider to enter connection details in separate fields: + +- **Email (SMTP):** server, sender address, recipients, and optional login. + Recipients can be comma-separated. The port defaults to 25; StartTLS is + enabled when the server advertises support. +- **Discord webhook:** paste the HTTPS webhook URL for a Discord channel. +- **ntfy:** enter a topic and, when needed, a server and login. A blank server + uses `https://ntfy.sh`. +- **Advanced Shoutrrr URL:** use this for any other provider Shoutrrr supports + or when you already have a URL. + +The form never reads a saved credential back. To rotate a saved destination, +edit it and enter all fields for its new provider configuration; leaving the +Advanced URL blank keeps its existing credentials. Replacing credentials +discards alerts queued for the old credentials. A successful test means the +provider accepted the test send; check the recipient to confirm the message +arrived. Destinations added after existing job routing is frozen remain +opt-in. Select a destination in each job that should use it. + ## Routing and update alerts Each job can select its own destinations. On the **Notifications** page, @@ -51,8 +72,9 @@ audit record. Its delivery health goes with it, so its failures no longer count in the notification totals. Renaming a destination keeps its queued alerts, including an alert that is -raised while the rename is saved. Replacing its URL discards its queued alerts -instead of sending them to the new URL, and deleting it discards them too. +raised while the rename is saved. Replacing its URL or provider configuration +discards its queued alerts instead of sending them to the new credentials, and +deleting it discards them too. This includes an alert that a delivery pass has picked up but not yet sent. An alert raised while either change is saved is also discarded, and the security audit log records it as `notifications.pending_discarded`. An alert raised diff --git a/internal/notify/provider_config.go b/internal/notify/provider_config.go new file mode 100644 index 00000000..b87bb99b --- /dev/null +++ b/internal/notify/provider_config.go @@ -0,0 +1,239 @@ +package notify + +import ( + "errors" + "net" + "net/mail" + "net/url" + "strconv" + "strings" + + "github.com/containrrr/shoutrrr" + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/services/discord" + "github.com/containrrr/shoutrrr/pkg/services/ntfy" + "github.com/containrrr/shoutrrr/pkg/services/smtp" +) + +var ErrInvalidProviderConfiguration = errors.New("notification provider configuration is invalid") + +const ( + discordWebhookHost = "discord" + "." + "com" + discordLegacyWebhookHost = "discordapp" + "." + "com" +) + +// ProviderConfig contains the fields needed to build one supported Shoutrrr +// destination. Credentials only travel through the existing encrypted write. +type ProviderConfig struct { + Provider string `json:"provider"` + Fields map[string]string `json:"fields"` +} + +// CompileProviderConfig builds a Shoutrrr URL from a supported provider form +// and validates it with the pinned Shoutrrr parser. The returned URL contains +// credentials and must only be passed to existing encrypted destination +// operations. +func CompileProviderConfig(input ProviderConfig) (string, error) { + fields, err := checkedProviderFields(input.Provider, input.Fields) + if err != nil { + return "", err + } + var raw string + switch input.Provider { + case "smtp": + raw, err = compileSMTP(fields) + case "discord": + raw, err = compileDiscord(fields) + case "ntfy": + raw, err = compileNtfy(fields) + default: + return "", ErrInvalidProviderConfiguration + } + if err != nil { + return "", ErrInvalidProviderConfiguration + } + if _, err := shoutrrr.CreateSender(raw); err != nil { + return "", ErrInvalidProviderConfiguration + } + return raw, nil +} + +func checkedProviderFields(provider string, fields map[string]string) (map[string]string, error) { + var allowed map[string]struct{} + switch provider { + case "smtp": + allowed = fieldSet("host", "port", "from", "to", "username", "password") + case "discord": + allowed = fieldSet("webhook_url") + case "ntfy": + allowed = fieldSet("server", "topic", "username", "password") + default: + return nil, ErrInvalidProviderConfiguration + } + if len(fields) > len(allowed) { + return nil, ErrInvalidProviderConfiguration + } + for key, value := range fields { + if _, ok := allowed[key]; !ok || len(value) > 4096 { + return nil, ErrInvalidProviderConfiguration + } + } + return fields, nil +} + +func fieldSet(values ...string) map[string]struct{} { + result := make(map[string]struct{}, len(values)) + for _, value := range values { + result[value] = struct{}{} + } + return result +} + +func field(fields map[string]string, name string) string { return strings.TrimSpace(fields[name]) } + +func compileSMTP(fields map[string]string) (string, error) { + host := field(fields, "host") + host, ok := normalizedSMTPHost(host) + if !ok { + return "", ErrInvalidProviderConfiguration + } + port := 25 + if rawPort := field(fields, "port"); rawPort != "" { + parsed, err := strconv.Atoi(rawPort) + if err != nil || parsed < 1 || parsed > 65535 { + return "", ErrInvalidProviderConfiguration + } + port = parsed + } + from, err := parseMailAddress(field(fields, "from")) + if err != nil { + return "", ErrInvalidProviderConfiguration + } + recipients := splitRecipients(field(fields, "to")) + if len(recipients) == 0 { + return "", ErrInvalidProviderConfiguration + } + for index, recipient := range recipients { + recipients[index], err = parseMailAddress(recipient) + if err != nil { + return "", ErrInvalidProviderConfiguration + } + } + config := smtp.Config{} + resolver := format.NewPropKeyResolver(&config) + if err := resolver.SetDefaultProps(&config); err != nil { + return "", ErrInvalidProviderConfiguration + } + config.Host = host + config.Port = uint16(port) + config.Username = field(fields, "username") + config.Password = fields["password"] + config.FromAddress = from + config.ToAddresses = recipients + destination := config.GetURL() + destination.Host = net.JoinHostPort(host, strconv.Itoa(port)) + return destination.String(), nil +} + +func compileDiscord(fields map[string]string) (string, error) { + u, err := url.Parse(field(fields, "webhook_url")) + if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || u.RawQuery != "" || u.Fragment != "" { + return "", ErrInvalidProviderConfiguration + } + if host := strings.ToLower(u.Hostname()); host != discordWebhookHost && host != discordLegacyWebhookHost { + return "", ErrInvalidProviderConfiguration + } + parts := strings.Split(strings.Trim(u.EscapedPath(), "/"), "/") + if len(parts) != 4 || parts[0] != "api" || parts[1] != "webhooks" { + return "", ErrInvalidProviderConfiguration + } + webhookID, err := url.PathUnescape(parts[2]) + if err != nil || !digitsOnly(webhookID) { + return "", ErrInvalidProviderConfiguration + } + token, err := url.PathUnescape(parts[3]) + if err != nil || token == "" || strings.ContainsAny(token, "/?#\r\n") { + return "", ErrInvalidProviderConfiguration + } + config := discord.Config{} + resolver := format.NewPropKeyResolver(&config) + if err := resolver.SetDefaultProps(&config); err != nil { + return "", ErrInvalidProviderConfiguration + } + config.WebhookID = webhookID + config.Token = token + return config.GetURL().String(), nil +} + +func compileNtfy(fields map[string]string) (string, error) { + topic := field(fields, "topic") + if topic == "" || strings.ContainsAny(topic, "/?#\r\n") { + return "", ErrInvalidProviderConfiguration + } + server := field(fields, "server") + if server == "" { + server = "https://ntfy.sh" + } + u, err := url.Parse(server) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || + u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return "", ErrInvalidProviderConfiguration + } + username, password := field(fields, "username"), fields["password"] + config := ntfy.Config{} + resolver := format.NewPropKeyResolver(&config) + if err := resolver.SetDefaultProps(&config); err != nil { + return "", ErrInvalidProviderConfiguration + } + config.Host = u.Host + config.Scheme = u.Scheme + config.Topic = topic + config.Username = username + config.Password = password + return config.GetURL().String(), nil +} + +func normalizedSMTPHost(host string) (string, bool) { + if host == "" || strings.ContainsAny(host, " \t\r\n/@?#") { + return "", false + } + u, err := url.Parse("smtp://" + host) + if err != nil || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || + u.Port() != "" || u.Host != host || u.Hostname() == "" || strings.Contains(u.Hostname(), ":") { + return "", false + } + return u.Hostname(), true +} + +func parseMailAddress(value string) (string, error) { + if value == "" || strings.ContainsAny(value, "\r\n") { + return "", ErrInvalidProviderConfiguration + } + address, err := mail.ParseAddress(value) + if err != nil || address.Address == "" { + return "", ErrInvalidProviderConfiguration + } + return address.Address, nil +} + +func splitRecipients(value string) []string { + var recipients []string + for _, recipient := range strings.Split(value, ",") { + if recipient = strings.TrimSpace(recipient); recipient != "" { + recipients = append(recipients, recipient) + } + } + return recipients +} + +func digitsOnly(value string) bool { + if value == "" { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} diff --git a/internal/notify/provider_config_test.go b/internal/notify/provider_config_test.go new file mode 100644 index 00000000..dad6d699 --- /dev/null +++ b/internal/notify/provider_config_test.go @@ -0,0 +1,198 @@ +package notify + +import ( + "net/url" + "strings" + "testing" + + "github.com/containrrr/shoutrrr/pkg/format" + "github.com/containrrr/shoutrrr/pkg/services/discord" + "github.com/containrrr/shoutrrr/pkg/services/ntfy" + "github.com/containrrr/shoutrrr/pkg/services/smtp" +) + +func TestCompileProviderConfigRoundTripsSupportedCredentials(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input ProviderConfig + verify func(t *testing.T, raw string) + }{ + { + name: "SMTP encodes credentials and recipients", + input: ProviderConfig{Provider: "smtp", Fields: map[string]string{ + "host": "mail.example.test", "port": "587", + "from": "edgewatch@example.test", "to": "ops+edge@example.test, oncall@example.test", + "username": "smtp-user", "password": "p@ss:/?&%", + }}, + verify: func(t *testing.T, raw string) { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + config := smtp.Config{} + resolver := format.NewPropKeyResolver(&config) + if err := resolver.SetDefaultProps(&config); err != nil { + t.Fatal("apply SMTP defaults:", err) + } + if err := config.SetURL(u); err != nil { + t.Fatal("parse generated SMTP configuration:", err) + } + if config.Password != "p@ss:/?&%" || config.Username != "smtp-user" { + t.Fatalf("SMTP credentials did not round-trip: username=%q password=%q", config.Username, config.Password) + } + if config.FromAddress != "edgewatch@example.test" || len(config.ToAddresses) != 2 || config.ToAddresses[0] != "ops+edge@example.test" { + t.Fatalf("SMTP addresses did not round-trip: from=%q to=%#v", config.FromAddress, config.ToAddresses) + } + if config.Port != 587 || !config.UseStartTLS { + t.Fatalf("SMTP defaults did not apply: port=%d STARTTLS=%v", config.Port, config.UseStartTLS) + } + }, + }, + { + name: "SMTP applies defaults without authentication", + input: ProviderConfig{Provider: "smtp", Fields: map[string]string{ + "host": "mail.example.test", "from": "edgewatch@example.test", "to": "ops@example.test", + }}, + verify: func(t *testing.T, raw string) { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + config := smtp.Config{} + resolver := format.NewPropKeyResolver(&config) + if err := resolver.SetDefaultProps(&config); err != nil { + t.Fatal("apply SMTP defaults:", err) + } + if err := config.SetURL(u); err != nil { + t.Fatal("parse default SMTP configuration:", err) + } + if config.Host != "mail.example.test" || config.Port != 25 || !config.UseStartTLS || + config.Username != "" || config.Password != "" || len(config.ToAddresses) != 1 { + t.Fatalf("unexpected default SMTP configuration: %+v", config) + } + }, + }, + { + name: "Discord converts a native webhook URL", + input: ProviderConfig{Provider: "discord", Fields: map[string]string{ + "webhook_url": "https://discord.com/api/webhooks/123456789012345678/token.part_value-1", + }}, + verify: func(t *testing.T, raw string) { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + config := discord.Config{} + if err := config.SetURL(u); err != nil { + t.Fatal("parse generated Discord configuration:", err) + } + if config.WebhookID != "123456789012345678" || config.Token != "token.part_value-1" { + t.Fatalf("Discord webhook did not round-trip: id=%q token=%q", config.WebhookID, config.Token) + } + }, + }, + { + name: "ntfy preserves a custom HTTPS server and port", + input: ProviderConfig{Provider: "ntfy", Fields: map[string]string{ + "server": "https://notifications.example.test:8443/", "topic": "edgewatch-alerts", + }}, + verify: func(t *testing.T, raw string) { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + config := ntfy.Config{} + resolver := format.NewPropKeyResolver(&config) + if err := resolver.SetDefaultProps(&config); err != nil { + t.Fatal("apply ntfy defaults:", err) + } + if err := config.SetURL(u); err != nil { + t.Fatal("parse custom ntfy configuration:", err) + } + if config.GetAPIURL() != "https://notifications.example.test:8443/edgewatch-alerts" || + config.Username != "" || config.Password != "" { + t.Fatalf("custom ntfy server did not round-trip: %+v", config) + } + }, + }, + { + name: "ntfy applies its server default and escapes a token", + input: ProviderConfig{Provider: "ntfy", Fields: map[string]string{ + "topic": "edgewatch-alerts", "username": "operator", "password": "token&with/slash", + }}, + verify: func(t *testing.T, raw string) { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + config := ntfy.Config{} + if err := config.SetURL(u); err != nil { + t.Fatal("parse generated ntfy configuration:", err) + } + resolver := format.NewPropKeyResolver(&config) + if err := resolver.SetDefaultProps(&config); err != nil { + t.Fatal("apply ntfy defaults:", err) + } + if config.Host != "ntfy.sh" || config.Scheme != "https" || config.Topic != "edgewatch-alerts" || + config.Username != "operator" || config.Password != "token&with/slash" { + t.Fatalf("ntfy configuration did not round-trip: %+v", config) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw, err := CompileProviderConfig(test.input) + if err != nil { + t.Fatal("compile provider config:", err) + } + test.verify(t, raw) + }) + } +} + +func TestCompileProviderConfigRejectsMalformedAndUnsupportedInput(t *testing.T) { + t.Parallel() + secret := "never-echo-this-token" + tests := []ProviderConfig{ + {Provider: "unknown", Fields: map[string]string{"url": "generic://example.test"}}, + {Provider: "smtp", Fields: map[string]string{"from": "from@example.test", "to": "to@example.test"}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test/path", "from": "from@example.test", "to": "to@example.test"}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test:587", "from": "from@example.test", "to": "to@example.test"}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test", "port": "invalid", "from": "from@example.test", "to": "to@example.test"}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test", "port": "70000", "from": "from@example.test", "to": "to@example.test"}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test", "from": "from@example.test\r\nBcc: attacker@example.test", "to": "to@example.test"}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test", "from": "from@example.test", "to": " , "}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test", "from": "from@example.test", "to": "not-an-email"}}, + {Provider: "smtp", Fields: map[string]string{"host": "smtp.example.test", "from": "from@example.test", "to": "to@example.test", "unexpected": "value"}}, + {Provider: "discord", Fields: map[string]string{"webhook_url": "https://attacker.example/api/webhooks/12345678/" + secret}}, + {Provider: "discord", Fields: map[string]string{"webhook_url": "http://discord.com/api/webhooks/12345678/" + secret}}, + {Provider: "discord", Fields: map[string]string{"webhook_url": "https://discord.com/api/unknown/12345678/" + secret}}, + {Provider: "discord", Fields: map[string]string{"webhook_url": "https://discord.com/api/webhooks/not-numeric/" + secret}}, + {Provider: "discord", Fields: map[string]string{"webhook_url": "https://discord.com/api/webhooks/12345678/" + secret + "%2F"}}, + {Provider: "discord", Fields: map[string]string{"webhook_url": strings.Repeat("x", 4097)}}, + {Provider: "ntfy", Fields: map[string]string{"topic": "alerts", "server": "https://notify.example.test", "username": "operator", "password": secret, "unexpected": "value"}}, + {Provider: "ntfy", Fields: map[string]string{"topic": "", "password": secret}}, + {Provider: "ntfy", Fields: map[string]string{"topic": "alerts/another-topic", "password": secret}}, + {Provider: "ntfy", Fields: map[string]string{"topic": "alerts", "server": "ftp://notify.example.test", "password": secret}}, + {Provider: "ntfy", Fields: map[string]string{"topic": "alerts", "server": "https://notify.example.test/path", "password": secret}}, + {Provider: "ntfy", Fields: map[string]string{"topic": "alerts", "server": "https://notify.example.test?token=" + secret}}, + } + for _, input := range tests { + _, err := CompileProviderConfig(input) + if err == nil || err != ErrInvalidProviderConfiguration { + t.Fatalf("CompileProviderConfig(%+v) error = %v, want generic validation error", input, err) + } + if strings.Contains(err.Error(), secret) { + t.Fatal("provider error disclosed a credential") + } + } +} diff --git a/internal/web/notification_handlers.go b/internal/web/notification_handlers.go index 4cfb9541..71ffb910 100644 --- a/internal/web/notification_handlers.go +++ b/internal/web/notification_handlers.go @@ -15,11 +15,29 @@ import ( ) type notificationPayload struct { - Name string `json:"name"` - URL *string `json:"url"` - Password string `json:"password"` - Enabled *bool `json:"enabled"` - Revision *int64 `json:"revision"` + Name string `json:"name"` + URL *string `json:"url"` + Config *notify.ProviderConfig `json:"config"` + Password string `json:"password"` + Enabled *bool `json:"enabled"` + Revision *int64 `json:"revision"` +} + +func notificationDestinationURL(rawURL *string, providerConfig *notify.ProviderConfig, required bool) (*string, error) { + if rawURL != nil && providerConfig != nil { + return nil, notify.ErrInvalidProviderConfiguration + } + if providerConfig != nil { + compiled, err := notify.CompileProviderConfig(*providerConfig) + if err != nil { + return nil, err + } + return &compiled, nil + } + if rawURL == nil && required { + return nil, errors.New("notification URL is required") + } + return rawURL, nil } // listNotificationDestinations lists the destinations of the session's @@ -291,11 +309,12 @@ func (s *Server) createNotificationDestination(w http.ResponseWriter, r *http.Re if input.Enabled != nil { enabled = *input.Enabled } - if input.URL == nil { - writeError(w, http.StatusBadRequest, "validation_failed", "notification URL is required", map[string]string{"url": "notification URL is required"}) + rawURL, err := notificationDestinationURL(input.URL, input.Config, true) + if err != nil { + s.writeNotificationError(w, err) return } - view, err := s.App.Notifier.Tenant(ts).CreateManagedWithAudit(r.Context(), input.Name, *input.URL, enabled, store.AuditEntry{Action: "notifications.created", Detail: "managed notification created", ActorUserID: session.UserID, ActorUsername: session.Username}) + view, err := s.App.Notifier.Tenant(ts).CreateManagedWithAudit(r.Context(), input.Name, *rawURL, enabled, store.AuditEntry{Action: "notifications.created", Detail: "managed notification created", ActorUserID: session.UserID, ActorUsername: session.Username}) if err != nil { if s.writeAuditUnavailable(w, err, "notifications.created") { return @@ -376,7 +395,12 @@ func (s *Server) updateNotificationDestination(w http.ResponseWriter, r *http.Re s.writeNotificationAuthError(w, err) return } - view, err := s.App.Notifier.Tenant(ts).UpdateManagedWithAudit(r.Context(), id, *input.Revision, input.Name, input.URL, input.Enabled, store.AuditEntry{Action: "notifications.updated", Detail: "managed notification updated: " + id, ActorUserID: session.UserID, ActorUsername: session.Username}) + rawURL, err := notificationDestinationURL(input.URL, input.Config, false) + if err != nil { + s.writeNotificationError(w, err) + return + } + view, err := s.App.Notifier.Tenant(ts).UpdateManagedWithAudit(r.Context(), id, *input.Revision, input.Name, rawURL, input.Enabled, store.AuditEntry{Action: "notifications.updated", Detail: "managed notification updated: " + id, ActorUserID: session.UserID, ActorUsername: session.Username}) if err != nil { if s.writeAuditUnavailable(w, err, "notifications.updated") { return @@ -444,6 +468,8 @@ func (s *Server) writePasswordConfirmationError(w http.ResponseWriter, err error func (s *Server) writeNotificationError(w http.ResponseWriter, err error) { lower := strings.ToLower(err.Error()) switch { + case errors.Is(err, notify.ErrInvalidProviderConfiguration): + writeError(w, http.StatusBadRequest, "validation_failed", "notification provider configuration is invalid", map[string]string{"config": "check the selected provider and its required fields"}) case errors.Is(err, store.ErrConflict): writeError(w, http.StatusConflict, "conflict", "notification was modified; reload before saving", nil) case errors.Is(err, store.ErrNotFound): diff --git a/internal/web/notification_provider_config_test.go b/internal/web/notification_provider_config_test.go new file mode 100644 index 00000000..24931d37 --- /dev/null +++ b/internal/web/notification_provider_config_test.go @@ -0,0 +1,126 @@ +package web + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/crypt0rr/edgewatch/internal/notify" +) + +func TestNotificationProviderConfigUsesEncryptedWriteOnlyDestination(t *testing.T) { + t.Parallel() + server, _, admin := newUsersTestServer(t) + createBody := `{"name":"Push alerts","config":{"provider":"ntfy","fields":{"topic":"edgewatch-alerts","username":"operator","password":"provider-secret"}},"password":"administrator password"}` + request := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/destinations", strings.NewReader(createBody)) + request.Header.Set("Content-Type", "application/json") + created := httptest.NewRecorder() + server.createNotificationDestination(created, request, admin, defaultTenantStore(server)) + expectResponse(t, created, http.StatusCreated, "create provider destination", nil) + expectNoMarkers(t, created.Body.String(), "create response", "provider-secret", "edgewatch-alerts", "url", "password") + if !strings.Contains(created.Body.String(), `"provider":"ntfy"`) { + t.Fatalf("create response omitted provider metadata: %s", created.Body.String()) + } + + var view struct { + ID string `json:"id"` + Revision int64 `json:"revision"` + } + if err := json.Unmarshal(created.Body.Bytes(), &view); err != nil || view.ID == "" || view.Revision != 1 { + t.Fatalf("created destination = %s (%v)", created.Body.String(), err) + } + record, err := defaultTenantStore(server).GetManagedNotification(context.Background(), view.ID) + if err != nil { + t.Fatal("load encrypted destination:", err) + } + if strings.Contains(string(record.Ciphertext), "provider-secret") || strings.Contains(string(record.Ciphertext), "edgewatch-alerts") { + t.Fatal("provider credentials were stored in plaintext") + } + + updateBody := `{"name":"Push alerts","revision":1,"config":{"provider":"ntfy","fields":{"topic":"new-alerts","username":"operator","password":"replacement-secret"}},"password":"administrator password"}` + updateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/destinations/"+view.ID, strings.NewReader(updateBody)) + updateRequest.Header.Set("Content-Type", "application/json") + updated := httptest.NewRecorder() + server.updateNotificationDestination(updated, updateRequest, admin, defaultTenantStore(server), view.ID) + expectResponse(t, updated, http.StatusOK, "replace provider credentials", nil) + expectNoMarkers(t, updated.Body.String(), "update response", "replacement-secret", "new-alerts", "url", "password") + if !strings.Contains(updated.Body.String(), `"revision":2`) || !strings.Contains(updated.Body.String(), `"provider":"ntfy"`) { + t.Fatalf("updated destination metadata = %s", updated.Body.String()) + } +} + +func TestNotificationDestinationURLRejectsConflictingCredentialInputs(t *testing.T) { + t.Parallel() + rawURL := "generic://example.test/alerts?disabletls=yes" + config := ¬ify.ProviderConfig{Provider: "ntfy", Fields: map[string]string{"topic": "alerts"}} + _, err := notificationDestinationURL(&rawURL, config, true) + if err == nil || err.Error() != "notification provider configuration is invalid" { + t.Fatalf("conflicting credential inputs error = %v", err) + } + if _, err := notificationDestinationURL(nil, nil, true); err == nil || err.Error() != "notification URL is required" { + t.Fatalf("missing credential input error = %v", err) + } + if value, err := notificationDestinationURL(nil, nil, false); err != nil || value != nil { + t.Fatalf("omitted update credentials = %v, %v; want preserve existing", value, err) + } +} + +func TestNotificationProviderConfigRejectsMalformedInputWithoutDisclosingCredentials(t *testing.T) { + t.Parallel() + server, _, admin := newUsersTestServer(t) + body := `{"name":"Invalid push","config":{"provider":"ntfy","fields":{"topic":"alerts","server":"https://notify.example.test?token=private-provider-token"}},"password":"administrator password"}` + request := httptest.NewRequest(http.MethodPost, "/api/v1/notifications/destinations", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.createNotificationDestination(response, request, admin, defaultTenantStore(server)) + expectError(t, response, http.StatusBadRequest, "validation_failed", "invalid unit provider configuration") + expectNoMarkers(t, response.Body.String(), "invalid unit provider configuration", "private-provider-token", "notify.example.test") +} + +func TestPlatformNotificationProviderConfigRejectsMalformedInputWithoutDisclosingCredentials(t *testing.T) { + t.Parallel() + f := newPlatformFixture(t) + body := confirmBody(`"name":"Invalid push","config":{"provider":"ntfy","fields":{"topic":"alerts","server":"https://notify.example.test?token=private-platform-token"}}`) + response := f.call(t, actorPlatform, http.MethodPost, "/platform/notifications", body) + expectError(t, response, http.StatusBadRequest, "validation_failed", "invalid platform provider configuration") + expectNoMarkers(t, response.Body.String(), "invalid platform provider configuration", "private-platform-token", "notify.example.test") +} + +func TestPlatformNotificationProviderConfigUsesEncryptedWriteOnlyDestination(t *testing.T) { + t.Parallel() + f := newPlatformFixture(t) + body := confirmBody(`"name":"Platform push","config":{"provider":"ntfy","fields":{"topic":"platform-alerts","password":"platform-provider-secret"}}`) + created := f.call(t, actorPlatform, http.MethodPost, "/platform/notifications", body) + expectResponse(t, created, http.StatusCreated, "create platform provider destination", nil) + expectNoMarkers(t, created.Body.String(), "platform create response", "platform-provider-secret", "platform-alerts") + if !strings.Contains(created.Body.String(), `"provider":"ntfy"`) { + t.Fatalf("platform create response omitted provider metadata: %s", created.Body.String()) + } + var view struct { + ID string `json:"id"` + } + if err := json.Unmarshal(created.Body.Bytes(), &view); err != nil || view.ID == "" { + t.Fatalf("created platform destination = %s (%v)", created.Body.String(), err) + } + record, err := f.db.System().GetManagedNotification(context.Background(), view.ID) + if err != nil { + t.Fatal("load encrypted platform destination:", err) + } + if strings.Contains(string(record.Ciphertext), "platform-provider-secret") || strings.Contains(string(record.Ciphertext), "platform-alerts") { + t.Fatal("platform provider credentials were stored in plaintext") + } + updateBody := confirmBody(`"revision":1,"name":"Platform push","config":{"provider":"ntfy","fields":{"topic":"platform-replacement-alerts","password":"platform-replacement-secret"}}`) + updated := f.call(t, actorPlatform, http.MethodPatch, "/platform/notifications/"+view.ID, updateBody) + expectResponse(t, updated, http.StatusOK, "replace platform provider configuration", nil) + expectNoMarkers(t, updated.Body.String(), "platform update response", "platform-replacement-secret", "platform-replacement-alerts") + record, err = f.db.System().GetManagedNotification(context.Background(), view.ID) + if err != nil { + t.Fatal("load updated encrypted platform destination:", err) + } + if strings.Contains(string(record.Ciphertext), "platform-replacement-secret") || strings.Contains(string(record.Ciphertext), "platform-replacement-alerts") { + t.Fatal("updated platform provider credentials were stored in plaintext") + } +} diff --git a/internal/web/platform.go b/internal/web/platform.go index 1f382125..8a628b36 100644 --- a/internal/web/platform.go +++ b/internal/web/platform.go @@ -959,12 +959,13 @@ func (s *Server) createPlatformNotification(w http.ResponseWriter, r *http.Reque if !decodeJSON(w, r, &input) || !s.confirmNotificationPassword(w, r, session, input.Password) { return } - if input.URL == nil { - writeError(w, http.StatusBadRequest, "validation_failed", "notification URL is required", map[string]string{"url": "notification URL is required"}) + rawURL, err := notificationDestinationURL(input.URL, input.Config, true) + if err != nil { + s.writeNotificationError(w, err) return } enabled := input.Enabled == nil || *input.Enabled - view, err := s.App.Notifier.Platform(s.Store.Platform()).CreateManagedWithAudit(r.Context(), input.Name, *input.URL, enabled, platformActorAudit(session, "", "platform notification destination created")) + view, err := s.App.Notifier.Platform(s.Store.Platform()).CreateManagedWithAudit(r.Context(), input.Name, *rawURL, enabled, platformActorAudit(session, "", "platform notification destination created")) if err != nil { if s.writeAuditUnavailable(w, err, "platform_notifications.created") { return @@ -987,7 +988,12 @@ func (s *Server) updatePlatformNotification(w http.ResponseWriter, r *http.Reque if !s.confirmNotificationPassword(w, r, session, input.Password) { return } - view, err := s.App.Notifier.Platform(s.Store.Platform()).UpdateManagedWithAudit(r.Context(), id, *input.Revision, input.Name, input.URL, input.Enabled, platformActorAudit(session, "", "platform notification destination updated: "+id)) + rawURL, err := notificationDestinationURL(input.URL, input.Config, false) + if err != nil { + s.writeNotificationError(w, err) + return + } + view, err := s.App.Notifier.Platform(s.Store.Platform()).UpdateManagedWithAudit(r.Context(), id, *input.Revision, input.Name, rawURL, input.Enabled, platformActorAudit(session, "", "platform notification destination updated: "+id)) if err != nil { if s.writeAuditUnavailable(w, err, "platform_notifications.updated") { return diff --git a/scripts/coverage-gates.test.mjs b/scripts/coverage-gates.test.mjs index b6e6239d..4e64c832 100644 --- a/scripts/coverage-gates.test.mjs +++ b/scripts/coverage-gates.test.mjs @@ -28,6 +28,7 @@ const frontendReport = (overrides = {}) => { 'src/components/AuditLog.tsx', 'src/components/ErrorNotice.tsx', 'src/components/HostEmptyState.tsx', + 'src/components/NotificationDestinationConfig.tsx', 'src/components/navigation.ts', 'src/components/OneTimeLink.tsx', 'src/components/PasswordField.tsx', diff --git a/src/api.test.ts b/src/api.test.ts index 5c959eec..2b893f37 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { APIError, acceptIncident, activate, api, baselineHost, baselineHosts, createNotificationDestination, createUser, getPublicDashboard, getScan, getScanSummary, historicalScanHost, issueUserActivation, issueUserPasswordReset, listHosts, listScans, listUsers, login, recordActivity, revokeUserSessions, scheduleSuggestion, setCSRF, setForbiddenHandler, setup, setupStatus, suppressIncident, updateNotificationDestination, updateUser } from './api' +import { APIError, acceptIncident, activate, api, baselineHost, baselineHosts, createNotificationDestination, createPlatformNotification, createUser, getPublicDashboard, getScan, getScanSummary, historicalScanHost, issueUserActivation, issueUserPasswordReset, listHosts, listScans, listUsers, login, recordActivity, revokeUserSessions, scheduleSuggestion, setCSRF, setForbiddenHandler, setup, setupStatus, suppressIncident, updateNotificationDestination, updatePlatformNotification, updateUser } from './api' import * as apiRoutes from './api' import { getDisplayTimeZone, setDisplayTimeZone } from './format' @@ -213,6 +213,35 @@ describe('notification API contract', () => { expect(updateBody).toMatchObject({ revision: 1, name: 'Ops', password: 'correct horse battery staple', enabled: false }) expect(updateBody.url).toBeUndefined() }) + + it('sends structured provider fields through unit and platform routes', async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({ id: 'dest-1', name: 'Ops', provider: 'ntfy', source: 'web', enabled: true, locked: false, read_only: false, revision: 2 }), { status: 200, headers: { 'Content-Type': 'application/json' } })) + vi.stubGlobal('fetch', fetchMock) + setCSRF('csrf-token') + const config = { provider: 'ntfy' as const, fields: { topic: 'edgewatch-alerts', password: 'provider-token' } } + + await createNotificationDestination('Ops', config, 'account password') + const unitCreate = JSON.parse(String(fetchMock.mock.calls[0][1]?.body)) as Record + expect(unitCreate).toMatchObject({ name: 'Ops', config, password: 'account password', enabled: true }) + expect(unitCreate.url).toBeUndefined() + + await updateNotificationDestination('dest-1', 1, 'Ops', 'account password', { config }) + const unitUpdate = JSON.parse(String(fetchMock.mock.calls[1][1]?.body)) as Record + expect(unitUpdate).toMatchObject({ name: 'Ops', revision: 1, config }) + expect(unitUpdate.url).toBeUndefined() + + await createPlatformNotification('Platform ops', config, 'platform password') + const platformCreate = JSON.parse(String(fetchMock.mock.calls[2][1]?.body)) as Record + expect(String(fetchMock.mock.calls[2][0])).toBe('/api/v1/platform/notifications') + expect(platformCreate).toMatchObject({ name: 'Platform ops', config, password: 'platform password', enabled: true }) + expect(platformCreate.url).toBeUndefined() + + await updatePlatformNotification('dest-1', 2, 'Platform ops', 'platform password', { config }) + const platformUpdate = JSON.parse(String(fetchMock.mock.calls[3][1]?.body)) as Record + expect(String(fetchMock.mock.calls[3][0])).toContain('/api/v1/platform/notifications/dest-1') + expect(platformUpdate).toMatchObject({ name: 'Platform ops', revision: 2, config }) + expect(platformUpdate.url).toBeUndefined() + }) }) describe('host explorer API contract', () => { diff --git a/src/api.ts b/src/api.ts index 70ee1ec9..a1cac650 100644 --- a/src/api.ts +++ b/src/api.ts @@ -23,6 +23,11 @@ export type NotificationDestination = { last_error_code?: string last_error_fingerprint?: string } +export type NotificationProvider = 'smtp' | 'discord' | 'ntfy' +export type NotificationProviderConfig = { + provider: NotificationProvider + fields: Record +} export type NotificationStatus = { deployment: number managed: number @@ -273,8 +278,11 @@ export const updateNotificationRouting = (destinations: string[], password: stri export const toggleNotificationUpdateAlert = (destinationID: string, enabled: boolean, password: string) => api('/notifications/update-routing', { method: 'PATCH', body: JSON.stringify({ destination_id: destinationID, enabled, password }) }) export const updateIncidentReminders = (settings: { enabled?: boolean; cadence?: IncidentReminderCadence }, password: string) => api<{ enabled: boolean; cadence: IncidentReminderCadence }>('/notifications/incident-reminders', { method: 'PUT', body: JSON.stringify({ ...settings, password }) }) export const getNotificationDestination = (id: string) => api(`/notifications/destinations/${encodeURIComponent(id)}`) -export const createNotificationDestination = (name: string, url: string, password: string, enabled = true) => api('/notifications/destinations', { method: 'POST', body: JSON.stringify({ name, url, password, enabled }) }) -export const updateNotificationDestination = (id: string, revision: number, name: string, password: string, options: { url?: string; enabled?: boolean } = {}) => api(`/notifications/destinations/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ name, revision, password, ...options }) }) +function notificationCredentialField(input: string | NotificationProviderConfig) { + return typeof input === 'string' ? { url: input } : { config: input } +} +export const createNotificationDestination = (name: string, credentials: string | NotificationProviderConfig, password: string, enabled = true) => api('/notifications/destinations', { method: 'POST', body: JSON.stringify({ name, ...notificationCredentialField(credentials), password, enabled }) }) +export const updateNotificationDestination = (id: string, revision: number, name: string, password: string, options: { url?: string; config?: NotificationProviderConfig; enabled?: boolean } = {}) => api(`/notifications/destinations/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ name, revision, password, ...options }) }) export const deleteNotificationDestination = (id: string, revision: number, password: string) => api(`/notifications/destinations/${encodeURIComponent(id)}`, { method: 'DELETE', body: JSON.stringify({ revision, password }) }) export const testNotificationDestination = (id: string) => api<{ sent: number }>(`/notifications/destinations/${encodeURIComponent(id)}/test`, { method: 'POST' }) @@ -388,8 +396,8 @@ export const renewPlatformAdminInvitation = (id: string, password: string) => ap export const deletePendingPlatformAdmin = (id: string, password: string) => api(`/platform/admins/${encodeURIComponent(id)}`, { method: 'DELETE', body: JSON.stringify({ password }) }) // The platform's own notification destinations. Like a unit's, their URLs are write-only. export const listPlatformNotifications = () => api('/platform/notifications') -export const createPlatformNotification = (name: string, url: string, password: string, enabled = true) => api('/platform/notifications', { method: 'POST', body: JSON.stringify({ name, url, password, enabled }) }) -export const updatePlatformNotification = (id: string, revision: number, name: string, password: string, options: { url?: string; enabled?: boolean } = {}) => api(`/platform/notifications/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name, revision, password, ...options }) }) +export const createPlatformNotification = (name: string, credentials: string | NotificationProviderConfig, password: string, enabled = true) => api('/platform/notifications', { method: 'POST', body: JSON.stringify({ name, ...notificationCredentialField(credentials), password, enabled }) }) +export const updatePlatformNotification = (id: string, revision: number, name: string, password: string, options: { url?: string; config?: NotificationProviderConfig; enabled?: boolean } = {}) => api(`/platform/notifications/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name, revision, password, ...options }) }) export const deletePlatformNotification = (id: string, revision: number, password: string) => api(`/platform/notifications/${encodeURIComponent(id)}`, { method: 'DELETE', body: JSON.stringify({ revision, password }) }) export const updatePlatformNotificationRouting = (destinations: string[], password: string) => api('/platform/notifications/update-routing', { method: 'PUT', body: JSON.stringify({ destinations, password }) }) export const togglePlatformNotificationUpdateAlert = (destinationID: string, enabled: boolean, password: string) => api('/platform/notifications/update-routing', { method: 'PATCH', body: JSON.stringify({ destination_id: destinationID, enabled, password }) }) diff --git a/src/components/NotificationDestinationConfig.tsx b/src/components/NotificationDestinationConfig.tsx new file mode 100644 index 00000000..56f024db --- /dev/null +++ b/src/components/NotificationDestinationConfig.tsx @@ -0,0 +1,118 @@ +import type { NotificationProvider as ProviderKind, NotificationProviderConfig } from '../api' + +export type NotificationProvider = ProviderKind | 'url' + +export type NotificationConfigDraft = { + provider: NotificationProvider + fields: Record +} + +export type NotificationCredentials = + | { url: string } + | { config: NotificationProviderConfig } + +export const initialNotificationConfigDraft = (): NotificationConfigDraft => ({ + provider: 'smtp', + fields: {}, +}) + +export function credentialsFromNotificationDraft(draft: NotificationConfigDraft): NotificationCredentials | null { + if (draft.provider === 'url') { + const url = draft.fields.url?.trim() ?? '' + return url ? { url } : null + } + return { + config: { + provider: draft.provider, + fields: { ...draft.fields }, + }, + } +} + +export function NotificationDestinationConfig({ + draft, + onChange, + idPrefix, + allowBlankURL = false, +}: { + draft: NotificationConfigDraft + onChange: (draft: NotificationConfigDraft) => void + idPrefix: string + allowBlankURL?: boolean +}) { + function changeProvider(provider: NotificationProvider) { + onChange({ provider, fields: {} }) + } + + function changeField(name: string, value: string) { + onChange({ ...draft, fields: { ...draft.fields, [name]: value } }) + } + + function field(name: string, label: string, options: { required?: boolean; type?: string; placeholder?: string; help?: string; min?: number; max?: number } = {}) { + const id = `${idPrefix}-${name}` + return + } + + return <> + + {draft.provider === 'smtp' &&
+
+ {field('host', 'SMTP server', { required: true, placeholder: 'mail.example.com' })} + {field('port', 'Port', { type: 'number', min: 1, max: 65535, placeholder: '25', help: 'Defaults to 25. StartTLS is enabled when the server supports it.' })} +
+
+ {field('from', 'From address', { required: true, placeholder: 'edgewatch@example.com' })} + {field('to', 'Recipients', { required: true, placeholder: 'ops@example.com, team@example.com', help: 'Separate multiple email addresses with commas.' })} +
+
+ {field('username', 'SMTP username', { placeholder: 'Optional' })} + {field('password', 'SMTP password', { type: 'password', placeholder: 'Optional' })} +
+
} + {draft.provider === 'discord' &&
+ {field('webhook_url', 'Discord webhook URL', { type: 'url', required: true, placeholder: 'https://discord.com/api/webhooks/…', help: 'Paste the webhook URL from your Discord channel integration.' })} +
} + {draft.provider === 'ntfy' &&
+
+ {field('server', 'ntfy server', { type: 'url', placeholder: 'https://ntfy.sh', help: 'Leave blank to use ntfy.sh.' })} + {field('topic', 'Topic', { required: true, placeholder: 'edgewatch-alerts' })} +
+
+ {field('username', 'Username', { placeholder: 'Optional' })} + {field('password', 'Password or token', { type: 'password', placeholder: 'Optional' })} +
+
} + {draft.provider === 'url' &&
+ {field('url', 'Shoutrrr URL', { type: 'url', required: true, placeholder: 'generic://host/path?disabletls=yes', help: 'The URL is encrypted after saving and cannot be read back.' })} +
} + +} diff --git a/src/pages/Notifications.behavior.test.tsx b/src/pages/Notifications.behavior.test.tsx index fa7031a0..41c533ae 100644 --- a/src/pages/Notifications.behavior.test.tsx +++ b/src/pages/Notifications.behavior.test.tsx @@ -61,6 +61,7 @@ describe('notification destination workflows', () => { await waitFor(() => expect(screen.getByText('Mattermost')).toBeInTheDocument()) fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'Alerts' } }) + fireEvent.change(screen.getByRole('combobox', { name: 'Notification service' }), { target: { value: 'url' } }) fireEvent.change(screen.getByLabelText(/^Shoutrrr URL/), { target: { value: 'generic://example.test/path' } }) const createForm = screen.getByRole('button', { name: 'Add destination' }).closest('form')! fireEvent.change(createForm.querySelector('input[type="password"]')!, { target: { value: 'administrator-password' } }) @@ -85,7 +86,68 @@ describe('notification destination workflows', () => { await waitFor(() => expect(deleteNotificationDestination).toHaveBeenCalledWith('destination-1', 4, 'administrator-password')) }) - // Only a new URL discards the alerts queued for a destination; a rename or + it('creates a Discord destination from its native webhook URL', async () => { + renderWithProviders() + await waitFor(() => expect(screen.getByText('Mattermost')).toBeInTheDocument()) + + fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'Discord alerts' } }) + fireEvent.change(screen.getByRole('combobox', { name: 'Notification service' }), { target: { value: 'discord' } }) + fireEvent.change(screen.getByLabelText(/^Discord webhook URL/), { target: { value: 'https://discord.com/api/webhooks/12345678/secret-token' } }) + const createForm = screen.getByRole('button', { name: 'Add destination' }).closest('form')! + fireEvent.change(createForm.querySelector('input[type="password"]')!, { target: { value: 'administrator-password' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add destination' })) + + await waitFor(() => expect(createNotificationDestination).toHaveBeenCalledWith( + 'Discord alerts', + { provider: 'discord', fields: { webhook_url: 'https://discord.com/api/webhooks/12345678/secret-token' } }, + 'administrator-password', + true, + )) + expect(screen.queryByDisplayValue('https://discord.com/api/webhooks/12345678/secret-token')).not.toBeInTheDocument() + }) + + it('creates an SMTP destination from server, sender, and recipient fields', async () => { + renderWithProviders() + await waitFor(() => expect(screen.getByText('Mattermost')).toBeInTheDocument()) + + fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'Mail alerts' } }) + fireEvent.change(screen.getByLabelText(/^SMTP server/), { target: { value: 'mail.example.test' } }) + fireEvent.change(screen.getByLabelText(/^Port/), { target: { value: '587' } }) + fireEvent.change(screen.getByLabelText(/^From address/), { target: { value: 'edgewatch@example.test' } }) + fireEvent.change(screen.getByLabelText(/^Recipients/), { target: { value: 'ops@example.test, oncall@example.test' } }) + fireEvent.change(screen.getByLabelText(/^SMTP username/), { target: { value: 'edgewatch' } }) + fireEvent.change(screen.getByLabelText(/^SMTP password/), { target: { value: 'smtp-token' } }) + fireEvent.change(screen.getByLabelText(/^Password confirmation/), { target: { value: 'administrator-password' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add destination' })) + + await waitFor(() => expect(createNotificationDestination).toHaveBeenCalledWith( + 'Mail alerts', + { provider: 'smtp', fields: { host: 'mail.example.test', port: '587', from: 'edgewatch@example.test', to: 'ops@example.test, oncall@example.test', username: 'edgewatch', password: 'smtp-token' } }, + 'administrator-password', + true, + )) + }) + + it('creates an ntfy destination using the default server when left blank', async () => { + renderWithProviders() + await waitFor(() => expect(screen.getByText('Mattermost')).toBeInTheDocument()) + + fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'Push alerts' } }) + fireEvent.change(screen.getByRole('combobox', { name: 'Notification service' }), { target: { value: 'ntfy' } }) + fireEvent.change(screen.getByLabelText(/^Topic/), { target: { value: 'edgewatch-alerts' } }) + fireEvent.change(screen.getByLabelText(/^Password or token/), { target: { value: 'ntfy-token' } }) + fireEvent.change(screen.getByLabelText(/^Password confirmation/), { target: { value: 'administrator-password' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add destination' })) + + await waitFor(() => expect(createNotificationDestination).toHaveBeenCalledWith( + 'Push alerts', + { provider: 'ntfy', fields: { topic: 'edgewatch-alerts', password: 'ntfy-token' } }, + 'administrator-password', + true, + )) + }) + + // Only replacement credentials discard the alerts queued for a destination; a rename or // a pause saved through the edit form keeps them. async function saveEdit(change: (form: HTMLFormElement) => void) { renderWithProviders() @@ -114,7 +176,7 @@ describe('notification destination workflows', () => { it('reports discarded alerts after an edit that replaces the URL', async () => { const banner = await saveEdit(form => fireEvent.change(form.querySelector('input[type="url"]')!, { target: { value: ' generic://example.test/rotated ' } })) expect(updateNotificationDestination).toHaveBeenCalledWith('destination-1', 4, 'Mattermost', 'administrator-password', { enabled: true, url: 'generic://example.test/rotated' }) - expect(banner).toBe('Notification destination updated. Alerts queued for the previous URL were discarded.') + expect(banner).toBe('Notification destination updated. Alerts queued for the previous credentials were discarded.') }) it('saves a unit destination against the revision opened and reloads after a conflict', async () => { diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index dc477072..131c4884 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -8,6 +8,7 @@ import { listNotificationDestinations, NotificationDestination, type NotificationDestinationsResponse, + type NotificationProviderConfig, type IncidentReminderCadence, type NotificationUpdateRouting, testNotificationDestination, @@ -18,12 +19,18 @@ import { } from '../api' import { ActionDialog } from '../components/ActionDialog' import { ErrorNotice } from '../components/ErrorNotice' +import { + credentialsFromNotificationDraft, + initialNotificationConfigDraft, + NotificationDestinationConfig, + type NotificationConfigDraft, +} from '../components/NotificationDestinationConfig' import { formatDateTime } from '../format' type EditState = { id: string name: string - url: string + configuration: NotificationConfigDraft enabled: boolean revision?: number } @@ -45,8 +52,8 @@ type DestinationFeedback = { message?: string; error?: string } export type NotificationScope = { queryKey: readonly unknown[] list: () => Promise - create: (name: string, url: string, password: string, enabled: boolean) => Promise - update: (id: string, revision: number, name: string, password: string, options: { url?: string; enabled?: boolean }) => Promise + create: (name: string, credentials: string | NotificationProviderConfig, password: string, enabled: boolean) => Promise + update: (id: string, revision: number, name: string, password: string, options: { url?: string; config?: NotificationProviderConfig; enabled?: boolean }) => Promise remove: (id: string, revision: number, password: string) => Promise /** Sends a test message; the platform's destinations have none. */ test?: (id: string) => Promise @@ -90,7 +97,7 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco const client = useQueryClient() const destinations = useQuery({ queryKey: scope.queryKey, queryFn: scope.list, refetchInterval: 30_000 }) const [name, setName] = useState('') - const [url, setURL] = useState('') + const [configuration, setConfiguration] = useState(initialNotificationConfigDraft) const [enabled, setEnabled] = useState(true) const [password, setPassword] = useState('') const [edit, setEdit] = useState(null) @@ -140,18 +147,20 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco async function create(event: FormEvent) { event.preventDefault() resetFeedback() - if (!name.trim() || !url.trim() || !password) { - setError('Name, Shoutrrr URL, and password confirmation are required.') + const credentials = credentialsFromNotificationDraft(configuration) + if (!name.trim() || !credentials || !password) { + setError('Name, provider details, and password confirmation are required.') return } setBusy('create') try { - await scope.create(name.trim(), url.trim(), password, enabled) + const providerInput = 'url' in credentials ? credentials.url : credentials.config + await scope.create(name.trim(), providerInput, password, enabled) setName('') - setURL('') + setConfiguration(initialNotificationConfigDraft()) setPassword('') setEnabled(true) - setMessage('Notification destination added. The URL is stored encrypted and will not be shown again.') + setMessage('Notification destination added. Credentials are stored encrypted and cannot be read back.') await client.invalidateQueries({ queryKey: scope.queryKey }) } catch (err) { reportError(err, 'Could not add notification destination.') @@ -163,7 +172,7 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco function beginEdit(destination: NotificationDestination) { resetFeedback() clearRowFeedback(destination.id) - setEdit({ id: destination.id, name: destination.name, url: '', enabled: destination.enabled, revision: destination.revision }) + setEdit({ id: destination.id, name: destination.name, configuration: { provider: 'url', fields: {} }, enabled: destination.enabled, revision: destination.revision }) } function askPassword(title: string, description: string, confirmLabel: string) { @@ -190,13 +199,17 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco if (confirmation === null) return setBusy(edit.id) try { - const options: { url?: string; enabled?: boolean } = { enabled: edit.enabled } - if (edit.url.trim()) options.url = edit.url.trim() + const options: { url?: string; config?: NotificationProviderConfig; enabled?: boolean } = { enabled: edit.enabled } + const credentials = credentialsFromNotificationDraft(edit.configuration) + if (credentials) { + if ('url' in credentials) options.url = credentials.url + else options.config = credentials.config + } await scope.update(edit.id, edit.revision, edit.name.trim(), confirmation, options) setEdit(null) - // Only a new URL discards the queued alerts; a rename or a pause keeps - // them for delivery. - reportRowMessage(edit.id, options.url ? 'Notification destination updated. Alerts queued for the previous URL were discarded.' : 'Notification destination updated.') + // Only replacement credentials discard queued alerts; a rename or a + // pause keeps them for delivery. + reportRowMessage(edit.id, options.url || options.config ? 'Notification destination updated. Alerts queued for the previous credentials were discarded.' : 'Notification destination updated.') await client.invalidateQueries({ queryKey: scope.queryKey }) } catch (err) { if (err instanceof APIError && err.code === 'conflict') { @@ -209,7 +222,7 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco const conflictMessage = latest ? 'This destination is no longer available for editing.' : 'This destination was removed in another session.' reportRowError(edit.id, new Error(conflictMessage), conflictMessage) } else { - setEdit({ id: latest.id, name: latest.name, url: '', enabled: latest.enabled, revision: latest.revision }) + setEdit({ id: latest.id, name: latest.name, configuration: { provider: 'url', fields: {} }, enabled: latest.enabled, revision: latest.revision }) const conflictMessage = 'This destination changed in another session. The latest values are loaded; review them and save again.' reportRowError(edit.id, new Error(conflictMessage), conflictMessage) } @@ -267,7 +280,7 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco setBusy(`test:${destination.id}`) try { await scope.test(destination.id) - reportRowMessage(destination.id, `Test sent to ${destination.name}.`) + reportRowMessage(destination.id, `Test send completed for ${destination.name}. Check that the message arrived.`) } catch (err) { reportRowError(destination.id, err, 'Notification test failed.') } finally { @@ -372,9 +385,10 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco } {canManage &&
-

Add destination

Paste one complete Shoutrrr URL. It is never returned by the API.

+

Add destination

Choose a notification service and enter its connection details. Credentials are never returned by the API.

-
+ +
{message &&
{message}
} @@ -421,7 +435,13 @@ function DestinationRow({ destination, editing, busy, canManage, updateAlertSele
{deployment ? : }
{destination.name}{destination.provider || 'unknown provider'} · {deployment ? 'deployment configuration' : `revision ${destination.revision}`}
{canManage && }
{destination.locked ? Locked : deployment ? Read-only : {destination.enabled ? 'Enabled' : 'Paused'}}
{destination.locked &&
Credentials cannot be decrypted ({destination.error_code ?? 'key unavailable'}). Restore the key before editing or enabling it; if it cannot be recovered, remove and recreate this destination.
} - {!deployment && editing &&
} + {!deployment && editing &&
+ + onChange({ ...editing, configuration })} allowBlankURL /> +

Leave the advanced URL blank to keep the encrypted credentials. Choosing a provider and filling its fields replaces them completely.

+ +
+ } {canManage && !deployment && !editing &&
{onTest && }
} {feedback?.message &&
{feedback.message}
} {feedback?.error &&
{feedback.error}
} diff --git a/src/pages/platform/PlatformPages.test.tsx b/src/pages/platform/PlatformPages.test.tsx index 172dd4b6..aedf9084 100644 --- a/src/pages/platform/PlatformPages.test.tsx +++ b/src/pages/platform/PlatformPages.test.tsx @@ -330,7 +330,7 @@ describe('platform notifications', () => { vi.mocked(togglePlatformNotificationUpdateAlert).mockResolvedValue({ configured: true, destinations: ['p-ops'] }) }) - it('manages the platform’s own destinations with write-only URLs and routes update alerts to none by default', async () => { + it('manages the platform’s own destinations with write-only credentials and routes update alerts to none by default', async () => { renderWithProviders() expect(await screen.findByText('Operations')).toBeInTheDocument() expect(screen.getByText(/Each business unit manages its own destinations/)).toBeInTheDocument() @@ -340,6 +340,7 @@ describe('platform notifications', () => { expect(document.querySelector('.notification-config-import')).toBeNull() fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: 'Pager' } }) + fireEvent.change(screen.getByRole('combobox', { name: 'Notification service' }), { target: { value: 'url' } }) fireEvent.change(screen.getByLabelText(/^Shoutrrr URL/), { target: { value: 'generic://pager.example.test/hook' } }) fireEvent.change(screen.getByLabelText(/^Password confirmation/), { target: { value: 'my-password' } }) await act(async () => { @@ -347,7 +348,7 @@ describe('platform notifications', () => { await Promise.resolve() }) await waitFor(() => expect(createPlatformNotification).toHaveBeenCalledWith('Pager', 'generic://pager.example.test/hook', 'my-password', true)) - expect(await screen.findByText(/The URL is stored encrypted and will not be shown again/)).toBeInTheDocument() + expect(await screen.findByText(/Credentials are stored encrypted and cannot be read back/)).toBeInTheDocument() fireEvent.click(screen.getByRole('checkbox', { name: 'Enable update alerts for Operations' })) await confirmWithPassword('Account password') @@ -377,13 +378,13 @@ describe('platform notifications', () => { expect(within(security).queryByText('Delivery health')).toBeNull() }) - it('reports discarded alerts only when an edit replaces the URL', async () => { + it('reports discarded alerts only when an edit replaces credentials', async () => { renderWithProviders() const operations = (await screen.findByText('Operations')).closest('.notification-row') as HTMLElement const edits: { change: (form: HTMLFormElement) => void; options: { url?: string; enabled?: boolean }; name: string; banner: string }[] = [ { change: form => fireEvent.change(within(form).getByDisplayValue('Operations'), { target: { value: 'Operations renamed' } }), options: { enabled: true }, name: 'Operations renamed', banner: 'Notification destination updated.' }, { change: form => fireEvent.click(within(form).getByRole('checkbox')), options: { enabled: false }, name: 'Operations', banner: 'Notification destination updated.' }, - { change: form => fireEvent.change(within(form).getByPlaceholderText('Leave blank to keep the encrypted URL'), { target: { value: 'generic://pager.example.test/rotated' } }), options: { enabled: true, url: 'generic://pager.example.test/rotated' }, name: 'Operations', banner: 'Notification destination updated. Alerts queued for the previous URL were discarded.' }, + { change: form => fireEvent.change(within(form).getByPlaceholderText('generic://host/path?disabletls=yes'), { target: { value: 'generic://pager.example.test/rotated' } }), options: { enabled: true, url: 'generic://pager.example.test/rotated' }, name: 'Operations', banner: 'Notification destination updated. Alerts queued for the previous credentials were discarded.' }, ] for (const edit of edits) { vi.mocked(updatePlatformNotification).mockClear()