1133 lines
30 KiB
Go
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))
|
|
}
|