d09954d85d
net/http wraps every transport failure in *url.Error, whose Error() prints the request URL. Telegram accepts the bot token nowhere but the URL path, so a send failure wrote the live token into the daemon log. On 2026-08-01 homesrv could not reach api.telegram.org and did that once a minute for as long as the network stayed down. The token lives in deploy/telegram.env to stay out of the repo; putting it in `docker compose logs` undoes that. Both error sites now go through redact. The structural branch rewrites url.Error.URL and keeps the type, so errors.As still matches; anything else falls back to scrubbing the rendered message. No minimum-token-length guard: a one-character token would shred the message, but that beats leaking it. DESIGN.md still said the classifier cascade was the path that runs today with llmrouter wired nil, and gave the resident checkpoint as Qwen3.5-0.8B. Both stopped being true on 2026-07-31. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
235 lines
9.4 KiB
Go
235 lines
9.4 KiB
Go
// Package telegramsink implements delivery.Sink for the telegram push channel.
|
||
//
|
||
// 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
|
||
// 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 —
|
||
// through your relay" maps here to a Proxy config field (HTTP/HTTPS/SOCKS5).
|
||
// stdlib net/http Transport.Proxy supports all three; the daemon wires the
|
||
// relay URL from config. the bot token is a delivery-config secret (not a db
|
||
// key — a popped telegram sink can push spam to your chat, nothing else;
|
||
// matches the module key-isolation invariant: the sink never holds the
|
||
// sqlcipher key).
|
||
//
|
||
// repeat-til-ack is driven by the dispatcher + AckTracker (delivery/ack.go +
|
||
// dispatcher.RepeatUnacked), NOT by the sink. the sink is fire-and-forget per
|
||
// call — telegram has no priority/insistence field analogous to ntfy's 1–5;
|
||
// the repeat mechanism IS the insistence, re-sending on the dispatcher's tick
|
||
// until MarkAcked.
|
||
package telegramsink
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/delivery"
|
||
)
|
||
|
||
// DefaultBaseURL — telegram bot API. overridable in Config (e.g. for a
|
||
// self-hosted bridge that mirrors the API shape and handles the region cut
|
||
// itself); the proxy is the normal path, the BaseURL override is the fallback.
|
||
const DefaultBaseURL = "https://api.telegram.org"
|
||
|
||
const DefaultTimeout = 10 * time.Second
|
||
|
||
// Config — telegram bot API publish config. the daemon wires this from its
|
||
// config file; the bot token + chat id live in the daemon's config (or a
|
||
// systemd credential), never in the binary.
|
||
type Config struct {
|
||
// BotToken — the telegram bot token from BotFather. required. sent in the
|
||
// URL path (the only place telegram accepts it), not in the body.
|
||
BotToken string `json:"bot_token"`
|
||
|
||
// ChatID — destination chat. may be a numeric user/group id (sent as a
|
||
// JSON number) or @channelusername (sent as a JSON string). required.
|
||
ChatID string `json:"chat_id"`
|
||
|
||
// BaseURL — telegram API base. empty = DefaultBaseURL. override to point
|
||
// at a self-hosted API bridge if the proxy path isn't used.
|
||
BaseURL string `json:"base_url,omitempty"`
|
||
|
||
// Proxy — URL of an HTTP/HTTPS/SOCKS5 relay used to reach the telegram
|
||
// API (region-restricted direct egress). empty = direct (won't work from
|
||
// the homesrv without a relay; kept configurable for tests + future
|
||
// topology change).
|
||
Proxy string `json:"proxy,omitempty"`
|
||
|
||
// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
|
||
// tick loop.
|
||
Timeout time.Duration
|
||
}
|
||
|
||
// Sink — implements delivery.Sink via the telegram bot sendMessage API. one
|
||
// POST per Send; no retry (the dispatcher + daemon decide retry policy). the
|
||
// repeat-til-ack clock is driven by the dispatcher calling Send again each
|
||
// interval — the sink itself is stateless.
|
||
type Sink struct {
|
||
cfg Config
|
||
hc *http.Client
|
||
base string // resolved BaseURL, no trailing slash
|
||
}
|
||
|
||
// New validates the config and builds the sink. BotToken and ChatID are
|
||
// required; Proxy and BaseURL are optional.
|
||
func New(cfg Config) (*Sink, error) {
|
||
if cfg.BotToken == "" {
|
||
return nil, fmt.Errorf("telegramsink: BotToken is required")
|
||
}
|
||
if cfg.ChatID == "" {
|
||
return nil, fmt.Errorf("telegramsink: ChatID is required")
|
||
}
|
||
base := cfg.BaseURL
|
||
if base == "" {
|
||
base = DefaultBaseURL
|
||
}
|
||
if _, err := url.Parse(base); err != nil {
|
||
return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err)
|
||
}
|
||
if cfg.Proxy != "" {
|
||
if _, err := url.Parse(cfg.Proxy); err != nil {
|
||
return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err)
|
||
}
|
||
}
|
||
to := cfg.Timeout
|
||
if to == 0 {
|
||
to = DefaultTimeout
|
||
}
|
||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||
if cfg.Proxy != "" {
|
||
pu, _ := url.Parse(cfg.Proxy) // parsed+checked above
|
||
transport.Proxy = http.ProxyURL(pu)
|
||
}
|
||
return &Sink{
|
||
cfg: cfg,
|
||
base: strings.TrimRight(base, "/"),
|
||
hc: &http.Client{Timeout: to, Transport: transport},
|
||
}, nil
|
||
}
|
||
|
||
// sendMessageReq — the subset of sendMessage params maven uses. JSON-encoded
|
||
// as the request body. chat_id accepts number or string; go json tags keep
|
||
// both shapes (channel usernames are strings, user ids are numbers — send
|
||
// whatever ChatID was configured as).
|
||
type sendMessageReq struct {
|
||
ChatID string `json:"chat_id"`
|
||
Text string `json:"text"`
|
||
DisableNotification bool `json:"disable_notification"` // false = ring (always — these are alarms)
|
||
ProtectContent bool `json:"protect_content"` // true = no forwarding out of chat
|
||
}
|
||
|
||
// telegramResp — the shape telegram returns. ok=false on logical error with
|
||
// error_code + description; ok=true with result on success (result contents
|
||
// not needed by the sink).
|
||
type telegramResp struct {
|
||
Ok bool `json:"ok"`
|
||
ErrorCode int `json:"error_code,omitempty"`
|
||
Description string `json:"description,omitempty"`
|
||
}
|
||
|
||
// Send publishes one message to the configured telegram chat. the body is the
|
||
// 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 {
|
||
// 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,
|
||
Text: body,
|
||
DisableNotification: false, // maven sends to telegram only on sev4/sev3/reminder — all want to ring
|
||
ProtectContent: true, // no forwarding out — locks minimal body at the channel last mile
|
||
}
|
||
pb, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return fmt.Errorf("telegramsink: marshal: %w", err)
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.sendMessageURL(), bytes.NewReader(pb))
|
||
if err != nil {
|
||
return fmt.Errorf("telegramsink: build request: %w", s.redact(err))
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := s.hc.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("telegramsink: sendMessage: %w", s.redact(err))
|
||
}
|
||
defer resp.Body.Close()
|
||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||
|
||
// telegram returns 200 with ok=true on success; non-2xx with ok=false +
|
||
// error_code + description on failure. parse the body either way so a 200
|
||
// with ok=false (shouldn't happen, but the API reserves that) still surfaces.
|
||
var tr telegramResp
|
||
if jsonErr := json.Unmarshal(rb, &tr); jsonErr == nil && !tr.Ok {
|
||
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||
}
|
||
if resp.StatusCode/100 != 2 {
|
||
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// sendMessageURL — the bot API path. the token is in the URL path
|
||
// (https://api.telegram.org/bot<token>/sendMessage); telegram does not accept
|
||
// it anywhere else. the URL is built per-send from the resolved base and never
|
||
// stored, but it does end up inside transport errors — see redact.
|
||
func (s *Sink) sendMessageURL() string {
|
||
return s.base + "/bot" + s.cfg.BotToken + "/sendMessage"
|
||
}
|
||
|
||
// tokenPlaceholder — what a redacted token reads as in an error. Recognisable
|
||
// on sight, so nobody reads a redacted URL as a malformed one.
|
||
const tokenPlaceholder = "<redacted>"
|
||
|
||
// redact strips the bot token out of a transport error before it becomes a
|
||
// returned error, and from there a log line.
|
||
//
|
||
// This is not hypothetical. net/http wraps every transport failure in
|
||
// *url.Error, whose Error() prints the full request URL, and the token is IN
|
||
// that URL because telegram accepts it nowhere else. On 2026-08-01 homesrv
|
||
// could not reach api.telegram.org, so the retry wrote the whole bot token
|
||
// into the daemon log once a minute for as long as the network stayed down.
|
||
// The token lives in deploy/telegram.env specifically to stay out of the repo;
|
||
// putting it in `docker compose logs` undoes that.
|
||
//
|
||
// The structural case rewrites url.Error.URL and keeps the error's type, so
|
||
// callers matching on *url.Error still work. Anything else falls back to
|
||
// scrubbing the rendered message, which loses the type but cannot leak.
|
||
//
|
||
// There is deliberately no minimum-length guard. A one-character token would
|
||
// make this replace every occurrence of that character in the message, which
|
||
// is ugly; leaking a short token is worse. New already refuses an empty one.
|
||
func (s *Sink) redact(err error) error {
|
||
if err == nil {
|
||
return err
|
||
}
|
||
var ue *url.Error
|
||
if errors.As(err, &ue) {
|
||
clean := *ue
|
||
clean.URL = strings.ReplaceAll(clean.URL, s.cfg.BotToken, tokenPlaceholder)
|
||
err = &clean
|
||
}
|
||
if msg := strings.ReplaceAll(err.Error(), s.cfg.BotToken, tokenPlaceholder); msg != err.Error() {
|
||
return errors.New(msg)
|
||
}
|
||
return err
|
||
}
|