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 / // 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 // Body carries the event verb so the body alone is unambiguous when // it shows up on a phone lockscreen without the title. body := p.Message switch p.Event { case EventResolved: body = "Resolved · " + p.Message case EventAcknowledged: body = "Acknowledged · " + p.Message } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body)) if err != nil { return 0, 0, fmt.Errorf("ntfy: build request: %w", err) } // Title prefix tracks the event so raise vs ack vs resolve are // visually distinct in the ntfy notification list. verb := "raised" switch p.Event { case EventAcknowledged: verb = "ack" case EventResolved: verb = "resolved" case EventTest: verb = "test" } req.Header.Set("Content-Type", "text/plain") req.Header.Set("Title", fmt.Sprintf("[%s · %s] %s %s", verb, p.Severity, p.HostName, p.Kind)) req.Header.Set("Tags", verb+","+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" } }