Make nmon easier to install and run without editing YAML by hand: - Settings: point-and-click forms over config.yaml (GET/PUT /api/v1/settings/form); the YAML editor moves to /settings/yaml. - First install: with no sign-in configured nmon starts in install mode, serves only the setup wizard (sign-in, basics, devices, alerting) and closes it for good once sign-in is saved. A self-signed certificate is generated when no TLS source is given. poller.interval becomes a config setting. - Reverse proxies: opt-in --http-listen plain-HTTP listener beside HTTPS; sign-in stays required. - Webhooks: Generic, Teams Adaptive Card, Slack and Discord presets, template preview and test send. - Help: the operations, coverage and profile docs served at /help. - Polling: per-device "Poll now", a dry-run test poll returning an SNMP trace, and time-limited in-memory SNMP debug recording, in the UI and as `nmon device poll`. - Ansible: deploys into install mode when no sign-in is set, and no longer overwrites the config.yaml the wizard wrote. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TuKgyKve2XLFtrrXWMi2w
132 lines
4.3 KiB
Go
132 lines
4.3 KiB
Go
package notify
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// testExcerpt caps the response body a test send reports.
|
|
const testExcerpt = 4 << 10
|
|
|
|
// ErrTestBusy is returned when too many test sends are already in flight.
|
|
var ErrTestBusy = errors.New("notify: too many test sends in progress")
|
|
|
|
// TestResult is the outcome of one immediate test send. Payload and ResponseBody are redacted.
|
|
type TestResult struct {
|
|
Payload string `json:"payload"`
|
|
StatusCode int `json:"status_code"`
|
|
ResponseBody string `json:"response_body"`
|
|
DurationMS int64 `json:"duration_ms"`
|
|
Error string `json:"error"`
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
// Preview renders r's template against a sample notification in state, exactly as a delivery would, and returns
|
|
// the redacted output. Connection settings are not needed (a placeholder fills in what validation insists on), so
|
|
// a half-filled receiver form can be previewed. The error is the template, JSON or validation problem.
|
|
func Preview(r Receiver, state string, now time.Time) (string, error) {
|
|
if !ValidState(state) {
|
|
return "", fmt.Errorf("unknown state %q", state)
|
|
}
|
|
if r.Name == "" {
|
|
r.Name = "preview"
|
|
}
|
|
switch r.Kind {
|
|
case KindWebhook:
|
|
r.URL = "https://preview.invalid/"
|
|
r.Headers = nil
|
|
case KindSMTP:
|
|
r.SMTP = SMTP{Host: "preview.invalid", From: "nmon@preview.invalid", To: []string{"ops@preview.invalid"}}
|
|
}
|
|
c, err := compile(r)
|
|
if err != nil {
|
|
return "", unwrapReceiver(err, r.Name)
|
|
}
|
|
return c.render(SampleNotification(state, now))
|
|
}
|
|
|
|
// unwrapReceiver drops the `receiver "x": ` prefix that compile adds, which is noise in a single-receiver preview.
|
|
func unwrapReceiver(err error, name string) error {
|
|
return errors.New(strings.TrimPrefix(err.Error(), fmt.Sprintf("receiver %q: ", name)))
|
|
}
|
|
|
|
// SendTest renders a sample notification for r and sends it once, now, through the queue's outbound policy and
|
|
// timeout. It bypasses the delivery table and the retry schedule and persists nothing. At most two test sends run
|
|
// at a time; more return ErrTestBusy. Failures of the receiver itself are reported in the result, not as an error.
|
|
func (q *Queue) SendTest(ctx context.Context, r Receiver, state string) (TestResult, error) {
|
|
select {
|
|
case q.testSlots <- struct{}{}:
|
|
defer func() { <-q.testSlots }()
|
|
default:
|
|
return TestResult{}, ErrTestBusy
|
|
}
|
|
if !ValidState(state) {
|
|
return TestResult{}, fmt.Errorf("unknown state %q", state)
|
|
}
|
|
start := q.clk.Now()
|
|
res := q.sendTest(ctx, r, state, start)
|
|
res.DurationMS = q.clk.Now().Sub(start).Milliseconds()
|
|
return res, nil
|
|
}
|
|
|
|
// sendTest is SendTest after the slot and state checks.
|
|
func (q *Queue) sendTest(ctx context.Context, r Receiver, state string, start time.Time) (res TestResult) {
|
|
c, err := compile(r)
|
|
if err != nil {
|
|
res.Error = sanitizeErr(unwrapReceiver(err, r.Name))
|
|
return res
|
|
}
|
|
n := SampleNotification(state, start)
|
|
if res.Payload, err = c.render(n); err != nil {
|
|
res.Error = sanitizeErr(err)
|
|
return res
|
|
}
|
|
actx, cancel := context.WithTimeout(ctx, q.o.Timeout)
|
|
defer cancel()
|
|
d := store.Delivery{AlertID: n.AlertID, Receiver: r.Name, IncidentID: "test-" + n.IncidentID, Payload: res.Payload}
|
|
switch c.Kind {
|
|
case KindWebhook:
|
|
err = q.testWebhook(actx, c, d, &res)
|
|
case KindSMTP:
|
|
err = q.sendSMTP(actx, c, d)
|
|
}
|
|
if err != nil {
|
|
res.Error = sanitizeErr(err)
|
|
}
|
|
res.OK = err == nil
|
|
return res
|
|
}
|
|
|
|
// testWebhook POSTs d like sendWebhook but keeps the status and a body excerpt for the caller.
|
|
func (q *Queue) testWebhook(ctx context.Context, c *compiled, d store.Delivery, res *TestResult) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, strings.NewReader(d.Payload))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "nmon")
|
|
for k, v := range c.Headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
req.Header.Set(HeaderIncident, d.IncidentID)
|
|
resp, err := q.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }() // the excerpt is read below
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, testExcerpt))
|
|
res.StatusCode = resp.StatusCode
|
|
res.ResponseBody = RedactText(strings.ToValidUTF8(string(body), "?"))
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return fmt.Errorf("webhook returned HTTP %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|