From 44d5f52885b5855ecc65cc812f76f22a7aaf563d Mon Sep 17 00:00:00 2001 From: Nacho Date: Sun, 13 Sep 2026 11:21:46 +0200 Subject: [PATCH 1/6] feat(channels): add Telegram channel and harden ntfy/Gotify sends (#134) --- internal/alerts/channels_test.go | 2 +- internal/channels/channels.go | 368 +++++++++++++++++---- internal/channels/channels_test.go | 203 +++++++++++- internal/config/config.go | 33 +- internal/httpapi/channels_handlers.go | 94 ++++++ internal/httpapi/channels_handlers_test.go | 138 ++++++++ internal/httpapi/httpapi.go | 9 +- main.go | 27 +- 8 files changed, 791 insertions(+), 83 deletions(-) create mode 100644 internal/httpapi/channels_handlers.go create mode 100644 internal/httpapi/channels_handlers_test.go diff --git a/internal/alerts/channels_test.go b/internal/alerts/channels_test.go index 2931bc4..249e040 100644 --- a/internal/alerts/channels_test.go +++ b/internal/alerts/channels_test.go @@ -43,7 +43,7 @@ func TestRaiseKind_EnviaCanales(t *testing.T) { })) defer srv.Close() - c := channels.New(srv.URL, "", "", "", "", 0, "udp", 1) + c := channels.New(channels.Config{NtfyURL: srv.URL}) a := New(d, hub.NewHub(), st) a.SetChannels(c) a.RaiseKind(context.Background(), "warn", "pool.tank", "pools:tank", diff --git a/internal/channels/channels.go b/internal/channels/channels.go index 4e5b981..4b554d1 100644 --- a/internal/channels/channels.go +++ b/internal/channels/channels.go @@ -1,21 +1,55 @@ -// Package channels — canales de alerta adicionales (#86): ntfy, Gotify y -// Syslog. Cada canal es inerte si no está configurado (env). Envíos +// Package channels — canales de alerta adicionales (#86, #134): ntfy, Gotify, +// Telegram y Syslog. Cada canal es inerte si no está configurado (env). Envíos // best-effort con timeout acotado; los fallos se loguean y no rompen nada. +// +// Reglas de seguridad (lecciones de NetPulse #773 / NetGrip #298): +// - Los secretos viajan en la URL de ntfy (el topic) y de Telegram (el bot +// token): un error de red de *url.Error incluiría la URL completa, así que +// SIEMPRE se redacta el secreto antes de loguear. +// - Los 4xx permanentes no se reintentan; 429/5xx y fallos de red sí (1 +// reintento), porque una alerta de pool DEGRADED no puede perderse por un +// corte puntual. +// - El texto se trunca por límite de runas (nunca a mitad de UTF-8). package channels import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "log" "net" "net/http" + "net/url" + "path" "strconv" + "strings" "time" ) -// Client — conjunto de canales configurados. Los nil no envían. +// ErrNotConfigured — el canal pedido no tiene configuración mínima. +var ErrNotConfigured = errors.New("canal no configurado") + +// Config — datos de configuración de los canales (viene de env). +type Config struct { + NtfyURL string + NtfyToken string + + GotifyURL string + GotifyToken string + + TelegramBotToken string + TelegramChatID string + + SyslogHost string + SyslogPort int + SyslogProto string + SyslogFacility int +} + +// Client — conjunto de canales configurados. Los vacíos no envían. type Client struct { ntfyURL string ntfyToken string @@ -23,6 +57,10 @@ type Client struct { gotifyURL string gotifyToken string + telegramToken string + telegramChatID string + telegramBase string // base de la Bot API (inyectable en tests) + syslogHost string syslogPort int syslogProto string @@ -32,136 +70,354 @@ type Client struct { } // New construye el cliente a partir de la configuración. Solo registra los -// canales con los datos mínimos; el resto queda nil (inactivo). -func New(ntfyURL, ntfyToken, gotifyURL, gotifyToken string, - syslogHost string, syslogPort int, syslogProto string, syslogFacility int) *Client { +// canales con los datos mínimos; el resto queda inactivo. +func New(cfg Config) *Client { return &Client{ - ntfyURL: ntfyURL, - ntfyToken: ntfyToken, - gotifyURL: gotifyURL, - gotifyToken: gotifyToken, - syslogHost: syslogHost, - syslogPort: syslogPort, - syslogProto: syslogProto, - syslogFacility: syslogFacility, + ntfyURL: cfg.NtfyURL, + ntfyToken: cfg.NtfyToken, + gotifyURL: cfg.GotifyURL, + gotifyToken: cfg.GotifyToken, + telegramToken: cfg.TelegramBotToken, + telegramChatID: cfg.TelegramChatID, + telegramBase: telegramAPIBase, + syslogHost: cfg.SyslogHost, + syslogPort: cfg.SyslogPort, + syslogProto: cfg.SyslogProto, + syslogFacility: cfg.SyslogFacility, http: &http.Client{Timeout: 10 * time.Second}, } } +// telegramReady — Telegram necesita token Y chat id. +func (c *Client) telegramReady() bool { + return c != nil && c.telegramToken != "" && c.telegramChatID != "" +} + // Enabled — ¿hay al menos un canal configurado? func (c *Client) Enabled() bool { - return c != nil && (c.ntfyURL != "" || c.gotifyURL != "" || c.syslogHost != "") + return c != nil && (c.ntfyURL != "" || c.gotifyURL != "" || c.syslogHost != "" || c.telegramReady()) +} + +// Configured — ¿el canal indicado tiene configuración mínima? Nombres válidos: +// ntfy, gotify, telegram, syslog. Cualquier otro devuelve false. +func (c *Client) Configured(name string) bool { + if c == nil { + return false + } + switch name { + case "ntfy": + return c.ntfyURL != "" + case "gotify": + return c.gotifyURL != "" + case "telegram": + return c.telegramReady() + case "syslog": + return c.syslogHost != "" + default: + return false + } +} + +// TelegramChatID — chat de destino configurado (no es un secreto: el token +// nunca se expone). Vacío si Telegram no está configurado. +func (c *Client) TelegramChatID() string { + if c == nil { + return "" + } + return c.telegramChatID } // Send entrega la alerta a todos los canales configurados (best-effort). // El contexto lleva timeout; cada canal se envía en serie con su propio -// límite. Compose recibe el texto ya compuesto (título + cuerpo). +// límite. Los fallos se loguean (redactados) y no propagan. func (c *Client) Send(ctx context.Context, title, body string) { if c == nil { return } - if c.ntfyURL != "" { - c.sendNtfy(ctx, title, body) + if err := c.sendNtfy(ctx, title, body); err != nil { + log.Printf("channels: ntfy: %v", err) + } + if err := c.sendGotify(ctx, title, body); err != nil { + log.Printf("channels: gotify: %v", err) } - if c.gotifyURL != "" { - c.sendGotify(ctx, title, body) + if err := c.sendTelegram(ctx, title, body); err != nil { + log.Printf("channels: telegram: %v", err) } - if c.syslogHost != "" { - c.sendSyslog(ctx, title, body) + if err := c.sendSyslog(ctx, title, body); err != nil { + log.Printf("channels: syslog: %v", err) } } +// Test envía un mensaje de prueba por el canal indicado y devuelve el error +// real (sin loguear) para que el endpoint HTTP pueda informar. ErrNotConfigured +// si el canal no está listo. +func (c *Client) Test(ctx context.Context, name, title, body string) error { + if c == nil { + return ErrNotConfigured + } + switch name { + case "ntfy": + return c.sendNtfy(ctx, title, body) + case "gotify": + return c.sendGotify(ctx, title, body) + case "telegram": + return c.sendTelegram(ctx, title, body) + case "syslog": + return c.sendSyslog(ctx, title, body) + default: + return fmt.Errorf("canal desconocido: %q", name) + } +} + +// --- ntfy --- + // sendNtfy — POST JSON a NTFY_URL con Authorization Bearer si hay token. -// Formato: {"topic":…} se envía a la URL base del topic directamente: -// la URL configurada es el topic completo (p.ej. https://ntfy.sh/mialerta). -func (c *Client) sendNtfy(ctx context.Context, title, body string) { +// La URL configurada es el topic completo (p.ej. https://ntfy.sh/mialerta). +func (c *Client) sendNtfy(ctx context.Context, title, body string) error { + if c == nil || c.ntfyURL == "" { + return nil + } payload, err := json.Marshal(map[string]string{ "title": title, - "message": body, + "message": truncateRunes(body, maxMessageRunes), }) if err != nil { - log.Printf("channels: ntfy: marshal: %v", err) - return + return fmt.Errorf("marshal: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ntfyURL, bytes.NewReader(payload)) if err != nil { - log.Printf("channels: ntfy: %v", err) - return + return redactErr(err, topicOf(c.ntfyURL)) } req.Header.Set("Content-Type", "application/json") if c.ntfyToken != "" { req.Header.Set("Authorization", "Bearer "+c.ntfyToken) } - if err := c.do(req); err != nil { - log.Printf("channels: ntfy: %v", err) - } + // El topic (secreto) va en la URL: redactarlo en cualquier error. + return c.doRetry(req, topicOf(c.ntfyURL)) } +// --- gotify --- + // sendGotify — POST JSON a GOTIFY_URL/message con X-Gotify-Key. -func (c *Client) sendGotify(ctx context.Context, title, body string) { +func (c *Client) sendGotify(ctx context.Context, title, body string) error { + if c == nil || c.gotifyURL == "" { + return nil + } payload, err := json.Marshal(map[string]string{ "title": title, - "message": body, + "message": truncateRunes(body, maxMessageRunes), "priority": "5", }) if err != nil { - log.Printf("channels: gotify: marshal: %v", err) - return + return fmt.Errorf("marshal: %w", err) } - url := c.gotifyURL + "/message" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + // El token viaja en cabecera, no en la URL: sin secreto que redactar. + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(c.gotifyURL, "/")+"/message", bytes.NewReader(payload)) if err != nil { - log.Printf("channels: gotify: %v", err) - return + return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Gotify-Key", c.gotifyToken) - if err := c.do(req); err != nil { - log.Printf("channels: gotify: %v", err) + return c.doRetry(req, "") +} + +// --- telegram --- + +// telegramAPIBase — API pública de Telegram (Bot API). +const telegramAPIBase = "https://api.telegram.org" + +// maxMessageRunes — límite de la Bot API (sendMessage: 4096 caracteres) y de +// ntfy.sh por defecto; se trunca por runas para no partir UTF-8. +const maxMessageRunes = 4096 + +// sendTelegram — POST a /bot/sendMessage con chat_id y texto. +// El token va en la URL: se redacta en cualquier error. +func (c *Client) sendTelegram(ctx context.Context, title, body string) error { + if !c.telegramReady() { + return nil + } + text := truncateRunes(strings.TrimSpace(title+"\n"+body), maxMessageRunes) + payload, err := json.Marshal(map[string]any{ + "chat_id": c.telegramChatID, + "text": text, + "disable_web_page_preview": true, + }) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + endpoint := c.telegramBase + "/bot" + c.telegramToken + "/sendMessage" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return redactErr(err, c.telegramToken) } + req.Header.Set("Content-Type", "application/json") + return c.doRetry(req, c.telegramToken) } +// --- syslog --- + // sendSyslog — datagrama RFC 3164 (PRI + timestamp + host + texto) por UDP/TCP. -func (c *Client) sendSyslog(ctx context.Context, title, body string) { +func (c *Client) sendSyslog(ctx context.Context, title, body string) error { + if c == nil || c.syslogHost == "" { + return nil + } // facility*8 + severity(1=notice); fallback 14 (1*8+1=9 → user.notice). pri := c.syslogFacility*8 + 1 msg := fmt.Sprintf("<%d>%s EasyZFS[%d]: %s: %s", - pri, time.Now().Format("Jan _2 15:04:05"), 0, title, body) + pri, time.Now().Format("Jan _2 15:04:05"), 0, title, truncateRunes(body, maxMessageRunes)) addr := net.JoinHostPort(c.syslogHost, strconv.Itoa(c.syslogPort)) if c.syslogProto == "tcp" { var d net.Dialer conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { - log.Printf("channels: syslog tcp: %v", err) - return + return err } defer conn.Close() if _, err := conn.Write([]byte(msg + "\n")); err != nil { - log.Printf("channels: syslog tcp: %v", err) + return err } - return + return nil } // UDP: dial y close por envío (sin conexión persistente). conn, err := net.Dial("udp", addr) if err != nil { - log.Printf("channels: syslog udp: %v", err) - return + return err } defer conn.Close() if _, err := conn.Write([]byte(msg + "\n")); err != nil { - log.Printf("channels: syslog udp: %v", err) + return err + } + return nil +} + +// --- entrega con reintento acotado --- + +// maxIntentos — 1 envío + 1 reintento (solo para fallos transitorios). +const maxIntentos = 2 + +// statusError — respuesta HTTP no-2xx (código + cuerpo acotado). +type statusError struct { + code int + body string +} + +func (e *statusError) Error() string { + if e.body == "" { + return fmt.Sprintf("status %d", e.code) + } + return fmt.Sprintf("status %d: %s", e.code, e.body) +} + +// doRetry ejecuta la petición; reintenta UNA vez si el fallo es transitorio +// (429, 5xx o error de red) y el contexto sigue vivo. El secreto se redacta +// en el error devuelto. +func (c *Client) doRetry(req *http.Request, secret string) error { + backoff := 1 * time.Second + var lastErr error + for intento := 1; ; intento++ { + lastErr = c.do(req, secret) + if lastErr == nil || intento >= maxIntentos || !retryable(lastErr) { + return lastErr + } + // Reusar el cuerpo en el reintento (NewRequest rellena GetBody para + // bytes.Reader, así que se puede recomponer). + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return lastErr + } + req.Body = body + } + select { + case <-req.Context().Done(): + return lastErr + case <-time.After(backoff): + } + backoff *= 2 } } -// do ejecuta la petición y verifica 2xx; el cuerpo se cierra siempre. -func (c *Client) do(req *http.Request) error { +// retryable — ¿merece la pena reintentar? 429/5xx y fallos de red sí; 4xx +// permanentes y contexto cancelado/agotado no. +func retryable(err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + var se *statusError + if errors.As(err, &se) { + return se.code == http.StatusTooManyRequests || se.code >= 500 + } + return true // error de transporte (timeout, DNS, conexión): transitorio +} + +// do ejecuta la petición y verifica 2xx; el cuerpo se cierra siempre. El +// secreto se usa para redactar los errores (nunca se loguea la URL cruda). +func (c *Client) do(req *http.Request, secret string) error { resp, err := c.http.Do(req) if err != nil { - return err + return redactErr(err, secret) } defer resp.Body.Close() if resp.StatusCode >= 300 { - return fmt.Errorf("status %d", resp.StatusCode) + // Cuerpo acotado: en Telegram trae la "description" del fallo + // (chat not found, Unauthorized…), útil para el botón Probar. + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return redactErr(&statusError{code: resp.StatusCode, body: strings.TrimSpace(string(b))}, secret) } return nil } + +// redactErr — sustituye el secreto por "***" en errores de red y de status. +// Los *url.Error de net/http incluyen la URL completa (topic de ntfy, token de +// Telegram): sin esto, el secreto acabaría en el log. +func redactErr(err error, secret string) error { + if err == nil || secret == "" { + return err + } + var ue *url.Error + if errors.As(err, &ue) { + clean := *ue + clean.URL = strings.ReplaceAll(ue.URL, secret, "***") + if ue.Err != nil { + clean.Err = errors.New(strings.ReplaceAll(ue.Err.Error(), secret, "***")) + } + return &clean + } + var se *statusError + if errors.As(err, &se) { + return &statusError{code: se.code, body: strings.ReplaceAll(se.body, secret, "***")} + } + if strings.Contains(err.Error(), secret) { + return errors.New(strings.ReplaceAll(err.Error(), secret, "***")) + } + return err +} + +// topicOf — último segmento del path de una URL de ntfy (el topic), para +// redactarlo sin ocultar el servidor. Vacío si no se puede derivar. +func topicOf(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + t := path.Base(u.Path) + if t == "/" || t == "." { + return "" + } + return t +} + +// truncateRunes — recorta a max runas (no bytes) y añade elipsis. +func truncateRunes(s string, max int) string { + if max <= 0 { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} diff --git a/internal/channels/channels_test.go b/internal/channels/channels_test.go index f6a701b..587c041 100644 --- a/internal/channels/channels_test.go +++ b/internal/channels/channels_test.go @@ -1,5 +1,6 @@ -// channels_test.go — tests de los canales ntfy/gotify/syslog con destinos de -// prueba (httptest + listener UDP) para verificar payloads y cabeceras. +// channels_test.go — tests de los canales ntfy/gotify/telegram/syslog con +// destinos de prueba (httptest + listener UDP): payloads, cabeceras, límites +// y reglas de seguridad (redacción de secretos, reintentos). package channels import ( @@ -10,6 +11,7 @@ import ( "net/http/httptest" "strconv" "strings" + "sync/atomic" "testing" "time" ) @@ -28,7 +30,7 @@ func TestNtfy(t *testing.T) { })) defer srv.Close() - c := New(srv.URL, "tok123", "", "", "", 0, "udp", 1) + c := New(Config{NtfyURL: srv.URL, NtfyToken: "tok123"}) if !c.Enabled() { t.Fatal("con ntfy URL debería estar enabled") } @@ -58,7 +60,7 @@ func TestGotify(t *testing.T) { })) defer srv.Close() - c := New("", "", srv.URL, "apptok", "", 0, "udp", 1) + c := New(Config{GotifyURL: srv.URL, GotifyToken: "apptok"}) if !c.Enabled() { t.Fatal("con gotify URL debería estar enabled") } @@ -74,6 +76,162 @@ func TestGotify(t *testing.T) { } } +// telegram: POST a /bot/sendMessage con chat_id y texto. +func TestTelegram(t *testing.T) { + var got map[string]any + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&got) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(Config{TelegramBotToken: "123:ABC", TelegramChatID: "-1009"}) + c.telegramBase = srv.URL + if !c.Enabled() { + t.Fatal("con Telegram configurado debería estar enabled") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c.Send(ctx, "Pool DEGRADED", "TheZBox") + + if path != "/bot123:ABC/sendMessage" { + t.Fatalf("path %q, esperado /bot123:ABC/sendMessage", path) + } + if got["chat_id"] != "-1009" { + t.Fatalf("chat_id %v, esperado -1009", got["chat_id"]) + } + if txt, _ := got["text"].(string); txt != "Pool DEGRADED\nTheZBox" { + t.Fatalf("text %q inesperado", txt) + } + if v, ok := got["disable_web_page_preview"]; !ok || v != true { + t.Fatalf("disable_web_page_preview ausente/falso: %v", got) + } +} + +// telegram incompleto (solo token o solo chat) = canal desactivado. +func TestTelegramIncomplete(t *testing.T) { + onlyToken := New(Config{TelegramBotToken: "123:ABC"}) + if onlyToken.Enabled() || onlyToken.Configured("telegram") { + t.Fatal("solo token no debe activar Telegram") + } + onlyChat := New(Config{TelegramChatID: "-1009"}) + if onlyChat.Enabled() || onlyChat.Configured("telegram") { + t.Fatal("solo chat id no debe activar Telegram") + } + if err := onlyChat.Test(context.Background(), "telegram", "t", "b"); err != nil { + t.Fatalf("canal incompleto debe ser no-op, no error: %v", err) + } +} + +// Telegram limita sendMessage a 4096 caracteres: se trunca por runas. +func TestTelegramTruncate(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&got) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(Config{TelegramBotToken: "tok", TelegramChatID: "1"}) + c.telegramBase = srv.URL + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c.Send(ctx, "Título", strings.Repeat("á", 5000)) + + txt, _ := got["text"].(string) + runes := []rune(txt) + if len(runes) != maxMessageRunes { + t.Fatalf("longitud %d runas, esperado %d", len(runes), maxMessageRunes) + } + if runes[len(runes)-1] != '…' { + t.Fatalf("el truncado debe acabar en elipsis: %q", string(runes[len(runes)-3:])) + } +} + +// Redacción de secretos: un fallo de red en ntfy no puede filtrar el topic. +func TestRedactTopicOnNetworkError(t *testing.T) { + c := New(Config{NtfyURL: "http://127.0.0.1:1/mi-topic-secreto"}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + err := c.Test(ctx, "ntfy", "t", "b") + if err == nil { + t.Fatal("se esperaba error de red") + } + if strings.Contains(err.Error(), "mi-topic-secreto") { + t.Fatalf("el error filtra el topic: %v", err) + } + if !strings.Contains(err.Error(), "***") { + t.Fatalf("el error debería redactar el topic con ***: %v", err) + } +} + +// Redacción del bot token de Telegram en fallos de red. +func TestRedactTelegramTokenOnNetworkError(t *testing.T) { + c := New(Config{TelegramBotToken: "123:SUPERSECRETO", TelegramChatID: "1"}) + c.telegramBase = "http://127.0.0.1:1" + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + err := c.Test(ctx, "telegram", "t", "b") + if err == nil { + t.Fatal("se esperaba error de red") + } + if strings.Contains(err.Error(), "SUPERSECRETO") { + t.Fatalf("el error filtra el bot token: %v", err) + } + if !strings.Contains(err.Error(), "***") { + t.Fatalf("el error debería redactar el token con ***: %v", err) + } +} + +// 5xx es transitorio: se reintenta una vez y el segundo intento entrega. +func TestRetryOn5xx(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) == 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := New(Config{NtfyURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.Test(ctx, "ntfy", "t", "b"); err != nil { + t.Fatalf("debería entregar tras reintento: %v", err) + } + if n := atomic.LoadInt32(&calls); n != 2 { + t.Fatalf("peticiones %d, esperado 2", n) + } +} + +// 4xx permanente no se reintenta (una sola petición). +func TestNoRetry4xx(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + http.Error(w, `{"ok":false,"description":"chat not found"}`, http.StatusBadRequest) + })) + defer srv.Close() + + c := New(Config{NtfyURL: srv.URL}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err := c.Test(ctx, "ntfy", "t", "b") + if err == nil { + t.Fatal("se esperaba error 400") + } + if !strings.Contains(err.Error(), "chat not found") { + t.Fatalf("el error debe incluir el cuerpo acotado del destino: %v", err) + } + if n := atomic.LoadInt32(&calls); n != 1 { + t.Fatalf("peticiones %d, esperado 1 (sin reintento en 4xx)", n) + } +} + // syslog UDP: el datagrama llega con PRI y texto. func TestSyslogUDP(t *testing.T) { pc, err := net.ListenPacket("udp", "127.0.0.1:0") @@ -85,7 +243,7 @@ func TestSyslogUDP(t *testing.T) { host, portStr, _ := net.SplitHostPort(addr) port, _ := strconv.Atoi(portStr) - c := New("", "", "", "", host, port, "udp", 1) + c := New(Config{SyslogHost: host, SyslogPort: port, SyslogProto: "udp", SyslogFacility: 1}) if !c.Enabled() { t.Fatal("con syslog host debería estar enabled") } @@ -110,10 +268,15 @@ func TestSyslogUDP(t *testing.T) { // sin configuración: Enabled false y Send no rompe. func TestDisabled(t *testing.T) { - c := New("", "", "", "", "", 0, "udp", 1) + c := New(Config{}) if c.Enabled() { t.Fatal("sin canales no debería estar enabled") } + for _, name := range []string{"ntfy", "gotify", "telegram", "syslog"} { + if c.Configured(name) { + t.Fatalf("%s no debería estar configurado", name) + } + } c.Send(context.Background(), "t", "b") // no debe panickear if c == nil { t.Fatal("nil") @@ -124,8 +287,34 @@ func TestDisabled(t *testing.T) { // fallo del destino: log y sigue, sin panic. func TestSendErrorNoPanic(t *testing.T) { - c := New("http://127.0.0.1:1/no", "", "", "", "", 0, "udp", 1) // puerto 1: nada escucha + c := New(Config{NtfyURL: "http://127.0.0.1:1/no"}) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() c.Send(ctx, "t", "b") } + +// Configured — cada canal responde a su configuración mínima. +func TestConfigured(t *testing.T) { + c := New(Config{NtfyURL: "https://ntfy.sh/x", GotifyURL: "https://g", TelegramBotToken: "t", TelegramChatID: "1", SyslogHost: "127.0.0.1"}) + for _, name := range []string{"ntfy", "gotify", "telegram", "syslog"} { + if !c.Configured(name) { + t.Fatalf("%s debería estar configurado", name) + } + } + if c.Configured("email") { + t.Fatal("email no es un canal del paquete") + } + if c.TelegramChatID() != "1" { + t.Fatalf("chat id %q", c.TelegramChatID()) + } +} + +// Test exige canal configurado: no-op silencioso en los canales inertes. +func TestChannelTestOnDisabledIsNoop(t *testing.T) { + c := New(Config{}) + for _, name := range []string{"ntfy", "gotify", "telegram", "syslog"} { + if err := c.Test(context.Background(), name, "t", "b"); err != nil { + t.Fatalf("%s desactivado debe ser no-op: %v", name, err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3110001..abf3bec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,7 +41,7 @@ type Config struct { SMTPTimeout time.Duration // SMTP_TIMEOUT (def 10s) SMTPTestTo string // SMTP_TEST_TO: fuerza destino de prueba en todos los envíos - // Canales de alerta adicionales (#86). Vacio = canal desactivado. + // Canales de alerta adicionales (#86, #134). Vacio = canal desactivado. NtfyURL string // NTFY_URL (p.ej. https://ntfy.sh/mi-topic) NtfyToken string // NTFY_TOKEN (opcional; vacío = sin auth) GotifyURL string // GOTIFY_URL (p.ej. https://gotify.example.com) @@ -51,6 +51,10 @@ type Config struct { SyslogProto string // SYSLOG_PROTO: udp | tcp (def udp) SyslogFacility int // SYSLOG_FACILITY (def 1 = user) + // Telegram (#134): Bot API sendMessage. Requiere AMBOS valores. + TelegramBotToken string // TELEGRAM_BOT_TOKEN (token del bot de @BotFather) + TelegramChatID string // TELEGRAM_CHAT_ID (chat/group id de destino) + // Intervalos del colector principal de ZFS (#124/#126). // ZpoolInterval = ritmo con la UI abierta (full collect). // ZpoolAlertInterval = heartbeat de salud/alertas con la UI cerrada. @@ -103,14 +107,17 @@ func Load() *Config { SMTPTimeout: time.Duration(envInt("SMTP_TIMEOUT", 10)) * time.Second, SMTPTestTo: os.Getenv("SMTP_TEST_TO"), - NtfyURL: os.Getenv("NTFY_URL"), - NtfyToken: os.Getenv("NTFY_TOKEN"), - GotifyURL: os.Getenv("GOTIFY_URL"), - GotifyToken: os.Getenv("GOTIFY_TOKEN"), - SyslogHost: os.Getenv("SYSLOG_HOST"), - SyslogPort: envInt("SYSLOG_PORT", 514), - SyslogProto: env("SYSLOG_PROTO", "udp"), - SyslogFacility: envInt("SYSLOG_FACILITY", 1), + NtfyURL: os.Getenv("NTFY_URL"), + NtfyToken: os.Getenv("NTFY_TOKEN"), + GotifyURL: os.Getenv("GOTIFY_URL"), + GotifyToken: os.Getenv("GOTIFY_TOKEN"), + SyslogHost: os.Getenv("SYSLOG_HOST"), + SyslogPort: envInt("SYSLOG_PORT", 514), + SyslogProto: env("SYSLOG_PROTO", "udp"), + SyslogFacility: envInt("SYSLOG_FACILITY", 1), + + TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"), + TelegramChatID: os.Getenv("TELEGRAM_CHAT_ID"), ZpoolInterval: time.Duration(envInt("EASYZFS_ZPOOL_INTERVAL", 10)) * time.Second, ZpoolAlertInterval: time.Duration(envInt("EASYZFS_ZPOOL_ALERT_INTERVAL", 60)) * time.Second, @@ -161,6 +168,14 @@ func Load() *Config { } else if cfg.SMTPFrom == "" { log.Println("aviso: SMTP_FROM no configurado; notificaciones por email desactivadas") } + // Telegram (#134): requiere bot token Y chat id. + if (cfg.TelegramBotToken == "") != (cfg.TelegramChatID == "") { + missing := "TELEGRAM_CHAT_ID" + if cfg.TelegramChatID != "" { + missing = "TELEGRAM_BOT_TOKEN" + } + log.Printf("aviso: %s sin valor; canal de Telegram incompleto y desactivado (faltan ambos: TELEGRAM_BOT_TOKEN y TELEGRAM_CHAT_ID)", missing) + } return cfg } diff --git a/internal/httpapi/channels_handlers.go b/internal/httpapi/channels_handlers.go new file mode 100644 index 0000000..e38ffcb --- /dev/null +++ b/internal/httpapi/channels_handlers.go @@ -0,0 +1,94 @@ +// channels_handlers.go — estado de los canales de alerta y prueba de envío +// (#134). Los secretos (bot token de Telegram, token de ntfy/Gotify) NUNCA +// salen en la respuesta; el chat id de Telegram sí se muestra porque no es una +// credencial (permite al admin confirmar el destino configurado). +package httpapi + +import ( + "context" + "log" + "net/http" + "time" + + "easyzfs/internal/auth" +) + +// channelInfo — estado de un canal para la UI. +type channelInfo struct { + Configured bool `json:"configured"` + Detail string `json:"detail,omitempty"` +} + +// testableChannel — canales del paquete channels que admiten prueba de envío. +func testableChannel(name string) bool { + switch name { + case "ntfy", "gotify", "telegram", "syslog": + return true + } + return false +} + +// getChannels — GET /api/channels (admin): estado de cada canal de alerta. +// Incluye los canales de infraestructura (ntfy/Gotify/Telegram/syslog), email, +// webhook y Web Push. Nunca expone secretos. +func (s *Server) getChannels(w http.ResponseWriter, r *http.Request) { + out := map[string]channelInfo{} + for _, name := range []string{"ntfy", "gotify", "telegram", "syslog"} { + info := channelInfo{Configured: s.channels.Configured(name)} + if name == "telegram" && info.Configured { + info.Detail = s.channels.TelegramChatID() + } + out[name] = info + } + // Email: operativo con SMTP_HOST + SMTP_FROM (mismo criterio que el wiring). + out["email"] = channelInfo{Configured: s.cfg.SMTPHost != "" && s.cfg.SMTPFrom != ""} + // Webhook saliente: su URL vive en settings (BD), no en env. + whConfigured := false + if s.settings != nil { + if st, err := s.settings.Load(r.Context()); err == nil { + whConfigured = st.Webhook != "" + } + } + out["webhook"] = channelInfo{Configured: whConfigured} + out["push"] = channelInfo{Configured: s.cfg.PushEnabled()} + writeJSON(w, http.StatusOK, map[string]any{"channels": out}) +} + +// testChannel — POST /api/channels/{name}/test (admin): envía una notificación +// de prueba por el canal indicado. 400 si el canal no está configurado (no se +// finge un éxito), 502 si el destino falla (mensaje ya redactado por el paquete). +func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !testableChannel(name) { + writeErr(w, http.StatusNotFound, "unknown_channel", "canal desconocido: "+name) + return + } + if s.channels == nil || !s.channels.Configured(name) { + writeErr(w, http.StatusBadRequest, "channel_not_configured", + "el canal "+name+" no está configurado (define sus variables de entorno y reinicia el servicio)") + return + } + lang := "es" + if u, err := s.users.Get(r.Context(), auth.UserFromContext(r.Context())); err == nil && u.Language == "en" { + lang = "en" + } + title, body := testMessage(lang) + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + if err := s.channels.Test(ctx, name, title, body); err != nil { + log.Printf("channels: prueba de %s falló: %v", name, err) + writeErr(w, http.StatusBadGateway, "channel_test_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "channel": name}) +} + +// testMessage — texto de la notificación de prueba en el idioma del admin. +func testMessage(lang string) (title, body string) { + if lang == "en" { + return "EasyZFS test notification", + "If you can read this, the alert channel is configured correctly." + } + return "Notificación de prueba de EasyZFS", + "Si lees esto, el canal de alertas está bien configurado." +} diff --git a/internal/httpapi/channels_handlers_test.go b/internal/httpapi/channels_handlers_test.go new file mode 100644 index 0000000..8647a59 --- /dev/null +++ b/internal/httpapi/channels_handlers_test.go @@ -0,0 +1,138 @@ +// channels_handlers_test.go — endpoints de canales de alerta (#134): +// GET /api/channels nunca expone secretos y POST /api/channels/{name}/test +// exige canal configurado y propaga el fallo del destino. +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "easyzfs/internal/auth" + "easyzfs/internal/channels" + "easyzfs/internal/config" + "easyzfs/internal/db" + "easyzfs/internal/users" +) + +// serverChannelsPrueba — servidor con BD migrada, admin y canales inyectados. +func serverChannelsPrueba(t *testing.T, ch *channels.Client) (http.Handler, *http.Cookie) { + t.Helper() + d, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { d.Close() }) + if err := db.Migrate(context.Background(), d); err != nil { + t.Fatalf("migrate: %v", err) + } + us := users.NewStore(d) + if err := us.Bootstrap(context.Background(), "adminpass-largo"); err != nil { + t.Fatalf("bootstrap: %v", err) + } + cfg := &config.Config{} + am := auth.NewManager(d, []byte("secreto-de-prueba-32-bytes-xxxxxxxx"), false) + srv := NewServer(Deps{Cfg: cfg, DB: d, Auth: am, Users: us, Channels: ch}) + cookie, err := am.CreateSession(context.Background(), "admin") + if err != nil { + t.Fatalf("sesión: %v", err) + } + return srv.Handler(), cookie +} + +// GET /api/channels: estado sin secretos (el token NUNCA sale; el chat id sí). +func TestGetChannelsNoSecrets(t *testing.T) { + ch := channels.New(channels.Config{ + NtfyURL: "https://ntfy.sh/topic-secreto", + NtfyToken: "ntfy-token-secreto", + GotifyURL: "https://gotify.example.com", + GotifyToken: "gotify-token-secreto", + TelegramBotToken: "123:TELEGRAM-SECRETO", + TelegramChatID: "-100987654", + }) + h, cookie := serverChannelsPrueba(t, ch) + w := doReq(t, h, cookie, "GET", "/api/channels", "") + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + for _, secret := range []string{"topic-secreto", "ntfy-token-secreto", "gotify-token-secreto", "TELEGRAM-SECRETO"} { + if strings.Contains(body, secret) { + t.Fatalf("la respuesta filtra un secreto (%q): %s", secret, body) + } + } + var resp struct { + Channels map[string]struct { + Configured bool `json:"configured"` + Detail string `json:"detail"` + } `json:"channels"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("body no JSON: %v", err) + } + for _, name := range []string{"ntfy", "gotify", "telegram"} { + if !resp.Channels[name].Configured { + t.Errorf("%s debería estar configured", name) + } + } + if resp.Channels["syslog"].Configured { + t.Error("syslog no está configurado") + } + if resp.Channels["telegram"].Detail != "-100987654" { + t.Errorf("detail de telegram = %q, esperado el chat id", resp.Channels["telegram"].Detail) + } + if resp.Channels["push"].Configured { + t.Error("push sin claves VAPID no debe estar configurado") + } +} + +// POST /api/channels/{name}/test: canal configurado → 200 y el destino recibe. +func TestTestChannelDelivers(t *testing.T) { + var gotTitle string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p map[string]string + _ = json.NewDecoder(r.Body).Decode(&p) + gotTitle = p["title"] + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + ch := channels.New(channels.Config{NtfyURL: srv.URL}) + h, cookie := serverChannelsPrueba(t, ch) + w := doReq(t, h, cookie, "POST", "/api/channels/ntfy/test", "") + if w.Code != http.StatusOK { + t.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + if gotTitle == "" { + t.Fatal("el canal de prueba no recibió el mensaje") + } +} + +// Canal no configurado: 400 channel_not_configured (no se finge éxito). +func TestTestChannelNotConfigured(t *testing.T) { + ch := channels.New(channels.Config{}) + h, cookie := serverChannelsPrueba(t, ch) + w := doReq(t, h, cookie, "POST", "/api/channels/telegram/test", "") + if w.Code != http.StatusBadRequest { + t.Fatalf("status %d, esperado 400", w.Code) + } + var resp map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("body no JSON: %v", err) + } + if resp["error"] != "channel_not_configured" { + t.Fatalf("error = %q, esperado channel_not_configured", resp["error"]) + } +} + +// Canal desconocido: 404. +func TestTestChannelUnknown(t *testing.T) { + h, cookie := serverChannelsPrueba(t, channels.New(channels.Config{NtfyURL: "https://ntfy.sh/x"})) + w := doReq(t, h, cookie, "POST", "/api/channels/palomitas/test", "") + if w.Code != http.StatusNotFound { + t.Fatalf("status %d, esperado 404", w.Code) + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 74322c3..436d579 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -20,6 +20,7 @@ import ( "easyzfs/internal/apikeys" "easyzfs/internal/auth" "easyzfs/internal/backup" + "easyzfs/internal/channels" "easyzfs/internal/collectors" "easyzfs/internal/config" "easyzfs/internal/hub" @@ -51,6 +52,7 @@ type Server struct { jstore *scheduler.Store h *hub.Hub push *push.Sender + channels *channels.Client backup *backup.Store longOps *longops.Manager repl *replication.Runner @@ -82,6 +84,7 @@ type Deps struct { Jobs *scheduler.Store Hub *hub.Hub Push *push.Sender + Channels *channels.Client Backup *backup.Store LongOps *longops.Manager Repl *replication.Runner @@ -99,7 +102,8 @@ func NewServer(d Deps) *Server { pools: d.Pools, disks: d.Disks, sysTimers: d.SysTimers, perf: d.Perf, caps: d.Caps, act: d.Actions, sched: d.Sched, jstore: d.Jobs, h: d.Hub, push: d.Push, - backup: d.Backup, longOps: d.LongOps, repl: d.Repl, + channels: d.Channels, + backup: d.Backup, longOps: d.LongOps, repl: d.Repl, updater: d.Updater, started: time.Now(), version: d.Version, build: d.Build, zfsVersion: d.ZFSVersion, loginLimiter: newLoginLimiter(), @@ -150,6 +154,9 @@ func (s *Server) Handler() http.Handler { a.HandleFunc("GET /api/alerts", s.listAlerts) a.HandleFunc("POST /api/alerts/{id}/ack", s.ackAlert) a.HandleFunc("GET /api/overview", s.getOverview) + // canales de alerta (#134): estado (sin secretos) y prueba de envío + a.HandleFunc("GET /api/channels", s.auth.RequireAdmin(s.getChannels)) + a.HandleFunc("POST /api/channels/{name}/test", s.auth.RequireAdmin(s.testChannel)) a.HandleFunc("GET /api/system-timers", s.listSystemTimers) a.HandleFunc("POST /api/system-timers/schedule", s.auth.RequireAdmin(s.sysTimerSchedule)) a.HandleFunc("POST /api/system-timers/migrate", s.auth.RequireAdmin(s.sysTimerMigrate)) diff --git a/main.go b/main.go index 7055a13..54871be 100644 --- a/main.go +++ b/main.go @@ -128,16 +128,24 @@ func main() { // la ventana de silencio. En demo o sin VAPID queda inerte. go pushSender.RunQueue(ctx) - // Canales ntfy/gotify/syslog (#86): inerte si no hay ninguna configurada. - channelsClient := channels.New( - cfg.NtfyURL, cfg.NtfyToken, - cfg.GotifyURL, cfg.GotifyToken, - cfg.SyslogHost, cfg.SyslogPort, cfg.SyslogProto, cfg.SyslogFacility, - ) + // Canales ntfy/gotify/telegram/syslog (#86, #134): inertes si no hay + // ninguno configurado. + channelsClient := channels.New(channels.Config{ + NtfyURL: cfg.NtfyURL, + NtfyToken: cfg.NtfyToken, + GotifyURL: cfg.GotifyURL, + GotifyToken: cfg.GotifyToken, + TelegramBotToken: cfg.TelegramBotToken, + TelegramChatID: cfg.TelegramChatID, + SyslogHost: cfg.SyslogHost, + SyslogPort: cfg.SyslogPort, + SyslogProto: cfg.SyslogProto, + SyslogFacility: cfg.SyslogFacility, + }) if channelsClient.Enabled() { alerter.SetChannels(channelsClient) - log.Printf("canales de alerta configurados (ntfy=%v gotify=%v syslog=%v)", - cfg.NtfyURL != "", cfg.GotifyURL != "", cfg.SyslogHost != "") + log.Printf("canales de alerta configurados (ntfy=%v gotify=%v telegram=%v syslog=%v)", + cfg.NtfyURL != "", cfg.GotifyURL != "", cfg.TelegramBotToken != "" && cfg.TelegramChatID != "", cfg.SyslogHost != "") } // Colectores (reales o mock) + providers para los handlers. @@ -183,7 +191,8 @@ func main() { Perf: providers.Perf, Caps: providers.Caps, Actions: act, Sched: sched, Jobs: jobStore, Hub: h, Push: pushSender, Backup: backupStore, LongOps: longOps, Repl: replRunner, Updater: updaterSvc, - Version: version, Build: build, ZFSVersion: zfsVersion, + Channels: channelsClient, + Version: version, Build: build, ZFSVersion: zfsVersion, }) mux := http.NewServeMux() From 5bd94602d0251333d0a14cc5963d2982df6e66c5 Mon Sep 17 00:00:00 2001 From: Nacho Date: Sun, 13 Sep 2026 11:21:49 +0200 Subject: [PATCH 2/6] feat(ui): add alert channels panel in Settings (#134) --- web/src/components/icons.tsx | 1 + web/src/data/http.ts | 7 ++- web/src/data/mock.ts | 16 +++++- web/src/data/provider.ts | 6 ++- web/src/data/types.ts | 9 ++++ web/src/ui/i18n.ts | 28 +++++++++++ web/src/views/Settings.tsx | 98 ++++++++++++++++++++++++++++++++++-- 7 files changed, 159 insertions(+), 6 deletions(-) diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 2dbd666..79ba534 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -20,6 +20,7 @@ export const IconTask = (p: P) => base(p, <> base(p, <>); export const IconGear = (p: P) => base(p, <>); export const IconBell = (p: P) => base(p, <>); +export const IconSend = (p: P) => base(p, <>); export const IconMoon = (p: P) => base(p, ); export const IconSun = (p: P) => base(p, <>); export const IconChev = (p: P) => base({ size: 15, strokeWidth: 2.4, ...p }, ); diff --git a/web/src/data/http.ts b/web/src/data/http.ts index 90b84b7..5ddf2f1 100644 --- a/web/src/data/http.ts +++ b/web/src/data/http.ts @@ -3,7 +3,7 @@ import type { DataProvider } from './provider'; import { ApiError } from './types'; import { notifyAuthExpired } from './events'; import type { - ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, + ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, Recommendation, ReplicationJob, ReplicationSSHKey, ReplicationTestResult, SessionUser, Settings, SeriesResp, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UpdateReplicationReq, UpdateStatus, UserInfo, VersionInfo, @@ -86,6 +86,11 @@ export class HttpProvider implements DataProvider { getBackupStatus = () => get('/backup/status'); runBackup = () => post('/backup/run'); + getChannels = async (): Promise => { + const r = await get<{ channels: ChannelsStatus }>('/channels'); + return r.channels; + }; + testChannel = async (name: ChannelName) => { await post(`/channels/${enc(name)}/test`); }; importBackup = async (file: File): Promise => { // Body crudo (no JSON): el server verifica magic + quick_check y, si es // válido, hace swap y reinicia el proceso (202). diff --git a/web/src/data/mock.ts b/web/src/data/mock.ts index 58faa4e..40dc34f 100644 --- a/web/src/data/mock.ts +++ b/web/src/data/mock.ts @@ -5,7 +5,7 @@ import { emitEvent } from './events'; import { ApiError } from './types'; import { computeRecommendations } from './recs'; import type { - Alert, BackupFile, BackupStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateSnapshotReq, CreateUserReq, + Alert, BackupFile, BackupStatus, ChannelName, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateSnapshotReq, CreateUserReq, Dataset, DatasetProp, DatasetPropsResp, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, SeriesPoint, SeriesResp, SessionUser, Settings, Snapshot, SmartSelftest, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UserInfo, VersionInfo, APIKeyCreated, APIKeyInfo, @@ -282,6 +282,20 @@ export class MockProvider implements DataProvider { return { ...this.backupLast }; }; importBackup = async (_f: File) => { await delay(800); }; + // Canales de alerta (#134): estado de ejemplo (config por entorno en real). + getChannels = async (): Promise => { + await delay(); + return { + ntfy: { configured: true, detail: 'https://ntfy.sh/easyzfs-demo' }, + telegram: { configured: true, detail: '-1001234567890' }, + gotify: { configured: false }, + syslog: { configured: false }, + email: { configured: false }, + webhook: { configured: true }, + push: { configured: false }, + }; + }; + testChannel = async (_name: ChannelName) => { await delay(700); }; getAlerts = async () => { await delay(); return this.alerts.map((a) => ({ ...a })); }; ackAlert = async (id: number) => { await delay(); diff --git a/web/src/data/provider.ts b/web/src/data/provider.ts index 54470a3..b9b1635 100644 --- a/web/src/data/provider.ts +++ b/web/src/data/provider.ts @@ -1,6 +1,6 @@ // Interfaz DataProvider: abstrae el origen de datos (HTTP real o mock demo). import type { - ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, + ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, SeriesResp, Recommendation, ReplicationJob, ReplicationSSHKey, ReplicationTestResult, SessionUser, Settings, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UpdateReplicationReq, UpdateStatus, UserInfo, VersionInfo, @@ -29,6 +29,10 @@ export interface DataProvider { runBackup(): Promise; importBackup(file: File): Promise; + // Canales de alerta (#134): estado (admin) y prueba de envío + getChannels(): Promise; + testChannel(name: ChannelName): Promise; + // Auth y sesión login(user: string, password: string): Promise; login2FA(pending: string, code: string): Promise; diff --git a/web/src/data/types.ts b/web/src/data/types.ts index f2fc097..85b0861 100644 --- a/web/src/data/types.ts +++ b/web/src/data/types.ts @@ -137,6 +137,15 @@ export interface BackupStatus { dir: string; } +// Canales de alerta (#134): estado sin secretos (GET /api/channels) +export interface ChannelInfo { + configured: boolean; + // Detalle no sensible del destino (p.ej. el chat id de Telegram). + detail?: string; +} +export type ChannelName = 'ntfy' | 'gotify' | 'telegram' | 'syslog' | 'email' | 'webhook' | 'push'; +export type ChannelsStatus = Record; + export type AlertLevel = 'info' | 'warn' | 'crit'; export interface Alert { id: number; diff --git a/web/src/ui/i18n.ts b/web/src/ui/i18n.ts index 561b6cf..6eafad9 100644 --- a/web/src/ui/i18n.ts +++ b/web/src/ui/i18n.ts @@ -507,6 +507,20 @@ const es = { s_quiet_from: 'Desde', s_quiet_to: 'Hasta', s_quiet_err: 'La hora de inicio y la de fin deben ser distintas.', + s_ch_title: 'Canales de alerta', + s_ch_d: 'Canales de infraestructura para las alertas (independientes del push por dispositivo). Se configuran con variables de entorno en /etc/easyzfs/env y el servicio las lee al arrancar; aquí puedes comprobar que cada uno entrega de verdad.', + s_ch_ntfy: 'ntfy', + s_ch_gotify: 'Gotify', + s_ch_telegram: 'Telegram', + s_ch_syslog: 'Syslog', + s_ch_email: 'Email (SMTP)', + s_ch_webhook: 'Webhook saliente', + s_ch_push: 'Push por dispositivo (Web Push)', + s_ch_on: 'Configurado', + s_ch_off: 'Sin configurar', + s_ch_test: 'Probar', + s_ch_testing: 'Enviando…', + s_ch_ok: 'Notificación de prueba enviada', s_session: 'Mi sesión', s_mypass: 'Cambiar mi contraseña', s_profile: 'Mi perfil', s_displayname: 'Nombre', @@ -1109,6 +1123,20 @@ const en: Record = { s_quiet_from: 'From', s_quiet_to: 'Until', s_quiet_err: 'Start and end hours must be different.', + s_ch_title: 'Alert channels', + s_ch_d: 'Infrastructure channels for alerts (independent from per-device push). They are configured with environment variables in /etc/easyzfs/env and read at startup; here you can verify each one actually delivers.', + s_ch_ntfy: 'ntfy', + s_ch_gotify: 'Gotify', + s_ch_telegram: 'Telegram', + s_ch_syslog: 'Syslog', + s_ch_email: 'Email (SMTP)', + s_ch_webhook: 'Outgoing webhook', + s_ch_push: 'Per-device push (Web Push)', + s_ch_on: 'Configured', + s_ch_off: 'Not configured', + s_ch_test: 'Test', + s_ch_testing: 'Sending…', + s_ch_ok: 'Test notification sent', s_session: 'My session', s_mypass: 'Change my password', s_profile: 'My profile', s_displayname: 'Name', diff --git a/web/src/views/Settings.tsx b/web/src/views/Settings.tsx index 540e61a..8392868 100644 --- a/web/src/views/Settings.tsx +++ b/web/src/views/Settings.tsx @@ -11,7 +11,7 @@ import { getProvider } from '../data'; import { errorMessage, useApp } from '../ui/store'; import { fmtBytes, timeAgo } from '../ui/format'; import { Seg, Select, Spinner, Switch, Badge } from '../components/ui'; -import { Logo, IconCode, IconList, IconHeart, IconShield, IconCheck, IconUpload, IconCamera, IconChev, IconData, IconUser, IconX, IconTrash, IconLock, IconBell, IconMail, IconPencil, IconLogout, IconLanguages } from '../components/icons'; +import { Logo, IconCode, IconList, IconHeart, IconShield, IconCheck, IconUpload, IconCamera, IconChev, IconData, IconUser, IconX, IconTrash, IconLock, IconBell, IconMail, IconPencil, IconLogout, IconLanguages, IconSend } from '../components/icons'; import { useModal } from '../components/Modal'; import { AvatarCropDialog } from '../components/AvatarCropDialog'; import { TwoFAPanel } from '../components/TwoFA'; @@ -22,7 +22,7 @@ import { FAMILY_ACCENTS, getAccent, setAccent, getDensity, setDensity, getReduce import type { Density, ThemeFamily, ThemeMode } from '../ui/theme'; import type { I18nKey } from '../ui/i18n'; import type { - BackupStatus, Lang, PushAlertTipo, PushPreference, + BackupStatus, ChannelName, ChannelsStatus, Lang, PushAlertTipo, PushPreference, Settings as SettingsData, UpdateStatus, } from '../data/types'; @@ -691,6 +691,84 @@ function ProfileCard() { ); } +// Etiqueta traducida de cada canal de alerta (exhaustivo sobre ChannelName). +const CHANNEL_LABEL: Record = { + ntfy: 's_ch_ntfy', + gotify: 's_ch_gotify', + telegram: 's_ch_telegram', + syslog: 's_ch_syslog', + email: 's_ch_email', + webhook: 's_ch_webhook', + push: 's_ch_push', +}; + +// Orden de presentación y canales que admiten prueba de envío (los del +// paquete channels; email/webhook/push solo muestran su estado). +const CHANNEL_ORDER: ChannelName[] = ['telegram', 'ntfy', 'gotify', 'syslog', 'email', 'webhook', 'push']; +const CHANNEL_TESTABLE: ChannelName[] = ['telegram', 'ntfy', 'gotify', 'syslog']; + +// Tarjeta "Canales de alerta" (zona admin): estado de cada canal de +// infraestructura (config por entorno, sin secretos) + botón Probar para +// verificar que la entrega funciona de verdad. +function ChannelsPanel() { + const { t, notify } = useApp(); + const [ch, setCh] = useState(null); + const [busy, setBusy] = useState(null); + const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + let alive = true; + getProvider().getChannels().then((c) => alive && setCh(c)).catch(() => {}); + return () => { alive = false; }; + }, []); + + const test = async (name: ChannelName) => { + setBusy(name); + setResult(null); + try { + await getProvider().testChannel(name); + setResult({ ok: true, text: t('s_ch_ok') }); + notify(t('s_ch_ok'), 'ok'); + } catch (e) { + const m = errorMessage(e, t); + setResult({ ok: false, text: m }); + notify(m, 'err'); + } + setBusy(null); + }; + + if (!ch) return ; + return ( +
+

{t('s_ch_title')}

+

{t('s_ch_d')}

+
+ {CHANNEL_ORDER.map((name) => ( +
+
+
{t(CHANNEL_LABEL[name])}
+ {ch[name].detail &&
{ch[name].detail}
} +
+ + {ch[name].configured ? t('s_ch_on') : t('s_ch_off')} + + {CHANNEL_TESTABLE.includes(name) && ( + + )} +
+ ))} +
+ {result && ( +

{result.text}

+ )} +
+ ); +} + export default function Settings() { const { t, family, mode, themeEff, setFamily, setMode, isAdmin, user, refresh, reloadUser, logout, setLang, notify } = useApp(); const { openModal } = useModal(); @@ -704,7 +782,7 @@ export default function Settings() { const [reduceMotion, setReduceMotionState] = useState(getReduceMotion()); const [installEvt, setInstallEvt] = useState(null); const [installed, setInstalled] = useState(isStandalone()); - const [adminPanel, setAdminPanel] = useState<'backup' | 'users' | 'apikeys' | null>(null); + const [adminPanel, setAdminPanel] = useState<'backup' | 'users' | 'apikeys' | 'channels' | null>(null); // Snapshot de los umbrales guardados (para resaltar los campos modificados // y limpiar la marca al guardar) + mensaje de feedback local de la tarjeta. const [threshSaved, setThreshSaved] = useState<{ cap_warn_pct: number; cap_crit_pct: number; disk_temp_c: number } | null>(null); @@ -933,6 +1011,15 @@ export default function Settings() { + {/* 3c. Canales de alerta (desplegable, #134) */} + + {/* 4. Modo demo a la derecha */}
{t('s_demo_enable')} @@ -993,6 +1080,11 @@ export default function Settings() {
)} + {adminPanel === 'channels' && ( +
+ +
+ )} )} From 80638cb39015380e6328765e0f092c8a49611fde Mon Sep 17 00:00:00 2001 From: Nacho Date: Sun, 13 Sep 2026 11:55:40 +0200 Subject: [PATCH 3/6] feat(channels): make alert channels configurable from Settings (#134) --- internal/channels/channels.go | 191 ++++++++-------- internal/channels/channels_test.go | 4 +- internal/channels/store.go | 55 +++++ internal/channels/store_test.go | 77 +++++++ internal/db/db.go | 7 + internal/httpapi/channels_handlers.go | 244 +++++++++++++++++++-- internal/httpapi/channels_handlers_test.go | 136 ++++++++++-- internal/httpapi/httpapi.go | 112 +++++----- main.go | 53 +++-- 9 files changed, 667 insertions(+), 212 deletions(-) create mode 100644 internal/channels/store.go create mode 100644 internal/channels/store_test.go diff --git a/internal/channels/channels.go b/internal/channels/channels.go index 4b554d1..e322862 100644 --- a/internal/channels/channels.go +++ b/internal/channels/channels.go @@ -1,6 +1,7 @@ // Package channels — canales de alerta adicionales (#86, #134): ntfy, Gotify, -// Telegram y Syslog. Cada canal es inerte si no está configurado (env). Envíos -// best-effort con timeout acotado; los fallos se loguean y no rompen nada. +// Telegram y Syslog. Cada canal es inerte si no está configurado. La +// configuración vive en BD (editable desde Ajustes sin reiniciar) y se lee en +// cada envío; el env solo siembra la primera vez. // // Reglas de seguridad (lecciones de NetPulse #773 / NetGrip #298): // - Los secretos viajan en la URL de ntfy (el topic) y de Telegram (el bot @@ -26,76 +27,76 @@ import ( "path" "strconv" "strings" + "sync" "time" ) // ErrNotConfigured — el canal pedido no tiene configuración mínima. var ErrNotConfigured = errors.New("canal no configurado") -// Config — datos de configuración de los canales (viene de env). +// Config — configuración de los canales (BD; el env siembra la primera vez). +// Los tokens nunca se exponen en la API. type Config struct { - NtfyURL string - NtfyToken string + NtfyURL string `json:"ntfy_url,omitempty"` + NtfyToken string `json:"ntfy_token,omitempty"` - GotifyURL string - GotifyToken string + GotifyURL string `json:"gotify_url,omitempty"` + GotifyToken string `json:"gotify_token,omitempty"` - TelegramBotToken string - TelegramChatID string + TelegramBotToken string `json:"telegram_bot_token,omitempty"` + TelegramChatID string `json:"telegram_chat_id,omitempty"` - SyslogHost string - SyslogPort int - SyslogProto string - SyslogFacility int + SyslogHost string `json:"syslog_host,omitempty"` + SyslogPort int `json:"syslog_port,omitempty"` + SyslogProto string `json:"syslog_proto,omitempty"` + SyslogFacility int `json:"syslog_facility,omitempty"` } -// Client — conjunto de canales configurados. Los vacíos no envían. +// Client — conjunto de canales. La configuración es dinámica (Apply) para que +// los cambios desde Ajustes entren en vigor sin reiniciar el servicio. type Client struct { - ntfyURL string - ntfyToken string + mu sync.RWMutex + cfg Config - gotifyURL string - gotifyToken string - - telegramToken string - telegramChatID string - telegramBase string // base de la Bot API (inyectable en tests) - - syslogHost string - syslogPort int - syslogProto string - syslogFacility int - - http *http.Client + http *http.Client + telegramBase string // base de la Bot API (inyectable en tests) } -// New construye el cliente a partir de la configuración. Solo registra los -// canales con los datos mínimos; el resto queda inactivo. +// New construye el cliente con la configuración inicial. func New(cfg Config) *Client { return &Client{ - ntfyURL: cfg.NtfyURL, - ntfyToken: cfg.NtfyToken, - gotifyURL: cfg.GotifyURL, - gotifyToken: cfg.GotifyToken, - telegramToken: cfg.TelegramBotToken, - telegramChatID: cfg.TelegramChatID, - telegramBase: telegramAPIBase, - syslogHost: cfg.SyslogHost, - syslogPort: cfg.SyslogPort, - syslogProto: cfg.SyslogProto, - syslogFacility: cfg.SyslogFacility, - http: &http.Client{Timeout: 10 * time.Second}, + cfg: cfg, + http: &http.Client{Timeout: 10 * time.Second}, + telegramBase: telegramAPIBase, } } +// Apply reemplaza la configuración en caliente (tras guardar en Ajustes). +func (c *Client) Apply(cfg Config) { + c.mu.Lock() + c.cfg = cfg + c.mu.Unlock() +} + +// Config devuelve una copia de la configuración actual. +func (c *Client) Config() Config { + c.mu.RLock() + defer c.mu.RUnlock() + return c.cfg +} + // telegramReady — Telegram necesita token Y chat id. -func (c *Client) telegramReady() bool { - return c != nil && c.telegramToken != "" && c.telegramChatID != "" +func telegramReady(cfg Config) bool { + return cfg.TelegramBotToken != "" && cfg.TelegramChatID != "" } // Enabled — ¿hay al menos un canal configurado? func (c *Client) Enabled() bool { - return c != nil && (c.ntfyURL != "" || c.gotifyURL != "" || c.syslogHost != "" || c.telegramReady()) + if c == nil { + return false + } + cfg := c.Config() + return cfg.NtfyURL != "" || cfg.GotifyURL != "" || cfg.SyslogHost != "" || telegramReady(cfg) } // Configured — ¿el canal indicado tiene configuración mínima? Nombres válidos: @@ -104,29 +105,21 @@ func (c *Client) Configured(name string) bool { if c == nil { return false } + cfg := c.Config() switch name { case "ntfy": - return c.ntfyURL != "" + return cfg.NtfyURL != "" case "gotify": - return c.gotifyURL != "" + return cfg.GotifyURL != "" case "telegram": - return c.telegramReady() + return telegramReady(cfg) case "syslog": - return c.syslogHost != "" + return cfg.SyslogHost != "" default: return false } } -// TelegramChatID — chat de destino configurado (no es un secreto: el token -// nunca se expone). Vacío si Telegram no está configurado. -func (c *Client) TelegramChatID() string { - if c == nil { - return "" - } - return c.telegramChatID -} - // Send entrega la alerta a todos los canales configurados (best-effort). // El contexto lleva timeout; cada canal se envía en serie con su propio // límite. Los fallos se loguean (redactados) y no propagan. @@ -134,36 +127,37 @@ func (c *Client) Send(ctx context.Context, title, body string) { if c == nil { return } - if err := c.sendNtfy(ctx, title, body); err != nil { + cfg := c.Config() + if err := c.sendNtfy(ctx, cfg, title, body); err != nil { log.Printf("channels: ntfy: %v", err) } - if err := c.sendGotify(ctx, title, body); err != nil { + if err := c.sendGotify(ctx, cfg, title, body); err != nil { log.Printf("channels: gotify: %v", err) } - if err := c.sendTelegram(ctx, title, body); err != nil { + if err := c.sendTelegram(ctx, cfg, title, body); err != nil { log.Printf("channels: telegram: %v", err) } - if err := c.sendSyslog(ctx, title, body); err != nil { + if err := c.sendSyslog(ctx, cfg, title, body); err != nil { log.Printf("channels: syslog: %v", err) } } // Test envía un mensaje de prueba por el canal indicado y devuelve el error -// real (sin loguear) para que el endpoint HTTP pueda informar. ErrNotConfigured -// si el canal no está listo. +// real (sin loguear) para que el endpoint HTTP pueda informar. func (c *Client) Test(ctx context.Context, name, title, body string) error { if c == nil { return ErrNotConfigured } + cfg := c.Config() switch name { case "ntfy": - return c.sendNtfy(ctx, title, body) + return c.sendNtfy(ctx, cfg, title, body) case "gotify": - return c.sendGotify(ctx, title, body) + return c.sendGotify(ctx, cfg, title, body) case "telegram": - return c.sendTelegram(ctx, title, body) + return c.sendTelegram(ctx, cfg, title, body) case "syslog": - return c.sendSyslog(ctx, title, body) + return c.sendSyslog(ctx, cfg, title, body) default: return fmt.Errorf("canal desconocido: %q", name) } @@ -171,10 +165,9 @@ func (c *Client) Test(ctx context.Context, name, title, body string) error { // --- ntfy --- -// sendNtfy — POST JSON a NTFY_URL con Authorization Bearer si hay token. -// La URL configurada es el topic completo (p.ej. https://ntfy.sh/mialerta). -func (c *Client) sendNtfy(ctx context.Context, title, body string) error { - if c == nil || c.ntfyURL == "" { +// sendNtfy — POST JSON a la URL del topic con Authorization Bearer si hay token. +func (c *Client) sendNtfy(ctx context.Context, cfg Config, title, body string) error { + if cfg.NtfyURL == "" { return nil } payload, err := json.Marshal(map[string]string{ @@ -184,23 +177,23 @@ func (c *Client) sendNtfy(ctx context.Context, title, body string) error { if err != nil { return fmt.Errorf("marshal: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ntfyURL, bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.NtfyURL, bytes.NewReader(payload)) if err != nil { - return redactErr(err, topicOf(c.ntfyURL)) + return redactErr(err, topicOf(cfg.NtfyURL)) } req.Header.Set("Content-Type", "application/json") - if c.ntfyToken != "" { - req.Header.Set("Authorization", "Bearer "+c.ntfyToken) + if cfg.NtfyToken != "" { + req.Header.Set("Authorization", "Bearer "+cfg.NtfyToken) } // El topic (secreto) va en la URL: redactarlo en cualquier error. - return c.doRetry(req, topicOf(c.ntfyURL)) + return c.doRetry(req, topicOf(cfg.NtfyURL)) } // --- gotify --- -// sendGotify — POST JSON a GOTIFY_URL/message con X-Gotify-Key. -func (c *Client) sendGotify(ctx context.Context, title, body string) error { - if c == nil || c.gotifyURL == "" { +// sendGotify — POST JSON a /message con X-Gotify-Key. +func (c *Client) sendGotify(ctx context.Context, cfg Config, title, body string) error { + if cfg.GotifyURL == "" { return nil } payload, err := json.Marshal(map[string]string{ @@ -212,12 +205,12 @@ func (c *Client) sendGotify(ctx context.Context, title, body string) error { return fmt.Errorf("marshal: %w", err) } // El token viaja en cabecera, no en la URL: sin secreto que redactar. - req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(c.gotifyURL, "/")+"/message", bytes.NewReader(payload)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(cfg.GotifyURL, "/")+"/message", bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Gotify-Key", c.gotifyToken) + req.Header.Set("X-Gotify-Key", cfg.GotifyToken) return c.doRetry(req, "") } @@ -232,41 +225,41 @@ const maxMessageRunes = 4096 // sendTelegram — POST a /bot/sendMessage con chat_id y texto. // El token va en la URL: se redacta en cualquier error. -func (c *Client) sendTelegram(ctx context.Context, title, body string) error { - if !c.telegramReady() { +func (c *Client) sendTelegram(ctx context.Context, cfg Config, title, body string) error { + if !telegramReady(cfg) { return nil } text := truncateRunes(strings.TrimSpace(title+"\n"+body), maxMessageRunes) payload, err := json.Marshal(map[string]any{ - "chat_id": c.telegramChatID, + "chat_id": cfg.TelegramChatID, "text": text, "disable_web_page_preview": true, }) if err != nil { return fmt.Errorf("marshal: %w", err) } - endpoint := c.telegramBase + "/bot" + c.telegramToken + "/sendMessage" + endpoint := c.telegramBase + "/bot" + cfg.TelegramBotToken + "/sendMessage" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { - return redactErr(err, c.telegramToken) + return redactErr(err, cfg.TelegramBotToken) } req.Header.Set("Content-Type", "application/json") - return c.doRetry(req, c.telegramToken) + return c.doRetry(req, cfg.TelegramBotToken) } // --- syslog --- // sendSyslog — datagrama RFC 3164 (PRI + timestamp + host + texto) por UDP/TCP. -func (c *Client) sendSyslog(ctx context.Context, title, body string) error { - if c == nil || c.syslogHost == "" { +func (c *Client) sendSyslog(ctx context.Context, cfg Config, title, body string) error { + if cfg.SyslogHost == "" { return nil } // facility*8 + severity(1=notice); fallback 14 (1*8+1=9 → user.notice). - pri := c.syslogFacility*8 + 1 + pri := cfg.SyslogFacility*8 + 1 msg := fmt.Sprintf("<%d>%s EasyZFS[%d]: %s: %s", pri, time.Now().Format("Jan _2 15:04:05"), 0, title, truncateRunes(body, maxMessageRunes)) - addr := net.JoinHostPort(c.syslogHost, strconv.Itoa(c.syslogPort)) - if c.syslogProto == "tcp" { + addr := net.JoinHostPort(cfg.SyslogHost, strconv.Itoa(cfg.SyslogPort)) + if cfg.SyslogProto == "tcp" { var d net.Dialer conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { @@ -410,6 +403,16 @@ func topicOf(rawURL string) string { return t } +// NtfyServer — servidor de una URL de ntfy sin el topic (para la API: el topic +// es la contraseña y no se expone). +func NtfyServer(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "" + } + return u.Scheme + "://" + u.Host +} + // truncateRunes — recorta a max runas (no bytes) y añade elipsis. func truncateRunes(s string, max int) string { if max <= 0 { diff --git a/internal/channels/channels_test.go b/internal/channels/channels_test.go index 587c041..c38361e 100644 --- a/internal/channels/channels_test.go +++ b/internal/channels/channels_test.go @@ -304,8 +304,8 @@ func TestConfigured(t *testing.T) { if c.Configured("email") { t.Fatal("email no es un canal del paquete") } - if c.TelegramChatID() != "1" { - t.Fatalf("chat id %q", c.TelegramChatID()) + if c.Config().TelegramChatID != "1" { + t.Fatalf("chat id %q", c.Config().TelegramChatID) } } diff --git a/internal/channels/store.go b/internal/channels/store.go new file mode 100644 index 0000000..b53d501 --- /dev/null +++ b/internal/channels/store.go @@ -0,0 +1,55 @@ +// store.go — persistencia de la configuración de canales en SQLite (#134). +// Una fila única ('all') con el Config completo en JSON. Los secretos viven +// aquí y NUNCA se devuelven por la API (solo "tokenSet"). +package channels + +import ( + "context" + "database/sql" + "encoding/json" + "errors" +) + +// configRowName — clave de la fila única con la config de canales. +const configRowName = "all" + +// Store persiste la config de canales. +type Store struct { + db *sql.DB +} + +// NewStore crea el store sobre la BD de la app. +func NewStore(d *sql.DB) *Store { + return &Store{db: d} +} + +// Load lee la config persistida. ok=false si aún no hay fila (primera vez: +// se siembra desde el entorno). +func (s *Store) Load(ctx context.Context) (cfg Config, ok bool, err error) { + var raw string + err = s.db.QueryRowContext(ctx, + "SELECT json FROM channel_configs WHERE name=?", configRowName).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return Config{}, false, nil + } + if err != nil { + return Config{}, false, err + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return Config{}, false, err + } + return cfg, true, nil +} + +// Save persiste la config completa (upsert de la fila única). +func (s *Store) Save(ctx context.Context, cfg Config) error { + raw, err := json.Marshal(cfg) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO channel_configs(name, json, updated_at) VALUES(?,?,datetime('now')) + ON CONFLICT(name) DO UPDATE SET json=excluded.json, updated_at=datetime('now')`, + configRowName, string(raw)) + return err +} diff --git a/internal/channels/store_test.go b/internal/channels/store_test.go new file mode 100644 index 0000000..fdfbe74 --- /dev/null +++ b/internal/channels/store_test.go @@ -0,0 +1,77 @@ +// store_test.go — persistencia de la config de canales (#134) y aplicación en +// caliente (sin reiniciar). +package channels + +import ( + "context" + "testing" + + "easyzfs/internal/db" +) + +// Roundtrip: Save/Load conserva todos los campos (incluidos secretos) y el +// primer Load (sin fila) lo indica con ok=false. +func TestStoreRoundtrip(t *testing.T) { + d, err := db.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer d.Close() + if err := db.Migrate(context.Background(), d); err != nil { + t.Fatal(err) + } + s := NewStore(d) + + if _, ok, err := s.Load(context.Background()); err != nil || ok { + t.Fatalf("sin fila: ok=%v err=%v (esperado ok=false, err=nil)", ok, err) + } + + in := Config{ + NtfyURL: "https://ntfy.sh/topic-secreto", + NtfyToken: "tok-ntfy", + GotifyURL: "https://gotify.example.com", + GotifyToken: "tok-gotify", + TelegramBotToken: "123:ABC", + TelegramChatID: "-1009", + SyslogHost: "127.0.0.1", + SyslogPort: 514, + SyslogProto: "udp", + SyslogFacility: 1, + } + if err := s.Save(context.Background(), in); err != nil { + t.Fatalf("save: %v", err) + } + out, ok, err := s.Load(context.Background()) + if err != nil || !ok { + t.Fatalf("load: ok=%v err=%v", ok, err) + } + if out != in { + t.Fatalf("roundtrip distinto:\n in=%+v\nout=%+v", in, out) + } +} + +// Apply en caliente: un cliente vacío pasa a tener canal sin reiniciar. +func TestApplyLive(t *testing.T) { + c := New(Config{}) + if c.Enabled() || c.Configured("ntfy") { + t.Fatal("sin config no debe estar habilitado") + } + c.Apply(Config{NtfyURL: "https://ntfy.sh/x"}) + if !c.Enabled() || !c.Configured("ntfy") { + t.Fatal("Apply debe activar el canal en caliente") + } + c.Apply(Config{}) + if c.Enabled() { + t.Fatal("Apply vacío debe desactivar") + } +} + +// NtfyServer oculta el topic (secreto) y conserva el servidor. +func TestNtfyServer(t *testing.T) { + if got := NtfyServer("https://ntfy.sh/mi-topic-secreto"); got != "https://ntfy.sh" { + t.Fatalf("NtfyServer = %q", got) + } + if got := NtfyServer(""); got != "" { + t.Fatalf("NtfyServer vacío = %q", got) + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 771dce4..14ca2ce 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -172,6 +172,13 @@ var migrations = []string{ created_at TEXT NOT NULL DEFAULT (datetime('now')), last_used TEXT );`, + // v22: canales de alerta editables desde Ajustes (#134). Una fila única + // con la config completa (incluidos secretos, que nunca salen por la API). + `CREATE TABLE IF NOT EXISTS channel_configs ( + name TEXT PRIMARY KEY, + json TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + );`, } // Open abre la BD con WAL, busy_timeout y una sola conexión escritora. diff --git a/internal/httpapi/channels_handlers.go b/internal/httpapi/channels_handlers.go index e38ffcb..8e57295 100644 --- a/internal/httpapi/channels_handlers.go +++ b/internal/httpapi/channels_handlers.go @@ -1,25 +1,54 @@ -// channels_handlers.go — estado de los canales de alerta y prueba de envío -// (#134). Los secretos (bot token de Telegram, token de ntfy/Gotify) NUNCA -// salen en la respuesta; el chat id de Telegram sí se muestra porque no es una -// credencial (permite al admin confirmar el destino configurado). +// channels_handlers.go — canales de alerta (#134): estado, configuración y +// prueba de envío. Los secretos (bot token de Telegram, tokens de ntfy/Gotify) +// NUNCA salen en la respuesta: solo un booleano "token_set"/"topic_set". +// La configuración se guarda en BD y entra en vigor sin reiniciar. package httpapi import ( "context" "log" "net/http" + "net/url" + "regexp" + "strings" "time" "easyzfs/internal/auth" + "easyzfs/internal/channels" ) -// channelInfo — estado de un canal para la UI. +// channelInfo — estado saneado de un canal para la UI. type channelInfo struct { Configured bool `json:"configured"` - Detail string `json:"detail,omitempty"` + Server string `json:"server,omitempty"` // ntfy: servidor sin topic + URL string `json:"url,omitempty"` // gotify + ChatID string `json:"chat_id,omitempty"` // telegram + Host string `json:"host,omitempty"` // syslog + Port int `json:"port,omitempty"` + Proto string `json:"proto,omitempty"` + Facility int `json:"facility,omitempty"` + TokenSet bool `json:"token_set,omitempty"` + TopicSet bool `json:"topic_set,omitempty"` + Editable bool `json:"editable"` } -// testableChannel — canales del paquete channels que admiten prueba de envío. +// channelPatch — campos editables (punteros: ausente = no tocar). +// token vacío o ausente = conservar; clear_token = borrar. +type channelPatch struct { + URL *string `json:"url"` + Token *string `json:"token"` + ClearToken bool `json:"clear_token"` + ChatID *string `json:"chat_id"` + Host *string `json:"host"` + Port *int `json:"port"` + Proto *string `json:"proto"` + Facility *int `json:"facility"` +} + +// telegramTokenRe — formato del token de @BotFather: :. +var telegramTokenRe = regexp.MustCompile(`^\d+:[A-Za-z0-9_-]{10,}$`) + +// testableChannel — canales que admiten prueba de envío y edición. func testableChannel(name string) bool { switch name { case "ntfy", "gotify", "telegram", "syslog": @@ -28,19 +57,41 @@ func testableChannel(name string) bool { return false } -// getChannels — GET /api/channels (admin): estado de cada canal de alerta. -// Incluye los canales de infraestructura (ntfy/Gotify/Telegram/syslog), email, -// webhook y Web Push. Nunca expone secretos. +// getChannels — GET /api/channels (admin): estado de cada canal. Incluye los +// canales de infraestructura (editables) y email/webhook/push (solo estado: +// se configuran por entorno o en otras secciones). Nunca expone secretos. func (s *Server) getChannels(w http.ResponseWriter, r *http.Request) { - out := map[string]channelInfo{} - for _, name := range []string{"ntfy", "gotify", "telegram", "syslog"} { - info := channelInfo{Configured: s.channels.Configured(name)} - if name == "telegram" && info.Configured { - info.Detail = s.channels.TelegramChatID() - } - out[name] = info + cfg := s.channels.Config() + out := map[string]channelInfo{ + "ntfy": { + Configured: cfg.NtfyURL != "", + Server: channels.NtfyServer(cfg.NtfyURL), + TopicSet: cfg.NtfyURL != "", + TokenSet: cfg.NtfyToken != "", + Editable: true, + }, + "gotify": { + Configured: cfg.GotifyURL != "", + URL: cfg.GotifyURL, + TokenSet: cfg.GotifyToken != "", + Editable: true, + }, + "telegram": { + Configured: cfg.TelegramBotToken != "" && cfg.TelegramChatID != "", + ChatID: cfg.TelegramChatID, + TokenSet: cfg.TelegramBotToken != "", + Editable: true, + }, + "syslog": { + Configured: cfg.SyslogHost != "", + Host: cfg.SyslogHost, + Port: cfg.SyslogPort, + Proto: cfg.SyslogProto, + Facility: cfg.SyslogFacility, + Editable: true, + }, } - // Email: operativo con SMTP_HOST + SMTP_FROM (mismo criterio que el wiring). + // Email: operativo con SMTP_HOST + SMTP_FROM (env; sin edición aquí). out["email"] = channelInfo{Configured: s.cfg.SMTPHost != "" && s.cfg.SMTPFrom != ""} // Webhook saliente: su URL vive en settings (BD), no en env. whConfigured := false @@ -54,6 +105,161 @@ func (s *Server) getChannels(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"channels": out}) } +// putChannel — PUT /api/channels/{name} (admin): guarda la config del canal y +// la aplica en caliente (sin reiniciar). Valida antes de persistir. +func (s *Server) putChannel(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !testableChannel(name) { + writeErr(w, http.StatusNotFound, "unknown_channel", "canal desconocido: "+name) + return + } + var p channelPatch + if !decodeJSON(w, r, &p) { + return + } + cfg := s.channels.Config() + switch name { + case "ntfy": + // La URL incluye el topic (secreto): write-only, vacío conserva. + // clear_token solo afecta al token, nunca a la URL. + applyValue(&cfg.NtfyURL, p.URL, false) + applyValue(&cfg.NtfyToken, p.Token, p.ClearToken) + case "gotify": + if p.URL != nil { + cfg.GotifyURL = strings.TrimSpace(*p.URL) + } + applyValue(&cfg.GotifyToken, p.Token, p.ClearToken) + case "telegram": + applyValue(&cfg.TelegramBotToken, p.Token, p.ClearToken) + if p.ChatID != nil { + cfg.TelegramChatID = strings.TrimSpace(*p.ChatID) + } + case "syslog": + if p.Host != nil { + cfg.SyslogHost = strings.TrimSpace(*p.Host) + } + if p.Port != nil { + cfg.SyslogPort = *p.Port + } + if p.Proto != nil { + cfg.SyslogProto = strings.TrimSpace(*p.Proto) + } + if p.Facility != nil { + cfg.SyslogFacility = *p.Facility + } + // Defaults al activar el canal sin especificarlos. + if cfg.SyslogHost != "" { + if cfg.SyslogPort == 0 { + cfg.SyslogPort = 514 + } + if cfg.SyslogProto == "" { + cfg.SyslogProto = "udp" + } + } + } + if msg, errCode := validateChannel(name, cfg); errCode != "" { + writeErr(w, http.StatusBadRequest, errCode, msg) + return + } + if err := s.channelStore.Save(r.Context(), cfg); err != nil { + log.Printf("channels: guardar %s: %v", name, err) + writeErr(w, http.StatusInternalServerError, "save_failed", "no se pudo guardar la configuración") + return + } + s.channels.Apply(cfg) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "channel": name}) +} + +// deleteChannel — DELETE /api/channels/{name} (admin): desactiva el canal. +func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if !testableChannel(name) { + writeErr(w, http.StatusNotFound, "unknown_channel", "canal desconocido: "+name) + return + } + cfg := s.channels.Config() + switch name { + case "ntfy": + cfg.NtfyURL, cfg.NtfyToken = "", "" + case "gotify": + cfg.GotifyURL, cfg.GotifyToken = "", "" + case "telegram": + cfg.TelegramBotToken, cfg.TelegramChatID = "", "" + case "syslog": + cfg.SyslogHost, cfg.SyslogPort, cfg.SyslogProto, cfg.SyslogFacility = "", 0, "", 0 + } + if err := s.channelStore.Save(r.Context(), cfg); err != nil { + log.Printf("channels: desactivar %s: %v", name, err) + writeErr(w, http.StatusInternalServerError, "save_failed", "no se pudo guardar la configuración") + return + } + s.channels.Apply(cfg) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "channel": name}) +} + +// applyValue — campo write-only: valor vacío/ausente conserva el actual; +// clear lo borra. (El valor es un puntero para distinguir "ausente" de "vacío".) +func applyValue(dst *string, val *string, clear bool) { + if clear { + *dst = "" + return + } + if val != nil && strings.TrimSpace(*val) != "" { + *dst = strings.TrimSpace(*val) + } +} + +// validateChannel — valida la config resultante del canal. Devuelve mensaje y +// código de error (vacíos si es válida). +func validateChannel(name string, cfg channels.Config) (string, string) { + switch name { + case "ntfy": + if cfg.NtfyURL == "" { + return "", "" // desactivado + } + u, err := url.Parse(cfg.NtfyURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "la URL de ntfy debe ser http(s)://servidor/topic", "invalid_url" + } + if strings.Trim(u.Path, "/") == "" { + return "la URL de ntfy debe incluir el topic (p.ej. https://ntfy.sh/mi-topic)", "invalid_topic" + } + case "gotify": + if cfg.GotifyURL == "" { + return "", "" + } + u, err := url.Parse(cfg.GotifyURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return "la URL de Gotify debe ser http(s)://servidor", "invalid_url" + } + case "telegram": + token, chat := cfg.TelegramBotToken != "", cfg.TelegramChatID != "" + if !token && !chat { + return "", "" // desactivado + } + if token != chat { + return "Telegram requiere bot token Y chat id (o ninguno de los dos)", "incomplete" + } + if !telegramTokenRe.MatchString(cfg.TelegramBotToken) { + return "el bot token no tiene el formato de @BotFather (:)", "invalid_token" + } + case "syslog": + if cfg.SyslogHost == "" { + return "", "" // desactivado + } + if cfg.SyslogPort < 1 || cfg.SyslogPort > 65535 { + return "el puerto de syslog debe estar entre 1 y 65535", "invalid_port" + } + if cfg.SyslogProto != "udp" && cfg.SyslogProto != "tcp" { + return "el protocolo de syslog debe ser udp o tcp", "invalid_proto" + } + if cfg.SyslogFacility < 0 || cfg.SyslogFacility > 23 { + return "la facility de syslog debe estar entre 0 y 23", "invalid_facility" + } + } + return "", "" +} + // testChannel — POST /api/channels/{name}/test (admin): envía una notificación // de prueba por el canal indicado. 400 si el canal no está configurado (no se // finge un éxito), 502 si el destino falla (mensaje ya redactado por el paquete). @@ -65,7 +271,7 @@ func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) { } if s.channels == nil || !s.channels.Configured(name) { writeErr(w, http.StatusBadRequest, "channel_not_configured", - "el canal "+name+" no está configurado (define sus variables de entorno y reinicia el servicio)") + "el canal "+name+" no está configurado") return } lang := "es" diff --git a/internal/httpapi/channels_handlers_test.go b/internal/httpapi/channels_handlers_test.go index 8647a59..9293d3b 100644 --- a/internal/httpapi/channels_handlers_test.go +++ b/internal/httpapi/channels_handlers_test.go @@ -1,6 +1,5 @@ // channels_handlers_test.go — endpoints de canales de alerta (#134): -// GET /api/channels nunca expone secretos y POST /api/channels/{name}/test -// exige canal configurado y propaga el fallo del destino. +// estado sin secretos, configuración en caliente y prueba de envío. package httpapi import ( @@ -18,7 +17,7 @@ import ( "easyzfs/internal/users" ) -// serverChannelsPrueba — servidor con BD migrada, admin y canales inyectados. +// serverChannelsPrueba — servidor con BD migrada, admin, canales y su store. func serverChannelsPrueba(t *testing.T, ch *channels.Client) (http.Handler, *http.Cookie) { t.Helper() d, err := db.Open(t.TempDir() + "/test.db") @@ -35,7 +34,10 @@ func serverChannelsPrueba(t *testing.T, ch *channels.Client) (http.Handler, *htt } cfg := &config.Config{} am := auth.NewManager(d, []byte("secreto-de-prueba-32-bytes-xxxxxxxx"), false) - srv := NewServer(Deps{Cfg: cfg, DB: d, Auth: am, Users: us, Channels: ch}) + srv := NewServer(Deps{ + Cfg: cfg, DB: d, Auth: am, Users: us, + Channels: ch, ChannelStore: channels.NewStore(d), + }) cookie, err := am.CreateSession(context.Background(), "admin") if err != nil { t.Fatalf("sesión: %v", err) @@ -43,7 +45,8 @@ func serverChannelsPrueba(t *testing.T, ch *channels.Client) (http.Handler, *htt return srv.Handler(), cookie } -// GET /api/channels: estado sin secretos (el token NUNCA sale; el chat id sí). +// GET /api/channels: estado sin secretos (el token y el topic NUNCA salen; el +// chat id y el servidor sí, no son credenciales). func TestGetChannelsNoSecrets(t *testing.T) { ch := channels.New(channels.Config{ NtfyURL: "https://ntfy.sh/topic-secreto", @@ -67,47 +70,134 @@ func TestGetChannelsNoSecrets(t *testing.T) { var resp struct { Channels map[string]struct { Configured bool `json:"configured"` - Detail string `json:"detail"` + Server string `json:"server"` + ChatID string `json:"chat_id"` + TokenSet bool `json:"token_set"` + TopicSet bool `json:"topic_set"` + Editable bool `json:"editable"` } `json:"channels"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body no JSON: %v", err) } - for _, name := range []string{"ntfy", "gotify", "telegram"} { - if !resp.Channels[name].Configured { - t.Errorf("%s debería estar configured", name) - } + if !resp.Channels["ntfy"].Configured || !resp.Channels["ntfy"].TopicSet || !resp.Channels["ntfy"].TokenSet { + t.Errorf("ntfy: %+v", resp.Channels["ntfy"]) + } + if resp.Channels["ntfy"].Server != "https://ntfy.sh" { + t.Errorf("ntfy server = %q, esperado sin topic", resp.Channels["ntfy"].Server) + } + if !resp.Channels["gotify"].Configured || !resp.Channels["gotify"].TokenSet { + t.Errorf("gotify: %+v", resp.Channels["gotify"]) + } + if !resp.Channels["telegram"].Configured || resp.Channels["telegram"].ChatID != "-100987654" { + t.Errorf("telegram: %+v", resp.Channels["telegram"]) } if resp.Channels["syslog"].Configured { t.Error("syslog no está configurado") } - if resp.Channels["telegram"].Detail != "-100987654" { - t.Errorf("detail de telegram = %q, esperado el chat id", resp.Channels["telegram"].Detail) - } - if resp.Channels["push"].Configured { - t.Error("push sin claves VAPID no debe estar configurado") + if !resp.Channels["telegram"].Editable || resp.Channels["email"].Editable { + t.Error("editable mal marcado (telegram sí, email no)") } } -// POST /api/channels/{name}/test: canal configurado → 200 y el destino recibe. -func TestTestChannelDelivers(t *testing.T) { +// PUT /api/channels/{name}: guarda en caliente, token write-only (vacío +// conserva, clear_token borra) y DELETE desactiva. +func TestPutAndDeleteChannel(t *testing.T) { var gotTitle string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var p map[string]string _ = json.NewDecoder(r.Body).Decode(&p) gotTitle = p["title"] w.WriteHeader(http.StatusOK) })) - defer srv.Close() + defer fake.Close() - ch := channels.New(channels.Config{NtfyURL: srv.URL}) + ch := channels.New(channels.Config{}) h, cookie := serverChannelsPrueba(t, ch) - w := doReq(t, h, cookie, "POST", "/api/channels/ntfy/test", "") + + // Configurar ntfy apuntando al fake. + w := doReq(t, h, cookie, "PUT", "/api/channels/ntfy", + `{"url":"`+fake.URL+`/topic-secreto","token":"tok-123"}`) if w.Code != http.StatusOK { - t.Fatalf("status %d: %s", w.Code, w.Body.String()) + t.Fatalf("PUT ntfy status %d: %s", w.Code, w.Body.String()) + } + if !ch.Configured("ntfy") { + t.Fatal("el canal debe estar activo sin reiniciar") + } + // El topic no se expone; el token tampoco (solo token_set). + gw := doReq(t, h, cookie, "GET", "/api/channels", "") + if strings.Contains(gw.Body.String(), "topic-secreto") || strings.Contains(gw.Body.String(), "tok-123") { + t.Fatalf("GET filtra secretos: %s", gw.Body.String()) + } + if !strings.Contains(gw.Body.String(), `"token_set":true`) { + t.Fatalf("GET debería marcar token_set: %s", gw.Body.String()) + } + + // Probar entrega end-to-end contra el fake (sin reiniciar). + tw := doReq(t, h, cookie, "POST", "/api/channels/ntfy/test", "") + if tw.Code != http.StatusOK { + t.Fatalf("test status %d: %s", tw.Code, tw.Body.String()) } if gotTitle == "" { - t.Fatal("el canal de prueba no recibió el mensaje") + t.Fatal("el fake no recibió la notificación") + } + + // Token vacío (ausente) conserva el existente. + w = doReq(t, h, cookie, "PUT", "/api/channels/ntfy", `{"url":"`+fake.URL+`/topic-secreto"}`) + if w.Code != http.StatusOK { + t.Fatalf("PUT sin token status %d: %s", w.Code, w.Body.String()) + } + if ch.Config().NtfyToken != "tok-123" { + t.Fatalf("token vacío debe conservar, quedó %q", ch.Config().NtfyToken) + } + + // clear_token borra el token (ntfy sigue activo: el token es opcional). + w = doReq(t, h, cookie, "PUT", "/api/channels/ntfy", `{"clear_token":true}`) + if w.Code != http.StatusOK { + t.Fatalf("PUT clear_token status %d: %s", w.Code, w.Body.String()) + } + if ch.Config().NtfyToken != "" || !ch.Configured("ntfy") { + t.Fatalf("clear_token debe borrar el token y mantener el canal: %+v", ch.Config()) + } + + // DELETE desactiva. + w = doReq(t, h, cookie, "DELETE", "/api/channels/ntfy", "") + if w.Code != http.StatusOK { + t.Fatalf("DELETE status %d: %s", w.Code, w.Body.String()) + } + if ch.Configured("ntfy") { + t.Fatal("DELETE debe desactivar el canal") + } +} + +// PUT inválido: validaciones por canal (sin persistir). +func TestPutChannelValidation(t *testing.T) { + ch := channels.New(channels.Config{}) + h, cookie := serverChannelsPrueba(t, ch) + casos := []struct { + canal, body, errCode string + }{ + {"ntfy", `{"url":"https://ntfy.sh"}`, "invalid_topic"}, + {"ntfy", `{"url":"ftp://ntfy.sh/x"}`, "invalid_url"}, + {"telegram", `{"token":"123:AAAAAAAAAA"}`, "incomplete"}, + {"telegram", `{"token":"malo","chat_id":"1"}`, "invalid_token"}, + {"syslog", `{"host":"127.0.0.1","port":70000}`, "invalid_port"}, + {"syslog", `{"host":"127.0.0.1","proto":"sctp"}`, "invalid_proto"}, + } + for _, c := range casos { + w := doReq(t, h, cookie, "PUT", "/api/channels/"+c.canal, c.body) + if w.Code != http.StatusBadRequest { + t.Errorf("%s %s: status %d, esperado 400", c.canal, c.body, w.Code) + continue + } + var resp map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp["error"] != c.errCode { + t.Errorf("%s %s: error %q, esperado %q", c.canal, c.body, resp["error"], c.errCode) + } + } + if ch.Enabled() { + t.Fatal("una config inválida no debe persistir") } } diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 436d579..3e33d38 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -35,63 +35,65 @@ import ( // Server — dependencias inyectadas desde main (sin framework de DI). type Server struct { - cfg *config.Config - db *sql.DB - auth *auth.Manager - users *users.Store - apiKeys *apikeys.Store - alerter *alerts.Alerter - settings *settings.Store - pools collectors.PoolProvider - disks collectors.DiskProvider - sysTimers collectors.SysTimerProvider - perf collectors.PerfProvider - caps collectors.CapProvider - act *actions.Service - sched *scheduler.Scheduler - jstore *scheduler.Store - h *hub.Hub - push *push.Sender - channels *channels.Client - backup *backup.Store - longOps *longops.Manager - repl *replication.Runner - updater *updater.Updater - started time.Time - version string - build string - zfsVersion string + cfg *config.Config + db *sql.DB + auth *auth.Manager + users *users.Store + apiKeys *apikeys.Store + alerter *alerts.Alerter + settings *settings.Store + pools collectors.PoolProvider + disks collectors.DiskProvider + sysTimers collectors.SysTimerProvider + perf collectors.PerfProvider + caps collectors.CapProvider + act *actions.Service + sched *scheduler.Scheduler + jstore *scheduler.Store + h *hub.Hub + push *push.Sender + channels *channels.Client + channelStore *channels.Store + backup *backup.Store + longOps *longops.Manager + repl *replication.Runner + updater *updater.Updater + started time.Time + version string + build string + zfsVersion string loginLimiter *loginLimiter // rate limit de /api/login (IP+usuario) } // Deps — parámetros del constructor. type Deps struct { - Cfg *config.Config - DB *sql.DB - Auth *auth.Manager - Users *users.Store - APIKeys *apikeys.Store - Alerter *alerts.Alerter - Settings *settings.Store - Pools collectors.PoolProvider - Disks collectors.DiskProvider - SysTimers collectors.SysTimerProvider - Perf collectors.PerfProvider - Caps collectors.CapProvider - Actions *actions.Service - Sched *scheduler.Scheduler - Jobs *scheduler.Store - Hub *hub.Hub - Push *push.Sender - Channels *channels.Client - Backup *backup.Store - LongOps *longops.Manager - Repl *replication.Runner - Updater *updater.Updater - Version string - Build string - ZFSVersion string + Cfg *config.Config + DB *sql.DB + Auth *auth.Manager + Users *users.Store + APIKeys *apikeys.Store + Alerter *alerts.Alerter + Settings *settings.Store + Pools collectors.PoolProvider + Disks collectors.DiskProvider + SysTimers collectors.SysTimerProvider + Perf collectors.PerfProvider + Caps collectors.CapProvider + Actions *actions.Service + Sched *scheduler.Scheduler + Jobs *scheduler.Store + Hub *hub.Hub + Push *push.Sender + Channels *channels.Client + ChannelStore *channels.Store + Backup *backup.Store + LongOps *longops.Manager + Repl *replication.Runner + Updater *updater.Updater + Version string + Build string + ZFSVersion string } // NewServer crea el servidor del API. @@ -102,8 +104,8 @@ func NewServer(d Deps) *Server { pools: d.Pools, disks: d.Disks, sysTimers: d.SysTimers, perf: d.Perf, caps: d.Caps, act: d.Actions, sched: d.Sched, jstore: d.Jobs, h: d.Hub, push: d.Push, - channels: d.Channels, - backup: d.Backup, longOps: d.LongOps, repl: d.Repl, + channels: d.Channels, channelStore: d.ChannelStore, + backup: d.Backup, longOps: d.LongOps, repl: d.Repl, updater: d.Updater, started: time.Now(), version: d.Version, build: d.Build, zfsVersion: d.ZFSVersion, loginLimiter: newLoginLimiter(), @@ -154,8 +156,10 @@ func (s *Server) Handler() http.Handler { a.HandleFunc("GET /api/alerts", s.listAlerts) a.HandleFunc("POST /api/alerts/{id}/ack", s.ackAlert) a.HandleFunc("GET /api/overview", s.getOverview) - // canales de alerta (#134): estado (sin secretos) y prueba de envío + // canales de alerta (#134): estado (sin secretos), configuración y prueba a.HandleFunc("GET /api/channels", s.auth.RequireAdmin(s.getChannels)) + a.HandleFunc("PUT /api/channels/{name}", s.auth.RequireAdmin(s.putChannel)) + a.HandleFunc("DELETE /api/channels/{name}", s.auth.RequireAdmin(s.deleteChannel)) a.HandleFunc("POST /api/channels/{name}/test", s.auth.RequireAdmin(s.testChannel)) a.HandleFunc("GET /api/system-timers", s.listSystemTimers) a.HandleFunc("POST /api/system-timers/schedule", s.auth.RequireAdmin(s.sysTimerSchedule)) diff --git a/main.go b/main.go index 54871be..f0a06d1 100644 --- a/main.go +++ b/main.go @@ -128,25 +128,38 @@ func main() { // la ventana de silencio. En demo o sin VAPID queda inerte. go pushSender.RunQueue(ctx) - // Canales ntfy/gotify/telegram/syslog (#86, #134): inertes si no hay - // ninguno configurado. - channelsClient := channels.New(channels.Config{ - NtfyURL: cfg.NtfyURL, - NtfyToken: cfg.NtfyToken, - GotifyURL: cfg.GotifyURL, - GotifyToken: cfg.GotifyToken, - TelegramBotToken: cfg.TelegramBotToken, - TelegramChatID: cfg.TelegramChatID, - SyslogHost: cfg.SyslogHost, - SyslogPort: cfg.SyslogPort, - SyslogProto: cfg.SyslogProto, - SyslogFacility: cfg.SyslogFacility, - }) - if channelsClient.Enabled() { - alerter.SetChannels(channelsClient) - log.Printf("canales de alerta configurados (ntfy=%v gotify=%v telegram=%v syslog=%v)", - cfg.NtfyURL != "", cfg.GotifyURL != "", cfg.TelegramBotToken != "" && cfg.TelegramChatID != "", cfg.SyslogHost != "") + // Canales ntfy/gotify/telegram/syslog (#86, #134): la config vive en BD + // (editable desde Ajustes sin reiniciar); el entorno solo la siembra la + // primera vez (compatibilidad con la config previa por env). + channelStore := channels.NewStore(database) + channelCfg, ok, err := channelStore.Load(ctx) + if err != nil { + log.Printf("aviso: no se pudo leer la config de canales: %v", err) + } + if !ok { + channelCfg = channels.Config{ + NtfyURL: cfg.NtfyURL, + NtfyToken: cfg.NtfyToken, + GotifyURL: cfg.GotifyURL, + GotifyToken: cfg.GotifyToken, + TelegramBotToken: cfg.TelegramBotToken, + TelegramChatID: cfg.TelegramChatID, + SyslogHost: cfg.SyslogHost, + SyslogPort: cfg.SyslogPort, + SyslogProto: cfg.SyslogProto, + SyslogFacility: cfg.SyslogFacility, + } + if err := channelStore.Save(ctx, channelCfg); err != nil { + log.Printf("aviso: no se pudo sembrar la config de canales: %v", err) + } } + channelsClient := channels.New(channelCfg) + // El alerter SIEMPRE recibe el cliente: al configurar un canal desde la UI + // entra en vigor sin reiniciar (el cliente decide por config en cada evento). + alerter.SetChannels(channelsClient) + log.Printf("canales de alerta: ntfy=%v gotify=%v telegram=%v syslog=%v", + channelsClient.Configured("ntfy"), channelsClient.Configured("gotify"), + channelsClient.Configured("telegram"), channelsClient.Configured("syslog")) // Colectores (reales o mock) + providers para los handlers. providers, cols := collectors.Build(cfg, database, h, alerter) @@ -191,8 +204,8 @@ func main() { Perf: providers.Perf, Caps: providers.Caps, Actions: act, Sched: sched, Jobs: jobStore, Hub: h, Push: pushSender, Backup: backupStore, LongOps: longOps, Repl: replRunner, Updater: updaterSvc, - Channels: channelsClient, - Version: version, Build: build, ZFSVersion: zfsVersion, + Channels: channelsClient, ChannelStore: channelStore, + Version: version, Build: build, ZFSVersion: zfsVersion, }) mux := http.NewServeMux() From 1e6eaa5624c90968d2e31796d2591d407a1781f9 Mon Sep 17 00:00:00 2001 From: Nacho Date: Sun, 13 Sep 2026 11:55:40 +0200 Subject: [PATCH 4/6] feat(ui): edit alert channels in Settings (#134) --- web/src/data/http.ts | 4 +- web/src/data/mock.ts | 41 ++++++-- web/src/data/provider.ts | 6 +- web/src/data/types.ts | 28 +++++- web/src/ui/i18n.ts | 36 +++++++ web/src/views/Settings.tsx | 191 +++++++++++++++++++++++++++++++++---- 6 files changed, 270 insertions(+), 36 deletions(-) diff --git a/web/src/data/http.ts b/web/src/data/http.ts index 5ddf2f1..595e770 100644 --- a/web/src/data/http.ts +++ b/web/src/data/http.ts @@ -3,7 +3,7 @@ import type { DataProvider } from './provider'; import { ApiError } from './types'; import { notifyAuthExpired } from './events'; import type { - ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, + ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelPatch, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, Recommendation, ReplicationJob, ReplicationSSHKey, ReplicationTestResult, SessionUser, Settings, SeriesResp, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UpdateReplicationReq, UpdateStatus, UserInfo, VersionInfo, @@ -90,6 +90,8 @@ export class HttpProvider implements DataProvider { const r = await get<{ channels: ChannelsStatus }>('/channels'); return r.channels; }; + putChannel = async (name: ChannelName, patch: ChannelPatch) => { await put(`/channels/${enc(name)}`, patch); }; + deleteChannel = async (name: ChannelName) => { await del(`/channels/${enc(name)}`); }; testChannel = async (name: ChannelName) => { await post(`/channels/${enc(name)}/test`); }; importBackup = async (file: File): Promise => { // Body crudo (no JSON): el server verifica magic + quick_check y, si es diff --git a/web/src/data/mock.ts b/web/src/data/mock.ts index 40dc34f..97a377d 100644 --- a/web/src/data/mock.ts +++ b/web/src/data/mock.ts @@ -5,7 +5,7 @@ import { emitEvent } from './events'; import { ApiError } from './types'; import { computeRecommendations } from './recs'; import type { - Alert, BackupFile, BackupStatus, ChannelName, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateSnapshotReq, CreateUserReq, + Alert, BackupFile, BackupStatus, ChannelName, ChannelPatch, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateSnapshotReq, CreateUserReq, Dataset, DatasetProp, DatasetPropsResp, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, SeriesPoint, SeriesResp, SessionUser, Settings, Snapshot, SmartSelftest, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UserInfo, VersionInfo, APIKeyCreated, APIKeyInfo, @@ -70,6 +70,15 @@ export class MockProvider implements DataProvider { backup_enabled: true, backup_freq_hours: 24, backup_retention_days: 3, }; + private channels: ChannelsStatus = { + ntfy: { configured: true, server: 'https://ntfy.sh', topic_set: true, token_set: false, editable: true }, + telegram: { configured: true, chat_id: '-1001234567890', token_set: true, editable: true }, + gotify: { configured: false, editable: true }, + syslog: { configured: false, editable: true }, + email: { configured: false, editable: false }, + webhook: { configured: true, editable: false }, + push: { configured: false, editable: false }, + }; private backupLast: BackupFile | null = { file: 'app-20260801-030000.db', ts: iso(daysAgo(1, 3)), bytes: 318 * 1024, }; @@ -285,15 +294,27 @@ export class MockProvider implements DataProvider { // Canales de alerta (#134): estado de ejemplo (config por entorno en real). getChannels = async (): Promise => { await delay(); - return { - ntfy: { configured: true, detail: 'https://ntfy.sh/easyzfs-demo' }, - telegram: { configured: true, detail: '-1001234567890' }, - gotify: { configured: false }, - syslog: { configured: false }, - email: { configured: false }, - webhook: { configured: true }, - push: { configured: false }, - }; + return structuredClone(this.channels); + }; + putChannel = async (name: ChannelName, patch: ChannelPatch) => { + await delay(); + const c = this.channels[name]; + if (!c) return; + if (patch.url !== undefined && patch.url !== '') { + if (name === 'ntfy') { c.server = patch.url.replace(/\/[^/]*$/, ''); c.topic_set = true; } + else { c.url = patch.url; } + c.configured = true; + } + if (patch.token) { c.token_set = true; c.configured = true; } + if (patch.chat_id !== undefined) { c.chat_id = patch.chat_id; c.configured = true; } + if (patch.host !== undefined) { c.host = patch.host; c.configured = true; } + if (patch.port !== undefined) c.port = patch.port; + if (patch.proto !== undefined) c.proto = patch.proto; + if (patch.facility !== undefined) c.facility = patch.facility; + }; + deleteChannel = async (name: ChannelName) => { + await delay(); + this.channels[name] = { configured: false, editable: this.channels[name].editable }; }; testChannel = async (_name: ChannelName) => { await delay(700); }; getAlerts = async () => { await delay(); return this.alerts.map((a) => ({ ...a })); }; diff --git a/web/src/data/provider.ts b/web/src/data/provider.ts index b9b1635..744b41f 100644 --- a/web/src/data/provider.ts +++ b/web/src/data/provider.ts @@ -1,6 +1,6 @@ // Interfaz DataProvider: abstrae el origen de datos (HTTP real o mock demo). import type { - ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, + ActivityItem, Alert, APIKeyCreated, APIKeyInfo, BackupFile, BackupStatus, ChannelName, ChannelPatch, ChannelsStatus, CreateDatasetReq, CreateJobReq, CreatePoolReq, CreateReplicationReq, CreateSnapshotReq, CreateUserReq, Dataset, DatasetProp, DatasetPropsResp, DiffEntry, Disk, DiskSmartLogResp, DiskSmartResp, Job, JobHistoryItem, Lang, LoginResult, LongOp, Overview, Performance, Pool, PoolHistoryEntry, PushAlertTipo, PushPreference, PushQuietHours, PushSubscriptionJSON, SeriesResp, Recommendation, ReplicationJob, ReplicationSSHKey, ReplicationTestResult, SessionUser, Settings, SnapshotGroup, SystemTimer, SystemTimersResp, TwoFARecovery, TwoFASetup, TwoFAStatus, UpdateJobReq, UpdateReplicationReq, UpdateStatus, UserInfo, VersionInfo, @@ -29,8 +29,10 @@ export interface DataProvider { runBackup(): Promise; importBackup(file: File): Promise; - // Canales de alerta (#134): estado (admin) y prueba de envío + // Canales de alerta (#134): estado (admin), configuración y prueba de envío getChannels(): Promise; + putChannel(name: ChannelName, patch: ChannelPatch): Promise; + deleteChannel(name: ChannelName): Promise; testChannel(name: ChannelName): Promise; // Auth y sesión diff --git a/web/src/data/types.ts b/web/src/data/types.ts index 85b0861..db12ba9 100644 --- a/web/src/data/types.ts +++ b/web/src/data/types.ts @@ -137,15 +137,37 @@ export interface BackupStatus { dir: string; } -// Canales de alerta (#134): estado sin secretos (GET /api/channels) +// Canales de alerta (#134): estado saneado (GET /api/channels). Los secretos +// (tokens, topic de ntfy) NUNCA llegan: solo token_set/topic_set. export interface ChannelInfo { configured: boolean; - // Detalle no sensible del destino (p.ej. el chat id de Telegram). - detail?: string; + server?: string; // ntfy: servidor sin el topic + url?: string; // gotify + chat_id?: string; // telegram + host?: string; // syslog + port?: number; + proto?: string; + facility?: number; + token_set?: boolean; + topic_set?: boolean; + editable: boolean; } export type ChannelName = 'ntfy' | 'gotify' | 'telegram' | 'syslog' | 'email' | 'webhook' | 'push'; export type ChannelsStatus = Record; +// Cambios al guardar un canal: los campos write-only vacíos/ausentes conservan +// el valor actual; clear_token lo borra. +export interface ChannelPatch { + url?: string; + token?: string; + clear_token?: boolean; + chat_id?: string; + host?: string; + port?: number; + proto?: string; + facility?: number; +} + export type AlertLevel = 'info' | 'warn' | 'crit'; export interface Alert { id: number; diff --git a/web/src/ui/i18n.ts b/web/src/ui/i18n.ts index 6eafad9..43f37ef 100644 --- a/web/src/ui/i18n.ts +++ b/web/src/ui/i18n.ts @@ -521,6 +521,24 @@ const es = { s_ch_test: 'Probar', s_ch_testing: 'Enviando…', s_ch_ok: 'Notificación de prueba enviada', + s_ch_edit: 'Configurar', + s_ch_saved: 'Canal guardado', + s_ch_disabled: 'Canal desactivado', + s_ch_disable: 'Desactivar', + s_ch_env_only: 'Se configura por entorno (no editable aquí)', + s_ch_ntfy_url: 'URL del topic', + s_ch_ntfy_url_hint: 'El topic es la contraseña: no se muestra nunca. Déjalo vacío para conservar el actual.', + s_ch_token: 'Token', + s_ch_token_opt: 'Token (opcional)', + s_ch_token_hint: 'Déjalo vacío para conservar el actual.', + s_ch_gotify_url: 'URL del servidor', + s_ch_tg_token: 'Bot token', + s_ch_tg_chat: 'Chat id', + s_ch_syslog_host: 'Host', + s_ch_syslog_port: 'Puerto', + s_ch_syslog_proto: 'Protocolo', + s_ch_syslog_facility: 'Facility', + s_ch_topic_set: 'topic configurado', s_session: 'Mi sesión', s_mypass: 'Cambiar mi contraseña', s_profile: 'Mi perfil', s_displayname: 'Nombre', @@ -1137,6 +1155,24 @@ const en: Record = { s_ch_test: 'Test', s_ch_testing: 'Sending…', s_ch_ok: 'Test notification sent', + s_ch_edit: 'Configure', + s_ch_saved: 'Channel saved', + s_ch_disabled: 'Channel disabled', + s_ch_disable: 'Disable', + s_ch_env_only: 'Configured via environment (not editable here)', + s_ch_ntfy_url: 'Topic URL', + s_ch_ntfy_url_hint: 'The topic is the password: it is never shown. Leave empty to keep the current one.', + s_ch_token: 'Token', + s_ch_token_opt: 'Token (optional)', + s_ch_token_hint: 'Leave empty to keep the current one.', + s_ch_gotify_url: 'Server URL', + s_ch_tg_token: 'Bot token', + s_ch_tg_chat: 'Chat id', + s_ch_syslog_host: 'Host', + s_ch_syslog_port: 'Port', + s_ch_syslog_proto: 'Protocol', + s_ch_syslog_facility: 'Facility', + s_ch_topic_set: 'topic set', s_session: 'My session', s_mypass: 'Change my password', s_profile: 'My profile', s_displayname: 'Name', diff --git a/web/src/views/Settings.tsx b/web/src/views/Settings.tsx index 8392868..41df168 100644 --- a/web/src/views/Settings.tsx +++ b/web/src/views/Settings.tsx @@ -22,7 +22,7 @@ import { FAMILY_ACCENTS, getAccent, setAccent, getDensity, setDensity, getReduce import type { Density, ThemeFamily, ThemeMode } from '../ui/theme'; import type { I18nKey } from '../ui/i18n'; import type { - BackupStatus, ChannelName, ChannelsStatus, Lang, PushAlertTipo, PushPreference, + BackupStatus, ChannelInfo, ChannelName, ChannelPatch, ChannelsStatus, Lang, PushAlertTipo, PushPreference, Settings as SettingsData, UpdateStatus, } from '../data/types'; @@ -707,15 +707,152 @@ const CHANNEL_LABEL: Record = { const CHANNEL_ORDER: ChannelName[] = ['telegram', 'ntfy', 'gotify', 'syslog', 'email', 'webhook', 'push']; const CHANNEL_TESTABLE: ChannelName[] = ['telegram', 'ntfy', 'gotify', 'syslog']; -// Tarjeta "Canales de alerta" (zona admin): estado de cada canal de -// infraestructura (config por entorno, sin secretos) + botón Probar para -// verificar que la entrega funciona de verdad. +// Detalle no secreto de la fila de un canal. +function channelDetail(name: ChannelName, info: ChannelInfo, t: (k: I18nKey) => string): string { + switch (name) { + case 'ntfy': + return info.topic_set ? `${info.server ?? ''} · ${t('s_ch_topic_set')}` : (info.server ?? ''); + case 'gotify': + return info.url ?? ''; + case 'telegram': + return info.chat_id ? `chat ${info.chat_id}` : ''; + case 'syslog': + return info.host ? `${info.host}:${info.port ?? 514} (${info.proto ?? 'udp'})` : ''; + default: + return ''; + } +} + +// Formulario de configuración de un canal (se despliega bajo su fila). Los +// campos write-only (URL de ntfy, tokens) vacíos conservan el valor actual. +function ChannelForm({ name, info, onDone }: { name: ChannelName; info: ChannelInfo; onDone: () => void }) { + const { t, notify } = useApp(); + const [url, setUrl] = useState(name === 'gotify' ? (info.url ?? '') : ''); + const [token, setToken] = useState(''); + const [chatId, setChatId] = useState(info.chat_id ?? ''); + const [host, setHost] = useState(info.host ?? ''); + const [port, setPort] = useState(String(info.port ?? 514)); + const [proto, setProto] = useState(info.proto ?? 'udp'); + const [facility, setFacility] = useState(String(info.facility ?? 1)); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(''); + + const save = async () => { + setBusy(true); setErr(''); + try { + const patch: ChannelPatch = {}; + if (name === 'ntfy') { + if (url.trim()) patch.url = url.trim(); + if (token.trim()) patch.token = token.trim(); + } else if (name === 'gotify') { + patch.url = url.trim(); + if (token.trim()) patch.token = token.trim(); + } else if (name === 'telegram') { + if (token.trim()) patch.token = token.trim(); + patch.chat_id = chatId.trim(); + } else { + patch.host = host.trim(); + patch.port = +port; + patch.proto = proto; + patch.facility = +facility; + } + await getProvider().putChannel(name, patch); + notify(t('s_ch_saved'), 'ok'); + onDone(); + } catch (e) { setErr(errorMessage(e, t)); } + setBusy(false); + }; + + const disable = async () => { + setBusy(true); setErr(''); + try { + await getProvider().deleteChannel(name); + notify(t('s_ch_disabled'), 'ok'); + onDone(); + } catch (e) { setErr(errorMessage(e, t)); } + setBusy(false); + }; + + return ( +
+ {name === 'ntfy' && ( + <> + + setUrl(e.target.value)} /> +

{t('s_ch_ntfy_url_hint')}

+ + setToken(e.target.value)} /> +

{t('s_ch_token_hint')}

+ + )} + {name === 'gotify' && ( + <> + + setUrl(e.target.value)} /> + + setToken(e.target.value)} /> +

{t('s_ch_token_hint')}

+ + )} + {name === 'telegram' && ( + <> + + setToken(e.target.value)} /> +

{t('s_ch_token_hint')}

+ + setChatId(e.target.value)} /> + + )} + {name === 'syslog' && ( +
+
+ + setHost(e.target.value)} /> +
+
+ + setPort(e.target.value)} /> +
+
+ + setFacility(e.target.value)} /> +
+
+ )} + {err &&

{err}

} +
+ + {info.configured && ( + + )} + +
+
+ ); +} + +// Tarjeta "Canales de alerta" (zona admin): estado de cada canal (sin +// secretos), configuración en caliente y botón Probar. function ChannelsPanel() { const { t, notify } = useApp(); const [ch, setCh] = useState(null); + const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(null); const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null); + const load = () => getProvider().getChannels().then(setCh).catch(() => {}); useEffect(() => { let alive = true; getProvider().getChannels().then((c) => alive && setCh(c)).catch(() => {}); @@ -737,29 +874,43 @@ function ChannelsPanel() { setBusy(null); }; + const done = () => { setEditing(null); setResult(null); void load(); }; + if (!ch) return ; return (

{t('s_ch_title')}

{t('s_ch_d')}

- {CHANNEL_ORDER.map((name) => ( -
-
-
{t(CHANNEL_LABEL[name])}
- {ch[name].detail &&
{ch[name].detail}
} + {CHANNEL_ORDER.map((name) => { + const info = ch[name]; + return ( +
+
+
+
{t(CHANNEL_LABEL[name])}
+
{info.editable ? channelDetail(name, info, t) : t('s_ch_env_only')}
+
+ + {info.configured ? t('s_ch_on') : t('s_ch_off')} + + {CHANNEL_TESTABLE.includes(name) && ( + + )} + {info.editable && ( + + )} +
+ {editing === name && }
- - {ch[name].configured ? t('s_ch_on') : t('s_ch_off')} - - {CHANNEL_TESTABLE.includes(name) && ( - - )} -
- ))} + ); + })}
{result && (

Date: Sun, 13 Sep 2026 12:24:58 +0200 Subject: [PATCH 5/6] feat(channels): make email and webhook configurable from Settings (#134) --- internal/channels/channels.go | 12 ++ internal/httpapi/channels_handlers.go | 205 ++++++++++++++++++--- internal/httpapi/channels_handlers_test.go | 78 +++++++- internal/httpapi/httpapi.go | 4 + main.go | 36 ++-- 5 files changed, 296 insertions(+), 39 deletions(-) diff --git a/internal/channels/channels.go b/internal/channels/channels.go index e322862..2abb469 100644 --- a/internal/channels/channels.go +++ b/internal/channels/channels.go @@ -50,6 +50,14 @@ type Config struct { SyslogPort int `json:"syslog_port,omitempty"` SyslogProto string `json:"syslog_proto,omitempty"` SyslogFacility int `json:"syslog_facility,omitempty"` + + // Canal email (SMTP), editable desde Ajustes. La contraseña es write-only. + SMTPHost string `json:"smtp_host,omitempty"` + SMTPPort int `json:"smtp_port,omitempty"` + SMTPUser string `json:"smtp_user,omitempty"` + SMTPPass string `json:"smtp_pass,omitempty"` + SMTPFrom string `json:"smtp_from,omitempty"` + SMTPEncryption string `json:"smtp_encryption,omitempty"` } // Client — conjunto de canales. La configuración es dinámica (Apply) para que @@ -115,6 +123,10 @@ func (c *Client) Configured(name string) bool { return telegramReady(cfg) case "syslog": return cfg.SyslogHost != "" + case "email": + // El email no lo envía este paquete (lo hace el alerter con su + // Mailer), pero la UI consulta su estado aquí. + return cfg.SMTPHost != "" && cfg.SMTPFrom != "" default: return false } diff --git a/internal/httpapi/channels_handlers.go b/internal/httpapi/channels_handlers.go index 8e57295..c8a17e4 100644 --- a/internal/httpapi/channels_handlers.go +++ b/internal/httpapi/channels_handlers.go @@ -1,6 +1,6 @@ // channels_handlers.go — canales de alerta (#134): estado, configuración y -// prueba de envío. Los secretos (bot token de Telegram, tokens de ntfy/Gotify) -// NUNCA salen en la respuesta: solo un booleano "token_set"/"topic_set". +// prueba de envío. Los secretos (bot token de Telegram, tokens de ntfy/Gotify, +// contraseña SMTP) NUNCA salen en la respuesta: solo un booleano token_set. // La configuración se guarda en BD y entra en vigor sin reiniciar. package httpapi @@ -15,25 +15,29 @@ import ( "easyzfs/internal/auth" "easyzfs/internal/channels" + "easyzfs/internal/notifier" ) // channelInfo — estado saneado de un canal para la UI. type channelInfo struct { Configured bool `json:"configured"` Server string `json:"server,omitempty"` // ntfy: servidor sin topic - URL string `json:"url,omitempty"` // gotify + URL string `json:"url,omitempty"` // gotify / webhook ChatID string `json:"chat_id,omitempty"` // telegram - Host string `json:"host,omitempty"` // syslog + Host string `json:"host,omitempty"` // syslog / email Port int `json:"port,omitempty"` Proto string `json:"proto,omitempty"` Facility int `json:"facility,omitempty"` - TokenSet bool `json:"token_set,omitempty"` - TopicSet bool `json:"topic_set,omitempty"` + User string `json:"user,omitempty"` // email + From string `json:"from,omitempty"` // email + Encryption string `json:"encryption,omitempty"` // email + TokenSet bool `json:"token_set,omitempty"` // hay secreto guardado + TopicSet bool `json:"topic_set,omitempty"` // ntfy Editable bool `json:"editable"` } // channelPatch — campos editables (punteros: ausente = no tocar). -// token vacío o ausente = conservar; clear_token = borrar. +// Los secretos (token/pass) y la URL de ntfy son write-only: vacío conserva. type channelPatch struct { URL *string `json:"url"` Token *string `json:"token"` @@ -43,23 +47,33 @@ type channelPatch struct { Port *int `json:"port"` Proto *string `json:"proto"` Facility *int `json:"facility"` + User *string `json:"user"` + Pass *string `json:"pass"` + ClearPass bool `json:"clear_pass"` + From *string `json:"from"` + Encryption *string `json:"encryption"` } // telegramTokenRe — formato del token de @BotFather: :. var telegramTokenRe = regexp.MustCompile(`^\d+:[A-Za-z0-9_-]{10,}$`) -// testableChannel — canales que admiten prueba de envío y edición. +// testableChannel — canales que admiten prueba de envío. El webhook no: su +// entrega es asíncrona (cola + DLQ) y no tiene un resultado síncrono que mostrar. func testableChannel(name string) bool { switch name { - case "ntfy", "gotify", "telegram", "syslog": + case "ntfy", "gotify", "telegram", "syslog", "email": return true } return false } -// getChannels — GET /api/channels (admin): estado de cada canal. Incluye los -// canales de infraestructura (editables) y email/webhook/push (solo estado: -// se configuran por entorno o en otras secciones). Nunca expone secretos. +// editableChannel — canales configurables desde la UI. +func editableChannel(name string) bool { + return testableChannel(name) || name == "webhook" +} + +// getChannels — GET /api/channels (admin): estado de cada canal. Nunca expone +// secretos (tokens, contraseña SMTP, topic de ntfy). func (s *Server) getChannels(w http.ResponseWriter, r *http.Request) { cfg := s.channels.Config() out := map[string]channelInfo{ @@ -90,17 +104,25 @@ func (s *Server) getChannels(w http.ResponseWriter, r *http.Request) { Facility: cfg.SyslogFacility, Editable: true, }, + "email": { + Configured: cfg.SMTPHost != "" && cfg.SMTPFrom != "", + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + User: cfg.SMTPUser, + From: cfg.SMTPFrom, + Encryption: cfg.SMTPEncryption, + TokenSet: cfg.SMTPPass != "", + Editable: true, + }, } - // Email: operativo con SMTP_HOST + SMTP_FROM (env; sin edición aquí). - out["email"] = channelInfo{Configured: s.cfg.SMTPHost != "" && s.cfg.SMTPFrom != ""} - // Webhook saliente: su URL vive en settings (BD), no en env. - whConfigured := false + // Webhook saliente: su URL vive en settings (BD). + whURL := "" if s.settings != nil { if st, err := s.settings.Load(r.Context()); err == nil { - whConfigured = st.Webhook != "" + whURL = st.Webhook } } - out["webhook"] = channelInfo{Configured: whConfigured} + out["webhook"] = channelInfo{Configured: whURL != "", URL: whURL, Editable: true} out["push"] = channelInfo{Configured: s.cfg.PushEnabled()} writeJSON(w, http.StatusOK, map[string]any{"channels": out}) } @@ -109,7 +131,7 @@ func (s *Server) getChannels(w http.ResponseWriter, r *http.Request) { // la aplica en caliente (sin reiniciar). Valida antes de persistir. func (s *Server) putChannel(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - if !testableChannel(name) { + if !editableChannel(name) { writeErr(w, http.StatusNotFound, "unknown_channel", "canal desconocido: "+name) return } @@ -117,6 +139,13 @@ func (s *Server) putChannel(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &p) { return } + + // El webhook vive en settings, no en la config de canales. + if name == "webhook" { + s.putWebhook(w, r, p) + return + } + cfg := s.channels.Config() switch name { case "ntfy": @@ -156,6 +185,29 @@ func (s *Server) putChannel(w http.ResponseWriter, r *http.Request) { cfg.SyslogProto = "udp" } } + case "email": + if p.Host != nil { + cfg.SMTPHost = strings.TrimSpace(*p.Host) + } + if p.Port != nil { + cfg.SMTPPort = *p.Port + } + if p.User != nil { + cfg.SMTPUser = strings.TrimSpace(*p.User) + } + if p.From != nil { + cfg.SMTPFrom = strings.TrimSpace(*p.From) + } + if p.Encryption != nil { + cfg.SMTPEncryption = strings.TrimSpace(*p.Encryption) + } + applyValue(&cfg.SMTPPass, p.Pass, p.ClearPass) + if cfg.SMTPHost != "" && cfg.SMTPPort == 0 { + cfg.SMTPPort = 587 + } + if cfg.SMTPHost != "" && cfg.SMTPEncryption == "" { + cfg.SMTPEncryption = "starttls" + } } if msg, errCode := validateChannel(name, cfg); errCode != "" { writeErr(w, http.StatusBadRequest, errCode, msg) @@ -167,16 +219,79 @@ func (s *Server) putChannel(w http.ResponseWriter, r *http.Request) { return } s.channels.Apply(cfg) + if name == "email" { + s.applyMailer(cfg) + } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "channel": name}) } +// putWebhook — guarda la URL del webhook saliente en settings (aplica sin +// reiniciar: el notifier la lee en cada envío). +func (s *Server) putWebhook(w http.ResponseWriter, r *http.Request, p channelPatch) { + raw := "" + if p.URL != nil { + raw = strings.TrimSpace(*p.URL) + } + if raw != "" { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + writeErr(w, http.StatusBadRequest, "invalid_url", "la URL del webhook debe ser http(s)://…") + return + } + } + st, err := s.settings.Load(r.Context()) + if err != nil { + writeErr(w, http.StatusInternalServerError, "load_failed", "no se pudieron leer los ajustes") + return + } + st.Webhook = raw + if err := s.settings.Save(r.Context(), st); err != nil { + log.Printf("channels: guardar webhook: %v", err) + writeErr(w, http.StatusInternalServerError, "save_failed", "no se pudo guardar la configuración") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "channel": "webhook"}) +} + +// applyMailer — recrea el cliente SMTP con la config nueva y lo engancha al +// alerter (nil si el canal queda incompleto). Cierra el anterior. +func (s *Server) applyMailer(cfg channels.Config) { + if old := s.mailer; old != nil { + _ = old.Close() + } + s.mailer = MailerFromConfig(cfg) + if s.alerter != nil { + s.alerter.SetEmail(s.mailer) + } +} + +// mailerFromConfig — construye el cliente SMTP desde la config de canales. +func MailerFromConfig(cfg channels.Config) *notifier.Mailer { + if cfg.SMTPHost == "" || cfg.SMTPFrom == "" { + return nil + } + m, err := notifier.NewMailer(notifier.SMTP{ + Host: cfg.SMTPHost, Port: cfg.SMTPPort, User: cfg.SMTPUser, Pass: cfg.SMTPPass, + From: cfg.SMTPFrom, Encryption: cfg.SMTPEncryption, Timeout: 10 * time.Second, + }) + if err != nil { + log.Printf("channels: cliente SMTP inválido: %v", err) + return nil + } + return m +} + // deleteChannel — DELETE /api/channels/{name} (admin): desactiva el canal. func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - if !testableChannel(name) { + if !editableChannel(name) { writeErr(w, http.StatusNotFound, "unknown_channel", "canal desconocido: "+name) return } + if name == "webhook" { + s.putWebhook(w, r, channelPatch{URL: strPtr("")}) + return + } cfg := s.channels.Config() switch name { case "ntfy": @@ -187,6 +302,8 @@ func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) { cfg.TelegramBotToken, cfg.TelegramChatID = "", "" case "syslog": cfg.SyslogHost, cfg.SyslogPort, cfg.SyslogProto, cfg.SyslogFacility = "", 0, "", 0 + case "email": + cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPEncryption = "", 0, "", "", "", "" } if err := s.channelStore.Save(r.Context(), cfg); err != nil { log.Printf("channels: desactivar %s: %v", name, err) @@ -194,9 +311,15 @@ func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) { return } s.channels.Apply(cfg) + if name == "email" { + s.applyMailer(cfg) + } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "channel": name}) } +// strPtr — puntero a string (para reutilizar putWebhook al desactivar). +func strPtr(s string) *string { return &s } + // applyValue — campo write-only: valor vacío/ausente conserva el actual; // clear lo borra. (El valor es un puntero para distinguir "ausente" de "vacío".) func applyValue(dst *string, val *string, clear bool) { @@ -256,13 +379,29 @@ func validateChannel(name string, cfg channels.Config) (string, string) { if cfg.SyslogFacility < 0 || cfg.SyslogFacility > 23 { return "la facility de syslog debe estar entre 0 y 23", "invalid_facility" } + case "email": + host, from := cfg.SMTPHost != "", cfg.SMTPFrom != "" + if !host && !from { + return "", "" // desactivado + } + if host != from { + return "el email requiere servidor SMTP Y remitente (o ninguno de los dos)", "incomplete" + } + if cfg.SMTPPort < 1 || cfg.SMTPPort > 65535 { + return "el puerto SMTP debe estar entre 1 y 65535", "invalid_port" + } + switch cfg.SMTPEncryption { + case "none", "starttls", "tls": + default: + return "el cifrado SMTP debe ser none, starttls o tls", "invalid_encryption" + } } return "", "" } // testChannel — POST /api/channels/{name}/test (admin): envía una notificación // de prueba por el canal indicado. 400 si el canal no está configurado (no se -// finge un éxito), 502 si el destino falla (mensaje ya redactado por el paquete). +// finge un éxito), 502 si el destino falla (mensaje ya redactado). func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !testableChannel(name) { @@ -281,6 +420,30 @@ func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) { title, body := testMessage(lang) ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() + + // Email: la prueba va al correo del admin que la lanza. + if name == "email" { + u, err := s.users.Get(r.Context(), auth.UserFromContext(r.Context())) + if err != nil || u.Email == "" { + writeErr(w, http.StatusBadRequest, "no_recipient", + "tu usuario no tiene email configurado en Mi perfil: la prueba necesita un destinatario") + return + } + if s.mailer == nil { + writeErr(w, http.StatusBadRequest, "channel_not_configured", "el canal email no está configurado") + return + } + if err := s.mailer.Send(ctx, []string{u.Email}, lang, + notifier.Alert{Level: "info", Source: "test", Target: "settings", Timestamp: time.Now()}, + title, body); err != nil { + log.Printf("channels: prueba de email falló: %v", err) + writeErr(w, http.StatusBadGateway, "channel_test_failed", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "channel": name}) + return + } + if err := s.channels.Test(ctx, name, title, body); err != nil { log.Printf("channels: prueba de %s falló: %v", name, err) writeErr(w, http.StatusBadGateway, "channel_test_failed", err.Error()) diff --git a/internal/httpapi/channels_handlers_test.go b/internal/httpapi/channels_handlers_test.go index 9293d3b..66f16bf 100644 --- a/internal/httpapi/channels_handlers_test.go +++ b/internal/httpapi/channels_handlers_test.go @@ -9,17 +9,30 @@ import ( "net/http/httptest" "strings" "testing" + "time" "easyzfs/internal/auth" "easyzfs/internal/channels" "easyzfs/internal/config" "easyzfs/internal/db" + "easyzfs/internal/settings" "easyzfs/internal/users" ) +// resetRateGuard — vacía el cupo global de mutaciones por IP: los tests +// comparten 127.0.0.1 y el cupo es por IP y minuto, así que sin esto un test +// con muchas mutaciones deja a los siguientes con 429. +func resetRateGuard() { + rateGuardGlobal.mu.Lock() + rateGuardGlobal.hits = map[string][]time.Time{} + rateGuardGlobal.mu.Unlock() +} + // serverChannelsPrueba — servidor con BD migrada, admin, canales y su store. func serverChannelsPrueba(t *testing.T, ch *channels.Client) (http.Handler, *http.Cookie) { t.Helper() + resetRateGuard() + t.Cleanup(resetRateGuard) d, err := db.Open(t.TempDir() + "/test.db") if err != nil { t.Fatalf("open: %v", err) @@ -34,9 +47,14 @@ func serverChannelsPrueba(t *testing.T, ch *channels.Client) (http.Handler, *htt } cfg := &config.Config{} am := auth.NewManager(d, []byte("secreto-de-prueba-32-bytes-xxxxxxxx"), false) + st, err := settings.NewStore(d) + if err != nil { + t.Fatalf("settings: %v", err) + } srv := NewServer(Deps{ Cfg: cfg, DB: d, Auth: am, Users: us, Channels: ch, ChannelStore: channels.NewStore(d), + Settings: st, }) cookie, err := am.CreateSession(context.Background(), "admin") if err != nil { @@ -95,8 +113,11 @@ func TestGetChannelsNoSecrets(t *testing.T) { if resp.Channels["syslog"].Configured { t.Error("syslog no está configurado") } - if !resp.Channels["telegram"].Editable || resp.Channels["email"].Editable { - t.Error("editable mal marcado (telegram sí, email no)") + if !resp.Channels["telegram"].Editable || !resp.Channels["email"].Editable || !resp.Channels["webhook"].Editable { + t.Error("telegram/email/webhook deben ser editables") + } + if resp.Channels["push"].Editable { + t.Error("push no debe ser editable (VAPID lo genera el instalador)") } } @@ -201,6 +222,59 @@ func TestPutChannelValidation(t *testing.T) { } } +// PUT email y webhook: guardado en caliente, validación y no-fuga de secretos. +func TestPutEmailAndWebhook(t *testing.T) { + ch := channels.New(channels.Config{}) + h, cookie := serverChannelsPrueba(t, ch) + + // Email incompleto (host sin remitente) → 400. + w := doReq(t, h, cookie, "PUT", "/api/channels/email", `{"host":"smtp.example.com"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("email incompleto: status %d", w.Code) + } + + // Email completo → 200 y canal configurado sin reiniciar. + w = doReq(t, h, cookie, "PUT", "/api/channels/email", + `{"host":"smtp.example.com","port":587,"user":"u","pass":"secreto-smtp","from":"easyzfs@example.com","encryption":"starttls"}`) + if w.Code != http.StatusOK { + t.Fatalf("PUT email: %d %s", w.Code, w.Body.String()) + } + if !ch.Configured("email") { + t.Fatal("email debería estar configurado") + } + + // GET no expone la contraseña SMTP. + gw := doReq(t, h, cookie, "GET", "/api/channels", "") + if strings.Contains(gw.Body.String(), "secreto-smtp") { + t.Fatalf("GET filtra la contraseña SMTP: %s", gw.Body.String()) + } + + // Webhook válido → 200 y URL visible en el GET. + w = doReq(t, h, cookie, "PUT", "/api/channels/webhook", `{"url":"https://hooks.example.com/x"}`) + if w.Code != http.StatusOK { + t.Fatalf("PUT webhook: %d %s", w.Code, w.Body.String()) + } + gw = doReq(t, h, cookie, "GET", "/api/channels", "") + if !strings.Contains(gw.Body.String(), "hooks.example.com") { + t.Fatalf("webhook no guardado: %s", gw.Body.String()) + } + + // Webhook inválido → 400. + w = doReq(t, h, cookie, "PUT", "/api/channels/webhook", `{"url":"ftp://x"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("webhook inválido: status %d", w.Code) + } + + // DELETE desactiva email. + w = doReq(t, h, cookie, "DELETE", "/api/channels/email", "") + if w.Code != http.StatusOK { + t.Fatalf("DELETE email: status %d", w.Code) + } + if ch.Configured("email") { + t.Fatal("email debería quedar desactivado") + } +} + // Canal no configurado: 400 channel_not_configured (no se finge éxito). func TestTestChannelNotConfigured(t *testing.T) { ch := channels.New(channels.Config{}) diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 3e33d38..1845abe 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -25,6 +25,7 @@ import ( "easyzfs/internal/config" "easyzfs/internal/hub" "easyzfs/internal/longops" + "easyzfs/internal/notifier" "easyzfs/internal/push" "easyzfs/internal/replication" "easyzfs/internal/scheduler" @@ -54,6 +55,7 @@ type Server struct { push *push.Sender channels *channels.Client channelStore *channels.Store + mailer *notifier.Mailer backup *backup.Store longOps *longops.Manager repl *replication.Runner @@ -87,6 +89,7 @@ type Deps struct { Push *push.Sender Channels *channels.Client ChannelStore *channels.Store + Mailer *notifier.Mailer Backup *backup.Store LongOps *longops.Manager Repl *replication.Runner @@ -105,6 +108,7 @@ func NewServer(d Deps) *Server { perf: d.Perf, caps: d.Caps, act: d.Actions, sched: d.Sched, jstore: d.Jobs, h: d.Hub, push: d.Push, channels: d.Channels, channelStore: d.ChannelStore, + mailer: d.Mailer, backup: d.Backup, longOps: d.LongOps, repl: d.Repl, updater: d.Updater, started: time.Now(), version: d.Version, build: d.Build, zfsVersion: d.ZFSVersion, diff --git a/main.go b/main.go index f0a06d1..12bbce0 100644 --- a/main.go +++ b/main.go @@ -110,17 +110,6 @@ func main() { }) alerter.SetWebhook(webhookNotifier) - // Canal email (S5): inerte si SMTP no está configurado (log aviso en config). - var emailNotifier *notifier.Mailer - if smtpCfg := notifier.FromConfig(cfg); smtpCfg.Validate() == nil { - emailNotifier, err = notifier.NewMailer(smtpCfg) - if err != nil { - log.Printf("aviso: email desactivado: %v", err) - } else { - alerter.SetEmail(emailNotifier) - } - } - // Sender Web Push: inerte si faltan claves VAPID; en demo nunca envía. pushSender := push.New(cfg, database, h) alerter.SetPush(pushSender) @@ -128,8 +117,8 @@ func main() { // la ventana de silencio. En demo o sin VAPID queda inerte. go pushSender.RunQueue(ctx) - // Canales ntfy/gotify/telegram/syslog (#86, #134): la config vive en BD - // (editable desde Ajustes sin reiniciar); el entorno solo la siembra la + // Canales ntfy/gotify/telegram/syslog/email (#86, #134): la config vive en + // BD (editable desde Ajustes sin reiniciar); el entorno solo la siembra la // primera vez (compatibilidad con la config previa por env). channelStore := channels.NewStore(database) channelCfg, ok, err := channelStore.Load(ctx) @@ -148,6 +137,12 @@ func main() { SyslogPort: cfg.SyslogPort, SyslogProto: cfg.SyslogProto, SyslogFacility: cfg.SyslogFacility, + SMTPHost: cfg.SMTPHost, + SMTPPort: cfg.SMTPPort, + SMTPUser: cfg.SMTPUser, + SMTPPass: cfg.SMTPPass, + SMTPFrom: cfg.SMTPFrom, + SMTPEncryption: cfg.SMTPEncryption, } if err := channelStore.Save(ctx, channelCfg); err != nil { log.Printf("aviso: no se pudo sembrar la config de canales: %v", err) @@ -157,9 +152,18 @@ func main() { // El alerter SIEMPRE recibe el cliente: al configurar un canal desde la UI // entra en vigor sin reiniciar (el cliente decide por config en cada evento). alerter.SetChannels(channelsClient) - log.Printf("canales de alerta: ntfy=%v gotify=%v telegram=%v syslog=%v", + log.Printf("canales de alerta: ntfy=%v gotify=%v telegram=%v syslog=%v email=%v", channelsClient.Configured("ntfy"), channelsClient.Configured("gotify"), - channelsClient.Configured("telegram"), channelsClient.Configured("syslog")) + channelsClient.Configured("telegram"), channelsClient.Configured("syslog"), + channelsClient.Configured("email")) + + // Canal email (SMTP): mismo origen de config que los canales; se recrea en + // caliente al guardar desde Ajustes. + var emailNotifier *notifier.Mailer + if m := httpapi.MailerFromConfig(channelCfg); m != nil { + emailNotifier = m + alerter.SetEmail(m) + } // Colectores (reales o mock) + providers para los handlers. providers, cols := collectors.Build(cfg, database, h, alerter) @@ -204,7 +208,7 @@ func main() { Perf: providers.Perf, Caps: providers.Caps, Actions: act, Sched: sched, Jobs: jobStore, Hub: h, Push: pushSender, Backup: backupStore, LongOps: longOps, Repl: replRunner, Updater: updaterSvc, - Channels: channelsClient, ChannelStore: channelStore, + Channels: channelsClient, ChannelStore: channelStore, Mailer: emailNotifier, Version: version, Build: build, ZFSVersion: zfsVersion, }) From ea0b092442522cf771c16bd7a54b2cb3ab051a90 Mon Sep 17 00:00:00 2001 From: Nacho Date: Sun, 13 Sep 2026 12:24:58 +0200 Subject: [PATCH 6/6] feat(ui): configure email and webhook channels in Settings (#134) --- web/src/data/mock.ts | 8 ++++-- web/src/data/types.ts | 14 +++++++-- web/src/ui/i18n.ts | 14 +++++++++ web/src/views/Settings.tsx | 58 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/web/src/data/mock.ts b/web/src/data/mock.ts index 97a377d..c99791c 100644 --- a/web/src/data/mock.ts +++ b/web/src/data/mock.ts @@ -75,8 +75,8 @@ export class MockProvider implements DataProvider { telegram: { configured: true, chat_id: '-1001234567890', token_set: true, editable: true }, gotify: { configured: false, editable: true }, syslog: { configured: false, editable: true }, - email: { configured: false, editable: false }, - webhook: { configured: true, editable: false }, + email: { configured: false, editable: true }, + webhook: { configured: true, url: 'https://hooks.example.com/easyzfs', editable: true }, push: { configured: false, editable: false }, }; private backupLast: BackupFile | null = { @@ -311,6 +311,10 @@ export class MockProvider implements DataProvider { if (patch.port !== undefined) c.port = patch.port; if (patch.proto !== undefined) c.proto = patch.proto; if (patch.facility !== undefined) c.facility = patch.facility; + if (patch.user !== undefined) c.user = patch.user; + if (patch.from !== undefined) { c.from = patch.from; c.configured = true; } + if (patch.encryption !== undefined) c.encryption = patch.encryption; + if (patch.pass) { c.token_set = true; c.configured = true; } }; deleteChannel = async (name: ChannelName) => { await delay(); diff --git a/web/src/data/types.ts b/web/src/data/types.ts index db12ba9..d7f355c 100644 --- a/web/src/data/types.ts +++ b/web/src/data/types.ts @@ -142,12 +142,15 @@ export interface BackupStatus { export interface ChannelInfo { configured: boolean; server?: string; // ntfy: servidor sin el topic - url?: string; // gotify + url?: string; // gotify / webhook chat_id?: string; // telegram - host?: string; // syslog + host?: string; // syslog / email port?: number; proto?: string; facility?: number; + user?: string; // email + from?: string; // email + encryption?: string; // email token_set?: boolean; topic_set?: boolean; editable: boolean; @@ -156,7 +159,7 @@ export type ChannelName = 'ntfy' | 'gotify' | 'telegram' | 'syslog' | 'email' | export type ChannelsStatus = Record; // Cambios al guardar un canal: los campos write-only vacíos/ausentes conservan -// el valor actual; clear_token lo borra. +// el valor actual; clear_token/clear_pass lo borran. export interface ChannelPatch { url?: string; token?: string; @@ -166,6 +169,11 @@ export interface ChannelPatch { port?: number; proto?: string; facility?: number; + user?: string; + pass?: string; + clear_pass?: boolean; + from?: string; + encryption?: string; } export type AlertLevel = 'info' | 'warn' | 'crit'; diff --git a/web/src/ui/i18n.ts b/web/src/ui/i18n.ts index 43f37ef..2a7c8ab 100644 --- a/web/src/ui/i18n.ts +++ b/web/src/ui/i18n.ts @@ -539,6 +539,13 @@ const es = { s_ch_syslog_proto: 'Protocolo', s_ch_syslog_facility: 'Facility', s_ch_topic_set: 'topic configurado', + s_ch_email_host: 'Servidor SMTP', + s_ch_email_port: 'Puerto', + s_ch_email_user: 'Usuario', + s_ch_email_pass: 'Contraseña', + s_ch_email_from: 'Remitente', + s_ch_email_enc: 'Cifrado', + s_ch_webhook_url: 'URL del webhook', s_session: 'Mi sesión', s_mypass: 'Cambiar mi contraseña', s_profile: 'Mi perfil', s_displayname: 'Nombre', @@ -1173,6 +1180,13 @@ const en: Record = { s_ch_syslog_proto: 'Protocol', s_ch_syslog_facility: 'Facility', s_ch_topic_set: 'topic set', + s_ch_email_host: 'SMTP server', + s_ch_email_port: 'Port', + s_ch_email_user: 'Username', + s_ch_email_pass: 'Password', + s_ch_email_from: 'Sender', + s_ch_email_enc: 'Encryption', + s_ch_webhook_url: 'Webhook URL', s_session: 'My session', s_mypass: 'Change my password', s_profile: 'My profile', s_displayname: 'Name', diff --git a/web/src/views/Settings.tsx b/web/src/views/Settings.tsx index 41df168..a18f499 100644 --- a/web/src/views/Settings.tsx +++ b/web/src/views/Settings.tsx @@ -705,7 +705,7 @@ const CHANNEL_LABEL: Record = { // Orden de presentación y canales que admiten prueba de envío (los del // paquete channels; email/webhook/push solo muestran su estado). const CHANNEL_ORDER: ChannelName[] = ['telegram', 'ntfy', 'gotify', 'syslog', 'email', 'webhook', 'push']; -const CHANNEL_TESTABLE: ChannelName[] = ['telegram', 'ntfy', 'gotify', 'syslog']; +const CHANNEL_TESTABLE: ChannelName[] = ['telegram', 'ntfy', 'gotify', 'syslog', 'email']; // Detalle no secreto de la fila de un canal. function channelDetail(name: ChannelName, info: ChannelInfo, t: (k: I18nKey) => string): string { @@ -718,6 +718,10 @@ function channelDetail(name: ChannelName, info: ChannelInfo, t: (k: I18nKey) => return info.chat_id ? `chat ${info.chat_id}` : ''; case 'syslog': return info.host ? `${info.host}:${info.port ?? 514} (${info.proto ?? 'udp'})` : ''; + case 'email': + return info.host ? `${info.host}:${info.port ?? 587}` : ''; + case 'webhook': + return info.url ?? ''; default: return ''; } @@ -727,13 +731,17 @@ function channelDetail(name: ChannelName, info: ChannelInfo, t: (k: I18nKey) => // campos write-only (URL de ntfy, tokens) vacíos conservan el valor actual. function ChannelForm({ name, info, onDone }: { name: ChannelName; info: ChannelInfo; onDone: () => void }) { const { t, notify } = useApp(); - const [url, setUrl] = useState(name === 'gotify' ? (info.url ?? '') : ''); + const [url, setUrl] = useState(name === 'gotify' || name === 'webhook' ? (info.url ?? '') : ''); const [token, setToken] = useState(''); const [chatId, setChatId] = useState(info.chat_id ?? ''); const [host, setHost] = useState(info.host ?? ''); - const [port, setPort] = useState(String(info.port ?? 514)); + const [port, setPort] = useState(String(info.port ?? (name === 'email' ? 587 : 514))); const [proto, setProto] = useState(info.proto ?? 'udp'); const [facility, setFacility] = useState(String(info.facility ?? 1)); + const [user, setUser] = useState(info.user ?? ''); + const [pass, setPass] = useState(''); + const [from, setFrom] = useState(info.from ?? ''); + const [encryption, setEncryption] = useState(info.encryption ?? 'starttls'); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); @@ -750,6 +758,15 @@ function ChannelForm({ name, info, onDone }: { name: ChannelName; info: ChannelI } else if (name === 'telegram') { if (token.trim()) patch.token = token.trim(); patch.chat_id = chatId.trim(); + } else if (name === 'email') { + patch.host = host.trim(); + patch.port = +port; + patch.user = user.trim(); + patch.from = from.trim(); + patch.encryption = encryption; + if (pass.trim()) patch.pass = pass.trim(); + } else if (name === 'webhook') { + patch.url = url.trim(); } else { patch.host = host.trim(); patch.port = +port; @@ -831,6 +848,41 @@ function ChannelForm({ name, info, onDone }: { name: ChannelName; info: ChannelI

)} + {name === 'email' && ( + <> +
+
+ + setHost(e.target.value)} /> +
+
+ + setPort(e.target.value)} /> +
+
+ + setUser(e.target.value)} /> + + setPass(e.target.value)} /> +

{t('s_ch_token_hint')}

+ + setFrom(e.target.value)} /> + + )} + {name === 'webhook' && ( + <> + + setUrl(e.target.value)} /> + + )} {err &&

{err}

}