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
8 changes: 5 additions & 3 deletions docs/src/content/docs/administration/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions docs/src/content/docs/getting-started/first-scan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions docs/src/content/docs/reference/api-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions docs/src/content/docs/user-guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
239 changes: 239 additions & 0 deletions internal/notify/provider_config.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading