Files
Maven/internal/delivery/ntfysink/ntfysink.go
T
2026-07-03 00:32:48 +02:00

143 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package ntfysink implements delivery.Sink for the ntfy push channel.
//
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
// nudges when present (alongside voice), and reminders when away. the
// message body is the Sendable's Summary — the minimal-body rule from the
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
// the relay). voice gets Body; away channels get Summary, enforced at the
// sink so a phraser bug can't exfil.
//
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
// with a dedicated user (write-only to maven-* topics) — the credential is a
// delivery-config secret, not a db key; a popped ntfy sink can push spam to
// your phone, nothing else. matches the module key-isolation invariant: the
// sink never holds the sqlcipher key.
package ntfysink
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/loop"
)
// Config — ntfy publish config. the daemon wires this from its config file;
// the credential lives in the daemon's config (or a systemd credential),
// never in the binary.
type Config struct {
BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path)
Topic string // e.g. maven (all maven notifications land here)
Username string // basic auth; empty = anonymous (won't work with deny-all)
Password string // basic auth
Timeout time.Duration // per-request; 0 = DefaultTimeout
}
const DefaultTimeout = 10 * time.Second
// Sink — implements delivery.Sink via ntfy HTTP publish. one POST per Send.
// no retry (the dispatcher + daemon decide retry policy); no streaming.
type Sink struct {
cfg Config
hc *http.Client
}
// New validates the config and builds the sink. BaseURL and Topic are
// required; auth is optional (but deny-all servers reject unauthed publishes).
func New(cfg Config) (*Sink, error) {
if cfg.BaseURL == "" {
return nil, fmt.Errorf("ntfysink: BaseURL is required")
}
if _, err := url.Parse(cfg.BaseURL); err != nil {
return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err)
}
if cfg.Topic == "" {
return nil, fmt.Errorf("ntfysink: Topic is required")
}
to := cfg.Timeout
if to == 0 {
to = DefaultTimeout
}
return &Sink{
cfg: cfg,
hc: &http.Client{Timeout: to},
}, nil
}
// Send publishes one notification to ntfy. the body is the Sendable's Summary
// (minimal body); Title is "maven" (consistent sender identity on the lock
// screen — the content is in the body). Priority maps from severity/kind so
// the phone client can ring differently for an alarm vs a soft ops nudge.
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
body := d.Summary
if body == "" {
body = d.Body // terse full message beats no message
}
if body == "" {
return fmt.Errorf("ntfysink: empty message for %s", d.Channel)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
if err != nil {
return fmt.Errorf("ntfysink: build request: %w", err)
}
req.Header.Set("Title", "maven")
req.Header.Set("Priority", priorityFor(d).String())
if s.cfg.Username != "" {
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
}
resp, err := s.hc.Do(req)
if err != nil {
return fmt.Errorf("ntfysink: publish: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
}
return nil
}
func (s *Sink) topicURL() string {
return strings.TrimRight(s.cfg.BaseURL, "/") + "/" + s.cfg.Topic
}
// ntfyPriority — ntfy's 15 priority scale.
type ntfyPriority int
const (
prioMin ntfyPriority = 1
prioLow ntfyPriority = 2
prioDefault ntfyPriority = 3
prioHigh ntfyPriority = 4
prioMax ntfyPriority = 5
)
func (p ntfyPriority) String() string { return fmt.Sprintf("%d", int(p)) }
// priorityFor — maps maven's (kind, severity) to ntfy's 15.
//
// reminders are user-stated intent ("wake me 7") → high (4); they bypassed the
// gate to reach you, make them ring. sev4 (ops hard, present — away goes to
// telegram) → max (5): a disk-fire alarm that also pushes to your watch. sev3
// (ops soft, away) → high (4): it held through away for a reason. care nudges
// never reach ntfy (they drop on away), so no sev12 mapping here.
func priorityFor(d delivery.Sendable) ntfyPriority {
if d.Kind == delivery.KindReminder {
return prioHigh
}
if d.Severity >= loop.Sev4 {
return prioMax
}
if d.Severity == loop.Sev3 {
return prioHigh
}
return prioDefault
}