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 }