add mdns hostname discovery and artnet universe tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-22 23:59:32 -08:00
parent 7bced7b350
commit 536c2d3dc9
7 changed files with 570 additions and 16 deletions

366
artnet.go Normal file
View File

@@ -0,0 +1,366 @@
package tendrils
import (
"context"
"encoding/binary"
"fmt"
"log"
"net"
"sort"
"strings"
"sync"
"time"
"github.com/fvbommel/sortorder"
)
const (
artNetPort = 6454
artNetID = "Art-Net\x00"
opPoll = 0x2000
opPollReply = 0x2100
protocolVersion = 14
)
type ArtNetNode struct {
IP net.IP
MAC net.HardwareAddr
ShortName string
LongName string
Inputs []int
Outputs []int
LastSeen time.Time
}
func (t *Tendrils) listenArtNet(ctx context.Context, iface net.Interface) {
addrs, err := iface.Addrs()
if err != nil {
return
}
var srcIP net.IP
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
srcIP = ipnet.IP.To4()
break
}
}
if srcIP == nil {
return
}
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: srcIP, Port: artNetPort})
if err != nil {
log.Printf("[ERROR] failed to listen artnet on %s: %v", iface.Name, err)
return
}
defer conn.Close()
go t.runArtNetPoller(ctx, iface, conn)
buf := make([]byte, 65536)
for {
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
n, src, err := conn.ReadFromUDP(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
continue
}
t.handleArtNetPacket(iface.Name, src.IP, buf[:n])
}
}
func (t *Tendrils) handleArtNetPacket(ifaceName string, srcIP net.IP, data []byte) {
if len(data) < 12 {
return
}
if string(data[:8]) != artNetID {
return
}
opcode := binary.LittleEndian.Uint16(data[8:10])
switch opcode {
case opPollReply:
t.handleArtPollReply(ifaceName, srcIP, data)
}
}
func (t *Tendrils) handleArtPollReply(ifaceName string, srcIP net.IP, data []byte) {
if len(data) < 207 {
return
}
ip := net.IPv4(data[10], data[11], data[12], data[13])
var mac net.HardwareAddr
if len(data) >= 207 {
mac = net.HardwareAddr(data[201:207])
}
shortName := strings.TrimRight(string(data[26:44]), "\x00")
longName := strings.TrimRight(string(data[44:108]), "\x00")
netSwitch := int(data[18])
subSwitch := int(data[19])
numPorts := int(data[173])
if numPorts > 4 {
numPorts = 4
}
var inputs, outputs []int
for i := 0; i < numPorts; i++ {
portType := data[174+i]
swIn := int(data[186+i])
swOut := int(data[190+i])
universe := netSwitch<<8 | subSwitch<<4
if portType&0x40 != 0 {
inputs = append(inputs, universe|swIn)
}
if portType&0x80 != 0 {
outputs = append(outputs, universe|swOut)
}
}
if t.DebugArtNet {
log.Printf("[artnet] %s: %s %s short=%q long=%q numPorts=%d portTypes=%v in=%v out=%v", ifaceName, ip, mac, shortName, longName, numPorts, data[174:178], inputs, outputs)
}
node := &ArtNetNode{
IP: ip,
MAC: mac,
ShortName: shortName,
LongName: longName,
Inputs: inputs,
Outputs: outputs,
LastSeen: time.Now(),
}
t.nodes.UpdateArtNet(node)
name := longName
if name == "" {
name = shortName
}
if name != "" {
t.nodes.Update(nil, mac, []net.IP{ip}, "", name, "artnet")
}
}
func (t *Tendrils) runArtNetPoller(ctx context.Context, iface net.Interface, conn *net.UDPConn) {
addrs, err := iface.Addrs()
if err != nil {
return
}
var broadcast net.IP
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
ip := ipnet.IP.To4()
mask := ipnet.Mask
broadcast = make(net.IP, 4)
for i := 0; i < 4; i++ {
broadcast[i] = ip[i] | ^mask[i]
}
break
}
}
if broadcast == nil {
return
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
t.sendArtPoll(conn, broadcast, iface.Name)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.sendArtPoll(conn, broadcast, iface.Name)
}
}
}
func (t *Tendrils) sendArtPoll(conn *net.UDPConn, broadcast net.IP, ifaceName string) {
packet := make([]byte, 14)
copy(packet[0:8], artNetID)
binary.LittleEndian.PutUint16(packet[8:10], opPoll)
binary.LittleEndian.PutUint16(packet[10:12], protocolVersion)
packet[12] = 0x00
packet[13] = 0x00
_, err := conn.WriteToUDP(packet, &net.UDPAddr{IP: broadcast, Port: artNetPort})
if err != nil {
if t.DebugArtNet {
log.Printf("[artnet] %s: failed to send poll: %v", ifaceName, err)
}
return
}
if t.DebugArtNet {
log.Printf("[artnet] %s: sent poll to %s", ifaceName, broadcast)
}
}
type ArtNetNodes struct {
mu sync.RWMutex
nodes map[string]*ArtNetNode
}
func NewArtNetNodes() *ArtNetNodes {
return &ArtNetNodes{
nodes: map[string]*ArtNetNode{},
}
}
func (a *ArtNetNodes) Update(node *ArtNetNode) {
a.mu.Lock()
defer a.mu.Unlock()
key := node.IP.String()
existing, exists := a.nodes[key]
if exists {
for _, u := range node.Inputs {
if !containsInt(existing.Inputs, u) {
existing.Inputs = append(existing.Inputs, u)
}
}
for _, u := range node.Outputs {
if !containsInt(existing.Outputs, u) {
existing.Outputs = append(existing.Outputs, u)
}
}
existing.LastSeen = node.LastSeen
if node.ShortName != "" {
existing.ShortName = node.ShortName
}
if node.LongName != "" {
existing.LongName = node.LongName
}
} else {
a.nodes[key] = node
}
}
func containsInt(slice []int, val int) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
func (a *ArtNetNodes) Expire() {
a.mu.Lock()
defer a.mu.Unlock()
expireTime := time.Now().Add(-60 * time.Second)
for key, node := range a.nodes {
if node.LastSeen.Before(expireTime) {
delete(a.nodes, key)
}
}
}
func (a *ArtNetNodes) GetAll() []*ArtNetNode {
a.mu.RLock()
defer a.mu.RUnlock()
result := make([]*ArtNetNode, 0, len(a.nodes))
for _, node := range a.nodes {
result = append(result, node)
}
return result
}
func (a *ArtNetNodes) LogAll() {
a.Expire()
a.mu.RLock()
defer a.mu.RUnlock()
if len(a.nodes) == 0 {
return
}
var nodes []*ArtNetNode
for _, node := range a.nodes {
nodes = append(nodes, node)
}
sort.Slice(nodes, func(i, j int) bool {
return sortorder.NaturalLess(nodes[i].LongName, nodes[j].LongName)
})
inputUniverses := map[int][]string{}
outputUniverses := map[int][]string{}
for _, node := range nodes {
name := node.LongName
if name == "" {
name = node.ShortName
}
if name == "" {
name = node.IP.String()
}
for _, u := range node.Inputs {
inputUniverses[u] = append(inputUniverses[u], name)
}
for _, u := range node.Outputs {
outputUniverses[u] = append(outputUniverses[u], name)
}
}
var allUniverses []int
seen := map[int]bool{}
for u := range inputUniverses {
if !seen[u] {
allUniverses = append(allUniverses, u)
seen[u] = true
}
}
for u := range outputUniverses {
if !seen[u] {
allUniverses = append(allUniverses, u)
seen[u] = true
}
}
sort.Ints(allUniverses)
log.Printf("[sigusr1] ================ %d artnet universes ================", len(allUniverses))
for _, u := range allUniverses {
ins := inputUniverses[u]
outs := outputUniverses[u]
var parts []string
if len(ins) > 0 {
sort.Slice(ins, func(i, j int) bool { return sortorder.NaturalLess(ins[i], ins[j]) })
parts = append(parts, fmt.Sprintf("in:%v", ins))
}
if len(outs) > 0 {
sort.Slice(outs, func(i, j int) bool { return sortorder.NaturalLess(outs[i], outs[j]) })
parts = append(parts, fmt.Sprintf("out:%v", outs))
}
net := (u >> 8) & 0x7f
subnet := (u >> 4) & 0x0f
universe := u & 0x0f
log.Printf("[sigusr1] artnet:%d (%d/%d/%d) %s", u, net, subnet, universe, strings.Join(parts, " "))
}
}
func (n *Nodes) UpdateArtNet(artNode *ArtNetNode) {
n.t.artnet.Update(artNode)
}

View File

@@ -12,12 +12,16 @@ func main() {
noLLDP := flag.Bool("no-lldp", false, "disable LLDP discovery")
noSNMP := flag.Bool("no-snmp", false, "disable SNMP discovery")
noIGMP := flag.Bool("no-igmp", false, "disable IGMP querier")
noMDNS := flag.Bool("no-mdns", false, "disable mDNS discovery")
noArtNet := flag.Bool("no-artnet", false, "disable Art-Net discovery")
logEvents := flag.Bool("log-events", false, "log node events")
logNodes := flag.Bool("log-nodes", false, "log full node details on changes")
debugARP := flag.Bool("debug-arp", false, "debug ARP discovery")
debugLLDP := flag.Bool("debug-lldp", false, "debug LLDP discovery")
debugSNMP := flag.Bool("debug-snmp", false, "debug SNMP discovery")
debugIGMP := flag.Bool("debug-igmp", false, "debug IGMP querier")
debugMDNS := flag.Bool("debug-mdns", false, "debug mDNS discovery")
debugArtNet := flag.Bool("debug-artnet", false, "debug Art-Net discovery")
flag.Parse()
t := tendrils.New()
@@ -26,11 +30,15 @@ func main() {
t.DisableLLDP = *noLLDP
t.DisableSNMP = *noSNMP
t.DisableIGMP = *noIGMP
t.DisableMDNS = *noMDNS
t.DisableArtNet = *noArtNet
t.LogEvents = *logEvents
t.LogNodes = *logNodes
t.DebugARP = *debugARP
t.DebugLLDP = *debugLLDP
t.DebugSNMP = *debugSNMP
t.DebugIGMP = *debugIGMP
t.DebugMDNS = *debugMDNS
t.DebugArtNet = *debugArtNet
t.Run()
}

9
go.mod
View File

@@ -6,6 +6,13 @@ require (
github.com/fvbommel/sortorder v1.1.0
github.com/google/gopacket v1.1.19
github.com/gosnmp/gosnmp v1.42.1
github.com/miekg/dns v1.1.72
)
require golang.org/x/sys v0.13.0 // indirect
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

18
go.sum
View File

@@ -2,10 +2,14 @@ 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/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=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gosnmp/gosnmp v1.42.1 h1:MEJxhpC5v1coL3tFRix08PYmky9nyb1TLRRgJAmXm8A=
github.com/gosnmp/gosnmp v1.42.1/go.mod h1:CxVS6bXqmWZlafUj9pZUnQX5e4fAltqPcijxWpCitDo=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -14,17 +18,23 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
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=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

149
mdns.go Normal file
View File

@@ -0,0 +1,149 @@
package tendrils
import (
"context"
"log"
"net"
"strings"
"time"
"github.com/miekg/dns"
)
const (
mdnsAddr = "224.0.0.251:5353"
)
func (t *Tendrils) listenMDNS(ctx context.Context, iface net.Interface) {
addr, err := net.ResolveUDPAddr("udp4", mdnsAddr)
if err != nil {
log.Printf("[ERROR] failed to resolve mdns address: %v", err)
return
}
conn, err := net.ListenMulticastUDP("udp4", &iface, addr)
if err != nil {
log.Printf("[ERROR] failed to listen mdns on %s: %v", iface.Name, err)
return
}
defer conn.Close()
go t.runMDNSQuerier(ctx, iface)
buf := make([]byte, 65536)
for {
select {
case <-ctx.Done():
return
default:
}
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
n, src, err := conn.ReadFromUDP(buf)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
continue
}
t.handleMDNSPacket(iface.Name, src.IP, buf[:n])
}
}
func (t *Tendrils) handleMDNSPacket(ifaceName string, srcIP net.IP, data []byte) {
msg := new(dns.Msg)
if err := msg.Unpack(data); err != nil {
return
}
if msg.Response {
t.processMDNSResponse(ifaceName, srcIP, msg)
}
}
func (t *Tendrils) processMDNSResponse(ifaceName string, srcIP net.IP, msg *dns.Msg) {
var hostname string
allRecords := append(msg.Answer, msg.Extra...)
for _, rr := range allRecords {
switch r := rr.(type) {
case *dns.A:
name := strings.TrimSuffix(r.Hdr.Name, ".local.")
name = strings.TrimSuffix(name, ".")
if name != "" && r.A.Equal(srcIP) {
hostname = name
}
case *dns.AAAA:
continue
case *dns.PTR:
name := strings.TrimSuffix(r.Ptr, ".local.")
name = strings.TrimSuffix(name, ".")
if hostname == "" && name != "" && !strings.HasPrefix(name, "_") {
hostname = name
}
}
}
if hostname != "" {
if t.DebugMDNS {
log.Printf("[mdns] %s: %s -> %s", ifaceName, srcIP, hostname)
}
t.nodes.Update(nil, nil, []net.IP{srcIP}, "", hostname, "mdns")
}
}
func (t *Tendrils) runMDNSQuerier(ctx context.Context, iface net.Interface) {
addrs, err := iface.Addrs()
if err != nil {
return
}
var srcIP net.IP
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
srcIP = ipnet.IP.To4()
break
}
}
if srcIP == nil {
return
}
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
t.sendMDNSQuery(iface.Name, srcIP)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.sendMDNSQuery(iface.Name, srcIP)
}
}
}
func (t *Tendrils) sendMDNSQuery(ifaceName string, srcIP net.IP) {
conn, err := net.DialUDP("udp4", &net.UDPAddr{IP: srcIP}, &net.UDPAddr{IP: net.IPv4(224, 0, 0, 251), Port: 5353})
if err != nil {
return
}
defer conn.Close()
msg := new(dns.Msg)
msg.SetQuestion("_services._dns-sd._udp.local.", dns.TypePTR)
msg.RecursionDesired = false
data, err := msg.Pack()
if err != nil {
return
}
conn.Write(data)
if t.DebugMDNS {
log.Printf("[mdns] %s: sent query", ifaceName)
}
}

View File

@@ -617,6 +617,8 @@ func (n *Nodes) LogAll() {
log.Printf("[sigusr1] %s: %v", gm.Group.Name(), memberNames)
}
}
n.t.artnet.LogAll()
}
func (n *Nodes) expireMulticastMemberships() {

View File

@@ -13,23 +13,29 @@ import (
type Tendrils struct {
activeInterfaces map[string]context.CancelFunc
nodes *Nodes
artnet *ArtNetNodes
Interface string
DisableARP bool
DisableLLDP bool
DisableSNMP bool
DisableIGMP bool
DisableMDNS bool
DisableArtNet bool
LogEvents bool
LogNodes bool
DebugARP bool
DebugLLDP bool
DebugSNMP bool
DebugIGMP bool
DebugMDNS bool
DebugArtNet bool
}
func New() *Tendrils {
t := &Tendrils{
activeInterfaces: map[string]context.CancelFunc{},
artnet: NewArtNetNodes(),
}
t.nodes = NewNodes(t)
return t
@@ -183,4 +189,10 @@ func (t *Tendrils) startInterface(ctx context.Context, iface net.Interface) {
if !t.DisableIGMP {
go t.listenIGMP(ctx, iface)
}
if !t.DisableMDNS {
go t.listenMDNS(ctx, iface)
}
if !t.DisableArtNet {
go t.listenArtNet(ctx, iface)
}
}