Never send the nudge body off-box; record a suppressed nudge (#368, #370) #33

Closed
claude wants to merge 4 commits from overnight/delivery-boundary into overnight/address-check
5 changed files with 61 additions and 47 deletions
Showing only changes of commit e9ff2c4912 - Show all commits
+7
View File
@@ -396,6 +396,13 @@ func messageForChannel(s Sendable) string {
if !isAway(s.Channel) {
return s.Body
}
return AwayMessage(s)
}
// AwayMessage — the only text an off-box channel may ever carry. Exported so
// the away sinks share this one rule instead of each inventing a fallback: the
// summary if we have one, otherwise a fixed generic line. Never the body.
func AwayMessage(s Sendable) string {
if s.Summary != "" {
return s.Summary
}
+9 -13
View File
@@ -2,10 +2,10 @@
//
// 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
// 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). voice gets Body; away channels get Summary, enforced at the
// sink so a phraser bug can't exfil.
// 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 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
@@ -69,18 +69,14 @@ func New(cfg Config) (*Sink, error) {
}, 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
// 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 {
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)
}
// 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 {
+16 -9
View File
@@ -147,9 +147,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
}
}
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
// a terse full message is better than no message; the phraser should
// produce a summary for away-bound severities, but don't silently drop.
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
// #368: this used to fall back to the full body. ntfy leaves the box, so
// an empty summary gets a fixed generic line plus the rule name instead.
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
@@ -160,12 +160,15 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
t.Fatalf("Send: %v", err)
}
_, _, body, _, _, _ := rs.snapshot()
if body != s.Body {
t.Fatalf("fallback body: want %q, got %q", s.Body, body)
want := delivery.GenericAwayMessage + ": service_down"
if body != want {
t.Fatalf("body: want %q, got %q", want, body)
}
}
func TestSendRejectsEmptyMessage(t *testing.T) {
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
// with nothing at all to say we still send the generic line — an away
// channel can never carry detail, but it also never goes out blank.
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
@@ -173,9 +176,13 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
s := nudgeSendable(loop.Sev3, "")
s.Body = ""
err := sink.Send(context.Background(), s)
if err == nil {
t.Fatal("want error for empty message")
s.RuleName = ""
if err := sink.Send(context.Background(), s); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, body, _, _, _ := rs.snapshot()
if body != delivery.GenericAwayMessage {
t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body)
}
}
+12 -16
View File
@@ -2,11 +2,12 @@
//
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
// at 2am routes to telegram, repeat til ack." 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. additionally, protect_content=true is passed on every send so the
// message can't be forwarded out of the chat — locks the minimal body further.
// 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. additionally,
// protect_content=true is passed on every send so the message can't be
// forwarded out of the chat — locks the minimal body further.
//
// telegram's bot API is region-restricted for this homesrv — direct egress to
// api.telegram.org is unreliable. the spec's "away channels leave the box —
@@ -140,18 +141,13 @@ type telegramResp struct {
}
// Send publishes one message to the configured telegram chat. the body is the
// Sendable's Summary (minimal body); empty Summary falls back to Body (terse
// full message beats no message). protect_content=true so a phraser bug (Body
// leaking detail through Summary) can't be forwarded onward by the user or a
// chat observer — locks the minimal-body rule at the channel's own last mile.
// minimal away message (never the full body). protect_content=true so even
// that can't be forwarded onward by the user or a chat observer — locks the
// minimal-body rule at the channel's own last mile.
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
body := d.Summary
if body == "" {
body = d.Body
}
if body == "" {
return fmt.Errorf("telegramsink: empty message for %s", d.Channel)
}
// never fall back to d.Body: telegram leaves the box, so an empty summary
// gets a generic line instead of the full detail.
body := delivery.AwayMessage(d)
payload := sendMessageReq{
ChatID: s.cfg.ChatID,
@@ -173,9 +173,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
}
}
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
// terse full message beats none; the phraser should produce a summary for
// away-bound severities, but don't silently drop.
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
// #368: this used to fall back to the full body. telegram leaves the box,
// so an empty summary gets a fixed generic line plus the rule name.
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
@@ -188,12 +188,14 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
_, _, body, _, _ := rs.snapshot()
var req sendMessageReq
_ = json.Unmarshal([]byte(body), &req)
if req.Text != s.Body {
t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text)
want := delivery.GenericAwayMessage + ": service_down"
if req.Text != want {
t.Fatalf("text: want %q, got %q", want, req.Text)
}
}
func TestSendRejectsEmptyMessage(t *testing.T) {
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
// with nothing at all to say we still send the generic line.
rs := newRecordingServer(t, 200, "")
srv := httptest.NewServer(rs.handler())
defer srv.Close()
@@ -201,9 +203,15 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
sink, _ := New(sinkCfg(srv.URL))
s := nudgeSendable(loop.Sev4, "")
s.Body = ""
err := sink.Send(context.Background(), s)
if err == nil {
t.Fatal("want error for empty message")
s.RuleName = ""
if err := sink.Send(context.Background(), s); err != nil {
t.Fatalf("Send: %v", err)
}
_, _, body, _, _ := rs.snapshot()
var req sendMessageReq
_ = json.Unmarshal([]byte(body), &req)
if req.Text != delivery.GenericAwayMessage {
t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text)
}
}