Files
Anand 6e008bae75 Fix SMTP auth failing with "unencrypted connection" over plaintext
net/smtp.PlainAuth refuses to send credentials unless the connection is TLS
or literally "localhost" — a hardcoded stdlib guard, not something the
UseTLS toggle controls. So an admin who deliberately configured a
plaintext relay with a username/password (a trusted internal network) got
every send rejected client-side with "authenticating: unencrypted
connection" before a single byte reached the server, surfacing as a 502
from the test-notification endpoint.

sendPlain now authenticates via a small unencryptedPlainAuth (PLAIN
without the TLS-or-localhost check) instead of smtp.PlainAuth — used only
on the explicitly-no-TLS path; the STARTTLS and implicit-TLS paths keep
the normal stdlib guard. Covered by a new test against an in-process fake
SMTP server that verifies the AUTH PLAIN payload actually reaches the
server over a real, non-localhost loopback connection.
2026-09-03 23:35:40 +05:30

292 lines
9.5 KiB
Go

// Package notify sends outbound alert notifications to Gotify and/or SMTP —
// both optional and independently enabled. Everything here is stdlib-only
// (net/http, net/smtp): neither channel needs a third-party client.
package notify
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/smtp"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
type GotifyConfig struct {
Enabled bool
URL string // base URL, e.g. https://gotify.example.com
Token string // application token
}
type SMTPConfig struct {
Enabled bool
Host string
Port int
Username string
Password string
From string
To string // comma/semicolon-separated recipients
UseTLS bool // STARTTLS on 25/587; implicit TLS is used automatically on port 465
}
type Settings struct {
Gotify GotifyConfig
SMTP SMTPConfig
}
// Notifier holds the live, admin-editable settings and dispatches
// notifications to every enabled channel. Safe for concurrent use — Update
// is called from the settings HTTP handler while Notify runs from the
// background alert evaluator.
type Notifier struct {
mu sync.RWMutex
settings Settings
client *http.Client
}
func New(s Settings) *Notifier {
return &Notifier{settings: s, client: &http.Client{Timeout: 10 * time.Second}}
}
func (n *Notifier) Update(s Settings) {
n.mu.Lock()
n.settings = s
n.mu.Unlock()
}
func (n *Notifier) Settings() Settings {
n.mu.RLock()
defer n.mu.RUnlock()
return n.settings
}
// Notify sends title/message to every enabled channel. Failures are logged
// by the caller (via the returned errors), never raised as a panic or
// blocking retry — a notification backend being down must never affect
// alert evaluation itself.
//
// extraEmails are per-user opt-in recipients (a user's own account email,
// see user_preferences.notify_email) added on top of — never instead of —
// the admin's globally configured SMTP "to" list. They're silently ignored
// when SMTP itself isn't enabled: opting in to "email me" doesn't stand up
// a relay on its own.
func (n *Notifier) Notify(ctx context.Context, title, message string, extraEmails ...string) []error {
s := n.Settings()
var errs []error
if s.Gotify.Enabled {
if err := sendGotify(ctx, n.client, s.Gotify, title, message); err != nil {
errs = append(errs, fmt.Errorf("gotify: %w", err))
}
}
if s.SMTP.Enabled {
cfg := s.SMTP
if len(extraEmails) > 0 {
cfg.To = strings.Join(append(splitRecipients(cfg.To), extraEmails...), ",")
}
if err := sendSMTP(cfg, title, message); err != nil {
errs = append(errs, fmt.Errorf("smtp: %w", err))
}
}
return errs
}
// TestGotify and TestSMTP send a fixed test message through one channel
// using caller-supplied (not-yet-saved) config — the "Send test
// notification" button in the settings UI.
func TestGotify(ctx context.Context, cfg GotifyConfig) error {
client := &http.Client{Timeout: 10 * time.Second}
return sendGotify(ctx, client, cfg, "Ferrum test notification", "This is a test notification from Ferrum. If you received this, Gotify is configured correctly.")
}
func TestSMTP(cfg SMTPConfig) error {
return sendSMTP(cfg, "Ferrum test notification", "This is a test notification from Ferrum. If you received this, SMTP is configured correctly.")
}
func sendGotify(ctx context.Context, client *http.Client, cfg GotifyConfig, title, message string) error {
if cfg.URL == "" || cfg.Token == "" {
return errors.New("gotify URL and token are required")
}
endpoint := strings.TrimRight(cfg.URL, "/") + "/message?token=" + url.QueryEscape(cfg.Token)
form := url.Values{"title": {title}, "message": {message}, "priority": {"5"}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("calling gotify: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("gotify returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func sendSMTP(cfg SMTPConfig, subject, body string) error {
if cfg.Host == "" || cfg.From == "" || cfg.To == "" {
return errors.New("smtp host, from, and to are required")
}
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
to := splitRecipients(cfg.To)
msg := buildMessage(cfg.From, to, subject, body)
// Port 465 is implicit TLS (the connection is TLS from the first byte,
// no STARTTLS negotiation) — dialed by hand below. For everything else,
// cfg.UseTLS picks the transport explicitly rather than going through
// net/smtp.SendMail: SendMail opportunistically attempts STARTTLS
// whenever the server *advertises* the extension, regardless of what
// the caller asked for — so an admin who deliberately unchecked "Use
// STARTTLS" (an internal relay with a self-signed or expired cert that
// still advertises STARTTLS) got the send fail on a TLS handshake they
// never asked for. sendPlain below never attempts TLS at all; only the
// UseTLS branch does, and only then.
if cfg.Port == 465 {
return sendImplicitTLS(addr, cfg.Host, tlsAuth(cfg), cfg.From, to, msg)
}
if cfg.UseTLS {
return sendStartTLS(addr, cfg.Host, tlsAuth(cfg), cfg.From, to, msg)
}
return sendPlain(addr, cfg, to, msg)
}
// tlsAuth is smtp.PlainAuth for the two TLS-protected send paths — its
// stdlib TLS-required guard is exactly what should apply there.
func tlsAuth(cfg SMTPConfig) smtp.Auth {
if cfg.Username == "" {
return nil
}
return smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
}
// sendPlain sends over a bare, never-upgraded connection — no STARTTLS
// attempt regardless of what the server advertises, honoring an explicit
// "don't use TLS" choice instead of net/smtp.SendMail's opportunistic
// upgrade. Authenticates via unencryptedPlainAuth rather than
// smtp.PlainAuth: the stdlib version refuses to send credentials over any
// connection that isn't TLS or literally localhost, which would otherwise
// make "no TLS" and "have a username" mutually exclusive — the admin
// choosing both explicitly (a trusted internal network) is not for Ferrum
// to second-guess.
func sendPlain(addr string, cfg SMTPConfig, to []string, msg []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dialing: %w", err)
}
defer c.Close()
var auth smtp.Auth
if cfg.Username != "" {
auth = &unencryptedPlainAuth{username: cfg.Username, password: cfg.Password}
}
return sendViaClient(c, auth, cfg.From, to, msg)
}
// unencryptedPlainAuth is net/smtp's PLAIN mechanism without the
// TLS-or-localhost requirement smtp.PlainAuth enforces — see sendPlain.
type unencryptedPlainAuth struct {
username, password string
}
func (a *unencryptedPlainAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
return "PLAIN", []byte("\x00" + a.username + "\x00" + a.password), nil
}
func (a *unencryptedPlainAuth) Next(_ []byte, more bool) ([]byte, error) {
if more {
return nil, errors.New("smtp: unexpected server challenge for PLAIN auth")
}
return nil, nil
}
func sendImplicitTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return fmt.Errorf("dialing (implicit TLS): %w", err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("starting SMTP session: %w", err)
}
defer c.Close()
return sendViaClient(c, auth, from, to, msg)
}
// sendStartTLS mirrors smtp.SendMail but requires the STARTTLS upgrade to
// succeed rather than silently falling back to plaintext auth, since the
// user explicitly asked for TLS.
func sendStartTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dialing: %w", err)
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); !ok {
return errors.New("server does not support STARTTLS")
}
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return fmt.Errorf("STARTTLS: %w", err)
}
return sendViaClient(c, auth, from, to, msg)
}
func sendViaClient(c *smtp.Client, auth smtp.Auth, from string, to []string, msg []byte) error {
if auth != nil {
if ok, _ := c.Extension("AUTH"); ok {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("authenticating: %w", err)
}
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL FROM: %w", err)
}
for _, rcpt := range to {
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("RCPT TO %s: %w", rcpt, err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("DATA: %w", err)
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
func splitRecipients(s string) []string {
parts := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == ' ' })
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func buildMessage(from string, to []string, subject, body string) []byte {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
fmt.Fprintf(&b, "Subject: %s\r\n", subject)
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n\r\n")
b.WriteString(body)
return []byte(b.String())
}