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
250 lines
7.8 KiB
Go
250 lines
7.8 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"maps"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/v10networks/nmon/internal/checks"
|
|
"github.com/v10networks/nmon/internal/config"
|
|
"github.com/v10networks/nmon/internal/notify"
|
|
"github.com/v10networks/nmon/internal/proxmox"
|
|
)
|
|
|
|
// fieldError is a form value that cannot even be turned into configuration (an unparsable duration); Loc is
|
|
// the JSON path of the field.
|
|
type fieldError struct{ Loc, Msg string }
|
|
|
|
func (e *fieldError) Error() string { return e.Loc + ": " + e.Msg }
|
|
|
|
// parseDur reads a form duration; empty is unset (zero).
|
|
func parseDur(loc, s string) (time.Duration, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, nil
|
|
}
|
|
d, err := config.ParseDuration(s)
|
|
if err != nil {
|
|
return 0, &fieldError{Loc: loc, Msg: fmt.Sprintf("%q is not a duration (examples: 30d, 5m, 1h30m)", s)}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// durs parses several durations in order, stopping at the first bad one.
|
|
type durs struct{ err error }
|
|
|
|
func (p *durs) get(loc, s string) time.Duration {
|
|
if p.err != nil {
|
|
return 0
|
|
}
|
|
d, err := parseDur(loc, s)
|
|
p.err = err
|
|
return d
|
|
}
|
|
|
|
// applyFormPatch replaces the sections present in p inside c. Validation of the result is the caller's job
|
|
// and is the same as for a YAML document.
|
|
func applyFormPatch(c *config.Config, p SettingsFormPatch) error {
|
|
steps := []func(*config.Config, SettingsFormPatch) error{
|
|
patchRetention, patchGeneral, patchSites, patchAlerting, patchReceivers, patchAuth, patchBackupDisk,
|
|
patchL2OUI, patchProxmox, patchChecks,
|
|
}
|
|
for _, step := range steps {
|
|
if err := step(c, p); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func patchRetention(c *config.Config, p SettingsFormPatch) error {
|
|
if p.Retention == nil {
|
|
return nil
|
|
}
|
|
var d durs
|
|
r := config.Retention{Hot: config.Duration(d.get("retention.hot", p.Retention.Hot)),
|
|
FiveMinute: config.Duration(d.get("retention.fiveminute", p.Retention.FiveMinute)),
|
|
Long: config.Duration(d.get("retention.long", p.Retention.Long))}
|
|
c.Retention = r
|
|
return d.err
|
|
}
|
|
|
|
func patchGeneral(c *config.Config, p SettingsFormPatch) error {
|
|
if p.Timezone != nil {
|
|
c.Timezone = strings.TrimSpace(*p.Timezone)
|
|
}
|
|
var d durs
|
|
if p.RetiredPurgeAfter != nil {
|
|
c.RetiredPurgeAfter = config.Duration(d.get("retired_purge_after", *p.RetiredPurgeAfter))
|
|
}
|
|
if p.PollerInterval != nil {
|
|
c.Poller.Interval = config.Duration(d.get("poller.interval", *p.PollerInterval))
|
|
}
|
|
return d.err
|
|
}
|
|
|
|
func patchSites(c *config.Config, p SettingsFormPatch) error {
|
|
if p.Sites == nil {
|
|
return nil
|
|
}
|
|
sites := make(map[string]config.Site, len(*p.Sites))
|
|
for i, s := range *p.Sites {
|
|
name := strings.TrimSpace(s.Name)
|
|
if _, dup := sites[name]; name == "" || dup {
|
|
return &fieldError{Loc: fmt.Sprintf("sites[%d].name", i), Msg: "site names must be non-empty and unique"}
|
|
}
|
|
sites[name] = config.Site{Timezone: strings.TrimSpace(s.Timezone)}
|
|
}
|
|
if len(sites) == 0 {
|
|
sites = nil
|
|
}
|
|
c.Sites = sites
|
|
return nil
|
|
}
|
|
|
|
func patchAlerting(c *config.Config, p SettingsFormPatch) error {
|
|
if p.Alerting == nil {
|
|
return nil
|
|
}
|
|
var d durs
|
|
c.Alerting.EvaluationInterval = config.Duration(d.get("alerting.evaluation_interval", p.Alerting.EvaluationInterval))
|
|
c.Alerting.OutboundAllow = cleanList(p.Alerting.OutboundAllow)
|
|
return d.err
|
|
}
|
|
|
|
// cleanList trims entries and drops blanks; an empty list is nil so it is omitted from config.yaml.
|
|
func cleanList(in []string) []string {
|
|
var out []string
|
|
for _, s := range in {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// storedReceivers are the receivers now in the configuration, by name, so that write-only credentials the form
|
|
// leaves blank can be carried over.
|
|
func storedReceivers(a config.Alerting) map[string]notify.Receiver {
|
|
out := make(map[string]notify.Receiver, len(a.Receivers))
|
|
for i := range a.Receivers {
|
|
var r notify.Receiver
|
|
if err := a.Receivers[i].Decode(&r); err == nil {
|
|
out[r.Name] = r
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// receiverFrom builds a receiver from its form, keeping the stored credentials the form left blank.
|
|
func receiverFrom(f ReceiverForm, old notify.Receiver) notify.Receiver {
|
|
headers := maps.Clone(f.Headers)
|
|
for k, v := range headers {
|
|
if stored, ok := old.Headers[k]; ok && v == "" {
|
|
headers[k] = stored
|
|
}
|
|
}
|
|
password := f.SMTP.Password
|
|
if password == "" && !f.SMTP.ClearPassword {
|
|
password = old.SMTP.Password
|
|
}
|
|
return notify.Receiver{Name: strings.TrimSpace(f.Name), Kind: f.Kind, URL: strings.TrimSpace(f.URL),
|
|
Headers: headers, Template: f.Template,
|
|
SMTP: notify.SMTP{Host: strings.TrimSpace(f.SMTP.Host), Port: f.SMTP.Port, From: strings.TrimSpace(f.SMTP.From),
|
|
To: cleanList(f.SMTP.To), Username: f.SMTP.Username, Password: password, StartTLS: f.SMTP.StartTLS}}
|
|
}
|
|
|
|
func patchReceivers(c *config.Config, p SettingsFormPatch) error {
|
|
if p.Receivers == nil {
|
|
return nil
|
|
}
|
|
stored := storedReceivers(c.Alerting)
|
|
var nodes []yaml.Node
|
|
for i, f := range *p.Receivers {
|
|
var n yaml.Node
|
|
if err := n.Encode(receiverFrom(f, stored[strings.TrimSpace(f.Name)])); err != nil {
|
|
return &fieldError{Loc: fmt.Sprintf("receivers[%d]", i), Msg: err.Error()}
|
|
}
|
|
nodes = append(nodes, n)
|
|
}
|
|
c.Alerting.Receivers = nodes
|
|
return nil
|
|
}
|
|
|
|
func patchAuth(c *config.Config, p SettingsFormPatch) error {
|
|
if p.Auth == nil {
|
|
return nil
|
|
}
|
|
f := p.Auth
|
|
var d durs
|
|
a := config.Auth{BreakGlassFile: strings.TrimSpace(f.BreakGlassFile), BreakGlassUser: strings.TrimSpace(f.BreakGlassUser),
|
|
TrustedProxies: cleanList(f.TrustedProxies),
|
|
Session: config.Session{Idle: config.Duration(d.get("auth.session.idle", f.Session.Idle)),
|
|
Absolute: config.Duration(d.get("auth.session.absolute", f.Session.Absolute))}}
|
|
if o := f.OIDC; o != nil {
|
|
a.OIDC = &config.OIDC{Issuer: strings.TrimSpace(o.Issuer), ClientID: strings.TrimSpace(o.ClientID),
|
|
ClientSecretName: strings.TrimSpace(o.ClientSecretName), RedirectURL: strings.TrimSpace(o.RedirectURL),
|
|
GroupsClaim: strings.TrimSpace(o.GroupsClaim), AdminGroup: strings.TrimSpace(o.AdminGroup)}
|
|
}
|
|
c.Auth = a
|
|
return d.err
|
|
}
|
|
|
|
func patchBackupDisk(c *config.Config, p SettingsFormPatch) error {
|
|
if b := p.Backup; b != nil {
|
|
c.Backup = config.Backup{At: strings.TrimSpace(b.At), Dir: strings.TrimSpace(b.Dir), Keep: b.Keep}
|
|
}
|
|
if d := p.Disk; d != nil {
|
|
c.Disk = config.Disk{AlertUsed: d.AlertUsed, AdmitUsed: d.AdmitUsed, ReserveFree: d.ReserveFree}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func patchL2OUI(c *config.Config, p SettingsFormPatch) error {
|
|
var d durs
|
|
if l := p.L2; l != nil {
|
|
c.L2 = config.L2{Interval: config.Duration(d.get("l2.interval", l.Interval)), Keep: config.Duration(d.get("l2.keep", l.Keep)),
|
|
EdgeMaxMACs: l.EdgeMaxMACs, Concurrency: l.Concurrency,
|
|
DeviceTimeout: config.Duration(d.get("l2.device_timeout", l.DeviceTimeout))}
|
|
}
|
|
if o := p.OUI; o != nil {
|
|
oui := config.OUI{Refresh: config.Duration(d.get("oui.refresh", o.Refresh))}
|
|
if !o.Fetch { // fetch defaults to on, so only "off" is written
|
|
off := false
|
|
oui.Fetch = &off
|
|
}
|
|
c.OUI = oui
|
|
}
|
|
return d.err
|
|
}
|
|
|
|
func patchProxmox(c *config.Config, p SettingsFormPatch) error {
|
|
f := p.Proxmox
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
var d durs
|
|
pc := proxmox.Config{Interval: d.get("proxmox.interval", f.Interval), Timeout: d.get("proxmox.timeout", f.Timeout),
|
|
RetireGrace: d.get("proxmox.retire_grace", f.RetireGrace), Workers: f.Workers}
|
|
for _, cl := range f.Clusters {
|
|
pc.Clusters = append(pc.Clusters, proxmox.ClusterConfig{Name: strings.TrimSpace(cl.Name), URL: strings.TrimSpace(cl.URL),
|
|
TokenID: strings.TrimSpace(cl.TokenID), SecretName: strings.TrimSpace(cl.SecretName), CAFile: strings.TrimSpace(cl.CAFile),
|
|
InsecureSkipVerify: cl.InsecureSkipVerify})
|
|
}
|
|
c.Proxmox = pc
|
|
return d.err
|
|
}
|
|
|
|
func patchChecks(c *config.Config, p SettingsFormPatch) error {
|
|
f := p.Checks
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
var d durs
|
|
c.Checks = checks.Config{Workers: f.Workers, Timeout: d.get("checks.timeout", f.Timeout), Tick: d.get("checks.tick", f.Tick)}
|
|
return d.err
|
|
}
|