// 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 delivery.AwayMessage — the minimal-body rule from the // spec ("disk low on homesrv," not detail; no shoulder-surf exfil through // the relay). the dispatcher already strips detail off away sendables; the // sink uses the same helper so it can't leak the body on its own either. // // ntfy is a self-hosted server with deny-all auth — ntfy.kvmx.ru as of // 07-08-2026, reached directly, not through the socks relay telegram needs. // maven publishes with a write-only token scoped to its own topic; the // credential is a delivery-config secret, not a db key. a popped ntfy sink // can push spam to that one topic, nothing else — it cannot read the topic // back and it never holds the sqlcipher key. // // this is the second reach, and the reason there is one is that telegram was // the only one (V-649). telegram needs api.telegram.org, a socks relay on the // host and a matching ufw rule, three things in series that have each broken // once. ntfy shares none of them. 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 — the ntfy server, no trailing path. Required. BaseURL string `json:"base_url"` // Topic — where maven publishes. Required. All maven notifications land // on this one topic; severity rides the Priority header, not the topic. Topic string `json:"topic"` // Token — an ntfy access token, sent as a bearer. This is the preferred // credential: ntfy scopes a token to a topic and to write-only, so a // popped sink can push to this one topic and cannot read it back or // touch another. Revoking it does not disturb a password anyone else // uses. Mutually exclusive with Username. Token string `json:"token,omitempty"` // Username, Password — basic auth, for a server that has no tokens. // Empty username means no credential is sent at all, which a deny-all // server rejects. Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` // Timeout — per-request; 0 = DefaultTimeout. A dead server must not hang // the tick loop. Timeout time.Duration `json:"-"` } 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") } // Refuse rather than pick. Two credentials configured means someone // intended one of them, and guessing which would send the other nowhere // and leave a working config that is not the one they wrote. if cfg.Token != "" && cfg.Username != "" { return nil, fmt.Errorf("ntfysink: set Token or Username, not both") } 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 minimal away // message (never the full 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 { // never fall back to d.Body: ntfy leaves the box, so an empty summary gets // a generic line instead of the full detail. body := delivery.AwayMessage(d) 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.Token != "" { req.Header.Set("Authorization", "Bearer "+s.cfg.Token) } else 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 1–5 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 1–5. // // 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 sev1–2 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 }