Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
278 lines
9.1 KiB
Go
278 lines
9.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/checks"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// CheckLast is the latest outcome of a check since nmon started.
|
|
type CheckLast struct {
|
|
At time.Time `json:"at"`
|
|
Status string `json:"status" enum:"up,down,unsupported"`
|
|
LatencySeconds *float64 `json:"latency_seconds" nullable:"true"`
|
|
TLSDaysLeft *float64 `json:"tls_days_left" nullable:"true" doc:"Days until the certificate expires; negative once expired."`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// CheckView is a service check and its latest result.
|
|
type CheckView struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind" enum:"ping,http,dns,tls"`
|
|
Target string `json:"target" doc:"Address (ping), URL (http), name (dns) or host:port (tls)."`
|
|
IntervalS int64 `json:"interval_s"`
|
|
Enabled bool `json:"enabled"`
|
|
Device DeviceRef `json:"device" doc:"The device the results belong to."`
|
|
Params checks.Params `json:"params"`
|
|
Last *CheckLast `json:"last,omitempty" doc:"Absent until the check has run in this process."`
|
|
}
|
|
|
|
// CheckList is every check.
|
|
type CheckList struct {
|
|
Items []CheckView `json:"items"`
|
|
}
|
|
|
|
// CheckBody is the body of POST and PUT /checks.
|
|
type CheckBody struct {
|
|
Kind string `json:"kind" enum:"ping,http,dns,tls"`
|
|
Target string `json:"target" minLength:"1"`
|
|
IntervalS int64 `json:"interval_s,omitempty" minimum:"0" doc:"Seconds between runs; 0 or absent means 60. At least 10."`
|
|
Device string `json:"device,omitempty" doc:"Slug of the device the results belong to. Absent: a check_only device called name is created."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
|
Name string `json:"name,omitempty" doc:"Name of the check_only device created when device is absent."`
|
|
Params checks.Params `json:"params,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty" doc:"Default true."`
|
|
}
|
|
|
|
func (s *server) registerChecks(api huma.API) {
|
|
tags := []string{"checks"}
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "list-checks", Method: http.MethodGet, Path: "/api/v1/checks", Tags: tags,
|
|
Summary: "List service checks with their latest result",
|
|
}, s.listChecks)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "create-check", Method: http.MethodPost, Path: "/api/v1/checks", Tags: tags, DefaultStatus: http.StatusCreated,
|
|
Summary: "Create a service check",
|
|
Description: "Ping, HTTP, DNS or TLS-expiry. The check belongs to a device; without device a check_only device is created " +
|
|
"from name. Targets must pass the outbound allow-list when they run.",
|
|
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity},
|
|
}, s.createCheck)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "update-check", Method: http.MethodPut, Path: "/api/v1/checks/{id}", Tags: tags,
|
|
Summary: "Replace a service check's definition", Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity},
|
|
}, s.updateCheck)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "delete-check", Method: http.MethodDelete, Path: "/api/v1/checks/{id}", Tags: tags, DefaultStatus: http.StatusNoContent,
|
|
Summary: "Delete a service check", Errors: []int{http.StatusNotFound},
|
|
}, s.deleteCheck)
|
|
}
|
|
|
|
// checkResults indexes the latest results by check id.
|
|
func (s *server) checkResults() map[int64]checks.LastResult {
|
|
out := map[int64]checks.LastResult{}
|
|
if s.Checks == nil {
|
|
return out
|
|
}
|
|
for _, r := range s.Checks.Results() {
|
|
out[r.CheckID] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
func lastOf(r checks.LastResult, ok bool) *CheckLast {
|
|
if !ok {
|
|
return nil
|
|
}
|
|
l := &CheckLast{At: r.At.UTC(), Error: r.Result.Err, Status: "down"}
|
|
switch r.Result.Probe {
|
|
case checks.ProbeOK:
|
|
l.Status = "up"
|
|
case checks.ProbeUnsupported:
|
|
l.Status = "unsupported"
|
|
}
|
|
if r.Result.Probe == checks.ProbeOK {
|
|
sec := r.Result.Latency.Seconds()
|
|
l.LatencySeconds = &sec
|
|
}
|
|
if r.Result.HasTLSExpiry {
|
|
d := r.Result.TLSExpiry.Hours() / 24
|
|
l.TLSDaysLeft = &d
|
|
}
|
|
return l
|
|
}
|
|
|
|
func (s *server) checkView(ctx context.Context, c *store.Check, res map[int64]checks.LastResult) CheckView {
|
|
v := CheckView{
|
|
ID: strconv.FormatInt(c.ID, 10), Kind: c.Kind, Target: c.Target, IntervalS: c.IntervalS, Enabled: c.Enabled,
|
|
Device: DeviceRef{},
|
|
}
|
|
v.Params, _ = checks.ParseParams(c.Params) // a stored row was validated on the way in
|
|
if c.DeviceID != nil {
|
|
if d, err := s.Store.GetDeviceByID(ctx, *c.DeviceID); err == nil {
|
|
v.Device = DeviceRef{Slug: d.Slug, Name: d.Name}
|
|
}
|
|
}
|
|
r, ok := res[c.ID]
|
|
v.Last = lastOf(r, ok)
|
|
return v
|
|
}
|
|
|
|
func (s *server) listChecks(ctx context.Context, _ *struct{}) (*struct{ Body CheckList }, error) {
|
|
rows, err := s.Store.ListChecks(ctx, true)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
res := s.checkResults()
|
|
out := CheckList{Items: make([]CheckView, 0, len(rows))}
|
|
for i := range rows {
|
|
out.Items = append(out.Items, s.checkView(ctx, &rows[i], res))
|
|
}
|
|
return &struct{ Body CheckList }{out}, nil
|
|
}
|
|
|
|
// checkRow validates the body and builds the row, creating the check_only device when asked (inside tx).
|
|
func (s *server) checkRow(ctx context.Context, tx store.Store, b CheckBody) (*store.Check, error) {
|
|
params, err := encodeParams(b.Params)
|
|
if err != nil {
|
|
return nil, huma.Error422UnprocessableEntity(err.Error())
|
|
}
|
|
c := &store.Check{Kind: b.Kind, Target: strings.TrimSpace(b.Target), IntervalS: b.IntervalS, Params: params, Enabled: true}
|
|
if c.IntervalS == 0 {
|
|
c.IntervalS = checks.DefaultIntervalS
|
|
}
|
|
if b.Enabled != nil {
|
|
c.Enabled = *b.Enabled
|
|
}
|
|
if c.IntervalS < store.MinCheckIntervalS {
|
|
return nil, huma.Error422UnprocessableEntity("interval_s must be at least " + strconv.Itoa(store.MinCheckIntervalS))
|
|
}
|
|
if err := checks.Validate(*c); err != nil {
|
|
return nil, huma.Error422UnprocessableEntity(err.Error())
|
|
}
|
|
d, err := s.checkDevice(ctx, tx, b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.DeviceID = &d.ID
|
|
return c, nil
|
|
}
|
|
|
|
// checkDevice resolves the body's device, or creates the check_only one.
|
|
func (s *server) checkDevice(ctx context.Context, tx store.Store, b CheckBody) (*store.Device, error) {
|
|
if b.Device != "" {
|
|
d, err := tx.GetDeviceBySlug(ctx, b.Device)
|
|
if err != nil || d.RetiredAt != nil || d.PurgedAt != nil {
|
|
if err != nil && !isNotFound(err) {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
return nil, huma.Error422UnprocessableEntity("device: no active device with slug " + quote(b.Device))
|
|
}
|
|
return d, nil
|
|
}
|
|
name := strings.TrimSpace(b.Name)
|
|
if name == "" {
|
|
return nil, huma.Error422UnprocessableEntity("give device (a slug) or name (for a new check_only device)")
|
|
}
|
|
d := &store.Device{Name: name, Kind: store.KindCheckOnly, Enabled: true, Profile: "generic"}
|
|
if err := tx.CreateDevice(ctx, d); err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (s *server) createCheck(ctx context.Context, in *struct{ Body CheckBody }) (*struct{ Body CheckView }, error) {
|
|
var row *store.Check
|
|
err := s.Store.WithTx(ctx, func(tx store.Store) error {
|
|
c, err := s.checkRow(ctx, tx, in.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.CreateCheck(ctx, c); err != nil {
|
|
return err
|
|
}
|
|
row = c
|
|
return tx.AppendAudit(ctx, actorOf(ctx), "check.create", "check:"+strconv.FormatInt(c.ID, 10), nil, in.Body)
|
|
})
|
|
if err != nil {
|
|
return nil, s.txErr(err)
|
|
}
|
|
s.inventoryChanged(ctx)
|
|
return &struct{ Body CheckView }{s.checkView(ctx, row, s.checkResults())}, nil
|
|
}
|
|
|
|
func (s *server) updateCheck(ctx context.Context, in *struct {
|
|
ID string `path:"id" doc:"Check id."`
|
|
Body CheckBody
|
|
}) (*struct{ Body CheckView }, error) {
|
|
id, err := idParam(in.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var row *store.Check
|
|
err = s.Store.WithTx(ctx, func(tx store.Store) error {
|
|
if _, err := tx.GetCheck(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
c, err := s.checkRow(ctx, tx, in.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.ID = id
|
|
if err := tx.UpdateCheck(ctx, c); err != nil {
|
|
return err
|
|
}
|
|
row = c
|
|
return tx.AppendAudit(ctx, actorOf(ctx), "check.update", "check:"+in.ID, nil, in.Body)
|
|
})
|
|
if err != nil {
|
|
return nil, s.txErr(err)
|
|
}
|
|
s.inventoryChanged(ctx)
|
|
return &struct{ Body CheckView }{s.checkView(ctx, row, s.checkResults())}, nil
|
|
}
|
|
|
|
func (s *server) deleteCheck(ctx context.Context, in *struct {
|
|
ID string `path:"id" doc:"Check id."`
|
|
}) (*struct{}, error) {
|
|
id, err := idParam(in.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = s.Store.WithTx(ctx, func(tx store.Store) error {
|
|
if err := tx.DeleteCheck(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
return tx.AppendAudit(ctx, actorOf(ctx), "check.delete", "check:"+in.ID, nil, nil)
|
|
})
|
|
if err != nil {
|
|
return nil, s.txErr(err)
|
|
}
|
|
return &struct{}{}, nil
|
|
}
|
|
|
|
// txErr passes problems built inside a transaction through and maps store errors.
|
|
func (s *server) txErr(err error) error {
|
|
var se huma.StatusError
|
|
if errors.As(err, &se) {
|
|
return se
|
|
}
|
|
return s.storeErr(err)
|
|
}
|
|
|
|
// encodeParams is the stored form of p: JSON, or empty when every setting is the default.
|
|
func encodeParams(p checks.Params) (string, error) {
|
|
if p == (checks.Params{}) {
|
|
return "", nil
|
|
}
|
|
b, err := json.Marshal(p)
|
|
return string(b), err
|
|
}
|