08889cad88
Telegram was the only way off this box, and it is not a direct path: it
needs api.telegram.org, a socks relay on the host and a matching ufw rule.
Each of those three has failed once, and when they do a sev4 nudge has
nowhere to go. ntfy shares none of them.
The spare is the smaller half of it. The routing table already sends
sev3-away nudges and away reminders to ntfy and to nothing else, so with no
block configured those two routes hit a nil sink in DispatchNudge and
DispatchReminder and are skipped — no log line, no delivery_attempts row.
An away reminder is worse than dropped: out stays empty, so MarkReminder
never runs and it re-fires every tick without ever being delivered.
Owner's call, 07-08-2026: ntfy.kvmx.ru, topic maven.
The sink now takes a bearer token, which is what that server wants and what
it could not do before. ntfy scopes a token to one topic and to write-only,
so a popped sink can push to the maven topic and cannot read it back. Basic
auth stays for a server with no tokens; configuring both is refused rather
than resolved by guessing.
Config keys got json tags. docs/operations.md has documented this block as
base_url/topic since before it existed, and the untagged struct would only
have answered to BaseURL/Topic — the documented config would have parsed
into an empty one.
The token is a ${NTFY_TOKEN} expansion from the gitignored
deploy/telegram.env, beside the telegram secrets. TestDeployConfigLoads now
fails if the block goes missing, because deleting it is how you turn the
reach off and the two silent routes are what that costs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
171 lines
6.1 KiB
Go
171 lines
6.1 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 {
|
||
// 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
|
||
}
|