Skip to content

Commit aa6f521

Browse files
committed
Support IPv6-only LAN discovery
1 parent ebede91 commit aa6f521

6 files changed

Lines changed: 645 additions & 109 deletions

File tree

Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ public BasisLanIpv4Subnet(IPAddress address, IPAddress mask)
4848
}
4949
}
5050

51+
internal readonly struct BasisLanIpv6Subnet
52+
{
53+
public readonly IPAddress Address;
54+
public readonly int PrefixLength;
55+
56+
public BasisLanIpv6Subnet(IPAddress address, int prefixLength)
57+
{
58+
Address = address;
59+
PrefixLength = prefixLength;
60+
}
61+
}
62+
5163
/// <summary>Shared address filtering and preference rules for Basis LAN discovery.</summary>
5264
public static class BasisLanAddressUtility
5365
{
@@ -190,6 +202,63 @@ internal static BasisLanIpv4Subnet[] GetLocalIpv4Subnets()
190202
return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray();
191203
}
192204

205+
internal static BasisLanIpv6Subnet[] GetLocalIpv6Subnets()
206+
{
207+
List<BasisLanIpv6Subnet> gatewaySubnets = new List<BasisLanIpv6Subnet>();
208+
List<BasisLanIpv6Subnet> fallbackSubnets = new List<BasisLanIpv6Subnet>();
209+
try
210+
{
211+
foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces())
212+
{
213+
if (networkInterface.OperationalStatus != OperationalStatus.Up
214+
|| networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback
215+
|| networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
216+
{
217+
continue;
218+
}
219+
220+
try
221+
{
222+
IPInterfaceProperties properties = networkInterface.GetIPProperties();
223+
bool hasUsableGateway = properties.GatewayAddresses
224+
.Any(gateway => IsUsable(gateway?.Address));
225+
foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses)
226+
{
227+
if (unicast?.Address?.AddressFamily != AddressFamily.InterNetworkV6
228+
|| !IsUsable(unicast.Address))
229+
{
230+
continue;
231+
}
232+
233+
int prefixLength = unicast.PrefixLength;
234+
if (prefixLength <= 0 || prefixLength > 128)
235+
{
236+
// SLAAC uses /64 by default. Use it only when a platform does not
237+
// report a usable prefix length for an otherwise valid IPv6 address.
238+
prefixLength = 64;
239+
}
240+
241+
BasisLanIpv6Subnet subnet =
242+
new BasisLanIpv6Subnet(unicast.Address, prefixLength);
243+
fallbackSubnets.Add(subnet);
244+
if (hasUsableGateway)
245+
{
246+
gatewaySubnets.Add(subnet);
247+
}
248+
}
249+
}
250+
catch
251+
{
252+
// Ignore interfaces that disappear or reject property queries mid-enumeration.
253+
}
254+
}
255+
}
256+
catch
257+
{
258+
}
259+
return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray();
260+
}
261+
193262
internal static bool IsOnSameIpv4Subnet(
194263
IPAddress candidate,
195264
BasisLanIpv4Subnet subnet)
@@ -214,6 +283,49 @@ internal static bool IsOnSameIpv4Subnet(
214283
}
215284
return true;
216285
}
286+
287+
internal static bool IsOnSameIpv6Subnet(
288+
IPAddress candidate,
289+
BasisLanIpv6Subnet subnet)
290+
{
291+
if (candidate?.AddressFamily != AddressFamily.InterNetworkV6
292+
|| subnet.Address?.AddressFamily != AddressFamily.InterNetworkV6
293+
|| subnet.PrefixLength <= 0
294+
|| subnet.PrefixLength > 128)
295+
{
296+
return false;
297+
}
298+
299+
if (candidate.IsIPv6LinkLocal
300+
&& candidate.ScopeId != 0
301+
&& subnet.Address.ScopeId != 0
302+
&& candidate.ScopeId != subnet.Address.ScopeId)
303+
{
304+
return false;
305+
}
306+
307+
byte[] candidateBytes = candidate.GetAddressBytes();
308+
byte[] localBytes = subnet.Address.GetAddressBytes();
309+
int wholeBytes = subnet.PrefixLength / 8;
310+
int remainingBits = subnet.PrefixLength % 8;
311+
312+
for (int index = 0; index < wholeBytes; index++)
313+
{
314+
if (candidateBytes[index] != localBytes[index])
315+
{
316+
return false;
317+
}
318+
}
319+
320+
if (remainingBits == 0)
321+
{
322+
return true;
323+
}
324+
325+
int mask = 0xFF << (8 - remainingBits);
326+
return (candidateBytes[wholeBytes] & mask)
327+
== (localBytes[wholeBytes] & mask);
328+
}
217329
}
218330

219331
/// <summary>Shared constants and bounded TXT metadata encoding for Basis LAN DNS-SD.</summary>

Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs

Lines changed: 122 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -26,27 +26,38 @@ public sealed class BasisLanServerBrowser : IDisposable
2626
private readonly Action<BasisLanAdvertisement, IPAddress> _found;
2727
private readonly Action<Guid> _removed;
2828
private readonly BasisLanIpv4Subnet[] _localIpv4Subnets;
29+
private readonly BasisLanIpv6Subnet[] _localIpv6Subnets;
2930
private MulticastService _mdns;
3031
private ServiceDiscovery _discovery;
3132
private volatile bool _disposed;
3233

3334
public BasisLanServerBrowser(
3435
Action<BasisLanAdvertisement, IPAddress> found,
3536
Action<Guid> removed,
37+
bool useIpv4 = true,
3638
bool useIpv6 = true)
3739
{
3840
_found = found ?? throw new ArgumentNullException(nameof(found));
3941
_removed = removed ?? throw new ArgumentNullException(nameof(removed));
4042
_localIpv4Subnets = BasisLanAddressUtility.GetLocalIpv4Subnets();
43+
_localIpv6Subnets = BasisLanAddressUtility.GetLocalIpv6Subnets();
44+
45+
bool ipv4Enabled = useIpv4 && Socket.OSSupportsIPv4;
46+
bool ipv6Enabled = useIpv6 && Socket.OSSupportsIPv6;
47+
if (!ipv4Enabled && !ipv6Enabled)
48+
{
49+
throw new PlatformNotSupportedException(
50+
"LAN discovery requires at least one supported IP address family.");
51+
}
4152

4253
MulticastService mdns = null;
4354
ServiceDiscovery discovery = null;
4455
try
4556
{
4657
mdns = new MulticastService
4758
{
48-
UseIpv4 = Socket.OSSupportsIPv4,
49-
UseIpv6 = useIpv6 && Socket.OSSupportsIPv6,
59+
UseIpv4 = ipv4Enabled,
60+
UseIpv6 = ipv6Enabled,
5061
IgnoreDuplicateMessages = true,
5162
};
5263
discovery = new ServiceDiscovery(mdns);
@@ -139,7 +150,8 @@ private void OnServiceDiscovered(object sender, ServiceInstanceDiscoveryEventArg
139150
args.RemoteEndPoint?.Address,
140151
out BasisLanAdvertisement advertisement,
141152
out IPAddress address,
142-
_localIpv4Subnets))
153+
_localIpv4Subnets,
154+
_localIpv6Subnets))
143155
{
144156
_found(advertisement, address);
145157
}
@@ -164,7 +176,8 @@ internal static bool TryExtractAdvertisement(
164176
IPAddress remoteAddress,
165177
out BasisLanAdvertisement advertisement,
166178
out IPAddress address,
167-
IReadOnlyList<BasisLanIpv4Subnet> localIpv4Subnets = null)
179+
IReadOnlyList<BasisLanIpv4Subnet> localIpv4Subnets = null,
180+
IReadOnlyList<BasisLanIpv6Subnet> localIpv6Subnets = null)
168181
{
169182
advertisement = default;
170183
address = null;
@@ -210,7 +223,12 @@ internal static bool TryExtractAdvertisement(
210223
return false;
211224
}
212225

213-
address = SelectAddress(records, service.Target, remoteAddress, localIpv4Subnets);
226+
address = SelectAddress(
227+
records,
228+
service.Target,
229+
remoteAddress,
230+
localIpv4Subnets,
231+
localIpv6Subnets);
214232
if (address == null)
215233
{
216234
return false;
@@ -294,17 +312,17 @@ private static IPAddress SelectAddress(
294312
IEnumerable<ResourceRecord> records,
295313
DomainName hostName,
296314
IPAddress remoteAddress,
297-
IReadOnlyList<BasisLanIpv4Subnet> localIpv4Subnets)
315+
IReadOnlyList<BasisLanIpv4Subnet> localIpv4Subnets,
316+
IReadOnlyList<BasisLanIpv6Subnet> localIpv6Subnets)
298317
{
299-
if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)
300-
&& remoteAddress.AddressFamily == AddressFamily.InterNetwork)
301-
{
302-
// A usable IPv4 response source is the interface that actually reached us.
303-
return remoteAddress;
304-
}
318+
IPAddress selected = BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)
319+
? remoteAddress
320+
: null;
321+
bool selectedOnLocalSubnet = IsOnLocalSubnet(
322+
selected,
323+
localIpv4Subnets,
324+
localIpv6Subnets);
305325

306-
IPAddress selected = null;
307-
bool selectedOnLocalSubnet = false;
308326
foreach (ResourceRecord record in records)
309327
{
310328
if (!(record is AddressRecord addressRecord)
@@ -314,8 +332,14 @@ private static IPAddress SelectAddress(
314332
continue;
315333
}
316334

317-
IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress);
318-
bool candidateOnLocalSubnet = IsOnLocalIpv4Subnet(candidate, localIpv4Subnets);
335+
IPAddress candidate = RestoreScope(
336+
addressRecord.Address,
337+
remoteAddress,
338+
localIpv6Subnets);
339+
bool candidateOnLocalSubnet = IsOnLocalSubnet(
340+
candidate,
341+
localIpv4Subnets,
342+
localIpv6Subnets);
319343
if (selected == null
320344
|| (candidateOnLocalSubnet && !selectedOnLocalSubnet)
321345
|| (candidateOnLocalSubnet == selectedOnLocalSubnet
@@ -327,64 +351,117 @@ private static IPAddress SelectAddress(
327351
}
328352
}
329353

330-
if (selected?.AddressFamily == AddressFamily.InterNetwork
331-
&& localIpv4Subnets?.Count > 0
354+
if (selected != null
355+
&& HasLocalSubnetsForFamily(selected, localIpv4Subnets, localIpv6Subnets)
332356
&& !selectedOnLocalSubnet)
333357
{
334358
// Do not create an entry from a partial response that only contains an unrelated
335-
// virtual/VPN subnet. A later complete response can provide the same-subnet address.
336-
selected = null;
337-
}
338-
339-
if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true))
340-
{
341-
// Android can receive an IPv6 mDNS response while the hosted LiteNetLib server
342-
// is reachable over IPv4. Prefer a same-subnet advertised IPv4 in that case.
343-
if (!selectedOnLocalSubnet
344-
&& selected?.AddressFamily != AddressFamily.InterNetwork)
345-
{
346-
return remoteAddress;
347-
}
359+
// virtual/VPN subnet. A later complete response can provide the same-link address.
360+
return null;
348361
}
349362

350363
return selected;
351364
}
352365

353-
private static bool IsOnLocalIpv4Subnet(
366+
private static bool HasLocalSubnetsForFamily(
367+
IPAddress address,
368+
IReadOnlyList<BasisLanIpv4Subnet> localIpv4Subnets,
369+
IReadOnlyList<BasisLanIpv6Subnet> localIpv6Subnets)
370+
{
371+
return address?.AddressFamily == AddressFamily.InterNetwork
372+
? localIpv4Subnets?.Count > 0
373+
: address?.AddressFamily == AddressFamily.InterNetworkV6
374+
&& localIpv6Subnets?.Count > 0;
375+
}
376+
377+
private static bool IsOnLocalSubnet(
354378
IPAddress candidate,
355-
IReadOnlyList<BasisLanIpv4Subnet> localIpv4Subnets)
379+
IReadOnlyList<BasisLanIpv4Subnet> localIpv4Subnets,
380+
IReadOnlyList<BasisLanIpv6Subnet> localIpv6Subnets)
356381
{
357-
if (candidate?.AddressFamily != AddressFamily.InterNetwork
358-
|| localIpv4Subnets == null)
382+
if (candidate?.AddressFamily == AddressFamily.InterNetwork)
383+
{
384+
if (localIpv4Subnets == null)
385+
{
386+
return false;
387+
}
388+
389+
for (int index = 0; index < localIpv4Subnets.Count; index++)
390+
{
391+
if (BasisLanAddressUtility.IsOnSameIpv4Subnet(
392+
candidate,
393+
localIpv4Subnets[index]))
394+
{
395+
return true;
396+
}
397+
}
398+
return false;
399+
}
400+
401+
if (candidate?.AddressFamily != AddressFamily.InterNetworkV6
402+
|| localIpv6Subnets == null
403+
|| (candidate.IsIPv6LinkLocal && candidate.ScopeId == 0))
359404
{
360405
return false;
361406
}
362407

363-
for (int index = 0; index < localIpv4Subnets.Count; index++)
408+
for (int index = 0; index < localIpv6Subnets.Count; index++)
364409
{
365-
if (BasisLanAddressUtility.IsOnSameIpv4Subnet(
410+
if (BasisLanAddressUtility.IsOnSameIpv6Subnet(
366411
candidate,
367-
localIpv4Subnets[index]))
412+
localIpv6Subnets[index]))
368413
{
369414
return true;
370415
}
371416
}
372417
return false;
373418
}
374419

375-
private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress)
420+
private static IPAddress RestoreScope(
421+
IPAddress address,
422+
IPAddress remoteAddress,
423+
IReadOnlyList<BasisLanIpv6Subnet> localIpv6Subnets)
376424
{
377-
if (address != null
378-
&& address.AddressFamily == AddressFamily.InterNetworkV6
379-
&& address.IsIPv6LinkLocal
380-
&& address.ScopeId == 0
381-
&& remoteAddress != null
425+
if (address == null
426+
|| address.AddressFamily != AddressFamily.InterNetworkV6
427+
|| !address.IsIPv6LinkLocal
428+
|| address.ScopeId != 0)
429+
{
430+
return address;
431+
}
432+
433+
if (remoteAddress != null
382434
&& remoteAddress.AddressFamily == AddressFamily.InterNetworkV6
383435
&& remoteAddress.ScopeId != 0)
384436
{
385437
return new IPAddress(address.GetAddressBytes(), remoteAddress.ScopeId);
386438
}
387-
return address;
439+
440+
long selectedScope = 0;
441+
if (localIpv6Subnets != null)
442+
{
443+
for (int index = 0; index < localIpv6Subnets.Count; index++)
444+
{
445+
BasisLanIpv6Subnet subnet = localIpv6Subnets[index];
446+
if (subnet.Address?.ScopeId == 0
447+
|| !BasisLanAddressUtility.IsOnSameIpv6Subnet(address, subnet))
448+
{
449+
continue;
450+
}
451+
452+
if (selectedScope != 0 && selectedScope != subnet.Address.ScopeId)
453+
{
454+
// Multiple interfaces share fe80::/64 and the response did not identify
455+
// which one received it. Leave it unscoped rather than choosing incorrectly.
456+
return address;
457+
}
458+
selectedScope = subnet.Address.ScopeId;
459+
}
460+
}
461+
462+
return selectedScope == 0
463+
? address
464+
: new IPAddress(address.GetAddressBytes(), selectedScope);
388465
}
389466

390467
public void Dispose()

0 commit comments

Comments
 (0)