diff --git a/cmd/mon-server/main.go b/cmd/mon-server/main.go index 1d05da7..72ce775 100644 --- a/cmd/mon-server/main.go +++ b/cmd/mon-server/main.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -450,6 +451,10 @@ func main() { // stateful checks (failed-login threshold, security updates) on a // 60s tick. eng := alerts.New(st.Pool, lw.Out, sched.Out) + // Host-context enrichment for HTML alert emails; MON_PUBLIC_URL (optional) + // adds a deep link to the host page. + eng.Store = st + eng.PublicURL = strings.TrimSpace(os.Getenv("MON_PUBLIC_URL")) go eng.Run(ctx) // Housekeeping reaper: bounds tables that don't have a Timescale diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index e79bb61..0af48e4 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -512,6 +512,10 @@ See `docs/SIGNING.md` for the full procedure. | `MON_TLS_KEY` | unset | Private key for direct TLS. | | `MON_RP_ID` | `localhost` | WebAuthn RP ID — must be the bare host. | | `MON_RP_ORIGIN` | `http://localhost:5173` | WebAuthn RP origin — full scheme+host(+port). | +| `MON_PUBLIC_URL` | unset | Public base URL (e.g. `https://mon.example.com`); adds a "View host" deep link to HTML alert emails. | +| `MON_ALLOW_INSECURE_HTTP` | unset | Set to `1` to permit plain-HTTP startup behind a TLS-terminating proxy. | +| `MON_TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For`/`X-Real-IP` headers are trusted. | +| `MON_DATA_ENCRYPTION_KEY` | unset | Base64 32-byte AES-256 key; enables TOTP-seed encryption at rest. | | `MON_LOG_BUFFER` | `5000` | In-memory log ring buffer size. | | `MON_INGEST_BUFFER` | `100` | Captured-ingest ring buffer size. | | `MON_INGEST_MAX_BYTES`| `1048576` | Max captured ingest body size. | diff --git a/docs/superpowers/specs/2026-07-16-html-alert-email-design.md b/docs/superpowers/specs/2026-07-16-html-alert-email-design.md new file mode 100644 index 0000000..10799d2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-html-alert-email-design.md @@ -0,0 +1,82 @@ +# HTML alert emails with host context — design + +**Date:** 2026-07-16 +**Status:** approved (design) + +## Problem + +Alert emails are plain text built per rule type via `fmt.Sprintf`. They are hard +to scan and identify the host only by UUID (e.g. `Host f1360f46-… sustained +ram_used_pct > 80.00`). Operators want a readable HTML email that names the host +and shows enough context to triage without opening the dashboard. + +## Goals + +- Render a clean, responsive HTML email for the SMTP channel. +- Resolve and show the **hostname** (not the UUID) plus host facts: IP, OS/kernel, + arch/CPU/RAM, agent version, status + last-seen, uptime. +- Keep a plain-text alternative (accessibility, non-HTML clients). +- Leave Slack/Mattermost/Discord/ntfy unchanged (they already use plain/markdown). +- No new required config. Optional `MON_PUBLIC_URL` adds a "View host" button. + +## Non-goals + +- Redesigning non-email channels. +- Per-rule bespoke templates — one template driven by structured fields. +- Charts/images in the email (keep it lightweight and MIME-simple). + +## Design + +### 1. Structured message fields (`internal/server/notify`) +Extend `notify.Message` with optional fields; `Subject`/`Body` stay as the +canonical plain-text form used by every channel: + +``` +Severity string // existing +FiredAt time.Time // optional +RuleName string // optional +Metric *MetricDetail // optional: Name, Current, Threshold +Host *HostContext // optional: Name, IP, OS, Kernel, Arch, CPUCores, RAMBytes, AgentVersion, Status, LastSeen, UptimeSec +HostURL string // optional deep link (only when MON_PUBLIC_URL set) +``` +When `Host`/`Metric` are nil, the HTML simply omits those blocks. This keeps every +existing caller valid (they set only Subject/Body/Severity today). + +### 2. Enrichment at the single choke point (`internal/server/alerts`) +All alerts pass through `Engine.fire(ctx, r, subject, body, dedup)`. Enrich there: +- `splitDedupKey(dedup)` already yields the host UUID for host-scoped rules. +- New `store` method `HostContext(ctx, hostID) (apitypes.HostContext, error)` returns + the facts from the `hosts` table (+ latest status / uptime). One query, read-only. +- Populate `Message.Host` when a host id is present; leave nil otherwise + (monitor-only / global rules render without a host block). +- `HostURL` built from `MON_PUBLIC_URL` + `/hosts/` when the env var is set. +- The `Metric` block is populated for the `metric_threshold` condition type, which + already has name/value/threshold in scope; other types leave it nil. + +### 3. HTML rendering (`internal/server/notify`) +- New `email_html.go`: a `html/template` (auto-escaping) producing an + email-safe, table-based, inline-styled document. Severity → accent color + (critical `#b91c1c`, warning `#e8a23a`, info `#3aa3e8`). +- `SMTP.Send` builds a `multipart/alternative` message: part 1 `text/plain` + (existing `m.Body`), part 2 `text/html` (rendered template). `buildRFC5322` + grows a multipart variant; the existing plain-only helper stays for callers + that pass no HTML. +- All dynamic values flow through `html/template`, so host/rule strings are + auto-escaped (no injection from hostnames/labels). + +### 4. Error handling +- HTML render failure falls back to the plain-text-only email (never drops the + alert). Logged at warn. +- `HostContext` lookup failure → send without the host block (best-effort + enrichment must not block delivery). + +## Testing +- `notify`: unit test that `SMTP` message building yields a `multipart/alternative` + with both parts and that the HTML escapes a hostile hostname. +- `notify`: template renders with `Host == nil` / `Metric == nil` (no panic, blocks omitted). +- `alerts`: `HostContext` populated maps into `Message.Host` (existing engine tests + extended or a focused test on the mapping helper). + +## Rollout +- Backward compatible; no migration. Ships in the normal release. +- `MON_PUBLIC_URL` documented in OPERATIONS.md as optional (enables the deep link). diff --git a/internal/server/alerts/alerts.go b/internal/server/alerts/alerts.go index 18cf3e8..bcab58c 100644 --- a/internal/server/alerts/alerts.go +++ b/internal/server/alerts/alerts.go @@ -26,6 +26,7 @@ import ( "github.com/MalteKiefer/MonSys/internal/server/liveness" "github.com/MalteKiefer/MonSys/internal/server/notify" "github.com/MalteKiefer/MonSys/internal/server/probe" + "github.com/MalteKiefer/MonSys/internal/server/store" ) // OnAlertFired is an optional metric-emission hook called from fire() @@ -40,6 +41,14 @@ var OnAlertFired func(conditionType, severity string) type Engine struct { Pool *pgxpool.Pool + // Store enriches alert notifications with host context (HTML email). May + // be nil (tests / minimal wiring), in which case alerts still deliver + // without the host block. + Store *store.Store + // PublicURL, when set (MON_PUBLIC_URL), produces a deep link to the host + // page in the notification. + PublicURL string + LivenessOut <-chan liveness.Transition MonitorOut <-chan probe.ResultEvent @@ -531,6 +540,7 @@ func (e *Engine) fire(ctx context.Context, r ruleRow, subject, body, dedup strin } m := notify.Message{Subject: subject, Body: body, Severity: severity} + e.enrichHost(ctx, r.Name, dedup, &m) delivered := []string{} deliverErrs := map[string]string{} @@ -693,6 +703,48 @@ func (e *Engine) resolve(ctx context.Context, dedup, body string) { // ":" and returns them as driver-friendly any values. nil means // "store NULL"; this lets the upsert skip host_id for monitor-scoped rules // and vice versa without a separate code path. +// enrichHost adds host context + deep link to an outgoing alert message so the +// HTML email can name the host and show its facts. Best-effort: any failure +// leaves the message plain and delivery proceeds. +func (e *Engine) enrichHost(ctx context.Context, ruleName, dedup string, m *notify.Message) { + m.RuleName = ruleName + m.FiredAt = time.Now() + if e.Store == nil { + return + } + hostArg, _ := splitDedupKey(dedup) + hid, ok := hostArg.(uuid.UUID) + if !ok { + return + } + f, err := e.Store.HostFactsFor(ctx, hid) + if err != nil { + return + } + m.Host = ¬ify.HostContext{ + Name: f.Hostname, + IP: f.IP, + OS: f.Distro, + Kernel: f.Kernel, + Arch: f.Arch, + CPUCores: f.CPUCores, + RAMBytes: f.RAMBytes, + AgentVersion: f.AgentVersion, + Status: f.Status, + LastSeen: f.LastSeen, + UptimeSec: e.uptimeFor(hid), + } + if e.PublicURL != "" { + m.HostURL = strings.TrimRight(e.PublicURL, "/") + "/hosts/" + hid.String() + } +} + +func (e *Engine) uptimeFor(id uuid.UUID) int64 { + e.stateMu.Lock() + defer e.stateMu.Unlock() + return e.hostUptime[id] +} + func splitDedupKey(dedup string) (hostArg, monitorArg any) { idx := strings.IndexByte(dedup, ':') if idx < 0 || idx == len(dedup)-1 { diff --git a/internal/server/notify/email_html.go b/internal/server/notify/email_html.go new file mode 100644 index 0000000..5cce130 --- /dev/null +++ b/internal/server/notify/email_html.go @@ -0,0 +1,209 @@ +package notify + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "html/template" + "strings" + "time" +) + +// buildEmail renders the RFC 5322 message for an alert. When an HTML body can +// be produced it emits a multipart/alternative (plain + HTML); otherwise it +// falls back to the plain-text-only message. Both parts are base64-encoded so +// arbitrary content (long inline-styled HTML lines, leading dots) transfers +// safely regardless of the SMTP writer's line handling. +func buildEmail(from, to string, m Message) string { + htmlBody, err := renderAlertHTML(m) + if err != nil || strings.TrimSpace(htmlBody) == "" { + return buildRFC5322(from, to, m.Subject, m.Body) + } + + boundary := mimeBoundary() + var b strings.Builder + fmt.Fprintf(&b, "From: %s\r\n", from) + fmt.Fprintf(&b, "To: %s\r\n", to) + fmt.Fprintf(&b, "Subject: %s\r\n", m.Subject) + fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)) + b.WriteString("MIME-Version: 1.0\r\n") + fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n\r\n", boundary) + + writePart := func(contentType, body string) { + fmt.Fprintf(&b, "--%s\r\n", boundary) + fmt.Fprintf(&b, "Content-Type: %s; charset=UTF-8\r\n", contentType) + b.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") + b.WriteString(base64Wrapped(body)) + b.WriteString("\r\n") + } + writePart("text/plain", m.Body) + writePart("text/html", htmlBody) + fmt.Fprintf(&b, "--%s--\r\n", boundary) + return b.String() +} + +func mimeBoundary() string { + var buf [18]byte + if _, err := rand.Read(buf[:]); err != nil { + // A fixed fallback is acceptable: collision with body content is only + // a formatting risk, and the base64 encoding of parts avoids it. + return "mon-alert-boundary-fixed" + } + return "mon_" + base64.RawURLEncoding.EncodeToString(buf[:]) +} + +// base64Wrapped encodes s and hard-wraps at 76 columns per MIME. +func base64Wrapped(s string) string { + enc := base64.StdEncoding.EncodeToString([]byte(s)) + var b strings.Builder + for len(enc) > 76 { + b.WriteString(enc[:76]) + b.WriteString("\r\n") + enc = enc[76:] + } + b.WriteString(enc) + return b.String() +} + +// alertColors maps a severity to (accent, pill-bg, pill-fg). +func alertColors(severity string) (accent, pillBG, pillFG string) { + switch strings.ToLower(severity) { + case "critical": + return "#b91c1c", "#fbdada", "#8a1414" + case "info": + return "#3aa3e8", "#d7ecfb", "#155e88" + default: // warning + return "#e8a23a", "#fbe7cd", "#8a5a12" + } +} + +type alertTemplateData struct { + Message + Accent string + PillBG string + PillFG string + Title string + Severity string + FiredStr string + RAMHuman string + UptimeStr string + LastSeenStr string + HasHost bool + HasMeta bool +} + +var alertTmpl = template.Must(template.New("alert").Parse(alertHTML)) + +// renderAlertHTML renders the alert email body. Returns an empty string with a +// nil error when there is nothing to render (caller falls back to plain text). +func renderAlertHTML(m Message) (string, error) { + if strings.TrimSpace(m.Subject) == "" && strings.TrimSpace(m.Body) == "" { + return "", nil + } + accent, pillBG, pillFG := alertColors(m.Severity) + sev := strings.ToUpper(m.Severity) + if sev == "" { + sev = "ALERT" + } + data := alertTemplateData{ + Message: m, + Accent: accent, + PillBG: pillBG, + PillFG: pillFG, + Title: m.Subject, + Severity: sev, + HasHost: m.Host != nil, + HasMeta: m.Metric != nil, + } + if !m.FiredAt.IsZero() { + data.FiredStr = m.FiredAt.UTC().Format("2006-01-02 15:04 MST") + } + if m.Host != nil { + if m.Host.RAMBytes > 0 { + data.RAMHuman = humanBytes(m.Host.RAMBytes) + } + data.UptimeStr = humanUptime(m.Host.UptimeSec) + if !m.Host.LastSeen.IsZero() { + data.LastSeenStr = m.Host.LastSeen.UTC().Format("2006-01-02 15:04 MST") + } + } + var buf bytes.Buffer + if err := alertTmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +func humanUptime(sec int64) string { + if sec <= 0 { + return "" + } + d := time.Duration(sec) * time.Second + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + if days > 0 { + return fmt.Sprintf("%dd %dh", days, hours) + } + return fmt.Sprintf("%dh %dm", hours, int(d.Minutes())%60) +} + +// alertHTML is an email-safe, table-based, inline-styled template. All dynamic +// values are auto-escaped by html/template. +const alertHTML = `
+ + + + +{{if .HasMeta}}{{end}} +{{if .HasHost}}{{end}} +{{if .HostURL}}{{end}} + +
+ + + +
◆ MonSys{{.Severity}}
+
+
{{.Title}}
+

{{.Body}}

+
+ + + + +
Metric
{{.Metric.Name}}
Current
{{.Metric.Current}}
Threshold
{{.Metric.Threshold}}
+
+
Host
+ + {{with .Host}} + {{if .Name}}{{end}} + {{if .IP}}{{end}} + {{if .OS}}{{end}} + {{if .Arch}}{{end}} + {{if .AgentVersion}}{{end}} + {{if .Status}}{{end}} + {{if $.UptimeStr}}{{end}} + {{end}} +
Hostname{{.Name}}
IP address{{.IP}}
OS / kernel{{.OS}}{{if .Kernel}} · {{.Kernel}}{{end}}
Arch · CPU · RAM{{.Arch}}{{if .CPUCores}} · {{.CPUCores}} cores{{end}}{{if $.RAMHuman}} · {{$.RAMHuman}}{{end}}
Agent{{.AgentVersion}}
Status{{.Status}}{{if $.LastSeenStr}} · last seen {{$.LastSeenStr}}{{end}}
Uptime{{$.UptimeStr}}
+
+ View host in MonSys → +
+
+

{{if .RuleName}}Rule {{.RuleName}} · {{end}}severity {{.Message.Severity}}{{if .FiredStr}} · fired {{.FiredStr}}{{end}}

+
+

MonSys · self-hosted server monitoring

+
` diff --git a/internal/server/notify/email_html_test.go b/internal/server/notify/email_html_test.go new file mode 100644 index 0000000..9b988f2 --- /dev/null +++ b/internal/server/notify/email_html_test.go @@ -0,0 +1,81 @@ +package notify + +import ( + "strings" + "testing" + "time" +) + +func TestBuildEmailMultipartAndEscaping(t *testing.T) { + m := Message{ + Subject: "High memory usage on web-01", + Body: "Memory above 80%.", + Severity: "warning", + FiredAt: time.Date(2026, 7, 16, 8, 33, 0, 0, time.UTC), + RuleName: "RAM > 80%", + Host: &HostContext{ + Name: `web-01`, + IP: "10.0.0.5", + OS: "Debian 13", + Kernel: "7.0.13-amd64", + Arch: "x86_64", + CPUCores: 8, + RAMBytes: 16 << 30, + AgentVersion: "v0.1.4", + Status: "online", + LastSeen: time.Date(2026, 7, 16, 8, 32, 40, 0, time.UTC), + UptimeSec: 6*86400 + 4*3600, + }, + } + msg := buildEmail("mon@example.com", "ops@example.com", m) + + if !strings.Contains(msg, "multipart/alternative") { + t.Fatal("expected multipart/alternative message") + } + if !strings.Contains(msg, "text/plain") || !strings.Contains(msg, "text/html") { + t.Fatal("expected both text/plain and text/html parts") + } + + // The HTML part is base64; decode by pulling the rendered template directly + // and asserting the hostile hostname is escaped. + html, err := renderAlertHTML(m) + if err != nil { + t.Fatal(err) + } + if strings.Contains(html, "") { + t.Fatal("hostname was not HTML-escaped (XSS/injection risk)") + } + if !strings.Contains(html, "web-01<script>") { + t.Fatal("expected escaped hostname in HTML") + } + for _, want := range []string{"Debian 13", "7.0.13-amd64", "16.0 GiB", "v0.1.4", "6d 4h", "RAM > 80%"} { + if !strings.Contains(html, want) { + t.Fatalf("HTML missing %q", want) + } + } +} + +func TestRenderAlertHTMLOmitsAbsentBlocks(t *testing.T) { + // No Host / Metric: template must render without panic and omit those blocks. + html, err := renderAlertHTML(Message{Subject: "Something happened", Body: "detail", Severity: "info"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(html, ">Host<") || strings.Contains(html, ">Metric<") { + t.Fatal("expected host/metric blocks omitted when data absent") + } + if !strings.Contains(html, "Something happened") { + t.Fatal("subject missing from HTML") + } +} + +func TestBuildEmailFallsBackToPlain(t *testing.T) { + // Empty subject+body → nothing to render → plain-text-only message. + msg := buildEmail("a@x", "b@x", Message{}) + if strings.Contains(msg, "multipart") { + t.Fatal("empty message should fall back to plain text, not multipart") + } + if !strings.Contains(msg, "text/plain") { + t.Fatal("expected plain text content type") + } +} diff --git a/internal/server/notify/notify.go b/internal/server/notify/notify.go index be80c48..771d946 100644 --- a/internal/server/notify/notify.go +++ b/internal/server/notify/notify.go @@ -31,6 +31,39 @@ type Message struct { // Severity is informational and may be used by backends that support // priority (ntfy, Slack color). Optional. Severity string // info | warning | critical + + // The fields below are optional structured context. They drive the rich + // HTML email (SMTP channel); Subject/Body remain the canonical plain-text + // form every channel uses. All are safe to leave zero — the HTML template + // omits any block whose data is absent. + FiredAt time.Time // when the alert fired (zero = omit) + RuleName string // the rule that tripped + Metric *MetricDetail // populated for metric-threshold alerts + Host *HostContext // populated for host-scoped alerts + HostURL string // deep link to the host page (set when MON_PUBLIC_URL is configured) +} + +// MetricDetail describes the metric that tripped a threshold rule. +type MetricDetail struct { + Name string // e.g. "ram_used_pct" + Current string // formatted current value, e.g. "87.4%" + Threshold string // formatted threshold, e.g. "> 80%" +} + +// HostContext carries human-readable facts about the affected host for the +// email body. Zero fields are omitted from the rendered table. +type HostContext struct { + Name string + IP string + OS string // distro, e.g. "Debian 13" + Kernel string + Arch string + CPUCores int + RAMBytes int64 + AgentVersion string + Status string // "online" | "offline" | "unknown" + LastSeen time.Time + UptimeSec int64 } // Channel is the minimal projection of a notification_channels row that @@ -160,7 +193,7 @@ func (SMTP) Send(ctx context.Context, ch Channel, m Message) error { addr := net.JoinHostPort(host, strconv.Itoa(port)) rcptList := strings.Join(tos, ", ") - body := buildRFC5322(from, rcptList, m.Subject, m.Body) + body := buildEmail(from, rcptList, m) var auth smtp.Auth if user != "" { diff --git a/internal/server/store/alert_context.go b/internal/server/store/alert_context.go new file mode 100644 index 0000000..34e864d --- /dev/null +++ b/internal/server/store/alert_context.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// HostFacts is the read-only projection used to enrich alert notifications with +// human-readable host context (AUDIT/feature 2026-07-16 HTML alert emails). +type HostFacts struct { + Hostname string + Distro string + Kernel string + Arch string + CPUCores int + RAMBytes int64 + AgentVersion string + Status string + LastSeen time.Time + IP string +} + +// HostFactsFor returns display facts for a host. It is best-effort context for +// notifications: callers treat an error as "no host block" and still deliver. +func (s *Store) HostFactsFor(ctx context.Context, hostID uuid.UUID) (HostFacts, error) { + var f HostFacts + var ip string + err := s.Pool.QueryRow(ctx, ` + SELECT h.hostname, + COALESCE(h.distro, ''), + COALESCE(h.kernel, ''), + COALESCE(h.arch, ''), + COALESCE(h.cpu_cores, 0), + COALESCE(h.ram_total_bytes, 0), + COALESCE(h.agent_version, ''), + h.last_seen_at, + COALESCE(hs.status, 'unknown'), + COALESCE(( + SELECT a FROM nics n, unnest(n.addrs) AS a + WHERE n.host_id = h.id + AND a NOT LIKE '127.%' + AND a NOT LIKE '::1%' + AND a NOT LIKE 'fe80%' + AND position('.' in a) > 0 + ORDER BY a + LIMIT 1), '') + FROM hosts h + LEFT JOIN host_status hs ON hs.host_id = h.id + WHERE h.id = $1 AND h.revoked_at IS NULL`, + hostID, + ).Scan(&f.Hostname, &f.Distro, &f.Kernel, &f.Arch, &f.CPUCores, + &f.RAMBytes, &f.AgentVersion, &f.LastSeen, &f.Status, &ip) + if errors.Is(err, pgx.ErrNoRows) { + return HostFacts{}, ErrHostNotFound + } + if err != nil { + return HostFacts{}, err + } + // Addresses may carry a /prefix suffix; show the bare address. + if i := strings.IndexByte(ip, '/'); i >= 0 { + ip = ip[:i] + } + f.IP = ip + return f, nil +}