Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
355 lines
12 KiB
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)
|
|
}
|
|
}
|