// 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 { // Disabled keeps a written endpoint explicitly dark. This is distinct from // an expanded-empty credential: the latter is a configuration error, while // this flag records an operator decision to use another delivery reach until // credentials are provisioned. Disabled bool `json:"disabled,omitempty"` // 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 } // Validate checks one configuration without constructing a client. A disabled // block is the only state in which credentials may be empty. Maven's ntfy // reach is private, and accepting an accidental anonymous configuration turns // a missing environment variable into an endless 403 retry loop. func Validate(cfg Config) error { if cfg.Disabled { return nil } if strings.TrimSpace(cfg.BaseURL) == "" { return fmt.Errorf("ntfysink: BaseURL is required while enabled") } if _, err := url.Parse(cfg.BaseURL); err != nil { return fmt.Errorf("ntfysink: bad BaseURL: %w", err) } if strings.TrimSpace(cfg.Topic) == "" { return fmt.Errorf("ntfysink: Topic is required while enabled") } // 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 strings.TrimSpace(cfg.Token) != "" && (strings.TrimSpace(cfg.Username) != "" || cfg.Password != "") { return fmt.Errorf("ntfysink: set Token or Username/Password, not both") } if strings.TrimSpace(cfg.Token) == "" { if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" { return fmt.Errorf("ntfysink: Token or Username/Password is required while enabled") } } return nil } // New validates the config and builds the sink. Disabled configs belong at the // daemon wiring boundary and cannot accidentally become live sinks. func New(cfg Config) (*Sink, error) { if cfg.Disabled { return nil, fmt.Errorf("ntfysink: config is disabled") } if err := Validate(cfg); err != nil { return nil, err } 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)) if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return fmt.Errorf("%w: ntfysink: credentials rejected (%d): %s", delivery.ErrPermanent, resp.StatusCode, strings.TrimSpace(string(rb))) } 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 }