feaeff217d
CI / Build (windows/amd64) (pull_request) Successful in 22s
CI / Build (linux/amd64) (pull_request) Successful in 22s
CI / Build (linux/arm64) (pull_request) Successful in 21s
CI / Lint (pull_request) Successful in 1m12s
CI / Test (linux/amd64) (pull_request) Successful in 1m18s
Self-hosted ntfy that doesn't expose a token-mint endpoint can still authenticate over HTTP Basic. Add Username + Password fields to NtfyConfig; the channel sends 'Authorization: Basic …' when token is empty and username is set. Token wins when both are configured. Form-side: two new optional fields next to the access token, with the same write-only placeholder treatment as smtp_password (blank on edit means 'keep stored value'). Username is round-tripped on edit; password is masked.
116 lines
3.7 KiB
Go
116 lines
3.7 KiB
Go
package notification
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// NtfyConfig is the per-channel JSON shape stored AEAD-encrypted in
|
|
// notification_channels.config. AccessToken takes precedence over
|
|
// (Username, Password) when both are set; supply one or the other for
|
|
// self-hosted ntfy that requires auth.
|
|
type NtfyConfig struct {
|
|
ServerURL string `json:"server_url"`
|
|
Topic string `json:"topic"`
|
|
AccessToken string `json:"access_token,omitempty"`
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
// NtfyChannel delivers alerts to an ntfy server using POST with
|
|
// ntfy-specific headers (Title, Priority, Tags, Click). One instance
|
|
// per configured channel row. Reused across sends — http.Client is
|
|
// goroutine-safe.
|
|
type NtfyChannel struct {
|
|
cfg NtfyConfig
|
|
defaultPriority string // "min"/"low"/"default"/"high"/"urgent" or ""
|
|
client *http.Client
|
|
}
|
|
|
|
// NewNtfyChannel builds an ntfy channel with a 5s http.Client timeout.
|
|
// defaultPriority is the channel-configured fallback when no
|
|
// severity-specific mapping applies; pass "" to use the built-in
|
|
// fallbacks (4 for warning, 3 for everything else).
|
|
func NewNtfyChannel(cfg NtfyConfig, defaultPriority string) *NtfyChannel {
|
|
if cfg.ServerURL == "" {
|
|
cfg.ServerURL = "https://ntfy.sh"
|
|
}
|
|
return &NtfyChannel{
|
|
cfg: cfg,
|
|
defaultPriority: defaultPriority,
|
|
client: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
}
|
|
|
|
// Kind returns "ntfy" for log enrichment and dispatcher routing.
|
|
func (c *NtfyChannel) Kind() string { return "ntfy" }
|
|
|
|
// Send delivers the payload as a plain-text POST to <server>/<topic>
|
|
// with ntfy headers. Returns (statusCode, latency, err). 4xx/5xx
|
|
// responses are returned as errors with the status code set.
|
|
func (c *NtfyChannel) Send(ctx context.Context, p Payload) (int, time.Duration, error) {
|
|
server := strings.TrimRight(c.cfg.ServerURL, "/")
|
|
url := server + "/" + c.cfg.Topic
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(p.Message))
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("ntfy: build request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "text/plain")
|
|
req.Header.Set("Title", fmt.Sprintf("[%s] %s %s", p.Severity, p.HostName, p.Kind))
|
|
req.Header.Set("Tags", p.Severity+","+p.Kind)
|
|
req.Header.Set("Priority", priorityForSeverity(p.Severity, c.defaultPriority))
|
|
if p.Link != "" {
|
|
req.Header.Set("Click", p.Link)
|
|
}
|
|
switch {
|
|
case c.cfg.AccessToken != "":
|
|
req.Header.Set("Authorization", "Bearer "+c.cfg.AccessToken)
|
|
case c.cfg.Username != "":
|
|
creds := c.cfg.Username + ":" + c.cfg.Password
|
|
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(creds)))
|
|
}
|
|
|
|
t0 := time.Now()
|
|
res, err := c.client.Do(req)
|
|
latency := time.Since(t0)
|
|
if err != nil {
|
|
return 0, latency, fmt.Errorf("ntfy: do: %w", err)
|
|
}
|
|
defer func() { _ = res.Body.Close() }()
|
|
// Drain body to keep the connection reusable.
|
|
_, _ = io.Copy(io.Discard, res.Body)
|
|
if res.StatusCode >= 400 {
|
|
return res.StatusCode, latency, fmt.Errorf("ntfy: http %d", res.StatusCode)
|
|
}
|
|
return res.StatusCode, latency, nil
|
|
}
|
|
|
|
// priorityForSeverity maps a severity string to an ntfy numeric priority
|
|
// string. critical always returns "5" regardless of defaultPri. For
|
|
// other severities, defaultPri is returned when non-empty, otherwise
|
|
// "4" for warning and "3" for everything else.
|
|
func priorityForSeverity(severity, defaultPri string) string {
|
|
switch severity {
|
|
case "critical":
|
|
return "5"
|
|
case "warning":
|
|
if defaultPri != "" {
|
|
return defaultPri
|
|
}
|
|
return "4"
|
|
default:
|
|
if defaultPri != "" {
|
|
return defaultPri
|
|
}
|
|
return "3"
|
|
}
|
|
}
|