Files
nmon/internal/api/devices.go
jleung 02dce40e2a
Some checks failed
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
M1 WP5: seed discovery, poller, API, serve wiring, gate script; M1 gate passed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 17:58:26 +00:00

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
}