package tendrils import ( "context" "encoding/json" "fmt" "math" "net" "sort" "strings" "time" "github.com/fvbommel/sortorder" "go.jetify.com/typeid" ) func newID(prefix string) string { tid, _ := typeid.WithPrefix(prefix) 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 { return (int(u) >> 8) & 0x7f } func (u ArtNetUniverse) Subnet() int { return (int(u) >> 4) & 0x0f } func (u ArtNetUniverse) Universe() int { return int(u) & 0x0f } func (u ArtNetUniverse) String() string { return fmt.Sprintf("%d:%d:%d (%d)", u.Net(), u.Subnet(), u.Universe(), int(u)) } type ArtNetUniverseSet map[ArtNetUniverse]time.Time func (s ArtNetUniverseSet) Add(u ArtNetUniverse) { s[u] = time.Now() } func (s ArtNetUniverseSet) Universes() []ArtNetUniverse { result := make([]ArtNetUniverse, 0, len(s)) for u := range s { result = append(result, u) } sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) return result } func (s ArtNetUniverseSet) Expire(maxAge time.Duration) { expireTime := time.Now().Add(-maxAge) for u, lastSeen := range s { if lastSeen.Before(expireTime) { delete(s, u) } } } func (s ArtNetUniverseSet) MarshalJSON() ([]byte, error) { return json.Marshal(s.Universes()) } type SACNUniverse int func (u SACNUniverse) String() string { return fmt.Sprintf("%d", u) } type SACNUniverseSet map[SACNUniverse]time.Time func (s SACNUniverseSet) Add(u SACNUniverse) { s[u] = time.Now() } func (s SACNUniverseSet) Universes() []SACNUniverse { result := make([]SACNUniverse, 0, len(s)) for u := range s { result = append(result, u) } sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) return result } func (s SACNUniverseSet) Expire(maxAge time.Duration) { expireTime := time.Now().Add(-maxAge) for u, lastSeen := range s { if lastSeen.Before(expireTime) { delete(s, u) } } } func (s SACNUniverseSet) MarshalJSON() ([]byte, error) { return json.Marshal(s.Universes()) } type ArtmapMapping struct { From ArtmapAddr `json:"from"` To ArtmapAddr `json:"to"` } type ArtmapAddr struct { Protocol string `json:"protocol"` Universe int `json:"universe"` ChannelStart int `json:"channel_start,omitempty"` ChannelEnd int `json:"channel_end,omitempty"` } type MulticastGroupID int const ( MulticastUnknown MulticastGroupID = iota MulticastMDNS MulticastPTP MulticastPTPAnnounce MulticastPTPSync MulticastPTPDelay MulticastSAP MulticastShureSLP MulticastSSDP MulticastSLP MulticastAdminScopedBroadcast ) type MulticastGroup struct { ID MulticastGroupID SACNUniverse SACNUniverse DanteFlow int DanteAV int RawIP string } func (g MulticastGroup) String() string { switch g.ID { case MulticastMDNS: return "mdns" case MulticastPTP: return "ptp" case MulticastPTPAnnounce: return "ptp-announce" case MulticastPTPSync: return "ptp-sync" case MulticastPTPDelay: return "ptp-delay" case MulticastSAP: return "sap" case MulticastShureSLP: return "shure-slp" case MulticastSSDP: return "ssdp" case MulticastSLP: return "slp" case MulticastAdminScopedBroadcast: return "admin-scoped-broadcast" } if g.SACNUniverse > 0 { return fmt.Sprintf("sacn:%d", g.SACNUniverse) } if g.DanteFlow > 0 { return fmt.Sprintf("dante-mcast:%d", g.DanteFlow) } if g.DanteAV > 0 { return fmt.Sprintf("dante-av:%d", g.DanteAV) } return g.RawIP } func (g MulticastGroup) MarshalJSON() ([]byte, error) { return json.Marshal(g.String()) } func (g MulticastGroup) IsDante() bool { return g.DanteFlow > 0 || g.DanteAV > 0 } func (g MulticastGroup) IsSACN() bool { return g.SACNUniverse > 0 } func ParseMulticastGroup(ip net.IP) MulticastGroup { ip4 := ip.To4() if ip4 == nil { return MulticastGroup{RawIP: ip.String()} } switch ip.String() { case "224.0.0.251": return MulticastGroup{ID: MulticastMDNS} case "224.0.1.129": return MulticastGroup{ID: MulticastPTP} case "224.0.1.130": return MulticastGroup{ID: MulticastPTPAnnounce} case "224.0.1.131": return MulticastGroup{ID: MulticastPTPSync} case "224.0.1.132": return MulticastGroup{ID: MulticastPTPDelay} case "224.2.127.254": return MulticastGroup{ID: MulticastSAP} case "239.255.254.253": return MulticastGroup{ID: MulticastShureSLP} case "239.255.255.250": return MulticastGroup{ID: MulticastSSDP} case "239.255.255.253": return MulticastGroup{ID: MulticastSLP} case "239.255.255.255": return MulticastGroup{ID: MulticastAdminScopedBroadcast} } if ip4[0] == 239 && ip4[1] == 255 { universe := int(ip4[2])*256 + int(ip4[3]) if universe >= 1 && universe <= 63999 { return MulticastGroup{SACNUniverse: SACNUniverse(universe)} } } if ip4[0] == 239 && ip4[1] >= 69 && ip4[1] <= 71 { flowID := (int(ip4[1]-69) << 16) | (int(ip4[2]) << 8) | int(ip4[3]) return MulticastGroup{DanteFlow: flowID} } if ip4[0] == 239 && ip4[1] == 253 { flowID := (int(ip4[2]) << 8) | int(ip4[3]) return MulticastGroup{DanteAV: flowID} } return MulticastGroup{RawIP: ip.String()} } type MulticastMembershipSet map[MulticastGroup]time.Time func (s MulticastMembershipSet) Add(group MulticastGroup) { s[group] = time.Now() } func (s MulticastMembershipSet) Remove(group MulticastGroup) { delete(s, group) } func (s MulticastMembershipSet) Groups() []MulticastGroup { result := make([]MulticastGroup, 0, len(s)) for g := range s { result = append(result, g) } sort.Slice(result, func(i, j int) bool { return result[i].String() < result[j].String() }) return result } func (s MulticastMembershipSet) SACNInputs() []SACNUniverse { var result []SACNUniverse for g := range s { if g.IsSACN() { result = append(result, g.SACNUniverse) } } sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) return result } func (s MulticastMembershipSet) Expire(maxAge time.Duration) { expireTime := time.Now().Add(-maxAge) for g, lastSeen := range s { if lastSeen.Before(expireTime) { delete(s, g) } } } func (s MulticastMembershipSet) MarshalJSON() ([]byte, error) { return json.Marshal(s.Groups()) } type MAC string func (m MAC) Parse() net.HardwareAddr { mac, _ := net.ParseMAC(string(m)) return mac } func MACFrom(mac net.HardwareAddr) MAC { if mac == nil { return "" } return MAC(mac.String()) } type IPSet map[string]bool func (s IPSet) MarshalJSON() ([]byte, error) { ips := make([]string, 0, len(s)) for ip := range s { ips = append(ips, ip) } sort.Strings(ips) return json.Marshal(ips) } func (s IPSet) Add(ip net.IP) { s[ip.String()] = true } func (s IPSet) Has(ip string) bool { return s[ip] } func (s IPSet) Slice() []string { ips := make([]string, 0, len(s)) for ip := range s { ips = append(ips, ip) } sort.Strings(ips) return ips } type NameSet map[string]bool func sortNamesByLength(names []string) { sort.Slice(names, func(i, j int) bool { if len(names[i]) != len(names[j]) { return len(names[i]) < len(names[j]) } return names[i] < names[j] }) } func (s NameSet) MarshalJSON() ([]byte, error) { names := make([]string, 0, len(s)) for name := range s { names = append(names, name) } sortNamesByLength(names) return json.Marshal(names) } func (s NameSet) Add(name string) { s[name] = true } func (s NameSet) Has(name string) bool { return s[name] } type InterfaceMap map[string]*Interface func (m InterfaceMap) MarshalJSON() ([]byte, error) { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { return sortorder.NaturalLess(keys[i], keys[j]) }) ifaces := make([]*Interface, 0, len(m)) for _, k := range keys { ifaces = append(ifaces, m[k]) } return json.Marshal(ifaces) } type Interface struct { Name string `json:"name,omitempty"` MAC MAC `json:"mac"` IPs IPSet `json:"ips,omitempty"` Up bool `json:"up,omitempty"` Stats *InterfaceStats `json:"stats,omitempty"` prevInPkts uint64 prevOutPkts uint64 prevInBytes uint64 prevOutBytes uint64 prevUptime uint64 prevTimestamp time.Time } func (i *Interface) MarshalJSON() ([]byte, error) { type ifaceJSON struct { Name string `json:"name,omitempty"` MAC MAC `json:"mac"` IPs []string `json:"ips,omitempty"` Up bool `json:"up,omitempty"` Stats *InterfaceStats `json:"stats,omitempty"` } var ips []string if len(i.IPs) > 0 { ips = i.IPs.Slice() } return json.Marshal(ifaceJSON{ Name: i.Name, MAC: i.MAC, IPs: ips, Up: i.Up, Stats: i.Stats, }) } type InterfaceStats struct { Speed uint64 `json:"speed,omitempty"` Uptime uint64 `json:"uptime,omitempty"` InErrors uint64 `json:"in_errors,omitempty"` OutErrors uint64 `json:"out_errors,omitempty"` InPktsRate float64 `json:"in_pkts_rate,omitempty"` OutPktsRate float64 `json:"out_pkts_rate,omitempty"` InBytesRate float64 `json:"in_bytes_rate,omitempty"` OutBytesRate float64 `json:"out_bytes_rate,omitempty"` LastError *time.Time `json:"last_error,omitempty"` PoE *PoEStats `json:"poe,omitempty"` } func round2(v float64) float64 { return math.Round(v*100) / 100 } func (s *InterfaceStats) MarshalJSON() ([]byte, error) { type statsJSON struct { Speed uint64 `json:"speed,omitempty"` Uptime uint64 `json:"uptime,omitempty"` InErrors uint64 `json:"in_errors,omitempty"` OutErrors uint64 `json:"out_errors,omitempty"` InPktsRate float64 `json:"in_pkts_rate,omitempty"` OutPktsRate float64 `json:"out_pkts_rate,omitempty"` InBytesRate float64 `json:"in_bytes_rate,omitempty"` OutBytesRate float64 `json:"out_bytes_rate,omitempty"` LastError *time.Time `json:"last_error,omitempty"` PoE *PoEStats `json:"poe,omitempty"` } return json.Marshal(statsJSON{ Speed: s.Speed, Uptime: s.Uptime, InErrors: s.InErrors, OutErrors: s.OutErrors, InPktsRate: round2(s.InPktsRate), OutPktsRate: round2(s.OutPktsRate), InBytesRate: round2(s.InBytesRate), OutBytesRate: round2(s.OutBytesRate), LastError: s.LastError, PoE: s.PoE, }) } type PoEStats struct { Power float64 `json:"power"` MaxPower float64 `json:"max_power"` } type PoEBudget struct { Power float64 `json:"power"` MaxPower float64 `json:"max_power"` } type DanteFlows struct { Tx []*DantePeer `json:"tx,omitempty"` Rx []*DantePeer `json:"rx,omitempty"` lastSeen time.Time } func (f *DanteFlows) Expire(maxAge time.Duration) bool { if f.lastSeen.IsZero() { return false } return time.Since(f.lastSeen) > maxAge } type Node struct { ID string `json:"id"` 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:"-"` DanteFlows *DanteFlows `json:"dante_flows,omitempty"` MulticastGroups MulticastMembershipSet `json:"multicast_groups,omitempty"` ArtNetInputs ArtNetUniverseSet `json:"artnet_inputs,omitempty"` ArtNetOutputs ArtNetUniverseSet `json:"artnet_outputs,omitempty"` SACNUnicastInputs SACNUniverseSet `json:"sacn_unicast_inputs,omitempty"` SACNOutputs SACNUniverseSet `json:"sacn_outputs,omitempty"` ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"` Unreachable bool `json:"unreachable,omitempty"` Avoid bool `json:"avoid,omitempty"` InConfig bool `json:"in_config,omitempty"` errors *ErrorTracker pollTrigger chan struct{} cancelFunc context.CancelFunc } func (n *Node) IsDanteClockMaster() bool { return !n.DanteClockMasterSeen.IsZero() && time.Since(n.DanteClockMasterSeen) < 5*time.Minute } func (n *Node) SetUnreachable(unreachable bool) bool { if n.Unreachable == unreachable { return false } n.Unreachable = unreachable if n.errors != nil { if unreachable { n.errors.AddUnreachable(n) } else { n.errors.RemoveUnreachable(n) } } return true } func (n *Node) SetInterfaceStats(portName string, stats *InterfaceStats) { iface := n.Interfaces[portName] if iface == nil { return } oldStats := iface.Stats iface.Stats = stats if n.errors == nil { return } if oldStats != nil { stats.LastError = oldStats.LastError } var inDelta, outDelta uint64 if oldStats != nil { if stats.InErrors > oldStats.InErrors { inDelta = stats.InErrors - oldStats.InErrors } if stats.OutErrors > oldStats.OutErrors { outDelta = stats.OutErrors - oldStats.OutErrors } } hasErrors := stats.InErrors > 0 || stats.OutErrors > 0 hasNewErrors := inDelta > 0 || outDelta > 0 if hasErrors && (hasNewErrors || stats.LastError == nil) { now := time.Now().UTC() stats.LastError = &now } if hasNewErrors { n.errors.AddPortError(n, portName, stats, inDelta, outDelta) } if stats.Speed > 0 { maxBytesRate := stats.InBytesRate if stats.OutBytesRate > maxBytesRate { maxBytesRate = stats.OutBytesRate } speedBytes := float64(stats.Speed) / 8.0 utilization := (maxBytesRate / speedBytes) * 100.0 var oldUtilization float64 if oldStats != nil && oldStats.Speed > 0 { oldMax := oldStats.InBytesRate if oldStats.OutBytesRate > oldMax { oldMax = oldStats.OutBytesRate } oldUtilization = (oldMax / (float64(oldStats.Speed) / 8.0)) * 100.0 } if oldUtilization < 70.0 && utilization >= 70.0 { n.errors.AddUtilizationError(n, portName, utilization) } else if utilization >= 70.0 { n.errors.UpdateUtilizationLastSeen(n, portName, utilization) } } } func (n *Node) MACTableSize() int { return len(n.MACTable) } func (n *Node) SACNInputs() []SACNUniverse { if n.MulticastGroups == nil { return nil } return n.MulticastGroups.SACNInputs() } type DanteFlowStatus uint8 const ( DanteFlowUnsubscribed DanteFlowStatus = 0x00 DanteFlowNoSource DanteFlowStatus = 0x01 DanteFlowActive DanteFlowStatus = 0x09 ) func (s DanteFlowStatus) String() string { switch s { case DanteFlowActive: return "active" case DanteFlowNoSource: return "no-source" default: return "" } } func (s DanteFlowStatus) MarshalJSON() ([]byte, error) { str := s.String() if str == "" { return []byte("null"), nil } return json.Marshal(str) } type DanteChannelType uint16 const ( DanteChannelUnknown DanteChannelType = 0 DanteChannelAudio DanteChannelType = 0x000f DanteChannelAudio2 DanteChannelType = 0x0006 DanteChannelVideo DanteChannelType = 0x000e ) func (t DanteChannelType) String() string { switch t { case DanteChannelAudio, DanteChannelAudio2: return "audio" case DanteChannelVideo: return "video" default: return "" } } func (t DanteChannelType) MarshalJSON() ([]byte, error) { str := t.String() if str == "" { return []byte("null"), nil } return json.Marshal(str) } type DanteChannel struct { TxChannel string `json:"tx_channel"` RxChannel int `json:"rx_channel"` Type DanteChannelType `json:"type,omitempty"` Status DanteFlowStatus `json:"status,omitempty"` } type DantePeer struct { Node *Node `json:"node"` Channels []*DanteChannel `json:"channels,omitempty"` } func (p *DantePeer) MarshalJSON() ([]byte, error) { type peerJSON struct { NodeID string `json:"node_id"` Channels []*DanteChannel `json:"channels,omitempty"` } return json.Marshal(peerJSON{ NodeID: p.Node.ID, Channels: p.Channels, }) } func (n *Node) WithInterface(ifaceKey string) *Node { if ifaceKey == "" { return n } iface, exists := n.Interfaces[ifaceKey] if !exists { return n } return &Node{ ID: n.ID, Names: n.Names, Interfaces: InterfaceMap{ifaceKey: iface}, MACTable: n.MACTable, PoEBudget: n.PoEBudget, DanteClockMasterSeen: n.DanteClockMasterSeen, DanteTxChannels: n.DanteTxChannels, } } func (i *Interface) String() string { var parts []string parts = append(parts, string(i.MAC)) if i.Name != "" { parts = append(parts, fmt.Sprintf("(%s)", i.Name)) } if len(i.IPs) > 0 { parts = append(parts, fmt.Sprintf("%v", i.IPs.Slice())) } if i.Stats != nil { parts = append(parts, i.Stats.String()) } result := parts[0] for _, p := range parts[1:] { result += " " + p } return result } func (s *InterfaceStats) String() string { var parts []string if s.Speed > 0 { if s.Speed >= 1000000000 { parts = append(parts, fmt.Sprintf("%dG", s.Speed/1000000000)) } else if s.Speed >= 1000000 { parts = append(parts, fmt.Sprintf("%dM", s.Speed/1000000)) } else { parts = append(parts, fmt.Sprintf("%d", s.Speed)) } } if s.InErrors > 0 || s.OutErrors > 0 { parts = append(parts, fmt.Sprintf("err:%d/%d", s.InErrors, s.OutErrors)) } if s.InBytesRate > 0 || s.OutBytesRate > 0 { parts = append(parts, fmt.Sprintf("%.0f/%.0fB/s", s.InBytesRate, s.OutBytesRate)) } if s.PoE != nil { if s.PoE.MaxPower > 0 { parts = append(parts, fmt.Sprintf("poe:%.1f/%.1fW", s.PoE.Power, s.PoE.MaxPower)) } else { parts = append(parts, fmt.Sprintf("poe:%.1fW", s.PoE.Power)) } } return "[" + strings.Join(parts, " ") + "]" } func (n *Node) String() string { name := n.DisplayName() if name == "" { name = "??" } var parts []string parts = append(parts, name) if n.PoEBudget != nil { parts = append(parts, fmt.Sprintf("[poe:%.0f/%.0fW]", n.PoEBudget.Power, n.PoEBudget.MaxPower)) } var ifaces []string for _, iface := range n.Interfaces { ifaces = append(ifaces, iface.String()) } sort.Slice(ifaces, func(i, j int) bool { return sortorder.NaturalLess(ifaces[i], ifaces[j]) }) parts = append(parts, fmt.Sprintf("{%v}", ifaces)) return strings.Join(parts, " ") } func (n *Node) DisplayName() string { if len(n.Names) > 0 { var names []string for name := range n.Names { names = append(names, name) } sortNamesByLength(names) return strings.Join(names, "/") } for _, iface := range n.Interfaces { for ip := range iface.IPs { return ip } } for _, iface := range n.Interfaces { if iface.MAC != "" { return string(iface.MAC) } } return "" } func (n *Node) FirstMAC() string { for _, iface := range n.Interfaces { if iface.MAC != "" { return string(iface.MAC) } } return "" } func (n *Node) FirstIP() string { for _, iface := range n.Interfaces { for ip := range iface.IPs { return ip } } return "" }