From 08889cad885883cb523f7ac804d187240f6b3285 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 7 Aug 2026 02:16:18 +0400 Subject: [PATCH] Give the box a second reach (V-649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb --- deploy/mavend.json | 19 ++++++++ deploy/telegram.env.example | 9 +++- docs/operations.md | 14 +++++- internal/config/deployconfig_test.go | 13 +++++ internal/delivery/ntfysink/ntfysink.go | 54 ++++++++++++++++----- internal/delivery/ntfysink/ntfysink_test.go | 31 ++++++++++++ 6 files changed, 126 insertions(+), 14 deletions(-) diff --git a/deploy/mavend.json b/deploy/mavend.json index 4a855d1..a7371e3 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -25,6 +25,25 @@ "llm_nudges": false }, + "//ntfy": [ + "The second reach (V-649). Until 07-08-2026 telegram was the only one, and", + "telegram needs api.telegram.org, the socks relay below and a matching ufw", + "rule — three things in series that have each failed once, and when they do", + "a sev4 nudge has nowhere to go. ntfy shares none of them: it is reached", + "directly, no relay.", + "It is not only a spare. The routing table sends sev3-away and away", + "reminders here and NOWHERE else, so with this block absent those two", + "routes hit a nil sink and vanish without a log or an outbox row.", + "The credential is an ntfy access token, scoped write-only to this one", + "topic, so a popped sink can push to it and cannot read it back. Set it in", + "deploy/telegram.env beside the telegram secrets; that file is gitignored." + ], + "ntfy": { + "base_url": "https://ntfy.kvmx.ru", + "topic": "maven", + "token": "${NTFY_TOKEN}" + }, + "telegram": { "bot_token": "${TELEGRAM_BOT_TOKEN}", "chat_id": "${TELEGRAM_CHAT_ID}", diff --git a/deploy/telegram.env.example b/deploy/telegram.env.example index edf8f73..8542bcf 100644 --- a/deploy/telegram.env.example +++ b/deploy/telegram.env.example @@ -1,5 +1,12 @@ -# Telegram bot token and chat ID for mavend's away-channel reach. +# Secrets for mavend's away-channel reaches. The file is still called +# telegram.env because compose names it that; it holds both reaches now. # Copy this file to deploy/telegram.env and fill in real values. # deploy/telegram.env is gitignored — never commit the real secrets. TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= + +# ntfy access token for the `maven` topic, the second reach (V-649). Mint it on +# the ntfy server with write access to that topic and nothing else: +# ntfy token add --expires=never maven +# Read access is not needed — mavend publishes and never subscribes. +NTFY_TOKEN= diff --git a/docs/operations.md b/docs/operations.md index 4d7fb4c..7b9dd63 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -1,6 +1,6 @@ # Start Commands -*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.* +*Last verified: 2026-08-07 @ a4630b9. Living doc: correct it in place, do not append.* All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`. @@ -44,7 +44,8 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options. "repeat_interval": "5m", "ntfy": { "base_url": "https://ntfy.kvmx.ru", - "topic": "maven" + "topic": "maven", + "token": "${NTFY_TOKEN}" }, "phraser": { "model_path": "/mnt/hdd1/llms/Qwen3-Maven-1.7B-Q8_0.gguf", @@ -66,6 +67,15 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options. Omit the `embedder` block entirely to use the deterministic HashEmbedder floor (no ML, no ONNX runtime dependency). Useful for testing or low-resource setups. +`${NTFY_TOKEN}` and the `${TELEGRAM_*}` vars are expanded from `deploy/telegram.env`, which is gitignored. Copy `deploy/telegram.env.example` and fill it in. Mint a scoped token rather than reusing an admin one. It needs write access to the `maven` topic and nothing else: + +```sh +ntfy access maven maven write-only +ntfy token add --expires=never maven +``` + +Deleting the `ntfy` block turns the reach off, and that is not a no-op. The routing table sends sev3-away nudges and away reminders to ntfy and nowhere else. With no sink wired they hit a nil and vanish, leaving no log line and no `delivery_attempts` row (V-649). + ## mavsttd — STT worker (optional, remote whisper.cpp) Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so). diff --git a/internal/config/deployconfig_test.go b/internal/config/deployconfig_test.go index 443be09..e888ba7 100644 --- a/internal/config/deployconfig_test.go +++ b/internal/config/deployconfig_test.go @@ -45,4 +45,17 @@ func TestDeployConfigLoads(t *testing.T) { if cfg.Voice.RouterThreshold <= 0 { t.Error("router threshold did not get its default") } + + // The second reach (V-649). Deleting this block is how you turn ntfy off, + // so its absence has to be loud: sev3-away nudges and away reminders route + // to ntfy and to nothing else, and a nil sink drops them with no log and no + // outbox row. The token is a ${VAR} that CI cannot resolve, so this checks + // the wiring and not the credential. + if cfg.Ntfy == nil { + t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " + + "would have nowhere to land, and would vanish silently rather than fail") + } + if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" { + t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic) + } } diff --git a/internal/delivery/ntfysink/ntfysink.go b/internal/delivery/ntfysink/ntfysink.go index 9e7fa3e..5942951 100644 --- a/internal/delivery/ntfysink/ntfysink.go +++ b/internal/delivery/ntfysink/ntfysink.go @@ -7,11 +7,17 @@ // 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 -// 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. +// 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 ( @@ -31,11 +37,29 @@ import ( // 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 + // 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 @@ -59,6 +83,12 @@ func New(cfg Config) (*Sink, error) { 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 @@ -84,7 +114,9 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { } req.Header.Set("Title", "maven") req.Header.Set("Priority", priorityFor(d).String()) - if s.cfg.Username != "" { + 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) } diff --git a/internal/delivery/ntfysink/ntfysink_test.go b/internal/delivery/ntfysink/ntfysink_test.go index c4edd1a..f7a0172 100644 --- a/internal/delivery/ntfysink/ntfysink_test.go +++ b/internal/delivery/ntfysink/ntfysink_test.go @@ -224,6 +224,37 @@ func TestSendNoAuthWhenUsernameEmpty(t *testing.T) { } } +// TestSendSetsBearerToken — the deployed credential (V-649) is an ntfy access +// token scoped write-only to the maven topic, not a password. A token sent as +// basic auth is rejected by ntfy, so the header shape is the whole test. +func TestSendSetsBearerToken(t *testing.T) { + rs := newRecordingServer(t, 200, "") + srv := httptest.NewServer(rs.handler()) + defer srv.Close() + + sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven", Token: "tk_secret"}) + if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, _, auth, _, _ := rs.snapshot() + if auth != "Bearer tk_secret" { + t.Fatalf("auth: want 'Bearer tk_secret', got %q", auth) + } +} + +// TestNewRejectsBothCredentials — configuring a token and a username means one +// of them was meant and the other is a leftover. Picking either would leave a +// server that authenticates against a credential nobody wrote down. +func TestNewRejectsBothCredentials(t *testing.T) { + _, err := New(Config{BaseURL: "http://x", Topic: "maven", Token: "tk_x", Username: "maven"}) + if err == nil { + t.Fatal("New accepted both a token and a username") + } + if !strings.Contains(err.Error(), "not both") { + t.Errorf("error does not say which to fix: %v", err) + } +} + func TestSendTitleIsMaven(t *testing.T) { rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler())