Never send the full nudge body off-box (#368)
The away sinks fell back to the whole Body when Summary was empty. ntfy and telegram leave the box, and the 0.8B phraser drops fields regularly, so that fallback could push full detail off the machine. The dispatcher already strips detail from away sendables. This exports that one rule as delivery.AwayMessage and has both sinks use it, so a sink can't leak the body on its own either: empty Summary means a generic line plus the rule name, never the body. The two sink tests named TestSendFallsBackToBodyWhenSummaryEmpty asserted the old, wrong behaviour, so they are rewritten to assert the generic line. TestSendRejectsEmptyMessage is likewise replaced: an away message can no longer be empty, so the sink has nothing left to reject. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user