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())