Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
282 lines
9.8 KiB
Go
282 lines
9.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/netip"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
|
|
"github.com/v10networks/nmon/internal/discovery"
|
|
"github.com/v10networks/nmon/internal/snmp"
|
|
"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"`
|
|
}
|
|
|
|
// 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\"]"`
|
|
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."`
|
|
}
|
|
|
|
// InterfaceView is an interface as the API shows it, addressed by ULID.
|
|
type InterfaceView struct {
|
|
ID string `json:"id" doc:"Immutable ULID."`
|
|
IfIndex int64 `json:"ifindex" doc:"Current ifIndex; may change, the ID does not."`
|
|
IfName string `json:"ifname"`
|
|
IfDescr string `json:"ifdescr"`
|
|
IfAlias string `json:"ifalias"`
|
|
IfPhysAddr string `json:"ifphysaddr"`
|
|
IfSpeed int64 `json:"ifspeed" doc:"bits per second"`
|
|
IfHighSpeed int64 `json:"ifhighspeed" doc:"megabits per second"`
|
|
IfType int64 `json:"iftype"`
|
|
AdminStatus int64 `json:"admin_status"`
|
|
OperStatus int64 `json:"oper_status"`
|
|
HasHCCounters bool `json:"has_hc_counters"`
|
|
Excluded bool `json:"excluded"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
LastDiscovered time.Time `json:"last_discovered"`
|
|
}
|
|
|
|
// DeviceView is a device as the API shows it. It carries no numeric ID and no credential.
|
|
type DeviceView struct {
|
|
Slug string `json:"slug" doc:"Immutable public key."`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
Site string `json:"site"`
|
|
Role string `json:"role"`
|
|
Profile string `json:"profile"`
|
|
Enabled bool `json:"enabled"`
|
|
MgmtIP string `json:"mgmt_ip"`
|
|
Addresses []string `json:"addresses"`
|
|
SNMPVersion string `json:"snmp_version,omitempty"`
|
|
SysName string `json:"sys_name"`
|
|
SysObjectID string `json:"sys_object_id"`
|
|
SysDescr string `json:"sys_descr"`
|
|
PollState string `json:"poll_state"`
|
|
Warnings []string `json:"warnings"`
|
|
LastSeen *time.Time `json:"last_seen,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
|
Interfaces []InterfaceView `json:"interfaces"`
|
|
}
|
|
|
|
type deviceOut struct{ Body DeviceView }
|
|
|
|
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: 201,
|
|
Errors: []int{409, 422, 502},
|
|
}, s.addDevice)
|
|
huma.Register(api, huma.Operation{
|
|
OperationID: "get-device", Method: "GET", Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
|
|
Summary: "Get a device and its interfaces",
|
|
Errors: []int{404},
|
|
}, s.getDevice)
|
|
}
|
|
|
|
func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }) (*deviceOut, error) {
|
|
b := in.Body
|
|
kind := b.Kind
|
|
if kind == "" {
|
|
kind = store.KindNetwork
|
|
}
|
|
var addrs []netip.Addr
|
|
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))
|
|
}
|
|
addrs = append(addrs, ip.WithZone("").Unmap())
|
|
}
|
|
if len(addrs) == 0 {
|
|
return nil, huma.Error422UnprocessableEntity("at least one address is required")
|
|
}
|
|
if kind == store.KindNetwork && b.SNMP == nil {
|
|
return nil, huma.Error422UnprocessableEntity("snmp credentials are required for kind network")
|
|
}
|
|
var (
|
|
cred *store.SNMPCredential
|
|
res *discovery.Result
|
|
)
|
|
if b.SNMP != nil {
|
|
var err error
|
|
if cred, err = s.credential(b.SNMP); err != nil {
|
|
return nil, err
|
|
}
|
|
pref := addrs[0]
|
|
for _, a := range addrs {
|
|
if a.Is6() {
|
|
pref = a
|
|
break
|
|
}
|
|
}
|
|
if res, err = s.collect(ctx, pref, cred); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var slug string
|
|
err := s.Store.WithTx(ctx, func(tx store.Store) error {
|
|
d := &store.Device{Name: b.Name, Kind: kind, Site: b.Site, Role: b.Role, Enabled: true, Profile: "generic"}
|
|
if cred != nil {
|
|
if err := tx.CreateCredential(ctx, cred); err != nil {
|
|
return err
|
|
}
|
|
d.SNMPCredentialID = &cred.ID
|
|
}
|
|
if err := tx.CreateDevice(ctx, d); err != nil {
|
|
return err
|
|
}
|
|
strs := make([]string, len(addrs))
|
|
for i, a := range addrs {
|
|
strs[i] = a.String()
|
|
}
|
|
if err := tx.SetAddresses(ctx, d.ID, strs); err != nil {
|
|
return err
|
|
}
|
|
if res != nil {
|
|
if err := discovery.Apply(ctx, tx, d, res, s.Clock); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
slug = d.Slug
|
|
return tx.AppendAudit(ctx, "anonymous", "device.add", d.Slug, nil,
|
|
map[string]any{"name": d.Name, "kind": d.Kind, "addresses": strs})
|
|
})
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
s.Poller.Refresh()
|
|
return s.view(ctx, slug)
|
|
}
|
|
|
|
// 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, cred *store.SNMPCredential) (*discovery.Result, error) {
|
|
dctx, cancel := context.WithTimeout(ctx, s.DiscoveryTimeout)
|
|
defer cancel()
|
|
sess, err := snmp.Dial(dctx, discovery.Target(addr, s.SNMPPort, cred, s.Clock), "discovery")
|
|
if err != nil {
|
|
return nil, huma.Error422UnprocessableEntity("snmp: " + err.Error())
|
|
}
|
|
defer sess.Close()
|
|
res, err := discovery.Collect(dctx, sess, s.Clock)
|
|
if err != nil {
|
|
return nil, huma.Error502BadGateway("discovery of " + addr.String() + " failed: " + err.Error())
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (s *server) getDevice(ctx context.Context, in *struct {
|
|
Slug string `path:"slug" doc:"Device slug."`
|
|
}) (*deviceOut, error) {
|
|
return s.view(ctx, in.Slug)
|
|
}
|
|
|
|
func (s *server) view(ctx context.Context, slug string) (*deviceOut, error) {
|
|
d, err := s.Store.GetDeviceBySlug(ctx, slug)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
if d.PurgedAt != nil {
|
|
return nil, huma.Error404NotFound("not found")
|
|
}
|
|
v := DeviceView{
|
|
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, Profile: d.Profile, Enabled: d.Enabled,
|
|
MgmtIP: d.MgmtIP, SysName: d.SysName, SysObjectID: d.SysObjectID, SysDescr: d.SysDescr,
|
|
PollState: d.PollState, Warnings: nonNil(d.Warnings), LastSeen: utcp(d.LastSeen), CreatedAt: d.CreatedAt.UTC(),
|
|
RetiredAt: utcp(d.RetiredAt), Addresses: []string{}, Interfaces: []InterfaceView{},
|
|
}
|
|
addrs, err := s.Store.ListAddresses(ctx, d.ID)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
for _, a := range addrs {
|
|
v.Addresses = append(v.Addresses, a.Addr)
|
|
}
|
|
if d.SNMPCredentialID != nil {
|
|
c, err := s.Store.GetCredential(ctx, *d.SNMPCredentialID)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
v.SNMPVersion = c.Version // the secrets stay behind
|
|
}
|
|
ifs, err := s.Store.ListInterfaces(ctx, d.ID, false)
|
|
if err != nil {
|
|
return nil, s.storeErr(err)
|
|
}
|
|
for _, i := range ifs {
|
|
v.Interfaces = append(v.Interfaces, InterfaceView{
|
|
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias,
|
|
IfPhysAddr: i.IfPhysAddr, IfSpeed: i.IfSpeed, IfHighSpeed: i.IfHighSpeed, IfType: i.IfType,
|
|
AdminStatus: i.AdminStatus, OperStatus: i.OperStatus, HasHCCounters: i.HasHCCounters, Excluded: i.Excluded,
|
|
FirstSeen: i.FirstSeen.UTC(), LastDiscovered: i.LastDiscovered.UTC(),
|
|
})
|
|
}
|
|
return &deviceOut{Body: v}, 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
|
|
}
|