// 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)) }