Files
Maven/internal/delivery/ntfysink/ntfysink.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

203 lines
7.4 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 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 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
}