Skip to content

Commit 610b8c2

Browse files
committed
Don't send mDNS probes on non-multicast, non-loopback, or link-local addresses
1 parent 02d0045 commit 610b8c2

2 files changed

Lines changed: 283 additions & 2 deletions

File tree

internal/airplay/discovery.go

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package airplay
33
import (
44
"context"
55
"fmt"
6+
"net"
67
"strconv"
78
"strings"
89
"time"
@@ -24,7 +25,18 @@ type AirPlayDevice struct {
2425

2526
// DiscoverAirPlayDevices browses the local network for AirPlay receivers.
2627
func DiscoverAirPlayDevices(ctx context.Context) ([]AirPlayDevice, error) {
27-
resolver, err := zeroconf.NewResolver(nil)
28+
ifaces, traffic, err := airPlayMDNSInterfaces()
29+
if err != nil {
30+
return nil, fmt.Errorf("mDNS interfaces: %w", err)
31+
}
32+
if len(ifaces) == 0 {
33+
return nil, nil
34+
}
35+
36+
resolver, err := zeroconf.NewResolver(
37+
zeroconf.SelectIfaces(ifaces),
38+
zeroconf.SelectIPTraffic(traffic),
39+
)
2840
if err != nil {
2941
return nil, fmt.Errorf("zeroconf resolver: %w", err)
3042
}
@@ -52,6 +64,101 @@ func DiscoverAirPlayDevices(ctx context.Context) ([]AirPlayDevice, error) {
5264
return devices, nil
5365
}
5466

67+
func airPlayMDNSInterfaces() ([]net.Interface, zeroconf.IPType, error) {
68+
systemIfaces, err := net.Interfaces()
69+
if err != nil {
70+
return nil, 0, err
71+
}
72+
73+
ifaces := make([]net.Interface, 0, len(systemIfaces))
74+
var traffic zeroconf.IPType
75+
for _, iface := range systemIfaces {
76+
addrs, err := iface.Addrs()
77+
if err != nil {
78+
continue
79+
}
80+
hasIPv4, hasIPv6 := mdnsAddressFamilies(addrs)
81+
if !isAirPlayMDNSInterface(iface, hasIPv4, hasIPv6) {
82+
continue
83+
}
84+
85+
ifaces = append(ifaces, iface)
86+
if hasIPv4 {
87+
traffic |= zeroconf.IPv4
88+
}
89+
if hasIPv6 {
90+
traffic |= zeroconf.IPv6
91+
}
92+
}
93+
94+
return ifaces, traffic, nil
95+
}
96+
97+
func isAirPlayMDNSInterface(iface net.Interface, hasIPv4, hasIPv6 bool) bool {
98+
if iface.Flags&net.FlagUp == 0 {
99+
return false
100+
}
101+
if iface.Flags&net.FlagMulticast == 0 {
102+
return false
103+
}
104+
if iface.Flags&(net.FlagLoopback|net.FlagPointToPoint) != 0 {
105+
return false
106+
}
107+
if isNonLANInterfaceName(iface.Name) {
108+
return false
109+
}
110+
return hasIPv4 || hasIPv6
111+
}
112+
113+
func isNonLANInterfaceName(name string) bool {
114+
name = strings.ToLower(name)
115+
prefixes := [...]string{
116+
"bnep", "bluetooth", "bt", "pan",
117+
"br-", "cilium", "cni", "docker", "flannel", "kube", "podman", "veth", "virbr",
118+
"tailscale", "tap", "tun", "utun", "wg", "zt",
119+
}
120+
for _, prefix := range prefixes {
121+
if strings.HasPrefix(name, prefix) {
122+
return true
123+
}
124+
}
125+
return false
126+
}
127+
128+
func mdnsAddressFamilies(addrs []net.Addr) (hasIPv4, hasIPv6 bool) {
129+
for _, addr := range addrs {
130+
ip := ipFromAddr(addr)
131+
if ip == nil || !isUsableMDNSAddress(ip) {
132+
continue
133+
}
134+
if ip.To4() != nil {
135+
hasIPv4 = true
136+
} else {
137+
hasIPv6 = true
138+
}
139+
}
140+
return hasIPv4, hasIPv6
141+
}
142+
143+
func ipFromAddr(addr net.Addr) net.IP {
144+
switch addr := addr.(type) {
145+
case *net.IPNet:
146+
return addr.IP
147+
case *net.IPAddr:
148+
return addr.IP
149+
default:
150+
return nil
151+
}
152+
}
153+
154+
func isUsableMDNSAddress(ip net.IP) bool {
155+
return ip != nil &&
156+
!ip.IsUnspecified() &&
157+
!ip.IsLoopback() &&
158+
!ip.IsLinkLocalUnicast() &&
159+
!ip.IsMulticast()
160+
}
161+
55162
func parseServiceEntry(entry *zeroconf.ServiceEntry) *AirPlayDevice {
56163
if len(entry.AddrIPv4) == 0 && len(entry.AddrIPv6) == 0 {
57164
return nil

internal/airplay/discovery_test.go

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,161 @@
11
package airplay
22

3-
import "testing"
3+
import (
4+
"net"
5+
"testing"
6+
7+
"github.com/grandcat/zeroconf"
8+
)
9+
10+
func TestIsAirPlayMDNSInterface(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
iface net.Interface
14+
hasIPv4 bool
15+
hasIPv6 bool
16+
want bool
17+
}{
18+
{
19+
name: "ethernet with usable ipv4",
20+
iface: testInterface("enp0s31f6", net.FlagUp|net.FlagBroadcast|net.FlagMulticast),
21+
hasIPv4: true,
22+
want: true,
23+
},
24+
{
25+
name: "wifi with usable ipv6",
26+
iface: testInterface("wlan0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast),
27+
hasIPv6: true,
28+
want: true,
29+
},
30+
{
31+
name: "down interface",
32+
iface: testInterface("eth0", net.FlagBroadcast|net.FlagMulticast),
33+
hasIPv4: true,
34+
want: false,
35+
},
36+
{
37+
name: "loopback interface",
38+
iface: testInterface("lo", net.FlagUp|net.FlagLoopback|net.FlagMulticast),
39+
hasIPv4: true,
40+
want: false,
41+
},
42+
{
43+
name: "point to point tunnel",
44+
iface: testInterface("ppp0", net.FlagUp|net.FlagPointToPoint|net.FlagMulticast),
45+
hasIPv4: true,
46+
want: false,
47+
},
48+
{
49+
name: "bluetooth pan interface",
50+
iface: testInterface("bnep0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast),
51+
hasIPv4: true,
52+
want: false,
53+
},
54+
{
55+
name: "bluetooth interface",
56+
iface: testInterface("bt0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast),
57+
hasIPv4: true,
58+
want: false,
59+
},
60+
{
61+
name: "docker bridge interface",
62+
iface: testInterface("docker0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast),
63+
hasIPv4: true,
64+
want: false,
65+
},
66+
{
67+
name: "no usable addresses",
68+
iface: testInterface("eth0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast),
69+
want: false,
70+
},
71+
{
72+
name: "no multicast support",
73+
iface: testInterface("eth0", net.FlagUp|net.FlagBroadcast),
74+
hasIPv4: true,
75+
want: false,
76+
},
77+
}
78+
79+
for _, tt := range tests {
80+
t.Run(tt.name, func(t *testing.T) {
81+
if got := isAirPlayMDNSInterface(tt.iface, tt.hasIPv4, tt.hasIPv6); got != tt.want {
82+
t.Fatalf("isAirPlayMDNSInterface() = %v, want %v", got, tt.want)
83+
}
84+
})
85+
}
86+
}
87+
88+
func TestMDNSAddressFamilies(t *testing.T) {
89+
tests := []struct {
90+
name string
91+
addrs []net.Addr
92+
wantIPv4 bool
93+
wantIPv6 bool
94+
}{
95+
{
96+
name: "private ipv4 is usable",
97+
addrs: []net.Addr{testIPNet("192.168.1.25")},
98+
wantIPv4: true,
99+
},
100+
{
101+
name: "unique local ipv6 is usable",
102+
addrs: []net.Addr{testIPNet("fd00::25")},
103+
wantIPv6: true,
104+
},
105+
{
106+
name: "link local addresses are ignored",
107+
addrs: []net.Addr{testIPNet("169.254.1.2"), testIPNet("fe80::1")},
108+
},
109+
{
110+
name: "loopback and unspecified addresses are ignored",
111+
addrs: []net.Addr{testIPNet("127.0.0.1"), testIPNet("::"), testIPNet("0.0.0.0")},
112+
},
113+
{
114+
name: "mixed addresses keep usable families",
115+
addrs: []net.Addr{testIPNet("fe80::1"), testIPNet("10.0.0.5"), testIPNet("fd12::5")},
116+
wantIPv4: true,
117+
wantIPv6: true,
118+
},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
gotIPv4, gotIPv6 := mdnsAddressFamilies(tt.addrs)
124+
if gotIPv4 != tt.wantIPv4 || gotIPv6 != tt.wantIPv6 {
125+
t.Fatalf("mdnsAddressFamilies() = (%v, %v), want (%v, %v)", gotIPv4, gotIPv6, tt.wantIPv4, tt.wantIPv6)
126+
}
127+
})
128+
}
129+
}
130+
131+
func TestMDNSTrafficMatchesEligibleInterfaceAddressFamilies(t *testing.T) {
132+
ifaces := []struct {
133+
iface net.Interface
134+
hasIPv4 bool
135+
hasIPv6 bool
136+
}{
137+
{iface: testInterface("eth0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast), hasIPv4: true},
138+
{iface: testInterface("wlan0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast), hasIPv6: true},
139+
{iface: testInterface("bnep0", net.FlagUp|net.FlagBroadcast|net.FlagMulticast), hasIPv4: true},
140+
}
141+
142+
var traffic zeroconf.IPType
143+
for _, candidate := range ifaces {
144+
if !isAirPlayMDNSInterface(candidate.iface, candidate.hasIPv4, candidate.hasIPv6) {
145+
continue
146+
}
147+
if candidate.hasIPv4 {
148+
traffic |= zeroconf.IPv4
149+
}
150+
if candidate.hasIPv6 {
151+
traffic |= zeroconf.IPv6
152+
}
153+
}
154+
155+
if traffic != zeroconf.IPv4AndIPv6 {
156+
t.Fatalf("traffic = %v, want %v", traffic, zeroconf.IPv4AndIPv6)
157+
}
158+
}
4159

5160
func TestUnescapeDNSName(t *testing.T) {
6161
tests := []struct {
@@ -34,6 +189,25 @@ func TestUnescapeDNSName(t *testing.T) {
34189
}
35190
}
36191

192+
func testInterface(name string, flags net.Flags) net.Interface {
193+
return net.Interface{Name: name, Flags: flags}
194+
}
195+
196+
func testIPNet(ip string) *net.IPNet {
197+
parsed := net.ParseIP(ip)
198+
if parsed == nil {
199+
panic("invalid test IP: " + ip)
200+
}
201+
bits := 128
202+
if parsed.To4() != nil {
203+
bits = 32
204+
}
205+
return &net.IPNet{
206+
IP: parsed,
207+
Mask: net.CIDRMask(bits, bits),
208+
}
209+
}
210+
37211
func TestSupportsFairPlaySAP(t *testing.T) {
38212
rokuFeatures := uint64(0x38bcf46007f8ad0)
39213
if (&ReceiverInfo{Features: rokuFeatures}).SupportsFairPlaySAP() {

0 commit comments

Comments
 (0)