Files
nmon/internal/api/devices.go

355 lines
12 KiB
Go

package api
import (
"context"
"fmt"
"net/http"
"net/netip"
"slices"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/snmptarget"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/ulid"
)
// SNMPBody is the SNMP credential in a request. Secrets are write-only: no
// response ever contains them.
type SNMPBody struct {
Version string `json:"version" enum:"v1,v2c,v3" doc:"SNMP version."`
Community string `json:"community,omitempty" doc:"v1 and v2c community." example:"public"`
V3User string `json:"v3_user,omitempty" doc:"v3 user name."`
V3AuthProto string `json:"v3_auth_proto,omitempty" doc:"MD5, SHA, SHA224, SHA256 (default), SHA384 or SHA512."`
V3AuthPass string `json:"v3_auth_pass,omitempty"`
V3PrivProto string `json:"v3_priv_proto,omitempty" doc:"DES, AES (default), AES192 or AES256."`
V3PrivPass string `json:"v3_priv_pass,omitempty"`
V3Context string `json:"v3_context,omitempty"`
Port int `json:"port,omitempty" minimum:"1" maximum:"65535" doc:"UDP port; default 161."`
}
// AddDeviceBody is the body of POST /api/v1/devices.
type AddDeviceBody struct {
Name string `json:"name" minLength:"1" maxLength:"255" example:"core-sw1"`
Addresses []string `json:"addresses" minItems:"1" doc:"Management addresses; an IPv6 address is preferred over IPv4." example:"[\"2001:db8::1\"]"` //nolint:lll // struct tag, cannot wrap
Kind string `json:"kind,omitempty" enum:"network,vm,container,check_only" default:"network"`
Site string `json:"site,omitempty"`
Role string `json:"role,omitempty"`
SNMP *SNMPBody `json:"snmp,omitempty" doc:"Required for kind network."`
// Reattach and CreateNew answer a 409 reattach_candidates response.
Reattach string `json:"reattach,omitempty" doc:"Slug of a retired device to restore instead of creating a new one (its slug and interface IDs come back)."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
CreateNew bool `json:"create_new,omitempty" doc:"Create a new device although a retired one matches the address or sysName."`
}
// reattachCandidate is a retired device offered in a 409.
type reattachCandidate struct {
Slug string `json:"slug"`
Name string `json:"name"`
RetiredAt time.Time `json:"retired_at"`
}
// reattachProblem is the 409 problem with the retired devices the client may restore.
type reattachProblem struct {
Problem
Candidates []reattachCandidate `json:"reattach_candidates"`
}
func (s *server) registerDevices(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "add-device", Method: "POST", Path: "/api/v1/devices", Tags: []string{"devices"},
Summary: "Add a device",
Description: "Creates the credential, device and addresses, runs seed discovery synchronously (30 s limit) and " +
"starts polling. A device that does not answer SNMP is rejected with 502 and nothing is stored.",
DefaultStatus: http.StatusCreated,
Errors: []int{http.StatusConflict, http.StatusUnprocessableEntity, http.StatusBadGateway},
}, s.addDevice)
}
// addRequest is an AddDeviceBody after validation: defaults applied and
// addresses parsed.
type addRequest struct {
AddDeviceBody
kind string
addrs []netip.Addr
strs []string // addrs in canonical text form, as stored
}
// addDevice validates the request, collects from the device before anything is
// stored, decides between a new device and a reattach, then persists in one
// transaction.
func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }) (*deviceOut, error) {
req, err := validateAdd(in.Body)
if err != nil {
return nil, err
}
cred, res, err := s.credentialAndSeed(ctx, req)
if err != nil {
return nil, err
}
restore, err := s.reattachDecision(ctx, req, res)
if err != nil {
return nil, err
}
slug, err := s.persistDevice(ctx, req, cred, res, restore)
if err != nil {
return nil, s.storeErr(err)
}
s.inventoryChanged(ctx)
return s.view(ctx, slug)
}
// validateAdd applies the kind default and parses the addresses.
func validateAdd(b AddDeviceBody) (*addRequest, error) {
req := &addRequest{AddDeviceBody: b, kind: b.Kind}
if req.kind == "" {
req.kind = store.KindNetwork
}
for _, a := range b.Addresses {
ip, err := netip.ParseAddr(a)
if err != nil {
return nil, huma.Error422UnprocessableEntity(fmt.Sprintf("address %q is not an IP address", a))
}
ip = ip.WithZone("").Unmap()
req.addrs = append(req.addrs, ip)
req.strs = append(req.strs, ip.String())
}
if len(req.addrs) == 0 {
return nil, huma.Error422UnprocessableEntity("at least one address is required")
}
if req.kind == store.KindNetwork && b.SNMP == nil {
return nil, huma.Error422UnprocessableEntity("snmp credentials are required for kind network")
}
return req, nil
}
// credentialAndSeed builds the credential from the request and runs the seed
// walk against the preferred address. Both are nil when the request has no
// SNMP block.
func (s *server) credentialAndSeed(ctx context.Context, req *addRequest) (*store.SNMPCredential, *discovery.Result, error) {
if req.SNMP == nil {
return nil, nil, nil
}
cred, err := s.credential(req.SNMP)
if err != nil {
return nil, nil, err
}
res, err := s.collect(ctx, req.preferredAddr(), s.port(req.SNMP.Port), cred)
if err != nil {
return nil, nil, err
}
return cred, res, nil
}
// preferredAddr is the first IPv6 address, else the first address.
func (r *addRequest) preferredAddr() netip.Addr {
for _, a := range r.addrs {
if a.Is6() {
return a
}
}
return r.addrs[0]
}
// reattachDecision looks for retired devices matching the request. It returns
// the one to restore, nil for a brand-new device, or a 409 offering the
// candidates when the client has not chosen.
func (s *server) reattachDecision(ctx context.Context, req *addRequest, res *discovery.Result) (*discovery.ReattachCandidate, error) {
if req.CreateNew {
return nil, nil
}
sysName := ""
if res != nil {
sysName = res.SysName
}
cands, err := discovery.FindReattachCandidates(ctx, s.Store, req.strs, sysName)
if err != nil {
return nil, s.storeErr(err)
}
if req.Reattach != "" {
i := slices.IndexFunc(cands, func(c discovery.ReattachCandidate) bool { return c.Slug == req.Reattach })
if i < 0 {
return nil, huma.Error422UnprocessableEntity(
"reattach: " + req.Reattach + " is not a retired device matching this address or sysName")
}
return &cands[i], nil
}
if len(cands) == 0 {
return nil, nil
}
if err := discovery.OfferReattach(ctx, s.Store, cands, req.Name, req.strs, sysName); err != nil {
return nil, s.storeErr(err)
}
return nil, reattachOffer(cands)
}
// reattachOffer is the 409 listing the retired devices the client may restore.
func reattachOffer(cands []discovery.ReattachCandidate) *reattachProblem {
p := &reattachProblem{Candidates: make([]reattachCandidate, len(cands))}
p.Status, p.Title = http.StatusConflict, http.StatusText(http.StatusConflict)
p.Detail = "a retired device has this address or sysName; send reattach: <slug> to restore it or create_new: true to add a new device"
for i, c := range cands {
p.Candidates[i] = reattachCandidate{Slug: c.Slug, Name: c.Name, RetiredAt: c.RetiredAt}
}
return p
}
// persistDevice writes the credential, device, addresses, seeded inventory and
// audit row in one transaction and returns the device slug.
func (s *server) persistDevice(
ctx context.Context, req *addRequest, cred *store.SNMPCredential, res *discovery.Result, restore *discovery.ReattachCandidate,
) (string, error) {
var slug string
err := s.Store.WithTx(ctx, func(tx store.Store) error {
d, err := s.writeDevice(ctx, tx, req, cred, res, restore)
if err != nil {
return err
}
slug = d.Slug
action := "device.add"
if restore != nil {
action = "device.reattach"
}
return tx.AppendAudit(ctx, actorOf(ctx), action, d.Slug, nil,
map[string]any{"name": d.Name, "kind": d.Kind, "addresses": req.strs})
})
return slug, err
}
// writeDevice stores the credential, the device row, its addresses and the
// seeded inventory inside tx.
func (s *server) writeDevice(
ctx context.Context, tx store.Store, req *addRequest, cred *store.SNMPCredential, res *discovery.Result,
restore *discovery.ReattachCandidate,
) (*store.Device, error) {
d, err := s.deviceRow(ctx, tx, req, restore)
if err != nil {
return nil, err
}
if cred != nil {
if err := tx.CreateCredential(ctx, cred); err != nil {
return nil, err
}
d.SNMPCredentialID = &cred.ID
}
if restore != nil {
err = tx.UpdateDevice(ctx, d)
} else {
err = tx.CreateDevice(ctx, d)
}
if err != nil {
return nil, err
}
if err := tx.SetAddresses(ctx, d.ID, req.strs); err != nil {
return nil, err
}
if res != nil {
if _, err := discovery.Apply(ctx, tx, d, res, s.Clock); err != nil {
return nil, err
}
}
return d, nil
}
// deviceRow is the device to write: a fresh row, or the restored one with the
// request's name, kind and (when given) site and role laid over it.
func (s *server) deviceRow(
ctx context.Context, tx store.Store, req *addRequest, restore *discovery.ReattachCandidate,
) (*store.Device, error) {
d := &store.Device{Name: req.Name, Kind: req.kind, Site: req.Site, Role: req.Role, Enabled: true, Profile: "generic"}
if restore != nil {
var err error
if d, err = tx.Reattach(ctx, restore.DeviceID); err != nil {
return nil, err
}
d.Name, d.Kind, d.Enabled = req.Name, req.kind, true
if req.Site != "" {
d.Site = req.Site
}
if req.Role != "" {
d.Role = req.Role
}
}
if req.SNMP != nil {
d.SNMPPort = req.SNMP.Port
}
return d, nil
}
// credential validates the request's SNMP block and returns a fresh credential.
func (s *server) credential(b *SNMPBody) (*store.SNMPCredential, error) {
c := &store.SNMPCredential{
Name: "cred-" + ulid.New(s.Clock.Now()), Version: b.Version, Community: b.Community, V3User: b.V3User,
V3AuthProto: b.V3AuthProto, V3AuthPass: b.V3AuthPass, V3PrivProto: b.V3PrivProto, V3PrivPass: b.V3PrivPass,
V3Context: b.V3Context,
}
switch b.Version {
case "v1", "v2c":
if b.Community == "" {
return nil, huma.Error422UnprocessableEntity("snmp.community is required for " + b.Version)
}
case "v3":
if b.V3User == "" {
return nil, huma.Error422UnprocessableEntity("snmp.v3_user is required for v3")
}
if _, err := snmp.ParseAuthProto(b.V3AuthProto); err != nil {
return nil, huma.Error422UnprocessableEntity(err.Error())
}
if _, err := snmp.ParsePrivProto(b.V3PrivProto); err != nil {
return nil, huma.Error422UnprocessableEntity(err.Error())
}
default:
return nil, huma.Error422UnprocessableEntity("snmp.version must be v1, v2c or v3")
}
return c, nil
}
// collect runs the seed walk against addr before anything is stored.
func (s *server) collect(ctx context.Context, addr netip.Addr, port int, cred *store.SNMPCredential) (*discovery.Result, error) {
dctx, cancel := context.WithTimeout(ctx, s.DiscoveryTimeout)
defer cancel()
sess, err := snmp.Dial(dctx, snmptarget.For(addr, port, cred, s.Clock), "discovery")
if err != nil {
return nil, huma.Error422UnprocessableEntity("snmp: " + err.Error())
}
defer func() { _ = sess.Close() }() // read-only session; nothing to lose on close
res, err := discovery.Collect(dctx, sess, s.Clock, discovery.Options{Profiles: s.Profiles})
if err != nil {
return nil, huma.Error502BadGateway("discovery of " + addr.String() + " failed: " + err.Error())
}
return res, nil
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
func utcp(t *time.Time) *time.Time {
if t == nil {
return nil
}
u := t.UTC()
return &u
}
// port is the device's SNMP port unless the dev-only --snmp-port override is set.
func (s *server) port(p int) int {
if s.SNMPPort != 0 {
return s.SNMPPort
}
return p
}
// inventoryChanged tells the poller and the dependency graph that devices or their parents changed.
func (s *server) inventoryChanged(ctx context.Context) {
s.Poller.Refresh()
if err := s.Dependencies.Refresh(ctx); err != nil {
s.Logger.Warn("refresh dependency graph", "err", err)
}
}