Initial multicast library with IGMP query response and periodic advertisements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-29 21:44:39 -08:00
commit 3ccee95804
5 changed files with 489 additions and 0 deletions

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module github.com/gopatchy/multicast
go 1.23.0
require (
github.com/google/gopacket v1.1.19
golang.org/x/net v0.34.0
)
require golang.org/x/sys v0.29.0 // indirect

18
go.sum Normal file
View File

@@ -0,0 +1,18 @@
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
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=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

124
listener.go Normal file
View File

@@ -0,0 +1,124 @@
package multicast
import (
"context"
"net"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
)
type JoinLeaveHandler func(sourceIP, groupIP net.IP, join bool)
type Listener struct {
iface *net.Interface
handle *pcap.Handle
joinHandler JoinLeaveHandler
}
func NewListener(iface *net.Interface, joinHandler JoinLeaveHandler) (*Listener, error) {
handle, err := pcap.OpenLive(iface.Name, 65536, true, 5*time.Second)
if err != nil {
return nil, err
}
if err := handle.SetBPFFilter("igmp"); err != nil {
handle.Close()
return nil, err
}
return &Listener{
iface: iface,
handle: handle,
joinHandler: joinHandler,
}, nil
}
func (l *Listener) Run(ctx context.Context) {
defer l.handle.Close()
packetSource := gopacket.NewPacketSource(l.handle, l.handle.LinkType())
packets := packetSource.Packets()
for {
select {
case <-ctx.Done():
return
case packet, ok := <-packets:
if !ok {
return
}
l.handlePacket(packet)
}
}
}
func (l *Listener) Close() {
l.handle.Close()
}
func (l *Listener) handlePacket(packet gopacket.Packet) {
ipLayer := packet.Layer(layers.LayerTypeIPv4)
if ipLayer == nil {
return
}
ip := ipLayer.(*layers.IPv4)
sourceIP := ip.SrcIP
igmpLayer := packet.Layer(layers.LayerTypeIGMP)
if igmpLayer == nil {
return
}
switch igmp := igmpLayer.(type) {
case *layers.IGMPv1or2:
l.handleIGMPv1or2(sourceIP, igmp)
case *layers.IGMP:
l.handleIGMPv3(sourceIP, igmp)
}
}
func (l *Listener) handleIGMPv1or2(sourceIP net.IP, igmp *layers.IGMPv1or2) {
switch igmp.Type {
case layers.IGMPMembershipReportV1, layers.IGMPMembershipReportV2:
groupIP := igmp.GroupAddress
if !groupIP.IsMulticast() || groupIP.IsLinkLocalMulticast() {
return
}
if l.joinHandler != nil {
l.joinHandler(sourceIP, groupIP, true)
}
case layers.IGMPLeaveGroup:
groupIP := igmp.GroupAddress
if l.joinHandler != nil {
l.joinHandler(sourceIP, groupIP, false)
}
}
}
func (l *Listener) handleIGMPv3(sourceIP net.IP, igmp *layers.IGMP) {
if igmp.Type != layers.IGMPMembershipReportV3 {
return
}
for _, record := range igmp.GroupRecords {
groupIP := record.MulticastAddress
if !groupIP.IsMulticast() || groupIP.IsLinkLocalMulticast() {
continue
}
switch record.Type {
case layers.IGMPIsEx, layers.IGMPToEx, layers.IGMPIsIn, layers.IGMPToIn:
if l.joinHandler != nil {
l.joinHandler(sourceIP, groupIP, true)
}
case layers.IGMPBlock:
if l.joinHandler != nil {
l.joinHandler(sourceIP, groupIP, false)
}
}
}
}

248
multicast.go Normal file
View File

@@ -0,0 +1,248 @@
package multicast
import (
"context"
"math/rand"
"net"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
"golang.org/x/net/ipv4"
)
type Conn struct {
*ipv4.PacketConn
rawConn net.PacketConn
iface *net.Interface
groupIP net.IP
srcIP net.IP
srcMAC net.HardwareAddr
queryChan chan struct{}
ctx context.Context
cancel context.CancelFunc
}
func ListenMulticastUDP(network string, iface *net.Interface, gaddr *net.UDPAddr) (*Conn, error) {
srcIP, _ := getInterfaceIPv4(iface)
c, err := net.ListenPacket(network, gaddr.String())
if err != nil {
return nil, err
}
p := ipv4.NewPacketConn(c)
if iface != nil {
p.SetMulticastInterface(iface)
}
if err := p.JoinGroup(iface, gaddr); err != nil {
c.Close()
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
conn := &Conn{
PacketConn: p,
rawConn: c,
iface: iface,
groupIP: gaddr.IP,
srcIP: srcIP,
srcMAC: iface.HardwareAddr,
queryChan: make(chan struct{}, 1),
ctx: ctx,
cancel: cancel,
}
go conn.runAdvertiser()
go conn.listenForQueries()
return conn, nil
}
func (c *Conn) Close() error {
c.cancel()
c.PacketConn.LeaveGroup(c.iface, &net.UDPAddr{IP: c.groupIP})
return c.rawConn.Close()
}
func (c *Conn) RawConn() net.PacketConn {
return c.rawConn
}
func (c *Conn) runAdvertiser() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
c.sendReport()
for {
select {
case <-c.ctx.Done():
return
case <-c.queryChan:
delay := time.Duration(rand.Intn(1000)) * time.Millisecond
time.Sleep(delay)
c.sendReport()
case <-ticker.C:
c.sendReport()
}
}
}
func (c *Conn) listenForQueries() {
handle, err := pcap.OpenLive(c.iface.Name, 65536, true, 5*time.Second)
if err != nil {
return
}
defer handle.Close()
handle.SetBPFFilter("igmp")
packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
packets := packetSource.Packets()
for {
select {
case <-c.ctx.Done():
return
case packet, ok := <-packets:
if !ok {
return
}
if c.isQuery(packet) {
select {
case c.queryChan <- struct{}{}:
default:
}
}
}
}
}
func (c *Conn) isQuery(packet gopacket.Packet) bool {
igmpLayer := packet.Layer(layers.LayerTypeIGMP)
if igmpLayer == nil {
return false
}
switch igmp := igmpLayer.(type) {
case *layers.IGMPv1or2:
if igmp.Type == layers.IGMPMembershipQuery {
return igmp.GroupAddress.IsUnspecified() || igmp.GroupAddress.Equal(c.groupIP)
}
case *layers.IGMP:
if igmp.Type == layers.IGMPMembershipQuery {
return true
}
}
return false
}
func (c *Conn) sendReport() {
if c.srcIP == nil {
return
}
handle, err := pcap.OpenLive(c.iface.Name, 65536, true, pcap.BlockForever)
if err != nil {
return
}
defer handle.Close()
eth := &layers.Ethernet{
SrcMAC: c.srcMAC,
DstMAC: multicastIPToMAC(c.groupIP),
EthernetType: layers.EthernetTypeIPv4,
}
ip := &layers.IPv4{
Version: 4,
IHL: 6,
TTL: 1,
Protocol: layers.IPProtocolIGMP,
SrcIP: c.srcIP,
DstIP: c.groupIP,
Options: []layers.IPv4Option{{OptionType: 148, OptionLength: 4, OptionData: []byte{0, 0}}},
}
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
gopacket.SerializeLayers(buf, opts, eth, ip, gopacket.Payload(buildIGMPv2Report(c.groupIP)))
handle.WritePacketData(buf.Bytes())
}
func buildIGMPv2Report(groupIP net.IP) []byte {
data := make([]byte, 8)
data[0] = 0x16
data[1] = 0
ip4 := groupIP.To4()
if ip4 != nil {
copy(data[4:8], ip4)
}
checksum := igmpChecksum(data)
data[2] = byte(checksum >> 8)
data[3] = byte(checksum)
return data
}
func buildIGMPQuery() []byte {
data := make([]byte, 8)
data[0] = 0x11
data[1] = 100
checksum := igmpChecksum(data)
data[2] = byte(checksum >> 8)
data[3] = byte(checksum)
return data
}
func igmpChecksum(data []byte) uint16 {
var sum uint32
for i := 0; i < len(data)-1; i += 2 {
sum += uint32(data[i])<<8 | uint32(data[i+1])
}
if len(data)%2 == 1 {
sum += uint32(data[len(data)-1]) << 8
}
for sum > 0xffff {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
func multicastIPToMAC(ip net.IP) net.HardwareAddr {
ip4 := ip.To4()
if ip4 == nil {
return net.HardwareAddr{0x01, 0x00, 0x5e, 0x00, 0x00, 0x01}
}
return net.HardwareAddr{
0x01, 0x00, 0x5e,
ip4[1] & 0x7f,
ip4[2],
ip4[3],
}
}
func getInterfaceIPv4(iface *net.Interface) (net.IP, error) {
addrs, err := iface.Addrs()
if err != nil {
return nil, err
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
if ip4 := ipNet.IP.To4(); ip4 != nil {
return ip4, nil
}
}
}
return nil, nil
}

89
querier.go Normal file
View File

@@ -0,0 +1,89 @@
package multicast
import (
"context"
"net"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/pcap"
)
type Querier struct {
iface *net.Interface
srcIP net.IP
srcMAC net.HardwareAddr
}
func NewQuerier(iface *net.Interface) (*Querier, error) {
srcIP, err := getInterfaceIPv4(iface)
if err != nil {
return nil, err
}
if srcIP == nil {
return nil, nil
}
return &Querier{
iface: iface,
srcIP: srcIP,
srcMAC: iface.HardwareAddr,
}, nil
}
func (q *Querier) Run(ctx context.Context) {
if q == nil {
return
}
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
q.SendQuery()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
q.SendQuery()
}
}
}
func (q *Querier) SendQuery() {
handle, err := pcap.OpenLive(q.iface.Name, 65536, true, pcap.BlockForever)
if err != nil {
return
}
defer handle.Close()
eth := &layers.Ethernet{
SrcMAC: q.srcMAC,
DstMAC: net.HardwareAddr{0x01, 0x00, 0x5e, 0x00, 0x00, 0x01},
EthernetType: layers.EthernetTypeIPv4,
}
ip := &layers.IPv4{
Version: 4,
IHL: 6,
TTL: 1,
Protocol: layers.IPProtocolIGMP,
SrcIP: q.srcIP,
DstIP: net.IPv4(224, 0, 0, 1),
Options: []layers.IPv4Option{{OptionType: 148, OptionLength: 4, OptionData: []byte{0, 0}}},
}
igmpPayload := buildIGMPQuery()
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}
if err := gopacket.SerializeLayers(buf, opts, eth, ip, gopacket.Payload(igmpPayload)); err != nil {
return
}
handle.WritePacketData(buf.Bytes())
}