35c6ff5a71
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.
203 lines
7.4 KiB
Go
203 lines
7.4 KiB
Go
// 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
|
||
}
|