network/mikrotik-tool/inventory.go
2026-05-08 17:47:42 -05:00

1133 lines
30 KiB
Go

// inventory subcommand: walks every router in routers.yaml, pulls
// /ip/address, /ip/arp, /ip/pool, and /ip/neighbor via the API, SNMP-probes
// each candidate radio for sysName, and re-renders inventory.yaml in place.
//
// Customer-facing equipment (anything in a customer CPE/CGNAT pool, plus
// LLDP/CDP entries that advertise wlan-ap or station-only capability) is
// deliberately excluded. The output covers only tower-side infrastructure:
// access points, backhaul radios, and tower switches.
//
// The existing inventory.yaml (or radios.yaml as a one-time fallback) is
// read as a seed: site names, parent_router links, and per-/29 backhaul
// link labels survive the refresh.
package main
import (
"bytes"
"fmt"
"log"
"net/netip"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/gosnmp/gosnmp"
"gopkg.in/yaml.v3"
)
const (
snmpCommunity = "kdyyJrT0Mm"
snmpOIDsysName = "1.3.6.1.2.1.1.5.0"
snmpWorkers = 16
snmpTimeout = 3 * time.Second
snmpRetries = 1
backhaulNetwork = "10.250.1.0/24"
cgnatNetwork = "100.64.0.0/10" // RFC 6598
rfc1918Net10 = "10.0.0.0/8"
inventoryPath = "inventory.yaml"
legacyRadiosPath = "radios.yaml" // one-time fallback seed
)
// ---------------------------------------------------------------------------
// seed yaml: what we keep from the existing inventory.yaml
type seedSite struct {
Name string
Router string // primary site has this
ParentRouter string // satellite has this instead
MgmtSubnet netip.Prefix // top /24 holds APs
// /29 → link label; we re-attach this when emitting backhaul radios.
LinkLabels map[netip.Prefix]string
}
type seed struct {
Order []string // site key insertion order
Sites map[string]*seedSite // by site key
Header []byte // leading comment block, copied verbatim
}
// loadSeedInventory parses inventory.yaml (falling back to a legacy
// radios.yaml if inventory.yaml is missing). If neither exists we return
// an empty seed so the user can bootstrap from nothing.
func loadSeedInventory(path string) (*seed, error) {
data, err := os.ReadFile(path)
if err != nil && os.IsNotExist(err) && path == inventoryPath {
// One-time migration: read the old file but write to the new one.
legacy, lerr := os.ReadFile(legacyRadiosPath)
if lerr == nil {
log.Printf("[inventory] seeding from legacy %s — output will be %s",
legacyRadiosPath, inventoryPath)
data, err = legacy, nil
}
}
if err != nil {
if os.IsNotExist(err) {
return &seed{Sites: map[string]*seedSite{}}, nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
s := &seed{Sites: map[string]*seedSite{}}
s.Header = leadingComments(data)
// Parse the document twice: once as raw nodes (to get site order), once
// as a typed struct (to extract fields cleanly).
var doc struct {
Sites yaml.Node `yaml:"sites"`
}
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if doc.Sites.Kind != yaml.MappingNode {
return s, nil
}
for i := 0; i+1 < len(doc.Sites.Content); i += 2 {
keyNode := doc.Sites.Content[i]
valNode := doc.Sites.Content[i+1]
var raw struct {
Router string `yaml:"router"`
ParentRouter string `yaml:"parent_router"`
MgmtSubnet string `yaml:"mgmt_subnet"`
BackhaulRadios []struct {
IP string `yaml:"ip"`
Link string `yaml:"link"`
} `yaml:"backhaul_radios"`
}
if err := valNode.Decode(&raw); err != nil {
return nil, fmt.Errorf("decode site %q: %w", keyNode.Value, err)
}
site := &seedSite{
Name: keyNode.Value,
Router: raw.Router,
ParentRouter: raw.ParentRouter,
LinkLabels: map[netip.Prefix]string{},
}
if raw.MgmtSubnet != "" {
p, err := netip.ParsePrefix(raw.MgmtSubnet)
if err != nil {
return nil, fmt.Errorf("site %q mgmt_subnet %q: %w", site.Name, raw.MgmtSubnet, err)
}
site.MgmtSubnet = p.Masked()
}
for _, br := range raw.BackhaulRadios {
ip, err := netip.ParseAddr(br.IP)
if err != nil || br.Link == "" {
continue
}
pfx := netip.PrefixFrom(ip, 29).Masked()
// Only the first occurrence wins; a /29 should have one label.
if _, ok := site.LinkLabels[pfx]; !ok {
site.LinkLabels[pfx] = br.Link
}
}
s.Order = append(s.Order, site.Name)
s.Sites[site.Name] = site
}
return s, nil
}
// leadingComments returns the byte prefix of `data` containing the initial
// run of comment / blank lines, ending with a trailing newline. We splice
// this back onto the regenerated yaml so the documentation header survives.
func leadingComments(data []byte) []byte {
var out bytes.Buffer
for _, line := range strings.SplitAfter(string(data), "\n") {
trimmed := strings.TrimLeft(line, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
out.WriteString(line)
continue
}
break
}
return out.Bytes()
}
// ---------------------------------------------------------------------------
// per-router discovery: pull /ip/address and /ip/arp via the API
type addrEntry struct {
Addr netip.Addr // the router's local IP on this subnet
Prefix netip.Prefix // the network in CIDR form
Interface string
}
type arpEntry struct {
Addr netip.Addr
MAC string
}
// neighborEntry is one CDP/LLDP/MNDP discovered device.
type neighborEntry struct {
Addr netip.Addr
MAC string
Identity string
Platform string
Caps string // raw "system-caps" string, comma-separated
Interface string
}
// poolRange is a contiguous range of addresses from /ip/pool, used to
// recognise customer-CPE / customer-CGNAT subnets we want to skip.
type poolRange struct {
Name string
Lo netip.Addr
Hi netip.Addr
}
func (p poolRange) contains(a netip.Addr) bool {
if !a.IsValid() || !p.Lo.IsValid() || !p.Hi.IsValid() {
return false
}
return a.Compare(p.Lo) >= 0 && a.Compare(p.Hi) <= 0
}
type routerDiscovery struct {
Router Router // resolved
OwnIP netip.Addr
Addresses []addrEntry
ARP []arpEntry
Neighbors []neighborEntry
Customer []poolRange // customer pool ranges to exclude
}
// mgmtSubnets returns the router-owned /20s within 10.0.0.0/8.
func (d *routerDiscovery) mgmtSubnets() []netip.Prefix {
rfc10 := netip.MustParsePrefix(rfc1918Net10)
var out []netip.Prefix
for _, a := range d.Addresses {
if a.Prefix.Bits() == 20 && rfc10.Contains(a.Prefix.Addr()) {
out = append(out, a.Prefix)
}
}
return out
}
// cgnatSubnets returns router-owned subnets within RFC 6598 100.64.0.0/10,
// paired with the router's gateway IP.
func (d *routerDiscovery) cgnatSubnets() []cgnat {
cgnatRange := netip.MustParsePrefix(cgnatNetwork)
var out []cgnat
for _, a := range d.Addresses {
if cgnatRange.Contains(a.Prefix.Addr()) {
out = append(out, cgnat{Subnet: a.Prefix, Gateway: a.Addr})
}
}
return out
}
// backhaulPrefixes returns router-owned /29s in 10.250.1.0/24.
func (d *routerDiscovery) backhaulPrefixes() []netip.Prefix {
bhRange := netip.MustParsePrefix(backhaulNetwork)
var out []netip.Prefix
for _, a := range d.Addresses {
if a.Prefix.Bits() == 29 && bhRange.Contains(a.Prefix.Addr()) {
out = append(out, a.Prefix)
}
}
return out
}
type cgnat struct {
Subnet netip.Prefix
Gateway netip.Addr
}
func discoverRouter(cfg *Config, r Router) (*routerDiscovery, error) {
r = cfg.resolve(r)
if r.Host == "" || r.Name == "" {
return nil, fmt.Errorf("router needs both name and host")
}
host, err := netip.ParseAddr(r.Host)
if err != nil {
return nil, fmt.Errorf("router host %q: %w", r.Host, err)
}
c, err := dialAPI(r)
if err != nil {
return nil, err
}
defer c.Close()
d := &routerDiscovery{Router: r, OwnIP: host}
addrRep, err := c.RunArgs([]string{
"/ip/address/print",
"=.proplist=address,network,interface",
})
if err != nil {
return nil, fmt.Errorf("/ip/address/print: %w", err)
}
for _, row := range addrRep.Re {
raw := row.Map["address"] // "10.10.15.254/20"
network := row.Map["network"]
if raw == "" || network == "" {
continue
}
slash := strings.IndexByte(raw, '/')
if slash < 0 {
continue
}
ip, err := netip.ParseAddr(raw[:slash])
if err != nil {
continue
}
var bits int
if _, err := fmt.Sscanf(raw[slash+1:], "%d", &bits); err != nil {
continue
}
netIP, err := netip.ParseAddr(network)
if err != nil {
continue
}
d.Addresses = append(d.Addresses, addrEntry{
Addr: ip,
Prefix: netip.PrefixFrom(netIP, bits),
Interface: row.Map["interface"],
})
}
arpRep, err := c.RunArgs([]string{
"/ip/arp/print",
"=.proplist=address,mac-address,complete",
})
if err != nil {
return nil, fmt.Errorf("/ip/arp/print: %w", err)
}
for _, row := range arpRep.Re {
if row.Map["complete"] != "true" {
continue
}
mac := row.Map["mac-address"]
if mac == "" {
continue
}
ip, err := netip.ParseAddr(row.Map["address"])
if err != nil {
continue
}
d.ARP = append(d.ARP, arpEntry{Addr: ip, MAC: strings.ToUpper(mac)})
}
// /ip/pool — used to identify customer pool ranges so we can skip them.
poolRep, err := c.RunArgs([]string{
"/ip/pool/print",
"=.proplist=name,ranges",
})
if err == nil {
for _, row := range poolRep.Re {
name := row.Map["name"]
if !customerPoolName(name) {
continue
}
for _, r := range strings.Split(row.Map["ranges"], ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
lo, hi := r, r
if i := strings.IndexByte(r, '-'); i >= 0 {
lo, hi = strings.TrimSpace(r[:i]), strings.TrimSpace(r[i+1:])
}
loA, e1 := netip.ParseAddr(lo)
hiA, e2 := netip.ParseAddr(hi)
if e1 != nil || e2 != nil {
continue
}
d.Customer = append(d.Customer, poolRange{Name: name, Lo: loA, Hi: hiA})
}
}
}
// /ip/neighbor — for tower switch discovery via LLDP/CDP.
nbrRep, err := c.RunArgs([]string{
"/ip/neighbor/print",
"=.proplist=address,mac-address,identity,platform,system-caps,interface",
})
if err == nil {
for _, row := range nbrRep.Re {
ip, _ := netip.ParseAddr(row.Map["address"])
d.Neighbors = append(d.Neighbors, neighborEntry{
Addr: ip,
MAC: strings.ToUpper(row.Map["mac-address"]),
Identity: row.Map["identity"],
Platform: row.Map["platform"],
Caps: row.Map["system-caps"],
Interface: row.Map["interface"],
})
}
}
return d, nil
}
// customerPoolName returns true for pools that hand out customer-side
// addresses we want to filter out of inventory discovery. Names observed
// across the existing routers: verona-cpe, altoga-cpe, culleoka-cpe,
// verona-cgnat, altoga-cgnat, climax cgnat, newhope cgnat-full, etc.
func customerPoolName(name string) bool {
n := strings.ToLower(name)
switch {
case strings.Contains(n, "cpe"),
strings.Contains(n, "cgnat"),
strings.HasPrefix(n, "dhcp_pool"),
strings.Contains(n, "customer"):
return true
}
return false
}
// inCustomerPool reports whether the given IP falls in any of the discovery's
// customer pool ranges.
func (d *routerDiscovery) inCustomerPool(a netip.Addr) bool {
for _, p := range d.Customer {
if p.contains(a) {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// SNMP probing
// probeSysName fetches sysName.0 from the radio at ip, trying SNMP v2c first
// then v1. Returns "" on unrecoverable failure.
func probeSysName(ip string) (string, error) {
for _, ver := range []gosnmp.SnmpVersion{gosnmp.Version2c, gosnmp.Version1} {
g := &gosnmp.GoSNMP{
Target: ip,
Port: 161,
Community: snmpCommunity,
Version: ver,
Timeout: snmpTimeout,
Retries: snmpRetries,
MaxOids: 1,
}
if err := g.Connect(); err != nil {
continue
}
pkt, err := g.Get([]string{snmpOIDsysName})
g.Conn.Close()
if err != nil || len(pkt.Variables) == 0 {
continue
}
v := pkt.Variables[0]
switch val := v.Value.(type) {
case []byte:
return string(val), nil
case string:
return val, nil
}
}
return "", fmt.Errorf("snmp sysName failed (v2c+v1)")
}
// probeAll runs probeSysName for every IP in `ips` with a worker pool.
// Returned map omits IPs whose probe failed; callers should treat absent
// entries as nil.
func probeAll(ips []netip.Addr) map[netip.Addr]string {
type result struct {
ip netip.Addr
name string
}
jobs := make(chan netip.Addr, len(ips))
results := make(chan result, len(ips))
var wg sync.WaitGroup
workers := snmpWorkers
if workers > len(ips) {
workers = len(ips)
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ip := range jobs {
name, err := probeSysName(ip.String())
if err != nil {
log.Printf("[snmp] %s: %v", ip, err)
continue
}
results <- result{ip: ip, name: name}
}
}()
}
for _, ip := range ips {
jobs <- ip
}
close(jobs)
wg.Wait()
close(results)
out := make(map[netip.Addr]string, len(ips))
for r := range results {
out[r.ip] = r.name
}
return out
}
// ---------------------------------------------------------------------------
// site assembly
// outRadio is one rendered yaml entry. mac == "" → null in yaml; same for name.
type outRadio struct {
IP netip.Addr
MAC string
Name string
Link string // backhauls only
}
// outSwitch is one tower-side switch (Netonix, MikroTik switch chassis,
// etc.) discovered via LLDP/CDP.
type outSwitch struct {
IP netip.Addr
MAC string
Name string
Model string
}
type outSite struct {
Name string
Router string
ParentRouter string
MgmtSubnet netip.Prefix
Cgnats []cgnat
AccessPoints []outRadio
BackhaulRadios []outRadio
Switches []outSwitch
}
// hasCap reports whether a comma-separated system-caps string contains the
// given capability token.
func hasCap(caps, want string) bool {
for _, c := range strings.Split(caps, ",") {
if strings.EqualFold(strings.TrimSpace(c), want) {
return true
}
}
return false
}
// collectSwitches returns LLDP/CDP neighbors of d that look like tower
// switches: bridge-capable, not a wireless AP/station, in our own address
// space, on a directly-connected subnet of d, and not one of our own
// routers. We do NOT apply the customer-pool exclusion here because some
// infrastructure (e.g. Verona/Culleoka Netonix) is intentionally pinned to
// a static address that lives inside a CGNAT pool range.
func collectSwitches(d *routerDiscovery, routerIPs map[netip.Addr]bool) []outSwitch {
rfc10 := netip.MustParsePrefix(rfc1918Net10)
cgnatRange := netip.MustParsePrefix(cgnatNetwork)
seen := map[netip.Addr]bool{}
var out []outSwitch
for _, n := range d.Neighbors {
if !n.Addr.IsValid() || !n.Addr.Is4() {
continue
}
if seen[n.Addr] {
continue
}
if routerIPs[n.Addr] {
continue
}
if !hasCap(n.Caps, "bridge") {
continue
}
if hasCap(n.Caps, "wlan-ap") || hasCap(n.Caps, "station-only") {
continue
}
// Address must be in our own network space (skips 192.168/16 etc.).
if !rfc10.Contains(n.Addr) && !cgnatRange.Contains(n.Addr) {
continue
}
// Address must live in a subnet this router is directly attached
// to — filters out neighbors leaked through tunnels / mesh mNDP.
direct := false
for _, a := range d.Addresses {
if a.Prefix.Contains(n.Addr) {
direct = true
break
}
}
if !direct {
continue
}
seen[n.Addr] = true
out = append(out, outSwitch{
IP: n.Addr,
MAC: n.MAC,
Name: n.Identity,
Model: n.Platform,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
return out
}
// topSlash24 returns the top /24 within a /20 (the AP subnet).
// CLAUDE.md formula: for X.Y.Z.0/20, APs are in X.Y.(Z+15).0/24.
func topSlash24(p20 netip.Prefix) netip.Prefix {
a := p20.Addr().As4()
a[2] += 15
a[3] = 0
return netip.PrefixFrom(netip.AddrFrom4(a), 24)
}
// assembleSites takes seed metadata and per-router discovery + SNMP results,
// and produces the ordered output site list ready for yaml rendering.
func assembleSites(seed *seed, discoveries []*routerDiscovery, names map[netip.Addr]string) []*outSite {
// Index discoveries by router host IP for cross-references.
byHost := map[netip.Addr]*routerDiscovery{}
for _, d := range discoveries {
byHost[d.OwnIP] = d
}
// Set of every IP any router owns. Used to filter out the far-end
// router from /29 ARP candidates so we don't mistake it for a radio.
routerIPs := map[netip.Addr]bool{}
for _, d := range discoveries {
routerIPs[d.OwnIP] = true
for _, a := range d.Addresses {
routerIPs[a.Addr] = true
}
}
// Map mgmt /20 → seed site name; also remember the parent-router-aware
// "owning router host" for that site so we know where to look for ARP.
siteByMgmt := map[netip.Prefix]string{}
siteHost := map[string]netip.Addr{} // site name → owning router IP (for ARP source)
for _, name := range seed.Order {
s := seed.Sites[name]
if !s.MgmtSubnet.IsValid() {
continue
}
siteByMgmt[s.MgmtSubnet] = name
hostStr := s.Router
if hostStr == "" {
hostStr = s.ParentRouter
}
if h, err := netip.ParseAddr(hostStr); err == nil {
siteHost[name] = h
}
}
// /29 → set of owning routers (the routers that have a local IP in it).
// Used for closest-router-IP backhaul ownership and to gate which /29s
// count as a real link (must have ≥2 routers).
type pfxOwners struct {
prefix netip.Prefix
routers []*routerDiscovery
}
bh29 := map[netip.Prefix]*pfxOwners{}
for _, d := range discoveries {
for _, p := range d.backhaulPrefixes() {
pm := p.Masked()
po, ok := bh29[pm]
if !ok {
po = &pfxOwners{prefix: pm}
bh29[pm] = po
}
po.routers = append(po.routers, d)
}
}
// Build out-sites in seed order.
out := make([]*outSite, 0, len(seed.Order))
seenSites := map[string]bool{}
addOut := func(o *outSite) {
out = append(out, o)
seenSites[o.Name] = true
}
for _, siteName := range seed.Order {
s := seed.Sites[siteName]
o := &outSite{
Name: siteName,
Router: s.Router,
ParentRouter: s.ParentRouter,
MgmtSubnet: s.MgmtSubnet,
}
// CGNAT only attaches to primary sites (parent_router satellites
// don't list cgnat in the existing radios.yaml).
if s.Router != "" {
if h, err := netip.ParseAddr(s.Router); err == nil {
if d := byHost[h]; d != nil {
o.Cgnats = d.cgnatSubnets()
}
}
}
// Access points: ARP entries on the owning router, in this site's
// top /24, minus customer pool ranges.
if owner := siteHost[siteName]; owner.IsValid() && s.MgmtSubnet.IsValid() {
d := byHost[owner]
if d != nil {
ap24 := topSlash24(s.MgmtSubnet)
for _, e := range d.ARP {
if !ap24.Contains(e.Addr) {
continue
}
if routerIPs[e.Addr] {
continue
}
if d.inCustomerPool(e.Addr) {
continue
}
o.AccessPoints = append(o.AccessPoints, outRadio{
IP: e.Addr,
MAC: e.MAC,
Name: names[e.Addr],
})
}
}
}
sortRadiosByIP(o.AccessPoints)
// Switches: LLDP/CDP neighbors with bridge capability that aren't
// AP radios, customer CPE, or other routers we already track.
// Switches attach to the primary site (the one with `router:`).
if s.Router != "" {
if h, err := netip.ParseAddr(s.Router); err == nil {
if d := byHost[h]; d != nil {
o.Switches = collectSwitches(d, routerIPs)
}
}
}
addOut(o)
}
// Synthesize sites for any mgmt /20 we discovered that wasn't in seed.
for _, d := range discoveries {
for _, mgmt := range d.mgmtSubnets() {
if _, claimed := siteByMgmt[mgmt]; claimed {
continue
}
synthName := fmt.Sprintf("auto-%s", strings.ReplaceAll(mgmt.String(), "/", "_"))
if seenSites[synthName] {
continue
}
log.Printf("[inventory] new mgmt subnet %s on %s — emitted as site %q",
mgmt, d.Router.Name, synthName)
o := &outSite{
Name: synthName,
Router: d.Router.Host,
MgmtSubnet: mgmt,
}
ap24 := topSlash24(mgmt)
for _, e := range d.ARP {
if !ap24.Contains(e.Addr) || routerIPs[e.Addr] {
continue
}
if d.inCustomerPool(e.Addr) {
continue
}
o.AccessPoints = append(o.AccessPoints, outRadio{
IP: e.Addr,
MAC: e.MAC,
Name: names[e.Addr],
})
}
sortRadiosByIP(o.AccessPoints)
o.Switches = collectSwitches(d, routerIPs)
addOut(o)
}
}
// Backhaul radios: for each /29 with ≥2 owning routers, emit each radio
// to the site whose router-IP in that /29 is closest. Only sites with
// `router:` set can own a backhaul (satellites can't).
for pfx, po := range bh29 {
if len(po.routers) < 2 {
continue // single-router /29 is something else (switch mgmt etc.)
}
// Gather radio candidates: the intersection of (ARP in this /29) on
// any owning router, minus router-owned IPs.
seenIP := map[netip.Addr]arpEntry{}
for _, d := range po.routers {
for _, e := range d.ARP {
if !pfx.Contains(e.Addr) {
continue
}
if routerIPs[e.Addr] {
continue
}
if _, ok := seenIP[e.Addr]; !ok {
seenIP[e.Addr] = e
}
}
}
// Routers with their own IP in this /29 → eligible owners.
owners := map[netip.Addr]*routerDiscovery{}
for _, d := range po.routers {
for _, a := range d.Addresses {
if pfx.Contains(a.Addr) && a.Prefix.Bits() == 29 {
owners[a.Addr] = d
break
}
}
}
// Determine the link label by consulting ANY seed site that
// references this /29 — labels were collected per-site but the
// /29 is the natural key.
linkLabel := ""
for _, ss := range seed.Sites {
if l, ok := ss.LinkLabels[pfx]; ok {
linkLabel = l
break
}
}
if linkLabel == "" {
linkLabel = "TODO"
}
for radioIP, e := range seenIP {
ownerHost := closestRouterIP(radioIP, owners)
if !ownerHost.IsValid() {
continue
}
d := owners[ownerHost]
ownerSite := primarySiteForRouter(seed, d.Router.Host)
if ownerSite == "" {
log.Printf("[radios] backhaul %s on %s: no primary site has router=%s",
radioIP, d.Router.Name, d.Router.Host)
continue
}
// Find the outSite to append to.
var target *outSite
for _, o := range out {
if o.Name == ownerSite {
target = o
break
}
}
if target == nil {
continue
}
target.BackhaulRadios = append(target.BackhaulRadios, outRadio{
IP: radioIP,
MAC: e.MAC,
Name: names[radioIP],
Link: linkLabel,
})
}
}
for _, o := range out {
sortRadiosByIP(o.BackhaulRadios)
}
return out
}
// closestRouterIP returns the key in `owners` whose IP is numerically closest
// to `radio` within the /29.
func closestRouterIP(radio netip.Addr, owners map[netip.Addr]*routerDiscovery) netip.Addr {
var best netip.Addr
bestDist := -1
rb := radio.As4()
for o := range owners {
ob := o.As4()
dist := abs(int(rb[3]) - int(ob[3]))
if bestDist < 0 || dist < bestDist {
bestDist = dist
best = o
}
}
return best
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
// primarySiteForRouter finds the seed site whose `router:` matches the given
// host, and returns its name. Empty if none.
func primarySiteForRouter(s *seed, host string) string {
for _, name := range s.Order {
if s.Sites[name].Router == host {
return name
}
}
return ""
}
func sortRadiosByIP(r []outRadio) {
sort.Slice(r, func(i, j int) bool { return r[i].IP.Less(r[j].IP) })
}
// ---------------------------------------------------------------------------
// yaml rendering
// scalar makes a plain scalar yaml.Node.
func scalar(s string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Value: s, Tag: "!!str"}
}
// nullNode renders as `null` (untagged).
func nullNode() *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}
}
// stringOrNull picks between a !!str scalar and a null scalar.
func stringOrNull(s string) *yaml.Node {
if s == "" {
return nullNode()
}
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: s, Style: yaml.DoubleQuotedStyle}
}
func plainScalar(v string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Value: v}
}
// radioFlow renders one access-point or backhaul entry as a flow-style
// mapping, matching the existing radios.yaml style.
func radioFlow(r outRadio) *yaml.Node {
n := &yaml.Node{Kind: yaml.MappingNode, Style: yaml.FlowStyle}
n.Content = append(n.Content, plainScalar("ip"), plainScalar(r.IP.String()))
n.Content = append(n.Content, plainScalar("mac"), stringOrNull(r.MAC))
n.Content = append(n.Content, plainScalar("name"), stringOrNull(r.Name))
if r.Link != "" {
n.Content = append(n.Content, plainScalar("link"), &yaml.Node{
Kind: yaml.ScalarNode, Tag: "!!str", Value: r.Link, Style: yaml.DoubleQuotedStyle,
})
}
return n
}
// cgnatFlow renders {subnet, gateway} as flow-style.
func cgnatFlow(c cgnat) *yaml.Node {
n := &yaml.Node{Kind: yaml.MappingNode, Style: yaml.FlowStyle}
n.Content = append(n.Content,
plainScalar("subnet"), plainScalar(c.Subnet.String()),
plainScalar("gateway"), plainScalar(c.Gateway.String()),
)
return n
}
// switchFlow renders one switch entry as a flow-style mapping.
func switchFlow(s outSwitch) *yaml.Node {
n := &yaml.Node{Kind: yaml.MappingNode, Style: yaml.FlowStyle}
n.Content = append(n.Content, plainScalar("ip"), plainScalar(s.IP.String()))
n.Content = append(n.Content, plainScalar("mac"), stringOrNull(s.MAC))
n.Content = append(n.Content, plainScalar("name"), stringOrNull(s.Name))
n.Content = append(n.Content, plainScalar("model"), stringOrNull(s.Model))
return n
}
func switchesBlock(items []outSwitch) *yaml.Node {
n := &yaml.Node{Kind: yaml.SequenceNode}
for _, s := range items {
n.Content = append(n.Content, switchFlow(s))
}
return n
}
func radiosBlock(items []outRadio) *yaml.Node {
n := &yaml.Node{Kind: yaml.SequenceNode}
for _, r := range items {
n.Content = append(n.Content, radioFlow(r))
}
return n
}
// siteToNode turns one outSite into the yaml mapping value for it.
func siteToNode(s *outSite) *yaml.Node {
m := &yaml.Node{Kind: yaml.MappingNode}
add := func(k string, v *yaml.Node) {
m.Content = append(m.Content, plainScalar(k), v)
}
if s.Router != "" {
add("router", plainScalar(s.Router))
}
if s.ParentRouter != "" {
add("parent_router", plainScalar(s.ParentRouter))
}
if s.MgmtSubnet.IsValid() {
add("mgmt_subnet", plainScalar(s.MgmtSubnet.String()))
}
switch len(s.Cgnats) {
case 0:
// nothing
case 1:
add("cgnat_subnet", cgnatFlow(s.Cgnats[0]))
default:
seq := &yaml.Node{Kind: yaml.SequenceNode}
for _, c := range s.Cgnats {
seq.Content = append(seq.Content, cgnatFlow(c))
}
add("cgnat_subnets", seq)
}
if len(s.AccessPoints) > 0 {
add("access_points", radiosBlock(s.AccessPoints))
}
if len(s.BackhaulRadios) > 0 {
add("backhaul_radios", radiosBlock(s.BackhaulRadios))
}
if len(s.Switches) > 0 {
add("switches", switchesBlock(s.Switches))
}
return m
}
func renderYAML(s *seed, sites []*outSite) ([]byte, error) {
root := &yaml.Node{Kind: yaml.DocumentNode}
doc := &yaml.Node{Kind: yaml.MappingNode}
root.Content = []*yaml.Node{doc}
sitesMap := &yaml.Node{Kind: yaml.MappingNode}
for _, o := range sites {
// Site key: quote names that look like numbers (e.g., "982", "494").
keyStyle := yaml.Style(0)
if _, err := fmt.Sscan(o.Name, new(int)); err == nil {
keyStyle = yaml.DoubleQuotedStyle
}
key := &yaml.Node{Kind: yaml.ScalarNode, Value: o.Name, Style: keyStyle, Tag: "!!str"}
sitesMap.Content = append(sitesMap.Content, key, siteToNode(o))
}
doc.Content = append(doc.Content, plainScalar("sites"), sitesMap)
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(root); err != nil {
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
var final bytes.Buffer
if len(s.Header) > 0 {
final.Write(s.Header)
if !bytes.HasSuffix(s.Header, []byte("\n")) {
final.WriteByte('\n')
}
}
final.Write(buf.Bytes())
return final.Bytes(), nil
}
// ---------------------------------------------------------------------------
// driver
func runInventory(cfg *Config) {
seedData, err := loadSeedInventory(inventoryPath)
if err != nil {
log.Fatalf("seed: %v", err)
}
// Discover all routers in parallel.
discoveries := make([]*routerDiscovery, len(cfg.Routers))
var wg sync.WaitGroup
for i, r := range cfg.Routers {
wg.Add(1)
go func(i int, r Router) {
defer wg.Done()
d, err := discoverRouter(cfg, r)
if err != nil {
log.Printf("[%s] discovery: %v", r.Name, err)
return
}
discoveries[i] = d
log.Printf("[%s] discovered %d addrs, %d arp, %d neighbors, %d customer pools",
r.Name, len(d.Addresses), len(d.ARP), len(d.Neighbors), len(d.Customer))
}(i, r)
}
wg.Wait()
// Drop nil entries from failed routers.
live := discoveries[:0]
for _, d := range discoveries {
if d != nil {
live = append(live, d)
}
}
if len(live) == 0 {
log.Fatal("no routers reachable")
}
// Collect candidate radio IPs: AP candidates (top /24 of each mgmt /20)
// + backhaul candidates (each /29 owned by ≥2 routers).
routerIPs := map[netip.Addr]bool{}
for _, d := range live {
routerIPs[d.OwnIP] = true
for _, a := range d.Addresses {
routerIPs[a.Addr] = true
}
}
bhCount := map[netip.Prefix]int{}
for _, d := range live {
for _, p := range d.backhaulPrefixes() {
bhCount[p.Masked()]++
}
}
candSet := map[netip.Addr]bool{}
for _, d := range live {
for _, mgmt := range d.mgmtSubnets() {
ap24 := topSlash24(mgmt)
for _, e := range d.ARP {
if ap24.Contains(e.Addr) && !routerIPs[e.Addr] {
candSet[e.Addr] = true
}
}
}
for _, p := range d.backhaulPrefixes() {
if bhCount[p.Masked()] < 2 {
continue
}
for _, e := range d.ARP {
if p.Contains(e.Addr) && !routerIPs[e.Addr] {
candSet[e.Addr] = true
}
}
}
}
candIPs := make([]netip.Addr, 0, len(candSet))
for ip := range candSet {
candIPs = append(candIPs, ip)
}
sort.Slice(candIPs, func(i, j int) bool { return candIPs[i].Less(candIPs[j]) })
log.Printf("[inventory] probing %d candidate radios via SNMP", len(candIPs))
names := probeAll(candIPs)
sites := assembleSites(seedData, live, names)
body, err := renderYAML(seedData, sites)
if err != nil {
log.Fatalf("render: %v", err)
}
tmp := inventoryPath + ".tmp"
if err := os.WriteFile(tmp, body, 0o644); err != nil {
log.Fatalf("write %s: %v", tmp, err)
}
if err := os.Rename(tmp, inventoryPath); err != nil {
log.Fatalf("rename %s: %v", inventoryPath, err)
}
log.Printf("[inventory] wrote %s (%d bytes, %d sites)", inventoryPath, len(body), len(sites))
}