diff --git a/config.yaml b/config.yaml index 50c930b..3f28ab7 100644 --- a/config.yaml +++ b/config.yaml @@ -194,7 +194,7 @@ locations: - names: ["showpi1"] - names: ["showpi2"] macs: ["d8:3a:dd:e3:5b:db"] - - names: ["AP"] + - names: ["EAP770"] macs: ["a8:29:48:ca:11:40"] - name: Sound Control diff --git a/link.go b/link.go index 4be2cf1..9277183 100644 --- a/link.go +++ b/link.go @@ -82,6 +82,13 @@ func (n *Nodes) getDirectLinks() []*Link { key := makeLinkKey(lh.node, lh.port, target, targetIface) if !seen[key] { seen[key] = true + if target.Type == "" { + if lh.node.Type == NodeTypeSwitch { + target.Type = NodeTypeWiredClient + } else if lh.node.Type == NodeTypeAP { + target.Type = NodeTypeWirelessClient + } + } links = append(links, &Link{ ID: newID("link"), NodeA: lh.node, diff --git a/nodes.go b/nodes.go index 2390dad..a5f4f57 100644 --- a/nodes.go +++ b/nodes.go @@ -388,6 +388,9 @@ func (n *Nodes) mergeNodes(keep, merge *Node) { return } + keepWasUnreachable := keep.Unreachable + mergeWasUnreachable := merge.Unreachable + for name := range merge.Names { if keep.Names == nil { keep.Names = NameSet{} @@ -429,6 +432,26 @@ func (n *Nodes) mergeNodes(keep, merge *Node) { merge.cancelFunc() } + hasIPs := false + for _, iface := range keep.Interfaces { + if len(iface.IPs) > 0 { + hasIPs = true + break + } + } + if hasIPs && (keepWasUnreachable || mergeWasUnreachable) { + keep.Unreachable = false + if n.t != nil && n.t.errors != nil { + n.t.errors.RemoveUnreachable(keep) + } + if keepWasUnreachable { + log.Printf("[merge] %s is now reachable (merged with node that has IPs)", keep.DisplayName()) + } + if mergeWasUnreachable { + log.Printf("[merge] %s is now reachable (merged into %s)", merge.DisplayName(), keep.DisplayName()) + } + } + n.removeNode(merge) } diff --git a/snmp.go b/snmp.go index 76e201a..9773d2d 100644 --- a/snmp.go +++ b/snmp.go @@ -6,6 +6,7 @@ import ( "net" "regexp" "strings" + "sync" "time" "github.com/gosnmp/gosnmp" @@ -98,6 +99,23 @@ func (t *Tendrils) connectSNMP(ip net.IP) (*gosnmp.GoSNMP, error) { return snmp, nil } +func (t *Tendrils) pollSNMP(node *Node, ip net.IP) { + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + t.queryWirelessAP(node, ip) + }() + + go func() { + defer wg.Done() + t.querySNMPDevice(node, ip) + }() + + wg.Wait() +} + func (t *Tendrils) querySNMPDevice(node *Node, ip net.IP) { snmp, err := t.connectSNMP(ip) if err != nil { @@ -339,6 +357,7 @@ func (t *Tendrils) queryPoEBudget(snmp *gosnmp.GoSNMP, node *Node) { if maxPower > 0 { t.nodes.mu.Lock() node.PoEBudget = &PoEBudget{Power: power, MaxPower: maxPower} + node.Type = NodeTypeSwitch t.nodes.mu.Unlock() } } @@ -650,3 +669,104 @@ func (t *Tendrils) queryDHCPBindings(snmp *gosnmp.GoSNMP) { t.nodes.Update(nil, mac, []net.IP{ip}, "", "", "dhcp") } } + +func (t *Tendrils) connectSNMPv2c(ip net.IP, community string) (*gosnmp.GoSNMP, error) { + snmp := &gosnmp.GoSNMP{ + Target: ip.String(), + Port: 161, + Version: gosnmp.Version2c, + Community: community, + Timeout: 5 * time.Second, + Retries: 1, + } + + err := snmp.Connect() + if err != nil { + return nil, err + } + + return snmp, nil +} + +func (t *Tendrils) queryWirelessAP(node *Node, ip net.IP) bool { + snmp, err := t.connectSNMPv2c(ip, "tendrils12!") + if err != nil { + return false + } + defer snmp.Conn.Close() + + sysObjID := t.getSysObjectID(snmp) + if !strings.HasPrefix(sysObjID, "1.3.6.1.4.1.11863.") { + return false + } + + t.nodes.mu.Lock() + node.Type = NodeTypeAP + t.nodes.mu.Unlock() + + ifNames := t.getInterfaceNames(snmp) + t.querySysName(snmp, node) + t.queryInterfaceMACs(snmp, node, ifNames) + t.queryTPLinkWirelessClients(snmp, node) + + return true +} + +func (t *Tendrils) getSysObjectID(snmp *gosnmp.GoSNMP) string { + oid := "1.3.6.1.2.1.1.2.0" + + result, err := snmp.Get([]string{oid}) + if err != nil { + return "" + } + + if len(result.Variables) == 0 { + return "" + } + + variable := result.Variables[0] + if variable.Type != gosnmp.ObjectIdentifier { + return "" + } + + return strings.TrimPrefix(variable.Value.(string), ".") +} + +func (t *Tendrils) queryTPLinkWirelessClients(snmp *gosnmp.GoSNMP, node *Node) { + clientMACOID := "1.3.6.1.4.1.11863.10.1.1.2.1.2" + + results, err := snmp.BulkWalkAll(clientMACOID) + if err != nil { + if t.DebugSNMP { + log.Printf("[snmp] %s: tp-link client walk failed: %v", snmp.Target, err) + } + return + } + + t.nodes.ClearMACTable(node) + + for _, result := range results { + if result.Type != gosnmp.OctetString { + continue + } + + macText := string(result.Value.([]byte)) + macText = strings.TrimRight(macText, "\x00") + macText = strings.ReplaceAll(macText, "-", ":") + mac, err := net.ParseMAC(macText) + if err != nil { + if t.DebugSNMP { + log.Printf("[snmp] %s: invalid wireless client mac %q: %v", snmp.Target, macText, err) + } + continue + } + + t.nodes.UpdateMACTable(node, mac, "wifi") + + if t.DebugSNMP { + log.Printf("[snmp] %s: wireless client mac=%s", snmp.Target, mac.String()) + } + } + + t.NotifyUpdate() +} diff --git a/static/js/components.js b/static/js/components.js index 694a75d..36ee6ff 100644 --- a/static/js/components.js +++ b/static/js/components.js @@ -1,4 +1,4 @@ -import { getLabel, getShortLabel, getFirstName, isSwitch, getSpeedClass } from './nodes.js'; +import { getLabel, getShortLabel, getFirstName, isSwitch, isAP, getSpeedClass } from './nodes.js'; import { addClickableValue, buildLinkStats, buildDanteDetail, buildClickableList, removeNode } from './ui.js'; import { nodeElements, locationElements, usedNodeIds, usedLocationIds } from './state.js'; @@ -20,7 +20,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn } div._nodeData = node; - div.className = 'node' + (isSwitch(node) ? ' switch' : ''); + div.className = 'node' + (isSwitch(node) ? ' switch' : '') + (isAP(node) ? ' ap' : ''); if (hasError) div.classList.add('has-error'); if (isUnreachable) div.classList.add('unreachable'); if (danteInfo?.isTx) div.classList.add('dante-tx'); @@ -129,7 +129,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn if (wrapper) wrapper.remove(); } - if (isSwitch(node) && uplinkInfo === 'ROOT') { + if ((isSwitch(node) || isAP(node)) && uplinkInfo === 'ROOT') { const container = div.querySelector(':scope > .uplink-hover'); if (container) container.remove(); @@ -140,7 +140,7 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn rootEl.textContent = 'ROOT'; div.appendChild(rootEl); } - } else if (isSwitch(node) && uplinkInfo) { + } else if ((isSwitch(node) || isAP(node)) && uplinkInfo) { const rootEl = div.querySelector(':scope > .root-label'); if (rootEl) rootEl.remove(); @@ -155,7 +155,9 @@ export function createNodeElement(node, switchConnection, nodeLocation, uplinkIn uplinkEl.className = 'uplink'; const speedClass = getSpeedClass(uplinkInfo.speed); if (speedClass) uplinkEl.classList.add(speedClass); - const uplinkLabel = uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort; + const uplinkLabel = isAP(node) + ? uplinkInfo.parentName + ':' + uplinkInfo.remotePort + : uplinkInfo.localPort + ' → ' + uplinkInfo.parentName + ':' + uplinkInfo.remotePort; uplinkEl.textContent = uplinkLabel; const statsEl = container.querySelector('.link-stats'); @@ -348,7 +350,8 @@ export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections locationElements.set(loc.id, container); } let classes = 'location'; - if (loc.anonymous) classes += ' anonymous'; + if (loc.anonymous && !loc.isAPLocation) classes += ' anonymous'; + if (loc.isAPLocation) classes += ' ap-location'; if (isTopLevel) classes += ' top-level'; container.className = classes; @@ -364,8 +367,8 @@ export function renderLocation(loc, assignedNodes, isTopLevel, switchConnections const nodeRowId = loc.id + '_nd'; if (hasNodes) { - const switches = nodes.filter(n => isSwitch(n)); - const nonSwitches = nodes.filter(n => !isSwitch(n)); + const switches = nodes.filter(n => isSwitch(n) || isAP(n)); + const nonSwitches = nodes.filter(n => !isSwitch(n) && !isAP(n)); if (switches.length > 0) { let switchRow = container.querySelector(':scope > .node-row[data-rowid="' + switchRowId + '"]'); diff --git a/static/js/nodes.js b/static/js/nodes.js index 90d8ec6..d652502 100644 --- a/static/js/nodes.js +++ b/static/js/nodes.js @@ -59,7 +59,11 @@ export function getNodeIdentifiers(node) { } export function isSwitch(node) { - return !!(node.poe_budget); + return node.type === 'switch'; +} + +export function isAP(node) { + return node.type === 'ap'; } export function getSpeedClass(speed) { diff --git a/static/js/render.js b/static/js/render.js index 44c3f3d..5a43015 100644 --- a/static/js/render.js +++ b/static/js/render.js @@ -1,4 +1,4 @@ -import { getLabel, getShortLabel, isSwitch, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates, getInterfaceUptime, getInterfaceLastError, getFirstName } from './nodes.js'; +import { getLabel, getShortLabel, isSwitch, isAP, getInterfaceSpeed, getInterfaceErrors, getInterfaceRates, getInterfaceUptime, getInterfaceLastError, getFirstName } from './nodes.js'; import { buildSwitchUplinks, buildLocationTree, buildNodeIndex, findLocationForNode, findEffectiveSwitch } from './topology.js'; import { formatUniverse } from './format.js'; import { createNodeElement, renderLocation } from './components.js'; @@ -48,17 +48,74 @@ export function render(data, config) { } }); + const apClients = new Map(); + links.forEach(link => { + const nodeA = nodesByTypeId.get(link.node_a_id); + const nodeB = nodesByTypeId.get(link.node_b_id); + if (!nodeA || !nodeB) return; + + if (isAP(nodeA) && link.interface_a === 'wifi' && !isSwitch(nodeB) && !isAP(nodeB)) { + if (!apClients.has(nodeA)) apClients.set(nodeA, []); + apClients.get(nodeA).push(nodeB); + } else if (isAP(nodeB) && link.interface_b === 'wifi' && !isSwitch(nodeA) && !isAP(nodeA)) { + if (!apClients.has(nodeB)) apClients.set(nodeB, []); + apClients.get(nodeB).push(nodeA); + } + }); + + apClients.forEach((clients, ap) => { + const apLoc = nodeLocations.get(ap.id); + const apSubLoc = { + id: 'ap_' + ap.id, + name: '', + anonymous: true, + isAPLocation: true, + direction: 'horizontal', + nodeRefs: [], + parent: apLoc || null, + children: [] + }; + + if (apLoc) { + const apLocNodes = assignedNodes.get(apLoc) || []; + const idx = apLocNodes.indexOf(ap); + if (idx !== -1) apLocNodes.splice(idx, 1); + apLoc.children.push(apSubLoc); + } else { + const idx = unassignedNodes.indexOf(ap); + if (idx !== -1) unassignedNodes.splice(idx, 1); + locationTree.push(apSubLoc); + } + + assignedNodes.set(apSubLoc, [ap]); + nodeLocations.set(ap.id, apSubLoc); + + clients.forEach(client => { + const clientLoc = nodeLocations.get(client.id); + if (clientLoc && clientLoc !== apSubLoc) { + const clientLocNodes = assignedNodes.get(clientLoc) || []; + const idx = clientLocNodes.indexOf(client); + if (idx !== -1) clientLocNodes.splice(idx, 1); + } else { + const idx = unassignedNodes.indexOf(client); + if (idx !== -1) unassignedNodes.splice(idx, 1); + } + assignedNodes.get(apSubLoc).push(client); + nodeLocations.set(client.id, apSubLoc); + }); + }); + const switchConnections = new Map(); const switchLinks = []; - const allSwitches = nodes.filter(n => isSwitch(n)); + const allSwitches = nodes.filter(n => isSwitch(n) || isAP(n)); links.forEach(link => { const nodeA = nodesByTypeId.get(link.node_a_id); const nodeB = nodesByTypeId.get(link.node_b_id); if (!nodeA || !nodeB) return; - const aIsSwitch = isSwitch(nodeA); - const bIsSwitch = isSwitch(nodeB); + const aIsSwitch = isSwitch(nodeA) || isAP(nodeA); + const bIsSwitch = isSwitch(nodeB) || isAP(nodeB); if (aIsSwitch && bIsSwitch) { switchLinks.push({ diff --git a/static/js/topology.js b/static/js/topology.js index a9e2fb4..b46f5c1 100644 --- a/static/js/topology.js +++ b/static/js/topology.js @@ -1,4 +1,4 @@ -import { getLabel, getNodeIdentifiers, isSwitch } from './nodes.js'; +import { getLabel, getNodeIdentifiers, isSwitch, isAP } from './nodes.js'; import { incrementAnonCounter } from './state.js'; export function buildSwitchUplinks(allSwitches, switchLinks) { @@ -83,11 +83,11 @@ export function buildSwitchUplinks(allSwitches, switchLinks) { remotePort: reverseEdge?.remotePort || '?', parentNode: current, parentName: getLabel(current), - speed: reverseEdge?.localSpeed || 0, - errors: reverseEdge?.localErrors || null, - rates: reverseEdge?.localRates || null, - uptime: reverseEdge?.localUptime || 0, - lastError: reverseEdge?.localLastError || null + speed: reverseEdge?.localSpeed || edge.localSpeed || 0, + errors: reverseEdge?.localErrors || edge.localErrors || null, + rates: reverseEdge?.localRates || edge.localRates || null, + uptime: reverseEdge?.localUptime || edge.localUptime || 0, + lastError: reverseEdge?.localLastError || edge.localLastError || null }); queue.push(edge.neighbor); } @@ -130,10 +130,10 @@ export function getSwitchesInLocation(loc, assignedNodes) { const switches = []; const nodes = assignedNodes.get(loc) || []; nodes.forEach(n => { - if (isSwitch(n)) switches.push(n); + if (isSwitch(n) || isAP(n)) switches.push(n); }); loc.children.forEach(child => { - if (child.anonymous) { + if (child.anonymous || child.isAPLocation) { switches.push(...getSwitchesInLocation(child, assignedNodes)); } }); diff --git a/static/style.css b/static/style.css index dc9f149..aee1f6f 100644 --- a/static/style.css +++ b/static/style.css @@ -80,6 +80,15 @@ body { display: none; } +.location.ap-location { + background: #1a2a2a; + border: 1px solid #28a; +} + +.location.ap-location > .location-name { + display: none; +} + .node-row { display: flex; flex-direction: row; @@ -274,6 +283,11 @@ body.sacn-mode .node .sacn-hover { font-weight: bold; } +.node.ap { + background: #28a; + font-weight: bold; +} + .node.copied { outline: 2px solid #fff; diff --git a/tendrils.go b/tendrils.go index d6e1abd..c396cb2 100644 --- a/tendrils.go +++ b/tendrils.go @@ -333,7 +333,7 @@ func (t *Tendrils) pollNode(node *Node) { if !t.DisableSNMP { for _, ip := range ips { - t.querySNMPDevice(node, ip) + t.pollSNMP(node, ip) } } diff --git a/types.go b/types.go index 59f80a2..792d1c0 100644 --- a/types.go +++ b/types.go @@ -19,6 +19,15 @@ func newID(prefix string) string { return tid.String() } +type NodeType string + +const ( + NodeTypeSwitch NodeType = "switch" + NodeTypeAP NodeType = "ap" + NodeTypeWirelessClient NodeType = "wireless_client" + NodeTypeWiredClient NodeType = "wired_client" +) + type ArtNetUniverse int func (u ArtNetUniverse) Net() int { @@ -464,6 +473,7 @@ type Node struct { Names NameSet `json:"names"` Interfaces InterfaceMap `json:"interfaces"` MACTable map[string]string `json:"mac_table,omitempty"` + Type NodeType `json:"type,omitempty"` PoEBudget *PoEBudget `json:"poe_budget,omitempty"` DanteTxChannels int `json:"dante_tx_channels,omitempty"` DanteClockMasterSeen time.Time `json:"-"`