diff --git a/CLAUDE.md b/CLAUDE.md index dfcbcf2..7d88281 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,5 +5,5 @@ - Don't mention claude in commit messages. Keep them to a single, short, descriptive sentence - Always push after commiting - Use git add -A so you don't miss files when committing -- Never use go build -- use go run instead -- DO NOT commit unless asked to \ No newline at end of file +- Never use go build -- use go vet instead +- DO NOT commit unless asked to diff --git a/artnet.go b/artnet.go index fb74dd4..f790a2f 100644 --- a/artnet.go +++ b/artnet.go @@ -23,10 +23,11 @@ const ( ) type ArtNetNode struct { - Node *Node - Inputs []int - Outputs []int - LastSeen time.Time + TypeID string `json:"typeid"` + Node *Node `json:"node"` + Inputs []int `json:"inputs,omitempty"` + Outputs []int `json:"outputs,omitempty"` + LastSeen time.Time `json:"last_seen"` } func (t *Tendrils) listenArtNet(ctx context.Context, iface net.Interface) { @@ -217,6 +218,7 @@ func (a *ArtNetNodes) Update(node *Node, inputs, outputs []int) { existing.LastSeen = time.Now() } else { a.nodes[node] = &ArtNetNode{ + TypeID: newTypeID("artnetnode"), Node: node, Inputs: inputs, Outputs: outputs, diff --git a/cmd/tendrils/main.go b/cmd/tendrils/main.go index 4906271..ebee37e 100644 --- a/cmd/tendrils/main.go +++ b/cmd/tendrils/main.go @@ -30,6 +30,7 @@ func main() { debugBMD := flag.Bool("debug-bmd", false, "debug Blackmagic discovery") debugShure := flag.Bool("debug-shure", false, "debug Shure discovery") debugYamaha := flag.Bool("debug-yamaha", false, "debug Yamaha discovery") + httpPort := flag.String("http", ":80", "HTTP server port (empty to disable)") flag.Parse() t := tendrils.New() @@ -56,5 +57,6 @@ func main() { t.DebugBMD = *debugBMD t.DebugShure = *debugShure t.DebugYamaha = *debugYamaha + t.HTTPPort = *httpPort t.Run() } diff --git a/dante.go b/dante.go index 136e02c..fb77b24 100644 --- a/dante.go +++ b/dante.go @@ -3,6 +3,7 @@ package tendrils import ( "context" "encoding/binary" + "encoding/json" "fmt" "log" "net" @@ -118,16 +119,30 @@ func (n *Nodes) GetDanteTxDeviceInGroup(groupIP net.IP) string { var danteSeqID uint32 +type DanteSubscriberMap map[*Node]*DanteFlowSubscriber + +func (m DanteSubscriberMap) MarshalJSON() ([]byte, error) { + subs := make([]*DanteFlowSubscriber, 0, len(m)) + for _, sub := range m { + subs = append(subs, sub) + } + sort.Slice(subs, func(i, j int) bool { + return sortorder.NaturalLess(subs[i].Node.DisplayName(), subs[j].Node.DisplayName()) + }) + return json.Marshal(subs) +} + type DanteFlow struct { - Source *Node - Subscribers map[*Node]*DanteFlowSubscriber + TypeID string `json:"typeid"` + Source *Node `json:"source"` + Subscribers DanteSubscriberMap `json:"subscribers"` } type DanteFlowSubscriber struct { - Node *Node - Channels []string - ChannelStatus map[string]DanteFlowStatus - LastSeen time.Time + Node *Node `json:"node"` + Channels []string `json:"channels,omitempty"` + ChannelStatus map[string]DanteFlowStatus `json:"channel_status,omitempty"` + LastSeen time.Time `json:"last_seen"` } type DanteFlows struct { @@ -157,8 +172,9 @@ func (d *DanteFlows) Update(source, subscriber *Node, channelInfo string, flowSt flow := d.flows[source] if flow == nil { flow = &DanteFlow{ + TypeID: newTypeID("danteflow"), Source: source, - Subscribers: map[*Node]*DanteFlowSubscriber{}, + Subscribers: DanteSubscriberMap{}, } d.flows[source] = flow } @@ -437,6 +453,10 @@ func (s DanteFlowStatus) String() string { } } +func (s DanteFlowStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(s.String()) +} + type DanteSubscription struct { RxChannel int TxDeviceName string @@ -866,7 +886,7 @@ func (t *Tendrils) probeDanteDeviceWithPort(ip net.IP, port int) { log.Printf("[dante] %s: multicast group %s -> tx device %q", ip, groupIP, sourceName) } if sourceName == "" { - sourceName = (&MulticastGroup{IP: groupIP}).Name() + sourceName = multicastGroupName(groupIP) } sourceNode := t.nodes.GetOrCreateByName(sourceName) subscriberNode := t.nodes.GetOrCreateByName(info.Name) diff --git a/go.mod b/go.mod index dcd2df1..b2d05cf 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,11 @@ require ( github.com/google/gopacket v1.1.19 github.com/gosnmp/gosnmp v1.42.1 github.com/miekg/dns v1.1.72 + go.jetify.com/typeid v1.3.0 ) require ( + github.com/gofrs/uuid/v5 v5.2.0 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index f987b9d..ebb0c53 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM= +github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= @@ -14,6 +16,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY= +go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -36,5 +40,7 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/http.go b/http.go new file mode 100644 index 0000000..1a0dd84 --- /dev/null +++ b/http.go @@ -0,0 +1,140 @@ +package tendrils + +import ( + "encoding/json" + "log" + "net/http" + "sort" + + "github.com/fvbommel/sortorder" +) + +type StatusResponse struct { + Nodes []*Node `json:"nodes"` + Links []*Link `json:"links"` + MulticastGroups []*MulticastGroupMembers `json:"multicast_groups"` + ArtNetNodes []*ArtNetNode `json:"artnet_nodes"` + DanteFlows []*DanteFlow `json:"dante_flows"` +} + +func (t *Tendrils) startHTTPServer() { + if t.HTTPPort == "" { + return + } + + mux := http.NewServeMux() + mux.HandleFunc("/api/status", t.handleAPIStatus) + mux.Handle("/", http.FileServer(http.Dir("static"))) + + log.Printf("[http] listening on %s", t.HTTPPort) + go func() { + if err := http.ListenAndServe(t.HTTPPort, mux); err != nil { + log.Printf("[ERROR] http server failed: %v", err) + } + }() +} + +func (t *Tendrils) handleAPIStatus(w http.ResponseWriter, r *http.Request) { + status := t.GetStatus() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} + +func (t *Tendrils) GetStatus() *StatusResponse { + return &StatusResponse{ + Nodes: t.getNodes(), + Links: t.getLinks(), + MulticastGroups: t.getMulticastGroups(), + ArtNetNodes: t.getArtNetNodes(), + DanteFlows: t.getDanteFlows(), + } +} + +func (t *Tendrils) getNodes() []*Node { + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + nodes := make([]*Node, 0, len(t.nodes.nodes)) + for _, node := range t.nodes.nodes { + nodes = append(nodes, node) + } + sort.Slice(nodes, func(i, j int) bool { + return sortorder.NaturalLess(nodes[i].DisplayName(), nodes[j].DisplayName()) + }) + + return nodes +} + +func (t *Tendrils) getLinks() []*Link { + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + links := t.nodes.getDirectLinks() + sort.Slice(links, func(i, j int) bool { + if links[i].NodeA.DisplayName() != links[j].NodeA.DisplayName() { + return sortorder.NaturalLess(links[i].NodeA.DisplayName(), links[j].NodeA.DisplayName()) + } + if links[i].InterfaceA != links[j].InterfaceA { + return sortorder.NaturalLess(links[i].InterfaceA, links[j].InterfaceA) + } + if links[i].NodeB.DisplayName() != links[j].NodeB.DisplayName() { + return sortorder.NaturalLess(links[i].NodeB.DisplayName(), links[j].NodeB.DisplayName()) + } + return sortorder.NaturalLess(links[i].InterfaceB, links[j].InterfaceB) + }) + + return links +} + +func (t *Tendrils) getMulticastGroups() []*MulticastGroupMembers { + t.nodes.mu.Lock() + t.nodes.expireMulticastMemberships() + t.nodes.mu.Unlock() + + t.nodes.mu.RLock() + defer t.nodes.mu.RUnlock() + + groups := make([]*MulticastGroupMembers, 0, len(t.nodes.multicastGroups)) + for _, gm := range t.nodes.multicastGroups { + groups = append(groups, gm) + } + sort.Slice(groups, func(i, j int) bool { + return sortorder.NaturalLess(groups[i].Group.Name, groups[j].Group.Name) + }) + + return groups +} + +func (t *Tendrils) getArtNetNodes() []*ArtNetNode { + t.artnet.Expire() + + t.artnet.mu.RLock() + defer t.artnet.mu.RUnlock() + + nodes := make([]*ArtNetNode, 0, len(t.artnet.nodes)) + for _, node := range t.artnet.nodes { + nodes = append(nodes, node) + } + sort.Slice(nodes, func(i, j int) bool { + return sortorder.NaturalLess(nodes[i].Node.DisplayName(), nodes[j].Node.DisplayName()) + }) + + return nodes +} + +func (t *Tendrils) getDanteFlows() []*DanteFlow { + t.danteFlows.Expire() + + t.danteFlows.mu.RLock() + defer t.danteFlows.mu.RUnlock() + + flows := make([]*DanteFlow, 0, len(t.danteFlows.flows)) + for _, flow := range t.danteFlows.flows { + flows = append(flows, flow) + } + sort.Slice(flows, func(i, j int) bool { + return sortorder.NaturalLess(flows[i].Source.DisplayName(), flows[j].Source.DisplayName()) + }) + + return flows +} diff --git a/link.go b/link.go index 02842c0..e9efa70 100644 --- a/link.go +++ b/link.go @@ -3,10 +3,11 @@ package tendrils import "fmt" type Link struct { - NodeA *Node - InterfaceA string - NodeB *Node - InterfaceB string + TypeID string `json:"typeid"` + NodeA *Node `json:"node_a"` + InterfaceA string `json:"interface_a,omitempty"` + NodeB *Node `json:"node_b"` + InterfaceB string `json:"interface_b,omitempty"` } func (l *Link) String() string { @@ -33,8 +34,8 @@ func (n *Nodes) getDirectLinks() []*Link { macToNode := map[string]*Node{} for _, node := range n.nodes { for _, iface := range node.Interfaces { - if iface.MAC != nil { - macToNode[iface.MAC.String()] = node + if iface.MAC != "" { + macToNode[string(iface.MAC)] = node } } } @@ -45,10 +46,10 @@ func (n *Nodes) getDirectLinks() []*Link { for _, target := range n.nodes { seenMACs := map[string]bool{} for _, iface := range target.Interfaces { - if iface.MAC == nil { + if iface.MAC == "" { continue } - mac := iface.MAC.String() + mac := string(iface.MAC) if seenMACs[mac] { continue } @@ -64,6 +65,7 @@ func (n *Nodes) getDirectLinks() []*Link { if !seen[key] { seen[key] = true links = append(links, &Link{ + TypeID: newTypeID("link"), NodeA: lastHop, InterfaceA: lastPort, NodeB: target, diff --git a/multicast.go b/multicast.go index 2ad6b9f..d55d84a 100644 --- a/multicast.go +++ b/multicast.go @@ -1,32 +1,74 @@ package tendrils import ( + "encoding/json" "fmt" "net" + "sort" "time" + + "github.com/fvbommel/sortorder" ) type MulticastGroup struct { - IP net.IP + Name string `json:"name"` + IP string `json:"ip"` } type MulticastMembership struct { - Node *Node - LastSeen time.Time + SourceIP string `json:"source_ip"` + Node *Node `json:"node,omitempty"` + LastSeen time.Time `json:"last_seen"` +} + +type MulticastMembershipMap map[string]*MulticastMembership + +func (m MulticastMembershipMap) MarshalJSON() ([]byte, error) { + members := make([]*MulticastMembership, 0, len(m)) + for _, membership := range m { + members = append(members, membership) + } + sort.Slice(members, func(i, j int) bool { + nameI := members[i].SourceIP + if members[i].Node != nil && members[i].Node.DisplayName() != "" { + nameI = members[i].Node.DisplayName() + } + nameJ := members[j].SourceIP + if members[j].Node != nil && members[j].Node.DisplayName() != "" { + nameJ = members[j].Node.DisplayName() + } + return sortorder.NaturalLess(nameI, nameJ) + }) + return json.Marshal(members) } type MulticastGroupMembers struct { - Group *MulticastGroup - Members map[string]*MulticastMembership // source IP -> membership + TypeID string `json:"typeid"` + Group *MulticastGroup `json:"group"` + Members MulticastMembershipMap `json:"members"` } -func (g *MulticastGroup) Name() string { - ip := g.IP.To4() +func (g *MulticastGroup) IsDante() bool { + ip := net.ParseIP(g.IP).To4() if ip == nil { - return g.IP.String() + return false + } + if ip[0] == 239 && ip[1] >= 69 && ip[1] <= 71 { + return true + } + if ip[0] == 239 && ip[1] == 253 { + return true + } + return false +} + +func multicastGroupName(ip net.IP) string { + ip4 := ip.To4() + if ip4 == nil { + return ip.String() } - switch g.IP.String() { + switch ip.String() { case "224.0.0.251": return "mdns" case "224.0.1.129": @@ -49,41 +91,24 @@ func (g *MulticastGroup) Name() string { return "admin-scoped-broadcast" } - // sACN (239.255.x.x, universes 1-63999) - if ip[0] == 239 && ip[1] == 255 { - universe := int(ip[2])*256 + int(ip[3]) + if ip4[0] == 239 && ip4[1] == 255 { + universe := int(ip4[2])*256 + int(ip4[3]) if universe >= 1 && universe <= 63999 { return fmt.Sprintf("sacn:%d", universe) } } - // Dante audio multicast (239.69-71.x.x) - if ip[0] == 239 && ip[1] >= 69 && ip[1] <= 71 { - flowID := (int(ip[1]-69) << 16) | (int(ip[2]) << 8) | int(ip[3]) + if ip4[0] == 239 && ip4[1] >= 69 && ip4[1] <= 71 { + flowID := (int(ip4[1]-69) << 16) | (int(ip4[2]) << 8) | int(ip4[3]) return fmt.Sprintf("dante-mcast:%d", flowID) } - // Dante AV multicast (239.253.x.x) - if ip[0] == 239 && ip[1] == 253 { - flowID := (int(ip[2]) << 8) | int(ip[3]) + if ip4[0] == 239 && ip4[1] == 253 { + flowID := (int(ip4[2]) << 8) | int(ip4[3]) return fmt.Sprintf("dante-av:%d", flowID) } - return g.IP.String() -} - -func (g *MulticastGroup) IsDante() bool { - ip := g.IP.To4() - if ip == nil { - return false - } - if ip[0] == 239 && ip[1] >= 69 && ip[1] <= 71 { - return true - } - if ip[0] == 239 && ip[1] == 253 { - return true - } - return false + return ip.String() } func (n *Nodes) UpdateMulticastMembership(sourceIP, groupIP net.IP) { @@ -98,16 +123,25 @@ func (n *Nodes) UpdateMulticastMembership(sourceIP, groupIP net.IP) { gm := n.multicastGroups[groupKey] if gm == nil { gm = &MulticastGroupMembers{ - Group: &MulticastGroup{IP: groupIP}, - Members: map[string]*MulticastMembership{}, + TypeID: newTypeID("mcastgroup"), + Group: &MulticastGroup{ + Name: multicastGroupName(groupIP), + IP: groupKey, + }, + Members: MulticastMembershipMap{}, } n.multicastGroups[groupKey] = gm } - gm.Members[sourceKey] = &MulticastMembership{ - Node: node, - LastSeen: time.Now(), + membership := gm.Members[sourceKey] + if membership == nil { + membership = &MulticastMembership{ + SourceIP: sourceKey, + } + gm.Members[sourceKey] = membership } + membership.Node = node + membership.LastSeen = time.Now() } func (n *Nodes) RemoveMulticastMembership(sourceIP, groupIP net.IP) { @@ -137,7 +171,7 @@ func (n *Nodes) GetDanteMulticastGroups(deviceIP net.IP) []net.IP { continue } if _, exists := gm.Members[deviceKey]; exists { - groups = append(groups, gm.Group.IP) + groups = append(groups, net.ParseIP(gm.Group.IP)) } } return groups diff --git a/nodes.go b/nodes.go index da47173..8a1b2dc 100644 --- a/nodes.go +++ b/nodes.go @@ -152,7 +152,8 @@ func (n *Nodes) createNode() int { targetID := n.nextID n.nextID++ node := &Node{ - Interfaces: map[string]*Interface{}, + TypeID: newTypeID("node"), + Interfaces: InterfaceMap{}, MACTable: map[string]string{}, pollTrigger: make(chan struct{}, 1), } @@ -172,10 +173,10 @@ func (n *Nodes) applyNodeUpdates(node *Node, nodeID int, mac net.HardwareAddr, i if nodeName != "" { if node.Names == nil { - node.Names = map[string]bool{} + node.Names = NameSet{} } - if !node.Names[nodeName] { - node.Names[nodeName] = true + if !node.Names.Has(nodeName) { + node.Names.Add(nodeName) n.nameIndex[nodeName] = nodeID added = append(added, "name="+nodeName) } @@ -194,10 +195,10 @@ func (n *Nodes) updateNodeIPs(node *Node, nodeID int, ips []net.IP) []string { n.ipIndex[ipKey] = nodeID iface, exists := node.Interfaces[ipKey] if !exists { - iface = &Interface{IPs: map[string]net.IP{}} + iface = &Interface{IPs: IPSet{}} node.Interfaces[ipKey] = iface } - iface.IPs[ipKey] = ip + iface.IPs.Add(ip) added = append(added, "ip="+ipKey) go n.t.requestARP(ip) } @@ -273,8 +274,8 @@ func (n *Nodes) updateNodeInterface(node *Node, nodeID int, mac net.HardwareAddr if !exists { iface = &Interface{ Name: ifaceName, - MAC: mac, - IPs: map[string]net.IP{}, + MAC: MACFrom(mac), + IPs: IPSet{}, } node.Interfaces[ifaceKey] = iface added = append(added, "iface="+ifaceKey) @@ -286,10 +287,10 @@ func (n *Nodes) updateNodeInterface(node *Node, nodeID int, mac net.HardwareAddr for _, ip := range ips { ipKey := ip.String() - if _, exists := iface.IPs[ipKey]; !exists { + if !iface.IPs.Has(ipKey) { added = append(added, "ip="+ipKey) } - iface.IPs[ipKey] = ip + iface.IPs.Add(ip) n.ipIndex[ipKey] = nodeID } @@ -300,7 +301,7 @@ func (n *Nodes) findOrCreateInterface(node *Node, macKey, ifaceName, ifaceKey st var added []string if ifaceName != "" { - if oldIface, oldExists := node.Interfaces[macKey]; oldExists && oldIface.MAC.String() == macKey { + if oldIface, oldExists := node.Interfaces[macKey]; oldExists && string(oldIface.MAC) == macKey { oldIface.Name = ifaceName delete(node.Interfaces, macKey) node.Interfaces[ifaceKey] = oldIface @@ -308,7 +309,7 @@ func (n *Nodes) findOrCreateInterface(node *Node, macKey, ifaceName, ifaceKey st } } else { for _, existing := range node.Interfaces { - if existing.MAC.String() == macKey { + if string(existing.MAC) == macKey { return existing, true, added } } @@ -364,20 +365,20 @@ func (n *Nodes) mergeNodes(keepID, mergeID int) { for name := range merge.Names { if keep.Names == nil { - keep.Names = map[string]bool{} + keep.Names = NameSet{} } - keep.Names[name] = true + keep.Names.Add(name) n.nameIndex[name] = keepID } for _, iface := range merge.Interfaces { var ips []net.IP - for _, ip := range iface.IPs { - ips = append(ips, ip) + for ipStr := range iface.IPs { + ips = append(ips, net.ParseIP(ipStr)) } - if iface.MAC != nil { - n.updateNodeInterface(keep, keepID, iface.MAC, ips, iface.Name) - n.macIndex[iface.MAC.String()] = keepID + if iface.MAC != "" { + n.updateNodeInterface(keep, keepID, iface.MAC.Parse(), ips, iface.Name) + n.macIndex[string(iface.MAC)] = keepID } } @@ -451,8 +452,9 @@ func (n *Nodes) GetOrCreateByName(name string) *Node { targetID := n.nextID n.nextID++ node := &Node{ - Names: map[string]bool{name: true}, - Interfaces: map[string]*Interface{}, + TypeID: newTypeID("node"), + Names: NameSet{name: true}, + Interfaces: InterfaceMap{}, MACTable: map[string]string{}, pollTrigger: make(chan struct{}, 1), } @@ -593,7 +595,7 @@ func (n *Nodes) LogAll() { groups = append(groups, gm) } sort.Slice(groups, func(i, j int) bool { - return sortorder.NaturalLess(groups[i].Group.Name(), groups[j].Group.Name()) + return sortorder.NaturalLess(groups[i].Group.Name, groups[j].Group.Name) }) log.Printf("[sigusr1] ================ %d multicast groups ================", len(groups)) @@ -614,7 +616,7 @@ func (n *Nodes) LogAll() { sort.Slice(memberNames, func(i, j int) bool { return sortorder.NaturalLess(memberNames[i], memberNames[j]) }) - log.Printf("[sigusr1] %s: %s", gm.Group.Name(), strings.Join(memberNames, ", ")) + log.Printf("[sigusr1] %s: %s", gm.Group.Name, strings.Join(memberNames, ", ")) } } diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tendrils.go b/tendrils.go index 3b906d1..a824996 100644 --- a/tendrils.go +++ b/tendrils.go @@ -58,6 +58,7 @@ type Tendrils struct { DebugBMD bool DebugShure bool DebugYamaha bool + HTTPPort string } func New() *Tendrils { @@ -83,6 +84,7 @@ func (t *Tendrils) Run() { }() t.populateLocalAddresses() + t.startHTTPServer() if !t.DisableARP { t.readARPTable() @@ -239,8 +241,9 @@ func (t *Tendrils) pollNode(node *Node) { t.nodes.mu.RLock() var ips []net.IP for _, iface := range node.Interfaces { - for _, ip := range iface.IPs { - if ip.To4() != nil { + for ipStr := range iface.IPs { + ip := net.ParseIP(ipStr) + if ip != nil && ip.To4() != nil { ips = append(ips, ip) } } diff --git a/types.go b/types.go index cdcc6e6..a0d6723 100644 --- a/types.go +++ b/types.go @@ -1,62 +1,143 @@ package tendrils import ( + "encoding/json" "fmt" "net" "sort" "strings" "github.com/fvbommel/sortorder" + "go.jetify.com/typeid" ) +func newTypeID(prefix string) string { + tid, _ := typeid.WithPrefix(prefix) + return tid.String() +} + +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 (s NameSet) MarshalJSON() ([]byte, error) { + names := make([]string, 0, len(s)) + for name := range s { + names = append(names, name) + } + sort.Strings(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 - MAC net.HardwareAddr - IPs map[string]net.IP - Stats *InterfaceStats + Name string `json:"name,omitempty"` + MAC MAC `json:"mac"` + IPs IPSet `json:"ips"` + Stats *InterfaceStats `json:"stats,omitempty"` } type InterfaceStats struct { - Speed uint64 // bits per second - InErrors uint64 - OutErrors uint64 - PoE *PoEStats + Speed uint64 `json:"speed,omitempty"` + InErrors uint64 `json:"in_errors,omitempty"` + OutErrors uint64 `json:"out_errors,omitempty"` + PoE *PoEStats `json:"poe,omitempty"` } type PoEStats struct { - Power float64 // watts in use - MaxPower float64 // watts allocated/negotiated + Power float64 `json:"power"` + MaxPower float64 `json:"max_power"` } type PoEBudget struct { - Power float64 // watts in use - MaxPower float64 // watts total budget + Power float64 `json:"power"` + MaxPower float64 `json:"max_power"` } type Node struct { - Names map[string]bool - Interfaces map[string]*Interface - MACTable map[string]string // peer MAC -> local interface name - PoEBudget *PoEBudget - IsDanteClockMaster bool - DanteTxChannels string + TypeID string `json:"typeid"` + Names NameSet `json:"names"` + Interfaces InterfaceMap `json:"interfaces"` + MACTable map[string]string `json:"-"` + MACTableSize int `json:"mac_table_size,omitempty"` + PoEBudget *PoEBudget `json:"poe_budget,omitempty"` + IsDanteClockMaster bool `json:"is_dante_clock_master,omitempty"` + DanteTxChannels string `json:"dante_tx_channels,omitempty"` pollTrigger chan struct{} } func (i *Interface) String() string { - var ips []string - for _, ip := range i.IPs { - ips = append(ips, ip.String()) - } - sort.Strings(ips) - var parts []string - parts = append(parts, i.MAC.String()) + parts = append(parts, string(i.MAC)) if i.Name != "" { parts = append(parts, fmt.Sprintf("(%s)", i.Name)) } - if len(ips) > 0 { - parts = append(parts, fmt.Sprintf("%v", ips)) + if len(i.IPs) > 0 { + parts = append(parts, fmt.Sprintf("%v", i.IPs.Slice())) } if i.Stats != nil { parts = append(parts, i.Stats.String()) @@ -135,8 +216,8 @@ func (n *Node) DisplayName() string { func (n *Node) FirstMAC() string { for _, iface := range n.Interfaces { - if iface.MAC != nil { - return iface.MAC.String() + if iface.MAC != "" { + return string(iface.MAC) } } return "??"