143 lines
4.6 KiB
Go
143 lines
4.6 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 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 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
|
||
}
|