From 4d34057ea8f42a9b88747bb4e81617efae8a0043 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 01:27:39 +0000 Subject: [PATCH 01/25] Add LAN server discovery --- .../Plugins/Android/AndroidManifest.xml | 1 + .../BasisUI/Localization/Languages/en.json | 24 + .../Networking/BasisConnectionService.cs | 7 +- .../Networking/BasisLanDiscoveryProtocol.cs | 791 ++++++++++++++++++ .../BasisLanDiscoveryProtocol.cs.meta | 2 + .../Networking/BasisLanMdnsDiscovery.cs | 418 +++++++++ .../Networking/BasisLanMdnsDiscovery.cs.meta | 2 + .../Networking/BasisLanMdnsWire.cs | 592 +++++++++++++ .../Networking/BasisLanMdnsWire.cs.meta | 2 + .../Networking/BasisNetworkManagement.cs | 6 + .../Networking/BasisNetworkServerRunner.cs | 33 +- .../Runtime/ServersProvider.cs | 158 +++- 12 files changed, 2020 insertions(+), 16 deletions(-) create mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs create mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs.meta create mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs create mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta create mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs create mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta diff --git a/Basis/Assets/Plugins/Android/AndroidManifest.xml b/Basis/Assets/Plugins/Android/AndroidManifest.xml index 59fe93ef62..ea2d399349 100644 --- a/Basis/Assets/Plugins/Android/AndroidManifest.xml +++ b/Basis/Assets/Plugins/Android/AndroidManifest.xml @@ -27,4 +27,5 @@ + diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json index 51299ed632..c714cc540e 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json @@ -2210,6 +2210,30 @@ "key": "menu.servers.source.savedServers", "value": "Saved Servers" }, + { + "key": "menu.servers.source.lanServers", + "value": "LAN Servers" + }, + { + "key": "menu.servers.hostShowToLan", + "value": "Show to LAN" + }, + { + "key": "menu.servers.hostShowToLan.description", + "value": "Advertise this hosted server to Basis clients on your local network. Password-protected servers ask nearby clients for the password when they connect; the password is never included in LAN announcements." + }, + { + "key": "menu.servers.lanPassword.title", + "value": "Join {0}" + }, + { + "key": "menu.servers.lanPassword.description", + "value": "This LAN server requires a password." + }, + { + "key": "menu.servers.list.lanBadge", + "value": "{0} (LAN)" + }, { "key": "menu.servers.status.connecting", "value": "Connecting" diff --git a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs index 6843577c80..a4654e95eb 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs @@ -111,7 +111,12 @@ public static async Task ConnectAsync(ServerDirectoryEntry entry, string userNam BasisLocalPlayer.Instance.DisplayName = userName.Trim(); BasisLocalPlayer.Instance.SetSafeDisplayname(); BasisDataStore.SaveString(BasisLocalPlayer.Instance.DisplayName, UsernameFileName); - BasisDataStore.SaveString(entry.Id, LastConnectedServerIdFile); + if (!string.Equals(entry.SourceId, LanServersDirectorySource.Id, StringComparison.OrdinalIgnoreCase)) + { + // LAN advertisements are session-scoped and their passwords are never persisted. + // Keep the previous durable auto-connect target instead of saving an expiring LAN id. + BasisDataStore.SaveString(entry.Id, LastConnectedServerIdFile); + } string address = entry.Target?.Get(ConnectionTarget.Keys.Address) ?? string.Empty; string portString = entry.Target?.Get(ConnectionTarget.Keys.Port) ?? string.Empty; diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs new file mode 100644 index 0000000000..436ff41301 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs @@ -0,0 +1,791 @@ +using Basis.Network.Core; +using Basis.Scripts.Device_Management; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace Basis.Scripts.Networking +{ + /// + /// Shared wire format for local-network server advertisements. + /// Advertisements use a site-local multicast address plus directed IPv4 broadcasts, + /// remain inside the LAN, and contain the hosted server's actual game port. + /// + internal static class BasisLanDiscoveryProtocol + { + public const int DiscoveryPort = 42960; + public const uint Magic = 0xBA515201u; + public const ushort Version = 1; + public const int MaxPacketBytes = 1024; + public const int MaxStackIdBytes = 64; + public const int MaxServerNameBytes = 128; + public const int MaxMotdBytes = 384; + + public static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.42.99"); + + internal readonly struct Advertisement + { + public readonly Guid InstanceId; + public readonly ushort ServerPort; + public readonly bool RequiresPassword; + public readonly string NetworkStackId; + public readonly string ServerName; + public readonly string Motd; + + public Advertisement(Guid instanceId, ushort serverPort, bool requiresPassword, string networkStackId, string serverName, string motd) + { + InstanceId = instanceId; + ServerPort = serverPort; + RequiresPassword = requiresPassword; + NetworkStackId = networkStackId; + ServerName = serverName; + Motd = motd; + } + } + + public static byte[] Serialize(Advertisement advertisement) + { + using MemoryStream stream = new MemoryStream(256); + using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); + writer.Write(Magic); + writer.Write(Version); + writer.Write(advertisement.InstanceId.ToByteArray()); + writer.Write(advertisement.ServerPort); + writer.Write(advertisement.RequiresPassword ? (byte)1 : (byte)0); + WriteString(writer, advertisement.NetworkStackId, MaxStackIdBytes); + WriteString(writer, advertisement.ServerName, MaxServerNameBytes); + WriteString(writer, advertisement.Motd, MaxMotdBytes); + writer.Flush(); + return stream.ToArray(); + } + + public static bool TryDeserialize(byte[] data, out Advertisement advertisement) + { + advertisement = default; + if (data == null || data.Length < 31 || data.Length > MaxPacketBytes) + { + return false; + } + + try + { + using MemoryStream stream = new MemoryStream(data, false); + using BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true); + if (reader.ReadUInt32() != Magic || reader.ReadUInt16() != Version) + { + return false; + } + + byte[] guidBytes = reader.ReadBytes(16); + if (guidBytes.Length != 16) + { + return false; + } + + ushort serverPort = reader.ReadUInt16(); + byte flags = reader.ReadByte(); + if (serverPort == 0 + || (flags & ~1) != 0 + || !TryReadString(reader, stream, MaxStackIdBytes, out string stackId) + || !TryReadString(reader, stream, MaxServerNameBytes, out string serverName) + || !TryReadString(reader, stream, MaxMotdBytes, out string motd)) + { + return false; + } + + advertisement = new Advertisement(new Guid(guidBytes), serverPort, (flags & 1) != 0, stackId, serverName, motd); + return advertisement.InstanceId != Guid.Empty; + } + catch (Exception) + { + return false; + } + } + + private static void WriteString(BinaryWriter writer, string value, int maxBytes) + { + string safeValue = value ?? string.Empty; + byte[] bytes = Encoding.UTF8.GetBytes(safeValue); + if (bytes.Length > maxBytes) + { + int characterCount = safeValue.Length; + do + { + characterCount--; + bytes = Encoding.UTF8.GetBytes(safeValue.Substring(0, characterCount)); + } + while (bytes.Length > maxBytes && characterCount > 0); + } + + writer.Write((ushort)bytes.Length); + writer.Write(bytes); + } + + private static bool TryReadString(BinaryReader reader, MemoryStream stream, int maxBytes, out string value) + { + value = string.Empty; + if (stream.Length - stream.Position < sizeof(ushort)) + { + return false; + } + + ushort byteCount = reader.ReadUInt16(); + if (byteCount > maxBytes || stream.Length - stream.Position < byteCount) + { + return false; + } + + byte[] bytes = reader.ReadBytes(byteCount); + if (bytes.Length != byteCount) + { + return false; + } + + value = Encoding.UTF8.GetString(bytes); + return true; + } + } + + /// + /// Periodically announces an in-process hosted server through the custom LAN + /// datagram protocol and standard mDNS/DNS-SD. The service is opt-in and is + /// stopped with the hosted server lifecycle. + /// + public static class BasisLanServerAdvertiser + { + private const int InitialAdvertisementDelayMs = 750; + private const int AdvertisementIntervalMs = 1500; + private static readonly object Gate = new object(); + private static CancellationTokenSource _cancellation; + private static BasisLanMdnsAdvertiser _mdnsAdvertiser; + + public static bool IsRunning + { + get + { + lock (Gate) + { + return _cancellation != null; + } + } + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStatics() => Stop(); + + public static void Start(ushort serverPort, string networkStackId, string serverName, string motd, bool requiresPassword) + { + string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) + ? BasisNetworkStackRegistry.DefaultId + : networkStackId; + BasisLanDiscoveryProtocol.Advertisement advertisement = new BasisLanDiscoveryProtocol.Advertisement( + Guid.NewGuid(), + serverPort, + requiresPassword, + effectiveStackId, + string.IsNullOrWhiteSpace(serverName) ? "Basis Server" : serverName, + motd ?? string.Empty); + byte[] payload = BasisLanDiscoveryProtocol.Serialize(advertisement); + BasisLanMdnsAdvertiser mdnsAdvertiser = null; + try + { + mdnsAdvertiser = new BasisLanMdnsAdvertiser(advertisement); + } + catch (Exception ex) + { + BasisDebug.LogWarning($"LAN mDNS advertisement could not start: {ex.Message}"); + } + + CancellationTokenSource cancellation = new CancellationTokenSource(); + CancellationTokenSource previousCancellation; + BasisLanMdnsAdvertiser previousMdnsAdvertiser; + lock (Gate) + { + previousCancellation = _cancellation; + previousMdnsAdvertiser = _mdnsAdvertiser; + _cancellation = cancellation; + _mdnsAdvertiser = mdnsAdvertiser; + } + + StopResources(previousCancellation, previousMdnsAdvertiser); + _ = Task.Run(() => AdvertiseAsync(payload, cancellation.Token)); + } + + public static void Stop() + { + CancellationTokenSource cancellation; + BasisLanMdnsAdvertiser mdnsAdvertiser; + lock (Gate) + { + cancellation = _cancellation; + mdnsAdvertiser = _mdnsAdvertiser; + _cancellation = null; + _mdnsAdvertiser = null; + } + + StopResources(cancellation, mdnsAdvertiser); + } + + private static void StopResources(CancellationTokenSource cancellation, BasisLanMdnsAdvertiser mdnsAdvertiser) + { + if (cancellation != null) + { + try { cancellation.Cancel(); } + catch (ObjectDisposedException) { } + cancellation.Dispose(); + } + mdnsAdvertiser?.Dispose(); + } + + private static async Task AdvertiseAsync(byte[] payload, CancellationToken cancellationToken) + { + try + { + using UdpClient sender = new UdpClient(AddressFamily.InterNetwork); + sender.EnableBroadcast = true; + sender.MulticastLoopback = true; + sender.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); + + await Task.Delay(InitialAdvertisementDelayMs, cancellationToken).ConfigureAwait(false); + while (!cancellationToken.IsCancellationRequested) + { + SendAdvertisement(sender, payload); + await Task.Delay(AdvertisementIntervalMs, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + BasisDebug.LogWarning($"LAN server advertisement stopped: {ex.Message}"); + } + } + + private static void SendAdvertisement(UdpClient sender, byte[] payload) + { + HashSet sentEndpoints = new HashSet(StringComparer.Ordinal); + SendTo(sender, payload, new IPEndPoint(BasisLanDiscoveryProtocol.MulticastAddress, BasisLanDiscoveryProtocol.DiscoveryPort), sentEndpoints); + SendTo(sender, payload, new IPEndPoint(IPAddress.Broadcast, BasisLanDiscoveryProtocol.DiscoveryPort), sentEndpoints); + + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + foreach (UnicastIPAddressInformation addressInformation in networkInterface.GetIPProperties().UnicastAddresses) + { + if (addressInformation.Address.AddressFamily != AddressFamily.InterNetwork + || addressInformation.IPv4Mask == null) + { + continue; + } + + byte[] addressBytes = addressInformation.Address.GetAddressBytes(); + byte[] maskBytes = addressInformation.IPv4Mask.GetAddressBytes(); + if (addressBytes.Length != 4 || maskBytes.Length != 4) + { + continue; + } + + byte[] broadcastBytes = new byte[4]; + for (int i = 0; i < broadcastBytes.Length; i++) + { + broadcastBytes[i] = (byte)(addressBytes[i] | ~maskBytes[i]); + } + + SendTo(sender, payload, new IPEndPoint(new IPAddress(broadcastBytes), BasisLanDiscoveryProtocol.DiscoveryPort), sentEndpoints); + } + } + } + catch (Exception) + { + // Multicast and limited broadcast were already attempted above. Some Unity + // platforms expose NetworkInterface but not its IPv4 mask details. + } + } + + private static void SendTo(UdpClient sender, byte[] payload, IPEndPoint endpoint, HashSet sentEndpoints) + { + if (!sentEndpoints.Add(endpoint.ToString())) + { + return; + } + + try { sender.Send(payload, payload.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + /// + /// Server-directory source backed by LAN advertisements. Entries expire automatically + /// when their host stops announcing, matching Minecraft-style LAN discovery behavior. + /// + public sealed class LanServersDirectorySource : IServerDirectorySource, IDisposable + { + public const string Id = "lanServers"; + private const int EntryLifetimeMs = 5000; + private const int CleanupIntervalMs = 1000; + private const int MaxTrackedServers = 256; + private static readonly long EntryLifetimeTicks = + (long)(EntryLifetimeMs / 1000.0 * Stopwatch.Frequency); + + private sealed class DiscoveredServer + { + public Guid InstanceId; + public IPAddress Address; + public ushort Port; + public bool RequiresPassword; + public string NetworkStackId; + public string ServerName; + public string Motd; + public long LastSeenTicks; + } + + private readonly object _gate = new object(); + private readonly Dictionary _servers = new Dictionary(); + private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); + private UdpClient _listener; + private BasisLanMdnsBrowser _mdnsBrowser; +#if UNITY_ANDROID && !UNITY_EDITOR + private AndroidJavaObject _androidMulticastLock; +#endif + private int _notificationQueued; + private volatile bool _disposed; + + public static LanServersDirectorySource Instance { get; private set; } + + public string SourceId => Id; + public string DisplayName => Basis.BasisUI.BasisLocalization.Get("menu.servers.source.lanServers"); + public bool SupportsAdd => false; + public event Action SourceChanged; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStatics() + { + Application.quitting -= Shutdown; + Instance?.Dispose(); + Instance = null; + BasisServerDirectoryRegistry.Unregister(Id); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void AutoRegister() + { +#if !UNITY_SERVER + Initialize(); +#endif + } + + public static void Initialize() + { + if (Instance != null) + { + return; + } + + Instance = new LanServersDirectorySource(); + BasisServerDirectoryRegistry.Register(Instance); + Application.quitting += Shutdown; + Instance.StartListening(); + } + + private static void Shutdown() + { + Application.quitting -= Shutdown; + Instance?.Dispose(); + Instance = null; + BasisServerDirectoryRegistry.Unregister(Id); + } + + public Task> ListAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + List entries = new List(); + bool removedExpired; + lock (_gate) + { + removedExpired = PruneExpiredLocked(Stopwatch.GetTimestamp()); + foreach (DiscoveredServer server in _servers.Values) + { + entries.Add(BuildEntry(server)); + } + } + + if (removedExpired) + { + QueueSourceChanged(); + } + return Task.FromResult>(entries); + } + + public Task RefreshAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (PruneExpired()) + { + QueueSourceChanged(); + } + return Task.CompletedTask; + } + + private void StartListening() + { + AcquireAndroidMulticastLock(); + bool discoveryStarted = false; + UdpClient listener = null; + try + { + listener = new UdpClient(AddressFamily.InterNetwork); + listener.Client.ExclusiveAddressUse = false; + listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + listener.Client.Bind(new IPEndPoint(IPAddress.Any, BasisLanDiscoveryProtocol.DiscoveryPort)); + try { listener.JoinMulticastGroup(BasisLanDiscoveryProtocol.MulticastAddress); } + catch (Exception ex) when (ex is SocketException || ex is PlatformNotSupportedException || ex is NotImplementedException) { } + + _listener = listener; + UdpClient activeListener = listener; + listener = null; + discoveryStarted = true; + _ = Task.Run(() => ReceiveLoopAsync(activeListener, _cancellation.Token)); + } + catch (Exception ex) + { + BasisDebug.LogWarning($"LAN server discovery could not listen on UDP {BasisLanDiscoveryProtocol.DiscoveryPort}: {ex.Message}"); + } + finally + { + listener?.Dispose(); + } + + try + { + _mdnsBrowser = new BasisLanMdnsBrowser(ProcessAdvertisement, RemoveAdvertisement); + discoveryStarted = true; + } + catch (Exception ex) + { + BasisDebug.LogWarning($"LAN mDNS discovery could not start: {ex.Message}"); + } + + if (discoveryStarted) + { + _ = Task.Run(() => CleanupLoopAsync(_cancellation.Token)); + } + else + { + ReleaseAndroidMulticastLock(); + } + } + + private void AcquireAndroidMulticastLock() + { +#if UNITY_ANDROID && !UNITY_EDITOR + try + { + using AndroidJavaClass unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); + using AndroidJavaObject activity = unityPlayer.GetStatic("currentActivity"); + using AndroidJavaObject applicationContext = activity.Call("getApplicationContext"); + using AndroidJavaObject wifiManager = applicationContext.Call("getSystemService", "wifi"); + _androidMulticastLock = wifiManager.Call("createMulticastLock", "BasisLanDiscovery"); + _androidMulticastLock.Call("setReferenceCounted", false); + _androidMulticastLock.Call("acquire"); + } + catch (Exception ex) + { + _androidMulticastLock?.Dispose(); + _androidMulticastLock = null; + BasisDebug.LogWarning($"Could not acquire Android LAN discovery multicast lock: {ex.Message}"); + } +#endif + } + + private void ReleaseAndroidMulticastLock() + { +#if UNITY_ANDROID && !UNITY_EDITOR + if (_androidMulticastLock == null) + { + return; + } + + try + { + if (_androidMulticastLock.Call("isHeld")) + { + _androidMulticastLock.Call("release"); + } + } + catch (Exception ex) + { + BasisDebug.LogWarning($"Could not release Android LAN discovery multicast lock: {ex.Message}"); + } + finally + { + _androidMulticastLock.Dispose(); + _androidMulticastLock = null; + } +#endif + } + + private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + UdpReceiveResult result = await listener.ReceiveAsync().ConfigureAwait(false); + if (result.RemoteEndPoint == null + || result.RemoteEndPoint.Address == null + || !BasisLanDiscoveryProtocol.TryDeserialize(result.Buffer, out BasisLanDiscoveryProtocol.Advertisement advertisement)) + { + continue; + } + + ProcessAdvertisement(advertisement, result.RemoteEndPoint.Address); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BasisDebug.LogWarning($"LAN server discovery receive failed: {ex.Message}"); + } + } + } + } + + private void ProcessAdvertisement(BasisLanDiscoveryProtocol.Advertisement advertisement, IPAddress address) + { + if (_disposed || address == null) + { + return; + } + + bool changed; + long nowTicks = Stopwatch.GetTimestamp(); + lock (_gate) + { + changed = PruneExpiredLocked(nowTicks); + changed |= !_servers.TryGetValue(advertisement.InstanceId, out DiscoveredServer existing); + if (existing == null) + { + if (_servers.Count >= MaxTrackedServers) + { + RemoveOldestServerLocked(); + } + existing = new DiscoveredServer { InstanceId = advertisement.InstanceId }; + _servers.Add(advertisement.InstanceId, existing); + } + else + { + changed |= !Equals(existing.Address, address) + || existing.Port != advertisement.ServerPort + || existing.RequiresPassword != advertisement.RequiresPassword + || !string.Equals(existing.NetworkStackId, advertisement.NetworkStackId, StringComparison.Ordinal) + || !string.Equals(existing.ServerName, advertisement.ServerName, StringComparison.Ordinal) + || !string.Equals(existing.Motd, advertisement.Motd, StringComparison.Ordinal); + } + + existing.Address = address; + existing.Port = advertisement.ServerPort; + existing.RequiresPassword = advertisement.RequiresPassword; + existing.NetworkStackId = advertisement.NetworkStackId; + existing.ServerName = advertisement.ServerName; + existing.Motd = advertisement.Motd; + existing.LastSeenTicks = nowTicks; + } + + if (changed) + { + QueueSourceChanged(); + } + } + + private void RemoveAdvertisement(Guid instanceId) + { + bool removed; + lock (_gate) + { + removed = _servers.Remove(instanceId); + } + if (removed) + { + QueueSourceChanged(); + } + } + + private async Task CleanupLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(CleanupIntervalMs, cancellationToken).ConfigureAwait(false); + if (PruneExpired()) + { + QueueSourceChanged(); + } + } + } + catch (OperationCanceledException) + { + } + } + + private bool PruneExpired() + { + lock (_gate) + { + return PruneExpiredLocked(Stopwatch.GetTimestamp()); + } + } + + private bool PruneExpiredLocked(long nowTicks) + { + List expired = null; + foreach (KeyValuePair pair in _servers) + { + if (nowTicks - pair.Value.LastSeenTicks <= EntryLifetimeTicks) + { + continue; + } + + expired ??= new List(); + expired.Add(pair.Key); + } + + if (expired == null) + { + return false; + } + + foreach (Guid id in expired) + { + _servers.Remove(id); + } + return true; + } + + private void RemoveOldestServerLocked() + { + Guid oldestId = Guid.Empty; + long oldestTicks = long.MaxValue; + foreach (KeyValuePair pair in _servers) + { + if (pair.Value.LastSeenTicks < oldestTicks) + { + oldestTicks = pair.Value.LastSeenTicks; + oldestId = pair.Key; + } + } + + if (oldestId != Guid.Empty) + { + _servers.Remove(oldestId); + } + } + + private static ServerDirectoryEntry BuildEntry(DiscoveredServer server) + { + string address = server.Address.ToString(); + string stackId = string.IsNullOrWhiteSpace(server.NetworkStackId) + || !BasisNetworkStackRegistry.IsRegistered(server.NetworkStackId) + ? BasisNetworkStackRegistry.DefaultId + : server.NetworkStackId; + string rawAddress = server.Address.AddressFamily == AddressFamily.InterNetworkV6 + ? $"[{address}]:{server.Port}" + : $"{address}:{server.Port}"; + ConnectionTarget target = new ConnectionTarget(stackId, rawAddress); + target.Set(ConnectionTarget.Keys.Address, address); + target.Set(ConnectionTarget.Keys.Port, server.Port.ToString(System.Globalization.CultureInfo.InvariantCulture)); + target.Set(ConnectionTarget.Keys.Password, string.Empty); + + return new ServerDirectoryEntry + { + Id = $"lan:{server.InstanceId:N}", + SourceId = Id, + DisplayName = string.IsNullOrWhiteSpace(server.ServerName) ? address : server.ServerName, + Description = server.Motd ?? string.Empty, + Password = string.Empty, + HasPassword = server.RequiresPassword, + Target = target, + CanEdit = false, + CanRemove = false, + }; + } + + private void QueueSourceChanged() + { + if (Interlocked.Exchange(ref _notificationQueued, 1) != 0) + { + return; + } + + BasisDeviceManagement.EnqueueOnMainThread(() => + { + Interlocked.Exchange(ref _notificationQueued, 0); + if (_disposed) + { + return; + } + + try { SourceChanged?.Invoke(); } + catch (Exception ex) { BasisDebug.LogError($"LanServersDirectorySource.SourceChanged threw: {ex.Message}"); } + }); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + try { _listener?.DropMulticastGroup(BasisLanDiscoveryProtocol.MulticastAddress); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + catch (PlatformNotSupportedException) { } + catch (NotImplementedException) { } + try { _listener?.Close(); } + catch (ObjectDisposedException) { } + _listener = null; + try { _mdnsBrowser?.Dispose(); } + catch (Exception ex) { BasisDebug.LogWarning($"LAN mDNS discovery shutdown failed: {ex.Message}"); } + _mdnsBrowser = null; + ReleaseAndroidMulticastLock(); + _cancellation.Dispose(); + + lock (_gate) + { + _servers.Clear(); + } + } + } +} diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs.meta b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs.meta new file mode 100644 index 0000000000..fec3e292c9 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d8b2a2ac49142a084e2a7f24d6880d5 diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs new file mode 100644 index 0000000000..3b5e447163 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Basis.Scripts.Networking +{ + /// Small dual-stack UDP/5353 transport used only by Basis LAN discovery. + internal sealed class BasisLanMdnsTransport : IDisposable + { + private readonly object _sendGate = new object(); + private readonly Action _received; + private readonly List _ipv4Interfaces = new List(); + private readonly List _ipv6Interfaces = new List(); + private UdpClient _ipv4; + private UdpClient _ipv6; + private int _disposed; + + public BasisLanMdnsTransport(Action received) + { + _received = received ?? throw new ArgumentNullException(nameof(received)); + DiscoverInterfaces(); + + if (Socket.OSSupportsIPv4) + { + try { _ipv4 = CreateIpv4(); } + catch (Exception ex) { BasisDebug.LogWarning($"IPv4 mDNS unavailable: {ex.Message}"); } + } + if (Socket.OSSupportsIPv6) + { + try { _ipv6 = CreateIpv6(); } + catch (Exception ex) { BasisDebug.LogWarning($"IPv6 mDNS unavailable: {ex.Message}"); } + } + if (_ipv4 == null && _ipv6 == null) + { + throw new SocketException((int)SocketError.AddressFamilyNotSupported); + } + + if (_ipv4 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv4)); + if (_ipv6 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv6)); + } + + public void SendMulticast(byte[] packet) + { + if (packet == null || packet.Length == 0 || Volatile.Read(ref _disposed) != 0) return; + lock (_sendGate) + { + SendIpv4(packet); + SendIpv6(packet); + } + } + + public void SendUnicast(byte[] packet, IPEndPoint endpoint) + { + if (packet == null || endpoint == null || Volatile.Read(ref _disposed) != 0) return; + lock (_sendGate) + { + try + { + UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork ? _ipv4 : _ipv6; + client?.Send(packet, packet.Length, endpoint); + } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private UdpClient CreateIpv4() + { + UdpClient client = new UdpClient(AddressFamily.InterNetwork); + try + { + Shared(client); + client.Client.Bind(new IPEndPoint(IPAddress.Any, BasisLanMdnsWire.Port)); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, + new MulticastOption(BasisLanMdnsWire.MulticastAddress, address)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) client.JoinMulticastGroup(BasisLanMdnsWire.MulticastAddress); + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private UdpClient CreateIpv6() + { + UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); + try + { + Shared(client); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); + client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, BasisLanMdnsWire.Port)); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); + IPAddress group = IPAddress.Parse("ff02::fb"); + + bool joined = false; + foreach (int index in _ipv6Interfaces) + { + try + { + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.AddMembership, + new IPv6MulticastOption(group, index)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) client.JoinMulticastGroup(group); + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static void Shared(UdpClient client) + { + try { client.Client.ExclusiveAddressUse = false; } + catch (PlatformNotSupportedException) { } + client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + } + + private void SendIpv4(byte[] packet) + { + if (_ipv4 == null) return; + IPEndPoint endpoint = new IPEndPoint(BasisLanMdnsWire.MulticastAddress, BasisLanMdnsWire.Port); + bool sent = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + _ipv4.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, address.GetAddressBytes()); + _ipv4.Send(packet, packet.Length, endpoint); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { return; } + } + if (!sent) + { + try { _ipv4.Send(packet, packet.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private void SendIpv6(byte[] packet) + { + if (_ipv6 == null) return; + IPAddress group = IPAddress.Parse("ff02::fb"); + bool sent = false; + foreach (int index in _ipv6Interfaces) + { + try + { + _ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); + IPAddress scoped = new IPAddress(group.GetAddressBytes(), index); + _ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, BasisLanMdnsWire.Port)); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { return; } + } + if (!sent) + { + try { _ipv6.Send(packet, packet.Length, new IPEndPoint(group, BasisLanMdnsWire.Port)); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private async Task ReceiveLoopAsync(UdpClient client) + { + while (Volatile.Read(ref _disposed) == 0) + { + try + { + UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); + if (result.RemoteEndPoint != null && result.Buffer != null) + _received(result.Buffer, result.RemoteEndPoint); + } + catch (ObjectDisposedException) { return; } + catch (SocketException) + { + if (Volatile.Read(ref _disposed) != 0) return; + } + catch (Exception ex) + { + if (Volatile.Read(ref _disposed) == 0) + BasisDebug.LogWarning($"LAN mDNS receive failed: {ex.Message}"); + } + } + } + + private void DiscoverInterfaces() + { + HashSet ipv4 = new HashSet(); + HashSet ipv6 = new HashSet(); + try + { + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + IPInterfaceProperties properties = nic.GetIPProperties(); + foreach (UnicastIPAddressInformation info in properties.UnicastAddresses) + { + if (info.Address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(info.Address)) + ipv4.Add(info.Address); + } + try + { + IPv6InterfaceProperties v6 = properties.GetIPv6Properties(); + if (v6 != null && v6.Index > 0) ipv6.Add(v6.Index); + } + catch (Exception ex) when (ex is NetworkInformationException || ex is PlatformNotSupportedException || ex is NotImplementedException) { } + } + } + catch (Exception) { } + _ipv4Interfaces.AddRange(ipv4); + _ipv6Interfaces.AddRange(ipv6); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + try { _ipv4?.Close(); } catch (ObjectDisposedException) { } + try { _ipv6?.Close(); } catch (ObjectDisposedException) { } + _ipv4 = null; + _ipv6 = null; + } + } + + internal sealed class BasisLanMdnsAdvertiser : IDisposable + { + private readonly object _gate = new object(); + private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); + private readonly BasisLanMdnsWire.Service _service; + private readonly BasisLanMdnsTransport _transport; + private long _lastResponse; + private bool _disposed; + + public BasisLanMdnsAdvertiser(BasisLanDiscoveryProtocol.Advertisement advertisement) + { + _service = BasisLanMdnsWire.CreateService(advertisement); + try + { + _transport = new BasisLanMdnsTransport(OnPacket); + } + catch + { + _cancellation.Dispose(); + throw; + } + _ = Task.Run(() => AnnounceAsync(_cancellation.Token)); + } + + private async Task AnnounceAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(20, cancellationToken).ConfigureAwait(false); + Announce(); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + Announce(); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + BasisDebug.LogWarning($"LAN mDNS announcement failed: {ex.Message}"); + } + } + + private void OnPacket(byte[] packet, IPEndPoint remote) + { + if (!BasisLanMdnsWire.TryParse(packet, out BasisLanMdnsWire.Message query) + || query.IsResponse + || !BasisLanMdnsWire.TryBuildResponse(_service, query, out byte[] response)) return; + + lock (_gate) + { + if (_disposed) return; + long now = Stopwatch.GetTimestamp(); + if (_lastResponse != 0 && now - _lastResponse < Stopwatch.Frequency / 50) return; + _lastResponse = now; + + bool unicast = remote != null && remote.Port != BasisLanMdnsWire.Port; + if (!unicast) + { + foreach (BasisLanMdnsWire.Question question in query.Questions) + { + if (question.WantsUnicast) { unicast = true; break; } + } + } + if (unicast) _transport.SendUnicast(response, remote); + else _transport.SendMulticast(response); + } + } + + private void Announce() + { + lock (_gate) + { + if (!_disposed) _transport.SendMulticast(BasisLanMdnsWire.BuildAnnouncement(_service)); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + try { _cancellation.Cancel(); } catch (ObjectDisposedException) { } + try + { + byte[] goodbye = BasisLanMdnsWire.BuildAnnouncement(_service, 0); + _transport.SendMulticast(goodbye); + _transport.SendMulticast(goodbye); + } + catch (Exception ex) { BasisDebug.LogWarning($"LAN mDNS goodbye failed: {ex.Message}"); } + _transport.Dispose(); + _cancellation.Dispose(); + } + } + } + + internal sealed class BasisLanMdnsBrowser : IDisposable + { + private readonly object _gate = new object(); + private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); + private readonly Action _found; + private readonly Action _removed; + private readonly BasisLanMdnsTransport _transport; + private bool _disposed; + + public BasisLanMdnsBrowser(Action found, Action removed) + { + _found = found ?? throw new ArgumentNullException(nameof(found)); + _removed = removed ?? throw new ArgumentNullException(nameof(removed)); + try + { + _transport = new BasisLanMdnsTransport(OnPacket); + } + catch + { + _cancellation.Dispose(); + throw; + } + _ = Task.Run(() => QueryLoopAsync(_cancellation.Token)); + } + + private async Task QueryLoopAsync(CancellationToken cancellationToken) + { + try + { + byte[] query = BasisLanMdnsWire.BuildQuery(); + while (!cancellationToken.IsCancellationRequested) + { + lock (_gate) + { + if (_disposed) return; + _transport.SendMulticast(query); + } + await Task.Delay(BasisLanMdnsWire.QueryIntervalMs, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + BasisDebug.LogWarning($"LAN mDNS discovery stopped: {ex.Message}"); + } + } + + private void OnPacket(byte[] packet, IPEndPoint remote) + { + if (!BasisLanMdnsWire.TryParse(packet, out BasisLanMdnsWire.Message message) || !message.IsResponse) return; + lock (_gate) + { + if (_disposed) return; + BasisLanMdnsWire.Extract(message, remote, _found, _removed); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + try { _cancellation.Cancel(); } catch (ObjectDisposedException) { } + _transport.Dispose(); + _cancellation.Dispose(); + } + } + } +} diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta new file mode 100644 index 0000000000..ea06d09260 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8e816674cf9a4cf9a42f38d44c13a2e6 diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs new file mode 100644 index 0000000000..292611165e --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs @@ -0,0 +1,592 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; + +namespace Basis.Scripts.Networking +{ + /// Minimal DNS wire support used only by Basis LAN mDNS. + internal static class BasisLanMdnsWire + { + public const string ServiceType = "_basisvr._udp.local"; + public const int Port = 5353; + public const int QueryIntervalMs = 2000; + public static readonly IPAddress MulticastAddress = IPAddress.Parse("224.0.0.251"); + + private const string MetaServiceType = "_services._dns-sd._udp.local"; + private const string ProtocolVersion = "1"; + private const int MaxPacketBytes = 9000; + private const int MaxQuestions = 64; + private const int MaxRecords = 256; + private const uint TtlSeconds = 120; + + internal const ushort A = 1; + internal const ushort Ptr = 12; + internal const ushort Txt = 16; + internal const ushort Aaaa = 28; + internal const ushort Srv = 33; + internal const ushort Any = 255; + internal const ushort In = 1; + internal const ushort FlushIn = 0x8001; + + private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); + + internal sealed class Service + { + public BasisLanDiscoveryProtocol.Advertisement Advertisement; + public string InstanceName; + public string HostName; + public List TxtValues; + public List Addresses; + } + + internal readonly struct Question + { + public readonly string Name; + public readonly ushort Type; + public readonly ushort Class; + public bool WantsUnicast => (Class & 0x8000) != 0; + + public Question(string name, ushort type, ushort recordClass) + { + Name = name; + Type = type; + Class = recordClass; + } + } + + internal sealed class Record + { + public string Name; + public ushort Type; + public ushort Class; + public uint Ttl; + public string DomainName; + public ushort Port; + public string Target; + public List TxtValues; + public IPAddress Address; + } + + internal sealed class Message + { + public bool IsResponse; + public readonly List Questions = new List(); + public readonly List Records = new List(); + } + + private readonly struct OutRecord + { + public readonly string Name; + public readonly ushort Type; + public readonly ushort Class; + public readonly uint Ttl; + public readonly byte[] Data; + + public OutRecord(string name, ushort type, ushort recordClass, uint ttl, byte[] data) + { + Name = name; + Type = type; + Class = recordClass; + Ttl = ttl; + Data = data; + } + } + + public static Service CreateService(BasisLanDiscoveryProtocol.Advertisement advertisement) + { + string id = advertisement.InstanceId.ToString("N"); + return new Service + { + Advertisement = advertisement, + InstanceName = $"Basis-{id}.{ServiceType}", + HostName = $"basis-{id}.local", + Addresses = GetAddresses(), + TxtValues = new List + { + TxtValue("protocol", ProtocolVersion), + TxtValue("id", id), + TxtValue("stack", advertisement.NetworkStackId), + TxtValue("name", advertisement.ServerName), + TxtValue("motd", advertisement.Motd), + TxtValue("pwd", advertisement.RequiresPassword ? "1" : "0"), + }, + }; + } + + public static byte[] BuildQuery() + { + return Build(0, new List { new Question(ServiceType, Ptr, In) }, new List(), new List()); + } + + public static byte[] BuildAnnouncement(Service service, uint ttl = TtlSeconds) + { + ServiceRecords(service, ttl, out List answers, out List additional); + return Build(0x8400, new List(), answers, additional); + } + + public static bool TryBuildResponse(Service service, Message query, out byte[] response) + { + response = null; + if (query == null || query.IsResponse) + { + return false; + } + + bool meta = false; + bool basis = false; + foreach (Question question in query.Questions) + { + if ((question.Class & 0x7FFF) != In) + { + continue; + } + + if (EqualName(question.Name, MetaServiceType) && Requested(question.Type, Ptr)) + { + meta = true; + } + else if ((EqualName(question.Name, ServiceType) && Requested(question.Type, Ptr)) + || (EqualName(question.Name, service.InstanceName) && (Requested(question.Type, Srv) || Requested(question.Type, Txt))) + || (EqualName(question.Name, service.HostName) && (Requested(question.Type, A) || Requested(question.Type, Aaaa)))) + { + basis = true; + } + } + if (!meta && !basis) + { + return false; + } + + List answers = new List(); + List additional = new List(); + if (meta) + { + answers.Add(new OutRecord(MetaServiceType, Ptr, In, TtlSeconds, EncodeName(ServiceType))); + } + if (basis) + { + ServiceRecords(service, TtlSeconds, out List serviceAnswers, out List serviceAdditional); + answers.AddRange(serviceAnswers); + additional.AddRange(serviceAdditional); + } + response = Build(0x8400, new List(), answers, additional); + return true; + } + + public static bool TryParse(byte[] packet, out Message message) + { + message = null; + if (packet == null || packet.Length < 12 || packet.Length > MaxPacketBytes) + { + return false; + } + + try + { + int offset = 0; + U16(packet, ref offset); + ushort flags = U16(packet, ref offset); + ushort questionCount = U16(packet, ref offset); + ushort answerCount = U16(packet, ref offset); + ushort authorityCount = U16(packet, ref offset); + ushort additionalCount = U16(packet, ref offset); + int recordCount = answerCount + authorityCount + additionalCount; + if (questionCount > MaxQuestions || recordCount > MaxRecords) + { + return false; + } + + if ((flags & 0x780F) != 0) + { + return false; + } + + Message parsed = new Message { IsResponse = (flags & 0x8000) != 0 }; + for (int i = 0; i < questionCount; i++) + { + if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 4) + { + return false; + } + parsed.Questions.Add(new Question(name, U16(packet, ref offset), U16(packet, ref offset))); + } + for (int i = 0; i < recordCount; i++) + { + if (!ReadRecord(packet, ref offset, out Record record)) + { + return false; + } + parsed.Records.Add(record); + } + message = parsed; + return true; + } + catch (Exception ex) when (ex is ArgumentException || ex is IOException || ex is DecoderFallbackException || ex is OverflowException) + { + return false; + } + } + + public static void Extract( + Message message, + IPEndPoint remote, + Action found, + Action removed) + { + if (message == null || !message.IsResponse) + { + return; + } + + foreach (Record pointer in message.Records) + { + if (pointer.Type != Ptr || !EqualName(pointer.Name, ServiceType) || string.IsNullOrWhiteSpace(pointer.DomainName)) + { + continue; + } + + if (pointer.Ttl == 0) + { + if (TryInstanceId(message, pointer.DomainName, out Guid id)) + { + removed?.Invoke(id); + } + continue; + } + + Record srv = null; + Record txt = null; + foreach (Record record in message.Records) + { + if (!EqualName(record.Name, pointer.DomainName)) + { + continue; + } + if (record.Type == Srv && srv == null) srv = record; + else if (record.Type == Txt && txt == null) txt = record; + } + if (srv == null || srv.Port == 0 || string.IsNullOrWhiteSpace(srv.Target) || txt?.TxtValues == null) + { + continue; + } + + Dictionary values = Properties(txt.TxtValues); + if (!values.TryGetValue("protocol", out string protocol) + || protocol != ProtocolVersion + || !values.TryGetValue("id", out string idText) + || !Guid.TryParseExact(idText, "N", out Guid instanceId) + || instanceId == Guid.Empty + || !values.TryGetValue("pwd", out string password) + || (password != "0" && password != "1")) + { + continue; + } + + IPAddress address = SelectAddress(message, srv.Target, remote?.Address); + if (address == null) + { + continue; + } + + values.TryGetValue("stack", out string stack); + values.TryGetValue("name", out string name); + values.TryGetValue("motd", out string motd); + found?.Invoke(new BasisLanDiscoveryProtocol.Advertisement( + instanceId, + srv.Port, + password == "1", + LimitUtf8(stack, BasisLanDiscoveryProtocol.MaxStackIdBytes), + LimitUtf8(name, BasisLanDiscoveryProtocol.MaxServerNameBytes), + LimitUtf8(motd, BasisLanDiscoveryProtocol.MaxMotdBytes)), address); + } + } + + public static string LimitUtf8(string value, int maxBytes) + { + if (string.IsNullOrEmpty(value) || maxBytes <= 0) return string.Empty; + if (Encoding.UTF8.GetByteCount(value) <= maxBytes) return value; + int length = value.Length; + while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) + { + length--; + if (length > 0 && char.IsHighSurrogate(value[length - 1])) length--; + } + return length == 0 ? string.Empty : value.Substring(0, length); + } + + private static void ServiceRecords(Service service, uint ttl, out List answers, out List additional) + { + answers = new List { new OutRecord(ServiceType, Ptr, In, ttl, EncodeName(service.InstanceName)) }; + additional = new List + { + new OutRecord(service.InstanceName, Srv, FlushIn, ttl, EncodeSrv(service.Advertisement.ServerPort, service.HostName)), + new OutRecord(service.InstanceName, Txt, FlushIn, ttl, EncodeTxt(service.TxtValues)), + }; + foreach (IPAddress address in service.Addresses) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + additional.Add(new OutRecord(service.HostName, A, FlushIn, ttl, address.GetAddressBytes())); + else if (address.AddressFamily == AddressFamily.InterNetworkV6) + additional.Add(new OutRecord(service.HostName, Aaaa, FlushIn, ttl, address.GetAddressBytes())); + } + } + + private static byte[] Build(ushort flags, List questions, List answers, List additional) + { + using MemoryStream stream = new MemoryStream(512); + W16(stream, 0); W16(stream, flags); W16(stream, checked((ushort)questions.Count)); + W16(stream, checked((ushort)answers.Count)); W16(stream, 0); W16(stream, checked((ushort)additional.Count)); + foreach (Question question in questions) + { + Bytes(stream, EncodeName(question.Name)); W16(stream, question.Type); W16(stream, question.Class); + } + foreach (OutRecord record in answers) WriteRecord(stream, record); + foreach (OutRecord record in additional) WriteRecord(stream, record); + if (stream.Length > MaxPacketBytes) throw new InvalidOperationException("Basis mDNS packet is too large."); + return stream.ToArray(); + } + + private static void WriteRecord(Stream stream, OutRecord record) + { + Bytes(stream, EncodeName(record.Name)); W16(stream, record.Type); W16(stream, record.Class); + W32(stream, record.Ttl); W16(stream, checked((ushort)record.Data.Length)); Bytes(stream, record.Data); + } + + private static byte[] EncodeSrv(ushort port, string target) + { + using MemoryStream stream = new MemoryStream(64); + W16(stream, 0); W16(stream, 0); W16(stream, port); Bytes(stream, EncodeName(target)); + return stream.ToArray(); + } + + private static byte[] EncodeTxt(List values) + { + using MemoryStream stream = new MemoryStream(256); + foreach (string value in values) + { + byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + if (bytes.Length > 255) throw new InvalidOperationException("Basis mDNS TXT value is too large."); + stream.WriteByte((byte)bytes.Length); Bytes(stream, bytes); + } + return stream.ToArray(); + } + + private static byte[] EncodeName(string value) + { + string normalized = Normalize(value); + using MemoryStream stream = new MemoryStream(128); + if (normalized.Length != 0) + { + foreach (string label in normalized.Split('.')) + { + byte[] bytes = Encoding.UTF8.GetBytes(label); + if (bytes.Length == 0 || bytes.Length > 63) throw new InvalidOperationException("Invalid mDNS label."); + stream.WriteByte((byte)bytes.Length); Bytes(stream, bytes); + } + } + stream.WriteByte(0); + if (stream.Length > 255) throw new InvalidOperationException("Invalid mDNS name."); + return stream.ToArray(); + } + + private static bool ReadRecord(byte[] packet, ref int offset, out Record record) + { + record = null; + if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 10) return false; + ushort type = U16(packet, ref offset); + ushort recordClass = U16(packet, ref offset); + uint ttl = U32(packet, ref offset); + ushort length = U16(packet, ref offset); + int start = offset; + int end = start + length; + if (end < start || end > packet.Length) return false; + + Record parsed = new Record { Name = name, Type = type, Class = recordClass, Ttl = ttl }; + if (type == Ptr) + { + int pos = start; + if (!ReadName(packet, ref pos, out parsed.DomainName) || pos > end) return false; + } + else if (type == Srv) + { + if (length < 7) return false; + int pos = start + 4; + parsed.Port = U16(packet, ref pos); + if (!ReadName(packet, ref pos, out parsed.Target) || pos > end) return false; + } + else if (type == Txt) + { + parsed.TxtValues = new List(); + int pos = start; + while (pos < end) + { + int size = packet[pos++]; + if (pos + size > end) return false; + parsed.TxtValues.Add(StrictUtf8.GetString(packet, pos, size)); + pos += size; + } + } + else if ((type == A && length == 4) || (type == Aaaa && length == 16)) + { + byte[] bytes = new byte[length]; + Buffer.BlockCopy(packet, start, bytes, 0, length); + parsed.Address = new IPAddress(bytes); + } + offset = end; + record = parsed; + return true; + } + + private static bool ReadName(byte[] packet, ref int offset, out string value) + { + value = string.Empty; + if (offset < 0 || offset >= packet.Length) return false; + StringBuilder result = new StringBuilder(64); + HashSet pointers = null; + int pos = offset; + int resume = -1; + int encoded = 1; + while (true) + { + if (pos >= packet.Length) return false; + byte length = packet[pos++]; + if (length == 0) + { + offset = resume >= 0 ? resume : pos; + value = result.ToString(); + return true; + } + if ((length & 0xC0) == 0xC0) + { + if (pos >= packet.Length) return false; + int pointer = ((length & 0x3F) << 8) | packet[pos++]; + if (pointer >= packet.Length) return false; + resume = resume >= 0 ? resume : pos; + pointers ??= new HashSet(); + if (!pointers.Add(pointer) || pointers.Count > 32) return false; + pos = pointer; + continue; + } + if ((length & 0xC0) != 0 || length > 63 || pos + length > packet.Length) return false; + encoded += length + 1; + if (encoded > 255) return false; + if (result.Length != 0) result.Append('.'); + result.Append(StrictUtf8.GetString(packet, pos, length)); + pos += length; + } + } + + private static bool TryInstanceId(Message message, string instanceName, out Guid id) + { + id = Guid.Empty; + foreach (Record record in message.Records) + { + if (record.Type == Txt && EqualName(record.Name, instanceName) && record.TxtValues != null) + { + Dictionary values = Properties(record.TxtValues); + if (values.TryGetValue("id", out string text) && Guid.TryParseExact(text, "N", out id) && id != Guid.Empty) + return true; + } + } + string label = Normalize(instanceName); + int dot = label.IndexOf('.'); + if (dot >= 0) label = label.Substring(0, dot); + return label.StartsWith("Basis-", StringComparison.OrdinalIgnoreCase) + && Guid.TryParseExact(label.Substring(6), "N", out id) + && id != Guid.Empty; + } + + private static Dictionary Properties(List values) + { + Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string value in values) + { + int separator = value?.IndexOf('=') ?? -1; + if (separator > 0 && !result.ContainsKey(value.Substring(0, separator))) + result.Add(value.Substring(0, separator), value.Substring(separator + 1)); + } + return result; + } + + private static IPAddress SelectAddress(Message message, string target, IPAddress remote) + { + IPAddress ipv6 = null; + foreach (Record record in message.Records) + { + if (!EqualName(record.Name, target) || !Usable(record.Address)) continue; + if (record.Address.AddressFamily == AddressFamily.InterNetwork) return record.Address; + if (record.Address.AddressFamily == AddressFamily.InterNetworkV6 && ipv6 == null) ipv6 = record.Address; + } + if (Usable(remote) && remote.AddressFamily == AddressFamily.InterNetwork) return remote; + if (ipv6 != null) + { + if (ipv6.IsIPv6LinkLocal + && ipv6.ScopeId == 0 + && remote != null + && remote.AddressFamily == AddressFamily.InterNetworkV6 + && remote.ScopeId != 0) + { + return new IPAddress(ipv6.GetAddressBytes(), remote.ScopeId); + } + return ipv6; + } + return Usable(remote) ? remote : null; + } + + private static List GetAddresses() + { + HashSet result = new HashSet(); + try + { + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; + foreach (UnicastIPAddressInformation info in nic.GetIPProperties().UnicastAddresses) + { + IPAddress address = info.Address; + if (Usable(address) && !IPAddress.IsLoopback(address) + && (address.AddressFamily == AddressFamily.InterNetwork || address.IsIPv6LinkLocal)) + result.Add(address); + } + } + } + catch (Exception) { } + return new List(result); + } + + private static string TxtValue(string key, string value) + { + int budget = 254 - Encoding.UTF8.GetByteCount(key); + return key + "=" + LimitUtf8(value, Math.Max(0, budget)); + } + + private static bool Requested(ushort actual, ushort expected) => actual == expected || actual == Any; + private static bool EqualName(string left, string right) => string.Equals(Normalize(left), Normalize(right), StringComparison.OrdinalIgnoreCase); + private static string Normalize(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.'); + private static bool Usable(IPAddress address) => address != null && !address.Equals(IPAddress.Any) && !address.Equals(IPAddress.IPv6Any) && !address.Equals(IPAddress.Broadcast) && !address.IsIPv6Multicast; + + private static ushort U16(byte[] packet, ref int offset) + { + if (packet.Length - offset < 2) throw new IOException(); + ushort value = (ushort)((packet[offset] << 8) | packet[offset + 1]); + offset += 2; + return value; + } + + private static uint U32(byte[] packet, ref int offset) + { + if (packet.Length - offset < 4) throw new IOException(); + uint value = ((uint)packet[offset] << 24) | ((uint)packet[offset + 1] << 16) | ((uint)packet[offset + 2] << 8) | packet[offset + 3]; + offset += 4; + return value; + } + + private static void W16(Stream stream, ushort value) { stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); } + private static void W32(Stream stream, uint value) { stream.WriteByte((byte)(value >> 24)); stream.WriteByte((byte)(value >> 16)); stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); } + private static void Bytes(Stream stream, byte[] bytes) => stream.Write(bytes, 0, bytes.Length); + } +} diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta new file mode 100644 index 0000000000..c1bca45958 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a4d514f63db401ab814ca9fc652835d diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkManagement.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkManagement.cs index 138ebe934a..fa930b0836 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkManagement.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkManagement.cs @@ -62,6 +62,12 @@ public static class BasisNetworkManagement public static bool HostUseAuth = true; + /// + /// Advertise an in-process hosted server to other Basis clients on the local network. + /// Disabled by default and only used for client-hosted sessions. + /// + public static bool HostShowToLan = false; + public static bool HostEnableConsole = true; public static bool HostAvatarsLocked = false; diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs index 340f9c993b..b257a294e4 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs @@ -1,4 +1,5 @@ using Basis.Network; +using Basis.Scripts.Networking; using System; using System.Threading; using System.Threading.Tasks; @@ -8,11 +9,12 @@ public class BasisNetworkServerRunner { public Task serverTask; + private readonly object lifecycleGate = new object(); CancellationTokenSource cancellationTokenSource; [SerializeField] public Configuration Configuration; // Start is called once before the first execution of Update after the MonoBehaviour is created - public void Initialize(Configuration configuration, string LogPath,string UUIDTomarkAsAdmin) + public void Initialize(Configuration configuration, string LogPath, string UUIDTomarkAsAdmin) { Configuration = configuration; BasisServerSideLogging.Initialize(Configuration, LogPath); @@ -22,13 +24,32 @@ public void Initialize(Configuration configuration, string LogPath,string UUIDTo { try { - NetworkServer.StartServer(Configuration); + lock (lifecycleGate) + { + cancellationToken.ThrowIfCancellationRequested(); + NetworkServer.StartServer(Configuration); + if (BasisNetworkManagement.HostShowToLan) + { + BasisLanServerAdvertiser.Start( + Configuration.SetPort, + Configuration.NetworkStackId, + Configuration.ServerName, + Configuration.ServerMotd, + Configuration.UseAuth && !string.IsNullOrEmpty(Configuration.Password)); + } + } + + cancellationToken.ThrowIfCancellationRequested(); PermissionIntegration.Manager.AddUserNode(UUIDTomarkAsAdmin,"*"); PermissionIntegration.Manager.AddUserToGroup(UUIDTomarkAsAdmin, "admin"); } + catch (OperationCanceledException) + { + } catch (Exception ex) { + BasisLanServerAdvertiser.Stop(); BNL.LogError($"Server encountered an error: {ex.Message} {ex.StackTrace}"); // Optionally, handle server restart or log critical errors } @@ -36,7 +57,11 @@ public void Initialize(Configuration configuration, string LogPath,string UUIDTo } public void Stop() { - cancellationTokenSource?.Cancel(); - NetworkServer.StopServer(); + lock (lifecycleGate) + { + cancellationTokenSource?.Cancel(); + BasisLanServerAdvertiser.Stop(); + NetworkServer.StopServer(); + } } } diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index faaac52084..11231de7be 100644 --- a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs +++ b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs @@ -37,6 +37,7 @@ public static void AddToMenu() public static string HostPortFile = "HostPort.BAS"; public static string HostPasswordFile = "HostPassword.BAS"; public static string HostUseAuthFile = "HostUseAuth.BAS"; + public static string HostShowToLanFile = "HostShowToLan.BAS"; public static string HostEnableConsoleFile = "HostEnableConsole.BAS"; public static string HostAvatarsLockedFile = "HostAvatarsLocked.BAS"; public static string HostPropsLockedFile = "HostPropsLocked.BAS"; @@ -56,14 +57,32 @@ public static void AddToMenu() private static bool IsDefault(ServerDirectoryEntry entry) => entry != null && SavedServersDirectorySource.IsDefaultEntryId(entry.Id); + private static bool IsLan(ServerDirectoryEntry entry) => + entry != null && string.Equals(entry.SourceId, LanServersDirectorySource.Id, StringComparison.OrdinalIgnoreCase); + + private static string FormatEntryName(ServerDirectoryEntry entry, string name) + { + if (IsDefault(entry)) + { + return string.Format(BasisLocalization.Get("menu.servers.list.defaultBadge"), name); + } + if (IsLan(entry)) + { + return string.Format(BasisLocalization.Get("menu.servers.list.lanBadge"), name); + } + return name; + } + // ── Static UI references rebuilt every RunAction() ──────────────────── private BasisMenuPanel _panel; private RectTransform _listContainer; private PanelElementDescriptor _editorSection; + private PanelElementDescriptor _lanPasswordSection; private PanelElementDescriptor _emptyState; private PanelTextField _editAddress; private PanelTextField _editPort; private PanelPasswordField _editPassword; + private PanelPasswordField _lanPasswordField; private PanelDropdown _editNetworkStack; private List _stackIds; private List _stackDisplayNames; @@ -71,8 +90,11 @@ private static bool IsDefault(ServerDirectoryEntry entry) => private PanelButton _editCancelButton; private PanelButton _editShareButton; private PanelButton _editRemoveButton; + private PanelButton _lanPasswordConnectButton; + private PanelButton _lanPasswordCancelButton; private PanelTextField _usernameField; private ServerDirectoryEntry _pendingUsernameEntry; + private ServerDirectoryEntry _pendingLanPasswordEntry; private bool _pendingUsernameHostMode; private PanelButton _addServerButton; private PanelButton _refreshAllButton; @@ -85,6 +107,7 @@ private static bool IsDefault(ServerDirectoryEntry entry) => private PanelTextField _hostPortField; private PanelPasswordField _hostPasswordField; private PanelToggle _hostUseAuthToggle; + private PanelToggle _hostShowToLanToggle; private PanelToggle _hostEnableConsoleToggle; private PanelToggle _hostAvatarsLockedToggle; private PanelToggle _hostPropsLockedToggle; @@ -117,6 +140,7 @@ public override void RunAction() BuildHeader(container); BuildEditorSection(container); + BuildLanPasswordSection(container); _listContainer = container; _emptyState = PanelElementDescriptor.CreateNew(PanelElementDescriptor.ElementStyles.Group, container); @@ -126,6 +150,7 @@ public override void RunAction() BuildAdvancedSection(container); HideEditor(); + HideLanPasswordPrompt(); SubscribeSourceEvents(); _ = ReloadEntriesAsync(probeAfter: true, autoConnectAfter: true); } @@ -137,6 +162,7 @@ private void OnPanelClosed() _rows.Clear(); _entries.Clear(); _pendingUsernameEntry = null; + _pendingLanPasswordEntry = null; UnsubscribeSourceEvents(); _panel = null; } @@ -283,6 +309,12 @@ private void BuildAdvancedSection(RectTransform container) _hostUseAuthToggle.SetValueWithoutNotify(BasisDataStore.LoadInt(HostUseAuthFile, 1) != 0); _hostUseAuthToggle.OnValueChanged = value => BasisDataStore.SaveInt(value ? 1 : 0, HostUseAuthFile); + _hostShowToLanToggle = PanelToggle.CreateNewEntry(container); + _hostShowToLanToggle.Descriptor.SetTitle(BasisLocalization.Get("menu.servers.hostShowToLan")); + _hostShowToLanToggle.Descriptor.SetDescription(BasisLocalization.Get("menu.servers.hostShowToLan.description")); + _hostShowToLanToggle.SetValueWithoutNotify(BasisDataStore.LoadInt(HostShowToLanFile, 0) != 0); + _hostShowToLanToggle.OnValueChanged = OnHostShowToLanChanged; + _hostEnableConsoleToggle = PanelToggle.CreateNewEntry(container); _hostEnableConsoleToggle.Descriptor.SetTitle(BasisLocalization.Get("menu.servers.hostEnableConsole")); _hostEnableConsoleToggle.SetValueWithoutNotify(BasisDataStore.LoadInt(HostEnableConsoleFile, 1) != 0); @@ -322,6 +354,34 @@ private void BuildAdvancedSection(RectTransform container) PanelSectionToggleHelpers.FinalizeFlatSectionFromIndex(_advancedToggle, container, advancedStart, false, null); } + private void OnHostShowToLanChanged(bool value) + { + BasisDataStore.SaveInt(value ? 1 : 0, HostShowToLanFile); + BasisNetworkManagement.HostShowToLan = value; + + BasisNetworkServerRunner runner = BasisNetworkConnection.BasisNetworkServerRunner; + if (!BasisNetworkManagement.IsHostMode + || !BasisNetworkConnection.LocalPlayerIsConnected + || runner?.Configuration == null) + { + return; + } + + if (!value) + { + BasisLanServerAdvertiser.Stop(); + return; + } + + Configuration configuration = runner.Configuration; + BasisLanServerAdvertiser.Start( + configuration.SetPort, + configuration.NetworkStackId, + configuration.ServerName, + configuration.ServerMotd, + configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); + } + private void PopulateHostStackDropdown() { if (_hostStackDropdown == null) return; @@ -430,6 +490,74 @@ private void BuildEditorSection(RectTransform container) _editRemoveButton.OnClicked += () => _ = OnEditRemoveClickedAsync(); } + private void BuildLanPasswordSection(RectTransform container) + { + _lanPasswordSection = PanelElementDescriptor.CreateNew(PanelElementDescriptor.ElementStyles.Group, container); + _lanPasswordSection.SetDescription(BasisLocalization.Get("menu.servers.lanPassword.description")); + + _lanPasswordField = PanelPasswordField.CreateNewEntry(_lanPasswordSection.ContentParent); + _lanPasswordField.Descriptor.SetTitle(BasisLocalization.Get("menu.servers.password")); + _lanPasswordField._inputField.onSubmit.AddListener(_ => SubmitLanPassword()); + + RectTransform actions = BuildActionRow(_lanPasswordSection.ContentParent); + + _lanPasswordConnectButton = PanelButton.CreateNew(actions); + _lanPasswordConnectButton.Descriptor.SetTitle(BasisLocalization.Get("menu.servers.connect")); + _lanPasswordConnectButton.OnClicked += SubmitLanPassword; + + _lanPasswordCancelButton = PanelButton.CreateNew(actions); + _lanPasswordCancelButton.Descriptor.SetTitle(BasisLocalization.Get("menu.servers.list.cancel")); + _lanPasswordCancelButton.OnClicked += HideLanPasswordPrompt; + } + + private void ShowLanPasswordPrompt(ServerDirectoryEntry entry) + { + if (entry == null || _lanPasswordSection == null || _lanPasswordField == null) + { + return; + } + + HideEditor(); + _pendingLanPasswordEntry = entry; + string name = string.IsNullOrEmpty(entry.DisplayName) + ? entry.Target?.Get(ConnectionTarget.Keys.Address) ?? string.Empty + : entry.DisplayName; + _lanPasswordSection.SetTitle(string.Format(BasisLocalization.Get("menu.servers.lanPassword.title"), name)); + _lanPasswordField.SetPassword(string.Empty); + _lanPasswordSection.SetActive(true); + _lanPasswordField._inputField.Select(); + _lanPasswordField._inputField.ActivateInputField(); + } + + private void SubmitLanPassword() + { + ServerDirectoryEntry entry = _pendingLanPasswordEntry; + if (entry == null || _lanPasswordField == null) + { + return; + } + + string password = _lanPasswordField.Password ?? string.Empty; + if (string.IsNullOrEmpty(password)) + { + _lanPasswordField._inputField.Select(); + _lanPasswordField._inputField.ActivateInputField(); + return; + } + + entry.Password = password; + entry.HasPassword = true; + entry.Target?.Set(ConnectionTarget.Keys.Password, password); + HideLanPasswordPrompt(); + _ = ConnectToAsync(entry); + } + + private void HideLanPasswordPrompt() + { + _pendingLanPasswordEntry = null; + _lanPasswordSection?.SetActive(false); + } + private async Task OnEditRemoveClickedAsync() { if (string.IsNullOrEmpty(_editingId)) return; @@ -447,6 +575,7 @@ private async Task OnEditRemoveClickedAsync() private void ShowEditor(ServerDirectoryEntry existing) { + HideLanPasswordPrompt(); _editingId = existing?.Id ?? string.Empty; _editorSection.SetTitle(BasisLocalization.Get(existing == null ? "menu.servers.list.newServer" @@ -626,8 +755,6 @@ private void RebuildRows() private void BuildRow(ServerDirectoryEntry entry) { - bool isDefault = IsDefault(entry); - string address = entry.Target?.Get(ConnectionTarget.Keys.Address) ?? string.Empty; string portString = entry.Target?.Get(ConnectionTarget.Keys.Port) ?? string.Empty; @@ -636,9 +763,7 @@ private void BuildRow(ServerDirectoryEntry entry) row.Group.transform.SetSiblingIndex(_advancedToggle.transform.GetSiblingIndex()); string baseTitle = string.IsNullOrEmpty(entry.DisplayName) ? address : entry.DisplayName; - row.Group.SetTitle(isDefault - ? string.Format(BasisLocalization.Get("menu.servers.list.defaultBadge"), baseTitle) - : baseTitle); + row.Group.SetTitle(FormatEntryName(entry, baseTitle)); ushort portForDisplay; ushort.TryParse(portString, out portForDisplay); row.Group.SetDescription(string.Format(BasisLocalization.Get("menu.servers.list.address"), address, portForDisplay)); @@ -653,7 +778,7 @@ private void BuildRow(ServerDirectoryEntry entry) && BasisNetworkManagement.Port == portForDisplay; row.ConnectButton.Descriptor.SetTitle(BasisLocalization.Get( isCurrentServer ? "menu.servers.reconnect" : "menu.servers.connect")); - row.ConnectButton.OnClicked += () => _ = ConnectToAsync(entry); + row.ConnectButton.OnClicked += () => OnConnectClicked(entry); if (entry.CanEdit) { @@ -771,8 +896,7 @@ private async Task QueryAndUpdateAsync(ServerDirectoryEntry entry, CancellationT string name = result.Name; if (string.IsNullOrEmpty(name)) name = entry.DisplayName; if (string.IsNullOrEmpty(name)) name = address; - if (IsDefault(entry)) - name = string.Format(BasisLocalization.Get("menu.servers.list.defaultBadge"), name); + name = FormatEntryName(entry, name); string playerCount = string.Format(BasisLocalization.Get("menu.servers.list.players"), result.Online, result.Max); row.Group.SetTitle(string.Format("{0} - {2}", name, @@ -792,8 +916,7 @@ private async Task QueryAndUpdateAsync(ServerDirectoryEntry entry, CancellationT { string name = entry.DisplayName; if (string.IsNullOrEmpty(name)) name = address; - if (IsDefault(entry)) - name = string.Format(BasisLocalization.Get("menu.servers.list.defaultBadge"), name); + name = FormatEntryName(entry, name); row.Group.SetTitle(name); row.Group.SetDescription(string.Format("{0} • {2}", DisplayAddress(address, port), @@ -816,6 +939,18 @@ private static string DisplayAddress(string address, ushort port) // ── Connection ─────────────────────────────────────────────────────── + private void OnConnectClicked(ServerDirectoryEntry entry) + { + if (IsLan(entry) && entry.HasPassword && string.IsNullOrEmpty(entry.Password)) + { + ShowLanPasswordPrompt(entry); + return; + } + + HideLanPasswordPrompt(); + _ = ConnectToAsync(entry); + } + private void TryAutoConnect() { if (BasisConnectionService.AutoConnectAttempted) return; @@ -833,7 +968,7 @@ private void TryAutoConnect() if (target == null) return; _usernameField?.SetValueWithoutNotify(username); - _ = ConnectToAsync(target); + OnConnectClicked(target); } private ServerDirectoryEntry ResolveAutoConnectTarget(string lastId) @@ -867,6 +1002,7 @@ private async Task ConnectToAsync(ServerDirectoryEntry entry, bool isHostMode = BasisNetworkManagement.HostServerMotd = BasisDataStore.LoadString(HostServerMotdFile, string.Empty); BasisNetworkManagement.HostPeerLimit = BasisDataStore.LoadInt(HostPeerLimitFile, DefaultHostPeerLimit); BasisNetworkManagement.HostUseAuth = BasisDataStore.LoadInt(HostUseAuthFile, 1) != 0; + BasisNetworkManagement.HostShowToLan = BasisDataStore.LoadInt(HostShowToLanFile, 0) != 0; BasisNetworkManagement.HostEnableConsole = BasisDataStore.LoadInt(HostEnableConsoleFile, 1) != 0; BasisNetworkManagement.HostAvatarsLocked = BasisDataStore.LoadInt(HostAvatarsLockedFile, 0) != 0; BasisNetworkManagement.HostPropsLocked = BasisDataStore.LoadInt(HostPropsLockedFile, 0) != 0; From ca1e24bfa2388dfe994829889ca9560c46d2d28b Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 01:52:38 +0000 Subject: [PATCH 02/25] Avoid LAN discovery refresh stutter --- .../Networking/BasisLanDiscoveryProtocol.cs | 127 +++++++++++++++++- .../Runtime/ServersProvider.cs | 17 ++- 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs index 436ff41301..cb4e7b86c6 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs @@ -344,6 +344,7 @@ public sealed class LanServersDirectorySource : IServerDirectorySource, IDisposa private const int EntryLifetimeMs = 5000; private const int CleanupIntervalMs = 1000; private const int MaxTrackedServers = 256; + private const int MaxAddressesPerServer = 16; private static readonly long EntryLifetimeTicks = (long)(EntryLifetimeMs / 1000.0 * Stopwatch.Frequency); @@ -351,6 +352,7 @@ private sealed class DiscoveredServer { public Guid InstanceId; public IPAddress Address; + public readonly Dictionary AddressLastSeen = new Dictionary(); public ushort Port; public bool RequiresPassword; public string NetworkStackId; @@ -590,7 +592,8 @@ private void ProcessAdvertisement(BasisLanDiscoveryProtocol.Advertisement advert lock (_gate) { changed = PruneExpiredLocked(nowTicks); - changed |= !_servers.TryGetValue(advertisement.InstanceId, out DiscoveredServer existing); + bool isNew = !_servers.TryGetValue(advertisement.InstanceId, out DiscoveredServer existing); + changed |= isNew; if (existing == null) { if (_servers.Count >= MaxTrackedServers) @@ -600,17 +603,25 @@ private void ProcessAdvertisement(BasisLanDiscoveryProtocol.Advertisement advert existing = new DiscoveredServer { InstanceId = advertisement.InstanceId }; _servers.Add(advertisement.InstanceId, existing); } - else + + PruneAddressCandidatesLocked(existing, nowTicks); + TrackAddressLocked(existing, address, nowTicks); + if (existing.Address == null || !existing.AddressLastSeen.ContainsKey(existing.Address)) + { + IPAddress selectedAddress = SelectPreferredAddressLocked(existing); + changed |= !Equals(existing.Address, selectedAddress); + existing.Address = selectedAddress; + } + + if (!isNew) { - changed |= !Equals(existing.Address, address) - || existing.Port != advertisement.ServerPort + changed |= existing.Port != advertisement.ServerPort || existing.RequiresPassword != advertisement.RequiresPassword || !string.Equals(existing.NetworkStackId, advertisement.NetworkStackId, StringComparison.Ordinal) || !string.Equals(existing.ServerName, advertisement.ServerName, StringComparison.Ordinal) || !string.Equals(existing.Motd, advertisement.Motd, StringComparison.Ordinal); } - existing.Address = address; existing.Port = advertisement.ServerPort; existing.RequiresPassword = advertisement.RequiresPassword; existing.NetworkStackId = advertisement.NetworkStackId; @@ -690,6 +701,112 @@ private bool PruneExpiredLocked(long nowTicks) return true; } + private static void PruneAddressCandidatesLocked(DiscoveredServer server, long nowTicks) + { + List expired = null; + foreach (KeyValuePair pair in server.AddressLastSeen) + { + if (nowTicks - pair.Value <= EntryLifetimeTicks) + { + continue; + } + + expired ??= new List(); + expired.Add(pair.Key); + } + + if (expired == null) + { + return; + } + + foreach (IPAddress address in expired) + { + server.AddressLastSeen.Remove(address); + } + } + + private static void TrackAddressLocked(DiscoveredServer server, IPAddress address, long nowTicks) + { + if (!server.AddressLastSeen.ContainsKey(address) + && server.AddressLastSeen.Count >= MaxAddressesPerServer) + { + IPAddress oldestAddress = null; + long oldestTicks = long.MaxValue; + foreach (KeyValuePair pair in server.AddressLastSeen) + { + if (Equals(pair.Key, server.Address) || pair.Value >= oldestTicks) + { + continue; + } + + oldestAddress = pair.Key; + oldestTicks = pair.Value; + } + + if (oldestAddress != null) + { + server.AddressLastSeen.Remove(oldestAddress); + } + else + { + return; + } + } + + server.AddressLastSeen[address] = nowTicks; + } + + private static IPAddress SelectPreferredAddressLocked(DiscoveredServer server) + { + IPAddress selected = null; + foreach (IPAddress candidate in server.AddressLastSeen.Keys) + { + if (selected == null || CompareAddressPreference(candidate, selected) < 0) + { + selected = candidate; + } + } + return selected; + } + + private static int CompareAddressPreference(IPAddress left, IPAddress right) + { + int rankComparison = AddressPreferenceRank(left).CompareTo(AddressPreferenceRank(right)); + if (rankComparison != 0) + { + return rankComparison; + } + + byte[] leftBytes = left.GetAddressBytes(); + byte[] rightBytes = right.GetAddressBytes(); + int lengthComparison = leftBytes.Length.CompareTo(rightBytes.Length); + if (lengthComparison != 0) + { + return lengthComparison; + } + + for (int i = 0; i < leftBytes.Length; i++) + { + int byteComparison = leftBytes[i].CompareTo(rightBytes[i]); + if (byteComparison != 0) + { + return byteComparison; + } + } + return left.ScopeId.CompareTo(right.ScopeId); + } + + private static int AddressPreferenceRank(IPAddress address) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254 ? 1 : 0; + } + return address.IsIPv6LinkLocal ? 3 : 2; + } + private void RemoveOldestServerLocked() { Guid oldestId = Guid.Empty; diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index 11231de7be..78ab9d31d1 100644 --- a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs +++ b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs @@ -52,7 +52,7 @@ public static void AddToMenu() private List _entries = new List(); private readonly Dictionary _rows = new(); private string _editingId; - private readonly List _subscribedSources = new List(); + private readonly Dictionary _subscribedSources = new Dictionary(); private static bool IsDefault(ServerDirectoryEntry entry) => entry != null && SavedServersDirectorySource.IsDefaultEntryId(entry.Id); @@ -173,17 +173,18 @@ private void SubscribeSourceEvents() BasisServerDirectoryRegistry.SourcesChanged += OnSourcesChanged; foreach (IServerDirectorySource source in BasisServerDirectoryRegistry.Sources) { - source.SourceChanged += OnSourceChanged; - _subscribedSources.Add(source); + Action handler = () => OnSourceChanged(source); + source.SourceChanged += handler; + _subscribedSources.Add(source, handler); } } private void UnsubscribeSourceEvents() { BasisServerDirectoryRegistry.SourcesChanged -= OnSourcesChanged; - foreach (IServerDirectorySource source in _subscribedSources) + foreach (KeyValuePair subscription in _subscribedSources) { - source.SourceChanged -= OnSourceChanged; + subscription.Key.SourceChanged -= subscription.Value; } _subscribedSources.Clear(); } @@ -194,9 +195,11 @@ private void OnSourcesChanged() _ = ReloadEntriesAsync(probeAfter: true, autoConnectAfter: false); } - private void OnSourceChanged() + private void OnSourceChanged(IServerDirectorySource source) { - _ = ReloadEntriesAsync(probeAfter: true, autoConnectAfter: false); + bool probeAfter = source == null + || !string.Equals(source.SourceId, LanServersDirectorySource.Id, StringComparison.OrdinalIgnoreCase); + _ = ReloadEntriesAsync(probeAfter, autoConnectAfter: false); } private async Task ReloadEntriesAsync(bool probeAfter, bool autoConnectAfter) From d65d3889c2585898b89edfd7752d5dfbde56927e Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 02:41:55 +0000 Subject: [PATCH 03/25] Add dedicated server LAN announcements --- .../BasisNetworkCore/BasisConfigXmlDocs.cs | 1 + .../BasisLanServerAnnouncer.cs | 916 ++++++++++++++++++ .../BasisServerConfiguration.cs | 4 +- .../BasisNetworkServer/NetworkServer.cs | 25 + .../BasisConsoleCommands.cs | 5 +- Basis Server/BasisServerConsole/Program.cs | 1 + .../Networking/BasisLanDiscoveryProtocol.cs | 156 +-- .../Networking/BasisLanMdnsDiscovery.cs | 95 -- .../BasisNetworkCore/BasisConfigXmlDocs.cs | 1 + .../BasisLanServerAnnouncer.cs | 916 ++++++++++++++++++ .../BasisLanServerAnnouncer.cs.meta | 2 + .../BasisServerConfiguration.cs | 4 +- .../BasisNetworkServer/NetworkServer.cs | 25 + .../BasisConsoleCommands.cs | 5 +- .../BasisServerConsole/Program.cs | 1 + 15 files changed, 1915 insertions(+), 242 deletions(-) create mode 100644 Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs.meta diff --git a/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs b/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs index ba4fe59eff..e1fac457a6 100644 --- a/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs +++ b/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs @@ -146,6 +146,7 @@ private static void RegisterServerConfig() t.Fields.Add(new FieldDoc("ConfigVersion", " Config schema version, managed automatically. When the server gains new settings this file is rewritten to add them (with their defaults) and this number is bumped — don't edit by hand. ")); t.Fields.Add(new FieldDoc("PeerLimit", " Maximum number of simultaneously connected peers (players). int. Default 65535. ", " ===== Networking / listener ===== ")); t.Fields.Add(new FieldDoc("SetPort", " UDP port the server binds and listens on; clients connect to this. ushort, range 1-65535. ")); + t.Fields.Add(new FieldDoc("AnnounceToLan", " Advertise this server to Basis clients on the local network using UDP broadcast/multicast and mDNS/DNS-SD. true|false. Default false. Applied on the next server start. ")); t.Fields.Add(new FieldDoc("ServerName", " Display name shown as the row title in client server-list UIs (server-info query). string. ")); t.Fields.Add(new FieldDoc("ServerMotd", " Short message-of-the-day returned alongside the server name. string; empty = none. ")); t.Fields.Add(new FieldDoc("EnableStatistics", " Collect transport statistics (per-peer/packet counters) and run the stats worker; surfaced via the health endpoint. true|false. ")); diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs new file mode 100644 index 0000000000..9b7f644337 --- /dev/null +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -0,0 +1,916 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Basis.Network.Core +{ + /// + /// Announces a running Basis server on the local network through the Basis LAN + /// datagram protocol and standard mDNS/DNS-SD. This class has no Unity dependency, + /// so both dedicated servers and in-client hosts use the same implementation. + /// + public sealed class BasisLanServerAnnouncer : IDisposable + { + public const int DiscoveryPort = 42960; + public const int MdnsPort = 5353; + + private const uint PacketMagic = 0xBA515201u; + private const ushort PacketVersion = 1; + private const int InitialAdvertisementDelayMs = 750; + private const int AdvertisementIntervalMs = 1500; + private const int MaxStackIdBytes = 64; + private const int MaxServerNameBytes = 128; + private const int MaxMotdBytes = 384; + private const string MdnsServiceType = "_basisvr._udp.local"; + private const string MdnsMetaServiceType = "_services._dns-sd._udp.local"; + private const uint MdnsTtlSeconds = 120; + private const ushort DnsA = 1; + private const ushort DnsPtr = 12; + private const ushort DnsTxt = 16; + private const ushort DnsAaaa = 28; + private const ushort DnsSrv = 33; + private const ushort DnsAny = 255; + private const ushort DnsIn = 1; + private const ushort DnsFlushIn = 0x8001; + + private static readonly IPAddress DiscoveryMulticastAddress = IPAddress.Parse("239.255.42.99"); + private static readonly IPAddress MdnsIpv4Address = IPAddress.Parse("224.0.0.251"); + private static readonly IPAddress MdnsIpv6Address = IPAddress.Parse("ff02::fb"); + + private readonly object _gate = new object(); + private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); + private readonly List _ipv4Interfaces = new List(); + private readonly List _ipv6Interfaces = new List(); + private readonly List _advertisedAddresses = new List(); + private readonly byte[] _customPayload; + private readonly byte[] _mdnsAnnouncement; + private readonly byte[] _mdnsMetaResponse; + private readonly byte[] _mdnsCombinedResponse; + private readonly byte[] _mdnsGoodbye; + private readonly string _instanceName; + private readonly string _hostName; + private readonly ushort _serverPort; + private readonly List _txtValues; + + private UdpClient _customSender; + private UdpClient _mdnsIpv4; + private UdpClient _mdnsIpv6; + private long _lastMdnsResponseTicks; + private int _lastMdnsResponseKey; + private string _lastMdnsResponseRemote; + private bool _disposed; + + public Guid InstanceId { get; } + public bool IsRunning + { + get + { + lock (_gate) + { + return !_disposed; + } + } + } + + public BasisLanServerAnnouncer( + ushort serverPort, + string networkStackId, + string serverName, + string motd, + bool requiresPassword) + { + if (serverPort == 0) + { + throw new ArgumentOutOfRangeException(nameof(serverPort)); + } + + InstanceId = Guid.NewGuid(); + _serverPort = serverPort; + string id = InstanceId.ToString("N"); + string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) + ? BasisNetworkStackRegistry.DefaultId + : networkStackId; + string effectiveServerName = string.IsNullOrWhiteSpace(serverName) ? "Basis Server" : serverName; + string effectiveMotd = motd ?? string.Empty; + + _instanceName = $"Basis-{id}.{MdnsServiceType}"; + _hostName = $"basis-{id}.local"; + _txtValues = new List + { + TxtValue("protocol", "1"), + TxtValue("id", id), + TxtValue("stack", effectiveStackId), + TxtValue("name", effectiveServerName), + TxtValue("motd", effectiveMotd), + TxtValue("pwd", requiresPassword ? "1" : "0"), + }; + + DiscoverInterfaces(); + _customPayload = BuildCustomPayload( + InstanceId, + serverPort, + requiresPassword, + effectiveStackId, + effectiveServerName, + effectiveMotd); + _mdnsAnnouncement = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: false, includeService: true); + _mdnsMetaResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: false); + _mdnsCombinedResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: true); + _mdnsGoodbye = BuildMdnsPacket(0, includeMeta: false, includeService: true); + + TryCreateSockets(); + if (_customSender == null && _mdnsIpv4 == null && _mdnsIpv6 == null) + { + _cancellation.Dispose(); + throw new SocketException((int)SocketError.AddressFamilyNotSupported); + } + + if (_mdnsIpv4 != null) + { + _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv4, _cancellation.Token)); + } + if (_mdnsIpv6 != null) + { + _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv6, _cancellation.Token)); + } + if (_customSender != null) + { + _ = Task.Run(() => CustomAdvertisementLoopAsync(_cancellation.Token)); + } + if (_mdnsIpv4 != null || _mdnsIpv6 != null) + { + _ = Task.Run(() => InitialMdnsAnnouncementsAsync(_cancellation.Token)); + } + } + + private void TryCreateSockets() + { + try + { + _customSender = new UdpClient(AddressFamily.InterNetwork) + { + EnableBroadcast = true, + MulticastLoopback = true, + }; + _customSender.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); + } + catch (Exception ex) + { + _customSender?.Dispose(); + _customSender = null; + BNL.LogWarning($"Basis LAN datagram announcements are unavailable: {ex.Message}"); + } + + if (Socket.OSSupportsIPv4) + { + try { _mdnsIpv4 = CreateMdnsIpv4Socket(); } + catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS announcements are unavailable: {ex.Message}"); } + } + if (Socket.OSSupportsIPv6) + { + try { _mdnsIpv6 = CreateMdnsIpv6Socket(); } + catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS announcements are unavailable: {ex.Message}"); } + } + } + + private UdpClient CreateMdnsIpv4Socket() + { + UdpClient client = new UdpClient(AddressFamily.InterNetwork); + try + { + ConfigureSharedSocket(client); + client.Client.Bind(new IPEndPoint(IPAddress.Any, MdnsPort)); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.AddMembership, + new MulticastOption(MdnsIpv4Address, address)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(MdnsIpv4Address); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private UdpClient CreateMdnsIpv6Socket() + { + UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); + try + { + ConfigureSharedSocket(client); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); + client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, MdnsPort)); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (int index in _ipv6Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IPv6, + SocketOptionName.AddMembership, + new IPv6MulticastOption(MdnsIpv6Address, index)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(MdnsIpv6Address); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static void ConfigureSharedSocket(UdpClient client) + { + try { client.Client.ExclusiveAddressUse = false; } + catch (PlatformNotSupportedException) { } + client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + } + + private async Task CustomAdvertisementLoopAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(InitialAdvertisementDelayMs, cancellationToken).ConfigureAwait(false); + while (!cancellationToken.IsCancellationRequested) + { + SendCustomAdvertisement(); + await Task.Delay(AdvertisementIntervalMs, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis LAN announcements stopped: {ex.Message}"); + } + } + } + + private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(20, cancellationToken).ConfigureAwait(false); + SendMdnsMulticast(_mdnsAnnouncement); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + SendMdnsMulticast(_mdnsAnnouncement); + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis mDNS startup announcement failed: {ex.Message}"); + } + } + } + + private async Task ReceiveMdnsLoopAsync(UdpClient client, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); + ProcessMdnsQuery(result.Buffer, result.RemoteEndPoint); + } + catch (ObjectDisposedException) { return; } + catch (SocketException) + { + if (cancellationToken.IsCancellationRequested) return; + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis mDNS receive failed: {ex.Message}"); + } + } + } + } + + private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) + { + if (!TryMatchMdnsQuery( + packet, + out bool wantsUnicast, + out bool includeMeta, + out bool includeService, + out ushort queryId)) + { + return; + } + + lock (_gate) + { + if (_disposed) + { + return; + } + + int responseKey = (includeMeta ? 1 : 0) | (includeService ? 2 : 0); + string responseRemote = remoteEndPoint?.ToString() ?? string.Empty; + long now = Stopwatch.GetTimestamp(); + if (_lastMdnsResponseTicks != 0 + && _lastMdnsResponseKey == responseKey + && string.Equals(_lastMdnsResponseRemote, responseRemote, StringComparison.Ordinal) + && now - _lastMdnsResponseTicks < Stopwatch.Frequency / 50) + { + return; + } + _lastMdnsResponseTicks = now; + _lastMdnsResponseKey = responseKey; + _lastMdnsResponseRemote = responseRemote; + + byte[] response = includeMeta + ? (includeService ? _mdnsCombinedResponse : _mdnsMetaResponse) + : _mdnsAnnouncement; + bool legacyUnicast = remoteEndPoint != null && remoteEndPoint.Port != MdnsPort; + if (legacyUnicast) + { + response = WithDnsId(response, queryId); + } + + if (wantsUnicast || legacyUnicast) + { + SendMdnsUnicast(response, remoteEndPoint); + } + else + { + SendMdnsMulticast(response); + } + } + } + + private void SendCustomAdvertisement() + { + UdpClient sender; + lock (_gate) + { + if (_disposed) return; + sender = _customSender; + } + if (sender == null) return; + + HashSet sent = new HashSet(StringComparer.Ordinal); + SendCustomTo(sender, new IPEndPoint(DiscoveryMulticastAddress, DiscoveryPort), sent); + SendCustomTo(sender, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort), sent); + + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + foreach (UnicastIPAddressInformation addressInformation in networkInterface.GetIPProperties().UnicastAddresses) + { + if (addressInformation.Address.AddressFamily != AddressFamily.InterNetwork + || addressInformation.IPv4Mask == null) + { + continue; + } + + byte[] addressBytes = addressInformation.Address.GetAddressBytes(); + byte[] maskBytes = addressInformation.IPv4Mask.GetAddressBytes(); + if (addressBytes.Length != 4 || maskBytes.Length != 4) continue; + + byte[] broadcastBytes = new byte[4]; + for (int i = 0; i < broadcastBytes.Length; i++) + { + broadcastBytes[i] = (byte)(addressBytes[i] | ~maskBytes[i]); + } + SendCustomTo(sender, new IPEndPoint(new IPAddress(broadcastBytes), DiscoveryPort), sent); + } + } + } + catch (Exception) + { + // Multicast and limited broadcast were already attempted. + } + } + + private void SendCustomTo(UdpClient sender, IPEndPoint endpoint, HashSet sent) + { + if (!sent.Add(endpoint.ToString())) return; + try { sender.Send(_customPayload, _customPayload.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + + private void SendMdnsMulticast(byte[] packet) + { + if (packet == null || packet.Length == 0) return; + + UdpClient ipv4 = _mdnsIpv4; + if (ipv4 != null) + { + IPEndPoint endpoint = new IPEndPoint(MdnsIpv4Address, MdnsPort); + bool sent = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + ipv4.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.MulticastInterface, + address.GetAddressBytes()); + ipv4.Send(packet, packet.Length, endpoint); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { break; } + } + if (!sent) + { + try { ipv4.Send(packet, packet.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + UdpClient ipv6 = _mdnsIpv6; + if (ipv6 != null) + { + bool sent = false; + foreach (int index in _ipv6Interfaces) + { + try + { + ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); + IPAddress scoped = new IPAddress(MdnsIpv6Address.GetAddressBytes(), index); + ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, MdnsPort)); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { break; } + } + if (!sent) + { + try { ipv6.Send(packet, packet.Length, new IPEndPoint(MdnsIpv6Address, MdnsPort)); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + } + + private void SendMdnsUnicast(byte[] packet, IPEndPoint endpoint) + { + if (packet == null || endpoint == null) return; + try + { + UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork + ? _mdnsIpv4 + : _mdnsIpv6; + client?.Send(packet, packet.Length, endpoint); + } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + + private void DiscoverInterfaces() + { + HashSet ipv4 = new HashSet(); + HashSet ipv6 = new HashSet(); + HashSet advertised = new HashSet(); + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + foreach (UnicastIPAddressInformation information in properties.UnicastAddresses) + { + IPAddress address = information.Address; + if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) continue; + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + ipv4.Add(address); + advertised.Add(address); + } + else if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv6LinkLocal) + { + advertised.Add(address); + } + } + + try + { + IPv6InterfaceProperties propertiesV6 = properties.GetIPv6Properties(); + if (propertiesV6 != null && propertiesV6.Index > 0) + { + ipv6.Add(propertiesV6.Index); + } + } + catch (Exception ex) when (ex is NetworkInformationException + || ex is PlatformNotSupportedException + || ex is NotImplementedException) { } + } + } + catch (Exception) { } + + _ipv4Interfaces.AddRange(ipv4); + _ipv6Interfaces.AddRange(ipv6); + _advertisedAddresses.AddRange(advertised); + } + + private bool TryMatchMdnsQuery( + byte[] packet, + out bool wantsUnicast, + out bool includeMeta, + out bool includeService, + out ushort queryId) + { + wantsUnicast = false; + includeMeta = false; + includeService = false; + queryId = 0; + if (packet == null || packet.Length < 12 || packet.Length > 9000) + { + return false; + } + + try + { + int offset = 0; + queryId = ReadUInt16(packet, ref offset); + ushort flags = ReadUInt16(packet, ref offset); + ushort questionCount = ReadUInt16(packet, ref offset); + ReadUInt16(packet, ref offset); + ReadUInt16(packet, ref offset); + ReadUInt16(packet, ref offset); + if ((flags & 0x8000) != 0 || (flags & 0x780F) != 0 || questionCount > 64) + { + return false; + } + + bool matched = false; + for (int i = 0; i < questionCount; i++) + { + if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 4) + { + return false; + } + ushort type = ReadUInt16(packet, ref offset); + ushort recordClass = ReadUInt16(packet, ref offset); + if ((recordClass & 0x7FFF) != DnsIn) continue; + + bool metaRequested = EqualName(name, MdnsMetaServiceType) + && (type == DnsPtr || type == DnsAny); + bool serviceRequested = (EqualName(name, MdnsServiceType) && (type == DnsPtr || type == DnsAny)) + || (EqualName(name, _instanceName) && (type == DnsSrv || type == DnsTxt || type == DnsAny)) + || (EqualName(name, _hostName) && (type == DnsA || type == DnsAaaa || type == DnsAny)); + if (!metaRequested && !serviceRequested) continue; + + matched = true; + includeMeta |= metaRequested; + includeService |= serviceRequested; + wantsUnicast |= (recordClass & 0x8000) != 0; + } + return matched; + } + catch (Exception ex) when (ex is IOException || ex is ArgumentException || ex is DecoderFallbackException) + { + return false; + } + } + + private byte[] BuildMdnsPacket(uint ttl, bool includeMeta, bool includeService) + { + List answers = new List(); + List additional = new List(); + if (includeMeta) + { + answers.Add(new DnsRecord(MdnsMetaServiceType, DnsPtr, DnsIn, ttl, EncodeName(MdnsServiceType))); + } + if (includeService) + { + answers.Add(new DnsRecord(MdnsServiceType, DnsPtr, DnsIn, ttl, EncodeName(_instanceName))); + additional.Add(new DnsRecord(_instanceName, DnsSrv, DnsFlushIn, ttl, EncodeSrv(_serverPort, _hostName))); + additional.Add(new DnsRecord(_instanceName, DnsTxt, DnsFlushIn, ttl, EncodeTxt(_txtValues))); + foreach (IPAddress address in _advertisedAddresses) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + additional.Add(new DnsRecord(_hostName, DnsA, DnsFlushIn, ttl, address.GetAddressBytes())); + } + else if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + additional.Add(new DnsRecord(_hostName, DnsAaaa, DnsFlushIn, ttl, address.GetAddressBytes())); + } + } + } + + using MemoryStream stream = new MemoryStream(512); + WriteUInt16(stream, 0); + WriteUInt16(stream, 0x8400); + WriteUInt16(stream, 0); + WriteUInt16(stream, checked((ushort)answers.Count)); + WriteUInt16(stream, 0); + WriteUInt16(stream, checked((ushort)additional.Count)); + foreach (DnsRecord record in answers) WriteDnsRecord(stream, record); + foreach (DnsRecord record in additional) WriteDnsRecord(stream, record); + if (stream.Length > 9000) throw new InvalidOperationException("Basis mDNS announcement is too large."); + return stream.ToArray(); + } + + private static byte[] WithDnsId(byte[] packet, ushort id) + { + byte[] response = (byte[])packet.Clone(); + response[0] = (byte)(id >> 8); + response[1] = (byte)id; + return response; + } + + private static byte[] BuildCustomPayload( + Guid instanceId, + ushort serverPort, + bool requiresPassword, + string networkStackId, + string serverName, + string motd) + { + using MemoryStream stream = new MemoryStream(256); + using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); + writer.Write(PacketMagic); + writer.Write(PacketVersion); + writer.Write(instanceId.ToByteArray()); + writer.Write(serverPort); + writer.Write(requiresPassword ? (byte)1 : (byte)0); + WritePacketString(writer, networkStackId, MaxStackIdBytes); + WritePacketString(writer, serverName, MaxServerNameBytes); + WritePacketString(writer, motd, MaxMotdBytes); + writer.Flush(); + return stream.ToArray(); + } + + private static void WritePacketString(BinaryWriter writer, string value, int maxBytes) + { + byte[] bytes = Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes)); + writer.Write((ushort)bytes.Length); + writer.Write(bytes); + } + + private static string TxtValue(string key, string value) + { + int budget = 254 - Encoding.UTF8.GetByteCount(key); + return key + "=" + LimitUtf8(value, Math.Max(0, budget)); + } + + private static string LimitUtf8(string value, int maxBytes) + { + if (string.IsNullOrEmpty(value) || maxBytes <= 0) return string.Empty; + if (Encoding.UTF8.GetByteCount(value) <= maxBytes) return value; + + int length = value.Length; + while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) + { + length--; + if (length > 0 && char.IsHighSurrogate(value[length - 1])) length--; + } + return length == 0 ? string.Empty : value.Substring(0, length); + } + + private readonly struct DnsRecord + { + public readonly string Name; + public readonly ushort Type; + public readonly ushort Class; + public readonly uint Ttl; + public readonly byte[] Data; + + public DnsRecord(string name, ushort type, ushort recordClass, uint ttl, byte[] data) + { + Name = name; + Type = type; + Class = recordClass; + Ttl = ttl; + Data = data; + } + } + + private static void WriteDnsRecord(Stream stream, DnsRecord record) + { + WriteBytes(stream, EncodeName(record.Name)); + WriteUInt16(stream, record.Type); + WriteUInt16(stream, record.Class); + WriteUInt32(stream, record.Ttl); + WriteUInt16(stream, checked((ushort)record.Data.Length)); + WriteBytes(stream, record.Data); + } + + private static byte[] EncodeSrv(ushort port, string target) + { + using MemoryStream stream = new MemoryStream(64); + WriteUInt16(stream, 0); + WriteUInt16(stream, 0); + WriteUInt16(stream, port); + WriteBytes(stream, EncodeName(target)); + return stream.ToArray(); + } + + private static byte[] EncodeTxt(List values) + { + using MemoryStream stream = new MemoryStream(256); + foreach (string value in values) + { + byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + if (bytes.Length > 255) throw new InvalidOperationException("Basis mDNS TXT value is too large."); + stream.WriteByte((byte)bytes.Length); + WriteBytes(stream, bytes); + } + return stream.ToArray(); + } + + private static byte[] EncodeName(string value) + { + string normalized = NormalizeName(value); + using MemoryStream stream = new MemoryStream(128); + if (normalized.Length != 0) + { + foreach (string label in normalized.Split('.')) + { + byte[] bytes = Encoding.UTF8.GetBytes(label); + if (bytes.Length == 0 || bytes.Length > 63) + { + throw new InvalidOperationException("Invalid mDNS label."); + } + stream.WriteByte((byte)bytes.Length); + WriteBytes(stream, bytes); + } + } + stream.WriteByte(0); + if (stream.Length > 255) throw new InvalidOperationException("Invalid mDNS name."); + return stream.ToArray(); + } + + private static bool ReadName(byte[] packet, ref int offset, out string value) + { + value = string.Empty; + if (offset < 0 || offset >= packet.Length) return false; + + UTF8Encoding strictUtf8 = new UTF8Encoding(false, true); + StringBuilder result = new StringBuilder(64); + HashSet pointers = null; + int position = offset; + int resume = -1; + int encodedLength = 1; + while (true) + { + if (position >= packet.Length) return false; + byte length = packet[position++]; + if (length == 0) + { + offset = resume >= 0 ? resume : position; + value = result.ToString(); + return true; + } + if ((length & 0xC0) == 0xC0) + { + if (position >= packet.Length) return false; + int pointer = ((length & 0x3F) << 8) | packet[position++]; + if (pointer >= packet.Length) return false; + resume = resume >= 0 ? resume : position; + pointers ??= new HashSet(); + if (!pointers.Add(pointer) || pointers.Count > 32) return false; + position = pointer; + continue; + } + if ((length & 0xC0) != 0 || length > 63 || position + length > packet.Length) + { + return false; + } + encodedLength += length + 1; + if (encodedLength > 255) return false; + if (result.Length != 0) result.Append('.'); + result.Append(strictUtf8.GetString(packet, position, length)); + position += length; + } + } + + private static ushort ReadUInt16(byte[] packet, ref int offset) + { + if (packet.Length - offset < 2) throw new IOException(); + ushort value = (ushort)((packet[offset] << 8) | packet[offset + 1]); + offset += 2; + return value; + } + + private static void WriteUInt16(Stream stream, ushort value) + { + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static void WriteUInt32(Stream stream, uint value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static void WriteBytes(Stream stream, byte[] bytes) + { + stream.Write(bytes, 0, bytes.Length); + } + + private static bool EqualName(string left, string right) + { + return string.Equals(NormalizeName(left), NormalizeName(right), StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeName(string value) + { + return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.'); + } + + private static bool IsUsableAddress(IPAddress address) + { + return address != null + && !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.IPv6Any) + && !address.Equals(IPAddress.Broadcast) + && !address.IsIPv6Multicast; + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + + try + { + SendMdnsMulticast(_mdnsGoodbye); + SendMdnsMulticast(_mdnsGoodbye); + } + catch (Exception ex) + { + BNL.LogWarning($"Basis mDNS goodbye failed: {ex.Message}"); + } + + try { _customSender?.Close(); } + catch (ObjectDisposedException) { } + try { _mdnsIpv4?.Close(); } + catch (ObjectDisposedException) { } + try { _mdnsIpv6?.Close(); } + catch (ObjectDisposedException) { } + + _customSender = null; + _mdnsIpv4 = null; + _mdnsIpv6 = null; + _cancellation.Dispose(); + } + } + } +} diff --git a/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs b/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs index e909d8d1dd..f4c5d8a070 100644 --- a/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs +++ b/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs @@ -19,12 +19,14 @@ public class Configuration /// doc comments). Newly-added settings are healed automatically regardless: on load a /// config missing any current field is re-saved with the new settings added. /// - public const int CurrentConfigVersion = 4; + public const int CurrentConfigVersion = 5; /// Schema version stamped into config.xml; 0 = a pre-versioning file that is upgraded on load. public int ConfigVersion = 0; public int PeerLimit = ushort.MaxValue; public ushort SetPort = 4296; + /// When true, advertise this server to Basis clients on the local network through UDP broadcast/multicast and mDNS. Disabled by default. + public bool AnnounceToLan = false; /// Display name returned by the unconnected server-info query — what shows up as the row title in a client server-list UI. public string ServerName = "Basis Server"; /// Short MOTD returned alongside the server name in the info query response. Two short lines render cleanly in the list UI. diff --git a/Basis Server/BasisNetworkServer/NetworkServer.cs b/Basis Server/BasisNetworkServer/NetworkServer.cs index 1ce02f3a6f..8f8d381889 100644 --- a/Basis Server/BasisNetworkServer/NetworkServer.cs +++ b/Basis Server/BasisNetworkServer/NetworkServer.cs @@ -24,6 +24,7 @@ public static class NetworkServer public static ConcurrentDictionary AuthenticatedPeers = new(); public static readonly object AuthenticatedPeerTag = new object(); public static Configuration Configuration; + private static BasisLanServerAnnouncer _lanServerAnnouncer; /// /// Allow-list consulted at /// when is set to AllowList. @@ -93,6 +94,25 @@ public static void StartServer(Configuration configuration) SetupServer(configuration); SubscribeEvents(Configuration); + if (configuration.AnnounceToLan) + { + try + { + _lanServerAnnouncer = new BasisLanServerAnnouncer( + configuration.SetPort, + configuration.NetworkStackId, + configuration.ServerName, + configuration.ServerMotd, + configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); + BNL.Log($"LAN announcements enabled on UDP {configuration.SetPort}."); + } + catch (Exception ex) + { + _lanServerAnnouncer = null; + BNL.LogWarning($"LAN announcements could not start: {ex.Message}"); + } + } + if (configuration.EnableStatistics) { BasisStatistics.StartWorkerThread(Server); @@ -105,6 +125,11 @@ public static void StartServer(Configuration configuration) public static void StopServer() { + BasisLanServerAnnouncer lanServerAnnouncer = _lanServerAnnouncer; + _lanServerAnnouncer = null; + try { lanServerAnnouncer?.Dispose(); } + catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } + if (Server == null) return; try { diff --git a/Basis Server/BasisServerConsole/BasisConsoleCommands.cs b/Basis Server/BasisServerConsole/BasisConsoleCommands.cs index 788ab8fedb..bcfff987ac 100644 --- a/Basis Server/BasisServerConsole/BasisConsoleCommands.cs +++ b/Basis Server/BasisServerConsole/BasisConsoleCommands.cs @@ -557,8 +557,9 @@ public static void HandleStatus(string[] args) public static void HandleShutdown(string[] args) { BNL.Log("Shutting down the server..."); - Program.isRunning = false; // Gracefully stop the server - Environment.Exit(0); // Exit the application + Program.isRunning = false; + NetworkServer.StopServer(); + Environment.Exit(0); } public static void HandleHelp(string[] args) diff --git a/Basis Server/BasisServerConsole/Program.cs b/Basis Server/BasisServerConsole/Program.cs index 923d68688b..2d51a5fcd4 100644 --- a/Basis Server/BasisServerConsole/Program.cs +++ b/Basis Server/BasisServerConsole/Program.cs @@ -86,6 +86,7 @@ public static void Main(string[] args) { BNL.Log("Shutting down server..."); isRunning = false; + NetworkServer.StopServer(); shutdownEvent.Set(); // Signal the main thread to exit #if !UNITY_2017_1_OR_NEWER Api?.Dispose(); diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs index cb4e7b86c6..0dc3370f00 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs @@ -161,11 +161,8 @@ private static bool TryReadString(BinaryReader reader, MemoryStream stream, int /// public static class BasisLanServerAdvertiser { - private const int InitialAdvertisementDelayMs = 750; - private const int AdvertisementIntervalMs = 1500; private static readonly object Gate = new object(); - private static CancellationTokenSource _cancellation; - private static BasisLanMdnsAdvertiser _mdnsAdvertiser; + private static Basis.Network.Core.BasisLanServerAnnouncer _announcer; public static bool IsRunning { @@ -173,7 +170,7 @@ public static bool IsRunning { lock (Gate) { - return _cancellation != null; + return _announcer != null && _announcer.IsRunning; } } } @@ -183,154 +180,31 @@ public static bool IsRunning public static void Start(ushort serverPort, string networkStackId, string serverName, string motd, bool requiresPassword) { - string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) - ? BasisNetworkStackRegistry.DefaultId - : networkStackId; - BasisLanDiscoveryProtocol.Advertisement advertisement = new BasisLanDiscoveryProtocol.Advertisement( - Guid.NewGuid(), + Basis.Network.Core.BasisLanServerAnnouncer announcer = new Basis.Network.Core.BasisLanServerAnnouncer( serverPort, - requiresPassword, - effectiveStackId, - string.IsNullOrWhiteSpace(serverName) ? "Basis Server" : serverName, - motd ?? string.Empty); - byte[] payload = BasisLanDiscoveryProtocol.Serialize(advertisement); - BasisLanMdnsAdvertiser mdnsAdvertiser = null; - try - { - mdnsAdvertiser = new BasisLanMdnsAdvertiser(advertisement); - } - catch (Exception ex) - { - BasisDebug.LogWarning($"LAN mDNS advertisement could not start: {ex.Message}"); - } + networkStackId, + serverName, + motd, + requiresPassword); - CancellationTokenSource cancellation = new CancellationTokenSource(); - CancellationTokenSource previousCancellation; - BasisLanMdnsAdvertiser previousMdnsAdvertiser; + Basis.Network.Core.BasisLanServerAnnouncer previousAnnouncer; lock (Gate) { - previousCancellation = _cancellation; - previousMdnsAdvertiser = _mdnsAdvertiser; - _cancellation = cancellation; - _mdnsAdvertiser = mdnsAdvertiser; + previousAnnouncer = _announcer; + _announcer = announcer; } - - StopResources(previousCancellation, previousMdnsAdvertiser); - _ = Task.Run(() => AdvertiseAsync(payload, cancellation.Token)); + previousAnnouncer?.Dispose(); } public static void Stop() { - CancellationTokenSource cancellation; - BasisLanMdnsAdvertiser mdnsAdvertiser; + Basis.Network.Core.BasisLanServerAnnouncer announcer; lock (Gate) { - cancellation = _cancellation; - mdnsAdvertiser = _mdnsAdvertiser; - _cancellation = null; - _mdnsAdvertiser = null; - } - - StopResources(cancellation, mdnsAdvertiser); - } - - private static void StopResources(CancellationTokenSource cancellation, BasisLanMdnsAdvertiser mdnsAdvertiser) - { - if (cancellation != null) - { - try { cancellation.Cancel(); } - catch (ObjectDisposedException) { } - cancellation.Dispose(); - } - mdnsAdvertiser?.Dispose(); - } - - private static async Task AdvertiseAsync(byte[] payload, CancellationToken cancellationToken) - { - try - { - using UdpClient sender = new UdpClient(AddressFamily.InterNetwork); - sender.EnableBroadcast = true; - sender.MulticastLoopback = true; - sender.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); - - await Task.Delay(InitialAdvertisementDelayMs, cancellationToken).ConfigureAwait(false); - while (!cancellationToken.IsCancellationRequested) - { - SendAdvertisement(sender, payload); - await Task.Delay(AdvertisementIntervalMs, cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { + announcer = _announcer; + _announcer = null; } - catch (ObjectDisposedException) - { - } - catch (Exception ex) - { - BasisDebug.LogWarning($"LAN server advertisement stopped: {ex.Message}"); - } - } - - private static void SendAdvertisement(UdpClient sender, byte[] payload) - { - HashSet sentEndpoints = new HashSet(StringComparer.Ordinal); - SendTo(sender, payload, new IPEndPoint(BasisLanDiscoveryProtocol.MulticastAddress, BasisLanDiscoveryProtocol.DiscoveryPort), sentEndpoints); - SendTo(sender, payload, new IPEndPoint(IPAddress.Broadcast, BasisLanDiscoveryProtocol.DiscoveryPort), sentEndpoints); - - try - { - foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) - { - if (networkInterface.OperationalStatus != OperationalStatus.Up - || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) - { - continue; - } - - foreach (UnicastIPAddressInformation addressInformation in networkInterface.GetIPProperties().UnicastAddresses) - { - if (addressInformation.Address.AddressFamily != AddressFamily.InterNetwork - || addressInformation.IPv4Mask == null) - { - continue; - } - - byte[] addressBytes = addressInformation.Address.GetAddressBytes(); - byte[] maskBytes = addressInformation.IPv4Mask.GetAddressBytes(); - if (addressBytes.Length != 4 || maskBytes.Length != 4) - { - continue; - } - - byte[] broadcastBytes = new byte[4]; - for (int i = 0; i < broadcastBytes.Length; i++) - { - broadcastBytes[i] = (byte)(addressBytes[i] | ~maskBytes[i]); - } - - SendTo(sender, payload, new IPEndPoint(new IPAddress(broadcastBytes), BasisLanDiscoveryProtocol.DiscoveryPort), sentEndpoints); - } - } - } - catch (Exception) - { - // Multicast and limited broadcast were already attempted above. Some Unity - // platforms expose NetworkInterface but not its IPv4 mask details. - } - } - - private static void SendTo(UdpClient sender, byte[] payload, IPEndPoint endpoint, HashSet sentEndpoints) - { - if (!sentEndpoints.Add(endpoint.ToString())) - { - return; - } - - try { sender.Send(payload, payload.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } + announcer?.Dispose(); } } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs index 3b5e447163..fde7757bc3 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs @@ -250,101 +250,6 @@ public void Dispose() } } - internal sealed class BasisLanMdnsAdvertiser : IDisposable - { - private readonly object _gate = new object(); - private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private readonly BasisLanMdnsWire.Service _service; - private readonly BasisLanMdnsTransport _transport; - private long _lastResponse; - private bool _disposed; - - public BasisLanMdnsAdvertiser(BasisLanDiscoveryProtocol.Advertisement advertisement) - { - _service = BasisLanMdnsWire.CreateService(advertisement); - try - { - _transport = new BasisLanMdnsTransport(OnPacket); - } - catch - { - _cancellation.Dispose(); - throw; - } - _ = Task.Run(() => AnnounceAsync(_cancellation.Token)); - } - - private async Task AnnounceAsync(CancellationToken cancellationToken) - { - try - { - await Task.Delay(20, cancellationToken).ConfigureAwait(false); - Announce(); - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - Announce(); - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - BasisDebug.LogWarning($"LAN mDNS announcement failed: {ex.Message}"); - } - } - - private void OnPacket(byte[] packet, IPEndPoint remote) - { - if (!BasisLanMdnsWire.TryParse(packet, out BasisLanMdnsWire.Message query) - || query.IsResponse - || !BasisLanMdnsWire.TryBuildResponse(_service, query, out byte[] response)) return; - - lock (_gate) - { - if (_disposed) return; - long now = Stopwatch.GetTimestamp(); - if (_lastResponse != 0 && now - _lastResponse < Stopwatch.Frequency / 50) return; - _lastResponse = now; - - bool unicast = remote != null && remote.Port != BasisLanMdnsWire.Port; - if (!unicast) - { - foreach (BasisLanMdnsWire.Question question in query.Questions) - { - if (question.WantsUnicast) { unicast = true; break; } - } - } - if (unicast) _transport.SendUnicast(response, remote); - else _transport.SendMulticast(response); - } - } - - private void Announce() - { - lock (_gate) - { - if (!_disposed) _transport.SendMulticast(BasisLanMdnsWire.BuildAnnouncement(_service)); - } - } - - public void Dispose() - { - lock (_gate) - { - if (_disposed) return; - _disposed = true; - try { _cancellation.Cancel(); } catch (ObjectDisposedException) { } - try - { - byte[] goodbye = BasisLanMdnsWire.BuildAnnouncement(_service, 0); - _transport.SendMulticast(goodbye); - _transport.SendMulticast(goodbye); - } - catch (Exception ex) { BasisDebug.LogWarning($"LAN mDNS goodbye failed: {ex.Message}"); } - _transport.Dispose(); - _cancellation.Dispose(); - } - } - } - internal sealed class BasisLanMdnsBrowser : IDisposable { private readonly object _gate = new object(); diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs index ba4fe59eff..e1fac457a6 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs @@ -146,6 +146,7 @@ private static void RegisterServerConfig() t.Fields.Add(new FieldDoc("ConfigVersion", " Config schema version, managed automatically. When the server gains new settings this file is rewritten to add them (with their defaults) and this number is bumped — don't edit by hand. ")); t.Fields.Add(new FieldDoc("PeerLimit", " Maximum number of simultaneously connected peers (players). int. Default 65535. ", " ===== Networking / listener ===== ")); t.Fields.Add(new FieldDoc("SetPort", " UDP port the server binds and listens on; clients connect to this. ushort, range 1-65535. ")); + t.Fields.Add(new FieldDoc("AnnounceToLan", " Advertise this server to Basis clients on the local network using UDP broadcast/multicast and mDNS/DNS-SD. true|false. Default false. Applied on the next server start. ")); t.Fields.Add(new FieldDoc("ServerName", " Display name shown as the row title in client server-list UIs (server-info query). string. ")); t.Fields.Add(new FieldDoc("ServerMotd", " Short message-of-the-day returned alongside the server name. string; empty = none. ")); t.Fields.Add(new FieldDoc("EnableStatistics", " Collect transport statistics (per-peer/packet counters) and run the stats worker; surfaced via the health endpoint. true|false. ")); diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs new file mode 100644 index 0000000000..9b7f644337 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -0,0 +1,916 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Basis.Network.Core +{ + /// + /// Announces a running Basis server on the local network through the Basis LAN + /// datagram protocol and standard mDNS/DNS-SD. This class has no Unity dependency, + /// so both dedicated servers and in-client hosts use the same implementation. + /// + public sealed class BasisLanServerAnnouncer : IDisposable + { + public const int DiscoveryPort = 42960; + public const int MdnsPort = 5353; + + private const uint PacketMagic = 0xBA515201u; + private const ushort PacketVersion = 1; + private const int InitialAdvertisementDelayMs = 750; + private const int AdvertisementIntervalMs = 1500; + private const int MaxStackIdBytes = 64; + private const int MaxServerNameBytes = 128; + private const int MaxMotdBytes = 384; + private const string MdnsServiceType = "_basisvr._udp.local"; + private const string MdnsMetaServiceType = "_services._dns-sd._udp.local"; + private const uint MdnsTtlSeconds = 120; + private const ushort DnsA = 1; + private const ushort DnsPtr = 12; + private const ushort DnsTxt = 16; + private const ushort DnsAaaa = 28; + private const ushort DnsSrv = 33; + private const ushort DnsAny = 255; + private const ushort DnsIn = 1; + private const ushort DnsFlushIn = 0x8001; + + private static readonly IPAddress DiscoveryMulticastAddress = IPAddress.Parse("239.255.42.99"); + private static readonly IPAddress MdnsIpv4Address = IPAddress.Parse("224.0.0.251"); + private static readonly IPAddress MdnsIpv6Address = IPAddress.Parse("ff02::fb"); + + private readonly object _gate = new object(); + private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); + private readonly List _ipv4Interfaces = new List(); + private readonly List _ipv6Interfaces = new List(); + private readonly List _advertisedAddresses = new List(); + private readonly byte[] _customPayload; + private readonly byte[] _mdnsAnnouncement; + private readonly byte[] _mdnsMetaResponse; + private readonly byte[] _mdnsCombinedResponse; + private readonly byte[] _mdnsGoodbye; + private readonly string _instanceName; + private readonly string _hostName; + private readonly ushort _serverPort; + private readonly List _txtValues; + + private UdpClient _customSender; + private UdpClient _mdnsIpv4; + private UdpClient _mdnsIpv6; + private long _lastMdnsResponseTicks; + private int _lastMdnsResponseKey; + private string _lastMdnsResponseRemote; + private bool _disposed; + + public Guid InstanceId { get; } + public bool IsRunning + { + get + { + lock (_gate) + { + return !_disposed; + } + } + } + + public BasisLanServerAnnouncer( + ushort serverPort, + string networkStackId, + string serverName, + string motd, + bool requiresPassword) + { + if (serverPort == 0) + { + throw new ArgumentOutOfRangeException(nameof(serverPort)); + } + + InstanceId = Guid.NewGuid(); + _serverPort = serverPort; + string id = InstanceId.ToString("N"); + string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) + ? BasisNetworkStackRegistry.DefaultId + : networkStackId; + string effectiveServerName = string.IsNullOrWhiteSpace(serverName) ? "Basis Server" : serverName; + string effectiveMotd = motd ?? string.Empty; + + _instanceName = $"Basis-{id}.{MdnsServiceType}"; + _hostName = $"basis-{id}.local"; + _txtValues = new List + { + TxtValue("protocol", "1"), + TxtValue("id", id), + TxtValue("stack", effectiveStackId), + TxtValue("name", effectiveServerName), + TxtValue("motd", effectiveMotd), + TxtValue("pwd", requiresPassword ? "1" : "0"), + }; + + DiscoverInterfaces(); + _customPayload = BuildCustomPayload( + InstanceId, + serverPort, + requiresPassword, + effectiveStackId, + effectiveServerName, + effectiveMotd); + _mdnsAnnouncement = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: false, includeService: true); + _mdnsMetaResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: false); + _mdnsCombinedResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: true); + _mdnsGoodbye = BuildMdnsPacket(0, includeMeta: false, includeService: true); + + TryCreateSockets(); + if (_customSender == null && _mdnsIpv4 == null && _mdnsIpv6 == null) + { + _cancellation.Dispose(); + throw new SocketException((int)SocketError.AddressFamilyNotSupported); + } + + if (_mdnsIpv4 != null) + { + _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv4, _cancellation.Token)); + } + if (_mdnsIpv6 != null) + { + _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv6, _cancellation.Token)); + } + if (_customSender != null) + { + _ = Task.Run(() => CustomAdvertisementLoopAsync(_cancellation.Token)); + } + if (_mdnsIpv4 != null || _mdnsIpv6 != null) + { + _ = Task.Run(() => InitialMdnsAnnouncementsAsync(_cancellation.Token)); + } + } + + private void TryCreateSockets() + { + try + { + _customSender = new UdpClient(AddressFamily.InterNetwork) + { + EnableBroadcast = true, + MulticastLoopback = true, + }; + _customSender.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); + } + catch (Exception ex) + { + _customSender?.Dispose(); + _customSender = null; + BNL.LogWarning($"Basis LAN datagram announcements are unavailable: {ex.Message}"); + } + + if (Socket.OSSupportsIPv4) + { + try { _mdnsIpv4 = CreateMdnsIpv4Socket(); } + catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS announcements are unavailable: {ex.Message}"); } + } + if (Socket.OSSupportsIPv6) + { + try { _mdnsIpv6 = CreateMdnsIpv6Socket(); } + catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS announcements are unavailable: {ex.Message}"); } + } + } + + private UdpClient CreateMdnsIpv4Socket() + { + UdpClient client = new UdpClient(AddressFamily.InterNetwork); + try + { + ConfigureSharedSocket(client); + client.Client.Bind(new IPEndPoint(IPAddress.Any, MdnsPort)); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.AddMembership, + new MulticastOption(MdnsIpv4Address, address)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(MdnsIpv4Address); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private UdpClient CreateMdnsIpv6Socket() + { + UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); + try + { + ConfigureSharedSocket(client); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); + client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, MdnsPort)); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (int index in _ipv6Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IPv6, + SocketOptionName.AddMembership, + new IPv6MulticastOption(MdnsIpv6Address, index)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(MdnsIpv6Address); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static void ConfigureSharedSocket(UdpClient client) + { + try { client.Client.ExclusiveAddressUse = false; } + catch (PlatformNotSupportedException) { } + client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + } + + private async Task CustomAdvertisementLoopAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(InitialAdvertisementDelayMs, cancellationToken).ConfigureAwait(false); + while (!cancellationToken.IsCancellationRequested) + { + SendCustomAdvertisement(); + await Task.Delay(AdvertisementIntervalMs, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis LAN announcements stopped: {ex.Message}"); + } + } + } + + private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(20, cancellationToken).ConfigureAwait(false); + SendMdnsMulticast(_mdnsAnnouncement); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); + SendMdnsMulticast(_mdnsAnnouncement); + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis mDNS startup announcement failed: {ex.Message}"); + } + } + } + + private async Task ReceiveMdnsLoopAsync(UdpClient client, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); + ProcessMdnsQuery(result.Buffer, result.RemoteEndPoint); + } + catch (ObjectDisposedException) { return; } + catch (SocketException) + { + if (cancellationToken.IsCancellationRequested) return; + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis mDNS receive failed: {ex.Message}"); + } + } + } + } + + private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) + { + if (!TryMatchMdnsQuery( + packet, + out bool wantsUnicast, + out bool includeMeta, + out bool includeService, + out ushort queryId)) + { + return; + } + + lock (_gate) + { + if (_disposed) + { + return; + } + + int responseKey = (includeMeta ? 1 : 0) | (includeService ? 2 : 0); + string responseRemote = remoteEndPoint?.ToString() ?? string.Empty; + long now = Stopwatch.GetTimestamp(); + if (_lastMdnsResponseTicks != 0 + && _lastMdnsResponseKey == responseKey + && string.Equals(_lastMdnsResponseRemote, responseRemote, StringComparison.Ordinal) + && now - _lastMdnsResponseTicks < Stopwatch.Frequency / 50) + { + return; + } + _lastMdnsResponseTicks = now; + _lastMdnsResponseKey = responseKey; + _lastMdnsResponseRemote = responseRemote; + + byte[] response = includeMeta + ? (includeService ? _mdnsCombinedResponse : _mdnsMetaResponse) + : _mdnsAnnouncement; + bool legacyUnicast = remoteEndPoint != null && remoteEndPoint.Port != MdnsPort; + if (legacyUnicast) + { + response = WithDnsId(response, queryId); + } + + if (wantsUnicast || legacyUnicast) + { + SendMdnsUnicast(response, remoteEndPoint); + } + else + { + SendMdnsMulticast(response); + } + } + } + + private void SendCustomAdvertisement() + { + UdpClient sender; + lock (_gate) + { + if (_disposed) return; + sender = _customSender; + } + if (sender == null) return; + + HashSet sent = new HashSet(StringComparer.Ordinal); + SendCustomTo(sender, new IPEndPoint(DiscoveryMulticastAddress, DiscoveryPort), sent); + SendCustomTo(sender, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort), sent); + + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + foreach (UnicastIPAddressInformation addressInformation in networkInterface.GetIPProperties().UnicastAddresses) + { + if (addressInformation.Address.AddressFamily != AddressFamily.InterNetwork + || addressInformation.IPv4Mask == null) + { + continue; + } + + byte[] addressBytes = addressInformation.Address.GetAddressBytes(); + byte[] maskBytes = addressInformation.IPv4Mask.GetAddressBytes(); + if (addressBytes.Length != 4 || maskBytes.Length != 4) continue; + + byte[] broadcastBytes = new byte[4]; + for (int i = 0; i < broadcastBytes.Length; i++) + { + broadcastBytes[i] = (byte)(addressBytes[i] | ~maskBytes[i]); + } + SendCustomTo(sender, new IPEndPoint(new IPAddress(broadcastBytes), DiscoveryPort), sent); + } + } + } + catch (Exception) + { + // Multicast and limited broadcast were already attempted. + } + } + + private void SendCustomTo(UdpClient sender, IPEndPoint endpoint, HashSet sent) + { + if (!sent.Add(endpoint.ToString())) return; + try { sender.Send(_customPayload, _customPayload.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + + private void SendMdnsMulticast(byte[] packet) + { + if (packet == null || packet.Length == 0) return; + + UdpClient ipv4 = _mdnsIpv4; + if (ipv4 != null) + { + IPEndPoint endpoint = new IPEndPoint(MdnsIpv4Address, MdnsPort); + bool sent = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + ipv4.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.MulticastInterface, + address.GetAddressBytes()); + ipv4.Send(packet, packet.Length, endpoint); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { break; } + } + if (!sent) + { + try { ipv4.Send(packet, packet.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + UdpClient ipv6 = _mdnsIpv6; + if (ipv6 != null) + { + bool sent = false; + foreach (int index in _ipv6Interfaces) + { + try + { + ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); + IPAddress scoped = new IPAddress(MdnsIpv6Address.GetAddressBytes(), index); + ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, MdnsPort)); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { break; } + } + if (!sent) + { + try { ipv6.Send(packet, packet.Length, new IPEndPoint(MdnsIpv6Address, MdnsPort)); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + } + + private void SendMdnsUnicast(byte[] packet, IPEndPoint endpoint) + { + if (packet == null || endpoint == null) return; + try + { + UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork + ? _mdnsIpv4 + : _mdnsIpv6; + client?.Send(packet, packet.Length, endpoint); + } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + + private void DiscoverInterfaces() + { + HashSet ipv4 = new HashSet(); + HashSet ipv6 = new HashSet(); + HashSet advertised = new HashSet(); + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + foreach (UnicastIPAddressInformation information in properties.UnicastAddresses) + { + IPAddress address = information.Address; + if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) continue; + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + ipv4.Add(address); + advertised.Add(address); + } + else if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv6LinkLocal) + { + advertised.Add(address); + } + } + + try + { + IPv6InterfaceProperties propertiesV6 = properties.GetIPv6Properties(); + if (propertiesV6 != null && propertiesV6.Index > 0) + { + ipv6.Add(propertiesV6.Index); + } + } + catch (Exception ex) when (ex is NetworkInformationException + || ex is PlatformNotSupportedException + || ex is NotImplementedException) { } + } + } + catch (Exception) { } + + _ipv4Interfaces.AddRange(ipv4); + _ipv6Interfaces.AddRange(ipv6); + _advertisedAddresses.AddRange(advertised); + } + + private bool TryMatchMdnsQuery( + byte[] packet, + out bool wantsUnicast, + out bool includeMeta, + out bool includeService, + out ushort queryId) + { + wantsUnicast = false; + includeMeta = false; + includeService = false; + queryId = 0; + if (packet == null || packet.Length < 12 || packet.Length > 9000) + { + return false; + } + + try + { + int offset = 0; + queryId = ReadUInt16(packet, ref offset); + ushort flags = ReadUInt16(packet, ref offset); + ushort questionCount = ReadUInt16(packet, ref offset); + ReadUInt16(packet, ref offset); + ReadUInt16(packet, ref offset); + ReadUInt16(packet, ref offset); + if ((flags & 0x8000) != 0 || (flags & 0x780F) != 0 || questionCount > 64) + { + return false; + } + + bool matched = false; + for (int i = 0; i < questionCount; i++) + { + if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 4) + { + return false; + } + ushort type = ReadUInt16(packet, ref offset); + ushort recordClass = ReadUInt16(packet, ref offset); + if ((recordClass & 0x7FFF) != DnsIn) continue; + + bool metaRequested = EqualName(name, MdnsMetaServiceType) + && (type == DnsPtr || type == DnsAny); + bool serviceRequested = (EqualName(name, MdnsServiceType) && (type == DnsPtr || type == DnsAny)) + || (EqualName(name, _instanceName) && (type == DnsSrv || type == DnsTxt || type == DnsAny)) + || (EqualName(name, _hostName) && (type == DnsA || type == DnsAaaa || type == DnsAny)); + if (!metaRequested && !serviceRequested) continue; + + matched = true; + includeMeta |= metaRequested; + includeService |= serviceRequested; + wantsUnicast |= (recordClass & 0x8000) != 0; + } + return matched; + } + catch (Exception ex) when (ex is IOException || ex is ArgumentException || ex is DecoderFallbackException) + { + return false; + } + } + + private byte[] BuildMdnsPacket(uint ttl, bool includeMeta, bool includeService) + { + List answers = new List(); + List additional = new List(); + if (includeMeta) + { + answers.Add(new DnsRecord(MdnsMetaServiceType, DnsPtr, DnsIn, ttl, EncodeName(MdnsServiceType))); + } + if (includeService) + { + answers.Add(new DnsRecord(MdnsServiceType, DnsPtr, DnsIn, ttl, EncodeName(_instanceName))); + additional.Add(new DnsRecord(_instanceName, DnsSrv, DnsFlushIn, ttl, EncodeSrv(_serverPort, _hostName))); + additional.Add(new DnsRecord(_instanceName, DnsTxt, DnsFlushIn, ttl, EncodeTxt(_txtValues))); + foreach (IPAddress address in _advertisedAddresses) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + additional.Add(new DnsRecord(_hostName, DnsA, DnsFlushIn, ttl, address.GetAddressBytes())); + } + else if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + additional.Add(new DnsRecord(_hostName, DnsAaaa, DnsFlushIn, ttl, address.GetAddressBytes())); + } + } + } + + using MemoryStream stream = new MemoryStream(512); + WriteUInt16(stream, 0); + WriteUInt16(stream, 0x8400); + WriteUInt16(stream, 0); + WriteUInt16(stream, checked((ushort)answers.Count)); + WriteUInt16(stream, 0); + WriteUInt16(stream, checked((ushort)additional.Count)); + foreach (DnsRecord record in answers) WriteDnsRecord(stream, record); + foreach (DnsRecord record in additional) WriteDnsRecord(stream, record); + if (stream.Length > 9000) throw new InvalidOperationException("Basis mDNS announcement is too large."); + return stream.ToArray(); + } + + private static byte[] WithDnsId(byte[] packet, ushort id) + { + byte[] response = (byte[])packet.Clone(); + response[0] = (byte)(id >> 8); + response[1] = (byte)id; + return response; + } + + private static byte[] BuildCustomPayload( + Guid instanceId, + ushort serverPort, + bool requiresPassword, + string networkStackId, + string serverName, + string motd) + { + using MemoryStream stream = new MemoryStream(256); + using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); + writer.Write(PacketMagic); + writer.Write(PacketVersion); + writer.Write(instanceId.ToByteArray()); + writer.Write(serverPort); + writer.Write(requiresPassword ? (byte)1 : (byte)0); + WritePacketString(writer, networkStackId, MaxStackIdBytes); + WritePacketString(writer, serverName, MaxServerNameBytes); + WritePacketString(writer, motd, MaxMotdBytes); + writer.Flush(); + return stream.ToArray(); + } + + private static void WritePacketString(BinaryWriter writer, string value, int maxBytes) + { + byte[] bytes = Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes)); + writer.Write((ushort)bytes.Length); + writer.Write(bytes); + } + + private static string TxtValue(string key, string value) + { + int budget = 254 - Encoding.UTF8.GetByteCount(key); + return key + "=" + LimitUtf8(value, Math.Max(0, budget)); + } + + private static string LimitUtf8(string value, int maxBytes) + { + if (string.IsNullOrEmpty(value) || maxBytes <= 0) return string.Empty; + if (Encoding.UTF8.GetByteCount(value) <= maxBytes) return value; + + int length = value.Length; + while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) + { + length--; + if (length > 0 && char.IsHighSurrogate(value[length - 1])) length--; + } + return length == 0 ? string.Empty : value.Substring(0, length); + } + + private readonly struct DnsRecord + { + public readonly string Name; + public readonly ushort Type; + public readonly ushort Class; + public readonly uint Ttl; + public readonly byte[] Data; + + public DnsRecord(string name, ushort type, ushort recordClass, uint ttl, byte[] data) + { + Name = name; + Type = type; + Class = recordClass; + Ttl = ttl; + Data = data; + } + } + + private static void WriteDnsRecord(Stream stream, DnsRecord record) + { + WriteBytes(stream, EncodeName(record.Name)); + WriteUInt16(stream, record.Type); + WriteUInt16(stream, record.Class); + WriteUInt32(stream, record.Ttl); + WriteUInt16(stream, checked((ushort)record.Data.Length)); + WriteBytes(stream, record.Data); + } + + private static byte[] EncodeSrv(ushort port, string target) + { + using MemoryStream stream = new MemoryStream(64); + WriteUInt16(stream, 0); + WriteUInt16(stream, 0); + WriteUInt16(stream, port); + WriteBytes(stream, EncodeName(target)); + return stream.ToArray(); + } + + private static byte[] EncodeTxt(List values) + { + using MemoryStream stream = new MemoryStream(256); + foreach (string value in values) + { + byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + if (bytes.Length > 255) throw new InvalidOperationException("Basis mDNS TXT value is too large."); + stream.WriteByte((byte)bytes.Length); + WriteBytes(stream, bytes); + } + return stream.ToArray(); + } + + private static byte[] EncodeName(string value) + { + string normalized = NormalizeName(value); + using MemoryStream stream = new MemoryStream(128); + if (normalized.Length != 0) + { + foreach (string label in normalized.Split('.')) + { + byte[] bytes = Encoding.UTF8.GetBytes(label); + if (bytes.Length == 0 || bytes.Length > 63) + { + throw new InvalidOperationException("Invalid mDNS label."); + } + stream.WriteByte((byte)bytes.Length); + WriteBytes(stream, bytes); + } + } + stream.WriteByte(0); + if (stream.Length > 255) throw new InvalidOperationException("Invalid mDNS name."); + return stream.ToArray(); + } + + private static bool ReadName(byte[] packet, ref int offset, out string value) + { + value = string.Empty; + if (offset < 0 || offset >= packet.Length) return false; + + UTF8Encoding strictUtf8 = new UTF8Encoding(false, true); + StringBuilder result = new StringBuilder(64); + HashSet pointers = null; + int position = offset; + int resume = -1; + int encodedLength = 1; + while (true) + { + if (position >= packet.Length) return false; + byte length = packet[position++]; + if (length == 0) + { + offset = resume >= 0 ? resume : position; + value = result.ToString(); + return true; + } + if ((length & 0xC0) == 0xC0) + { + if (position >= packet.Length) return false; + int pointer = ((length & 0x3F) << 8) | packet[position++]; + if (pointer >= packet.Length) return false; + resume = resume >= 0 ? resume : position; + pointers ??= new HashSet(); + if (!pointers.Add(pointer) || pointers.Count > 32) return false; + position = pointer; + continue; + } + if ((length & 0xC0) != 0 || length > 63 || position + length > packet.Length) + { + return false; + } + encodedLength += length + 1; + if (encodedLength > 255) return false; + if (result.Length != 0) result.Append('.'); + result.Append(strictUtf8.GetString(packet, position, length)); + position += length; + } + } + + private static ushort ReadUInt16(byte[] packet, ref int offset) + { + if (packet.Length - offset < 2) throw new IOException(); + ushort value = (ushort)((packet[offset] << 8) | packet[offset + 1]); + offset += 2; + return value; + } + + private static void WriteUInt16(Stream stream, ushort value) + { + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static void WriteUInt32(Stream stream, uint value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static void WriteBytes(Stream stream, byte[] bytes) + { + stream.Write(bytes, 0, bytes.Length); + } + + private static bool EqualName(string left, string right) + { + return string.Equals(NormalizeName(left), NormalizeName(right), StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeName(string value) + { + return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.'); + } + + private static bool IsUsableAddress(IPAddress address) + { + return address != null + && !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.IPv6Any) + && !address.Equals(IPAddress.Broadcast) + && !address.IsIPv6Multicast; + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + + try + { + SendMdnsMulticast(_mdnsGoodbye); + SendMdnsMulticast(_mdnsGoodbye); + } + catch (Exception ex) + { + BNL.LogWarning($"Basis mDNS goodbye failed: {ex.Message}"); + } + + try { _customSender?.Close(); } + catch (ObjectDisposedException) { } + try { _mdnsIpv4?.Close(); } + catch (ObjectDisposedException) { } + try { _mdnsIpv6?.Close(); } + catch (ObjectDisposedException) { } + + _customSender = null; + _mdnsIpv4 = null; + _mdnsIpv6 = null; + _cancellation.Dispose(); + } + } + } +} diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs.meta b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs.meta new file mode 100644 index 0000000000..f13555225f --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f9c1e274c8c49c8a5ad5e4a76fd60ef diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs index e909d8d1dd..f4c5d8a070 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs @@ -19,12 +19,14 @@ public class Configuration /// doc comments). Newly-added settings are healed automatically regardless: on load a /// config missing any current field is re-saved with the new settings added. /// - public const int CurrentConfigVersion = 4; + public const int CurrentConfigVersion = 5; /// Schema version stamped into config.xml; 0 = a pre-versioning file that is upgraded on load. public int ConfigVersion = 0; public int PeerLimit = ushort.MaxValue; public ushort SetPort = 4296; + /// When true, advertise this server to Basis clients on the local network through UDP broadcast/multicast and mDNS. Disabled by default. + public bool AnnounceToLan = false; /// Display name returned by the unconnected server-info query — what shows up as the row title in a client server-list UI. public string ServerName = "Basis Server"; /// Short MOTD returned alongside the server name in the info query response. Two short lines render cleanly in the list UI. diff --git a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs index 1ce02f3a6f..8f8d381889 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs @@ -24,6 +24,7 @@ public static class NetworkServer public static ConcurrentDictionary AuthenticatedPeers = new(); public static readonly object AuthenticatedPeerTag = new object(); public static Configuration Configuration; + private static BasisLanServerAnnouncer _lanServerAnnouncer; /// /// Allow-list consulted at /// when is set to AllowList. @@ -93,6 +94,25 @@ public static void StartServer(Configuration configuration) SetupServer(configuration); SubscribeEvents(Configuration); + if (configuration.AnnounceToLan) + { + try + { + _lanServerAnnouncer = new BasisLanServerAnnouncer( + configuration.SetPort, + configuration.NetworkStackId, + configuration.ServerName, + configuration.ServerMotd, + configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); + BNL.Log($"LAN announcements enabled on UDP {configuration.SetPort}."); + } + catch (Exception ex) + { + _lanServerAnnouncer = null; + BNL.LogWarning($"LAN announcements could not start: {ex.Message}"); + } + } + if (configuration.EnableStatistics) { BasisStatistics.StartWorkerThread(Server); @@ -105,6 +125,11 @@ public static void StartServer(Configuration configuration) public static void StopServer() { + BasisLanServerAnnouncer lanServerAnnouncer = _lanServerAnnouncer; + _lanServerAnnouncer = null; + try { lanServerAnnouncer?.Dispose(); } + catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } + if (Server == null) return; try { diff --git a/Basis/Packages/com.basis.server/BasisServerConsole/BasisConsoleCommands.cs b/Basis/Packages/com.basis.server/BasisServerConsole/BasisConsoleCommands.cs index 788ab8fedb..bcfff987ac 100644 --- a/Basis/Packages/com.basis.server/BasisServerConsole/BasisConsoleCommands.cs +++ b/Basis/Packages/com.basis.server/BasisServerConsole/BasisConsoleCommands.cs @@ -557,8 +557,9 @@ public static void HandleStatus(string[] args) public static void HandleShutdown(string[] args) { BNL.Log("Shutting down the server..."); - Program.isRunning = false; // Gracefully stop the server - Environment.Exit(0); // Exit the application + Program.isRunning = false; + NetworkServer.StopServer(); + Environment.Exit(0); } public static void HandleHelp(string[] args) diff --git a/Basis/Packages/com.basis.server/BasisServerConsole/Program.cs b/Basis/Packages/com.basis.server/BasisServerConsole/Program.cs index 923d68688b..2d51a5fcd4 100644 --- a/Basis/Packages/com.basis.server/BasisServerConsole/Program.cs +++ b/Basis/Packages/com.basis.server/BasisServerConsole/Program.cs @@ -86,6 +86,7 @@ public static void Main(string[] args) { BNL.Log("Shutting down server..."); isRunning = false; + NetworkServer.StopServer(); shutdownEvent.Set(); // Signal the main thread to exit #if !UNITY_2017_1_OR_NEWER Api?.Dispose(); From 9f42e2f82f61b245185175530c3d0bdd4f21721b Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 03:41:23 +0000 Subject: [PATCH 04/25] Reuse shared LAN discovery infrastructure --- .../BasisLanDiscoveryProtocol.cs | 172 ++++++++++ .../BasisNetworkCore/BasisLanMdnsTransport.cs | 313 +++++++++++++++++ .../BasisLanServerAnnouncer.cs | 321 ++---------------- .../Networking/BasisLanDiscoveryProtocol.cs | 147 +------- .../Networking/BasisLanMdnsDiscovery.cs | 281 ++------------- .../Networking/BasisLanMdnsWire.cs | 234 +------------ .../BasisLanDiscoveryProtocol.cs | 172 ++++++++++ .../BasisLanDiscoveryProtocol.cs.meta | 2 + .../BasisNetworkCore/BasisLanMdnsTransport.cs | 313 +++++++++++++++++ .../BasisLanMdnsTransport.cs.meta | 2 + .../BasisLanServerAnnouncer.cs | 321 ++---------------- 11 files changed, 1083 insertions(+), 1195 deletions(-) create mode 100644 Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs create mode 100644 Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs.meta create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs new file mode 100644 index 0000000000..77b17febdb --- /dev/null +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -0,0 +1,172 @@ +using System; +using System.IO; +using System.Net; +using System.Text; + +namespace Basis.Network.Core +{ + /// Metadata carried by Basis LAN discovery packets and DNS-SD records. + public readonly struct BasisLanAdvertisement + { + public readonly Guid InstanceId; + public readonly ushort ServerPort; + public readonly bool RequiresPassword; + public readonly string NetworkStackId; + public readonly string ServerName; + public readonly string Motd; + + public BasisLanAdvertisement( + Guid instanceId, + ushort serverPort, + bool requiresPassword, + string networkStackId, + string serverName, + string motd) + { + InstanceId = instanceId; + ServerPort = serverPort; + RequiresPassword = requiresPassword; + NetworkStackId = networkStackId ?? string.Empty; + ServerName = serverName ?? string.Empty; + Motd = motd ?? string.Empty; + } + } + + /// Shared Basis LAN datagram format used by servers and clients. + public static class BasisLanDiscoveryProtocol + { + public const int DiscoveryPort = 42960; + public const uint Magic = 0xBA515201u; + public const ushort Version = 1; + public const int MaxPacketBytes = 1024; + public const int MaxStackIdBytes = 64; + public const int MaxServerNameBytes = 128; + public const int MaxMotdBytes = 384; + + public static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.42.99"); + + public static byte[] Serialize(BasisLanAdvertisement advertisement) + { + using (MemoryStream stream = new MemoryStream(256)) + using (BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true)) + { + writer.Write(Magic); + writer.Write(Version); + writer.Write(advertisement.InstanceId.ToByteArray()); + writer.Write(advertisement.ServerPort); + writer.Write(advertisement.RequiresPassword ? (byte)1 : (byte)0); + WriteString(writer, advertisement.NetworkStackId, MaxStackIdBytes); + WriteString(writer, advertisement.ServerName, MaxServerNameBytes); + WriteString(writer, advertisement.Motd, MaxMotdBytes); + writer.Flush(); + return stream.ToArray(); + } + } + + public static bool TryDeserialize(byte[] data, out BasisLanAdvertisement advertisement) + { + advertisement = default; + if (data == null || data.Length < 31 || data.Length > MaxPacketBytes) + { + return false; + } + + try + { + using (MemoryStream stream = new MemoryStream(data, false)) + using (BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true)) + { + if (reader.ReadUInt32() != Magic || reader.ReadUInt16() != Version) + { + return false; + } + + byte[] guidBytes = reader.ReadBytes(16); + if (guidBytes.Length != 16) + { + return false; + } + + ushort serverPort = reader.ReadUInt16(); + byte flags = reader.ReadByte(); + if (serverPort == 0 + || (flags & ~1) != 0 + || !TryReadString(reader, stream, MaxStackIdBytes, out string stackId) + || !TryReadString(reader, stream, MaxServerNameBytes, out string serverName) + || !TryReadString(reader, stream, MaxMotdBytes, out string motd)) + { + return false; + } + + advertisement = new BasisLanAdvertisement( + new Guid(guidBytes), + serverPort, + (flags & 1) != 0, + stackId, + serverName, + motd); + return advertisement.InstanceId != Guid.Empty; + } + } + catch (Exception) + { + return false; + } + } + + internal static string LimitUtf8(string value, int maxBytes) + { + if (string.IsNullOrEmpty(value) || maxBytes <= 0) + { + return string.Empty; + } + if (Encoding.UTF8.GetByteCount(value) <= maxBytes) + { + return value; + } + + int length = value.Length; + while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) + { + length--; + if (length > 0 && char.IsHighSurrogate(value[length - 1])) + { + length--; + } + } + return length == 0 ? string.Empty : value.Substring(0, length); + } + + private static void WriteString(BinaryWriter writer, string value, int maxBytes) + { + string limited = LimitUtf8(value, maxBytes); + byte[] bytes = Encoding.UTF8.GetBytes(limited); + writer.Write((ushort)bytes.Length); + writer.Write(bytes); + } + + private static bool TryReadString(BinaryReader reader, MemoryStream stream, int maxBytes, out string value) + { + value = string.Empty; + if (stream.Length - stream.Position < sizeof(ushort)) + { + return false; + } + + ushort byteCount = reader.ReadUInt16(); + if (byteCount > maxBytes || stream.Length - stream.Position < byteCount) + { + return false; + } + + byte[] bytes = reader.ReadBytes(byteCount); + if (bytes.Length != byteCount) + { + return false; + } + + value = Encoding.UTF8.GetString(bytes); + return true; + } + } +} diff --git a/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs b/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs new file mode 100644 index 0000000000..40bbcfbccc --- /dev/null +++ b/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Basis.Network.Core +{ + /// Shared dual-stack UDP/5353 transport for Basis mDNS browsing and advertising. + public sealed class BasisLanMdnsTransport : IDisposable + { + public const int Port = 5353; + public static readonly IPAddress Ipv4MulticastAddress = IPAddress.Parse("224.0.0.251"); + public static readonly IPAddress Ipv6MulticastAddress = IPAddress.Parse("ff02::fb"); + + private readonly object _sendGate = new object(); + private readonly Action _received; + private readonly List _ipv4Interfaces = new List(); + private readonly List _ipv6Interfaces = new List(); + private UdpClient _ipv4; + private UdpClient _ipv6; + private int _disposed; + + public BasisLanMdnsTransport(Action received) + { + _received = received ?? throw new ArgumentNullException(nameof(received)); + DiscoverInterfaces(); + + if (Socket.OSSupportsIPv4) + { + try { _ipv4 = CreateIpv4(); } + catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS unavailable: {ex.Message}"); } + } + if (Socket.OSSupportsIPv6) + { + try { _ipv6 = CreateIpv6(); } + catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS unavailable: {ex.Message}"); } + } + if (_ipv4 == null && _ipv6 == null) + { + throw new SocketException((int)SocketError.AddressFamilyNotSupported); + } + + if (_ipv4 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv4)); + if (_ipv6 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv6)); + } + + public void SendMulticast(byte[] packet) + { + if (packet == null || packet.Length == 0 || Volatile.Read(ref _disposed) != 0) + { + return; + } + + lock (_sendGate) + { + SendIpv4(packet); + SendIpv6(packet); + } + } + + public void SendUnicast(byte[] packet, IPEndPoint endpoint) + { + if (packet == null || packet.Length == 0 || endpoint == null || Volatile.Read(ref _disposed) != 0) + { + return; + } + + lock (_sendGate) + { + try + { + UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork ? _ipv4 : _ipv6; + client?.Send(packet, packet.Length, endpoint); + } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private UdpClient CreateIpv4() + { + UdpClient client = new UdpClient(AddressFamily.InterNetwork); + try + { + ConfigureShared(client); + client.Client.Bind(new IPEndPoint(IPAddress.Any, Port)); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.AddMembership, + new MulticastOption(Ipv4MulticastAddress, address)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(Ipv4MulticastAddress); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private UdpClient CreateIpv6() + { + UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); + try + { + ConfigureShared(client); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); + client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, Port)); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (int index in _ipv6Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IPv6, + SocketOptionName.AddMembership, + new IPv6MulticastOption(Ipv6MulticastAddress, index)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(Ipv6MulticastAddress); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static void ConfigureShared(UdpClient client) + { + try { client.Client.ExclusiveAddressUse = false; } + catch (PlatformNotSupportedException) { } + client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + } + + private void SendIpv4(byte[] packet) + { + if (_ipv4 == null) + { + return; + } + + IPEndPoint endpoint = new IPEndPoint(Ipv4MulticastAddress, Port); + bool sent = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + _ipv4.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.MulticastInterface, + address.GetAddressBytes()); + _ipv4.Send(packet, packet.Length, endpoint); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { return; } + } + if (!sent) + { + try { _ipv4.Send(packet, packet.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private void SendIpv6(byte[] packet) + { + if (_ipv6 == null) + { + return; + } + + bool sent = false; + foreach (int index in _ipv6Interfaces) + { + try + { + _ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); + IPAddress scoped = new IPAddress(Ipv6MulticastAddress.GetAddressBytes(), index); + _ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, Port)); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { return; } + } + if (!sent) + { + try { _ipv6.Send(packet, packet.Length, new IPEndPoint(Ipv6MulticastAddress, Port)); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private async Task ReceiveLoopAsync(UdpClient client) + { + while (Volatile.Read(ref _disposed) == 0) + { + try + { + UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); + if (result.RemoteEndPoint != null && result.Buffer != null) + { + _received(result.Buffer, result.RemoteEndPoint); + } + } + catch (ObjectDisposedException) { return; } + catch (SocketException) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + } + catch (Exception ex) + { + if (Volatile.Read(ref _disposed) == 0) + { + BNL.LogWarning($"LAN mDNS receive failed: {ex.Message}"); + } + } + } + } + + private void DiscoverInterfaces() + { + HashSet ipv4 = new HashSet(); + HashSet ipv6 = new HashSet(); + try + { + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up + || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + IPInterfaceProperties properties = nic.GetIPProperties(); + foreach (UnicastIPAddressInformation info in properties.UnicastAddresses) + { + if (info.Address.AddressFamily == AddressFamily.InterNetwork + && !IPAddress.IsLoopback(info.Address)) + { + ipv4.Add(info.Address); + } + } + try + { + IPv6InterfaceProperties v6 = properties.GetIPv6Properties(); + if (v6 != null && v6.Index > 0) + { + ipv6.Add(v6.Index); + } + } + catch (Exception ex) when (ex is NetworkInformationException + || ex is PlatformNotSupportedException + || ex is NotImplementedException) + { + } + } + } + catch (Exception) + { + } + + _ipv4Interfaces.AddRange(ipv4); + _ipv6Interfaces.AddRange(ipv6); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try { _ipv4?.Close(); } + catch (ObjectDisposedException) { } + try { _ipv6?.Close(); } + catch (ObjectDisposedException) { } + _ipv4 = null; + _ipv6 = null; + } + } +} diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 9b7f644337..a0082af172 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -18,16 +18,11 @@ namespace Basis.Network.Core /// public sealed class BasisLanServerAnnouncer : IDisposable { - public const int DiscoveryPort = 42960; - public const int MdnsPort = 5353; + public const int DiscoveryPort = BasisLanDiscoveryProtocol.DiscoveryPort; + public const int MdnsPort = BasisLanMdnsTransport.Port; - private const uint PacketMagic = 0xBA515201u; - private const ushort PacketVersion = 1; private const int InitialAdvertisementDelayMs = 750; private const int AdvertisementIntervalMs = 1500; - private const int MaxStackIdBytes = 64; - private const int MaxServerNameBytes = 128; - private const int MaxMotdBytes = 384; private const string MdnsServiceType = "_basisvr._udp.local"; private const string MdnsMetaServiceType = "_services._dns-sd._udp.local"; private const uint MdnsTtlSeconds = 120; @@ -40,14 +35,8 @@ public sealed class BasisLanServerAnnouncer : IDisposable private const ushort DnsIn = 1; private const ushort DnsFlushIn = 0x8001; - private static readonly IPAddress DiscoveryMulticastAddress = IPAddress.Parse("239.255.42.99"); - private static readonly IPAddress MdnsIpv4Address = IPAddress.Parse("224.0.0.251"); - private static readonly IPAddress MdnsIpv6Address = IPAddress.Parse("ff02::fb"); - private readonly object _gate = new object(); private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private readonly List _ipv4Interfaces = new List(); - private readonly List _ipv6Interfaces = new List(); private readonly List _advertisedAddresses = new List(); private readonly byte[] _customPayload; private readonly byte[] _mdnsAnnouncement; @@ -60,8 +49,7 @@ public sealed class BasisLanServerAnnouncer : IDisposable private readonly List _txtValues; private UdpClient _customSender; - private UdpClient _mdnsIpv4; - private UdpClient _mdnsIpv6; + private BasisLanMdnsTransport _mdnsTransport; private long _lastMdnsResponseTicks; private int _lastMdnsResponseKey; private string _lastMdnsResponseRemote; @@ -112,39 +100,31 @@ public BasisLanServerAnnouncer( TxtValue("pwd", requiresPassword ? "1" : "0"), }; - DiscoverInterfaces(); - _customPayload = BuildCustomPayload( + DiscoverAdvertisedAddresses(); + _customPayload = BasisLanDiscoveryProtocol.Serialize(new BasisLanAdvertisement( InstanceId, serverPort, requiresPassword, effectiveStackId, effectiveServerName, - effectiveMotd); + effectiveMotd)); _mdnsAnnouncement = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: false, includeService: true); _mdnsMetaResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: false); _mdnsCombinedResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: true); _mdnsGoodbye = BuildMdnsPacket(0, includeMeta: false, includeService: true); TryCreateSockets(); - if (_customSender == null && _mdnsIpv4 == null && _mdnsIpv6 == null) + if (_customSender == null && _mdnsTransport == null) { _cancellation.Dispose(); throw new SocketException((int)SocketError.AddressFamilyNotSupported); } - if (_mdnsIpv4 != null) - { - _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv4, _cancellation.Token)); - } - if (_mdnsIpv6 != null) - { - _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv6, _cancellation.Token)); - } if (_customSender != null) { _ = Task.Run(() => CustomAdvertisementLoopAsync(_cancellation.Token)); } - if (_mdnsIpv4 != null || _mdnsIpv6 != null) + if (_mdnsTransport != null) { _ = Task.Run(() => InitialMdnsAnnouncementsAsync(_cancellation.Token)); } @@ -168,98 +148,18 @@ private void TryCreateSockets() BNL.LogWarning($"Basis LAN datagram announcements are unavailable: {ex.Message}"); } - if (Socket.OSSupportsIPv4) - { - try { _mdnsIpv4 = CreateMdnsIpv4Socket(); } - catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS announcements are unavailable: {ex.Message}"); } - } - if (Socket.OSSupportsIPv6) - { - try { _mdnsIpv6 = CreateMdnsIpv6Socket(); } - catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS announcements are unavailable: {ex.Message}"); } - } - } - - private UdpClient CreateMdnsIpv4Socket() - { - UdpClient client = new UdpClient(AddressFamily.InterNetwork); - try - { - ConfigureSharedSocket(client); - client.Client.Bind(new IPEndPoint(IPAddress.Any, MdnsPort)); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.AddMembership, - new MulticastOption(MdnsIpv4Address, address)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(MdnsIpv4Address); - } - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private UdpClient CreateMdnsIpv6Socket() - { - UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); try { - ConfigureSharedSocket(client); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); - client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, MdnsPort)); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (int index in _ipv6Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IPv6, - SocketOptionName.AddMembership, - new IPv6MulticastOption(MdnsIpv6Address, index)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(MdnsIpv6Address); - } - return client; + _mdnsTransport = new BasisLanMdnsTransport(ProcessMdnsQuery); } - catch + catch (Exception ex) { - client.Dispose(); - throw; + _mdnsTransport?.Dispose(); + _mdnsTransport = null; + BNL.LogWarning($"Basis mDNS announcements are unavailable: {ex.Message}"); } } - private static void ConfigureSharedSocket(UdpClient client) - { - try { client.Client.ExclusiveAddressUse = false; } - catch (PlatformNotSupportedException) { } - client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - private async Task CustomAdvertisementLoopAsync(CancellationToken cancellationToken) { try @@ -287,9 +187,9 @@ private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationT try { await Task.Delay(20, cancellationToken).ConfigureAwait(false); - SendMdnsMulticast(_mdnsAnnouncement); + _mdnsTransport.SendMulticast(_mdnsAnnouncement); await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - SendMdnsMulticast(_mdnsAnnouncement); + _mdnsTransport.SendMulticast(_mdnsAnnouncement); } catch (OperationCanceledException) { } catch (ObjectDisposedException) { } @@ -302,30 +202,6 @@ private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationT } } - private async Task ReceiveMdnsLoopAsync(UdpClient client, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); - ProcessMdnsQuery(result.Buffer, result.RemoteEndPoint); - } - catch (ObjectDisposedException) { return; } - catch (SocketException) - { - if (cancellationToken.IsCancellationRequested) return; - } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - { - BNL.LogWarning($"Basis mDNS receive failed: {ex.Message}"); - } - } - } - } - private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) { if (!TryMatchMdnsQuery( @@ -370,11 +246,11 @@ private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) if (wantsUnicast || legacyUnicast) { - SendMdnsUnicast(response, remoteEndPoint); + _mdnsTransport.SendUnicast(response, remoteEndPoint); } else { - SendMdnsMulticast(response); + _mdnsTransport.SendMulticast(response); } } } @@ -390,7 +266,7 @@ private void SendCustomAdvertisement() if (sender == null) return; HashSet sent = new HashSet(StringComparer.Ordinal); - SendCustomTo(sender, new IPEndPoint(DiscoveryMulticastAddress, DiscoveryPort), sent); + SendCustomTo(sender, new IPEndPoint(BasisLanDiscoveryProtocol.MulticastAddress, DiscoveryPort), sent); SendCustomTo(sender, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort), sent); try @@ -438,80 +314,8 @@ private void SendCustomTo(UdpClient sender, IPEndPoint endpoint, HashSet catch (ObjectDisposedException) { } } - private void SendMdnsMulticast(byte[] packet) + private void DiscoverAdvertisedAddresses() { - if (packet == null || packet.Length == 0) return; - - UdpClient ipv4 = _mdnsIpv4; - if (ipv4 != null) - { - IPEndPoint endpoint = new IPEndPoint(MdnsIpv4Address, MdnsPort); - bool sent = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - ipv4.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.MulticastInterface, - address.GetAddressBytes()); - ipv4.Send(packet, packet.Length, endpoint); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { break; } - } - if (!sent) - { - try { ipv4.Send(packet, packet.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - UdpClient ipv6 = _mdnsIpv6; - if (ipv6 != null) - { - bool sent = false; - foreach (int index in _ipv6Interfaces) - { - try - { - ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); - IPAddress scoped = new IPAddress(MdnsIpv6Address.GetAddressBytes(), index); - ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, MdnsPort)); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { break; } - } - if (!sent) - { - try { ipv6.Send(packet, packet.Length, new IPEndPoint(MdnsIpv6Address, MdnsPort)); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - } - - private void SendMdnsUnicast(byte[] packet, IPEndPoint endpoint) - { - if (packet == null || endpoint == null) return; - try - { - UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork - ? _mdnsIpv4 - : _mdnsIpv6; - client?.Send(packet, packet.Length, endpoint); - } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - - private void DiscoverInterfaces() - { - HashSet ipv4 = new HashSet(); - HashSet ipv6 = new HashSet(); HashSet advertised = new HashSet(); try { @@ -523,40 +327,24 @@ private void DiscoverInterfaces() continue; } - IPInterfaceProperties properties = networkInterface.GetIPProperties(); - foreach (UnicastIPAddressInformation information in properties.UnicastAddresses) + foreach (UnicastIPAddressInformation information in networkInterface.GetIPProperties().UnicastAddresses) { IPAddress address = information.Address; - if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) continue; - - if (address.AddressFamily == AddressFamily.InterNetwork) + if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) { - ipv4.Add(address); - advertised.Add(address); + continue; } - else if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv6LinkLocal) + if (address.AddressFamily == AddressFamily.InterNetwork || address.IsIPv6LinkLocal) { advertised.Add(address); } } - - try - { - IPv6InterfaceProperties propertiesV6 = properties.GetIPv6Properties(); - if (propertiesV6 != null && propertiesV6.Index > 0) - { - ipv6.Add(propertiesV6.Index); - } - } - catch (Exception ex) when (ex is NetworkInformationException - || ex is PlatformNotSupportedException - || ex is NotImplementedException) { } } } - catch (Exception) { } + catch (Exception) + { + } - _ipv4Interfaces.AddRange(ipv4); - _ipv6Interfaces.AddRange(ipv6); _advertisedAddresses.AddRange(advertised); } @@ -668,53 +456,10 @@ private static byte[] WithDnsId(byte[] packet, ushort id) return response; } - private static byte[] BuildCustomPayload( - Guid instanceId, - ushort serverPort, - bool requiresPassword, - string networkStackId, - string serverName, - string motd) - { - using MemoryStream stream = new MemoryStream(256); - using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); - writer.Write(PacketMagic); - writer.Write(PacketVersion); - writer.Write(instanceId.ToByteArray()); - writer.Write(serverPort); - writer.Write(requiresPassword ? (byte)1 : (byte)0); - WritePacketString(writer, networkStackId, MaxStackIdBytes); - WritePacketString(writer, serverName, MaxServerNameBytes); - WritePacketString(writer, motd, MaxMotdBytes); - writer.Flush(); - return stream.ToArray(); - } - - private static void WritePacketString(BinaryWriter writer, string value, int maxBytes) - { - byte[] bytes = Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes)); - writer.Write((ushort)bytes.Length); - writer.Write(bytes); - } - private static string TxtValue(string key, string value) { int budget = 254 - Encoding.UTF8.GetByteCount(key); - return key + "=" + LimitUtf8(value, Math.Max(0, budget)); - } - - private static string LimitUtf8(string value, int maxBytes) - { - if (string.IsNullOrEmpty(value) || maxBytes <= 0) return string.Empty; - if (Encoding.UTF8.GetByteCount(value) <= maxBytes) return value; - - int length = value.Length; - while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) - { - length--; - if (length > 0 && char.IsHighSurrogate(value[length - 1])) length--; - } - return length == 0 ? string.Empty : value.Substring(0, length); + return key + "=" + BasisLanDiscoveryProtocol.LimitUtf8(value, Math.Max(0, budget)); } private readonly struct DnsRecord @@ -891,8 +636,8 @@ public void Dispose() try { - SendMdnsMulticast(_mdnsGoodbye); - SendMdnsMulticast(_mdnsGoodbye); + _mdnsTransport?.SendMulticast(_mdnsGoodbye); + _mdnsTransport?.SendMulticast(_mdnsGoodbye); } catch (Exception ex) { @@ -901,14 +646,10 @@ public void Dispose() try { _customSender?.Close(); } catch (ObjectDisposedException) { } - try { _mdnsIpv4?.Close(); } - catch (ObjectDisposedException) { } - try { _mdnsIpv6?.Close(); } - catch (ObjectDisposedException) { } + _mdnsTransport?.Dispose(); _customSender = null; - _mdnsIpv4 = null; - _mdnsIpv6 = null; + _mdnsTransport = null; _cancellation.Dispose(); } } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs index 0dc3370f00..46dd1dee92 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs @@ -3,157 +3,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Net; -using System.Net.NetworkInformation; using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; namespace Basis.Scripts.Networking { - /// - /// Shared wire format for local-network server advertisements. - /// Advertisements use a site-local multicast address plus directed IPv4 broadcasts, - /// remain inside the LAN, and contain the hosted server's actual game port. - /// - internal static class BasisLanDiscoveryProtocol - { - public const int DiscoveryPort = 42960; - public const uint Magic = 0xBA515201u; - public const ushort Version = 1; - public const int MaxPacketBytes = 1024; - public const int MaxStackIdBytes = 64; - public const int MaxServerNameBytes = 128; - public const int MaxMotdBytes = 384; - - public static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.42.99"); - - internal readonly struct Advertisement - { - public readonly Guid InstanceId; - public readonly ushort ServerPort; - public readonly bool RequiresPassword; - public readonly string NetworkStackId; - public readonly string ServerName; - public readonly string Motd; - - public Advertisement(Guid instanceId, ushort serverPort, bool requiresPassword, string networkStackId, string serverName, string motd) - { - InstanceId = instanceId; - ServerPort = serverPort; - RequiresPassword = requiresPassword; - NetworkStackId = networkStackId; - ServerName = serverName; - Motd = motd; - } - } - - public static byte[] Serialize(Advertisement advertisement) - { - using MemoryStream stream = new MemoryStream(256); - using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); - writer.Write(Magic); - writer.Write(Version); - writer.Write(advertisement.InstanceId.ToByteArray()); - writer.Write(advertisement.ServerPort); - writer.Write(advertisement.RequiresPassword ? (byte)1 : (byte)0); - WriteString(writer, advertisement.NetworkStackId, MaxStackIdBytes); - WriteString(writer, advertisement.ServerName, MaxServerNameBytes); - WriteString(writer, advertisement.Motd, MaxMotdBytes); - writer.Flush(); - return stream.ToArray(); - } - - public static bool TryDeserialize(byte[] data, out Advertisement advertisement) - { - advertisement = default; - if (data == null || data.Length < 31 || data.Length > MaxPacketBytes) - { - return false; - } - - try - { - using MemoryStream stream = new MemoryStream(data, false); - using BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true); - if (reader.ReadUInt32() != Magic || reader.ReadUInt16() != Version) - { - return false; - } - - byte[] guidBytes = reader.ReadBytes(16); - if (guidBytes.Length != 16) - { - return false; - } - - ushort serverPort = reader.ReadUInt16(); - byte flags = reader.ReadByte(); - if (serverPort == 0 - || (flags & ~1) != 0 - || !TryReadString(reader, stream, MaxStackIdBytes, out string stackId) - || !TryReadString(reader, stream, MaxServerNameBytes, out string serverName) - || !TryReadString(reader, stream, MaxMotdBytes, out string motd)) - { - return false; - } - - advertisement = new Advertisement(new Guid(guidBytes), serverPort, (flags & 1) != 0, stackId, serverName, motd); - return advertisement.InstanceId != Guid.Empty; - } - catch (Exception) - { - return false; - } - } - - private static void WriteString(BinaryWriter writer, string value, int maxBytes) - { - string safeValue = value ?? string.Empty; - byte[] bytes = Encoding.UTF8.GetBytes(safeValue); - if (bytes.Length > maxBytes) - { - int characterCount = safeValue.Length; - do - { - characterCount--; - bytes = Encoding.UTF8.GetBytes(safeValue.Substring(0, characterCount)); - } - while (bytes.Length > maxBytes && characterCount > 0); - } - - writer.Write((ushort)bytes.Length); - writer.Write(bytes); - } - - private static bool TryReadString(BinaryReader reader, MemoryStream stream, int maxBytes, out string value) - { - value = string.Empty; - if (stream.Length - stream.Position < sizeof(ushort)) - { - return false; - } - - ushort byteCount = reader.ReadUInt16(); - if (byteCount > maxBytes || stream.Length - stream.Position < byteCount) - { - return false; - } - - byte[] bytes = reader.ReadBytes(byteCount); - if (bytes.Length != byteCount) - { - return false; - } - - value = Encoding.UTF8.GetString(bytes); - return true; - } - } - /// /// Periodically announces an in-process hosted server through the custom LAN /// datagram protocol and standard mDNS/DNS-SD. The service is opt-in and is @@ -429,7 +286,7 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel UdpReceiveResult result = await listener.ReceiveAsync().ConfigureAwait(false); if (result.RemoteEndPoint == null || result.RemoteEndPoint.Address == null - || !BasisLanDiscoveryProtocol.TryDeserialize(result.Buffer, out BasisLanDiscoveryProtocol.Advertisement advertisement)) + || !BasisLanDiscoveryProtocol.TryDeserialize(result.Buffer, out BasisLanAdvertisement advertisement)) { continue; } @@ -454,7 +311,7 @@ private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancel } } - private void ProcessAdvertisement(BasisLanDiscoveryProtocol.Advertisement advertisement, IPAddress address) + private void ProcessAdvertisement(BasisLanAdvertisement advertisement, IPAddress address) { if (_disposed || address == null) { diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs index fde7757bc3..9504846742 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs @@ -1,265 +1,22 @@ +using Basis.Network.Core; using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; namespace Basis.Scripts.Networking { - /// Small dual-stack UDP/5353 transport used only by Basis LAN discovery. - internal sealed class BasisLanMdnsTransport : IDisposable - { - private readonly object _sendGate = new object(); - private readonly Action _received; - private readonly List _ipv4Interfaces = new List(); - private readonly List _ipv6Interfaces = new List(); - private UdpClient _ipv4; - private UdpClient _ipv6; - private int _disposed; - - public BasisLanMdnsTransport(Action received) - { - _received = received ?? throw new ArgumentNullException(nameof(received)); - DiscoverInterfaces(); - - if (Socket.OSSupportsIPv4) - { - try { _ipv4 = CreateIpv4(); } - catch (Exception ex) { BasisDebug.LogWarning($"IPv4 mDNS unavailable: {ex.Message}"); } - } - if (Socket.OSSupportsIPv6) - { - try { _ipv6 = CreateIpv6(); } - catch (Exception ex) { BasisDebug.LogWarning($"IPv6 mDNS unavailable: {ex.Message}"); } - } - if (_ipv4 == null && _ipv6 == null) - { - throw new SocketException((int)SocketError.AddressFamilyNotSupported); - } - - if (_ipv4 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv4)); - if (_ipv6 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv6)); - } - - public void SendMulticast(byte[] packet) - { - if (packet == null || packet.Length == 0 || Volatile.Read(ref _disposed) != 0) return; - lock (_sendGate) - { - SendIpv4(packet); - SendIpv6(packet); - } - } - - public void SendUnicast(byte[] packet, IPEndPoint endpoint) - { - if (packet == null || endpoint == null || Volatile.Read(ref _disposed) != 0) return; - lock (_sendGate) - { - try - { - UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork ? _ipv4 : _ipv6; - client?.Send(packet, packet.Length, endpoint); - } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private UdpClient CreateIpv4() - { - UdpClient client = new UdpClient(AddressFamily.InterNetwork); - try - { - Shared(client); - client.Client.Bind(new IPEndPoint(IPAddress.Any, BasisLanMdnsWire.Port)); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, - new MulticastOption(BasisLanMdnsWire.MulticastAddress, address)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) client.JoinMulticastGroup(BasisLanMdnsWire.MulticastAddress); - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private UdpClient CreateIpv6() - { - UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); - try - { - Shared(client); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); - client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, BasisLanMdnsWire.Port)); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); - IPAddress group = IPAddress.Parse("ff02::fb"); - - bool joined = false; - foreach (int index in _ipv6Interfaces) - { - try - { - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.AddMembership, - new IPv6MulticastOption(group, index)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) client.JoinMulticastGroup(group); - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private static void Shared(UdpClient client) - { - try { client.Client.ExclusiveAddressUse = false; } - catch (PlatformNotSupportedException) { } - client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - - private void SendIpv4(byte[] packet) - { - if (_ipv4 == null) return; - IPEndPoint endpoint = new IPEndPoint(BasisLanMdnsWire.MulticastAddress, BasisLanMdnsWire.Port); - bool sent = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - _ipv4.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, address.GetAddressBytes()); - _ipv4.Send(packet, packet.Length, endpoint); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { return; } - } - if (!sent) - { - try { _ipv4.Send(packet, packet.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private void SendIpv6(byte[] packet) - { - if (_ipv6 == null) return; - IPAddress group = IPAddress.Parse("ff02::fb"); - bool sent = false; - foreach (int index in _ipv6Interfaces) - { - try - { - _ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); - IPAddress scoped = new IPAddress(group.GetAddressBytes(), index); - _ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, BasisLanMdnsWire.Port)); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { return; } - } - if (!sent) - { - try { _ipv6.Send(packet, packet.Length, new IPEndPoint(group, BasisLanMdnsWire.Port)); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private async Task ReceiveLoopAsync(UdpClient client) - { - while (Volatile.Read(ref _disposed) == 0) - { - try - { - UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); - if (result.RemoteEndPoint != null && result.Buffer != null) - _received(result.Buffer, result.RemoteEndPoint); - } - catch (ObjectDisposedException) { return; } - catch (SocketException) - { - if (Volatile.Read(ref _disposed) != 0) return; - } - catch (Exception ex) - { - if (Volatile.Read(ref _disposed) == 0) - BasisDebug.LogWarning($"LAN mDNS receive failed: {ex.Message}"); - } - } - } - - private void DiscoverInterfaces() - { - HashSet ipv4 = new HashSet(); - HashSet ipv6 = new HashSet(); - try - { - foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) - { - if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; - IPInterfaceProperties properties = nic.GetIPProperties(); - foreach (UnicastIPAddressInformation info in properties.UnicastAddresses) - { - if (info.Address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(info.Address)) - ipv4.Add(info.Address); - } - try - { - IPv6InterfaceProperties v6 = properties.GetIPv6Properties(); - if (v6 != null && v6.Index > 0) ipv6.Add(v6.Index); - } - catch (Exception ex) when (ex is NetworkInformationException || ex is PlatformNotSupportedException || ex is NotImplementedException) { } - } - } - catch (Exception) { } - _ipv4Interfaces.AddRange(ipv4); - _ipv6Interfaces.AddRange(ipv6); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - try { _ipv4?.Close(); } catch (ObjectDisposedException) { } - try { _ipv6?.Close(); } catch (ObjectDisposedException) { } - _ipv4 = null; - _ipv6 = null; - } - } - + /// Browses Basis DNS-SD records through the shared core mDNS transport. internal sealed class BasisLanMdnsBrowser : IDisposable { private readonly object _gate = new object(); private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private readonly Action _found; + private readonly Action _found; private readonly Action _removed; private readonly BasisLanMdnsTransport _transport; private bool _disposed; - public BasisLanMdnsBrowser(Action found, Action removed) + public BasisLanMdnsBrowser(Action found, Action removed) { _found = found ?? throw new ArgumentNullException(nameof(found)); _removed = removed ?? throw new ArgumentNullException(nameof(removed)); @@ -284,26 +41,40 @@ private async Task QueryLoopAsync(CancellationToken cancellationToken) { lock (_gate) { - if (_disposed) return; + if (_disposed) + { + return; + } _transport.SendMulticast(query); } await Task.Delay(BasisLanMdnsWire.QueryIntervalMs, cancellationToken).ConfigureAwait(false); } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + } catch (Exception ex) { if (!cancellationToken.IsCancellationRequested) + { BasisDebug.LogWarning($"LAN mDNS discovery stopped: {ex.Message}"); + } } } private void OnPacket(byte[] packet, IPEndPoint remote) { - if (!BasisLanMdnsWire.TryParse(packet, out BasisLanMdnsWire.Message message) || !message.IsResponse) return; + if (!BasisLanMdnsWire.TryParse(packet, out BasisLanMdnsWire.Message message) || !message.IsResponse) + { + return; + } + lock (_gate) { - if (_disposed) return; + if (_disposed) + { + return; + } BasisLanMdnsWire.Extract(message, remote, _found, _removed); } } @@ -312,9 +83,13 @@ public void Dispose() { lock (_gate) { - if (_disposed) return; + if (_disposed) + { + return; + } _disposed = true; - try { _cancellation.Cancel(); } catch (ObjectDisposedException) { } + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } _transport.Dispose(); _cancellation.Dispose(); } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs index 292611165e..b6dc248928 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs @@ -1,8 +1,8 @@ +using Basis.Network.Core; using System; using System.Collections.Generic; using System.IO; using System.Net; -using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text; @@ -11,53 +11,23 @@ namespace Basis.Scripts.Networking /// Minimal DNS wire support used only by Basis LAN mDNS. internal static class BasisLanMdnsWire { - public const string ServiceType = "_basisvr._udp.local"; - public const int Port = 5353; + private const string ServiceType = "_basisvr._udp.local"; public const int QueryIntervalMs = 2000; - public static readonly IPAddress MulticastAddress = IPAddress.Parse("224.0.0.251"); - private const string MetaServiceType = "_services._dns-sd._udp.local"; private const string ProtocolVersion = "1"; private const int MaxPacketBytes = 9000; private const int MaxQuestions = 64; private const int MaxRecords = 256; - private const uint TtlSeconds = 120; internal const ushort A = 1; internal const ushort Ptr = 12; internal const ushort Txt = 16; internal const ushort Aaaa = 28; internal const ushort Srv = 33; - internal const ushort Any = 255; internal const ushort In = 1; - internal const ushort FlushIn = 0x8001; private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); - internal sealed class Service - { - public BasisLanDiscoveryProtocol.Advertisement Advertisement; - public string InstanceName; - public string HostName; - public List TxtValues; - public List Addresses; - } - - internal readonly struct Question - { - public readonly string Name; - public readonly ushort Type; - public readonly ushort Class; - public bool WantsUnicast => (Class & 0x8000) != 0; - - public Question(string name, ushort type, ushort recordClass) - { - Name = name; - Type = type; - Class = recordClass; - } - } - internal sealed class Record { public string Name; @@ -74,107 +44,22 @@ internal sealed class Record internal sealed class Message { public bool IsResponse; - public readonly List Questions = new List(); public readonly List Records = new List(); } - private readonly struct OutRecord - { - public readonly string Name; - public readonly ushort Type; - public readonly ushort Class; - public readonly uint Ttl; - public readonly byte[] Data; - - public OutRecord(string name, ushort type, ushort recordClass, uint ttl, byte[] data) - { - Name = name; - Type = type; - Class = recordClass; - Ttl = ttl; - Data = data; - } - } - - public static Service CreateService(BasisLanDiscoveryProtocol.Advertisement advertisement) - { - string id = advertisement.InstanceId.ToString("N"); - return new Service - { - Advertisement = advertisement, - InstanceName = $"Basis-{id}.{ServiceType}", - HostName = $"basis-{id}.local", - Addresses = GetAddresses(), - TxtValues = new List - { - TxtValue("protocol", ProtocolVersion), - TxtValue("id", id), - TxtValue("stack", advertisement.NetworkStackId), - TxtValue("name", advertisement.ServerName), - TxtValue("motd", advertisement.Motd), - TxtValue("pwd", advertisement.RequiresPassword ? "1" : "0"), - }, - }; - } - public static byte[] BuildQuery() { - return Build(0, new List { new Question(ServiceType, Ptr, In) }, new List(), new List()); - } - - public static byte[] BuildAnnouncement(Service service, uint ttl = TtlSeconds) - { - ServiceRecords(service, ttl, out List answers, out List additional); - return Build(0x8400, new List(), answers, additional); - } - - public static bool TryBuildResponse(Service service, Message query, out byte[] response) - { - response = null; - if (query == null || query.IsResponse) - { - return false; - } - - bool meta = false; - bool basis = false; - foreach (Question question in query.Questions) - { - if ((question.Class & 0x7FFF) != In) - { - continue; - } - - if (EqualName(question.Name, MetaServiceType) && Requested(question.Type, Ptr)) - { - meta = true; - } - else if ((EqualName(question.Name, ServiceType) && Requested(question.Type, Ptr)) - || (EqualName(question.Name, service.InstanceName) && (Requested(question.Type, Srv) || Requested(question.Type, Txt))) - || (EqualName(question.Name, service.HostName) && (Requested(question.Type, A) || Requested(question.Type, Aaaa)))) - { - basis = true; - } - } - if (!meta && !basis) - { - return false; - } - - List answers = new List(); - List additional = new List(); - if (meta) - { - answers.Add(new OutRecord(MetaServiceType, Ptr, In, TtlSeconds, EncodeName(ServiceType))); - } - if (basis) - { - ServiceRecords(service, TtlSeconds, out List serviceAnswers, out List serviceAdditional); - answers.AddRange(serviceAnswers); - additional.AddRange(serviceAdditional); - } - response = Build(0x8400, new List(), answers, additional); - return true; + using MemoryStream stream = new MemoryStream(64); + W16(stream, 0); + W16(stream, 0); + W16(stream, 1); + W16(stream, 0); + W16(stream, 0); + W16(stream, 0); + Bytes(stream, EncodeName(ServiceType)); + W16(stream, Ptr); + W16(stream, In); + return stream.ToArray(); } public static bool TryParse(byte[] packet, out Message message) @@ -212,7 +97,8 @@ public static bool TryParse(byte[] packet, out Message message) { return false; } - parsed.Questions.Add(new Question(name, U16(packet, ref offset), U16(packet, ref offset))); + U16(packet, ref offset); + U16(packet, ref offset); } for (int i = 0; i < recordCount; i++) { @@ -234,7 +120,7 @@ public static bool TryParse(byte[] packet, out Message message) public static void Extract( Message message, IPEndPoint remote, - Action found, + Action found, Action removed) { if (message == null || !message.IsResponse) @@ -295,7 +181,7 @@ public static void Extract( values.TryGetValue("stack", out string stack); values.TryGetValue("name", out string name); values.TryGetValue("motd", out string motd); - found?.Invoke(new BasisLanDiscoveryProtocol.Advertisement( + found?.Invoke(new BasisLanAdvertisement( instanceId, srv.Port, password == "1", @@ -318,63 +204,6 @@ public static string LimitUtf8(string value, int maxBytes) return length == 0 ? string.Empty : value.Substring(0, length); } - private static void ServiceRecords(Service service, uint ttl, out List answers, out List additional) - { - answers = new List { new OutRecord(ServiceType, Ptr, In, ttl, EncodeName(service.InstanceName)) }; - additional = new List - { - new OutRecord(service.InstanceName, Srv, FlushIn, ttl, EncodeSrv(service.Advertisement.ServerPort, service.HostName)), - new OutRecord(service.InstanceName, Txt, FlushIn, ttl, EncodeTxt(service.TxtValues)), - }; - foreach (IPAddress address in service.Addresses) - { - if (address.AddressFamily == AddressFamily.InterNetwork) - additional.Add(new OutRecord(service.HostName, A, FlushIn, ttl, address.GetAddressBytes())); - else if (address.AddressFamily == AddressFamily.InterNetworkV6) - additional.Add(new OutRecord(service.HostName, Aaaa, FlushIn, ttl, address.GetAddressBytes())); - } - } - - private static byte[] Build(ushort flags, List questions, List answers, List additional) - { - using MemoryStream stream = new MemoryStream(512); - W16(stream, 0); W16(stream, flags); W16(stream, checked((ushort)questions.Count)); - W16(stream, checked((ushort)answers.Count)); W16(stream, 0); W16(stream, checked((ushort)additional.Count)); - foreach (Question question in questions) - { - Bytes(stream, EncodeName(question.Name)); W16(stream, question.Type); W16(stream, question.Class); - } - foreach (OutRecord record in answers) WriteRecord(stream, record); - foreach (OutRecord record in additional) WriteRecord(stream, record); - if (stream.Length > MaxPacketBytes) throw new InvalidOperationException("Basis mDNS packet is too large."); - return stream.ToArray(); - } - - private static void WriteRecord(Stream stream, OutRecord record) - { - Bytes(stream, EncodeName(record.Name)); W16(stream, record.Type); W16(stream, record.Class); - W32(stream, record.Ttl); W16(stream, checked((ushort)record.Data.Length)); Bytes(stream, record.Data); - } - - private static byte[] EncodeSrv(ushort port, string target) - { - using MemoryStream stream = new MemoryStream(64); - W16(stream, 0); W16(stream, 0); W16(stream, port); Bytes(stream, EncodeName(target)); - return stream.ToArray(); - } - - private static byte[] EncodeTxt(List values) - { - using MemoryStream stream = new MemoryStream(256); - foreach (string value in values) - { - byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); - if (bytes.Length > 255) throw new InvalidOperationException("Basis mDNS TXT value is too large."); - stream.WriteByte((byte)bytes.Length); Bytes(stream, bytes); - } - return stream.ToArray(); - } - private static byte[] EncodeName(string value) { string normalized = Normalize(value); @@ -537,34 +366,6 @@ private static IPAddress SelectAddress(Message message, string target, IPAddress return Usable(remote) ? remote : null; } - private static List GetAddresses() - { - HashSet result = new HashSet(); - try - { - foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) - { - if (nic.OperationalStatus != OperationalStatus.Up || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; - foreach (UnicastIPAddressInformation info in nic.GetIPProperties().UnicastAddresses) - { - IPAddress address = info.Address; - if (Usable(address) && !IPAddress.IsLoopback(address) - && (address.AddressFamily == AddressFamily.InterNetwork || address.IsIPv6LinkLocal)) - result.Add(address); - } - } - } - catch (Exception) { } - return new List(result); - } - - private static string TxtValue(string key, string value) - { - int budget = 254 - Encoding.UTF8.GetByteCount(key); - return key + "=" + LimitUtf8(value, Math.Max(0, budget)); - } - - private static bool Requested(ushort actual, ushort expected) => actual == expected || actual == Any; private static bool EqualName(string left, string right) => string.Equals(Normalize(left), Normalize(right), StringComparison.OrdinalIgnoreCase); private static string Normalize(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.'); private static bool Usable(IPAddress address) => address != null && !address.Equals(IPAddress.Any) && !address.Equals(IPAddress.IPv6Any) && !address.Equals(IPAddress.Broadcast) && !address.IsIPv6Multicast; @@ -586,7 +387,6 @@ private static uint U32(byte[] packet, ref int offset) } private static void W16(Stream stream, ushort value) { stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); } - private static void W32(Stream stream, uint value) { stream.WriteByte((byte)(value >> 24)); stream.WriteByte((byte)(value >> 16)); stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); } private static void Bytes(Stream stream, byte[] bytes) => stream.Write(bytes, 0, bytes.Length); } } diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs new file mode 100644 index 0000000000..77b17febdb --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -0,0 +1,172 @@ +using System; +using System.IO; +using System.Net; +using System.Text; + +namespace Basis.Network.Core +{ + /// Metadata carried by Basis LAN discovery packets and DNS-SD records. + public readonly struct BasisLanAdvertisement + { + public readonly Guid InstanceId; + public readonly ushort ServerPort; + public readonly bool RequiresPassword; + public readonly string NetworkStackId; + public readonly string ServerName; + public readonly string Motd; + + public BasisLanAdvertisement( + Guid instanceId, + ushort serverPort, + bool requiresPassword, + string networkStackId, + string serverName, + string motd) + { + InstanceId = instanceId; + ServerPort = serverPort; + RequiresPassword = requiresPassword; + NetworkStackId = networkStackId ?? string.Empty; + ServerName = serverName ?? string.Empty; + Motd = motd ?? string.Empty; + } + } + + /// Shared Basis LAN datagram format used by servers and clients. + public static class BasisLanDiscoveryProtocol + { + public const int DiscoveryPort = 42960; + public const uint Magic = 0xBA515201u; + public const ushort Version = 1; + public const int MaxPacketBytes = 1024; + public const int MaxStackIdBytes = 64; + public const int MaxServerNameBytes = 128; + public const int MaxMotdBytes = 384; + + public static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.42.99"); + + public static byte[] Serialize(BasisLanAdvertisement advertisement) + { + using (MemoryStream stream = new MemoryStream(256)) + using (BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true)) + { + writer.Write(Magic); + writer.Write(Version); + writer.Write(advertisement.InstanceId.ToByteArray()); + writer.Write(advertisement.ServerPort); + writer.Write(advertisement.RequiresPassword ? (byte)1 : (byte)0); + WriteString(writer, advertisement.NetworkStackId, MaxStackIdBytes); + WriteString(writer, advertisement.ServerName, MaxServerNameBytes); + WriteString(writer, advertisement.Motd, MaxMotdBytes); + writer.Flush(); + return stream.ToArray(); + } + } + + public static bool TryDeserialize(byte[] data, out BasisLanAdvertisement advertisement) + { + advertisement = default; + if (data == null || data.Length < 31 || data.Length > MaxPacketBytes) + { + return false; + } + + try + { + using (MemoryStream stream = new MemoryStream(data, false)) + using (BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true)) + { + if (reader.ReadUInt32() != Magic || reader.ReadUInt16() != Version) + { + return false; + } + + byte[] guidBytes = reader.ReadBytes(16); + if (guidBytes.Length != 16) + { + return false; + } + + ushort serverPort = reader.ReadUInt16(); + byte flags = reader.ReadByte(); + if (serverPort == 0 + || (flags & ~1) != 0 + || !TryReadString(reader, stream, MaxStackIdBytes, out string stackId) + || !TryReadString(reader, stream, MaxServerNameBytes, out string serverName) + || !TryReadString(reader, stream, MaxMotdBytes, out string motd)) + { + return false; + } + + advertisement = new BasisLanAdvertisement( + new Guid(guidBytes), + serverPort, + (flags & 1) != 0, + stackId, + serverName, + motd); + return advertisement.InstanceId != Guid.Empty; + } + } + catch (Exception) + { + return false; + } + } + + internal static string LimitUtf8(string value, int maxBytes) + { + if (string.IsNullOrEmpty(value) || maxBytes <= 0) + { + return string.Empty; + } + if (Encoding.UTF8.GetByteCount(value) <= maxBytes) + { + return value; + } + + int length = value.Length; + while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) + { + length--; + if (length > 0 && char.IsHighSurrogate(value[length - 1])) + { + length--; + } + } + return length == 0 ? string.Empty : value.Substring(0, length); + } + + private static void WriteString(BinaryWriter writer, string value, int maxBytes) + { + string limited = LimitUtf8(value, maxBytes); + byte[] bytes = Encoding.UTF8.GetBytes(limited); + writer.Write((ushort)bytes.Length); + writer.Write(bytes); + } + + private static bool TryReadString(BinaryReader reader, MemoryStream stream, int maxBytes, out string value) + { + value = string.Empty; + if (stream.Length - stream.Position < sizeof(ushort)) + { + return false; + } + + ushort byteCount = reader.ReadUInt16(); + if (byteCount > maxBytes || stream.Length - stream.Position < byteCount) + { + return false; + } + + byte[] bytes = reader.ReadBytes(byteCount); + if (bytes.Length != byteCount) + { + return false; + } + + value = Encoding.UTF8.GetString(bytes); + return true; + } + } +} diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs.meta b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs.meta new file mode 100644 index 0000000000..772c841ab2 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c8fb92b8bca4ee3ad25dcfc2e2cc09a diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs new file mode 100644 index 0000000000..40bbcfbccc --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Basis.Network.Core +{ + /// Shared dual-stack UDP/5353 transport for Basis mDNS browsing and advertising. + public sealed class BasisLanMdnsTransport : IDisposable + { + public const int Port = 5353; + public static readonly IPAddress Ipv4MulticastAddress = IPAddress.Parse("224.0.0.251"); + public static readonly IPAddress Ipv6MulticastAddress = IPAddress.Parse("ff02::fb"); + + private readonly object _sendGate = new object(); + private readonly Action _received; + private readonly List _ipv4Interfaces = new List(); + private readonly List _ipv6Interfaces = new List(); + private UdpClient _ipv4; + private UdpClient _ipv6; + private int _disposed; + + public BasisLanMdnsTransport(Action received) + { + _received = received ?? throw new ArgumentNullException(nameof(received)); + DiscoverInterfaces(); + + if (Socket.OSSupportsIPv4) + { + try { _ipv4 = CreateIpv4(); } + catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS unavailable: {ex.Message}"); } + } + if (Socket.OSSupportsIPv6) + { + try { _ipv6 = CreateIpv6(); } + catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS unavailable: {ex.Message}"); } + } + if (_ipv4 == null && _ipv6 == null) + { + throw new SocketException((int)SocketError.AddressFamilyNotSupported); + } + + if (_ipv4 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv4)); + if (_ipv6 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv6)); + } + + public void SendMulticast(byte[] packet) + { + if (packet == null || packet.Length == 0 || Volatile.Read(ref _disposed) != 0) + { + return; + } + + lock (_sendGate) + { + SendIpv4(packet); + SendIpv6(packet); + } + } + + public void SendUnicast(byte[] packet, IPEndPoint endpoint) + { + if (packet == null || packet.Length == 0 || endpoint == null || Volatile.Read(ref _disposed) != 0) + { + return; + } + + lock (_sendGate) + { + try + { + UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork ? _ipv4 : _ipv6; + client?.Send(packet, packet.Length, endpoint); + } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private UdpClient CreateIpv4() + { + UdpClient client = new UdpClient(AddressFamily.InterNetwork); + try + { + ConfigureShared(client); + client.Client.Bind(new IPEndPoint(IPAddress.Any, Port)); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.AddMembership, + new MulticastOption(Ipv4MulticastAddress, address)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(Ipv4MulticastAddress); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private UdpClient CreateIpv6() + { + UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); + try + { + ConfigureShared(client); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); + client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, Port)); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); + client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); + + bool joined = false; + foreach (int index in _ipv6Interfaces) + { + try + { + client.Client.SetSocketOption( + SocketOptionLevel.IPv6, + SocketOptionName.AddMembership, + new IPv6MulticastOption(Ipv6MulticastAddress, index)); + joined = true; + } + catch (SocketException) { } + } + if (!joined) + { + client.JoinMulticastGroup(Ipv6MulticastAddress); + } + return client; + } + catch + { + client.Dispose(); + throw; + } + } + + private static void ConfigureShared(UdpClient client) + { + try { client.Client.ExclusiveAddressUse = false; } + catch (PlatformNotSupportedException) { } + client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + } + + private void SendIpv4(byte[] packet) + { + if (_ipv4 == null) + { + return; + } + + IPEndPoint endpoint = new IPEndPoint(Ipv4MulticastAddress, Port); + bool sent = false; + foreach (IPAddress address in _ipv4Interfaces) + { + try + { + _ipv4.Client.SetSocketOption( + SocketOptionLevel.IP, + SocketOptionName.MulticastInterface, + address.GetAddressBytes()); + _ipv4.Send(packet, packet.Length, endpoint); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { return; } + } + if (!sent) + { + try { _ipv4.Send(packet, packet.Length, endpoint); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private void SendIpv6(byte[] packet) + { + if (_ipv6 == null) + { + return; + } + + bool sent = false; + foreach (int index in _ipv6Interfaces) + { + try + { + _ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); + IPAddress scoped = new IPAddress(Ipv6MulticastAddress.GetAddressBytes(), index); + _ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, Port)); + sent = true; + } + catch (SocketException) { } + catch (ObjectDisposedException) { return; } + } + if (!sent) + { + try { _ipv6.Send(packet, packet.Length, new IPEndPoint(Ipv6MulticastAddress, Port)); } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + } + + private async Task ReceiveLoopAsync(UdpClient client) + { + while (Volatile.Read(ref _disposed) == 0) + { + try + { + UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); + if (result.RemoteEndPoint != null && result.Buffer != null) + { + _received(result.Buffer, result.RemoteEndPoint); + } + } + catch (ObjectDisposedException) { return; } + catch (SocketException) + { + if (Volatile.Read(ref _disposed) != 0) + { + return; + } + } + catch (Exception ex) + { + if (Volatile.Read(ref _disposed) == 0) + { + BNL.LogWarning($"LAN mDNS receive failed: {ex.Message}"); + } + } + } + } + + private void DiscoverInterfaces() + { + HashSet ipv4 = new HashSet(); + HashSet ipv6 = new HashSet(); + try + { + foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) + { + if (nic.OperationalStatus != OperationalStatus.Up + || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) + { + continue; + } + + IPInterfaceProperties properties = nic.GetIPProperties(); + foreach (UnicastIPAddressInformation info in properties.UnicastAddresses) + { + if (info.Address.AddressFamily == AddressFamily.InterNetwork + && !IPAddress.IsLoopback(info.Address)) + { + ipv4.Add(info.Address); + } + } + try + { + IPv6InterfaceProperties v6 = properties.GetIPv6Properties(); + if (v6 != null && v6.Index > 0) + { + ipv6.Add(v6.Index); + } + } + catch (Exception ex) when (ex is NetworkInformationException + || ex is PlatformNotSupportedException + || ex is NotImplementedException) + { + } + } + } + catch (Exception) + { + } + + _ipv4Interfaces.AddRange(ipv4); + _ipv6Interfaces.AddRange(ipv6); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try { _ipv4?.Close(); } + catch (ObjectDisposedException) { } + try { _ipv6?.Close(); } + catch (ObjectDisposedException) { } + _ipv4 = null; + _ipv6 = null; + } + } +} diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta new file mode 100644 index 0000000000..1413e4fdb5 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 684df842763b48b0890a4c1e8fd9c142 diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 9b7f644337..a0082af172 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -18,16 +18,11 @@ namespace Basis.Network.Core /// public sealed class BasisLanServerAnnouncer : IDisposable { - public const int DiscoveryPort = 42960; - public const int MdnsPort = 5353; + public const int DiscoveryPort = BasisLanDiscoveryProtocol.DiscoveryPort; + public const int MdnsPort = BasisLanMdnsTransport.Port; - private const uint PacketMagic = 0xBA515201u; - private const ushort PacketVersion = 1; private const int InitialAdvertisementDelayMs = 750; private const int AdvertisementIntervalMs = 1500; - private const int MaxStackIdBytes = 64; - private const int MaxServerNameBytes = 128; - private const int MaxMotdBytes = 384; private const string MdnsServiceType = "_basisvr._udp.local"; private const string MdnsMetaServiceType = "_services._dns-sd._udp.local"; private const uint MdnsTtlSeconds = 120; @@ -40,14 +35,8 @@ public sealed class BasisLanServerAnnouncer : IDisposable private const ushort DnsIn = 1; private const ushort DnsFlushIn = 0x8001; - private static readonly IPAddress DiscoveryMulticastAddress = IPAddress.Parse("239.255.42.99"); - private static readonly IPAddress MdnsIpv4Address = IPAddress.Parse("224.0.0.251"); - private static readonly IPAddress MdnsIpv6Address = IPAddress.Parse("ff02::fb"); - private readonly object _gate = new object(); private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private readonly List _ipv4Interfaces = new List(); - private readonly List _ipv6Interfaces = new List(); private readonly List _advertisedAddresses = new List(); private readonly byte[] _customPayload; private readonly byte[] _mdnsAnnouncement; @@ -60,8 +49,7 @@ public sealed class BasisLanServerAnnouncer : IDisposable private readonly List _txtValues; private UdpClient _customSender; - private UdpClient _mdnsIpv4; - private UdpClient _mdnsIpv6; + private BasisLanMdnsTransport _mdnsTransport; private long _lastMdnsResponseTicks; private int _lastMdnsResponseKey; private string _lastMdnsResponseRemote; @@ -112,39 +100,31 @@ public BasisLanServerAnnouncer( TxtValue("pwd", requiresPassword ? "1" : "0"), }; - DiscoverInterfaces(); - _customPayload = BuildCustomPayload( + DiscoverAdvertisedAddresses(); + _customPayload = BasisLanDiscoveryProtocol.Serialize(new BasisLanAdvertisement( InstanceId, serverPort, requiresPassword, effectiveStackId, effectiveServerName, - effectiveMotd); + effectiveMotd)); _mdnsAnnouncement = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: false, includeService: true); _mdnsMetaResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: false); _mdnsCombinedResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: true); _mdnsGoodbye = BuildMdnsPacket(0, includeMeta: false, includeService: true); TryCreateSockets(); - if (_customSender == null && _mdnsIpv4 == null && _mdnsIpv6 == null) + if (_customSender == null && _mdnsTransport == null) { _cancellation.Dispose(); throw new SocketException((int)SocketError.AddressFamilyNotSupported); } - if (_mdnsIpv4 != null) - { - _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv4, _cancellation.Token)); - } - if (_mdnsIpv6 != null) - { - _ = Task.Run(() => ReceiveMdnsLoopAsync(_mdnsIpv6, _cancellation.Token)); - } if (_customSender != null) { _ = Task.Run(() => CustomAdvertisementLoopAsync(_cancellation.Token)); } - if (_mdnsIpv4 != null || _mdnsIpv6 != null) + if (_mdnsTransport != null) { _ = Task.Run(() => InitialMdnsAnnouncementsAsync(_cancellation.Token)); } @@ -168,98 +148,18 @@ private void TryCreateSockets() BNL.LogWarning($"Basis LAN datagram announcements are unavailable: {ex.Message}"); } - if (Socket.OSSupportsIPv4) - { - try { _mdnsIpv4 = CreateMdnsIpv4Socket(); } - catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS announcements are unavailable: {ex.Message}"); } - } - if (Socket.OSSupportsIPv6) - { - try { _mdnsIpv6 = CreateMdnsIpv6Socket(); } - catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS announcements are unavailable: {ex.Message}"); } - } - } - - private UdpClient CreateMdnsIpv4Socket() - { - UdpClient client = new UdpClient(AddressFamily.InterNetwork); - try - { - ConfigureSharedSocket(client); - client.Client.Bind(new IPEndPoint(IPAddress.Any, MdnsPort)); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.AddMembership, - new MulticastOption(MdnsIpv4Address, address)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(MdnsIpv4Address); - } - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private UdpClient CreateMdnsIpv6Socket() - { - UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); try { - ConfigureSharedSocket(client); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); - client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, MdnsPort)); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (int index in _ipv6Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IPv6, - SocketOptionName.AddMembership, - new IPv6MulticastOption(MdnsIpv6Address, index)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(MdnsIpv6Address); - } - return client; + _mdnsTransport = new BasisLanMdnsTransport(ProcessMdnsQuery); } - catch + catch (Exception ex) { - client.Dispose(); - throw; + _mdnsTransport?.Dispose(); + _mdnsTransport = null; + BNL.LogWarning($"Basis mDNS announcements are unavailable: {ex.Message}"); } } - private static void ConfigureSharedSocket(UdpClient client) - { - try { client.Client.ExclusiveAddressUse = false; } - catch (PlatformNotSupportedException) { } - client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - private async Task CustomAdvertisementLoopAsync(CancellationToken cancellationToken) { try @@ -287,9 +187,9 @@ private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationT try { await Task.Delay(20, cancellationToken).ConfigureAwait(false); - SendMdnsMulticast(_mdnsAnnouncement); + _mdnsTransport.SendMulticast(_mdnsAnnouncement); await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - SendMdnsMulticast(_mdnsAnnouncement); + _mdnsTransport.SendMulticast(_mdnsAnnouncement); } catch (OperationCanceledException) { } catch (ObjectDisposedException) { } @@ -302,30 +202,6 @@ private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationT } } - private async Task ReceiveMdnsLoopAsync(UdpClient client, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); - ProcessMdnsQuery(result.Buffer, result.RemoteEndPoint); - } - catch (ObjectDisposedException) { return; } - catch (SocketException) - { - if (cancellationToken.IsCancellationRequested) return; - } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - { - BNL.LogWarning($"Basis mDNS receive failed: {ex.Message}"); - } - } - } - } - private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) { if (!TryMatchMdnsQuery( @@ -370,11 +246,11 @@ private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) if (wantsUnicast || legacyUnicast) { - SendMdnsUnicast(response, remoteEndPoint); + _mdnsTransport.SendUnicast(response, remoteEndPoint); } else { - SendMdnsMulticast(response); + _mdnsTransport.SendMulticast(response); } } } @@ -390,7 +266,7 @@ private void SendCustomAdvertisement() if (sender == null) return; HashSet sent = new HashSet(StringComparer.Ordinal); - SendCustomTo(sender, new IPEndPoint(DiscoveryMulticastAddress, DiscoveryPort), sent); + SendCustomTo(sender, new IPEndPoint(BasisLanDiscoveryProtocol.MulticastAddress, DiscoveryPort), sent); SendCustomTo(sender, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort), sent); try @@ -438,80 +314,8 @@ private void SendCustomTo(UdpClient sender, IPEndPoint endpoint, HashSet catch (ObjectDisposedException) { } } - private void SendMdnsMulticast(byte[] packet) + private void DiscoverAdvertisedAddresses() { - if (packet == null || packet.Length == 0) return; - - UdpClient ipv4 = _mdnsIpv4; - if (ipv4 != null) - { - IPEndPoint endpoint = new IPEndPoint(MdnsIpv4Address, MdnsPort); - bool sent = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - ipv4.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.MulticastInterface, - address.GetAddressBytes()); - ipv4.Send(packet, packet.Length, endpoint); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { break; } - } - if (!sent) - { - try { ipv4.Send(packet, packet.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - UdpClient ipv6 = _mdnsIpv6; - if (ipv6 != null) - { - bool sent = false; - foreach (int index in _ipv6Interfaces) - { - try - { - ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); - IPAddress scoped = new IPAddress(MdnsIpv6Address.GetAddressBytes(), index); - ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, MdnsPort)); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { break; } - } - if (!sent) - { - try { ipv6.Send(packet, packet.Length, new IPEndPoint(MdnsIpv6Address, MdnsPort)); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - } - - private void SendMdnsUnicast(byte[] packet, IPEndPoint endpoint) - { - if (packet == null || endpoint == null) return; - try - { - UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork - ? _mdnsIpv4 - : _mdnsIpv6; - client?.Send(packet, packet.Length, endpoint); - } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - - private void DiscoverInterfaces() - { - HashSet ipv4 = new HashSet(); - HashSet ipv6 = new HashSet(); HashSet advertised = new HashSet(); try { @@ -523,40 +327,24 @@ private void DiscoverInterfaces() continue; } - IPInterfaceProperties properties = networkInterface.GetIPProperties(); - foreach (UnicastIPAddressInformation information in properties.UnicastAddresses) + foreach (UnicastIPAddressInformation information in networkInterface.GetIPProperties().UnicastAddresses) { IPAddress address = information.Address; - if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) continue; - - if (address.AddressFamily == AddressFamily.InterNetwork) + if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) { - ipv4.Add(address); - advertised.Add(address); + continue; } - else if (address.AddressFamily == AddressFamily.InterNetworkV6 && address.IsIPv6LinkLocal) + if (address.AddressFamily == AddressFamily.InterNetwork || address.IsIPv6LinkLocal) { advertised.Add(address); } } - - try - { - IPv6InterfaceProperties propertiesV6 = properties.GetIPv6Properties(); - if (propertiesV6 != null && propertiesV6.Index > 0) - { - ipv6.Add(propertiesV6.Index); - } - } - catch (Exception ex) when (ex is NetworkInformationException - || ex is PlatformNotSupportedException - || ex is NotImplementedException) { } } } - catch (Exception) { } + catch (Exception) + { + } - _ipv4Interfaces.AddRange(ipv4); - _ipv6Interfaces.AddRange(ipv6); _advertisedAddresses.AddRange(advertised); } @@ -668,53 +456,10 @@ private static byte[] WithDnsId(byte[] packet, ushort id) return response; } - private static byte[] BuildCustomPayload( - Guid instanceId, - ushort serverPort, - bool requiresPassword, - string networkStackId, - string serverName, - string motd) - { - using MemoryStream stream = new MemoryStream(256); - using BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true); - writer.Write(PacketMagic); - writer.Write(PacketVersion); - writer.Write(instanceId.ToByteArray()); - writer.Write(serverPort); - writer.Write(requiresPassword ? (byte)1 : (byte)0); - WritePacketString(writer, networkStackId, MaxStackIdBytes); - WritePacketString(writer, serverName, MaxServerNameBytes); - WritePacketString(writer, motd, MaxMotdBytes); - writer.Flush(); - return stream.ToArray(); - } - - private static void WritePacketString(BinaryWriter writer, string value, int maxBytes) - { - byte[] bytes = Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes)); - writer.Write((ushort)bytes.Length); - writer.Write(bytes); - } - private static string TxtValue(string key, string value) { int budget = 254 - Encoding.UTF8.GetByteCount(key); - return key + "=" + LimitUtf8(value, Math.Max(0, budget)); - } - - private static string LimitUtf8(string value, int maxBytes) - { - if (string.IsNullOrEmpty(value) || maxBytes <= 0) return string.Empty; - if (Encoding.UTF8.GetByteCount(value) <= maxBytes) return value; - - int length = value.Length; - while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) - { - length--; - if (length > 0 && char.IsHighSurrogate(value[length - 1])) length--; - } - return length == 0 ? string.Empty : value.Substring(0, length); + return key + "=" + BasisLanDiscoveryProtocol.LimitUtf8(value, Math.Max(0, budget)); } private readonly struct DnsRecord @@ -891,8 +636,8 @@ public void Dispose() try { - SendMdnsMulticast(_mdnsGoodbye); - SendMdnsMulticast(_mdnsGoodbye); + _mdnsTransport?.SendMulticast(_mdnsGoodbye); + _mdnsTransport?.SendMulticast(_mdnsGoodbye); } catch (Exception ex) { @@ -901,14 +646,10 @@ public void Dispose() try { _customSender?.Close(); } catch (ObjectDisposedException) { } - try { _mdnsIpv4?.Close(); } - catch (ObjectDisposedException) { } - try { _mdnsIpv6?.Close(); } - catch (ObjectDisposedException) { } + _mdnsTransport?.Dispose(); _customSender = null; - _mdnsIpv4 = null; - _mdnsIpv6 = null; + _mdnsTransport = null; _cancellation.Dispose(); } } From 1febd2f0b675159f1f8fcd38129f767d08633ed8 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 04:21:54 +0000 Subject: [PATCH 05/25] Simplify LAN discovery lifecycle --- .../BasisLanDiscoveryProtocol.cs | 2 +- .../BasisLanServerAnnouncer.cs | 10 - .../Networking/BasisLanDiscoveryProtocol.cs | 172 +++--------------- .../Networking/BasisLanMdnsWire.cs | 26 +-- .../Networking/BasisNetworkServerRunner.cs | 60 ++++-- .../Runtime/ServersProvider.cs | 14 +- .../BasisLanDiscoveryProtocol.cs | 2 +- .../BasisLanServerAnnouncer.cs | 10 - 8 files changed, 81 insertions(+), 215 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 77b17febdb..590574ffe7 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -114,7 +114,7 @@ public static bool TryDeserialize(byte[] data, out BasisLanAdvertisement adverti } } - internal static string LimitUtf8(string value, int maxBytes) + public static string LimitUtf8(string value, int maxBytes) { if (string.IsNullOrEmpty(value) || maxBytes <= 0) { diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index a0082af172..4d246b7490 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -56,16 +56,6 @@ public sealed class BasisLanServerAnnouncer : IDisposable private bool _disposed; public Guid InstanceId { get; } - public bool IsRunning - { - get - { - lock (_gate) - { - return !_disposed; - } - } - } public BasisLanServerAnnouncer( ushort serverPort, diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs index 46dd1dee92..98fa0d57ee 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs @@ -11,60 +11,6 @@ namespace Basis.Scripts.Networking { - /// - /// Periodically announces an in-process hosted server through the custom LAN - /// datagram protocol and standard mDNS/DNS-SD. The service is opt-in and is - /// stopped with the hosted server lifecycle. - /// - public static class BasisLanServerAdvertiser - { - private static readonly object Gate = new object(); - private static Basis.Network.Core.BasisLanServerAnnouncer _announcer; - - public static bool IsRunning - { - get - { - lock (Gate) - { - return _announcer != null && _announcer.IsRunning; - } - } - } - - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - private static void ResetStatics() => Stop(); - - public static void Start(ushort serverPort, string networkStackId, string serverName, string motd, bool requiresPassword) - { - Basis.Network.Core.BasisLanServerAnnouncer announcer = new Basis.Network.Core.BasisLanServerAnnouncer( - serverPort, - networkStackId, - serverName, - motd, - requiresPassword); - - Basis.Network.Core.BasisLanServerAnnouncer previousAnnouncer; - lock (Gate) - { - previousAnnouncer = _announcer; - _announcer = announcer; - } - previousAnnouncer?.Dispose(); - } - - public static void Stop() - { - Basis.Network.Core.BasisLanServerAnnouncer announcer; - lock (Gate) - { - announcer = _announcer; - _announcer = null; - } - announcer?.Dispose(); - } - } - /// /// Server-directory source backed by LAN advertisements. Entries expire automatically /// when their host stops announcing, matching Minecraft-style LAN discovery behavior. @@ -75,7 +21,6 @@ public sealed class LanServersDirectorySource : IServerDirectorySource, IDisposa private const int EntryLifetimeMs = 5000; private const int CleanupIntervalMs = 1000; private const int MaxTrackedServers = 256; - private const int MaxAddressesPerServer = 16; private static readonly long EntryLifetimeTicks = (long)(EntryLifetimeMs / 1000.0 * Stopwatch.Frequency); @@ -83,7 +28,9 @@ private sealed class DiscoveredServer { public Guid InstanceId; public IPAddress Address; - public readonly Dictionary AddressLastSeen = new Dictionary(); + public long AddressLastSeenTicks; + public IPAddress AlternateAddress; + public long AlternateAddressLastSeenTicks; public ushort Port; public bool RequiresPassword; public string NetworkStackId; @@ -335,14 +282,7 @@ private void ProcessAdvertisement(BasisLanAdvertisement advertisement, IPAddress _servers.Add(advertisement.InstanceId, existing); } - PruneAddressCandidatesLocked(existing, nowTicks); - TrackAddressLocked(existing, address, nowTicks); - if (existing.Address == null || !existing.AddressLastSeen.ContainsKey(existing.Address)) - { - IPAddress selectedAddress = SelectPreferredAddressLocked(existing); - changed |= !Equals(existing.Address, selectedAddress); - existing.Address = selectedAddress; - } + changed |= UpdateAddressLocked(existing, address, nowTicks); if (!isNew) { @@ -432,100 +372,46 @@ private bool PruneExpiredLocked(long nowTicks) return true; } - private static void PruneAddressCandidatesLocked(DiscoveredServer server, long nowTicks) + private static bool UpdateAddressLocked(DiscoveredServer server, IPAddress address, long nowTicks) { - List expired = null; - foreach (KeyValuePair pair in server.AddressLastSeen) + if (server.Address == null) { - if (nowTicks - pair.Value <= EntryLifetimeTicks) - { - continue; - } - - expired ??= new List(); - expired.Add(pair.Key); + server.Address = address; + server.AddressLastSeenTicks = nowTicks; + return true; } - if (expired == null) + if (Equals(server.Address, address)) { - return; - } - - foreach (IPAddress address in expired) - { - server.AddressLastSeen.Remove(address); - } - } - - private static void TrackAddressLocked(DiscoveredServer server, IPAddress address, long nowTicks) - { - if (!server.AddressLastSeen.ContainsKey(address) - && server.AddressLastSeen.Count >= MaxAddressesPerServer) - { - IPAddress oldestAddress = null; - long oldestTicks = long.MaxValue; - foreach (KeyValuePair pair in server.AddressLastSeen) - { - if (Equals(pair.Key, server.Address) || pair.Value >= oldestTicks) - { - continue; - } - - oldestAddress = pair.Key; - oldestTicks = pair.Value; - } - - if (oldestAddress != null) - { - server.AddressLastSeen.Remove(oldestAddress); - } - else - { - return; - } + server.AddressLastSeenTicks = nowTicks; + return false; } - server.AddressLastSeen[address] = nowTicks; - } - - private static IPAddress SelectPreferredAddressLocked(DiscoveredServer server) - { - IPAddress selected = null; - foreach (IPAddress candidate in server.AddressLastSeen.Keys) + if (Equals(server.AlternateAddress, address)) { - if (selected == null || CompareAddressPreference(candidate, selected) < 0) - { - selected = candidate; - } + server.AlternateAddressLastSeenTicks = nowTicks; } - return selected; - } - - private static int CompareAddressPreference(IPAddress left, IPAddress right) - { - int rankComparison = AddressPreferenceRank(left).CompareTo(AddressPreferenceRank(right)); - if (rankComparison != 0) + else if (server.AlternateAddress == null + || nowTicks - server.AlternateAddressLastSeenTicks > EntryLifetimeTicks + || AddressPreferenceRank(address) < AddressPreferenceRank(server.AlternateAddress)) { - return rankComparison; + server.AlternateAddress = address; + server.AlternateAddressLastSeenTicks = nowTicks; } - byte[] leftBytes = left.GetAddressBytes(); - byte[] rightBytes = right.GetAddressBytes(); - int lengthComparison = leftBytes.Length.CompareTo(rightBytes.Length); - if (lengthComparison != 0) + if (nowTicks - server.AddressLastSeenTicks <= EntryLifetimeTicks) { - return lengthComparison; + return false; } - for (int i = 0; i < leftBytes.Length; i++) - { - int byteComparison = leftBytes[i].CompareTo(rightBytes[i]); - if (byteComparison != 0) - { - return byteComparison; - } - } - return left.ScopeId.CompareTo(right.ScopeId); + IPAddress replacement = server.AlternateAddress ?? address; + server.Address = replacement; + server.AddressLastSeenTicks = Equals(replacement, address) + ? nowTicks + : server.AlternateAddressLastSeenTicks; + server.AlternateAddress = null; + server.AlternateAddressLastSeenTicks = 0; + return true; } private static int AddressPreferenceRank(IPAddress address) diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs index b6dc248928..cd06033d0d 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs @@ -32,7 +32,6 @@ internal sealed class Record { public string Name; public ushort Type; - public ushort Class; public uint Ttl; public string DomainName; public ushort Port; @@ -123,7 +122,7 @@ public static void Extract( Action found, Action removed) { - if (message == null || !message.IsResponse) + if (message == null) { return; } @@ -185,25 +184,12 @@ public static void Extract( instanceId, srv.Port, password == "1", - LimitUtf8(stack, BasisLanDiscoveryProtocol.MaxStackIdBytes), - LimitUtf8(name, BasisLanDiscoveryProtocol.MaxServerNameBytes), - LimitUtf8(motd, BasisLanDiscoveryProtocol.MaxMotdBytes)), address); + BasisLanDiscoveryProtocol.LimitUtf8(stack, BasisLanDiscoveryProtocol.MaxStackIdBytes), + BasisLanDiscoveryProtocol.LimitUtf8(name, BasisLanDiscoveryProtocol.MaxServerNameBytes), + BasisLanDiscoveryProtocol.LimitUtf8(motd, BasisLanDiscoveryProtocol.MaxMotdBytes)), address); } } - public static string LimitUtf8(string value, int maxBytes) - { - if (string.IsNullOrEmpty(value) || maxBytes <= 0) return string.Empty; - if (Encoding.UTF8.GetByteCount(value) <= maxBytes) return value; - int length = value.Length; - while (length > 0 && Encoding.UTF8.GetByteCount(value, 0, length) > maxBytes) - { - length--; - if (length > 0 && char.IsHighSurrogate(value[length - 1])) length--; - } - return length == 0 ? string.Empty : value.Substring(0, length); - } - private static byte[] EncodeName(string value) { string normalized = Normalize(value); @@ -227,14 +213,14 @@ private static bool ReadRecord(byte[] packet, ref int offset, out Record record) record = null; if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 10) return false; ushort type = U16(packet, ref offset); - ushort recordClass = U16(packet, ref offset); + U16(packet, ref offset); uint ttl = U32(packet, ref offset); ushort length = U16(packet, ref offset); int start = offset; int end = start + length; if (end < start || end > packet.Length) return false; - Record parsed = new Record { Name = name, Type = type, Class = recordClass, Ttl = ttl }; + Record parsed = new Record { Name = name, Type = type, Ttl = ttl }; if (type == Ptr) { int pos = start; diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs index b257a294e4..e910b77386 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs @@ -1,4 +1,5 @@ using Basis.Network; +using Basis.Network.Core; using Basis.Scripts.Networking; using System; using System.Threading; @@ -10,16 +11,24 @@ public class BasisNetworkServerRunner { public Task serverTask; private readonly object lifecycleGate = new object(); - CancellationTokenSource cancellationTokenSource; + private CancellationTokenSource cancellationTokenSource; + private BasisLanServerAnnouncer lanServerAnnouncer; + [SerializeField] public Configuration Configuration; - // Start is called once before the first execution of Update after the MonoBehaviour is created + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetLanAdvertising() + { + BasisNetworkConnection.BasisNetworkServerRunner?.SetLanAdvertising(false); + } + public void Initialize(Configuration configuration, string LogPath, string UUIDTomarkAsAdmin) { Configuration = configuration; BasisServerSideLogging.Initialize(Configuration, LogPath); cancellationTokenSource = new CancellationTokenSource(); - var cancellationToken = cancellationTokenSource.Token; + CancellationToken cancellationToken = cancellationTokenSource.Token; serverTask = Task.Run(() => { try @@ -28,20 +37,11 @@ public void Initialize(Configuration configuration, string LogPath, string UUIDT { cancellationToken.ThrowIfCancellationRequested(); NetworkServer.StartServer(Configuration); - - if (BasisNetworkManagement.HostShowToLan) - { - BasisLanServerAdvertiser.Start( - Configuration.SetPort, - Configuration.NetworkStackId, - Configuration.ServerName, - Configuration.ServerMotd, - Configuration.UseAuth && !string.IsNullOrEmpty(Configuration.Password)); - } + ApplyLanAdvertisingLocked(BasisNetworkManagement.HostShowToLan); } cancellationToken.ThrowIfCancellationRequested(); - PermissionIntegration.Manager.AddUserNode(UUIDTomarkAsAdmin,"*"); + PermissionIntegration.Manager.AddUserNode(UUIDTomarkAsAdmin, "*"); PermissionIntegration.Manager.AddUserToGroup(UUIDTomarkAsAdmin, "admin"); } catch (OperationCanceledException) @@ -49,18 +49,44 @@ public void Initialize(Configuration configuration, string LogPath, string UUIDT } catch (Exception ex) { - BasisLanServerAdvertiser.Stop(); + SetLanAdvertising(false); BNL.LogError($"Server encountered an error: {ex.Message} {ex.StackTrace}"); - // Optionally, handle server restart or log critical errors } }, cancellationToken); } + + public void SetLanAdvertising(bool enabled) + { + lock (lifecycleGate) + { + ApplyLanAdvertisingLocked(enabled); + } + } + + private void ApplyLanAdvertisingLocked(bool enabled) + { + BasisLanServerAnnouncer replacement = null; + if (enabled && Configuration != null) + { + replacement = new BasisLanServerAnnouncer( + Configuration.SetPort, + Configuration.NetworkStackId, + Configuration.ServerName, + Configuration.ServerMotd, + Configuration.UseAuth && !string.IsNullOrEmpty(Configuration.Password)); + } + + BasisLanServerAnnouncer previous = lanServerAnnouncer; + lanServerAnnouncer = replacement; + previous?.Dispose(); + } + public void Stop() { lock (lifecycleGate) { cancellationTokenSource?.Cancel(); - BasisLanServerAdvertiser.Stop(); + ApplyLanAdvertisingLocked(false); NetworkServer.StopServer(); } } diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index 78ab9d31d1..faac183837 100644 --- a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs +++ b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs @@ -370,19 +370,7 @@ private void OnHostShowToLanChanged(bool value) return; } - if (!value) - { - BasisLanServerAdvertiser.Stop(); - return; - } - - Configuration configuration = runner.Configuration; - BasisLanServerAdvertiser.Start( - configuration.SetPort, - configuration.NetworkStackId, - configuration.ServerName, - configuration.ServerMotd, - configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); + runner.SetLanAdvertising(value); } private void PopulateHostStackDropdown() diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 77b17febdb..590574ffe7 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -114,7 +114,7 @@ public static bool TryDeserialize(byte[] data, out BasisLanAdvertisement adverti } } - internal static string LimitUtf8(string value, int maxBytes) + public static string LimitUtf8(string value, int maxBytes) { if (string.IsNullOrEmpty(value) || maxBytes <= 0) { diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index a0082af172..4d246b7490 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -56,16 +56,6 @@ public sealed class BasisLanServerAnnouncer : IDisposable private bool _disposed; public Guid InstanceId { get; } - public bool IsRunning - { - get - { - lock (_gate) - { - return !_disposed; - } - } - } public BasisLanServerAnnouncer( ushort serverPort, From 0ced7319dc2a38bcdb1c8a5149de8c07856a679a Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 04:58:30 +0000 Subject: [PATCH 06/25] Retry LAN connections with default password --- .../BasisNetworkCore/BasisNetworkCommons.cs | 3 + .../BasisServerHandleEvents.cs | 7 +- .../BasisUI/Localization/Languages/en.json | 4 +- .../Networking/BasisConnectionService.cs | 88 ++++++++++++++++++- .../Networking/BasisNetworkConnection.cs | 1 + .../Networking/BasisNetworkEvents.cs | 20 +++++ .../Runtime/ServersProvider.cs | 25 ++++-- .../BasisNetworkCore/BasisNetworkCommons.cs | 3 + .../BasisServerHandleEvents.cs | 7 +- 9 files changed, 146 insertions(+), 12 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs b/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs index d22cabb6d9..94bee03657 100644 --- a/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs +++ b/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs @@ -378,12 +378,15 @@ public static bool IsPluginChannel(byte channel) // Wire: [magic:uint][kind:byte][aux0:ushort][aux1:ushort][message:string] // VersionMismatch → aux0 = server protocol version, aux1 = client protocol version. // ServerFull → aux0/aux1 unused (0); any counts are in the message. + // InvalidPassword → aux0/aux1 unused (0). /// Marker for a structured reject payload. "BA51 5CE1" ≈ "Basis reject". public const uint RejectMagic = 0xBA515CE1u; /// RejectKind: the client's protocol version does not match the server's. public const byte RejectKind_VersionMismatch = 1; /// RejectKind: the server has reached its player limit. public const byte RejectKind_ServerFull = 2; + /// RejectKind: the supplied server password was rejected. + public const byte RejectKind_InvalidPassword = 3; /// /// Maps quality index (0‑3) + additional data presence → byte-ID channel. diff --git a/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs b/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs index 031207051f..e081cfcb4b 100644 --- a/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs +++ b/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs @@ -254,7 +254,12 @@ public static void HandleConnectionRequest(ConnectionRequest ConReq) } if (NetworkServer.Auth.IsAuthenticated(AuthBytes) == false) { - RejectWithReason(ConReq, "Authentication failed, Auth rejected"); + RejectStructured( + ConReq, + BasisNetworkCommons.RejectKind_InvalidPassword, + 0, + 0, + "The server password is incorrect."); return; } } diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json index c714cc540e..0ff62ffc2c 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json @@ -2220,7 +2220,7 @@ }, { "key": "menu.servers.hostShowToLan.description", - "value": "Advertise this hosted server to Basis clients on your local network. Password-protected servers ask nearby clients for the password when they connect; the password is never included in LAN announcements." + "value": "Advertise this hosted server to Basis clients on your local network. Password-protected servers first try the standard default password, then prompt if it is rejected; the password is never included in LAN announcements." }, { "key": "menu.servers.lanPassword.title", @@ -2228,7 +2228,7 @@ }, { "key": "menu.servers.lanPassword.description", - "value": "This LAN server requires a password." + "value": "The password was rejected. Enter the server password." }, { "key": "menu.servers.list.lanBadge", diff --git a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs index a4654e95eb..96af6ff220 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs @@ -27,6 +27,26 @@ public static class BasisConnectionService public static bool AutoConnectAttempted; private static bool _connectInProgress; + private static readonly object LanPasswordGate = new object(); + private static ServerDirectoryEntry _pendingLanPasswordEntry; + + /// + /// Raised when a password-protected LAN connection is rejected for an invalid password. + /// UI packages can use this to request a replacement without coupling the framework to a + /// particular menu implementation. + /// + public static event Action LanPasswordRequired; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetLanPasswordState() + { + lock (LanPasswordGate) + { + _pendingLanPasswordEntry = null; + } + LanPasswordRequired = null; + _connectInProgress = false; + } // Stable key the loading bar uses to merge updates for the same connection // attempt, distinct from the bundle-load key BasisSceneLoad reports under. @@ -68,6 +88,59 @@ public static void ReportConnectionError(string message) public static void CompleteConnectionProgress() => BasisSceneLoad.progressCallback.ReportProgress(ConnectionProgressKey, 100f, string.Empty); + private static void SetPendingLanPasswordAttempt(ServerDirectoryEntry entry) + { + lock (LanPasswordGate) + { + _pendingLanPasswordEntry = entry; + } + } + + internal static void ClearPendingLanPasswordAttempt() + { + SetPendingLanPasswordAttempt(null); + } + + internal static bool TryHandleLanPasswordRejected() + { + ServerDirectoryEntry entry; + lock (LanPasswordGate) + { + entry = _pendingLanPasswordEntry; + _pendingLanPasswordEntry = null; + } + + if (entry == null) + { + return false; + } + + entry.Password = string.Empty; + entry.Target?.Set(ConnectionTarget.Keys.Password, string.Empty); + + Action handler = LanPasswordRequired; + if (handler == null) + { + return false; + } + + try + { + handler(entry); + return true; + } + catch (Exception ex) + { + BasisDebug.LogError($"LanPasswordRequired handler threw: {ex.Message}"); + return false; + } + } + + internal static void NotifyConnectionSucceeded() + { + ClearPendingLanPasswordAttempt(); + } + /// /// Panel-independent connection routine. Reports progress through the loading bar, /// disconnects any existing session, sets the network manager fields, loads the @@ -111,13 +184,24 @@ public static async Task ConnectAsync(ServerDirectoryEntry entry, string userNam BasisLocalPlayer.Instance.DisplayName = userName.Trim(); BasisLocalPlayer.Instance.SetSafeDisplayname(); BasisDataStore.SaveString(BasisLocalPlayer.Instance.DisplayName, UsernameFileName); - if (!string.Equals(entry.SourceId, LanServersDirectorySource.Id, StringComparison.OrdinalIgnoreCase)) + bool isLan = string.Equals( + entry.SourceId, + LanServersDirectorySource.Id, + StringComparison.OrdinalIgnoreCase); + if (!isLan) { // LAN advertisements are session-scoped and their passwords are never persisted. // Keep the previous durable auto-connect target instead of saving an expiring LAN id. BasisDataStore.SaveString(entry.Id, LastConnectedServerIdFile); } + if (isLan && entry.HasPassword && string.IsNullOrEmpty(entry.Password)) + { + entry.Password = SavedServersDirectorySource.DefaultServerPassword; + entry.Target?.Set(ConnectionTarget.Keys.Password, entry.Password); + } + SetPendingLanPasswordAttempt(isLan && entry.HasPassword ? entry : null); + string address = entry.Target?.Get(ConnectionTarget.Keys.Address) ?? string.Empty; string portString = entry.Target?.Get(ConnectionTarget.Keys.Port) ?? string.Empty; ushort port; @@ -156,11 +240,13 @@ public static async Task ConnectAsync(ServerDirectoryEntry entry, string userNam } catch (TimeoutException tex) { + ClearPendingLanPasswordAttempt(); ReportConnectionError(BasisLocalization.Get("menu.servers.error.timeout")); BasisDebug.LogError(tex.ToString()); } catch (Exception ex) { + ClearPendingLanPasswordAttempt(); ReportConnectionError(BasisLocalization.Get("menu.servers.error.connectFailed")); BasisDebug.LogError(ex.ToString()); } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs index 0982412a59..10e0caf3f3 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkConnection.cs @@ -171,6 +171,7 @@ private static void PeerConnectedEvent(NetPeer peer) public static void SetupLocalPlayer(NetPeer peer) { BasisDebug.Log("Authentication confirmed! Now setting up Networked Local Player"); + BasisConnectionService.NotifyConnectionSucceeded(); #if UNITY_SERVER BasisHeadlessRuntimeStatus.MarkConnected(); #endif diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs index 421cdcfbe1..459d213558 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs @@ -868,6 +868,7 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) { if (disconnectInfo.Reason == DisconnectReason.DisconnectPeerCalled) { + BasisConnectionService.ClearPendingLanPasswordAttempt(); BasisDebug.Log($"Disconnected locally [{disconnectInfo.Reason}]", BasisDebug.LogTag.Networking); return; } @@ -877,6 +878,7 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) if (disconnectInfo.Reason == DisconnectReason.RemoteConnectionClose) { + BasisConnectionService.ClearPendingLanPasswordAttempt(); #if UNITY_SERVER // PeekString is now defensive — a malformed additional-data payload // returns "" instead of throwing. Read once and re-use. @@ -945,6 +947,15 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) title = "Server Full"; body = !string.IsNullOrEmpty(structuredMsg) ? structuredMsg : "This server is full. Please try again later."; break; + case BasisNetworkCommons.RejectKind_InvalidPassword: + if (BasisConnectionService.TryHandleLanPasswordRejected()) + { + BasisDebug.LogWarning("LAN server rejected the password; requesting a replacement from the user."); + return; + } + title = "Incorrect Password"; + body = !string.IsNullOrEmpty(structuredMsg) ? structuredMsg : "The server password is incorrect."; + break; default: title = "Connection Rejected"; body = !string.IsNullOrEmpty(structuredMsg) ? structuredMsg : "The server rejected the connection."; @@ -956,6 +967,13 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) // Legacy bare-string reject, or a non-rejection disconnect (timeout, etc.). PeekString // is defensive: an empty/malformed payload yields "". string reason = extra?.PeekString(); + if (rejected + && string.Equals(reason, "Authentication failed, Auth rejected", StringComparison.Ordinal) + && BasisConnectionService.TryHandleLanPasswordRejected()) + { + BasisDebug.LogWarning("Legacy LAN server rejected the password; requesting a replacement from the user."); + return; + } title = rejected ? "Connection Rejected" : "Server Disconnected"; body = !string.IsNullOrEmpty(reason) ? reason @@ -964,6 +982,8 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) : disconnectInfo.Reason.ToString()); } + BasisConnectionService.ClearPendingLanPasswordAttempt(); + #if UNITY_SERVER if (canShowMenu) #endif diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index faac183837..59bd71c8f3 100644 --- a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs +++ b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs @@ -17,10 +17,27 @@ namespace Basis.BasisUI { public class ServersProvider : BasisMenuActionProvider { + private static ServersProvider _instance; + [RuntimeInitializeOnLoadMethod] public static void AddToMenu() { - BasisMenuBase.AddProvider(new ServersProvider()); + BasisConnectionService.LanPasswordRequired -= OnLanPasswordRequired; + _instance = new ServersProvider(); + BasisConnectionService.LanPasswordRequired += OnLanPasswordRequired; + BasisMenuBase.AddProvider(_instance); + } + + private static void OnLanPasswordRequired(ServerDirectoryEntry entry) + { + if (entry == null || _instance == null) + { + return; + } + + BasisMainMenu.Open(); + _instance.RunAction(); + _instance.ShowLanPasswordPrompt(entry); } public const string TitleKey = "menu.provider.servers"; @@ -932,12 +949,6 @@ private static string DisplayAddress(string address, ushort port) private void OnConnectClicked(ServerDirectoryEntry entry) { - if (IsLan(entry) && entry.HasPassword && string.IsNullOrEmpty(entry.Password)) - { - ShowLanPasswordPrompt(entry); - return; - } - HideLanPasswordPrompt(); _ = ConnectToAsync(entry); } diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs index d22cabb6d9..94bee03657 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs @@ -378,12 +378,15 @@ public static bool IsPluginChannel(byte channel) // Wire: [magic:uint][kind:byte][aux0:ushort][aux1:ushort][message:string] // VersionMismatch → aux0 = server protocol version, aux1 = client protocol version. // ServerFull → aux0/aux1 unused (0); any counts are in the message. + // InvalidPassword → aux0/aux1 unused (0). /// Marker for a structured reject payload. "BA51 5CE1" ≈ "Basis reject". public const uint RejectMagic = 0xBA515CE1u; /// RejectKind: the client's protocol version does not match the server's. public const byte RejectKind_VersionMismatch = 1; /// RejectKind: the server has reached its player limit. public const byte RejectKind_ServerFull = 2; + /// RejectKind: the supplied server password was rejected. + public const byte RejectKind_InvalidPassword = 3; /// /// Maps quality index (0‑3) + additional data presence → byte-ID channel. diff --git a/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs index 031207051f..e081cfcb4b 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs @@ -254,7 +254,12 @@ public static void HandleConnectionRequest(ConnectionRequest ConReq) } if (NetworkServer.Auth.IsAuthenticated(AuthBytes) == false) { - RejectWithReason(ConReq, "Authentication failed, Auth rejected"); + RejectStructured( + ConReq, + BasisNetworkCommons.RejectKind_InvalidPassword, + 0, + 0, + "The server password is incorrect."); return; } } From ba916c4dedc4b8bdfc5c7800ba9eeb9f60726553 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 21:52:29 +0000 Subject: [PATCH 07/25] Use MeaMod.DNS for LAN discovery --- .../BasisNetworkCore/BasisConfigXmlDocs.cs | 2 +- .../BasisLanDiscoveryProtocol.cs | 136 ++-- .../BasisNetworkCore/BasisLanMdnsTransport.cs | 314 +------- .../BasisLanServerAnnouncer.cs | 677 +++--------------- .../BasisNetworkCore/BasisLanServerBrowser.cs | 450 ++++++++++++ .../BasisNetworkCore/BasisNetworkCore.csproj | 1 + .../BasisServerConfiguration.cs | 2 +- .../BasisNetworkServer/NetworkServer.cs | 2 +- .../Networking/BasisLanDiscoveryProtocol.cs | 92 +-- .../Networking/BasisLanMdnsDiscovery.cs | 100 +-- .../Networking/BasisLanMdnsWire.cs | 379 +--------- .../BasisNetworkCore/BasisConfigXmlDocs.cs | 2 +- .../BasisLanDiscoveryProtocol.cs | 136 ++-- .../BasisNetworkCore/BasisLanMdnsTransport.cs | 314 +------- .../BasisLanServerAnnouncer.cs | 677 +++--------------- .../BasisNetworkCore/BasisLanServerBrowser.cs | 450 ++++++++++++ .../BasisLanServerBrowser.cs.meta | 2 + .../BasisServerConfiguration.cs | 2 +- .../BasisNetworkServer/NetworkServer.cs | 2 +- Basis/Packages/com.basis.server/package.json | 3 +- 20 files changed, 1230 insertions(+), 2513 deletions(-) create mode 100644 Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs create mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs.meta diff --git a/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs b/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs index e1fac457a6..e1b6bca5d0 100644 --- a/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs +++ b/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs @@ -146,7 +146,7 @@ private static void RegisterServerConfig() t.Fields.Add(new FieldDoc("ConfigVersion", " Config schema version, managed automatically. When the server gains new settings this file is rewritten to add them (with their defaults) and this number is bumped — don't edit by hand. ")); t.Fields.Add(new FieldDoc("PeerLimit", " Maximum number of simultaneously connected peers (players). int. Default 65535. ", " ===== Networking / listener ===== ")); t.Fields.Add(new FieldDoc("SetPort", " UDP port the server binds and listens on; clients connect to this. ushort, range 1-65535. ")); - t.Fields.Add(new FieldDoc("AnnounceToLan", " Advertise this server to Basis clients on the local network using UDP broadcast/multicast and mDNS/DNS-SD. true|false. Default false. Applied on the next server start. ")); + t.Fields.Add(new FieldDoc("AnnounceToLan", " Advertise this server to Basis clients on the local network using mDNS/DNS-SD. true|false. Default false. Applied on the next server start. ")); t.Fields.Add(new FieldDoc("ServerName", " Display name shown as the row title in client server-list UIs (server-info query). string. ")); t.Fields.Add(new FieldDoc("ServerMotd", " Short message-of-the-day returned alongside the server name. string; empty = none. ")); t.Fields.Add(new FieldDoc("EnableStatistics", " Collect transport statistics (per-peer/packet counters) and run the stats worker; surfaced via the health endpoint. true|false. ")); diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 590574ffe7..e10d55a5b4 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -1,11 +1,9 @@ using System; -using System.IO; -using System.Net; using System.Text; namespace Basis.Network.Core { - /// Metadata carried by Basis LAN discovery packets and DNS-SD records. + /// Metadata published through the Basis DNS-SD service. public readonly struct BasisLanAdvertisement { public readonly Guid InstanceId; @@ -32,88 +30,16 @@ public BasisLanAdvertisement( } } - /// Shared Basis LAN datagram format used by servers and clients. + /// Shared constants and text limits for Basis LAN DNS-SD. public static class BasisLanDiscoveryProtocol { - public const int DiscoveryPort = 42960; - public const uint Magic = 0xBA515201u; - public const ushort Version = 1; - public const int MaxPacketBytes = 1024; + private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); + public const string ServiceName = "_basisvr._udp"; + public const string ProtocolVersion = "1"; public const int MaxStackIdBytes = 64; public const int MaxServerNameBytes = 128; public const int MaxMotdBytes = 384; - public static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.42.99"); - - public static byte[] Serialize(BasisLanAdvertisement advertisement) - { - using (MemoryStream stream = new MemoryStream(256)) - using (BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true)) - { - writer.Write(Magic); - writer.Write(Version); - writer.Write(advertisement.InstanceId.ToByteArray()); - writer.Write(advertisement.ServerPort); - writer.Write(advertisement.RequiresPassword ? (byte)1 : (byte)0); - WriteString(writer, advertisement.NetworkStackId, MaxStackIdBytes); - WriteString(writer, advertisement.ServerName, MaxServerNameBytes); - WriteString(writer, advertisement.Motd, MaxMotdBytes); - writer.Flush(); - return stream.ToArray(); - } - } - - public static bool TryDeserialize(byte[] data, out BasisLanAdvertisement advertisement) - { - advertisement = default; - if (data == null || data.Length < 31 || data.Length > MaxPacketBytes) - { - return false; - } - - try - { - using (MemoryStream stream = new MemoryStream(data, false)) - using (BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true)) - { - if (reader.ReadUInt32() != Magic || reader.ReadUInt16() != Version) - { - return false; - } - - byte[] guidBytes = reader.ReadBytes(16); - if (guidBytes.Length != 16) - { - return false; - } - - ushort serverPort = reader.ReadUInt16(); - byte flags = reader.ReadByte(); - if (serverPort == 0 - || (flags & ~1) != 0 - || !TryReadString(reader, stream, MaxStackIdBytes, out string stackId) - || !TryReadString(reader, stream, MaxServerNameBytes, out string serverName) - || !TryReadString(reader, stream, MaxMotdBytes, out string motd)) - { - return false; - } - - advertisement = new BasisLanAdvertisement( - new Guid(guidBytes), - serverPort, - (flags & 1) != 0, - stackId, - serverName, - motd); - return advertisement.InstanceId != Guid.Empty; - } - } - catch (Exception) - { - return false; - } - } - public static string LimitUtf8(string value, int maxBytes) { if (string.IsNullOrEmpty(value) || maxBytes <= 0) @@ -137,36 +63,62 @@ public static string LimitUtf8(string value, int maxBytes) return length == 0 ? string.Empty : value.Substring(0, length); } - private static void WriteString(BinaryWriter writer, string value, int maxBytes) + internal static string LimitTxtProperty(string key, string value, int maxValueBytes) + { + int txtBudget = Math.Max(0, 254 - Encoding.UTF8.GetByteCount(key ?? string.Empty)); + return LimitUtf8(value, Math.Min(maxValueBytes, txtBudget)); + } + + internal static bool IsAscii(string value) + { + if (string.IsNullOrEmpty(value)) + { + return true; + } + for (int i = 0; i < value.Length; i++) + { + if (value[i] > 0x7F) + { + return false; + } + } + return true; + } + + internal static string EncodeTxtUtf8(string value, int maxBytes) { string limited = LimitUtf8(value, maxBytes); - byte[] bytes = Encoding.UTF8.GetBytes(limited); - writer.Write((ushort)bytes.Length); - writer.Write(bytes); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); } - private static bool TryReadString(BinaryReader reader, MemoryStream stream, int maxBytes, out string value) + internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string value) { value = string.Empty; - if (stream.Length - stream.Position < sizeof(ushort)) + if (encoded == null || maxBytes < 0) { return false; } - ushort byteCount = reader.ReadUInt16(); - if (byteCount > maxBytes || stream.Length - stream.Position < byteCount) + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; + if (encoded.Length > maxEncodedLength) { return false; } - byte[] bytes = reader.ReadBytes(byteCount); - if (bytes.Length != byteCount) + try + { + byte[] bytes = Convert.FromBase64String(encoded); + if (bytes.Length > maxBytes) + { + return false; + } + value = StrictUtf8.GetString(bytes); + return true; + } + catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) { return false; } - - value = Encoding.UTF8.GetString(bytes); - return true; } } } diff --git a/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs b/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs index 40bbcfbccc..58fd294cae 100644 --- a/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs +++ b/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs @@ -1,313 +1 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; - -namespace Basis.Network.Core -{ - /// Shared dual-stack UDP/5353 transport for Basis mDNS browsing and advertising. - public sealed class BasisLanMdnsTransport : IDisposable - { - public const int Port = 5353; - public static readonly IPAddress Ipv4MulticastAddress = IPAddress.Parse("224.0.0.251"); - public static readonly IPAddress Ipv6MulticastAddress = IPAddress.Parse("ff02::fb"); - - private readonly object _sendGate = new object(); - private readonly Action _received; - private readonly List _ipv4Interfaces = new List(); - private readonly List _ipv6Interfaces = new List(); - private UdpClient _ipv4; - private UdpClient _ipv6; - private int _disposed; - - public BasisLanMdnsTransport(Action received) - { - _received = received ?? throw new ArgumentNullException(nameof(received)); - DiscoverInterfaces(); - - if (Socket.OSSupportsIPv4) - { - try { _ipv4 = CreateIpv4(); } - catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS unavailable: {ex.Message}"); } - } - if (Socket.OSSupportsIPv6) - { - try { _ipv6 = CreateIpv6(); } - catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS unavailable: {ex.Message}"); } - } - if (_ipv4 == null && _ipv6 == null) - { - throw new SocketException((int)SocketError.AddressFamilyNotSupported); - } - - if (_ipv4 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv4)); - if (_ipv6 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv6)); - } - - public void SendMulticast(byte[] packet) - { - if (packet == null || packet.Length == 0 || Volatile.Read(ref _disposed) != 0) - { - return; - } - - lock (_sendGate) - { - SendIpv4(packet); - SendIpv6(packet); - } - } - - public void SendUnicast(byte[] packet, IPEndPoint endpoint) - { - if (packet == null || packet.Length == 0 || endpoint == null || Volatile.Read(ref _disposed) != 0) - { - return; - } - - lock (_sendGate) - { - try - { - UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork ? _ipv4 : _ipv6; - client?.Send(packet, packet.Length, endpoint); - } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private UdpClient CreateIpv4() - { - UdpClient client = new UdpClient(AddressFamily.InterNetwork); - try - { - ConfigureShared(client); - client.Client.Bind(new IPEndPoint(IPAddress.Any, Port)); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.AddMembership, - new MulticastOption(Ipv4MulticastAddress, address)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(Ipv4MulticastAddress); - } - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private UdpClient CreateIpv6() - { - UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); - try - { - ConfigureShared(client); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); - client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, Port)); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (int index in _ipv6Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IPv6, - SocketOptionName.AddMembership, - new IPv6MulticastOption(Ipv6MulticastAddress, index)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(Ipv6MulticastAddress); - } - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private static void ConfigureShared(UdpClient client) - { - try { client.Client.ExclusiveAddressUse = false; } - catch (PlatformNotSupportedException) { } - client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - - private void SendIpv4(byte[] packet) - { - if (_ipv4 == null) - { - return; - } - - IPEndPoint endpoint = new IPEndPoint(Ipv4MulticastAddress, Port); - bool sent = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - _ipv4.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.MulticastInterface, - address.GetAddressBytes()); - _ipv4.Send(packet, packet.Length, endpoint); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { return; } - } - if (!sent) - { - try { _ipv4.Send(packet, packet.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private void SendIpv6(byte[] packet) - { - if (_ipv6 == null) - { - return; - } - - bool sent = false; - foreach (int index in _ipv6Interfaces) - { - try - { - _ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); - IPAddress scoped = new IPAddress(Ipv6MulticastAddress.GetAddressBytes(), index); - _ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, Port)); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { return; } - } - if (!sent) - { - try { _ipv6.Send(packet, packet.Length, new IPEndPoint(Ipv6MulticastAddress, Port)); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private async Task ReceiveLoopAsync(UdpClient client) - { - while (Volatile.Read(ref _disposed) == 0) - { - try - { - UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); - if (result.RemoteEndPoint != null && result.Buffer != null) - { - _received(result.Buffer, result.RemoteEndPoint); - } - } - catch (ObjectDisposedException) { return; } - catch (SocketException) - { - if (Volatile.Read(ref _disposed) != 0) - { - return; - } - } - catch (Exception ex) - { - if (Volatile.Read(ref _disposed) == 0) - { - BNL.LogWarning($"LAN mDNS receive failed: {ex.Message}"); - } - } - } - } - - private void DiscoverInterfaces() - { - HashSet ipv4 = new HashSet(); - HashSet ipv6 = new HashSet(); - try - { - foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) - { - if (nic.OperationalStatus != OperationalStatus.Up - || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) - { - continue; - } - - IPInterfaceProperties properties = nic.GetIPProperties(); - foreach (UnicastIPAddressInformation info in properties.UnicastAddresses) - { - if (info.Address.AddressFamily == AddressFamily.InterNetwork - && !IPAddress.IsLoopback(info.Address)) - { - ipv4.Add(info.Address); - } - } - try - { - IPv6InterfaceProperties v6 = properties.GetIPv6Properties(); - if (v6 != null && v6.Index > 0) - { - ipv6.Add(v6.Index); - } - } - catch (Exception ex) when (ex is NetworkInformationException - || ex is PlatformNotSupportedException - || ex is NotImplementedException) - { - } - } - } - catch (Exception) - { - } - - _ipv4Interfaces.AddRange(ipv4); - _ipv6Interfaces.AddRange(ipv6); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - try { _ipv4?.Close(); } - catch (ObjectDisposedException) { } - try { _ipv6?.Close(); } - catch (ObjectDisposedException) { } - _ipv4 = null; - _ipv6 = null; - } - } -} +// UDP multicast transport is provided by MeaMod.DNS. diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 4d246b7490..2c00887335 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -1,58 +1,21 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; +using System.Linq; using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace Basis.Network.Core { /// - /// Announces a running Basis server on the local network through the Basis LAN - /// datagram protocol and standard mDNS/DNS-SD. This class has no Unity dependency, - /// so both dedicated servers and in-client hosts use the same implementation. + /// Announces a running Basis server as a DNS-SD service through MeaMod.DNS. + /// This class has no Unity dependency, so dedicated and in-client hosts share it. /// public sealed class BasisLanServerAnnouncer : IDisposable { - public const int DiscoveryPort = BasisLanDiscoveryProtocol.DiscoveryPort; - public const int MdnsPort = BasisLanMdnsTransport.Port; - - private const int InitialAdvertisementDelayMs = 750; - private const int AdvertisementIntervalMs = 1500; - private const string MdnsServiceType = "_basisvr._udp.local"; - private const string MdnsMetaServiceType = "_services._dns-sd._udp.local"; - private const uint MdnsTtlSeconds = 120; - private const ushort DnsA = 1; - private const ushort DnsPtr = 12; - private const ushort DnsTxt = 16; - private const ushort DnsAaaa = 28; - private const ushort DnsSrv = 33; - private const ushort DnsAny = 255; - private const ushort DnsIn = 1; - private const ushort DnsFlushIn = 0x8001; - private readonly object _gate = new object(); - private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private readonly List _advertisedAddresses = new List(); - private readonly byte[] _customPayload; - private readonly byte[] _mdnsAnnouncement; - private readonly byte[] _mdnsMetaResponse; - private readonly byte[] _mdnsCombinedResponse; - private readonly byte[] _mdnsGoodbye; - private readonly string _instanceName; - private readonly string _hostName; - private readonly ushort _serverPort; - private readonly List _txtValues; - - private UdpClient _customSender; - private BasisLanMdnsTransport _mdnsTransport; - private long _lastMdnsResponseTicks; - private int _lastMdnsResponseKey; - private string _lastMdnsResponseRemote; + private ServiceDiscovery _discovery; + private ServiceProfile _profile; private bool _disposed; public Guid InstanceId { get; } @@ -70,140 +33,122 @@ public BasisLanServerAnnouncer( } InstanceId = Guid.NewGuid(); - _serverPort = serverPort; string id = InstanceId.ToString("N"); string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) ? BasisNetworkStackRegistry.DefaultId : networkStackId; - string effectiveServerName = string.IsNullOrWhiteSpace(serverName) ? "Basis Server" : serverName; - string effectiveMotd = motd ?? string.Empty; + string effectiveServerName = string.IsNullOrWhiteSpace(serverName) + ? "Basis Server" + : serverName; - _instanceName = $"Basis-{id}.{MdnsServiceType}"; - _hostName = $"basis-{id}.local"; - _txtValues = new List - { - TxtValue("protocol", "1"), - TxtValue("id", id), - TxtValue("stack", effectiveStackId), - TxtValue("name", effectiveServerName), - TxtValue("motd", effectiveMotd), - TxtValue("pwd", requiresPassword ? "1" : "0"), - }; - - DiscoverAdvertisedAddresses(); - _customPayload = BasisLanDiscoveryProtocol.Serialize(new BasisLanAdvertisement( - InstanceId, - serverPort, - requiresPassword, - effectiveStackId, - effectiveServerName, - effectiveMotd)); - _mdnsAnnouncement = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: false, includeService: true); - _mdnsMetaResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: false); - _mdnsCombinedResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: true); - _mdnsGoodbye = BuildMdnsPacket(0, includeMeta: false, includeService: true); - - TryCreateSockets(); - if (_customSender == null && _mdnsTransport == null) + ServiceDiscovery discovery = null; + try { - _cancellation.Dispose(); - throw new SocketException((int)SocketError.AddressFamilyNotSupported); + discovery = new ServiceDiscovery(); + discovery.Mdns.IgnoreDuplicateMessages = true; + + IPAddress[] addresses = GetAdvertisedAddresses(); + ServiceProfile profile = new ServiceProfile( + new DomainName($"Basis-{id}"), + new DomainName(BasisLanDiscoveryProtocol.ServiceName), + serverPort, + addresses); + profile.AddProperty("protocol", BasisLanDiscoveryProtocol.ProtocolVersion); + profile.AddProperty("id", id); + AddMetadata( + profile, + "stack", + "stack64", + effectiveStackId, + BasisLanDiscoveryProtocol.MaxStackIdBytes); + AddMetadata( + profile, + "name", + "name64", + effectiveServerName, + BasisLanDiscoveryProtocol.MaxServerNameBytes); + AddMetadata( + profile, + "motd", + "motd64", + motd ?? string.Empty, + BasisLanDiscoveryProtocol.MaxMotdBytes); + profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + + discovery.Advertise(profile); + _profile = profile; + _discovery = discovery; + } + catch + { + discovery?.Dispose(); + throw; + } + } + + private static void AddMetadata( + ServiceProfile profile, + string legacyKey, + string encodedKey, + string value, + int maxBytes) + { + string limited = BasisLanDiscoveryProtocol.LimitUtf8(value, maxBytes); + if (BasisLanDiscoveryProtocol.IsAscii(limited)) + { + profile.AddProperty( + legacyKey, + BasisLanDiscoveryProtocol.LimitTxtProperty(legacyKey, limited, maxBytes)); + return; } - if (_customSender != null) - { - _ = Task.Run(() => CustomAdvertisementLoopAsync(_cancellation.Token)); - } - if (_mdnsTransport != null) + string encoded = BasisLanDiscoveryProtocol.EncodeTxtUtf8(limited, maxBytes); + int offset = 0; + for (int index = 0; offset < encoded.Length; index++) { - _ = Task.Run(() => InitialMdnsAnnouncementsAsync(_cancellation.Token)); - } - } - - private void TryCreateSockets() - { - try - { - _customSender = new UdpClient(AddressFamily.InterNetwork) + if (index > 9) { - EnableBroadcast = true, - MulticastLoopback = true, - }; - _customSender.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); - } - catch (Exception ex) - { - _customSender?.Dispose(); - _customSender = null; - BNL.LogWarning($"Basis LAN datagram announcements are unavailable: {ex.Message}"); - } + throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); + } - try - { - _mdnsTransport = new BasisLanMdnsTransport(ProcessMdnsQuery); - } - catch (Exception ex) - { - _mdnsTransport?.Dispose(); - _mdnsTransport = null; - BNL.LogWarning($"Basis mDNS announcements are unavailable: {ex.Message}"); + string chunkKey = $"{encodedKey}-{index}"; + int chunkBudget = Math.Max(1, 254 - Encoding.ASCII.GetByteCount(chunkKey)); + int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); + profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); + offset += chunkLength; } } - private async Task CustomAdvertisementLoopAsync(CancellationToken cancellationToken) + private static IPAddress[] GetAdvertisedAddresses() { try { - await Task.Delay(InitialAdvertisementDelayMs, cancellationToken).ConfigureAwait(false); - while (!cancellationToken.IsCancellationRequested) - { - SendCustomAdvertisement(); - await Task.Delay(AdvertisementIntervalMs, cancellationToken).ConfigureAwait(false); - } + return MulticastService.GetLinkLocalAddresses() + .Where(IsUsableAddress) + .Distinct() + .ToArray(); } - catch (OperationCanceledException) { } - catch (ObjectDisposedException) { } catch (Exception ex) { - if (!cancellationToken.IsCancellationRequested) - { - BNL.LogWarning($"Basis LAN announcements stopped: {ex.Message}"); - } + BNL.LogWarning($"Basis LAN address discovery failed: {ex.Message}"); + return Array.Empty(); } } - private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationToken) + private static bool IsUsableAddress(IPAddress address) { - try - { - await Task.Delay(20, cancellationToken).ConfigureAwait(false); - _mdnsTransport.SendMulticast(_mdnsAnnouncement); - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - _mdnsTransport.SendMulticast(_mdnsAnnouncement); - } - catch (OperationCanceledException) { } - catch (ObjectDisposedException) { } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - { - BNL.LogWarning($"Basis mDNS startup announcement failed: {ex.Message}"); - } - } + return address != null + && !IPAddress.IsLoopback(address) + && !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.IPv6Any) + && !address.Equals(IPAddress.Broadcast) + && !address.IsIPv6Multicast; } - private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) + public void Dispose() { - if (!TryMatchMdnsQuery( - packet, - out bool wantsUnicast, - out bool includeMeta, - out bool includeService, - out ushort queryId)) - { - return; - } - + ServiceDiscovery discovery; + ServiceProfile profile; lock (_gate) { if (_disposed) @@ -211,436 +156,32 @@ private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) return; } - int responseKey = (includeMeta ? 1 : 0) | (includeService ? 2 : 0); - string responseRemote = remoteEndPoint?.ToString() ?? string.Empty; - long now = Stopwatch.GetTimestamp(); - if (_lastMdnsResponseTicks != 0 - && _lastMdnsResponseKey == responseKey - && string.Equals(_lastMdnsResponseRemote, responseRemote, StringComparison.Ordinal) - && now - _lastMdnsResponseTicks < Stopwatch.Frequency / 50) - { - return; - } - _lastMdnsResponseTicks = now; - _lastMdnsResponseKey = responseKey; - _lastMdnsResponseRemote = responseRemote; - - byte[] response = includeMeta - ? (includeService ? _mdnsCombinedResponse : _mdnsMetaResponse) - : _mdnsAnnouncement; - bool legacyUnicast = remoteEndPoint != null && remoteEndPoint.Port != MdnsPort; - if (legacyUnicast) - { - response = WithDnsId(response, queryId); - } - - if (wantsUnicast || legacyUnicast) - { - _mdnsTransport.SendUnicast(response, remoteEndPoint); - } - else - { - _mdnsTransport.SendMulticast(response); - } - } - } - - private void SendCustomAdvertisement() - { - UdpClient sender; - lock (_gate) - { - if (_disposed) return; - sender = _customSender; + _disposed = true; + discovery = _discovery; + profile = _profile; + _discovery = null; + _profile = null; } - if (sender == null) return; - - HashSet sent = new HashSet(StringComparer.Ordinal); - SendCustomTo(sender, new IPEndPoint(BasisLanDiscoveryProtocol.MulticastAddress, DiscoveryPort), sent); - SendCustomTo(sender, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort), sent); - try + if (discovery == null) { - foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) - { - if (networkInterface.OperationalStatus != OperationalStatus.Up - || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) - { - continue; - } - - foreach (UnicastIPAddressInformation addressInformation in networkInterface.GetIPProperties().UnicastAddresses) - { - if (addressInformation.Address.AddressFamily != AddressFamily.InterNetwork - || addressInformation.IPv4Mask == null) - { - continue; - } - - byte[] addressBytes = addressInformation.Address.GetAddressBytes(); - byte[] maskBytes = addressInformation.IPv4Mask.GetAddressBytes(); - if (addressBytes.Length != 4 || maskBytes.Length != 4) continue; - - byte[] broadcastBytes = new byte[4]; - for (int i = 0; i < broadcastBytes.Length; i++) - { - broadcastBytes[i] = (byte)(addressBytes[i] | ~maskBytes[i]); - } - SendCustomTo(sender, new IPEndPoint(new IPAddress(broadcastBytes), DiscoveryPort), sent); - } - } - } - catch (Exception) - { - // Multicast and limited broadcast were already attempted. + return; } - } - - private void SendCustomTo(UdpClient sender, IPEndPoint endpoint, HashSet sent) - { - if (!sent.Add(endpoint.ToString())) return; - try { sender.Send(_customPayload, _customPayload.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - private void DiscoverAdvertisedAddresses() - { - HashSet advertised = new HashSet(); try { - foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + if (profile != null) { - if (networkInterface.OperationalStatus != OperationalStatus.Up - || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) - { - continue; - } - - foreach (UnicastIPAddressInformation information in networkInterface.GetIPProperties().UnicastAddresses) - { - IPAddress address = information.Address; - if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) - { - continue; - } - if (address.AddressFamily == AddressFamily.InterNetwork || address.IsIPv6LinkLocal) - { - advertised.Add(address); - } - } + discovery.Unadvertise(profile); } } - catch (Exception) - { - } - - _advertisedAddresses.AddRange(advertised); - } - - private bool TryMatchMdnsQuery( - byte[] packet, - out bool wantsUnicast, - out bool includeMeta, - out bool includeService, - out ushort queryId) - { - wantsUnicast = false; - includeMeta = false; - includeService = false; - queryId = 0; - if (packet == null || packet.Length < 12 || packet.Length > 9000) - { - return false; - } - - try - { - int offset = 0; - queryId = ReadUInt16(packet, ref offset); - ushort flags = ReadUInt16(packet, ref offset); - ushort questionCount = ReadUInt16(packet, ref offset); - ReadUInt16(packet, ref offset); - ReadUInt16(packet, ref offset); - ReadUInt16(packet, ref offset); - if ((flags & 0x8000) != 0 || (flags & 0x780F) != 0 || questionCount > 64) - { - return false; - } - - bool matched = false; - for (int i = 0; i < questionCount; i++) - { - if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 4) - { - return false; - } - ushort type = ReadUInt16(packet, ref offset); - ushort recordClass = ReadUInt16(packet, ref offset); - if ((recordClass & 0x7FFF) != DnsIn) continue; - - bool metaRequested = EqualName(name, MdnsMetaServiceType) - && (type == DnsPtr || type == DnsAny); - bool serviceRequested = (EqualName(name, MdnsServiceType) && (type == DnsPtr || type == DnsAny)) - || (EqualName(name, _instanceName) && (type == DnsSrv || type == DnsTxt || type == DnsAny)) - || (EqualName(name, _hostName) && (type == DnsA || type == DnsAaaa || type == DnsAny)); - if (!metaRequested && !serviceRequested) continue; - - matched = true; - includeMeta |= metaRequested; - includeService |= serviceRequested; - wantsUnicast |= (recordClass & 0x8000) != 0; - } - return matched; - } - catch (Exception ex) when (ex is IOException || ex is ArgumentException || ex is DecoderFallbackException) - { - return false; - } - } - - private byte[] BuildMdnsPacket(uint ttl, bool includeMeta, bool includeService) - { - List answers = new List(); - List additional = new List(); - if (includeMeta) - { - answers.Add(new DnsRecord(MdnsMetaServiceType, DnsPtr, DnsIn, ttl, EncodeName(MdnsServiceType))); - } - if (includeService) - { - answers.Add(new DnsRecord(MdnsServiceType, DnsPtr, DnsIn, ttl, EncodeName(_instanceName))); - additional.Add(new DnsRecord(_instanceName, DnsSrv, DnsFlushIn, ttl, EncodeSrv(_serverPort, _hostName))); - additional.Add(new DnsRecord(_instanceName, DnsTxt, DnsFlushIn, ttl, EncodeTxt(_txtValues))); - foreach (IPAddress address in _advertisedAddresses) - { - if (address.AddressFamily == AddressFamily.InterNetwork) - { - additional.Add(new DnsRecord(_hostName, DnsA, DnsFlushIn, ttl, address.GetAddressBytes())); - } - else if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - additional.Add(new DnsRecord(_hostName, DnsAaaa, DnsFlushIn, ttl, address.GetAddressBytes())); - } - } - } - - using MemoryStream stream = new MemoryStream(512); - WriteUInt16(stream, 0); - WriteUInt16(stream, 0x8400); - WriteUInt16(stream, 0); - WriteUInt16(stream, checked((ushort)answers.Count)); - WriteUInt16(stream, 0); - WriteUInt16(stream, checked((ushort)additional.Count)); - foreach (DnsRecord record in answers) WriteDnsRecord(stream, record); - foreach (DnsRecord record in additional) WriteDnsRecord(stream, record); - if (stream.Length > 9000) throw new InvalidOperationException("Basis mDNS announcement is too large."); - return stream.ToArray(); - } - - private static byte[] WithDnsId(byte[] packet, ushort id) - { - byte[] response = (byte[])packet.Clone(); - response[0] = (byte)(id >> 8); - response[1] = (byte)id; - return response; - } - - private static string TxtValue(string key, string value) - { - int budget = 254 - Encoding.UTF8.GetByteCount(key); - return key + "=" + BasisLanDiscoveryProtocol.LimitUtf8(value, Math.Max(0, budget)); - } - - private readonly struct DnsRecord - { - public readonly string Name; - public readonly ushort Type; - public readonly ushort Class; - public readonly uint Ttl; - public readonly byte[] Data; - - public DnsRecord(string name, ushort type, ushort recordClass, uint ttl, byte[] data) - { - Name = name; - Type = type; - Class = recordClass; - Ttl = ttl; - Data = data; - } - } - - private static void WriteDnsRecord(Stream stream, DnsRecord record) - { - WriteBytes(stream, EncodeName(record.Name)); - WriteUInt16(stream, record.Type); - WriteUInt16(stream, record.Class); - WriteUInt32(stream, record.Ttl); - WriteUInt16(stream, checked((ushort)record.Data.Length)); - WriteBytes(stream, record.Data); - } - - private static byte[] EncodeSrv(ushort port, string target) - { - using MemoryStream stream = new MemoryStream(64); - WriteUInt16(stream, 0); - WriteUInt16(stream, 0); - WriteUInt16(stream, port); - WriteBytes(stream, EncodeName(target)); - return stream.ToArray(); - } - - private static byte[] EncodeTxt(List values) - { - using MemoryStream stream = new MemoryStream(256); - foreach (string value in values) - { - byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); - if (bytes.Length > 255) throw new InvalidOperationException("Basis mDNS TXT value is too large."); - stream.WriteByte((byte)bytes.Length); - WriteBytes(stream, bytes); - } - return stream.ToArray(); - } - - private static byte[] EncodeName(string value) - { - string normalized = NormalizeName(value); - using MemoryStream stream = new MemoryStream(128); - if (normalized.Length != 0) - { - foreach (string label in normalized.Split('.')) - { - byte[] bytes = Encoding.UTF8.GetBytes(label); - if (bytes.Length == 0 || bytes.Length > 63) - { - throw new InvalidOperationException("Invalid mDNS label."); - } - stream.WriteByte((byte)bytes.Length); - WriteBytes(stream, bytes); - } - } - stream.WriteByte(0); - if (stream.Length > 255) throw new InvalidOperationException("Invalid mDNS name."); - return stream.ToArray(); - } - - private static bool ReadName(byte[] packet, ref int offset, out string value) - { - value = string.Empty; - if (offset < 0 || offset >= packet.Length) return false; - - UTF8Encoding strictUtf8 = new UTF8Encoding(false, true); - StringBuilder result = new StringBuilder(64); - HashSet pointers = null; - int position = offset; - int resume = -1; - int encodedLength = 1; - while (true) + catch (Exception ex) { - if (position >= packet.Length) return false; - byte length = packet[position++]; - if (length == 0) - { - offset = resume >= 0 ? resume : position; - value = result.ToString(); - return true; - } - if ((length & 0xC0) == 0xC0) - { - if (position >= packet.Length) return false; - int pointer = ((length & 0x3F) << 8) | packet[position++]; - if (pointer >= packet.Length) return false; - resume = resume >= 0 ? resume : position; - pointers ??= new HashSet(); - if (!pointers.Add(pointer) || pointers.Count > 32) return false; - position = pointer; - continue; - } - if ((length & 0xC0) != 0 || length > 63 || position + length > packet.Length) - { - return false; - } - encodedLength += length + 1; - if (encodedLength > 255) return false; - if (result.Length != 0) result.Append('.'); - result.Append(strictUtf8.GetString(packet, position, length)); - position += length; + BNL.LogWarning($"Basis LAN DNS-SD goodbye failed: {ex.Message}"); } - } - - private static ushort ReadUInt16(byte[] packet, ref int offset) - { - if (packet.Length - offset < 2) throw new IOException(); - ushort value = (ushort)((packet[offset] << 8) | packet[offset + 1]); - offset += 2; - return value; - } - - private static void WriteUInt16(Stream stream, ushort value) - { - stream.WriteByte((byte)(value >> 8)); - stream.WriteByte((byte)value); - } - - private static void WriteUInt32(Stream stream, uint value) - { - stream.WriteByte((byte)(value >> 24)); - stream.WriteByte((byte)(value >> 16)); - stream.WriteByte((byte)(value >> 8)); - stream.WriteByte((byte)value); - } - - private static void WriteBytes(Stream stream, byte[] bytes) - { - stream.Write(bytes, 0, bytes.Length); - } - - private static bool EqualName(string left, string right) - { - return string.Equals(NormalizeName(left), NormalizeName(right), StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizeName(string value) - { - return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.'); - } - - private static bool IsUsableAddress(IPAddress address) - { - return address != null - && !address.Equals(IPAddress.Any) - && !address.Equals(IPAddress.IPv6Any) - && !address.Equals(IPAddress.Broadcast) - && !address.IsIPv6Multicast; - } - - public void Dispose() - { - lock (_gate) + finally { - if (_disposed) return; - _disposed = true; - try { _cancellation.Cancel(); } - catch (ObjectDisposedException) { } - - try - { - _mdnsTransport?.SendMulticast(_mdnsGoodbye); - _mdnsTransport?.SendMulticast(_mdnsGoodbye); - } - catch (Exception ex) - { - BNL.LogWarning($"Basis mDNS goodbye failed: {ex.Message}"); - } - - try { _customSender?.Close(); } - catch (ObjectDisposedException) { } - _mdnsTransport?.Dispose(); - - _customSender = null; - _mdnsTransport = null; - _cancellation.Dispose(); + discovery.Dispose(); } } } diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs new file mode 100644 index 0000000000..8b7c52a058 --- /dev/null +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -0,0 +1,450 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Basis.Network.Core +{ + /// + /// Browses Basis DNS-SD services through MeaMod.DNS and translates them into the + /// framework-independent advertisement model used by the server directory. + /// + public sealed class BasisLanServerBrowser : IDisposable + { + private const int QueryIntervalMs = 2000; + private const int MaxRecords = 256; + private const int MaxTxtProperties = 32; + private static readonly DomainName ServiceName = new DomainName(BasisLanDiscoveryProtocol.ServiceName); + + private readonly object _gate = new object(); + private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); + private readonly Action _found; + private readonly Action _removed; + private ServiceDiscovery _discovery; + private volatile bool _disposed; + + public BasisLanServerBrowser( + Action found, + Action removed) + { + _found = found ?? throw new ArgumentNullException(nameof(found)); + _removed = removed ?? throw new ArgumentNullException(nameof(removed)); + + try + { + _discovery = new ServiceDiscovery(); + _discovery.Mdns.IgnoreDuplicateMessages = true; + _discovery.ServiceInstanceDiscovered += OnServiceDiscovered; + _discovery.ServiceInstanceShutdown += OnServiceShutdown; + Query(); + _ = Task.Run(() => QueryLoopAsync(_cancellation.Token)); + } + catch + { + _discovery?.Dispose(); + _discovery = null; + _cancellation.Dispose(); + throw; + } + } + + public void Query() + { + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + discovery = _discovery; + } + + try + { + discovery?.QueryServiceInstances(ServiceName); + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + if (!_disposed) + { + BNL.LogWarning($"Basis LAN DNS-SD query failed: {ex.Message}"); + } + } + } + + private async Task QueryLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(QueryIntervalMs, cancellationToken).ConfigureAwait(false); + Query(); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis LAN DNS-SD browsing stopped: {ex.Message}"); + } + } + } + + private void OnServiceDiscovered(object sender, ServiceInstanceDiscoveryEventArgs args) + { + if (_disposed || args == null) + { + return; + } + + if (TryExtractAdvertisement( + args.Message, + args.ServiceInstanceName, + args.RemoteEndPoint?.Address, + out BasisLanAdvertisement advertisement, + out IPAddress address)) + { + _found(advertisement, address); + } + } + + private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs args) + { + if (_disposed || args == null) + { + return; + } + + if (TryReadInstanceId(args.Message, args.ServiceInstanceName, out Guid instanceId)) + { + _removed(instanceId); + } + } + + internal static bool TryExtractAdvertisement( + Message message, + DomainName serviceInstanceName, + IPAddress remoteAddress, + out BasisLanAdvertisement advertisement, + out IPAddress address) + { + advertisement = default; + address = null; + if (message == null || serviceInstanceName == null) + { + return false; + } + + List records = CollectRecords(message); + SRVRecord service = null; + TXTRecord text = null; + foreach (ResourceRecord record in records) + { + if (!NamesEqual(record?.Name, serviceInstanceName)) + { + continue; + } + + if (service == null && record is SRVRecord srv) + { + service = srv; + } + else if (text == null && record is TXTRecord txt) + { + text = txt; + } + } + + if (service == null || service.Port == 0 || service.Target == null || text?.Strings == null) + { + return false; + } + + Dictionary properties = ReadProperties(text.Strings); + if (!properties.TryGetValue("protocol", out string protocol) + || !string.Equals(protocol, BasisLanDiscoveryProtocol.ProtocolVersion, StringComparison.Ordinal) + || !properties.TryGetValue("id", out string idText) + || !Guid.TryParseExact(idText, "N", out Guid instanceId) + || instanceId == Guid.Empty + || !properties.TryGetValue("pwd", out string password) + || (password != "0" && password != "1")) + { + return false; + } + + address = SelectAddress(records, service.Target, remoteAddress); + if (address == null) + { + return false; + } + + string stackId = ReadMetadata( + properties, + "stack", + "stack64", + BasisLanDiscoveryProtocol.MaxStackIdBytes); + string serverName = ReadMetadata( + properties, + "name", + "name64", + BasisLanDiscoveryProtocol.MaxServerNameBytes); + string motd = ReadMetadata( + properties, + "motd", + "motd64", + BasisLanDiscoveryProtocol.MaxMotdBytes); + advertisement = new BasisLanAdvertisement( + instanceId, + service.Port, + password == "1", + stackId, + serverName, + motd); + return true; + } + + private static bool TryReadInstanceId( + Message message, + DomainName serviceInstanceName, + out Guid instanceId) + { + instanceId = Guid.Empty; + if (message != null) + { + foreach (ResourceRecord record in CollectRecords(message)) + { + if (!(record is TXTRecord text) + || !NamesEqual(record.Name, serviceInstanceName) + || text.Strings == null) + { + continue; + } + + Dictionary properties = ReadProperties(text.Strings); + if (properties.TryGetValue("id", out string idText) + && Guid.TryParseExact(idText, "N", out instanceId) + && instanceId != Guid.Empty) + { + return true; + } + } + } + + string instance = serviceInstanceName?.ToString() ?? string.Empty; + int separator = instance.IndexOf('.'); + if (separator >= 0) + { + instance = instance.Substring(0, separator); + } + return instance.StartsWith("Basis-", StringComparison.OrdinalIgnoreCase) + && Guid.TryParseExact(instance.Substring(6), "N", out instanceId) + && instanceId != Guid.Empty; + } + + private static List CollectRecords(Message message) + { + List records = new List(); + AddRecords(records, message.Answers); + AddRecords(records, message.AuthorityRecords); + AddRecords(records, message.AdditionalRecords); + return records; + } + + private static void AddRecords(List target, IList source) + { + if (source == null) + { + return; + } + + for (int i = 0; i < source.Count && target.Count < MaxRecords; i++) + { + if (source[i] != null) + { + target.Add(source[i]); + } + } + } + + private static Dictionary ReadProperties(IList values) + { + Dictionary properties = + new Dictionary(StringComparer.OrdinalIgnoreCase); + if (values == null) + { + return properties; + } + + for (int i = 0; i < values.Count && properties.Count < MaxTxtProperties; i++) + { + string value = values[i]; + int separator = value?.IndexOf('=') ?? -1; + if (separator <= 0) + { + continue; + } + + string key = value.Substring(0, separator); + if (!properties.ContainsKey(key)) + { + properties.Add(key, value.Substring(separator + 1)); + } + } + return properties; + } + + private static string ReadMetadata( + Dictionary properties, + string legacyKey, + string encodedKey, + int maxBytes) + { + if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) + { + return decoded; + } + + properties.TryGetValue(legacyKey, out string legacy); + return BasisLanDiscoveryProtocol.LimitUtf8(legacy, maxBytes); + } + + private static bool TryReadEncodedMetadata( + Dictionary properties, + string encodedKey, + int maxBytes, + out string value) + { + value = string.Empty; + if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) + { + return false; + } + + StringBuilder encoded = new StringBuilder(firstChunk); + for (int index = 1; index <= 9; index++) + { + if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) + { + break; + } + encoded.Append(chunk); + } + + return BasisLanDiscoveryProtocol.TryDecodeTxtUtf8( + encoded.ToString(), + maxBytes, + out value); + } + + private static IPAddress SelectAddress( + List records, + DomainName hostName, + IPAddress remoteAddress) + { + if (IsUsableAddress(remoteAddress)) + { + return remoteAddress; + } + + IPAddress selected = null; + foreach (ResourceRecord record in records) + { + if (!(record is AddressRecord addressRecord) + || !NamesEqual(record.Name, hostName) + || !IsUsableAddress(addressRecord.Address)) + { + continue; + } + + IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); + if (selected == null || AddressPreferenceRank(candidate) < AddressPreferenceRank(selected)) + { + selected = candidate; + } + } + return selected; + } + + private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress) + { + if (address != null + && address.AddressFamily == AddressFamily.InterNetworkV6 + && address.IsIPv6LinkLocal + && address.ScopeId == 0 + && remoteAddress != null + && remoteAddress.AddressFamily == AddressFamily.InterNetworkV6 + && remoteAddress.ScopeId != 0) + { + return new IPAddress(address.GetAddressBytes(), remoteAddress.ScopeId); + } + return address; + } + + private static int AddressPreferenceRank(IPAddress address) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254 ? 1 : 0; + } + return address.IsIPv6LinkLocal ? 3 : 2; + } + + private static bool NamesEqual(DomainName left, DomainName right) + { + return left != null && right != null && left.Equals(right); + } + + private static bool IsUsableAddress(IPAddress address) + { + return address != null + && !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.IPv6Any) + && !address.Equals(IPAddress.Broadcast) + && !address.IsIPv6Multicast; + } + + public void Dispose() + { + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + + discovery = _discovery; + _discovery = null; + if (discovery != null) + { + discovery.ServiceInstanceDiscovered -= OnServiceDiscovered; + discovery.ServiceInstanceShutdown -= OnServiceShutdown; + } + } + + discovery?.Dispose(); + _cancellation.Dispose(); + } + } +} diff --git a/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj b/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj index 28d9835d20..37a5fdf42b 100644 --- a/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj +++ b/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj @@ -15,6 +15,7 @@ + diff --git a/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs b/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs index f4c5d8a070..b6e31646b6 100644 --- a/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs +++ b/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs @@ -25,7 +25,7 @@ public class Configuration public int PeerLimit = ushort.MaxValue; public ushort SetPort = 4296; - /// When true, advertise this server to Basis clients on the local network through UDP broadcast/multicast and mDNS. Disabled by default. + /// When true, advertise this server to Basis clients through mDNS/DNS-SD. Disabled by default. public bool AnnounceToLan = false; /// Display name returned by the unconnected server-info query — what shows up as the row title in a client server-list UI. public string ServerName = "Basis Server"; diff --git a/Basis Server/BasisNetworkServer/NetworkServer.cs b/Basis Server/BasisNetworkServer/NetworkServer.cs index 8f8d381889..0908ccaa35 100644 --- a/Basis Server/BasisNetworkServer/NetworkServer.cs +++ b/Basis Server/BasisNetworkServer/NetworkServer.cs @@ -104,7 +104,7 @@ public static void StartServer(Configuration configuration) configuration.ServerName, configuration.ServerMotd, configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); - BNL.Log($"LAN announcements enabled on UDP {configuration.SetPort}."); + BNL.Log($"LAN DNS-SD announcements enabled for server port {configuration.SetPort}."); } catch (Exception ex) { diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs index 98fa0d57ee..bc4ac16928 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs @@ -42,8 +42,7 @@ private sealed class DiscoveredServer private readonly object _gate = new object(); private readonly Dictionary _servers = new Dictionary(); private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private UdpClient _listener; - private BasisLanMdnsBrowser _mdnsBrowser; + private BasisLanServerBrowser _browser; #if UNITY_ANDROID && !UNITY_EDITOR private AndroidJavaObject _androidMulticastLock; #endif @@ -119,6 +118,7 @@ public Task> ListAsync(CancellationToken can public Task RefreshAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + _browser?.Query(); if (PruneExpired()) { QueueSourceChanged(); @@ -129,48 +129,14 @@ public Task RefreshAsync(CancellationToken cancellationToken) private void StartListening() { AcquireAndroidMulticastLock(); - bool discoveryStarted = false; - UdpClient listener = null; try { - listener = new UdpClient(AddressFamily.InterNetwork); - listener.Client.ExclusiveAddressUse = false; - listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - listener.Client.Bind(new IPEndPoint(IPAddress.Any, BasisLanDiscoveryProtocol.DiscoveryPort)); - try { listener.JoinMulticastGroup(BasisLanDiscoveryProtocol.MulticastAddress); } - catch (Exception ex) when (ex is SocketException || ex is PlatformNotSupportedException || ex is NotImplementedException) { } - - _listener = listener; - UdpClient activeListener = listener; - listener = null; - discoveryStarted = true; - _ = Task.Run(() => ReceiveLoopAsync(activeListener, _cancellation.Token)); - } - catch (Exception ex) - { - BasisDebug.LogWarning($"LAN server discovery could not listen on UDP {BasisLanDiscoveryProtocol.DiscoveryPort}: {ex.Message}"); - } - finally - { - listener?.Dispose(); - } - - try - { - _mdnsBrowser = new BasisLanMdnsBrowser(ProcessAdvertisement, RemoveAdvertisement); - discoveryStarted = true; - } - catch (Exception ex) - { - BasisDebug.LogWarning($"LAN mDNS discovery could not start: {ex.Message}"); - } - - if (discoveryStarted) - { + _browser = new BasisLanServerBrowser(ProcessAdvertisement, RemoveAdvertisement); _ = Task.Run(() => CleanupLoopAsync(_cancellation.Token)); } - else + catch (Exception ex) { + BasisDebug.LogWarning($"LAN DNS-SD discovery could not start: {ex.Message}"); ReleaseAndroidMulticastLock(); } } @@ -224,40 +190,6 @@ private void ReleaseAndroidMulticastLock() #endif } - private async Task ReceiveLoopAsync(UdpClient listener, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - UdpReceiveResult result = await listener.ReceiveAsync().ConfigureAwait(false); - if (result.RemoteEndPoint == null - || result.RemoteEndPoint.Address == null - || !BasisLanDiscoveryProtocol.TryDeserialize(result.Buffer, out BasisLanAdvertisement advertisement)) - { - continue; - } - - ProcessAdvertisement(advertisement, result.RemoteEndPoint.Address); - } - catch (ObjectDisposedException) - { - return; - } - catch (SocketException) when (cancellationToken.IsCancellationRequested) - { - return; - } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - { - BasisDebug.LogWarning($"LAN server discovery receive failed: {ex.Message}"); - } - } - } - } - private void ProcessAdvertisement(BasisLanAdvertisement advertisement, IPAddress address) { if (_disposed || address == null) @@ -502,17 +434,9 @@ public void Dispose() try { _cancellation.Cancel(); } catch (ObjectDisposedException) { } - try { _listener?.DropMulticastGroup(BasisLanDiscoveryProtocol.MulticastAddress); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - catch (PlatformNotSupportedException) { } - catch (NotImplementedException) { } - try { _listener?.Close(); } - catch (ObjectDisposedException) { } - _listener = null; - try { _mdnsBrowser?.Dispose(); } - catch (Exception ex) { BasisDebug.LogWarning($"LAN mDNS discovery shutdown failed: {ex.Message}"); } - _mdnsBrowser = null; + try { _browser?.Dispose(); } + catch (Exception ex) { BasisDebug.LogWarning($"LAN DNS-SD discovery shutdown failed: {ex.Message}"); } + _browser = null; ReleaseAndroidMulticastLock(); _cancellation.Dispose(); diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs index 9504846742..0cf3d32f02 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs @@ -1,98 +1,2 @@ -using Basis.Network.Core; -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace Basis.Scripts.Networking -{ - /// Browses Basis DNS-SD records through the shared core mDNS transport. - internal sealed class BasisLanMdnsBrowser : IDisposable - { - private readonly object _gate = new object(); - private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private readonly Action _found; - private readonly Action _removed; - private readonly BasisLanMdnsTransport _transport; - private bool _disposed; - - public BasisLanMdnsBrowser(Action found, Action removed) - { - _found = found ?? throw new ArgumentNullException(nameof(found)); - _removed = removed ?? throw new ArgumentNullException(nameof(removed)); - try - { - _transport = new BasisLanMdnsTransport(OnPacket); - } - catch - { - _cancellation.Dispose(); - throw; - } - _ = Task.Run(() => QueryLoopAsync(_cancellation.Token)); - } - - private async Task QueryLoopAsync(CancellationToken cancellationToken) - { - try - { - byte[] query = BasisLanMdnsWire.BuildQuery(); - while (!cancellationToken.IsCancellationRequested) - { - lock (_gate) - { - if (_disposed) - { - return; - } - _transport.SendMulticast(query); - } - await Task.Delay(BasisLanMdnsWire.QueryIntervalMs, cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - { - BasisDebug.LogWarning($"LAN mDNS discovery stopped: {ex.Message}"); - } - } - } - - private void OnPacket(byte[] packet, IPEndPoint remote) - { - if (!BasisLanMdnsWire.TryParse(packet, out BasisLanMdnsWire.Message message) || !message.IsResponse) - { - return; - } - - lock (_gate) - { - if (_disposed) - { - return; - } - BasisLanMdnsWire.Extract(message, remote, _found, _removed); - } - } - - public void Dispose() - { - lock (_gate) - { - if (_disposed) - { - return; - } - _disposed = true; - try { _cancellation.Cancel(); } - catch (ObjectDisposedException) { } - _transport.Dispose(); - _cancellation.Dispose(); - } - } - } -} +// LAN DNS-SD browsing is implemented by Basis.Network.Core.BasisLanServerBrowser +// through the shared MeaMod.DNS dependency. diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs index cd06033d0d..210428531f 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs @@ -1,378 +1 @@ -using Basis.Network.Core; -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Sockets; -using System.Text; - -namespace Basis.Scripts.Networking -{ - /// Minimal DNS wire support used only by Basis LAN mDNS. - internal static class BasisLanMdnsWire - { - private const string ServiceType = "_basisvr._udp.local"; - public const int QueryIntervalMs = 2000; - - private const string ProtocolVersion = "1"; - private const int MaxPacketBytes = 9000; - private const int MaxQuestions = 64; - private const int MaxRecords = 256; - - internal const ushort A = 1; - internal const ushort Ptr = 12; - internal const ushort Txt = 16; - internal const ushort Aaaa = 28; - internal const ushort Srv = 33; - internal const ushort In = 1; - - private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); - - internal sealed class Record - { - public string Name; - public ushort Type; - public uint Ttl; - public string DomainName; - public ushort Port; - public string Target; - public List TxtValues; - public IPAddress Address; - } - - internal sealed class Message - { - public bool IsResponse; - public readonly List Records = new List(); - } - - public static byte[] BuildQuery() - { - using MemoryStream stream = new MemoryStream(64); - W16(stream, 0); - W16(stream, 0); - W16(stream, 1); - W16(stream, 0); - W16(stream, 0); - W16(stream, 0); - Bytes(stream, EncodeName(ServiceType)); - W16(stream, Ptr); - W16(stream, In); - return stream.ToArray(); - } - - public static bool TryParse(byte[] packet, out Message message) - { - message = null; - if (packet == null || packet.Length < 12 || packet.Length > MaxPacketBytes) - { - return false; - } - - try - { - int offset = 0; - U16(packet, ref offset); - ushort flags = U16(packet, ref offset); - ushort questionCount = U16(packet, ref offset); - ushort answerCount = U16(packet, ref offset); - ushort authorityCount = U16(packet, ref offset); - ushort additionalCount = U16(packet, ref offset); - int recordCount = answerCount + authorityCount + additionalCount; - if (questionCount > MaxQuestions || recordCount > MaxRecords) - { - return false; - } - - if ((flags & 0x780F) != 0) - { - return false; - } - - Message parsed = new Message { IsResponse = (flags & 0x8000) != 0 }; - for (int i = 0; i < questionCount; i++) - { - if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 4) - { - return false; - } - U16(packet, ref offset); - U16(packet, ref offset); - } - for (int i = 0; i < recordCount; i++) - { - if (!ReadRecord(packet, ref offset, out Record record)) - { - return false; - } - parsed.Records.Add(record); - } - message = parsed; - return true; - } - catch (Exception ex) when (ex is ArgumentException || ex is IOException || ex is DecoderFallbackException || ex is OverflowException) - { - return false; - } - } - - public static void Extract( - Message message, - IPEndPoint remote, - Action found, - Action removed) - { - if (message == null) - { - return; - } - - foreach (Record pointer in message.Records) - { - if (pointer.Type != Ptr || !EqualName(pointer.Name, ServiceType) || string.IsNullOrWhiteSpace(pointer.DomainName)) - { - continue; - } - - if (pointer.Ttl == 0) - { - if (TryInstanceId(message, pointer.DomainName, out Guid id)) - { - removed?.Invoke(id); - } - continue; - } - - Record srv = null; - Record txt = null; - foreach (Record record in message.Records) - { - if (!EqualName(record.Name, pointer.DomainName)) - { - continue; - } - if (record.Type == Srv && srv == null) srv = record; - else if (record.Type == Txt && txt == null) txt = record; - } - if (srv == null || srv.Port == 0 || string.IsNullOrWhiteSpace(srv.Target) || txt?.TxtValues == null) - { - continue; - } - - Dictionary values = Properties(txt.TxtValues); - if (!values.TryGetValue("protocol", out string protocol) - || protocol != ProtocolVersion - || !values.TryGetValue("id", out string idText) - || !Guid.TryParseExact(idText, "N", out Guid instanceId) - || instanceId == Guid.Empty - || !values.TryGetValue("pwd", out string password) - || (password != "0" && password != "1")) - { - continue; - } - - IPAddress address = SelectAddress(message, srv.Target, remote?.Address); - if (address == null) - { - continue; - } - - values.TryGetValue("stack", out string stack); - values.TryGetValue("name", out string name); - values.TryGetValue("motd", out string motd); - found?.Invoke(new BasisLanAdvertisement( - instanceId, - srv.Port, - password == "1", - BasisLanDiscoveryProtocol.LimitUtf8(stack, BasisLanDiscoveryProtocol.MaxStackIdBytes), - BasisLanDiscoveryProtocol.LimitUtf8(name, BasisLanDiscoveryProtocol.MaxServerNameBytes), - BasisLanDiscoveryProtocol.LimitUtf8(motd, BasisLanDiscoveryProtocol.MaxMotdBytes)), address); - } - } - - private static byte[] EncodeName(string value) - { - string normalized = Normalize(value); - using MemoryStream stream = new MemoryStream(128); - if (normalized.Length != 0) - { - foreach (string label in normalized.Split('.')) - { - byte[] bytes = Encoding.UTF8.GetBytes(label); - if (bytes.Length == 0 || bytes.Length > 63) throw new InvalidOperationException("Invalid mDNS label."); - stream.WriteByte((byte)bytes.Length); Bytes(stream, bytes); - } - } - stream.WriteByte(0); - if (stream.Length > 255) throw new InvalidOperationException("Invalid mDNS name."); - return stream.ToArray(); - } - - private static bool ReadRecord(byte[] packet, ref int offset, out Record record) - { - record = null; - if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 10) return false; - ushort type = U16(packet, ref offset); - U16(packet, ref offset); - uint ttl = U32(packet, ref offset); - ushort length = U16(packet, ref offset); - int start = offset; - int end = start + length; - if (end < start || end > packet.Length) return false; - - Record parsed = new Record { Name = name, Type = type, Ttl = ttl }; - if (type == Ptr) - { - int pos = start; - if (!ReadName(packet, ref pos, out parsed.DomainName) || pos > end) return false; - } - else if (type == Srv) - { - if (length < 7) return false; - int pos = start + 4; - parsed.Port = U16(packet, ref pos); - if (!ReadName(packet, ref pos, out parsed.Target) || pos > end) return false; - } - else if (type == Txt) - { - parsed.TxtValues = new List(); - int pos = start; - while (pos < end) - { - int size = packet[pos++]; - if (pos + size > end) return false; - parsed.TxtValues.Add(StrictUtf8.GetString(packet, pos, size)); - pos += size; - } - } - else if ((type == A && length == 4) || (type == Aaaa && length == 16)) - { - byte[] bytes = new byte[length]; - Buffer.BlockCopy(packet, start, bytes, 0, length); - parsed.Address = new IPAddress(bytes); - } - offset = end; - record = parsed; - return true; - } - - private static bool ReadName(byte[] packet, ref int offset, out string value) - { - value = string.Empty; - if (offset < 0 || offset >= packet.Length) return false; - StringBuilder result = new StringBuilder(64); - HashSet pointers = null; - int pos = offset; - int resume = -1; - int encoded = 1; - while (true) - { - if (pos >= packet.Length) return false; - byte length = packet[pos++]; - if (length == 0) - { - offset = resume >= 0 ? resume : pos; - value = result.ToString(); - return true; - } - if ((length & 0xC0) == 0xC0) - { - if (pos >= packet.Length) return false; - int pointer = ((length & 0x3F) << 8) | packet[pos++]; - if (pointer >= packet.Length) return false; - resume = resume >= 0 ? resume : pos; - pointers ??= new HashSet(); - if (!pointers.Add(pointer) || pointers.Count > 32) return false; - pos = pointer; - continue; - } - if ((length & 0xC0) != 0 || length > 63 || pos + length > packet.Length) return false; - encoded += length + 1; - if (encoded > 255) return false; - if (result.Length != 0) result.Append('.'); - result.Append(StrictUtf8.GetString(packet, pos, length)); - pos += length; - } - } - - private static bool TryInstanceId(Message message, string instanceName, out Guid id) - { - id = Guid.Empty; - foreach (Record record in message.Records) - { - if (record.Type == Txt && EqualName(record.Name, instanceName) && record.TxtValues != null) - { - Dictionary values = Properties(record.TxtValues); - if (values.TryGetValue("id", out string text) && Guid.TryParseExact(text, "N", out id) && id != Guid.Empty) - return true; - } - } - string label = Normalize(instanceName); - int dot = label.IndexOf('.'); - if (dot >= 0) label = label.Substring(0, dot); - return label.StartsWith("Basis-", StringComparison.OrdinalIgnoreCase) - && Guid.TryParseExact(label.Substring(6), "N", out id) - && id != Guid.Empty; - } - - private static Dictionary Properties(List values) - { - Dictionary result = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (string value in values) - { - int separator = value?.IndexOf('=') ?? -1; - if (separator > 0 && !result.ContainsKey(value.Substring(0, separator))) - result.Add(value.Substring(0, separator), value.Substring(separator + 1)); - } - return result; - } - - private static IPAddress SelectAddress(Message message, string target, IPAddress remote) - { - IPAddress ipv6 = null; - foreach (Record record in message.Records) - { - if (!EqualName(record.Name, target) || !Usable(record.Address)) continue; - if (record.Address.AddressFamily == AddressFamily.InterNetwork) return record.Address; - if (record.Address.AddressFamily == AddressFamily.InterNetworkV6 && ipv6 == null) ipv6 = record.Address; - } - if (Usable(remote) && remote.AddressFamily == AddressFamily.InterNetwork) return remote; - if (ipv6 != null) - { - if (ipv6.IsIPv6LinkLocal - && ipv6.ScopeId == 0 - && remote != null - && remote.AddressFamily == AddressFamily.InterNetworkV6 - && remote.ScopeId != 0) - { - return new IPAddress(ipv6.GetAddressBytes(), remote.ScopeId); - } - return ipv6; - } - return Usable(remote) ? remote : null; - } - - private static bool EqualName(string left, string right) => string.Equals(Normalize(left), Normalize(right), StringComparison.OrdinalIgnoreCase); - private static string Normalize(string value) => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.'); - private static bool Usable(IPAddress address) => address != null && !address.Equals(IPAddress.Any) && !address.Equals(IPAddress.IPv6Any) && !address.Equals(IPAddress.Broadcast) && !address.IsIPv6Multicast; - - private static ushort U16(byte[] packet, ref int offset) - { - if (packet.Length - offset < 2) throw new IOException(); - ushort value = (ushort)((packet[offset] << 8) | packet[offset + 1]); - offset += 2; - return value; - } - - private static uint U32(byte[] packet, ref int offset) - { - if (packet.Length - offset < 4) throw new IOException(); - uint value = ((uint)packet[offset] << 24) | ((uint)packet[offset + 1] << 16) | ((uint)packet[offset + 2] << 8) | packet[offset + 3]; - offset += 4; - return value; - } - - private static void W16(Stream stream, ushort value) { stream.WriteByte((byte)(value >> 8)); stream.WriteByte((byte)value); } - private static void Bytes(Stream stream, byte[] bytes) => stream.Write(bytes, 0, bytes.Length); - } -} +// DNS wire parsing and serialization are provided by MeaMod.DNS in BasisNetworkCore. diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs index e1fac457a6..e1b6bca5d0 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs @@ -146,7 +146,7 @@ private static void RegisterServerConfig() t.Fields.Add(new FieldDoc("ConfigVersion", " Config schema version, managed automatically. When the server gains new settings this file is rewritten to add them (with their defaults) and this number is bumped — don't edit by hand. ")); t.Fields.Add(new FieldDoc("PeerLimit", " Maximum number of simultaneously connected peers (players). int. Default 65535. ", " ===== Networking / listener ===== ")); t.Fields.Add(new FieldDoc("SetPort", " UDP port the server binds and listens on; clients connect to this. ushort, range 1-65535. ")); - t.Fields.Add(new FieldDoc("AnnounceToLan", " Advertise this server to Basis clients on the local network using UDP broadcast/multicast and mDNS/DNS-SD. true|false. Default false. Applied on the next server start. ")); + t.Fields.Add(new FieldDoc("AnnounceToLan", " Advertise this server to Basis clients on the local network using mDNS/DNS-SD. true|false. Default false. Applied on the next server start. ")); t.Fields.Add(new FieldDoc("ServerName", " Display name shown as the row title in client server-list UIs (server-info query). string. ")); t.Fields.Add(new FieldDoc("ServerMotd", " Short message-of-the-day returned alongside the server name. string; empty = none. ")); t.Fields.Add(new FieldDoc("EnableStatistics", " Collect transport statistics (per-peer/packet counters) and run the stats worker; surfaced via the health endpoint. true|false. ")); diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 590574ffe7..e10d55a5b4 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -1,11 +1,9 @@ using System; -using System.IO; -using System.Net; using System.Text; namespace Basis.Network.Core { - /// Metadata carried by Basis LAN discovery packets and DNS-SD records. + /// Metadata published through the Basis DNS-SD service. public readonly struct BasisLanAdvertisement { public readonly Guid InstanceId; @@ -32,88 +30,16 @@ public BasisLanAdvertisement( } } - /// Shared Basis LAN datagram format used by servers and clients. + /// Shared constants and text limits for Basis LAN DNS-SD. public static class BasisLanDiscoveryProtocol { - public const int DiscoveryPort = 42960; - public const uint Magic = 0xBA515201u; - public const ushort Version = 1; - public const int MaxPacketBytes = 1024; + private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); + public const string ServiceName = "_basisvr._udp"; + public const string ProtocolVersion = "1"; public const int MaxStackIdBytes = 64; public const int MaxServerNameBytes = 128; public const int MaxMotdBytes = 384; - public static readonly IPAddress MulticastAddress = IPAddress.Parse("239.255.42.99"); - - public static byte[] Serialize(BasisLanAdvertisement advertisement) - { - using (MemoryStream stream = new MemoryStream(256)) - using (BinaryWriter writer = new BinaryWriter(stream, Encoding.UTF8, true)) - { - writer.Write(Magic); - writer.Write(Version); - writer.Write(advertisement.InstanceId.ToByteArray()); - writer.Write(advertisement.ServerPort); - writer.Write(advertisement.RequiresPassword ? (byte)1 : (byte)0); - WriteString(writer, advertisement.NetworkStackId, MaxStackIdBytes); - WriteString(writer, advertisement.ServerName, MaxServerNameBytes); - WriteString(writer, advertisement.Motd, MaxMotdBytes); - writer.Flush(); - return stream.ToArray(); - } - } - - public static bool TryDeserialize(byte[] data, out BasisLanAdvertisement advertisement) - { - advertisement = default; - if (data == null || data.Length < 31 || data.Length > MaxPacketBytes) - { - return false; - } - - try - { - using (MemoryStream stream = new MemoryStream(data, false)) - using (BinaryReader reader = new BinaryReader(stream, Encoding.UTF8, true)) - { - if (reader.ReadUInt32() != Magic || reader.ReadUInt16() != Version) - { - return false; - } - - byte[] guidBytes = reader.ReadBytes(16); - if (guidBytes.Length != 16) - { - return false; - } - - ushort serverPort = reader.ReadUInt16(); - byte flags = reader.ReadByte(); - if (serverPort == 0 - || (flags & ~1) != 0 - || !TryReadString(reader, stream, MaxStackIdBytes, out string stackId) - || !TryReadString(reader, stream, MaxServerNameBytes, out string serverName) - || !TryReadString(reader, stream, MaxMotdBytes, out string motd)) - { - return false; - } - - advertisement = new BasisLanAdvertisement( - new Guid(guidBytes), - serverPort, - (flags & 1) != 0, - stackId, - serverName, - motd); - return advertisement.InstanceId != Guid.Empty; - } - } - catch (Exception) - { - return false; - } - } - public static string LimitUtf8(string value, int maxBytes) { if (string.IsNullOrEmpty(value) || maxBytes <= 0) @@ -137,36 +63,62 @@ public static string LimitUtf8(string value, int maxBytes) return length == 0 ? string.Empty : value.Substring(0, length); } - private static void WriteString(BinaryWriter writer, string value, int maxBytes) + internal static string LimitTxtProperty(string key, string value, int maxValueBytes) + { + int txtBudget = Math.Max(0, 254 - Encoding.UTF8.GetByteCount(key ?? string.Empty)); + return LimitUtf8(value, Math.Min(maxValueBytes, txtBudget)); + } + + internal static bool IsAscii(string value) + { + if (string.IsNullOrEmpty(value)) + { + return true; + } + for (int i = 0; i < value.Length; i++) + { + if (value[i] > 0x7F) + { + return false; + } + } + return true; + } + + internal static string EncodeTxtUtf8(string value, int maxBytes) { string limited = LimitUtf8(value, maxBytes); - byte[] bytes = Encoding.UTF8.GetBytes(limited); - writer.Write((ushort)bytes.Length); - writer.Write(bytes); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); } - private static bool TryReadString(BinaryReader reader, MemoryStream stream, int maxBytes, out string value) + internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string value) { value = string.Empty; - if (stream.Length - stream.Position < sizeof(ushort)) + if (encoded == null || maxBytes < 0) { return false; } - ushort byteCount = reader.ReadUInt16(); - if (byteCount > maxBytes || stream.Length - stream.Position < byteCount) + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; + if (encoded.Length > maxEncodedLength) { return false; } - byte[] bytes = reader.ReadBytes(byteCount); - if (bytes.Length != byteCount) + try + { + byte[] bytes = Convert.FromBase64String(encoded); + if (bytes.Length > maxBytes) + { + return false; + } + value = StrictUtf8.GetString(bytes); + return true; + } + catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) { return false; } - - value = Encoding.UTF8.GetString(bytes); - return true; } } } diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs index 40bbcfbccc..58fd294cae 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs @@ -1,313 +1 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; - -namespace Basis.Network.Core -{ - /// Shared dual-stack UDP/5353 transport for Basis mDNS browsing and advertising. - public sealed class BasisLanMdnsTransport : IDisposable - { - public const int Port = 5353; - public static readonly IPAddress Ipv4MulticastAddress = IPAddress.Parse("224.0.0.251"); - public static readonly IPAddress Ipv6MulticastAddress = IPAddress.Parse("ff02::fb"); - - private readonly object _sendGate = new object(); - private readonly Action _received; - private readonly List _ipv4Interfaces = new List(); - private readonly List _ipv6Interfaces = new List(); - private UdpClient _ipv4; - private UdpClient _ipv6; - private int _disposed; - - public BasisLanMdnsTransport(Action received) - { - _received = received ?? throw new ArgumentNullException(nameof(received)); - DiscoverInterfaces(); - - if (Socket.OSSupportsIPv4) - { - try { _ipv4 = CreateIpv4(); } - catch (Exception ex) { BNL.LogWarning($"IPv4 mDNS unavailable: {ex.Message}"); } - } - if (Socket.OSSupportsIPv6) - { - try { _ipv6 = CreateIpv6(); } - catch (Exception ex) { BNL.LogWarning($"IPv6 mDNS unavailable: {ex.Message}"); } - } - if (_ipv4 == null && _ipv6 == null) - { - throw new SocketException((int)SocketError.AddressFamilyNotSupported); - } - - if (_ipv4 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv4)); - if (_ipv6 != null) _ = Task.Run(() => ReceiveLoopAsync(_ipv6)); - } - - public void SendMulticast(byte[] packet) - { - if (packet == null || packet.Length == 0 || Volatile.Read(ref _disposed) != 0) - { - return; - } - - lock (_sendGate) - { - SendIpv4(packet); - SendIpv6(packet); - } - } - - public void SendUnicast(byte[] packet, IPEndPoint endpoint) - { - if (packet == null || packet.Length == 0 || endpoint == null || Volatile.Read(ref _disposed) != 0) - { - return; - } - - lock (_sendGate) - { - try - { - UdpClient client = endpoint.AddressFamily == AddressFamily.InterNetwork ? _ipv4 : _ipv6; - client?.Send(packet, packet.Length, endpoint); - } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private UdpClient CreateIpv4() - { - UdpClient client = new UdpClient(AddressFamily.InterNetwork); - try - { - ConfigureShared(client); - client.Client.Bind(new IPEndPoint(IPAddress.Any, Port)); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.AddMembership, - new MulticastOption(Ipv4MulticastAddress, address)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(Ipv4MulticastAddress); - } - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private UdpClient CreateIpv6() - { - UdpClient client = new UdpClient(AddressFamily.InterNetworkV6); - try - { - ConfigureShared(client); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); - client.Client.Bind(new IPEndPoint(IPAddress.IPv6Any, Port)); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastTimeToLive, 255); - client.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastLoopback, true); - - bool joined = false; - foreach (int index in _ipv6Interfaces) - { - try - { - client.Client.SetSocketOption( - SocketOptionLevel.IPv6, - SocketOptionName.AddMembership, - new IPv6MulticastOption(Ipv6MulticastAddress, index)); - joined = true; - } - catch (SocketException) { } - } - if (!joined) - { - client.JoinMulticastGroup(Ipv6MulticastAddress); - } - return client; - } - catch - { - client.Dispose(); - throw; - } - } - - private static void ConfigureShared(UdpClient client) - { - try { client.Client.ExclusiveAddressUse = false; } - catch (PlatformNotSupportedException) { } - client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - - private void SendIpv4(byte[] packet) - { - if (_ipv4 == null) - { - return; - } - - IPEndPoint endpoint = new IPEndPoint(Ipv4MulticastAddress, Port); - bool sent = false; - foreach (IPAddress address in _ipv4Interfaces) - { - try - { - _ipv4.Client.SetSocketOption( - SocketOptionLevel.IP, - SocketOptionName.MulticastInterface, - address.GetAddressBytes()); - _ipv4.Send(packet, packet.Length, endpoint); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { return; } - } - if (!sent) - { - try { _ipv4.Send(packet, packet.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private void SendIpv6(byte[] packet) - { - if (_ipv6 == null) - { - return; - } - - bool sent = false; - foreach (int index in _ipv6Interfaces) - { - try - { - _ipv6.Client.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.MulticastInterface, index); - IPAddress scoped = new IPAddress(Ipv6MulticastAddress.GetAddressBytes(), index); - _ipv6.Send(packet, packet.Length, new IPEndPoint(scoped, Port)); - sent = true; - } - catch (SocketException) { } - catch (ObjectDisposedException) { return; } - } - if (!sent) - { - try { _ipv6.Send(packet, packet.Length, new IPEndPoint(Ipv6MulticastAddress, Port)); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - } - - private async Task ReceiveLoopAsync(UdpClient client) - { - while (Volatile.Read(ref _disposed) == 0) - { - try - { - UdpReceiveResult result = await client.ReceiveAsync().ConfigureAwait(false); - if (result.RemoteEndPoint != null && result.Buffer != null) - { - _received(result.Buffer, result.RemoteEndPoint); - } - } - catch (ObjectDisposedException) { return; } - catch (SocketException) - { - if (Volatile.Read(ref _disposed) != 0) - { - return; - } - } - catch (Exception ex) - { - if (Volatile.Read(ref _disposed) == 0) - { - BNL.LogWarning($"LAN mDNS receive failed: {ex.Message}"); - } - } - } - } - - private void DiscoverInterfaces() - { - HashSet ipv4 = new HashSet(); - HashSet ipv6 = new HashSet(); - try - { - foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces()) - { - if (nic.OperationalStatus != OperationalStatus.Up - || nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) - { - continue; - } - - IPInterfaceProperties properties = nic.GetIPProperties(); - foreach (UnicastIPAddressInformation info in properties.UnicastAddresses) - { - if (info.Address.AddressFamily == AddressFamily.InterNetwork - && !IPAddress.IsLoopback(info.Address)) - { - ipv4.Add(info.Address); - } - } - try - { - IPv6InterfaceProperties v6 = properties.GetIPv6Properties(); - if (v6 != null && v6.Index > 0) - { - ipv6.Add(v6.Index); - } - } - catch (Exception ex) when (ex is NetworkInformationException - || ex is PlatformNotSupportedException - || ex is NotImplementedException) - { - } - } - } - catch (Exception) - { - } - - _ipv4Interfaces.AddRange(ipv4); - _ipv6Interfaces.AddRange(ipv6); - } - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - try { _ipv4?.Close(); } - catch (ObjectDisposedException) { } - try { _ipv6?.Close(); } - catch (ObjectDisposedException) { } - _ipv4 = null; - _ipv6 = null; - } - } -} +// UDP multicast transport is provided by MeaMod.DNS. diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 4d246b7490..2c00887335 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -1,58 +1,21 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; +using System.Linq; using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace Basis.Network.Core { /// - /// Announces a running Basis server on the local network through the Basis LAN - /// datagram protocol and standard mDNS/DNS-SD. This class has no Unity dependency, - /// so both dedicated servers and in-client hosts use the same implementation. + /// Announces a running Basis server as a DNS-SD service through MeaMod.DNS. + /// This class has no Unity dependency, so dedicated and in-client hosts share it. /// public sealed class BasisLanServerAnnouncer : IDisposable { - public const int DiscoveryPort = BasisLanDiscoveryProtocol.DiscoveryPort; - public const int MdnsPort = BasisLanMdnsTransport.Port; - - private const int InitialAdvertisementDelayMs = 750; - private const int AdvertisementIntervalMs = 1500; - private const string MdnsServiceType = "_basisvr._udp.local"; - private const string MdnsMetaServiceType = "_services._dns-sd._udp.local"; - private const uint MdnsTtlSeconds = 120; - private const ushort DnsA = 1; - private const ushort DnsPtr = 12; - private const ushort DnsTxt = 16; - private const ushort DnsAaaa = 28; - private const ushort DnsSrv = 33; - private const ushort DnsAny = 255; - private const ushort DnsIn = 1; - private const ushort DnsFlushIn = 0x8001; - private readonly object _gate = new object(); - private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private readonly List _advertisedAddresses = new List(); - private readonly byte[] _customPayload; - private readonly byte[] _mdnsAnnouncement; - private readonly byte[] _mdnsMetaResponse; - private readonly byte[] _mdnsCombinedResponse; - private readonly byte[] _mdnsGoodbye; - private readonly string _instanceName; - private readonly string _hostName; - private readonly ushort _serverPort; - private readonly List _txtValues; - - private UdpClient _customSender; - private BasisLanMdnsTransport _mdnsTransport; - private long _lastMdnsResponseTicks; - private int _lastMdnsResponseKey; - private string _lastMdnsResponseRemote; + private ServiceDiscovery _discovery; + private ServiceProfile _profile; private bool _disposed; public Guid InstanceId { get; } @@ -70,140 +33,122 @@ public BasisLanServerAnnouncer( } InstanceId = Guid.NewGuid(); - _serverPort = serverPort; string id = InstanceId.ToString("N"); string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) ? BasisNetworkStackRegistry.DefaultId : networkStackId; - string effectiveServerName = string.IsNullOrWhiteSpace(serverName) ? "Basis Server" : serverName; - string effectiveMotd = motd ?? string.Empty; + string effectiveServerName = string.IsNullOrWhiteSpace(serverName) + ? "Basis Server" + : serverName; - _instanceName = $"Basis-{id}.{MdnsServiceType}"; - _hostName = $"basis-{id}.local"; - _txtValues = new List - { - TxtValue("protocol", "1"), - TxtValue("id", id), - TxtValue("stack", effectiveStackId), - TxtValue("name", effectiveServerName), - TxtValue("motd", effectiveMotd), - TxtValue("pwd", requiresPassword ? "1" : "0"), - }; - - DiscoverAdvertisedAddresses(); - _customPayload = BasisLanDiscoveryProtocol.Serialize(new BasisLanAdvertisement( - InstanceId, - serverPort, - requiresPassword, - effectiveStackId, - effectiveServerName, - effectiveMotd)); - _mdnsAnnouncement = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: false, includeService: true); - _mdnsMetaResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: false); - _mdnsCombinedResponse = BuildMdnsPacket(MdnsTtlSeconds, includeMeta: true, includeService: true); - _mdnsGoodbye = BuildMdnsPacket(0, includeMeta: false, includeService: true); - - TryCreateSockets(); - if (_customSender == null && _mdnsTransport == null) + ServiceDiscovery discovery = null; + try { - _cancellation.Dispose(); - throw new SocketException((int)SocketError.AddressFamilyNotSupported); + discovery = new ServiceDiscovery(); + discovery.Mdns.IgnoreDuplicateMessages = true; + + IPAddress[] addresses = GetAdvertisedAddresses(); + ServiceProfile profile = new ServiceProfile( + new DomainName($"Basis-{id}"), + new DomainName(BasisLanDiscoveryProtocol.ServiceName), + serverPort, + addresses); + profile.AddProperty("protocol", BasisLanDiscoveryProtocol.ProtocolVersion); + profile.AddProperty("id", id); + AddMetadata( + profile, + "stack", + "stack64", + effectiveStackId, + BasisLanDiscoveryProtocol.MaxStackIdBytes); + AddMetadata( + profile, + "name", + "name64", + effectiveServerName, + BasisLanDiscoveryProtocol.MaxServerNameBytes); + AddMetadata( + profile, + "motd", + "motd64", + motd ?? string.Empty, + BasisLanDiscoveryProtocol.MaxMotdBytes); + profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + + discovery.Advertise(profile); + _profile = profile; + _discovery = discovery; + } + catch + { + discovery?.Dispose(); + throw; + } + } + + private static void AddMetadata( + ServiceProfile profile, + string legacyKey, + string encodedKey, + string value, + int maxBytes) + { + string limited = BasisLanDiscoveryProtocol.LimitUtf8(value, maxBytes); + if (BasisLanDiscoveryProtocol.IsAscii(limited)) + { + profile.AddProperty( + legacyKey, + BasisLanDiscoveryProtocol.LimitTxtProperty(legacyKey, limited, maxBytes)); + return; } - if (_customSender != null) - { - _ = Task.Run(() => CustomAdvertisementLoopAsync(_cancellation.Token)); - } - if (_mdnsTransport != null) + string encoded = BasisLanDiscoveryProtocol.EncodeTxtUtf8(limited, maxBytes); + int offset = 0; + for (int index = 0; offset < encoded.Length; index++) { - _ = Task.Run(() => InitialMdnsAnnouncementsAsync(_cancellation.Token)); - } - } - - private void TryCreateSockets() - { - try - { - _customSender = new UdpClient(AddressFamily.InterNetwork) + if (index > 9) { - EnableBroadcast = true, - MulticastLoopback = true, - }; - _customSender.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); - } - catch (Exception ex) - { - _customSender?.Dispose(); - _customSender = null; - BNL.LogWarning($"Basis LAN datagram announcements are unavailable: {ex.Message}"); - } + throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); + } - try - { - _mdnsTransport = new BasisLanMdnsTransport(ProcessMdnsQuery); - } - catch (Exception ex) - { - _mdnsTransport?.Dispose(); - _mdnsTransport = null; - BNL.LogWarning($"Basis mDNS announcements are unavailable: {ex.Message}"); + string chunkKey = $"{encodedKey}-{index}"; + int chunkBudget = Math.Max(1, 254 - Encoding.ASCII.GetByteCount(chunkKey)); + int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); + profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); + offset += chunkLength; } } - private async Task CustomAdvertisementLoopAsync(CancellationToken cancellationToken) + private static IPAddress[] GetAdvertisedAddresses() { try { - await Task.Delay(InitialAdvertisementDelayMs, cancellationToken).ConfigureAwait(false); - while (!cancellationToken.IsCancellationRequested) - { - SendCustomAdvertisement(); - await Task.Delay(AdvertisementIntervalMs, cancellationToken).ConfigureAwait(false); - } + return MulticastService.GetLinkLocalAddresses() + .Where(IsUsableAddress) + .Distinct() + .ToArray(); } - catch (OperationCanceledException) { } - catch (ObjectDisposedException) { } catch (Exception ex) { - if (!cancellationToken.IsCancellationRequested) - { - BNL.LogWarning($"Basis LAN announcements stopped: {ex.Message}"); - } + BNL.LogWarning($"Basis LAN address discovery failed: {ex.Message}"); + return Array.Empty(); } } - private async Task InitialMdnsAnnouncementsAsync(CancellationToken cancellationToken) + private static bool IsUsableAddress(IPAddress address) { - try - { - await Task.Delay(20, cancellationToken).ConfigureAwait(false); - _mdnsTransport.SendMulticast(_mdnsAnnouncement); - await Task.Delay(1000, cancellationToken).ConfigureAwait(false); - _mdnsTransport.SendMulticast(_mdnsAnnouncement); - } - catch (OperationCanceledException) { } - catch (ObjectDisposedException) { } - catch (Exception ex) - { - if (!cancellationToken.IsCancellationRequested) - { - BNL.LogWarning($"Basis mDNS startup announcement failed: {ex.Message}"); - } - } + return address != null + && !IPAddress.IsLoopback(address) + && !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.IPv6Any) + && !address.Equals(IPAddress.Broadcast) + && !address.IsIPv6Multicast; } - private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) + public void Dispose() { - if (!TryMatchMdnsQuery( - packet, - out bool wantsUnicast, - out bool includeMeta, - out bool includeService, - out ushort queryId)) - { - return; - } - + ServiceDiscovery discovery; + ServiceProfile profile; lock (_gate) { if (_disposed) @@ -211,436 +156,32 @@ private void ProcessMdnsQuery(byte[] packet, IPEndPoint remoteEndPoint) return; } - int responseKey = (includeMeta ? 1 : 0) | (includeService ? 2 : 0); - string responseRemote = remoteEndPoint?.ToString() ?? string.Empty; - long now = Stopwatch.GetTimestamp(); - if (_lastMdnsResponseTicks != 0 - && _lastMdnsResponseKey == responseKey - && string.Equals(_lastMdnsResponseRemote, responseRemote, StringComparison.Ordinal) - && now - _lastMdnsResponseTicks < Stopwatch.Frequency / 50) - { - return; - } - _lastMdnsResponseTicks = now; - _lastMdnsResponseKey = responseKey; - _lastMdnsResponseRemote = responseRemote; - - byte[] response = includeMeta - ? (includeService ? _mdnsCombinedResponse : _mdnsMetaResponse) - : _mdnsAnnouncement; - bool legacyUnicast = remoteEndPoint != null && remoteEndPoint.Port != MdnsPort; - if (legacyUnicast) - { - response = WithDnsId(response, queryId); - } - - if (wantsUnicast || legacyUnicast) - { - _mdnsTransport.SendUnicast(response, remoteEndPoint); - } - else - { - _mdnsTransport.SendMulticast(response); - } - } - } - - private void SendCustomAdvertisement() - { - UdpClient sender; - lock (_gate) - { - if (_disposed) return; - sender = _customSender; + _disposed = true; + discovery = _discovery; + profile = _profile; + _discovery = null; + _profile = null; } - if (sender == null) return; - - HashSet sent = new HashSet(StringComparer.Ordinal); - SendCustomTo(sender, new IPEndPoint(BasisLanDiscoveryProtocol.MulticastAddress, DiscoveryPort), sent); - SendCustomTo(sender, new IPEndPoint(IPAddress.Broadcast, DiscoveryPort), sent); - try + if (discovery == null) { - foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) - { - if (networkInterface.OperationalStatus != OperationalStatus.Up - || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) - { - continue; - } - - foreach (UnicastIPAddressInformation addressInformation in networkInterface.GetIPProperties().UnicastAddresses) - { - if (addressInformation.Address.AddressFamily != AddressFamily.InterNetwork - || addressInformation.IPv4Mask == null) - { - continue; - } - - byte[] addressBytes = addressInformation.Address.GetAddressBytes(); - byte[] maskBytes = addressInformation.IPv4Mask.GetAddressBytes(); - if (addressBytes.Length != 4 || maskBytes.Length != 4) continue; - - byte[] broadcastBytes = new byte[4]; - for (int i = 0; i < broadcastBytes.Length; i++) - { - broadcastBytes[i] = (byte)(addressBytes[i] | ~maskBytes[i]); - } - SendCustomTo(sender, new IPEndPoint(new IPAddress(broadcastBytes), DiscoveryPort), sent); - } - } - } - catch (Exception) - { - // Multicast and limited broadcast were already attempted. + return; } - } - - private void SendCustomTo(UdpClient sender, IPEndPoint endpoint, HashSet sent) - { - if (!sent.Add(endpoint.ToString())) return; - try { sender.Send(_customPayload, _customPayload.Length, endpoint); } - catch (SocketException) { } - catch (ObjectDisposedException) { } - } - private void DiscoverAdvertisedAddresses() - { - HashSet advertised = new HashSet(); try { - foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + if (profile != null) { - if (networkInterface.OperationalStatus != OperationalStatus.Up - || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback) - { - continue; - } - - foreach (UnicastIPAddressInformation information in networkInterface.GetIPProperties().UnicastAddresses) - { - IPAddress address = information.Address; - if (!IsUsableAddress(address) || IPAddress.IsLoopback(address)) - { - continue; - } - if (address.AddressFamily == AddressFamily.InterNetwork || address.IsIPv6LinkLocal) - { - advertised.Add(address); - } - } + discovery.Unadvertise(profile); } } - catch (Exception) - { - } - - _advertisedAddresses.AddRange(advertised); - } - - private bool TryMatchMdnsQuery( - byte[] packet, - out bool wantsUnicast, - out bool includeMeta, - out bool includeService, - out ushort queryId) - { - wantsUnicast = false; - includeMeta = false; - includeService = false; - queryId = 0; - if (packet == null || packet.Length < 12 || packet.Length > 9000) - { - return false; - } - - try - { - int offset = 0; - queryId = ReadUInt16(packet, ref offset); - ushort flags = ReadUInt16(packet, ref offset); - ushort questionCount = ReadUInt16(packet, ref offset); - ReadUInt16(packet, ref offset); - ReadUInt16(packet, ref offset); - ReadUInt16(packet, ref offset); - if ((flags & 0x8000) != 0 || (flags & 0x780F) != 0 || questionCount > 64) - { - return false; - } - - bool matched = false; - for (int i = 0; i < questionCount; i++) - { - if (!ReadName(packet, ref offset, out string name) || packet.Length - offset < 4) - { - return false; - } - ushort type = ReadUInt16(packet, ref offset); - ushort recordClass = ReadUInt16(packet, ref offset); - if ((recordClass & 0x7FFF) != DnsIn) continue; - - bool metaRequested = EqualName(name, MdnsMetaServiceType) - && (type == DnsPtr || type == DnsAny); - bool serviceRequested = (EqualName(name, MdnsServiceType) && (type == DnsPtr || type == DnsAny)) - || (EqualName(name, _instanceName) && (type == DnsSrv || type == DnsTxt || type == DnsAny)) - || (EqualName(name, _hostName) && (type == DnsA || type == DnsAaaa || type == DnsAny)); - if (!metaRequested && !serviceRequested) continue; - - matched = true; - includeMeta |= metaRequested; - includeService |= serviceRequested; - wantsUnicast |= (recordClass & 0x8000) != 0; - } - return matched; - } - catch (Exception ex) when (ex is IOException || ex is ArgumentException || ex is DecoderFallbackException) - { - return false; - } - } - - private byte[] BuildMdnsPacket(uint ttl, bool includeMeta, bool includeService) - { - List answers = new List(); - List additional = new List(); - if (includeMeta) - { - answers.Add(new DnsRecord(MdnsMetaServiceType, DnsPtr, DnsIn, ttl, EncodeName(MdnsServiceType))); - } - if (includeService) - { - answers.Add(new DnsRecord(MdnsServiceType, DnsPtr, DnsIn, ttl, EncodeName(_instanceName))); - additional.Add(new DnsRecord(_instanceName, DnsSrv, DnsFlushIn, ttl, EncodeSrv(_serverPort, _hostName))); - additional.Add(new DnsRecord(_instanceName, DnsTxt, DnsFlushIn, ttl, EncodeTxt(_txtValues))); - foreach (IPAddress address in _advertisedAddresses) - { - if (address.AddressFamily == AddressFamily.InterNetwork) - { - additional.Add(new DnsRecord(_hostName, DnsA, DnsFlushIn, ttl, address.GetAddressBytes())); - } - else if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - additional.Add(new DnsRecord(_hostName, DnsAaaa, DnsFlushIn, ttl, address.GetAddressBytes())); - } - } - } - - using MemoryStream stream = new MemoryStream(512); - WriteUInt16(stream, 0); - WriteUInt16(stream, 0x8400); - WriteUInt16(stream, 0); - WriteUInt16(stream, checked((ushort)answers.Count)); - WriteUInt16(stream, 0); - WriteUInt16(stream, checked((ushort)additional.Count)); - foreach (DnsRecord record in answers) WriteDnsRecord(stream, record); - foreach (DnsRecord record in additional) WriteDnsRecord(stream, record); - if (stream.Length > 9000) throw new InvalidOperationException("Basis mDNS announcement is too large."); - return stream.ToArray(); - } - - private static byte[] WithDnsId(byte[] packet, ushort id) - { - byte[] response = (byte[])packet.Clone(); - response[0] = (byte)(id >> 8); - response[1] = (byte)id; - return response; - } - - private static string TxtValue(string key, string value) - { - int budget = 254 - Encoding.UTF8.GetByteCount(key); - return key + "=" + BasisLanDiscoveryProtocol.LimitUtf8(value, Math.Max(0, budget)); - } - - private readonly struct DnsRecord - { - public readonly string Name; - public readonly ushort Type; - public readonly ushort Class; - public readonly uint Ttl; - public readonly byte[] Data; - - public DnsRecord(string name, ushort type, ushort recordClass, uint ttl, byte[] data) - { - Name = name; - Type = type; - Class = recordClass; - Ttl = ttl; - Data = data; - } - } - - private static void WriteDnsRecord(Stream stream, DnsRecord record) - { - WriteBytes(stream, EncodeName(record.Name)); - WriteUInt16(stream, record.Type); - WriteUInt16(stream, record.Class); - WriteUInt32(stream, record.Ttl); - WriteUInt16(stream, checked((ushort)record.Data.Length)); - WriteBytes(stream, record.Data); - } - - private static byte[] EncodeSrv(ushort port, string target) - { - using MemoryStream stream = new MemoryStream(64); - WriteUInt16(stream, 0); - WriteUInt16(stream, 0); - WriteUInt16(stream, port); - WriteBytes(stream, EncodeName(target)); - return stream.ToArray(); - } - - private static byte[] EncodeTxt(List values) - { - using MemoryStream stream = new MemoryStream(256); - foreach (string value in values) - { - byte[] bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); - if (bytes.Length > 255) throw new InvalidOperationException("Basis mDNS TXT value is too large."); - stream.WriteByte((byte)bytes.Length); - WriteBytes(stream, bytes); - } - return stream.ToArray(); - } - - private static byte[] EncodeName(string value) - { - string normalized = NormalizeName(value); - using MemoryStream stream = new MemoryStream(128); - if (normalized.Length != 0) - { - foreach (string label in normalized.Split('.')) - { - byte[] bytes = Encoding.UTF8.GetBytes(label); - if (bytes.Length == 0 || bytes.Length > 63) - { - throw new InvalidOperationException("Invalid mDNS label."); - } - stream.WriteByte((byte)bytes.Length); - WriteBytes(stream, bytes); - } - } - stream.WriteByte(0); - if (stream.Length > 255) throw new InvalidOperationException("Invalid mDNS name."); - return stream.ToArray(); - } - - private static bool ReadName(byte[] packet, ref int offset, out string value) - { - value = string.Empty; - if (offset < 0 || offset >= packet.Length) return false; - - UTF8Encoding strictUtf8 = new UTF8Encoding(false, true); - StringBuilder result = new StringBuilder(64); - HashSet pointers = null; - int position = offset; - int resume = -1; - int encodedLength = 1; - while (true) + catch (Exception ex) { - if (position >= packet.Length) return false; - byte length = packet[position++]; - if (length == 0) - { - offset = resume >= 0 ? resume : position; - value = result.ToString(); - return true; - } - if ((length & 0xC0) == 0xC0) - { - if (position >= packet.Length) return false; - int pointer = ((length & 0x3F) << 8) | packet[position++]; - if (pointer >= packet.Length) return false; - resume = resume >= 0 ? resume : position; - pointers ??= new HashSet(); - if (!pointers.Add(pointer) || pointers.Count > 32) return false; - position = pointer; - continue; - } - if ((length & 0xC0) != 0 || length > 63 || position + length > packet.Length) - { - return false; - } - encodedLength += length + 1; - if (encodedLength > 255) return false; - if (result.Length != 0) result.Append('.'); - result.Append(strictUtf8.GetString(packet, position, length)); - position += length; + BNL.LogWarning($"Basis LAN DNS-SD goodbye failed: {ex.Message}"); } - } - - private static ushort ReadUInt16(byte[] packet, ref int offset) - { - if (packet.Length - offset < 2) throw new IOException(); - ushort value = (ushort)((packet[offset] << 8) | packet[offset + 1]); - offset += 2; - return value; - } - - private static void WriteUInt16(Stream stream, ushort value) - { - stream.WriteByte((byte)(value >> 8)); - stream.WriteByte((byte)value); - } - - private static void WriteUInt32(Stream stream, uint value) - { - stream.WriteByte((byte)(value >> 24)); - stream.WriteByte((byte)(value >> 16)); - stream.WriteByte((byte)(value >> 8)); - stream.WriteByte((byte)value); - } - - private static void WriteBytes(Stream stream, byte[] bytes) - { - stream.Write(bytes, 0, bytes.Length); - } - - private static bool EqualName(string left, string right) - { - return string.Equals(NormalizeName(left), NormalizeName(right), StringComparison.OrdinalIgnoreCase); - } - - private static string NormalizeName(string value) - { - return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().TrimEnd('.'); - } - - private static bool IsUsableAddress(IPAddress address) - { - return address != null - && !address.Equals(IPAddress.Any) - && !address.Equals(IPAddress.IPv6Any) - && !address.Equals(IPAddress.Broadcast) - && !address.IsIPv6Multicast; - } - - public void Dispose() - { - lock (_gate) + finally { - if (_disposed) return; - _disposed = true; - try { _cancellation.Cancel(); } - catch (ObjectDisposedException) { } - - try - { - _mdnsTransport?.SendMulticast(_mdnsGoodbye); - _mdnsTransport?.SendMulticast(_mdnsGoodbye); - } - catch (Exception ex) - { - BNL.LogWarning($"Basis mDNS goodbye failed: {ex.Message}"); - } - - try { _customSender?.Close(); } - catch (ObjectDisposedException) { } - _mdnsTransport?.Dispose(); - - _customSender = null; - _mdnsTransport = null; - _cancellation.Dispose(); + discovery.Dispose(); } } } diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs new file mode 100644 index 0000000000..8b7c52a058 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -0,0 +1,450 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Basis.Network.Core +{ + /// + /// Browses Basis DNS-SD services through MeaMod.DNS and translates them into the + /// framework-independent advertisement model used by the server directory. + /// + public sealed class BasisLanServerBrowser : IDisposable + { + private const int QueryIntervalMs = 2000; + private const int MaxRecords = 256; + private const int MaxTxtProperties = 32; + private static readonly DomainName ServiceName = new DomainName(BasisLanDiscoveryProtocol.ServiceName); + + private readonly object _gate = new object(); + private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); + private readonly Action _found; + private readonly Action _removed; + private ServiceDiscovery _discovery; + private volatile bool _disposed; + + public BasisLanServerBrowser( + Action found, + Action removed) + { + _found = found ?? throw new ArgumentNullException(nameof(found)); + _removed = removed ?? throw new ArgumentNullException(nameof(removed)); + + try + { + _discovery = new ServiceDiscovery(); + _discovery.Mdns.IgnoreDuplicateMessages = true; + _discovery.ServiceInstanceDiscovered += OnServiceDiscovered; + _discovery.ServiceInstanceShutdown += OnServiceShutdown; + Query(); + _ = Task.Run(() => QueryLoopAsync(_cancellation.Token)); + } + catch + { + _discovery?.Dispose(); + _discovery = null; + _cancellation.Dispose(); + throw; + } + } + + public void Query() + { + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + discovery = _discovery; + } + + try + { + discovery?.QueryServiceInstances(ServiceName); + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + if (!_disposed) + { + BNL.LogWarning($"Basis LAN DNS-SD query failed: {ex.Message}"); + } + } + } + + private async Task QueryLoopAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(QueryIntervalMs, cancellationToken).ConfigureAwait(false); + Query(); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + BNL.LogWarning($"Basis LAN DNS-SD browsing stopped: {ex.Message}"); + } + } + } + + private void OnServiceDiscovered(object sender, ServiceInstanceDiscoveryEventArgs args) + { + if (_disposed || args == null) + { + return; + } + + if (TryExtractAdvertisement( + args.Message, + args.ServiceInstanceName, + args.RemoteEndPoint?.Address, + out BasisLanAdvertisement advertisement, + out IPAddress address)) + { + _found(advertisement, address); + } + } + + private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs args) + { + if (_disposed || args == null) + { + return; + } + + if (TryReadInstanceId(args.Message, args.ServiceInstanceName, out Guid instanceId)) + { + _removed(instanceId); + } + } + + internal static bool TryExtractAdvertisement( + Message message, + DomainName serviceInstanceName, + IPAddress remoteAddress, + out BasisLanAdvertisement advertisement, + out IPAddress address) + { + advertisement = default; + address = null; + if (message == null || serviceInstanceName == null) + { + return false; + } + + List records = CollectRecords(message); + SRVRecord service = null; + TXTRecord text = null; + foreach (ResourceRecord record in records) + { + if (!NamesEqual(record?.Name, serviceInstanceName)) + { + continue; + } + + if (service == null && record is SRVRecord srv) + { + service = srv; + } + else if (text == null && record is TXTRecord txt) + { + text = txt; + } + } + + if (service == null || service.Port == 0 || service.Target == null || text?.Strings == null) + { + return false; + } + + Dictionary properties = ReadProperties(text.Strings); + if (!properties.TryGetValue("protocol", out string protocol) + || !string.Equals(protocol, BasisLanDiscoveryProtocol.ProtocolVersion, StringComparison.Ordinal) + || !properties.TryGetValue("id", out string idText) + || !Guid.TryParseExact(idText, "N", out Guid instanceId) + || instanceId == Guid.Empty + || !properties.TryGetValue("pwd", out string password) + || (password != "0" && password != "1")) + { + return false; + } + + address = SelectAddress(records, service.Target, remoteAddress); + if (address == null) + { + return false; + } + + string stackId = ReadMetadata( + properties, + "stack", + "stack64", + BasisLanDiscoveryProtocol.MaxStackIdBytes); + string serverName = ReadMetadata( + properties, + "name", + "name64", + BasisLanDiscoveryProtocol.MaxServerNameBytes); + string motd = ReadMetadata( + properties, + "motd", + "motd64", + BasisLanDiscoveryProtocol.MaxMotdBytes); + advertisement = new BasisLanAdvertisement( + instanceId, + service.Port, + password == "1", + stackId, + serverName, + motd); + return true; + } + + private static bool TryReadInstanceId( + Message message, + DomainName serviceInstanceName, + out Guid instanceId) + { + instanceId = Guid.Empty; + if (message != null) + { + foreach (ResourceRecord record in CollectRecords(message)) + { + if (!(record is TXTRecord text) + || !NamesEqual(record.Name, serviceInstanceName) + || text.Strings == null) + { + continue; + } + + Dictionary properties = ReadProperties(text.Strings); + if (properties.TryGetValue("id", out string idText) + && Guid.TryParseExact(idText, "N", out instanceId) + && instanceId != Guid.Empty) + { + return true; + } + } + } + + string instance = serviceInstanceName?.ToString() ?? string.Empty; + int separator = instance.IndexOf('.'); + if (separator >= 0) + { + instance = instance.Substring(0, separator); + } + return instance.StartsWith("Basis-", StringComparison.OrdinalIgnoreCase) + && Guid.TryParseExact(instance.Substring(6), "N", out instanceId) + && instanceId != Guid.Empty; + } + + private static List CollectRecords(Message message) + { + List records = new List(); + AddRecords(records, message.Answers); + AddRecords(records, message.AuthorityRecords); + AddRecords(records, message.AdditionalRecords); + return records; + } + + private static void AddRecords(List target, IList source) + { + if (source == null) + { + return; + } + + for (int i = 0; i < source.Count && target.Count < MaxRecords; i++) + { + if (source[i] != null) + { + target.Add(source[i]); + } + } + } + + private static Dictionary ReadProperties(IList values) + { + Dictionary properties = + new Dictionary(StringComparer.OrdinalIgnoreCase); + if (values == null) + { + return properties; + } + + for (int i = 0; i < values.Count && properties.Count < MaxTxtProperties; i++) + { + string value = values[i]; + int separator = value?.IndexOf('=') ?? -1; + if (separator <= 0) + { + continue; + } + + string key = value.Substring(0, separator); + if (!properties.ContainsKey(key)) + { + properties.Add(key, value.Substring(separator + 1)); + } + } + return properties; + } + + private static string ReadMetadata( + Dictionary properties, + string legacyKey, + string encodedKey, + int maxBytes) + { + if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) + { + return decoded; + } + + properties.TryGetValue(legacyKey, out string legacy); + return BasisLanDiscoveryProtocol.LimitUtf8(legacy, maxBytes); + } + + private static bool TryReadEncodedMetadata( + Dictionary properties, + string encodedKey, + int maxBytes, + out string value) + { + value = string.Empty; + if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) + { + return false; + } + + StringBuilder encoded = new StringBuilder(firstChunk); + for (int index = 1; index <= 9; index++) + { + if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) + { + break; + } + encoded.Append(chunk); + } + + return BasisLanDiscoveryProtocol.TryDecodeTxtUtf8( + encoded.ToString(), + maxBytes, + out value); + } + + private static IPAddress SelectAddress( + List records, + DomainName hostName, + IPAddress remoteAddress) + { + if (IsUsableAddress(remoteAddress)) + { + return remoteAddress; + } + + IPAddress selected = null; + foreach (ResourceRecord record in records) + { + if (!(record is AddressRecord addressRecord) + || !NamesEqual(record.Name, hostName) + || !IsUsableAddress(addressRecord.Address)) + { + continue; + } + + IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); + if (selected == null || AddressPreferenceRank(candidate) < AddressPreferenceRank(selected)) + { + selected = candidate; + } + } + return selected; + } + + private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress) + { + if (address != null + && address.AddressFamily == AddressFamily.InterNetworkV6 + && address.IsIPv6LinkLocal + && address.ScopeId == 0 + && remoteAddress != null + && remoteAddress.AddressFamily == AddressFamily.InterNetworkV6 + && remoteAddress.ScopeId != 0) + { + return new IPAddress(address.GetAddressBytes(), remoteAddress.ScopeId); + } + return address; + } + + private static int AddressPreferenceRank(IPAddress address) + { + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254 ? 1 : 0; + } + return address.IsIPv6LinkLocal ? 3 : 2; + } + + private static bool NamesEqual(DomainName left, DomainName right) + { + return left != null && right != null && left.Equals(right); + } + + private static bool IsUsableAddress(IPAddress address) + { + return address != null + && !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.IPv6Any) + && !address.Equals(IPAddress.Broadcast) + && !address.IsIPv6Multicast; + } + + public void Dispose() + { + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + + discovery = _discovery; + _discovery = null; + if (discovery != null) + { + discovery.ServiceInstanceDiscovered -= OnServiceDiscovered; + discovery.ServiceInstanceShutdown -= OnServiceShutdown; + } + } + + discovery?.Dispose(); + _cancellation.Dispose(); + } + } +} diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs.meta b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs.meta new file mode 100644 index 0000000000..83d3ccd717 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c5941de9f7e84fd5a5b2da7a704e2d93 diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs index f4c5d8a070..b6e31646b6 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs @@ -25,7 +25,7 @@ public class Configuration public int PeerLimit = ushort.MaxValue; public ushort SetPort = 4296; - /// When true, advertise this server to Basis clients on the local network through UDP broadcast/multicast and mDNS. Disabled by default. + /// When true, advertise this server to Basis clients through mDNS/DNS-SD. Disabled by default. public bool AnnounceToLan = false; /// Display name returned by the unconnected server-info query — what shows up as the row title in a client server-list UI. public string ServerName = "Basis Server"; diff --git a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs index 8f8d381889..0908ccaa35 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs @@ -104,7 +104,7 @@ public static void StartServer(Configuration configuration) configuration.ServerName, configuration.ServerMotd, configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); - BNL.Log($"LAN announcements enabled on UDP {configuration.SetPort}."); + BNL.Log($"LAN DNS-SD announcements enabled for server port {configuration.SetPort}."); } catch (Exception ex) { diff --git a/Basis/Packages/com.basis.server/package.json b/Basis/Packages/com.basis.server/package.json index 7ae8e4e980..e02cf304e3 100644 --- a/Basis/Packages/com.basis.server/package.json +++ b/Basis/Packages/com.basis.server/package.json @@ -14,6 +14,7 @@ "org.basisvr.k4os.compression.lz4": "1.3.8", "org.basisvr.generator.equals": "3.2.0", "org.basisvr.base128": "1.2.2", - "org.basisvr.simplebase": "4.0.2" + "org.basisvr.simplebase": "4.0.2", + "nuget.meamod.dns": "1.0.71" } } From 11a41f2f7535229cf1216c538867c3f834a44ecb Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Tue, 14 Jul 2026 22:12:55 +0000 Subject: [PATCH 08/25] Remove retired LAN discovery code --- Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs | 4 ++-- Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs | 1 - Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs | 5 +---- Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs | 2 +- .../com.basis.framework/Networking/BasisLanMdnsDiscovery.cs | 2 -- .../Networking/BasisLanMdnsDiscovery.cs.meta | 2 -- .../com.basis.framework/Networking/BasisLanMdnsWire.cs | 1 - .../com.basis.framework/Networking/BasisLanMdnsWire.cs.meta | 2 -- .../BasisNetworkCore/BasisLanDiscoveryProtocol.cs | 4 ++-- .../BasisNetworkCore/BasisLanMdnsTransport.cs | 1 - .../BasisNetworkCore/BasisLanMdnsTransport.cs.meta | 2 -- .../BasisNetworkCore/BasisLanServerAnnouncer.cs | 5 +---- .../BasisNetworkCore/BasisLanServerBrowser.cs | 2 +- 13 files changed, 8 insertions(+), 25 deletions(-) delete mode 100644 Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs delete mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs delete mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta delete mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs delete mode 100644 Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta delete mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs delete mode 100644 Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index e10d55a5b4..c2037653d1 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -31,7 +31,7 @@ public BasisLanAdvertisement( } /// Shared constants and text limits for Basis LAN DNS-SD. - public static class BasisLanDiscoveryProtocol + internal static class BasisLanDiscoveryProtocol { private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); public const string ServiceName = "_basisvr._udp"; @@ -40,7 +40,7 @@ public static class BasisLanDiscoveryProtocol public const int MaxServerNameBytes = 128; public const int MaxMotdBytes = 384; - public static string LimitUtf8(string value, int maxBytes) + internal static string LimitUtf8(string value, int maxBytes) { if (string.IsNullOrEmpty(value) || maxBytes <= 0) { diff --git a/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs b/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs deleted file mode 100644 index 58fd294cae..0000000000 --- a/Basis Server/BasisNetworkCore/BasisLanMdnsTransport.cs +++ /dev/null @@ -1 +0,0 @@ -// UDP multicast transport is provided by MeaMod.DNS. diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 2c00887335..f2c5ee9abd 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -18,8 +18,6 @@ public sealed class BasisLanServerAnnouncer : IDisposable private ServiceProfile _profile; private bool _disposed; - public Guid InstanceId { get; } - public BasisLanServerAnnouncer( ushort serverPort, string networkStackId, @@ -32,8 +30,7 @@ public BasisLanServerAnnouncer( throw new ArgumentOutOfRangeException(nameof(serverPort)); } - InstanceId = Guid.NewGuid(); - string id = InstanceId.ToString("N"); + string id = Guid.NewGuid().ToString("N"); string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) ? BasisNetworkStackRegistry.DefaultId : networkStackId; diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index 8b7c52a058..7ec644f930 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -137,7 +137,7 @@ private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs a } } - internal static bool TryExtractAdvertisement( + private static bool TryExtractAdvertisement( Message message, DomainName serviceInstanceName, IPAddress remoteAddress, diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs deleted file mode 100644 index 0cf3d32f02..0000000000 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs +++ /dev/null @@ -1,2 +0,0 @@ -// LAN DNS-SD browsing is implemented by Basis.Network.Core.BasisLanServerBrowser -// through the shared MeaMod.DNS dependency. diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta deleted file mode 100644 index ea06d09260..0000000000 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsDiscovery.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8e816674cf9a4cf9a42f38d44c13a2e6 diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs deleted file mode 100644 index 210428531f..0000000000 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs +++ /dev/null @@ -1 +0,0 @@ -// DNS wire parsing and serialization are provided by MeaMod.DNS in BasisNetworkCore. diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta b/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta deleted file mode 100644 index c1bca45958..0000000000 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanMdnsWire.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1a4d514f63db401ab814ca9fc652835d diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index e10d55a5b4..c2037653d1 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -31,7 +31,7 @@ public BasisLanAdvertisement( } /// Shared constants and text limits for Basis LAN DNS-SD. - public static class BasisLanDiscoveryProtocol + internal static class BasisLanDiscoveryProtocol { private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); public const string ServiceName = "_basisvr._udp"; @@ -40,7 +40,7 @@ public static class BasisLanDiscoveryProtocol public const int MaxServerNameBytes = 128; public const int MaxMotdBytes = 384; - public static string LimitUtf8(string value, int maxBytes) + internal static string LimitUtf8(string value, int maxBytes) { if (string.IsNullOrEmpty(value) || maxBytes <= 0) { diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs deleted file mode 100644 index 58fd294cae..0000000000 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs +++ /dev/null @@ -1 +0,0 @@ -// UDP multicast transport is provided by MeaMod.DNS. diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta deleted file mode 100644 index 1413e4fdb5..0000000000 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanMdnsTransport.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 684df842763b48b0890a4c1e8fd9c142 diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 2c00887335..f2c5ee9abd 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -18,8 +18,6 @@ public sealed class BasisLanServerAnnouncer : IDisposable private ServiceProfile _profile; private bool _disposed; - public Guid InstanceId { get; } - public BasisLanServerAnnouncer( ushort serverPort, string networkStackId, @@ -32,8 +30,7 @@ public BasisLanServerAnnouncer( throw new ArgumentOutOfRangeException(nameof(serverPort)); } - InstanceId = Guid.NewGuid(); - string id = InstanceId.ToString("N"); + string id = Guid.NewGuid().ToString("N"); string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) ? BasisNetworkStackRegistry.DefaultId : networkStackId; diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index 8b7c52a058..7ec644f930 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -137,7 +137,7 @@ private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs a } } - internal static bool TryExtractAdvertisement( + private static bool TryExtractAdvertisement( Message message, DomainName serviceInstanceName, IPAddress remoteAddress, From 1652bdd0f14edc3b78aef8e5c5573170352d787e Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 02:41:30 +0000 Subject: [PATCH 09/25] Harden LAN discovery integration --- .../BasisLanDiscoveryProtocol.cs | 180 +++++++++++++++--- .../BasisLanServerAnnouncer.cs | 135 ++++++------- .../BasisNetworkCore/BasisLanServerBrowser.cs | 79 +------- .../BasisNetworkCore/BasisNetworkCore.csproj | 1 + .../BasisNetworkServer/NetworkServer.cs | 73 +++++-- .../BasisLanDiscoveryTests.cs | 173 +++++++++++++++++ .../Plugins/Android/AndroidManifest.xml | 1 + .../Networking/BasisConnectionService.cs | 9 +- .../Networking/BasisNetworkServerRunner.cs | 35 +--- ...otocol.cs => LanServersDirectorySource.cs} | 21 +- ...meta => LanServersDirectorySource.cs.meta} | 0 .../BasisLanDiscoveryProtocol.cs | 180 +++++++++++++++--- .../BasisLanServerAnnouncer.cs | 135 ++++++------- .../BasisNetworkCore/BasisLanServerBrowser.cs | 79 +------- .../BasisNetworkServer/NetworkServer.cs | 73 +++++-- 15 files changed, 760 insertions(+), 414 deletions(-) create mode 100644 Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs rename Basis/Packages/com.basis.framework/Networking/{BasisLanDiscoveryProtocol.cs => LanServersDirectorySource.cs} (97%) rename Basis/Packages/com.basis.framework/Networking/{BasisLanDiscoveryProtocol.cs.meta => LanServersDirectorySource.cs.meta} (100%) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index c2037653d1..b37dc48e9a 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -1,4 +1,8 @@ +using MeaMod.DNS.Multicast; using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; using System.Text; namespace Basis.Network.Core @@ -30,15 +34,64 @@ public BasisLanAdvertisement( } } - /// Shared constants and text limits for Basis LAN DNS-SD. + /// Shared address filtering and preference rules for Basis LAN discovery. + public static class BasisLanAddressUtility + { + public static bool IsUsable(IPAddress address, bool allowLoopback = false) + { + if (address == null + || address.Equals(IPAddress.Any) + || address.Equals(IPAddress.IPv6Any) + || address.Equals(IPAddress.Broadcast) + || address.IsIPv6Multicast + || (!allowLoopback && IPAddress.IsLoopback(address))) + { + return false; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes.Length == 4 && (bytes[0] < 224 || bytes[0] > 239); + } + + return address.AddressFamily == AddressFamily.InterNetworkV6; + } + + public static int PreferenceRank(IPAddress address) + { + if (address == null) + { + return int.MaxValue; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + bool linkLocal = bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254; + return linkLocal ? 2 : 0; + } + + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + return address.IsIPv6LinkLocal ? 3 : 1; + } + + return int.MaxValue; + } + } + + /// Shared constants and bounded TXT metadata encoding for Basis LAN DNS-SD. internal static class BasisLanDiscoveryProtocol { private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); - public const string ServiceName = "_basisvr._udp"; - public const string ProtocolVersion = "1"; - public const int MaxStackIdBytes = 64; - public const int MaxServerNameBytes = 128; - public const int MaxMotdBytes = 384; + + internal const string ServiceName = "_basisvr._udp"; + internal const string ProtocolVersion = "1"; + internal const int MaxStackIdBytes = 64; + internal const int MaxServerNameBytes = 128; + internal const int MaxMotdBytes = 384; + internal const int MaxEncodedChunks = 10; internal static string LimitUtf8(string value, int maxBytes) { @@ -63,42 +116,99 @@ internal static string LimitUtf8(string value, int maxBytes) return length == 0 ? string.Empty : value.Substring(0, length); } - internal static string LimitTxtProperty(string key, string value, int maxValueBytes) + internal static void AddMetadata( + ServiceProfile profile, + string legacyKey, + string encodedKey, + string value, + int maxBytes) { - int txtBudget = Math.Max(0, 254 - Encoding.UTF8.GetByteCount(key ?? string.Empty)); - return LimitUtf8(value, Math.Min(maxValueBytes, txtBudget)); - } + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrEmpty(legacyKey)) throw new ArgumentException("TXT key is required.", nameof(legacyKey)); + if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("Encoded TXT key is required.", nameof(encodedKey)); - internal static bool IsAscii(string value) - { - if (string.IsNullOrEmpty(value)) + string limited = LimitUtf8(value, maxBytes); + int legacyBudget = TxtValueBudget(legacyKey); + bool ascii = IsAscii(limited); + if (ascii) { - return true; + string legacy = LimitUtf8(limited, Math.Min(maxBytes, legacyBudget)); + profile.AddProperty(legacyKey, legacy); + if (Encoding.ASCII.GetByteCount(limited) <= legacyBudget) + { + return; + } } - for (int i = 0; i < value.Length; i++) + + if (limited.Length == 0) { - if (value[i] > 0x7F) + return; + } + + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); + int offset = 0; + for (int index = 0; offset < encoded.Length; index++) + { + if (index >= MaxEncodedChunks) { - return false; + throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); + } + + string chunkKey = $"{encodedKey}-{index}"; + int chunkBudget = TxtValueBudget(chunkKey); + if (chunkBudget <= 0) + { + throw new InvalidOperationException("Basis LAN TXT key leaves no room for a value."); } + + int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); + profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); + offset += chunkLength; } - return true; } - internal static string EncodeTxtUtf8(string value, int maxBytes) + internal static string ReadMetadata( + IReadOnlyDictionary properties, + string legacyKey, + string encodedKey, + int maxBytes) { - string limited = LimitUtf8(value, maxBytes); - return Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); + if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) + { + return decoded; + } + + string legacy = null; + if (properties != null) + { + properties.TryGetValue(legacyKey, out legacy); + } + return LimitUtf8(legacy, maxBytes); } - internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string value) + private static bool TryReadEncodedMetadata( + IReadOnlyDictionary properties, + string encodedKey, + int maxBytes, + out string value) { value = string.Empty; - if (encoded == null || maxBytes < 0) + if (properties == null + || !properties.TryGetValue(encodedKey + "-0", out string firstChunk)) { return false; } + StringBuilder encoded = new StringBuilder(firstChunk); + for (int index = 1; index < MaxEncodedChunks; index++) + { + if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) + { + break; + } + encoded.Append(chunk); + } + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; if (encoded.Length > maxEncodedLength) { @@ -107,7 +217,7 @@ internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string v try { - byte[] bytes = Convert.FromBase64String(encoded); + byte[] bytes = Convert.FromBase64String(encoded.ToString()); if (bytes.Length > maxBytes) { return false; @@ -120,5 +230,27 @@ internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string v return false; } } + + private static int TxtValueBudget(string key) + { + return Math.Max(0, 254 - Encoding.ASCII.GetByteCount(key ?? string.Empty)); + } + + private static bool IsAscii(string value) + { + if (string.IsNullOrEmpty(value)) + { + return true; + } + + for (int i = 0; i < value.Length; i++) + { + if (value[i] > 0x7F) + { + return false; + } + } + return true; + } } } diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index f2c5ee9abd..9a320109b4 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -3,7 +3,6 @@ using System; using System.Linq; using System.Net; -using System.Text; namespace Basis.Network.Core { @@ -19,58 +18,36 @@ public sealed class BasisLanServerAnnouncer : IDisposable private bool _disposed; public BasisLanServerAnnouncer( + Guid instanceId, ushort serverPort, string networkStackId, string serverName, string motd, bool requiresPassword) { + if (instanceId == Guid.Empty) + { + throw new ArgumentException("LAN server instance ID cannot be empty.", nameof(instanceId)); + } if (serverPort == 0) { throw new ArgumentOutOfRangeException(nameof(serverPort)); } - string id = Guid.NewGuid().ToString("N"); - string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) - ? BasisNetworkStackRegistry.DefaultId - : networkStackId; - string effectiveServerName = string.IsNullOrWhiteSpace(serverName) - ? "Basis Server" - : serverName; - ServiceDiscovery discovery = null; try { discovery = new ServiceDiscovery(); discovery.Mdns.IgnoreDuplicateMessages = true; - IPAddress[] addresses = GetAdvertisedAddresses(); - ServiceProfile profile = new ServiceProfile( - new DomainName($"Basis-{id}"), - new DomainName(BasisLanDiscoveryProtocol.ServiceName), + ServiceProfile profile = CreateProfile( + instanceId, serverPort, - addresses); - profile.AddProperty("protocol", BasisLanDiscoveryProtocol.ProtocolVersion); - profile.AddProperty("id", id); - AddMetadata( - profile, - "stack", - "stack64", - effectiveStackId, - BasisLanDiscoveryProtocol.MaxStackIdBytes); - AddMetadata( - profile, - "name", - "name64", - effectiveServerName, - BasisLanDiscoveryProtocol.MaxServerNameBytes); - AddMetadata( - profile, - "motd", - "motd64", - motd ?? string.Empty, - BasisLanDiscoveryProtocol.MaxMotdBytes); - profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + networkStackId, + serverName, + motd, + requiresPassword, + GetAdvertisedAddresses()); discovery.Advertise(profile); _profile = profile; @@ -83,45 +60,67 @@ public BasisLanServerAnnouncer( } } - private static void AddMetadata( - ServiceProfile profile, - string legacyKey, - string encodedKey, - string value, - int maxBytes) + internal static ServiceProfile CreateProfile( + Guid instanceId, + ushort serverPort, + string networkStackId, + string serverName, + string motd, + bool requiresPassword, + IPAddress[] addresses) { - string limited = BasisLanDiscoveryProtocol.LimitUtf8(value, maxBytes); - if (BasisLanDiscoveryProtocol.IsAscii(limited)) + if (instanceId == Guid.Empty) { - profile.AddProperty( - legacyKey, - BasisLanDiscoveryProtocol.LimitTxtProperty(legacyKey, limited, maxBytes)); - return; + throw new ArgumentException("LAN server instance ID cannot be empty.", nameof(instanceId)); } - - string encoded = BasisLanDiscoveryProtocol.EncodeTxtUtf8(limited, maxBytes); - int offset = 0; - for (int index = 0; offset < encoded.Length; index++) + if (serverPort == 0) { - if (index > 9) - { - throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); - } - - string chunkKey = $"{encodedKey}-{index}"; - int chunkBudget = Math.Max(1, 254 - Encoding.ASCII.GetByteCount(chunkKey)); - int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); - profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); - offset += chunkLength; + throw new ArgumentOutOfRangeException(nameof(serverPort)); } + + string id = instanceId.ToString("N"); + string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) + ? BasisNetworkStackRegistry.DefaultId + : networkStackId; + string effectiveServerName = string.IsNullOrWhiteSpace(serverName) + ? "Basis Server" + : serverName; + + ServiceProfile profile = new ServiceProfile( + new DomainName($"Basis-{id}"), + new DomainName(BasisLanDiscoveryProtocol.ServiceName), + serverPort, + addresses ?? Array.Empty()); + profile.AddProperty("protocol", BasisLanDiscoveryProtocol.ProtocolVersion); + profile.AddProperty("id", id); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "stack", + "stack64", + effectiveStackId, + BasisLanDiscoveryProtocol.MaxStackIdBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "name", + "name64", + effectiveServerName, + BasisLanDiscoveryProtocol.MaxServerNameBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "motd", + "motd64", + motd ?? string.Empty, + BasisLanDiscoveryProtocol.MaxMotdBytes); + profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + return profile; } private static IPAddress[] GetAdvertisedAddresses() { try { - return MulticastService.GetLinkLocalAddresses() - .Where(IsUsableAddress) + return MulticastService.GetIPAddresses() + .Where(address => BasisLanAddressUtility.IsUsable(address)) .Distinct() .ToArray(); } @@ -132,16 +131,6 @@ private static IPAddress[] GetAdvertisedAddresses() } } - private static bool IsUsableAddress(IPAddress address) - { - return address != null - && !IPAddress.IsLoopback(address) - && !address.Equals(IPAddress.Any) - && !address.Equals(IPAddress.IPv6Any) - && !address.Equals(IPAddress.Broadcast) - && !address.IsIPv6Multicast; - } - public void Dispose() { ServiceDiscovery discovery; diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index 7ec644f930..3af6dc3afe 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Net; using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -137,7 +136,7 @@ private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs a } } - private static bool TryExtractAdvertisement( + internal static bool TryExtractAdvertisement( Message message, DomainName serviceInstanceName, IPAddress remoteAddress, @@ -194,17 +193,17 @@ private static bool TryExtractAdvertisement( return false; } - string stackId = ReadMetadata( + string stackId = BasisLanDiscoveryProtocol.ReadMetadata( properties, "stack", "stack64", BasisLanDiscoveryProtocol.MaxStackIdBytes); - string serverName = ReadMetadata( + string serverName = BasisLanDiscoveryProtocol.ReadMetadata( properties, "name", "name64", BasisLanDiscoveryProtocol.MaxServerNameBytes); - string motd = ReadMetadata( + string motd = BasisLanDiscoveryProtocol.ReadMetadata( properties, "motd", "motd64", @@ -309,55 +308,12 @@ private static Dictionary ReadProperties(IList values) return properties; } - private static string ReadMetadata( - Dictionary properties, - string legacyKey, - string encodedKey, - int maxBytes) - { - if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) - { - return decoded; - } - - properties.TryGetValue(legacyKey, out string legacy); - return BasisLanDiscoveryProtocol.LimitUtf8(legacy, maxBytes); - } - - private static bool TryReadEncodedMetadata( - Dictionary properties, - string encodedKey, - int maxBytes, - out string value) - { - value = string.Empty; - if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) - { - return false; - } - - StringBuilder encoded = new StringBuilder(firstChunk); - for (int index = 1; index <= 9; index++) - { - if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) - { - break; - } - encoded.Append(chunk); - } - - return BasisLanDiscoveryProtocol.TryDecodeTxtUtf8( - encoded.ToString(), - maxBytes, - out value); - } - private static IPAddress SelectAddress( List records, DomainName hostName, IPAddress remoteAddress) { - if (IsUsableAddress(remoteAddress)) + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) { return remoteAddress; } @@ -367,13 +323,15 @@ private static IPAddress SelectAddress( { if (!(record is AddressRecord addressRecord) || !NamesEqual(record.Name, hostName) - || !IsUsableAddress(addressRecord.Address)) + || !BasisLanAddressUtility.IsUsable(addressRecord.Address, allowLoopback: true)) { continue; } IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); - if (selected == null || AddressPreferenceRank(candidate) < AddressPreferenceRank(selected)) + if (selected == null + || BasisLanAddressUtility.PreferenceRank(candidate) + < BasisLanAddressUtility.PreferenceRank(selected)) { selected = candidate; } @@ -396,30 +354,11 @@ private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress return address; } - private static int AddressPreferenceRank(IPAddress address) - { - if (address.AddressFamily == AddressFamily.InterNetwork) - { - byte[] bytes = address.GetAddressBytes(); - return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254 ? 1 : 0; - } - return address.IsIPv6LinkLocal ? 3 : 2; - } - private static bool NamesEqual(DomainName left, DomainName right) { return left != null && right != null && left.Equals(right); } - private static bool IsUsableAddress(IPAddress address) - { - return address != null - && !address.Equals(IPAddress.Any) - && !address.Equals(IPAddress.IPv6Any) - && !address.Equals(IPAddress.Broadcast) - && !address.IsIPv6Multicast; - } - public void Dispose() { ServiceDiscovery discovery; diff --git a/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj b/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj index 37a5fdf42b..c3214fc8b7 100644 --- a/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj +++ b/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj @@ -18,5 +18,6 @@ + diff --git a/Basis Server/BasisNetworkServer/NetworkServer.cs b/Basis Server/BasisNetworkServer/NetworkServer.cs index 0908ccaa35..8e8db08a54 100644 --- a/Basis Server/BasisNetworkServer/NetworkServer.cs +++ b/Basis Server/BasisNetworkServer/NetworkServer.cs @@ -24,7 +24,10 @@ public static class NetworkServer public static ConcurrentDictionary AuthenticatedPeers = new(); public static readonly object AuthenticatedPeerTag = new object(); public static Configuration Configuration; + private static readonly object _lanAnnouncementGate = new object(); private static BasisLanServerAnnouncer _lanServerAnnouncer; + private static Guid _lanServerInstanceId; + private static bool _lanServerRunning; /// /// Allow-list consulted at /// when is set to AllowList. @@ -94,24 +97,12 @@ public static void StartServer(Configuration configuration) SetupServer(configuration); SubscribeEvents(Configuration); - if (configuration.AnnounceToLan) + lock (_lanAnnouncementGate) { - try - { - _lanServerAnnouncer = new BasisLanServerAnnouncer( - configuration.SetPort, - configuration.NetworkStackId, - configuration.ServerName, - configuration.ServerMotd, - configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); - BNL.Log($"LAN DNS-SD announcements enabled for server port {configuration.SetPort}."); - } - catch (Exception ex) - { - _lanServerAnnouncer = null; - BNL.LogWarning($"LAN announcements could not start: {ex.Message}"); - } + _lanServerInstanceId = Guid.NewGuid(); + _lanServerRunning = true; } + SetLanAdvertising(configuration.AnnounceToLan); if (configuration.EnableStatistics) { @@ -123,10 +114,56 @@ public static void StartServer(Configuration configuration) BNL.Log("Server Worker Threads Booted"); } + public static void SetLanAdvertising(bool enabled) + { + BasisLanServerAnnouncer previous = null; + lock (_lanAnnouncementGate) + { + if (!enabled) + { + previous = _lanServerAnnouncer; + _lanServerAnnouncer = null; + } + else + { + if (!_lanServerRunning || Server == null || Configuration == null || _lanServerAnnouncer != null) + { + return; + } + + try + { + _lanServerAnnouncer = new BasisLanServerAnnouncer( + _lanServerInstanceId, + Configuration.SetPort, + Configuration.NetworkStackId, + Configuration.ServerName, + Configuration.ServerMotd, + Configuration.UseAuth && !string.IsNullOrEmpty(Configuration.Password)); + BNL.Log($"LAN DNS-SD announcements enabled for server port {Configuration.SetPort}."); + } + catch (Exception ex) + { + _lanServerAnnouncer = null; + BNL.LogWarning($"LAN announcements could not start: {ex.Message}"); + } + } + } + + try { previous?.Dispose(); } + catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } + } + public static void StopServer() { - BasisLanServerAnnouncer lanServerAnnouncer = _lanServerAnnouncer; - _lanServerAnnouncer = null; + BasisLanServerAnnouncer lanServerAnnouncer; + lock (_lanAnnouncementGate) + { + _lanServerRunning = false; + _lanServerInstanceId = Guid.Empty; + lanServerAnnouncer = _lanServerAnnouncer; + _lanServerAnnouncer = null; + } try { lanServerAnnouncer?.Dispose(); } catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } diff --git a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs new file mode 100644 index 0000000000..23b569374f --- /dev/null +++ b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs @@ -0,0 +1,173 @@ +using Basis.Network.Core; +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System.Net; +using System.Text; +using Xunit; + +namespace BasisServerTests; + +public sealed class BasisLanDiscoveryTests +{ + [Fact] + public void LimitUtf8_DoesNotSplitSurrogatePair() + { + string value = "abc😀def"; + + string limited = BasisLanDiscoveryProtocol.LimitUtf8(value, 6); + + Assert.Equal("abc", limited); + Assert.True(Encoding.UTF8.GetByteCount(limited) <= 6); + Assert.False(limited.Length > 0 && char.IsHighSurrogate(limited[^1])); + } + + [Fact] + public void LongAsciiMotd_UsesChunksAndRoundTripsAtFullLimit() + { + string motd = new string('M', BasisLanDiscoveryProtocol.MaxMotdBytes); + Guid id = Guid.NewGuid(); + ServiceProfile profile = CreateProfile(id, "ASCII Server", motd, requiresPassword: false); + Dictionary properties = ReadProperties(profile); + + Assert.True(properties.TryGetValue("motd", out string? legacy)); + Assert.True(Encoding.ASCII.GetByteCount(legacy) < Encoding.ASCII.GetByteCount(motd)); + Assert.Contains("motd64-0", properties.Keys); + + BasisLanAdvertisement advertisement = Extract(profile); + Assert.Equal(id, advertisement.InstanceId); + Assert.Equal(motd, advertisement.Motd); + Assert.False(advertisement.RequiresPassword); + } + + [Fact] + public void UnicodeMetadata_RoundTripsWithinUtf8Limits() + { + string rawName = string.Concat(Enumerable.Repeat("世界🌐", 20)); + string rawMotd = string.Concat(Enumerable.Repeat("こんにちは世界🌐", 50)); + string expectedName = BasisLanDiscoveryProtocol.LimitUtf8( + rawName, + BasisLanDiscoveryProtocol.MaxServerNameBytes); + string expectedMotd = BasisLanDiscoveryProtocol.LimitUtf8( + rawMotd, + BasisLanDiscoveryProtocol.MaxMotdBytes); + + ServiceProfile profile = CreateProfile(Guid.NewGuid(), rawName, rawMotd, requiresPassword: true); + BasisLanAdvertisement advertisement = Extract(profile); + + Assert.Equal(expectedName, advertisement.ServerName); + Assert.Equal(expectedMotd, advertisement.Motd); + Assert.True(advertisement.RequiresPassword); + Assert.True(Encoding.UTF8.GetByteCount(advertisement.ServerName) <= BasisLanDiscoveryProtocol.MaxServerNameBytes); + Assert.True(Encoding.UTF8.GetByteCount(advertisement.Motd) <= BasisLanDiscoveryProtocol.MaxMotdBytes); + } + + [Fact] + public void MalformedEncodedMetadata_FallsBackToLegacyValue() + { + Dictionary properties = new(StringComparer.OrdinalIgnoreCase) + { + ["name"] = "Legacy Name", + ["name64-0"] = "not valid base64!", + }; + + string value = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "name", + "name64", + BasisLanDiscoveryProtocol.MaxServerNameBytes); + + Assert.Equal("Legacy Name", value); + } + + [Fact] + public void EmptyAddressRecords_UseResponseSourceAddress() + { + Guid id = Guid.NewGuid(); + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + id, + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Address fallback", + string.Empty, + false, + Array.Empty()); + + BasisLanAdvertisement advertisement = Extract(profile); + + Assert.Equal(id, advertisement.InstanceId); + Assert.DoesNotContain(profile.Resources, resource => resource is AddressRecord); + } + + [Fact] + public void AddressPreference_PrioritizesRoutableAddressesOverLinkLocal() + { + IPAddress ipv4 = IPAddress.Parse("192.168.1.10"); + IPAddress ipv6 = IPAddress.Parse("2001:db8::10"); + IPAddress apipa = IPAddress.Parse("169.254.10.20"); + IPAddress ipv6LinkLocal = IPAddress.Parse("fe80::10"); + + Assert.True(BasisLanAddressUtility.PreferenceRank(ipv4) + < BasisLanAddressUtility.PreferenceRank(ipv6)); + Assert.True(BasisLanAddressUtility.PreferenceRank(ipv6) + < BasisLanAddressUtility.PreferenceRank(apipa)); + Assert.True(BasisLanAddressUtility.PreferenceRank(apipa) + < BasisLanAddressUtility.PreferenceRank(ipv6LinkLocal)); + Assert.False(BasisLanAddressUtility.IsUsable(IPAddress.Parse("239.255.42.99"))); + Assert.False(BasisLanAddressUtility.IsUsable(IPAddress.Loopback)); + Assert.True(BasisLanAddressUtility.IsUsable(IPAddress.Loopback, allowLoopback: true)); + } + + private static ServiceProfile CreateProfile( + Guid id, + string serverName, + string motd, + bool requiresPassword) + { + return BasisLanServerAnnouncer.CreateProfile( + id, + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + serverName, + motd, + requiresPassword, + new[] { IPAddress.Parse("192.168.1.25") }); + } + + private static BasisLanAdvertisement Extract(ServiceProfile profile) + { + Message message = new Message(); + foreach (ResourceRecord resource in profile.Resources) + { + if (resource is PTRRecord) + { + message.Answers.Add(resource); + } + else + { + message.AdditionalRecords.Add(resource); + } + } + + Assert.True(BasisLanServerBrowser.TryExtractAdvertisement( + message, + profile.FullyQualifiedName, + IPAddress.Parse("192.168.1.25"), + out BasisLanAdvertisement advertisement, + out IPAddress? address)); + Assert.Equal(IPAddress.Parse("192.168.1.25"), address); + return advertisement; + } + + private static Dictionary ReadProperties(ServiceProfile profile) + { + TXTRecord text = Assert.Single(profile.Resources.OfType()); + Dictionary properties = new(StringComparer.OrdinalIgnoreCase); + foreach (string entry in text.Strings) + { + int separator = entry.IndexOf('='); + Assert.True(separator > 0); + properties[entry[..separator]] = entry[(separator + 1)..]; + } + return properties; + } +} diff --git a/Basis/Assets/Plugins/Android/AndroidManifest.xml b/Basis/Assets/Plugins/Android/AndroidManifest.xml index ea2d399349..7d0d01318a 100644 --- a/Basis/Assets/Plugins/Android/AndroidManifest.xml +++ b/Basis/Assets/Plugins/Android/AndroidManifest.xml @@ -27,5 +27,6 @@ + diff --git a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs index 96af6ff220..89f3d0b8dc 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs @@ -26,7 +26,7 @@ public static class BasisConnectionService public const string LastConnectedServerIdFile = "LastConnectedServerId.BAS"; public static bool AutoConnectAttempted; - private static bool _connectInProgress; + private static int _connectInProgress; private static readonly object LanPasswordGate = new object(); private static ServerDirectoryEntry _pendingLanPasswordEntry; @@ -45,7 +45,7 @@ private static void ResetLanPasswordState() _pendingLanPasswordEntry = null; } LanPasswordRequired = null; - _connectInProgress = false; + Volatile.Write(ref _connectInProgress, 0); } // Stable key the loading bar uses to merge updates for the same connection @@ -151,12 +151,11 @@ internal static void NotifyConnectionSucceeded() /// public static async Task ConnectAsync(ServerDirectoryEntry entry, string userName, bool isHostMode = false) { - if (_connectInProgress) + if (Interlocked.CompareExchange(ref _connectInProgress, 1, 0) != 0) { BasisDebug.LogWarning("Connect requested while a connection attempt is already in progress; ignoring."); return; } - _connectInProgress = true; try { ReportConnectionProgress(5f, BasisLocalization.Get("menu.servers.status.initializing")); @@ -252,7 +251,7 @@ public static async Task ConnectAsync(ServerDirectoryEntry entry, string userNam } finally { - _connectInProgress = false; + Volatile.Write(ref _connectInProgress, 0); } } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs index e910b77386..7e5492e126 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs @@ -1,5 +1,4 @@ using Basis.Network; -using Basis.Network.Core; using Basis.Scripts.Networking; using System; using System.Threading; @@ -12,7 +11,6 @@ public class BasisNetworkServerRunner public Task serverTask; private readonly object lifecycleGate = new object(); private CancellationTokenSource cancellationTokenSource; - private BasisLanServerAnnouncer lanServerAnnouncer; [SerializeField] public Configuration Configuration; @@ -20,7 +18,7 @@ public class BasisNetworkServerRunner [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetLanAdvertising() { - BasisNetworkConnection.BasisNetworkServerRunner?.SetLanAdvertising(false); + NetworkServer.SetLanAdvertising(false); } public void Initialize(Configuration configuration, string LogPath, string UUIDTomarkAsAdmin) @@ -37,10 +35,10 @@ public void Initialize(Configuration configuration, string LogPath, string UUIDT { cancellationToken.ThrowIfCancellationRequested(); NetworkServer.StartServer(Configuration); - ApplyLanAdvertisingLocked(BasisNetworkManagement.HostShowToLan); } cancellationToken.ThrowIfCancellationRequested(); + NetworkServer.SetLanAdvertising(BasisNetworkManagement.HostShowToLan); PermissionIntegration.Manager.AddUserNode(UUIDTomarkAsAdmin, "*"); PermissionIntegration.Manager.AddUserToGroup(UUIDTomarkAsAdmin, "admin"); } @@ -57,28 +55,7 @@ public void Initialize(Configuration configuration, string LogPath, string UUIDT public void SetLanAdvertising(bool enabled) { - lock (lifecycleGate) - { - ApplyLanAdvertisingLocked(enabled); - } - } - - private void ApplyLanAdvertisingLocked(bool enabled) - { - BasisLanServerAnnouncer replacement = null; - if (enabled && Configuration != null) - { - replacement = new BasisLanServerAnnouncer( - Configuration.SetPort, - Configuration.NetworkStackId, - Configuration.ServerName, - Configuration.ServerMotd, - Configuration.UseAuth && !string.IsNullOrEmpty(Configuration.Password)); - } - - BasisLanServerAnnouncer previous = lanServerAnnouncer; - lanServerAnnouncer = replacement; - previous?.Dispose(); + NetworkServer.SetLanAdvertising(enabled); } public void Stop() @@ -86,8 +63,10 @@ public void Stop() lock (lifecycleGate) { cancellationTokenSource?.Cancel(); - ApplyLanAdvertisingLocked(false); - NetworkServer.StopServer(); } + + // mDNS goodbye and socket shutdown may block briefly; do not hold the runner + // lifecycle lock while the server-owned announcer is disposed. + NetworkServer.StopServer(); } } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs similarity index 97% rename from Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs rename to Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs index bc4ac16928..8088f6b4d7 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs @@ -201,6 +201,11 @@ private void ProcessAdvertisement(BasisLanAdvertisement advertisement, IPAddress long nowTicks = Stopwatch.GetTimestamp(); lock (_gate) { + if (_disposed) + { + return; + } + changed = PruneExpiredLocked(nowTicks); bool isNew = !_servers.TryGetValue(advertisement.InstanceId, out DiscoveredServer existing); changed |= isNew; @@ -268,6 +273,9 @@ private async Task CleanupLoopAsync(CancellationToken cancellationToken) catch (OperationCanceledException) { } + catch (ObjectDisposedException) + { + } } private bool PruneExpired() @@ -325,7 +333,8 @@ private static bool UpdateAddressLocked(DiscoveredServer server, IPAddress addre } else if (server.AlternateAddress == null || nowTicks - server.AlternateAddressLastSeenTicks > EntryLifetimeTicks - || AddressPreferenceRank(address) < AddressPreferenceRank(server.AlternateAddress)) + || BasisLanAddressUtility.PreferenceRank(address) + < BasisLanAddressUtility.PreferenceRank(server.AlternateAddress)) { server.AlternateAddress = address; server.AlternateAddressLastSeenTicks = nowTicks; @@ -346,16 +355,6 @@ private static bool UpdateAddressLocked(DiscoveredServer server, IPAddress addre return true; } - private static int AddressPreferenceRank(IPAddress address) - { - if (address.AddressFamily == AddressFamily.InterNetwork) - { - byte[] bytes = address.GetAddressBytes(); - return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254 ? 1 : 0; - } - return address.IsIPv6LinkLocal ? 3 : 2; - } - private void RemoveOldestServerLocked() { Guid oldestId = Guid.Empty; diff --git a/Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs.meta b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs.meta similarity index 100% rename from Basis/Packages/com.basis.framework/Networking/BasisLanDiscoveryProtocol.cs.meta rename to Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs.meta diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index c2037653d1..b37dc48e9a 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -1,4 +1,8 @@ +using MeaMod.DNS.Multicast; using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; using System.Text; namespace Basis.Network.Core @@ -30,15 +34,64 @@ public BasisLanAdvertisement( } } - /// Shared constants and text limits for Basis LAN DNS-SD. + /// Shared address filtering and preference rules for Basis LAN discovery. + public static class BasisLanAddressUtility + { + public static bool IsUsable(IPAddress address, bool allowLoopback = false) + { + if (address == null + || address.Equals(IPAddress.Any) + || address.Equals(IPAddress.IPv6Any) + || address.Equals(IPAddress.Broadcast) + || address.IsIPv6Multicast + || (!allowLoopback && IPAddress.IsLoopback(address))) + { + return false; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes.Length == 4 && (bytes[0] < 224 || bytes[0] > 239); + } + + return address.AddressFamily == AddressFamily.InterNetworkV6; + } + + public static int PreferenceRank(IPAddress address) + { + if (address == null) + { + return int.MaxValue; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + bool linkLocal = bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254; + return linkLocal ? 2 : 0; + } + + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + return address.IsIPv6LinkLocal ? 3 : 1; + } + + return int.MaxValue; + } + } + + /// Shared constants and bounded TXT metadata encoding for Basis LAN DNS-SD. internal static class BasisLanDiscoveryProtocol { private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); - public const string ServiceName = "_basisvr._udp"; - public const string ProtocolVersion = "1"; - public const int MaxStackIdBytes = 64; - public const int MaxServerNameBytes = 128; - public const int MaxMotdBytes = 384; + + internal const string ServiceName = "_basisvr._udp"; + internal const string ProtocolVersion = "1"; + internal const int MaxStackIdBytes = 64; + internal const int MaxServerNameBytes = 128; + internal const int MaxMotdBytes = 384; + internal const int MaxEncodedChunks = 10; internal static string LimitUtf8(string value, int maxBytes) { @@ -63,42 +116,99 @@ internal static string LimitUtf8(string value, int maxBytes) return length == 0 ? string.Empty : value.Substring(0, length); } - internal static string LimitTxtProperty(string key, string value, int maxValueBytes) + internal static void AddMetadata( + ServiceProfile profile, + string legacyKey, + string encodedKey, + string value, + int maxBytes) { - int txtBudget = Math.Max(0, 254 - Encoding.UTF8.GetByteCount(key ?? string.Empty)); - return LimitUtf8(value, Math.Min(maxValueBytes, txtBudget)); - } + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrEmpty(legacyKey)) throw new ArgumentException("TXT key is required.", nameof(legacyKey)); + if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("Encoded TXT key is required.", nameof(encodedKey)); - internal static bool IsAscii(string value) - { - if (string.IsNullOrEmpty(value)) + string limited = LimitUtf8(value, maxBytes); + int legacyBudget = TxtValueBudget(legacyKey); + bool ascii = IsAscii(limited); + if (ascii) { - return true; + string legacy = LimitUtf8(limited, Math.Min(maxBytes, legacyBudget)); + profile.AddProperty(legacyKey, legacy); + if (Encoding.ASCII.GetByteCount(limited) <= legacyBudget) + { + return; + } } - for (int i = 0; i < value.Length; i++) + + if (limited.Length == 0) { - if (value[i] > 0x7F) + return; + } + + string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); + int offset = 0; + for (int index = 0; offset < encoded.Length; index++) + { + if (index >= MaxEncodedChunks) { - return false; + throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); + } + + string chunkKey = $"{encodedKey}-{index}"; + int chunkBudget = TxtValueBudget(chunkKey); + if (chunkBudget <= 0) + { + throw new InvalidOperationException("Basis LAN TXT key leaves no room for a value."); } + + int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); + profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); + offset += chunkLength; } - return true; } - internal static string EncodeTxtUtf8(string value, int maxBytes) + internal static string ReadMetadata( + IReadOnlyDictionary properties, + string legacyKey, + string encodedKey, + int maxBytes) { - string limited = LimitUtf8(value, maxBytes); - return Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); + if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) + { + return decoded; + } + + string legacy = null; + if (properties != null) + { + properties.TryGetValue(legacyKey, out legacy); + } + return LimitUtf8(legacy, maxBytes); } - internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string value) + private static bool TryReadEncodedMetadata( + IReadOnlyDictionary properties, + string encodedKey, + int maxBytes, + out string value) { value = string.Empty; - if (encoded == null || maxBytes < 0) + if (properties == null + || !properties.TryGetValue(encodedKey + "-0", out string firstChunk)) { return false; } + StringBuilder encoded = new StringBuilder(firstChunk); + for (int index = 1; index < MaxEncodedChunks; index++) + { + if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) + { + break; + } + encoded.Append(chunk); + } + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; if (encoded.Length > maxEncodedLength) { @@ -107,7 +217,7 @@ internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string v try { - byte[] bytes = Convert.FromBase64String(encoded); + byte[] bytes = Convert.FromBase64String(encoded.ToString()); if (bytes.Length > maxBytes) { return false; @@ -120,5 +230,27 @@ internal static bool TryDecodeTxtUtf8(string encoded, int maxBytes, out string v return false; } } + + private static int TxtValueBudget(string key) + { + return Math.Max(0, 254 - Encoding.ASCII.GetByteCount(key ?? string.Empty)); + } + + private static bool IsAscii(string value) + { + if (string.IsNullOrEmpty(value)) + { + return true; + } + + for (int i = 0; i < value.Length; i++) + { + if (value[i] > 0x7F) + { + return false; + } + } + return true; + } } } diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index f2c5ee9abd..9a320109b4 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -3,7 +3,6 @@ using System; using System.Linq; using System.Net; -using System.Text; namespace Basis.Network.Core { @@ -19,58 +18,36 @@ public sealed class BasisLanServerAnnouncer : IDisposable private bool _disposed; public BasisLanServerAnnouncer( + Guid instanceId, ushort serverPort, string networkStackId, string serverName, string motd, bool requiresPassword) { + if (instanceId == Guid.Empty) + { + throw new ArgumentException("LAN server instance ID cannot be empty.", nameof(instanceId)); + } if (serverPort == 0) { throw new ArgumentOutOfRangeException(nameof(serverPort)); } - string id = Guid.NewGuid().ToString("N"); - string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) - ? BasisNetworkStackRegistry.DefaultId - : networkStackId; - string effectiveServerName = string.IsNullOrWhiteSpace(serverName) - ? "Basis Server" - : serverName; - ServiceDiscovery discovery = null; try { discovery = new ServiceDiscovery(); discovery.Mdns.IgnoreDuplicateMessages = true; - IPAddress[] addresses = GetAdvertisedAddresses(); - ServiceProfile profile = new ServiceProfile( - new DomainName($"Basis-{id}"), - new DomainName(BasisLanDiscoveryProtocol.ServiceName), + ServiceProfile profile = CreateProfile( + instanceId, serverPort, - addresses); - profile.AddProperty("protocol", BasisLanDiscoveryProtocol.ProtocolVersion); - profile.AddProperty("id", id); - AddMetadata( - profile, - "stack", - "stack64", - effectiveStackId, - BasisLanDiscoveryProtocol.MaxStackIdBytes); - AddMetadata( - profile, - "name", - "name64", - effectiveServerName, - BasisLanDiscoveryProtocol.MaxServerNameBytes); - AddMetadata( - profile, - "motd", - "motd64", - motd ?? string.Empty, - BasisLanDiscoveryProtocol.MaxMotdBytes); - profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + networkStackId, + serverName, + motd, + requiresPassword, + GetAdvertisedAddresses()); discovery.Advertise(profile); _profile = profile; @@ -83,45 +60,67 @@ public BasisLanServerAnnouncer( } } - private static void AddMetadata( - ServiceProfile profile, - string legacyKey, - string encodedKey, - string value, - int maxBytes) + internal static ServiceProfile CreateProfile( + Guid instanceId, + ushort serverPort, + string networkStackId, + string serverName, + string motd, + bool requiresPassword, + IPAddress[] addresses) { - string limited = BasisLanDiscoveryProtocol.LimitUtf8(value, maxBytes); - if (BasisLanDiscoveryProtocol.IsAscii(limited)) + if (instanceId == Guid.Empty) { - profile.AddProperty( - legacyKey, - BasisLanDiscoveryProtocol.LimitTxtProperty(legacyKey, limited, maxBytes)); - return; + throw new ArgumentException("LAN server instance ID cannot be empty.", nameof(instanceId)); } - - string encoded = BasisLanDiscoveryProtocol.EncodeTxtUtf8(limited, maxBytes); - int offset = 0; - for (int index = 0; offset < encoded.Length; index++) + if (serverPort == 0) { - if (index > 9) - { - throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); - } - - string chunkKey = $"{encodedKey}-{index}"; - int chunkBudget = Math.Max(1, 254 - Encoding.ASCII.GetByteCount(chunkKey)); - int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); - profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); - offset += chunkLength; + throw new ArgumentOutOfRangeException(nameof(serverPort)); } + + string id = instanceId.ToString("N"); + string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) + ? BasisNetworkStackRegistry.DefaultId + : networkStackId; + string effectiveServerName = string.IsNullOrWhiteSpace(serverName) + ? "Basis Server" + : serverName; + + ServiceProfile profile = new ServiceProfile( + new DomainName($"Basis-{id}"), + new DomainName(BasisLanDiscoveryProtocol.ServiceName), + serverPort, + addresses ?? Array.Empty()); + profile.AddProperty("protocol", BasisLanDiscoveryProtocol.ProtocolVersion); + profile.AddProperty("id", id); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "stack", + "stack64", + effectiveStackId, + BasisLanDiscoveryProtocol.MaxStackIdBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "name", + "name64", + effectiveServerName, + BasisLanDiscoveryProtocol.MaxServerNameBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "motd", + "motd64", + motd ?? string.Empty, + BasisLanDiscoveryProtocol.MaxMotdBytes); + profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + return profile; } private static IPAddress[] GetAdvertisedAddresses() { try { - return MulticastService.GetLinkLocalAddresses() - .Where(IsUsableAddress) + return MulticastService.GetIPAddresses() + .Where(address => BasisLanAddressUtility.IsUsable(address)) .Distinct() .ToArray(); } @@ -132,16 +131,6 @@ private static IPAddress[] GetAdvertisedAddresses() } } - private static bool IsUsableAddress(IPAddress address) - { - return address != null - && !IPAddress.IsLoopback(address) - && !address.Equals(IPAddress.Any) - && !address.Equals(IPAddress.IPv6Any) - && !address.Equals(IPAddress.Broadcast) - && !address.IsIPv6Multicast; - } - public void Dispose() { ServiceDiscovery discovery; diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index 7ec644f930..3af6dc3afe 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Net; using System.Net.Sockets; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -137,7 +136,7 @@ private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs a } } - private static bool TryExtractAdvertisement( + internal static bool TryExtractAdvertisement( Message message, DomainName serviceInstanceName, IPAddress remoteAddress, @@ -194,17 +193,17 @@ private static bool TryExtractAdvertisement( return false; } - string stackId = ReadMetadata( + string stackId = BasisLanDiscoveryProtocol.ReadMetadata( properties, "stack", "stack64", BasisLanDiscoveryProtocol.MaxStackIdBytes); - string serverName = ReadMetadata( + string serverName = BasisLanDiscoveryProtocol.ReadMetadata( properties, "name", "name64", BasisLanDiscoveryProtocol.MaxServerNameBytes); - string motd = ReadMetadata( + string motd = BasisLanDiscoveryProtocol.ReadMetadata( properties, "motd", "motd64", @@ -309,55 +308,12 @@ private static Dictionary ReadProperties(IList values) return properties; } - private static string ReadMetadata( - Dictionary properties, - string legacyKey, - string encodedKey, - int maxBytes) - { - if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) - { - return decoded; - } - - properties.TryGetValue(legacyKey, out string legacy); - return BasisLanDiscoveryProtocol.LimitUtf8(legacy, maxBytes); - } - - private static bool TryReadEncodedMetadata( - Dictionary properties, - string encodedKey, - int maxBytes, - out string value) - { - value = string.Empty; - if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) - { - return false; - } - - StringBuilder encoded = new StringBuilder(firstChunk); - for (int index = 1; index <= 9; index++) - { - if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) - { - break; - } - encoded.Append(chunk); - } - - return BasisLanDiscoveryProtocol.TryDecodeTxtUtf8( - encoded.ToString(), - maxBytes, - out value); - } - private static IPAddress SelectAddress( List records, DomainName hostName, IPAddress remoteAddress) { - if (IsUsableAddress(remoteAddress)) + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) { return remoteAddress; } @@ -367,13 +323,15 @@ private static IPAddress SelectAddress( { if (!(record is AddressRecord addressRecord) || !NamesEqual(record.Name, hostName) - || !IsUsableAddress(addressRecord.Address)) + || !BasisLanAddressUtility.IsUsable(addressRecord.Address, allowLoopback: true)) { continue; } IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); - if (selected == null || AddressPreferenceRank(candidate) < AddressPreferenceRank(selected)) + if (selected == null + || BasisLanAddressUtility.PreferenceRank(candidate) + < BasisLanAddressUtility.PreferenceRank(selected)) { selected = candidate; } @@ -396,30 +354,11 @@ private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress return address; } - private static int AddressPreferenceRank(IPAddress address) - { - if (address.AddressFamily == AddressFamily.InterNetwork) - { - byte[] bytes = address.GetAddressBytes(); - return bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254 ? 1 : 0; - } - return address.IsIPv6LinkLocal ? 3 : 2; - } - private static bool NamesEqual(DomainName left, DomainName right) { return left != null && right != null && left.Equals(right); } - private static bool IsUsableAddress(IPAddress address) - { - return address != null - && !address.Equals(IPAddress.Any) - && !address.Equals(IPAddress.IPv6Any) - && !address.Equals(IPAddress.Broadcast) - && !address.IsIPv6Multicast; - } - public void Dispose() { ServiceDiscovery discovery; diff --git a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs index 0908ccaa35..8e8db08a54 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs @@ -24,7 +24,10 @@ public static class NetworkServer public static ConcurrentDictionary AuthenticatedPeers = new(); public static readonly object AuthenticatedPeerTag = new object(); public static Configuration Configuration; + private static readonly object _lanAnnouncementGate = new object(); private static BasisLanServerAnnouncer _lanServerAnnouncer; + private static Guid _lanServerInstanceId; + private static bool _lanServerRunning; /// /// Allow-list consulted at /// when is set to AllowList. @@ -94,24 +97,12 @@ public static void StartServer(Configuration configuration) SetupServer(configuration); SubscribeEvents(Configuration); - if (configuration.AnnounceToLan) + lock (_lanAnnouncementGate) { - try - { - _lanServerAnnouncer = new BasisLanServerAnnouncer( - configuration.SetPort, - configuration.NetworkStackId, - configuration.ServerName, - configuration.ServerMotd, - configuration.UseAuth && !string.IsNullOrEmpty(configuration.Password)); - BNL.Log($"LAN DNS-SD announcements enabled for server port {configuration.SetPort}."); - } - catch (Exception ex) - { - _lanServerAnnouncer = null; - BNL.LogWarning($"LAN announcements could not start: {ex.Message}"); - } + _lanServerInstanceId = Guid.NewGuid(); + _lanServerRunning = true; } + SetLanAdvertising(configuration.AnnounceToLan); if (configuration.EnableStatistics) { @@ -123,10 +114,56 @@ public static void StartServer(Configuration configuration) BNL.Log("Server Worker Threads Booted"); } + public static void SetLanAdvertising(bool enabled) + { + BasisLanServerAnnouncer previous = null; + lock (_lanAnnouncementGate) + { + if (!enabled) + { + previous = _lanServerAnnouncer; + _lanServerAnnouncer = null; + } + else + { + if (!_lanServerRunning || Server == null || Configuration == null || _lanServerAnnouncer != null) + { + return; + } + + try + { + _lanServerAnnouncer = new BasisLanServerAnnouncer( + _lanServerInstanceId, + Configuration.SetPort, + Configuration.NetworkStackId, + Configuration.ServerName, + Configuration.ServerMotd, + Configuration.UseAuth && !string.IsNullOrEmpty(Configuration.Password)); + BNL.Log($"LAN DNS-SD announcements enabled for server port {Configuration.SetPort}."); + } + catch (Exception ex) + { + _lanServerAnnouncer = null; + BNL.LogWarning($"LAN announcements could not start: {ex.Message}"); + } + } + } + + try { previous?.Dispose(); } + catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } + } + public static void StopServer() { - BasisLanServerAnnouncer lanServerAnnouncer = _lanServerAnnouncer; - _lanServerAnnouncer = null; + BasisLanServerAnnouncer lanServerAnnouncer; + lock (_lanAnnouncementGate) + { + _lanServerRunning = false; + _lanServerInstanceId = Guid.Empty; + lanServerAnnouncer = _lanServerAnnouncer; + _lanServerAnnouncer = null; + } try { lanServerAnnouncer?.Dispose(); } catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } From f573aaea6a96a494f1db3121d529bf3f4374b8c9 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 04:13:48 +0000 Subject: [PATCH 10/25] Trim redundant LAN discovery code --- .../BasisLanDiscoveryProtocol.cs | 18 ++++-------------- .../BasisLanServerAnnouncer.cs | 9 --------- .../BasisNetworkServer/NetworkServer.cs | 11 ++--------- .../Networking/BasisNetworkServerRunner.cs | 7 +------ .../Runtime/ServersProvider.cs | 2 +- .../BasisLanDiscoveryProtocol.cs | 18 ++++-------------- .../BasisLanServerAnnouncer.cs | 9 --------- .../BasisNetworkServer/NetworkServer.cs | 11 ++--------- 8 files changed, 14 insertions(+), 71 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index b37dc48e9a..c6fb0cd0d4 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -140,11 +140,6 @@ internal static void AddMetadata( } } - if (limited.Length == 0) - { - return; - } - string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); int offset = 0; for (int index = 0; offset < encoded.Length; index++) @@ -168,7 +163,7 @@ internal static void AddMetadata( } internal static string ReadMetadata( - IReadOnlyDictionary properties, + Dictionary properties, string legacyKey, string encodedKey, int maxBytes) @@ -178,23 +173,18 @@ internal static string ReadMetadata( return decoded; } - string legacy = null; - if (properties != null) - { - properties.TryGetValue(legacyKey, out legacy); - } + properties.TryGetValue(legacyKey, out string legacy); return LimitUtf8(legacy, maxBytes); } private static bool TryReadEncodedMetadata( - IReadOnlyDictionary properties, + Dictionary properties, string encodedKey, int maxBytes, out string value) { value = string.Empty; - if (properties == null - || !properties.TryGetValue(encodedKey + "-0", out string firstChunk)) + if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) { return false; } diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 9a320109b4..67333d9e79 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -69,15 +69,6 @@ internal static ServiceProfile CreateProfile( bool requiresPassword, IPAddress[] addresses) { - if (instanceId == Guid.Empty) - { - throw new ArgumentException("LAN server instance ID cannot be empty.", nameof(instanceId)); - } - if (serverPort == 0) - { - throw new ArgumentOutOfRangeException(nameof(serverPort)); - } - string id = instanceId.ToString("N"); string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) ? BasisNetworkStackRegistry.DefaultId diff --git a/Basis Server/BasisNetworkServer/NetworkServer.cs b/Basis Server/BasisNetworkServer/NetworkServer.cs index 8e8db08a54..effb496b0f 100644 --- a/Basis Server/BasisNetworkServer/NetworkServer.cs +++ b/Basis Server/BasisNetworkServer/NetworkServer.cs @@ -27,7 +27,6 @@ public static class NetworkServer private static readonly object _lanAnnouncementGate = new object(); private static BasisLanServerAnnouncer _lanServerAnnouncer; private static Guid _lanServerInstanceId; - private static bool _lanServerRunning; /// /// Allow-list consulted at /// when is set to AllowList. @@ -100,7 +99,6 @@ public static void StartServer(Configuration configuration) lock (_lanAnnouncementGate) { _lanServerInstanceId = Guid.NewGuid(); - _lanServerRunning = true; } SetLanAdvertising(configuration.AnnounceToLan); @@ -126,7 +124,7 @@ public static void SetLanAdvertising(bool enabled) } else { - if (!_lanServerRunning || Server == null || Configuration == null || _lanServerAnnouncer != null) + if (_lanServerInstanceId == Guid.Empty || Server == null || Configuration == null || _lanServerAnnouncer != null) { return; } @@ -156,16 +154,11 @@ public static void SetLanAdvertising(bool enabled) public static void StopServer() { - BasisLanServerAnnouncer lanServerAnnouncer; lock (_lanAnnouncementGate) { - _lanServerRunning = false; _lanServerInstanceId = Guid.Empty; - lanServerAnnouncer = _lanServerAnnouncer; - _lanServerAnnouncer = null; } - try { lanServerAnnouncer?.Dispose(); } - catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } + SetLanAdvertising(false); if (Server == null) return; try diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs index 7e5492e126..88d02f4a38 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs @@ -47,17 +47,12 @@ public void Initialize(Configuration configuration, string LogPath, string UUIDT } catch (Exception ex) { - SetLanAdvertising(false); + NetworkServer.SetLanAdvertising(false); BNL.LogError($"Server encountered an error: {ex.Message} {ex.StackTrace}"); } }, cancellationToken); } - public void SetLanAdvertising(bool enabled) - { - NetworkServer.SetLanAdvertising(enabled); - } - public void Stop() { lock (lifecycleGate) diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index 59bd71c8f3..b031c87b8e 100644 --- a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs +++ b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs @@ -387,7 +387,7 @@ private void OnHostShowToLanChanged(bool value) return; } - runner.SetLanAdvertising(value); + NetworkServer.SetLanAdvertising(value); } private void PopulateHostStackDropdown() diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index b37dc48e9a..c6fb0cd0d4 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -140,11 +140,6 @@ internal static void AddMetadata( } } - if (limited.Length == 0) - { - return; - } - string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); int offset = 0; for (int index = 0; offset < encoded.Length; index++) @@ -168,7 +163,7 @@ internal static void AddMetadata( } internal static string ReadMetadata( - IReadOnlyDictionary properties, + Dictionary properties, string legacyKey, string encodedKey, int maxBytes) @@ -178,23 +173,18 @@ internal static string ReadMetadata( return decoded; } - string legacy = null; - if (properties != null) - { - properties.TryGetValue(legacyKey, out legacy); - } + properties.TryGetValue(legacyKey, out string legacy); return LimitUtf8(legacy, maxBytes); } private static bool TryReadEncodedMetadata( - IReadOnlyDictionary properties, + Dictionary properties, string encodedKey, int maxBytes, out string value) { value = string.Empty; - if (properties == null - || !properties.TryGetValue(encodedKey + "-0", out string firstChunk)) + if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) { return false; } diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 9a320109b4..67333d9e79 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -69,15 +69,6 @@ internal static ServiceProfile CreateProfile( bool requiresPassword, IPAddress[] addresses) { - if (instanceId == Guid.Empty) - { - throw new ArgumentException("LAN server instance ID cannot be empty.", nameof(instanceId)); - } - if (serverPort == 0) - { - throw new ArgumentOutOfRangeException(nameof(serverPort)); - } - string id = instanceId.ToString("N"); string effectiveStackId = string.IsNullOrWhiteSpace(networkStackId) ? BasisNetworkStackRegistry.DefaultId diff --git a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs index 8e8db08a54..effb496b0f 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs @@ -27,7 +27,6 @@ public static class NetworkServer private static readonly object _lanAnnouncementGate = new object(); private static BasisLanServerAnnouncer _lanServerAnnouncer; private static Guid _lanServerInstanceId; - private static bool _lanServerRunning; /// /// Allow-list consulted at /// when is set to AllowList. @@ -100,7 +99,6 @@ public static void StartServer(Configuration configuration) lock (_lanAnnouncementGate) { _lanServerInstanceId = Guid.NewGuid(); - _lanServerRunning = true; } SetLanAdvertising(configuration.AnnounceToLan); @@ -126,7 +124,7 @@ public static void SetLanAdvertising(bool enabled) } else { - if (!_lanServerRunning || Server == null || Configuration == null || _lanServerAnnouncer != null) + if (_lanServerInstanceId == Guid.Empty || Server == null || Configuration == null || _lanServerAnnouncer != null) { return; } @@ -156,16 +154,11 @@ public static void SetLanAdvertising(bool enabled) public static void StopServer() { - BasisLanServerAnnouncer lanServerAnnouncer; lock (_lanAnnouncementGate) { - _lanServerRunning = false; _lanServerInstanceId = Guid.Empty; - lanServerAnnouncer = _lanServerAnnouncer; - _lanServerAnnouncer = null; } - try { lanServerAnnouncer?.Dispose(); } - catch (Exception ex) { BNL.LogWarning($"LAN announcements could not stop cleanly: {ex.Message}"); } + SetLanAdvertising(false); if (Server == null) return; try From ec580bf5def340bad177469e203e1682e48a5e59 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 07:17:26 +0000 Subject: [PATCH 11/25] Simplify LAN discovery internals --- .../BasisLanDiscoveryProtocol.cs | 157 +++++------------- .../BasisLanServerAnnouncer.cs | 3 - .../BasisNetworkCore/BasisLanServerBrowser.cs | 66 ++------ .../BasisLanDiscoveryTests.cs | 7 +- .../Networking/BasisConnectionService.cs | 28 +--- .../Networking/LanServersDirectorySource.cs | 35 +--- .../BasisLanDiscoveryProtocol.cs | 157 +++++------------- .../BasisLanServerAnnouncer.cs | 3 - .../BasisNetworkCore/BasisLanServerBrowser.cs | 66 ++------ 9 files changed, 127 insertions(+), 395 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index c6fb0cd0d4..1ce21ac358 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -39,45 +39,35 @@ public static class BasisLanAddressUtility { public static bool IsUsable(IPAddress address, bool allowLoopback = false) { - if (address == null - || address.Equals(IPAddress.Any) - || address.Equals(IPAddress.IPv6Any) - || address.Equals(IPAddress.Broadcast) - || address.IsIPv6Multicast - || (!allowLoopback && IPAddress.IsLoopback(address))) + if (address == null || (!allowLoopback && IPAddress.IsLoopback(address))) { return false; } if (address.AddressFamily == AddressFamily.InterNetwork) { - byte[] bytes = address.GetAddressBytes(); - return bytes.Length == 4 && (bytes[0] < 224 || bytes[0] > 239); + byte firstOctet = address.GetAddressBytes()[0]; + return !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.Broadcast) + && (firstOctet < 224 || firstOctet > 239); } - return address.AddressFamily == AddressFamily.InterNetworkV6; + return address.AddressFamily == AddressFamily.InterNetworkV6 + && !address.Equals(IPAddress.IPv6Any) + && !address.IsIPv6Multicast; } public static int PreferenceRank(IPAddress address) { - if (address == null) - { - return int.MaxValue; - } - - if (address.AddressFamily == AddressFamily.InterNetwork) + if (address?.AddressFamily == AddressFamily.InterNetwork) { byte[] bytes = address.GetAddressBytes(); - bool linkLocal = bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254; - return linkLocal ? 2 : 0; - } - - if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - return address.IsIPv6LinkLocal ? 3 : 1; + return bytes[0] == 169 && bytes[1] == 254 ? 2 : 0; } - return int.MaxValue; + return address?.AddressFamily == AddressFamily.InterNetworkV6 + ? address.IsIPv6LinkLocal ? 3 : 1 + : int.MaxValue; } } @@ -91,7 +81,6 @@ internal static class BasisLanDiscoveryProtocol internal const int MaxStackIdBytes = 64; internal const int MaxServerNameBytes = 128; internal const int MaxMotdBytes = 384; - internal const int MaxEncodedChunks = 10; internal static string LimitUtf8(string value, int maxBytes) { @@ -118,45 +107,19 @@ internal static string LimitUtf8(string value, int maxBytes) internal static void AddMetadata( ServiceProfile profile, - string legacyKey, string encodedKey, string value, int maxBytes) { if (profile == null) throw new ArgumentNullException(nameof(profile)); - if (string.IsNullOrEmpty(legacyKey)) throw new ArgumentException("TXT key is required.", nameof(legacyKey)); - if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("Encoded TXT key is required.", nameof(encodedKey)); - - string limited = LimitUtf8(value, maxBytes); - int legacyBudget = TxtValueBudget(legacyKey); - bool ascii = IsAscii(limited); - if (ascii) - { - string legacy = LimitUtf8(limited, Math.Min(maxBytes, legacyBudget)); - profile.AddProperty(legacyKey, legacy); - if (Encoding.ASCII.GetByteCount(limited) <= legacyBudget) - { - return; - } - } + if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("TXT key is required.", nameof(encodedKey)); - string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); - int offset = 0; - for (int index = 0; offset < encoded.Length; index++) + string encoded = Convert.ToBase64String( + Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes))); + for (int index = 0, offset = 0; offset < encoded.Length; index++) { - if (index >= MaxEncodedChunks) - { - throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); - } - string chunkKey = $"{encodedKey}-{index}"; - int chunkBudget = TxtValueBudget(chunkKey); - if (chunkBudget <= 0) - { - throw new InvalidOperationException("Basis LAN TXT key leaves no room for a value."); - } - - int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); + int chunkLength = Math.Min(TxtValueBudget(chunkKey), encoded.Length - offset); profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); offset += chunkLength; } @@ -168,79 +131,49 @@ internal static string ReadMetadata( string encodedKey, int maxBytes) { - if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) - { - return decoded; - } - - properties.TryGetValue(legacyKey, out string legacy); - return LimitUtf8(legacy, maxBytes); - } - - private static bool TryReadEncodedMetadata( - Dictionary properties, - string encodedKey, - int maxBytes, - out string value) - { - value = string.Empty; - if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) - { - return false; - } - - StringBuilder encoded = new StringBuilder(firstChunk); - for (int index = 1; index < MaxEncodedChunks; index++) + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; + StringBuilder encoded = new StringBuilder(maxEncodedLength); + for (int index = 0; + properties.TryGetValue($"{encodedKey}-{index}", out string chunk); + index++) { - if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) + if (chunk.Length > maxEncodedLength - encoded.Length) { - break; + return ReadLegacy(properties, legacyKey, maxBytes); } encoded.Append(chunk); } - int maxEncodedLength = ((maxBytes + 2) / 3) * 4; - if (encoded.Length > maxEncodedLength) - { - return false; - } - - try + if (encoded.Length != 0) { - byte[] bytes = Convert.FromBase64String(encoded.ToString()); - if (bytes.Length > maxBytes) + try + { + byte[] bytes = Convert.FromBase64String(encoded.ToString()); + if (bytes.Length <= maxBytes) + { + return StrictUtf8.GetString(bytes); + } + } + catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) { - return false; } - value = StrictUtf8.GetString(bytes); - return true; - } - catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) - { - return false; } + + return ReadLegacy(properties, legacyKey, maxBytes); } - private static int TxtValueBudget(string key) + private static string ReadLegacy( + Dictionary properties, + string key, + int maxBytes) { - return Math.Max(0, 254 - Encoding.ASCII.GetByteCount(key ?? string.Empty)); + properties.TryGetValue(key, out string value); + return LimitUtf8(value, maxBytes); } - private static bool IsAscii(string value) + private static int TxtValueBudget(string key) { - if (string.IsNullOrEmpty(value)) - { - return true; - } - - for (int i = 0; i < value.Length; i++) - { - if (value[i] > 0x7F) - { - return false; - } - } - return true; + return 254 - Encoding.ASCII.GetByteCount(key); } } } diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 67333d9e79..3cde391a36 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -86,19 +86,16 @@ internal static ServiceProfile CreateProfile( profile.AddProperty("id", id); BasisLanDiscoveryProtocol.AddMetadata( profile, - "stack", "stack64", effectiveStackId, BasisLanDiscoveryProtocol.MaxStackIdBytes); BasisLanDiscoveryProtocol.AddMetadata( profile, - "name", "name64", effectiveServerName, BasisLanDiscoveryProtocol.MaxServerNameBytes); BasisLanDiscoveryProtocol.AddMetadata( profile, - "motd", "motd64", motd ?? string.Empty, BasisLanDiscoveryProtocol.MaxMotdBytes); diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index 3af6dc3afe..e5841fb7f9 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Sockets; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -130,7 +131,7 @@ private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs a return; } - if (TryReadInstanceId(args.Message, args.ServiceInstanceName, out Guid instanceId)) + if (TryReadInstanceId(args.ServiceInstanceName, out Guid instanceId)) { _removed(instanceId); } @@ -150,12 +151,12 @@ internal static bool TryExtractAdvertisement( return false; } - List records = CollectRecords(message); + IEnumerable records = EnumerateRecords(message); SRVRecord service = null; TXTRecord text = null; foreach (ResourceRecord record in records) { - if (!NamesEqual(record?.Name, serviceInstanceName)) + if (!Equals(record?.Name, serviceInstanceName)) { continue; } @@ -219,32 +220,10 @@ internal static bool TryExtractAdvertisement( } private static bool TryReadInstanceId( - Message message, DomainName serviceInstanceName, out Guid instanceId) { instanceId = Guid.Empty; - if (message != null) - { - foreach (ResourceRecord record in CollectRecords(message)) - { - if (!(record is TXTRecord text) - || !NamesEqual(record.Name, serviceInstanceName) - || text.Strings == null) - { - continue; - } - - Dictionary properties = ReadProperties(text.Strings); - if (properties.TryGetValue("id", out string idText) - && Guid.TryParseExact(idText, "N", out instanceId) - && instanceId != Guid.Empty) - { - return true; - } - } - } - string instance = serviceInstanceName?.ToString() ?? string.Empty; int separator = instance.IndexOf('.'); if (separator >= 0) @@ -256,29 +235,13 @@ private static bool TryReadInstanceId( && instanceId != Guid.Empty; } - private static List CollectRecords(Message message) + private static IEnumerable EnumerateRecords(Message message) { - List records = new List(); - AddRecords(records, message.Answers); - AddRecords(records, message.AuthorityRecords); - AddRecords(records, message.AdditionalRecords); - return records; - } - - private static void AddRecords(List target, IList source) - { - if (source == null) - { - return; - } - - for (int i = 0; i < source.Count && target.Count < MaxRecords; i++) - { - if (source[i] != null) - { - target.Add(source[i]); - } - } + return message.Answers + .Concat(message.AuthorityRecords) + .Concat(message.AdditionalRecords) + .Where(record => record != null) + .Take(MaxRecords); } private static Dictionary ReadProperties(IList values) @@ -309,7 +272,7 @@ private static Dictionary ReadProperties(IList values) } private static IPAddress SelectAddress( - List records, + IEnumerable records, DomainName hostName, IPAddress remoteAddress) { @@ -322,7 +285,7 @@ private static IPAddress SelectAddress( foreach (ResourceRecord record in records) { if (!(record is AddressRecord addressRecord) - || !NamesEqual(record.Name, hostName) + || !Equals(record.Name, hostName) || !BasisLanAddressUtility.IsUsable(addressRecord.Address, allowLoopback: true)) { continue; @@ -354,11 +317,6 @@ private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress return address; } - private static bool NamesEqual(DomainName left, DomainName right) - { - return left != null && right != null && left.Equals(right); - } - public void Dispose() { ServiceDiscovery discovery; diff --git a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs index 23b569374f..0d9e61bf48 100644 --- a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs +++ b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs @@ -22,16 +22,15 @@ public void LimitUtf8_DoesNotSplitSurrogatePair() } [Fact] - public void LongAsciiMotd_UsesChunksAndRoundTripsAtFullLimit() + public void LongAsciiMotd_UsesOnlyEncodedChunksAndRoundTripsAtFullLimit() { string motd = new string('M', BasisLanDiscoveryProtocol.MaxMotdBytes); Guid id = Guid.NewGuid(); ServiceProfile profile = CreateProfile(id, "ASCII Server", motd, requiresPassword: false); Dictionary properties = ReadProperties(profile); - Assert.True(properties.TryGetValue("motd", out string? legacy)); - Assert.True(Encoding.ASCII.GetByteCount(legacy) < Encoding.ASCII.GetByteCount(motd)); - Assert.Contains("motd64-0", properties.Keys); + Assert.DoesNotContain("motd", properties.Keys); + Assert.Equal(3, properties.Keys.Count(key => key.StartsWith("motd64-", StringComparison.Ordinal))); BasisLanAdvertisement advertisement = Extract(profile); Assert.Equal(id, advertisement.InstanceId); diff --git a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs index 89f3d0b8dc..f9deaafe28 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs @@ -27,7 +27,6 @@ public static class BasisConnectionService public static bool AutoConnectAttempted; private static int _connectInProgress; - private static readonly object LanPasswordGate = new object(); private static ServerDirectoryEntry _pendingLanPasswordEntry; /// @@ -40,10 +39,7 @@ public static class BasisConnectionService [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetLanPasswordState() { - lock (LanPasswordGate) - { - _pendingLanPasswordEntry = null; - } + Interlocked.Exchange(ref _pendingLanPasswordEntry, null); LanPasswordRequired = null; Volatile.Write(ref _connectInProgress, 0); } @@ -88,27 +84,15 @@ public static void ReportConnectionError(string message) public static void CompleteConnectionProgress() => BasisSceneLoad.progressCallback.ReportProgress(ConnectionProgressKey, 100f, string.Empty); - private static void SetPendingLanPasswordAttempt(ServerDirectoryEntry entry) - { - lock (LanPasswordGate) - { - _pendingLanPasswordEntry = entry; - } - } + private static void SetPendingLanPasswordAttempt(ServerDirectoryEntry entry) => + Interlocked.Exchange(ref _pendingLanPasswordEntry, entry); - internal static void ClearPendingLanPasswordAttempt() - { - SetPendingLanPasswordAttempt(null); - } + internal static void ClearPendingLanPasswordAttempt() => + Interlocked.Exchange(ref _pendingLanPasswordEntry, null); internal static bool TryHandleLanPasswordRejected() { - ServerDirectoryEntry entry; - lock (LanPasswordGate) - { - entry = _pendingLanPasswordEntry; - _pendingLanPasswordEntry = null; - } + ServerDirectoryEntry entry = Interlocked.Exchange(ref _pendingLanPasswordEntry, null); if (entry == null) { diff --git a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs index 8088f6b4d7..c6a6b06431 100644 --- a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs @@ -29,8 +29,6 @@ private sealed class DiscoveredServer public Guid InstanceId; public IPAddress Address; public long AddressLastSeenTicks; - public IPAddress AlternateAddress; - public long AlternateAddressLastSeenTicks; public ushort Port; public bool RequiresPassword; public string NetworkStackId; @@ -314,7 +312,9 @@ private bool PruneExpiredLocked(long nowTicks) private static bool UpdateAddressLocked(DiscoveredServer server, IPAddress address, long nowTicks) { - if (server.Address == null) + if (server.Address == null + || (!Equals(server.Address, address) + && nowTicks - server.AddressLastSeenTicks > EntryLifetimeTicks)) { server.Address = address; server.AddressLastSeenTicks = nowTicks; @@ -324,35 +324,8 @@ private static bool UpdateAddressLocked(DiscoveredServer server, IPAddress addre if (Equals(server.Address, address)) { server.AddressLastSeenTicks = nowTicks; - return false; - } - - if (Equals(server.AlternateAddress, address)) - { - server.AlternateAddressLastSeenTicks = nowTicks; - } - else if (server.AlternateAddress == null - || nowTicks - server.AlternateAddressLastSeenTicks > EntryLifetimeTicks - || BasisLanAddressUtility.PreferenceRank(address) - < BasisLanAddressUtility.PreferenceRank(server.AlternateAddress)) - { - server.AlternateAddress = address; - server.AlternateAddressLastSeenTicks = nowTicks; } - - if (nowTicks - server.AddressLastSeenTicks <= EntryLifetimeTicks) - { - return false; - } - - IPAddress replacement = server.AlternateAddress ?? address; - server.Address = replacement; - server.AddressLastSeenTicks = Equals(replacement, address) - ? nowTicks - : server.AlternateAddressLastSeenTicks; - server.AlternateAddress = null; - server.AlternateAddressLastSeenTicks = 0; - return true; + return false; } private void RemoveOldestServerLocked() diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index c6fb0cd0d4..1ce21ac358 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -39,45 +39,35 @@ public static class BasisLanAddressUtility { public static bool IsUsable(IPAddress address, bool allowLoopback = false) { - if (address == null - || address.Equals(IPAddress.Any) - || address.Equals(IPAddress.IPv6Any) - || address.Equals(IPAddress.Broadcast) - || address.IsIPv6Multicast - || (!allowLoopback && IPAddress.IsLoopback(address))) + if (address == null || (!allowLoopback && IPAddress.IsLoopback(address))) { return false; } if (address.AddressFamily == AddressFamily.InterNetwork) { - byte[] bytes = address.GetAddressBytes(); - return bytes.Length == 4 && (bytes[0] < 224 || bytes[0] > 239); + byte firstOctet = address.GetAddressBytes()[0]; + return !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.Broadcast) + && (firstOctet < 224 || firstOctet > 239); } - return address.AddressFamily == AddressFamily.InterNetworkV6; + return address.AddressFamily == AddressFamily.InterNetworkV6 + && !address.Equals(IPAddress.IPv6Any) + && !address.IsIPv6Multicast; } public static int PreferenceRank(IPAddress address) { - if (address == null) - { - return int.MaxValue; - } - - if (address.AddressFamily == AddressFamily.InterNetwork) + if (address?.AddressFamily == AddressFamily.InterNetwork) { byte[] bytes = address.GetAddressBytes(); - bool linkLocal = bytes.Length == 4 && bytes[0] == 169 && bytes[1] == 254; - return linkLocal ? 2 : 0; - } - - if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - return address.IsIPv6LinkLocal ? 3 : 1; + return bytes[0] == 169 && bytes[1] == 254 ? 2 : 0; } - return int.MaxValue; + return address?.AddressFamily == AddressFamily.InterNetworkV6 + ? address.IsIPv6LinkLocal ? 3 : 1 + : int.MaxValue; } } @@ -91,7 +81,6 @@ internal static class BasisLanDiscoveryProtocol internal const int MaxStackIdBytes = 64; internal const int MaxServerNameBytes = 128; internal const int MaxMotdBytes = 384; - internal const int MaxEncodedChunks = 10; internal static string LimitUtf8(string value, int maxBytes) { @@ -118,45 +107,19 @@ internal static string LimitUtf8(string value, int maxBytes) internal static void AddMetadata( ServiceProfile profile, - string legacyKey, string encodedKey, string value, int maxBytes) { if (profile == null) throw new ArgumentNullException(nameof(profile)); - if (string.IsNullOrEmpty(legacyKey)) throw new ArgumentException("TXT key is required.", nameof(legacyKey)); - if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("Encoded TXT key is required.", nameof(encodedKey)); - - string limited = LimitUtf8(value, maxBytes); - int legacyBudget = TxtValueBudget(legacyKey); - bool ascii = IsAscii(limited); - if (ascii) - { - string legacy = LimitUtf8(limited, Math.Min(maxBytes, legacyBudget)); - profile.AddProperty(legacyKey, legacy); - if (Encoding.ASCII.GetByteCount(limited) <= legacyBudget) - { - return; - } - } + if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("TXT key is required.", nameof(encodedKey)); - string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(limited)); - int offset = 0; - for (int index = 0; offset < encoded.Length; index++) + string encoded = Convert.ToBase64String( + Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes))); + for (int index = 0, offset = 0; offset < encoded.Length; index++) { - if (index >= MaxEncodedChunks) - { - throw new InvalidOperationException("Basis LAN metadata requires too many TXT chunks."); - } - string chunkKey = $"{encodedKey}-{index}"; - int chunkBudget = TxtValueBudget(chunkKey); - if (chunkBudget <= 0) - { - throw new InvalidOperationException("Basis LAN TXT key leaves no room for a value."); - } - - int chunkLength = Math.Min(chunkBudget, encoded.Length - offset); + int chunkLength = Math.Min(TxtValueBudget(chunkKey), encoded.Length - offset); profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); offset += chunkLength; } @@ -168,79 +131,49 @@ internal static string ReadMetadata( string encodedKey, int maxBytes) { - if (TryReadEncodedMetadata(properties, encodedKey, maxBytes, out string decoded)) - { - return decoded; - } - - properties.TryGetValue(legacyKey, out string legacy); - return LimitUtf8(legacy, maxBytes); - } - - private static bool TryReadEncodedMetadata( - Dictionary properties, - string encodedKey, - int maxBytes, - out string value) - { - value = string.Empty; - if (!properties.TryGetValue(encodedKey + "-0", out string firstChunk)) - { - return false; - } - - StringBuilder encoded = new StringBuilder(firstChunk); - for (int index = 1; index < MaxEncodedChunks; index++) + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; + StringBuilder encoded = new StringBuilder(maxEncodedLength); + for (int index = 0; + properties.TryGetValue($"{encodedKey}-{index}", out string chunk); + index++) { - if (!properties.TryGetValue($"{encodedKey}-{index}", out string chunk)) + if (chunk.Length > maxEncodedLength - encoded.Length) { - break; + return ReadLegacy(properties, legacyKey, maxBytes); } encoded.Append(chunk); } - int maxEncodedLength = ((maxBytes + 2) / 3) * 4; - if (encoded.Length > maxEncodedLength) - { - return false; - } - - try + if (encoded.Length != 0) { - byte[] bytes = Convert.FromBase64String(encoded.ToString()); - if (bytes.Length > maxBytes) + try + { + byte[] bytes = Convert.FromBase64String(encoded.ToString()); + if (bytes.Length <= maxBytes) + { + return StrictUtf8.GetString(bytes); + } + } + catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) { - return false; } - value = StrictUtf8.GetString(bytes); - return true; - } - catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) - { - return false; } + + return ReadLegacy(properties, legacyKey, maxBytes); } - private static int TxtValueBudget(string key) + private static string ReadLegacy( + Dictionary properties, + string key, + int maxBytes) { - return Math.Max(0, 254 - Encoding.ASCII.GetByteCount(key ?? string.Empty)); + properties.TryGetValue(key, out string value); + return LimitUtf8(value, maxBytes); } - private static bool IsAscii(string value) + private static int TxtValueBudget(string key) { - if (string.IsNullOrEmpty(value)) - { - return true; - } - - for (int i = 0; i < value.Length; i++) - { - if (value[i] > 0x7F) - { - return false; - } - } - return true; + return 254 - Encoding.ASCII.GetByteCount(key); } } } diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 67333d9e79..3cde391a36 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -86,19 +86,16 @@ internal static ServiceProfile CreateProfile( profile.AddProperty("id", id); BasisLanDiscoveryProtocol.AddMetadata( profile, - "stack", "stack64", effectiveStackId, BasisLanDiscoveryProtocol.MaxStackIdBytes); BasisLanDiscoveryProtocol.AddMetadata( profile, - "name", "name64", effectiveServerName, BasisLanDiscoveryProtocol.MaxServerNameBytes); BasisLanDiscoveryProtocol.AddMetadata( profile, - "motd", "motd64", motd ?? string.Empty, BasisLanDiscoveryProtocol.MaxMotdBytes); diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index 3af6dc3afe..e5841fb7f9 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Sockets; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -130,7 +131,7 @@ private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs a return; } - if (TryReadInstanceId(args.Message, args.ServiceInstanceName, out Guid instanceId)) + if (TryReadInstanceId(args.ServiceInstanceName, out Guid instanceId)) { _removed(instanceId); } @@ -150,12 +151,12 @@ internal static bool TryExtractAdvertisement( return false; } - List records = CollectRecords(message); + IEnumerable records = EnumerateRecords(message); SRVRecord service = null; TXTRecord text = null; foreach (ResourceRecord record in records) { - if (!NamesEqual(record?.Name, serviceInstanceName)) + if (!Equals(record?.Name, serviceInstanceName)) { continue; } @@ -219,32 +220,10 @@ internal static bool TryExtractAdvertisement( } private static bool TryReadInstanceId( - Message message, DomainName serviceInstanceName, out Guid instanceId) { instanceId = Guid.Empty; - if (message != null) - { - foreach (ResourceRecord record in CollectRecords(message)) - { - if (!(record is TXTRecord text) - || !NamesEqual(record.Name, serviceInstanceName) - || text.Strings == null) - { - continue; - } - - Dictionary properties = ReadProperties(text.Strings); - if (properties.TryGetValue("id", out string idText) - && Guid.TryParseExact(idText, "N", out instanceId) - && instanceId != Guid.Empty) - { - return true; - } - } - } - string instance = serviceInstanceName?.ToString() ?? string.Empty; int separator = instance.IndexOf('.'); if (separator >= 0) @@ -256,29 +235,13 @@ private static bool TryReadInstanceId( && instanceId != Guid.Empty; } - private static List CollectRecords(Message message) + private static IEnumerable EnumerateRecords(Message message) { - List records = new List(); - AddRecords(records, message.Answers); - AddRecords(records, message.AuthorityRecords); - AddRecords(records, message.AdditionalRecords); - return records; - } - - private static void AddRecords(List target, IList source) - { - if (source == null) - { - return; - } - - for (int i = 0; i < source.Count && target.Count < MaxRecords; i++) - { - if (source[i] != null) - { - target.Add(source[i]); - } - } + return message.Answers + .Concat(message.AuthorityRecords) + .Concat(message.AdditionalRecords) + .Where(record => record != null) + .Take(MaxRecords); } private static Dictionary ReadProperties(IList values) @@ -309,7 +272,7 @@ private static Dictionary ReadProperties(IList values) } private static IPAddress SelectAddress( - List records, + IEnumerable records, DomainName hostName, IPAddress remoteAddress) { @@ -322,7 +285,7 @@ private static IPAddress SelectAddress( foreach (ResourceRecord record in records) { if (!(record is AddressRecord addressRecord) - || !NamesEqual(record.Name, hostName) + || !Equals(record.Name, hostName) || !BasisLanAddressUtility.IsUsable(addressRecord.Address, allowLoopback: true)) { continue; @@ -354,11 +317,6 @@ private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress return address; } - private static bool NamesEqual(DomainName left, DomainName right) - { - return left != null && right != null && left.Equals(right); - } - public void Dispose() { ServiceDiscovery discovery; From 9b0770234286d5ff0a8018be3fd06a68fbf66bfa Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 08:54:37 +0000 Subject: [PATCH 12/25] Fix LAN advertising assembly boundary --- .../Networking/BasisNetworkServerRunner.cs | 5 +++++ .../com.basis.provider.servers/Runtime/ServersProvider.cs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs index 88d02f4a38..7bf610e9a1 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkServerRunner.cs @@ -53,6 +53,11 @@ public void Initialize(Configuration configuration, string LogPath, string UUIDT }, cancellationToken); } + public void SetLanAdvertising(bool enabled) + { + NetworkServer.SetLanAdvertising(enabled); + } + public void Stop() { lock (lifecycleGate) diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index b031c87b8e..59bd71c8f3 100644 --- a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs +++ b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs @@ -387,7 +387,7 @@ private void OnHostShowToLanChanged(bool value) return; } - NetworkServer.SetLanAdvertising(value); + runner.SetLanAdvertising(value); } private void PopulateHostStackDropdown() From 8181b4562dbf2237033cb25bf18870ea4fc4443e Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 20:55:03 +0000 Subject: [PATCH 13/25] Remove unreleased LAN metadata fallback --- .../BasisLanDiscoveryProtocol.cs | 38 +++++++------------ .../BasisNetworkCore/BasisLanServerBrowser.cs | 3 -- .../BasisLanDiscoveryTests.cs | 6 +-- .../BasisLanDiscoveryProtocol.cs | 38 +++++++------------ .../BasisNetworkCore/BasisLanServerBrowser.cs | 3 -- 5 files changed, 30 insertions(+), 58 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 1ce21ac358..4c0bae5792 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -127,7 +127,6 @@ internal static void AddMetadata( internal static string ReadMetadata( Dictionary properties, - string legacyKey, string encodedKey, int maxBytes) { @@ -139,36 +138,27 @@ internal static string ReadMetadata( { if (chunk.Length > maxEncodedLength - encoded.Length) { - return ReadLegacy(properties, legacyKey, maxBytes); + return string.Empty; } encoded.Append(chunk); } - if (encoded.Length != 0) + if (encoded.Length == 0) { - try - { - byte[] bytes = Convert.FromBase64String(encoded.ToString()); - if (bytes.Length <= maxBytes) - { - return StrictUtf8.GetString(bytes); - } - } - catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) - { - } + return string.Empty; } - return ReadLegacy(properties, legacyKey, maxBytes); - } - - private static string ReadLegacy( - Dictionary properties, - string key, - int maxBytes) - { - properties.TryGetValue(key, out string value); - return LimitUtf8(value, maxBytes); + try + { + byte[] bytes = Convert.FromBase64String(encoded.ToString()); + return bytes.Length <= maxBytes + ? StrictUtf8.GetString(bytes) + : string.Empty; + } + catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) + { + return string.Empty; + } } private static int TxtValueBudget(string key) diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index e5841fb7f9..6a512c740b 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -196,17 +196,14 @@ internal static bool TryExtractAdvertisement( string stackId = BasisLanDiscoveryProtocol.ReadMetadata( properties, - "stack", "stack64", BasisLanDiscoveryProtocol.MaxStackIdBytes); string serverName = BasisLanDiscoveryProtocol.ReadMetadata( properties, - "name", "name64", BasisLanDiscoveryProtocol.MaxServerNameBytes); string motd = BasisLanDiscoveryProtocol.ReadMetadata( properties, - "motd", "motd64", BasisLanDiscoveryProtocol.MaxMotdBytes); advertisement = new BasisLanAdvertisement( diff --git a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs index 0d9e61bf48..4ae457e29e 100644 --- a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs +++ b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs @@ -61,21 +61,19 @@ public void UnicodeMetadata_RoundTripsWithinUtf8Limits() } [Fact] - public void MalformedEncodedMetadata_FallsBackToLegacyValue() + public void MalformedEncodedMetadata_ReturnsEmpty() { Dictionary properties = new(StringComparer.OrdinalIgnoreCase) { - ["name"] = "Legacy Name", ["name64-0"] = "not valid base64!", }; string value = BasisLanDiscoveryProtocol.ReadMetadata( properties, - "name", "name64", BasisLanDiscoveryProtocol.MaxServerNameBytes); - Assert.Equal("Legacy Name", value); + Assert.Empty(value); } [Fact] diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 1ce21ac358..4c0bae5792 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -127,7 +127,6 @@ internal static void AddMetadata( internal static string ReadMetadata( Dictionary properties, - string legacyKey, string encodedKey, int maxBytes) { @@ -139,36 +138,27 @@ internal static string ReadMetadata( { if (chunk.Length > maxEncodedLength - encoded.Length) { - return ReadLegacy(properties, legacyKey, maxBytes); + return string.Empty; } encoded.Append(chunk); } - if (encoded.Length != 0) + if (encoded.Length == 0) { - try - { - byte[] bytes = Convert.FromBase64String(encoded.ToString()); - if (bytes.Length <= maxBytes) - { - return StrictUtf8.GetString(bytes); - } - } - catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) - { - } + return string.Empty; } - return ReadLegacy(properties, legacyKey, maxBytes); - } - - private static string ReadLegacy( - Dictionary properties, - string key, - int maxBytes) - { - properties.TryGetValue(key, out string value); - return LimitUtf8(value, maxBytes); + try + { + byte[] bytes = Convert.FromBase64String(encoded.ToString()); + return bytes.Length <= maxBytes + ? StrictUtf8.GetString(bytes) + : string.Empty; + } + catch (Exception ex) when (ex is FormatException || ex is DecoderFallbackException) + { + return string.Empty; + } } private static int TxtValueBudget(string key) diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index e5841fb7f9..6a512c740b 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -196,17 +196,14 @@ internal static bool TryExtractAdvertisement( string stackId = BasisLanDiscoveryProtocol.ReadMetadata( properties, - "stack", "stack64", BasisLanDiscoveryProtocol.MaxStackIdBytes); string serverName = BasisLanDiscoveryProtocol.ReadMetadata( properties, - "name", "name64", BasisLanDiscoveryProtocol.MaxServerNameBytes); string motd = BasisLanDiscoveryProtocol.ReadMetadata( properties, - "motd", "motd64", BasisLanDiscoveryProtocol.MaxMotdBytes); advertisement = new BasisLanAdvertisement( From 736ab6f2ef1d05fa3db5931ca8b58ed56919f2ff Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 21:09:59 +0000 Subject: [PATCH 14/25] Prompt for LAN password after auth rejection --- .../BasisNetworkCore/BasisNetworkCommons.cs | 5 ++--- .../BasisServerHandleEvents.cs | 7 +------ .../BasisConnectionRejectionTests.cs | 19 +++++++++++++++++++ .../Networking/BasisConnectionService.cs | 4 ++-- .../Networking/BasisNetworkEvents.cs | 17 ++++------------- .../BasisNetworkCore/BasisNetworkCommons.cs | 5 ++--- .../BasisServerHandleEvents.cs | 7 +------ 7 files changed, 31 insertions(+), 33 deletions(-) create mode 100644 Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs diff --git a/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs b/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs index 94bee03657..8137ad69b1 100644 --- a/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs +++ b/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs @@ -378,15 +378,14 @@ public static bool IsPluginChannel(byte channel) // Wire: [magic:uint][kind:byte][aux0:ushort][aux1:ushort][message:string] // VersionMismatch → aux0 = server protocol version, aux1 = client protocol version. // ServerFull → aux0/aux1 unused (0); any counts are in the message. - // InvalidPassword → aux0/aux1 unused (0). + /// Bare rejection reason emitted when password authentication fails. + public const string AuthenticationRejectedReason = "Authentication failed, Auth rejected"; /// Marker for a structured reject payload. "BA51 5CE1" ≈ "Basis reject". public const uint RejectMagic = 0xBA515CE1u; /// RejectKind: the client's protocol version does not match the server's. public const byte RejectKind_VersionMismatch = 1; /// RejectKind: the server has reached its player limit. public const byte RejectKind_ServerFull = 2; - /// RejectKind: the supplied server password was rejected. - public const byte RejectKind_InvalidPassword = 3; /// /// Maps quality index (0‑3) + additional data presence → byte-ID channel. diff --git a/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs b/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs index e081cfcb4b..625e310a01 100644 --- a/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs +++ b/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs @@ -254,12 +254,7 @@ public static void HandleConnectionRequest(ConnectionRequest ConReq) } if (NetworkServer.Auth.IsAuthenticated(AuthBytes) == false) { - RejectStructured( - ConReq, - BasisNetworkCommons.RejectKind_InvalidPassword, - 0, - 0, - "The server password is incorrect."); + RejectWithReason(ConReq, BasisNetworkCommons.AuthenticationRejectedReason); return; } } diff --git a/Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs b/Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs new file mode 100644 index 0000000000..904cf0017b --- /dev/null +++ b/Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs @@ -0,0 +1,19 @@ +using Basis.Network.Core; +using LiteNetDataReader = LiteNetLib.Utils.NetDataReader; +using LiteNetDataWriter = LiteNetLib.Utils.NetDataWriter; +using Xunit; + +namespace BasisServerTests; + +public sealed class BasisConnectionRejectionTests +{ + [Fact] + public void AuthenticationRejectedReason_UsesBareStringPayload() + { + LiteNetDataWriter writer = new LiteNetDataWriter(); + writer.Put(BasisNetworkCommons.AuthenticationRejectedReason); + LiteNetDataReader reader = new LiteNetDataReader(writer); + + Assert.Equal("Authentication failed, Auth rejected", reader.PeekString()); + } +} diff --git a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs index f9deaafe28..e72b15de43 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs @@ -30,7 +30,7 @@ public static class BasisConnectionService private static ServerDirectoryEntry _pendingLanPasswordEntry; /// - /// Raised when a password-protected LAN connection is rejected for an invalid password. + /// Raised when a password-protected LAN connection receives an authentication rejection. /// UI packages can use this to request a replacement without coupling the framework to a /// particular menu implementation. /// @@ -90,7 +90,7 @@ private static void SetPendingLanPasswordAttempt(ServerDirectoryEntry entry) => internal static void ClearPendingLanPasswordAttempt() => Interlocked.Exchange(ref _pendingLanPasswordEntry, null); - internal static bool TryHandleLanPasswordRejected() + internal static bool TryHandleLanAuthenticationRejected() { ServerDirectoryEntry entry = Interlocked.Exchange(ref _pendingLanPasswordEntry, null); diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs index 459d213558..db0ff11732 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs @@ -947,15 +947,6 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) title = "Server Full"; body = !string.IsNullOrEmpty(structuredMsg) ? structuredMsg : "This server is full. Please try again later."; break; - case BasisNetworkCommons.RejectKind_InvalidPassword: - if (BasisConnectionService.TryHandleLanPasswordRejected()) - { - BasisDebug.LogWarning("LAN server rejected the password; requesting a replacement from the user."); - return; - } - title = "Incorrect Password"; - body = !string.IsNullOrEmpty(structuredMsg) ? structuredMsg : "The server password is incorrect."; - break; default: title = "Connection Rejected"; body = !string.IsNullOrEmpty(structuredMsg) ? structuredMsg : "The server rejected the connection."; @@ -964,14 +955,14 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) } else { - // Legacy bare-string reject, or a non-rejection disconnect (timeout, etc.). PeekString + // Bare-string reject, or a non-rejection disconnect (timeout, etc.). PeekString // is defensive: an empty/malformed payload yields "". string reason = extra?.PeekString(); if (rejected - && string.Equals(reason, "Authentication failed, Auth rejected", StringComparison.Ordinal) - && BasisConnectionService.TryHandleLanPasswordRejected()) + && string.Equals(reason, BasisNetworkCommons.AuthenticationRejectedReason, StringComparison.Ordinal) + && BasisConnectionService.TryHandleLanAuthenticationRejected()) { - BasisDebug.LogWarning("Legacy LAN server rejected the password; requesting a replacement from the user."); + BasisDebug.LogWarning("LAN server rejected authentication; requesting a password from the user."); return; } title = rejected ? "Connection Rejected" : "Server Disconnected"; diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs index 94bee03657..8137ad69b1 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs @@ -378,15 +378,14 @@ public static bool IsPluginChannel(byte channel) // Wire: [magic:uint][kind:byte][aux0:ushort][aux1:ushort][message:string] // VersionMismatch → aux0 = server protocol version, aux1 = client protocol version. // ServerFull → aux0/aux1 unused (0); any counts are in the message. - // InvalidPassword → aux0/aux1 unused (0). + /// Bare rejection reason emitted when password authentication fails. + public const string AuthenticationRejectedReason = "Authentication failed, Auth rejected"; /// Marker for a structured reject payload. "BA51 5CE1" ≈ "Basis reject". public const uint RejectMagic = 0xBA515CE1u; /// RejectKind: the client's protocol version does not match the server's. public const byte RejectKind_VersionMismatch = 1; /// RejectKind: the server has reached its player limit. public const byte RejectKind_ServerFull = 2; - /// RejectKind: the supplied server password was rejected. - public const byte RejectKind_InvalidPassword = 3; /// /// Maps quality index (0‑3) + additional data presence → byte-ID channel. diff --git a/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs index e081cfcb4b..625e310a01 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs @@ -254,12 +254,7 @@ public static void HandleConnectionRequest(ConnectionRequest ConReq) } if (NetworkServer.Auth.IsAuthenticated(AuthBytes) == false) { - RejectStructured( - ConReq, - BasisNetworkCommons.RejectKind_InvalidPassword, - 0, - 0, - "The server password is incorrect."); + RejectWithReason(ConReq, BasisNetworkCommons.AuthenticationRejectedReason); return; } } From 0734950092d78cb79ebbc0768963cb79123ffa78 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 21:14:14 +0000 Subject: [PATCH 15/25] Remove unnecessary connection rejection test --- .../BasisConnectionRejectionTests.cs | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs diff --git a/Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs b/Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs deleted file mode 100644 index 904cf0017b..0000000000 --- a/Basis Server/BasisServerTests/BasisConnectionRejectionTests.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Basis.Network.Core; -using LiteNetDataReader = LiteNetLib.Utils.NetDataReader; -using LiteNetDataWriter = LiteNetLib.Utils.NetDataWriter; -using Xunit; - -namespace BasisServerTests; - -public sealed class BasisConnectionRejectionTests -{ - [Fact] - public void AuthenticationRejectedReason_UsesBareStringPayload() - { - LiteNetDataWriter writer = new LiteNetDataWriter(); - writer.Put(BasisNetworkCommons.AuthenticationRejectedReason); - LiteNetDataReader reader = new LiteNetDataReader(writer); - - Assert.Equal("Authentication failed, Auth rejected", reader.PeekString()); - } -} From c0e096c51f743e16143ba4895b9889fde993a475 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 21:33:16 +0000 Subject: [PATCH 16/25] Add LAN discovery translations --- .../BasisUI/Localization/Languages/ar.json | 6 ++++++ .../BasisUI/Localization/Languages/bn.json | 6 ++++++ .../BasisUI/Localization/Languages/de.json | 6 ++++++ .../BasisUI/Localization/Languages/es-MX.json | 6 ++++++ .../BasisUI/Localization/Languages/es.json | 6 ++++++ .../BasisUI/Localization/Languages/fr.json | 6 ++++++ .../BasisUI/Localization/Languages/hi.json | 6 ++++++ .../BasisUI/Localization/Languages/it.json | 6 ++++++ .../BasisUI/Localization/Languages/ja.json | 6 ++++++ .../BasisUI/Localization/Languages/nl.json | 6 ++++++ .../BasisUI/Localization/Languages/pt.json | 6 ++++++ .../BasisUI/Localization/Languages/ru.json | 6 ++++++ .../BasisUI/Localization/Languages/ur.json | 6 ++++++ .../BasisUI/Localization/Languages/zh-CN.json | 6 ++++++ .../BasisUI/Localization/Languages/zh-Hans.json | 6 ++++++ .../BasisUI/Localization/Languages/zh-Hant.json | 6 ++++++ .../BasisUI/Localization/Languages/zh.json | 6 ++++++ 17 files changed, 102 insertions(+) diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ar.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ar.json index 4161c87f64..66023c5c79 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ar.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ar.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "طلب اتصال مباشر" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "جاري الطلب..." }, { "key": "menu.servers.source.savedServers", "value": "الخوادم المحفوظة" }, + { "key": "menu.servers.source.lanServers", "value": "خوادم LAN" }, + { "key": "menu.servers.hostShowToLan", "value": "إظهار على الشبكة المحلية" }, + { "key": "menu.servers.hostShowToLan.description", "value": "أعلن عن هذا الخادم المستضاف لعملاء Basis على شبكتك المحلية. تحاول الخوادم المحمية بكلمة مرور أولًا استخدام كلمة المرور الافتراضية القياسية، ثم تطلب منك إدخالها إذا رُفضت؛ ولا تُضمَّن كلمة المرور مطلقًا في إعلانات LAN." }, + { "key": "menu.servers.lanPassword.title", "value": "انضم إلى {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "تم رفض كلمة المرور. أدخل كلمة مرور الخادم." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "ضبط الاتصال المباشر." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "معدل مزامنة الأفاتار" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "التحديثات في الثانية إلى النظراء المباشرين — محدودة بمعدل الإطارات لديك." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/bn.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/bn.json index cd74ade5c8..765eab4a99 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/bn.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/bn.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "সরাসরি সংযোগ অনুরোধ করুন" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "অনুরোধ করা হচ্ছে..." }, { "key": "menu.servers.source.savedServers", "value": "সংরক্ষিত সার্ভার" }, + { "key": "menu.servers.source.lanServers", "value": "LAN সার্ভার" }, + { "key": "menu.servers.hostShowToLan", "value": "LAN-এ দেখান" }, + { "key": "menu.servers.hostShowToLan.description", "value": "আপনার স্থানীয় নেটওয়ার্কে Basis ক্লায়েন্টদের কাছে এই হোস্ট করা সার্ভারটির বিজ্ঞাপন দিন। পাসওয়ার্ড-সুরক্ষিত সার্ভারগুলি প্রথমে মানক ডিফল্ট পাসওয়ার্ড চেষ্টা করে, সেটি প্রত্যাখ্যাত হলে পরে পাসওয়ার্ড দিতে বলে; LAN ঘোষণায় পাসওয়ার্ড কখনও অন্তর্ভুক্ত করা হয় না।" }, + { "key": "menu.servers.lanPassword.title", "value": "{0}-এ যোগ দিন" }, + { "key": "menu.servers.lanPassword.description", "value": "পাসওয়ার্ড প্রত্যাখ্যাত হয়েছে। সার্ভারের পাসওয়ার্ড লিখুন।" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "সরাসরি সংযোগ সমন্বয়।" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "অবতার সিঙ্ক হার" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "প্রতি সেকেন্ডে সরাসরি পিয়ারদের কাছে আপডেট — আপনার ফ্রেমরেট দ্বারা সীমিত।" }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/de.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/de.json index c94d63d00d..6a75e84859 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/de.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/de.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Direktverbindung anfordern" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Anfrage läuft..." }, { "key": "menu.servers.source.savedServers", "value": "Gespeicherte Server" }, + { "key": "menu.servers.source.lanServers", "value": "LAN-Server" }, + { "key": "menu.servers.hostShowToLan", "value": "Im LAN anzeigen" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Diesen gehosteten Server für Basis-Clients in deinem lokalen Netzwerk bekannt machen. Passwortgeschützte Server versuchen zuerst das übliche Standardpasswort und fordern dich bei Ablehnung zur Eingabe auf; das Passwort wird niemals in LAN-Ankündigungen übertragen." }, + { "key": "menu.servers.lanPassword.title", "value": "{0} beitreten" }, + { "key": "menu.servers.lanPassword.description", "value": "Das Passwort wurde abgelehnt. Gib das Serverpasswort ein." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Einstellung der Direktverbindung." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Avatar-Sync-Rate" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Aktualisierungen pro Sekunde zu direkten Peers – begrenzt durch deine Bildrate." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es-MX.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es-MX.json index e4cc45459e..f822711ebd 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es-MX.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es-MX.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Solicitar conexión directa" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Solicitando..." }, { "key": "menu.servers.source.savedServers", "value": "Servidores guardados" }, + { "key": "menu.servers.source.lanServers", "value": "Servidores LAN" }, + { "key": "menu.servers.hostShowToLan", "value": "Mostrar en LAN" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Anuncia este servidor alojado a los clientes de Basis en tu red local. Los servidores protegidos con contraseña prueban primero la contraseña predeterminada estándar y, si se rechaza, te piden que la introduzcas; la contraseña nunca se incluye en los anuncios de LAN." }, + { "key": "menu.servers.lanPassword.title", "value": "Unirse a {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "La contraseña fue rechazada. Ingresa la contraseña del servidor." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Ajuste de conexión directa." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Tasa de sincronización del avatar" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Actualizaciones por segundo a pares directos: limitadas por tu tasa de fotogramas." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es.json index 1f54297f59..c72529b0ca 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/es.json @@ -2087,6 +2087,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Solicitar conexión directa" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Solicitando..." }, { "key": "menu.servers.source.savedServers", "value": "Servidores guardados" }, + { "key": "menu.servers.source.lanServers", "value": "Servidores LAN" }, + { "key": "menu.servers.hostShowToLan", "value": "Mostrar en LAN" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Anuncia este servidor alojado a los clientes de Basis en tu red local. Los servidores protegidos con contraseña prueban primero la contraseña predeterminada estándar y, si se rechaza, te piden que la introduzcas; la contraseña nunca se incluye en los anuncios de LAN." }, + { "key": "menu.servers.lanPassword.title", "value": "Unirse a {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "La contraseña fue rechazada. Introduce la contraseña del servidor." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Ajuste de conexión directa." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Frecuencia de sincronización del avatar" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Actualizaciones por segundo a pares directos: limitadas por tu tasa de fotogramas." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/fr.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/fr.json index fb55e8d5c4..e4f966d006 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/fr.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/fr.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Demander une connexion directe" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Demande en cours..." }, { "key": "menu.servers.source.savedServers", "value": "Serveurs enregistrés" }, + { "key": "menu.servers.source.lanServers", "value": "Serveurs LAN" }, + { "key": "menu.servers.hostShowToLan", "value": "Afficher sur le LAN" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Annoncez ce serveur hébergé aux clients Basis sur votre réseau local. Les serveurs protégés par un mot de passe essaient d'abord le mot de passe par défaut habituel, puis vous invitent à le saisir s'il est refusé ; le mot de passe n'est jamais inclus dans les annonces LAN." }, + { "key": "menu.servers.lanPassword.title", "value": "Rejoindre {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "Le mot de passe a été refusé. Saisissez le mot de passe du serveur." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Réglages de la connexion directe." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Fréquence de synchronisation avatar" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Mises à jour par seconde vers les pairs directs – limitées par votre fréquence d'images." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/hi.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/hi.json index a9e5166a82..ccaf971b5b 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/hi.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/hi.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "डायरेक्ट कनेक्शन का अनुरोध करें" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "अनुरोध कर रहे हैं..." }, { "key": "menu.servers.source.savedServers", "value": "सहेजे गए सर्वर" }, + { "key": "menu.servers.source.lanServers", "value": "LAN सर्वर" }, + { "key": "menu.servers.hostShowToLan", "value": "LAN पर दिखाएँ" }, + { "key": "menu.servers.hostShowToLan.description", "value": "अपने स्थानीय नेटवर्क पर Basis क्लाइंट को इस होस्ट किए गए सर्वर का विज्ञापन करें। पासवर्ड-सुरक्षित सर्वर पहले मानक डिफ़ॉल्ट पासवर्ड आज़माते हैं, और उसके अस्वीकार होने पर पासवर्ड दर्ज करने के लिए कहते हैं; LAN घोषणाओं में पासवर्ड कभी शामिल नहीं किया जाता।" }, + { "key": "menu.servers.lanPassword.title", "value": "{0} से जुड़ें" }, + { "key": "menu.servers.lanPassword.description", "value": "पासवर्ड अस्वीकार कर दिया गया। सर्वर का पासवर्ड दर्ज करें।" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "डायरेक्ट कनेक्शन समायोजन।" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "अवतार सिंक दर" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "प्रति सेकंड डायरेक्ट पीयर को अपडेट — आपके फ़्रेमरेट द्वारा सीमित।" }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/it.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/it.json index 44392a22ab..dca69cae17 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/it.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/it.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Richiedi connessione diretta" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Richiesta in corso..." }, { "key": "menu.servers.source.savedServers", "value": "Server salvati" }, + { "key": "menu.servers.source.lanServers", "value": "Server LAN" }, + { "key": "menu.servers.hostShowToLan", "value": "Mostra sulla LAN" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Pubblicizza questo server ospitato ai client Basis sulla tua rete locale. I server protetti da password provano prima la password predefinita standard, poi chiedono di inserirla se viene rifiutata; la password non viene mai inclusa negli annunci LAN." }, + { "key": "menu.servers.lanPassword.title", "value": "Unisciti a {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "La password è stata rifiutata. Inserisci la password del server." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Regolazione della connessione diretta." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Frequenza di sincronizzazione avatar" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Aggiornamenti al secondo verso i peer diretti – limitati dal tuo frame rate." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ja.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ja.json index b3743e88b3..11254a8513 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ja.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ja.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "ダイレクト接続をリクエスト" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "リクエスト中..." }, { "key": "menu.servers.source.savedServers", "value": "保存済みサーバー" }, + { "key": "menu.servers.source.lanServers", "value": "LANサーバー" }, + { "key": "menu.servers.hostShowToLan", "value": "LANに公開" }, + { "key": "menu.servers.hostShowToLan.description", "value": "このホストサーバーをローカルネットワーク上のBasisクライアントに公開します。パスワードで保護されたサーバーでは、まず標準のデフォルトパスワードを試し、拒否された場合は入力を求めます。パスワードがLAN告知に含まれることはありません。" }, + { "key": "menu.servers.lanPassword.title", "value": "{0}に参加" }, + { "key": "menu.servers.lanPassword.description", "value": "パスワードが拒否されました。サーバーのパスワードを入力してください。" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "ダイレクト接続の調整。" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "アバター同期レート" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "ダイレクトピアへの毎秒更新数 — フレームレートが上限になります。" }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/nl.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/nl.json index 0a6ad253ff..e273fd1e6f 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/nl.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/nl.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Directe verbinding aanvragen" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Aanvragen..." }, { "key": "menu.servers.source.savedServers", "value": "Opgeslagen servers" }, + { "key": "menu.servers.source.lanServers", "value": "LAN-servers" }, + { "key": "menu.servers.hostShowToLan", "value": "Op LAN tonen" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Adverteer deze gehoste server aan Basis-clients op je lokale netwerk. Servers met wachtwoord proberen eerst het standaardwachtwoord en vragen je daarna om het wachtwoord als dat wordt geweigerd; het wachtwoord wordt nooit opgenomen in LAN-aankondigingen." }, + { "key": "menu.servers.lanPassword.title", "value": "Deelnemen aan {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "Het wachtwoord is geweigerd. Voer het serverwachtwoord in." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Afstemming directe verbinding." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Avatar-synchronisatiefrequentie" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Updates per seconde naar directe peers – begrensd door je framerate." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/pt.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/pt.json index bc1b564cda..9a4256d5c6 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/pt.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/pt.json @@ -2087,6 +2087,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Solicitar conexão direta" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Solicitando..." }, { "key": "menu.servers.source.savedServers", "value": "Servidores salvos" }, + { "key": "menu.servers.source.lanServers", "value": "Servidores LAN" }, + { "key": "menu.servers.hostShowToLan", "value": "Mostrar na LAN" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Anuncie este servidor hospedado aos clientes Basis na sua rede local. Servidores protegidos por senha tentam primeiro a senha padrão e, se ela for rejeitada, solicitam que você a insira; a senha nunca é incluída nos anúncios da LAN." }, + { "key": "menu.servers.lanPassword.title", "value": "Entrar em {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "A senha foi rejeitada. Digite a senha do servidor." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Ajustes da conexão direta." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Taxa de sincronização do avatar" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Atualizações por segundo aos pares diretos – limitadas pela sua taxa de quadros." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ru.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ru.json index 885f7d9782..29098a4653 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ru.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ru.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "Запросить прямое соединение" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "Запрос..." }, { "key": "menu.servers.source.savedServers", "value": "Сохранённые серверы" }, + { "key": "menu.servers.source.lanServers", "value": "LAN-серверы" }, + { "key": "menu.servers.hostShowToLan", "value": "Показывать в LAN" }, + { "key": "menu.servers.hostShowToLan.description", "value": "Объявляйте этот сервер клиентам Basis в вашей локальной сети. Серверы, защищённые паролем, сначала пробуют стандартный пароль по умолчанию, затем запрашивают его, если он отклонён; пароль никогда не включается в объявления LAN." }, + { "key": "menu.servers.lanPassword.title", "value": "Подключиться к {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "Пароль отклонён. Введите пароль сервера." }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "Настройка прямого соединения." }, { "key": "settings.general.networking.p2pAvatarRate", "value": "Частота синхронизации аватара" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "Обновлений в секунду к прямым пирам — ограничено вашей частотой кадров." }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ur.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ur.json index 1c30716ee8..5148bd0e6d 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ur.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/ur.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "براہ راست کنکشن کی درخواست کریں" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "درخواست جاری ہے..." }, { "key": "menu.servers.source.savedServers", "value": "محفوظ شدہ سرورز" }, + { "key": "menu.servers.source.lanServers", "value": "LAN سرورز" }, + { "key": "menu.servers.hostShowToLan", "value": "LAN پر دکھائیں" }, + { "key": "menu.servers.hostShowToLan.description", "value": "اپنے مقامی نیٹ ورک پر Basis کلائنٹس کو اس سرور کا اعلان کریں۔ پاس ورڈ سے محفوظ سرور پہلے معیاری ڈیفالٹ پاس ورڈ آزماتے ہیں، پھر اسے مسترد کیے جانے پر پاس ورڈ درج کرنے کا کہتے ہیں؛ پاس ورڈ LAN اعلانات میں کبھی شامل نہیں کیا جاتا۔" }, + { "key": "menu.servers.lanPassword.title", "value": "{0} میں شامل ہوں" }, + { "key": "menu.servers.lanPassword.description", "value": "پاس ورڈ مسترد کر دیا گیا۔ سرور کا پاس ورڈ درج کریں۔" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "براہ راست کنکشن کی ٹیوننگ۔" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "اوتار سنک شرح" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "براہ راست پیئرز کو فی سیکنڈ اپڈیٹس — آپ کے فریم ریٹ سے محدود۔" }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-CN.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-CN.json index baac4cf921..b77cddfc0c 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-CN.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-CN.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "请求直接连接" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "请求中..." }, { "key": "menu.servers.source.savedServers", "value": "已保存的服务器" }, + { "key": "menu.servers.source.lanServers", "value": "LAN 服务器" }, + { "key": "menu.servers.hostShowToLan", "value": "在 LAN 上显示" }, + { "key": "menu.servers.hostShowToLan.description", "value": "将此托管服务器公布给本地网络上的 Basis 客户端。受密码保护的服务器会先尝试标准默认密码,如果被拒绝则提示输入密码;密码绝不会包含在 LAN 公告中。" }, + { "key": "menu.servers.lanPassword.title", "value": "加入 {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "密码被拒绝。请输入服务器密码。" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "直接连接调节。" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "虚拟形象同步速率" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "每秒发送给直接对等方的更新次数 — 受你的帧率限制。" }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hans.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hans.json index 7cb28b2057..ee3ca887ed 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hans.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hans.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "请求直接连接" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "请求中..." }, { "key": "menu.servers.source.savedServers", "value": "已保存的服务器" }, + { "key": "menu.servers.source.lanServers", "value": "LAN 服务器" }, + { "key": "menu.servers.hostShowToLan", "value": "在 LAN 上显示" }, + { "key": "menu.servers.hostShowToLan.description", "value": "将此托管服务器公布给本地网络上的 Basis 客户端。受密码保护的服务器会先尝试标准默认密码,如果被拒绝则提示输入密码;密码绝不会包含在 LAN 公告中。" }, + { "key": "menu.servers.lanPassword.title", "value": "加入 {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "密码被拒绝。请输入服务器密码。" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "直接连接调节。" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "虚拟形象同步速率" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "每秒发送给直接对等方的更新次数 — 受你的帧率限制。" }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hant.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hant.json index 08e58d806a..a407c775e6 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hant.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh-Hant.json @@ -2135,6 +2135,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "請求直接連線" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "請求中..." }, { "key": "menu.servers.source.savedServers", "value": "已儲存的伺服器" }, + { "key": "menu.servers.source.lanServers", "value": "LAN 伺服器" }, + { "key": "menu.servers.hostShowToLan", "value": "在 LAN 上顯示" }, + { "key": "menu.servers.hostShowToLan.description", "value": "將此託管伺服器公告給區域網路上的 Basis 用戶端。受密碼保護的伺服器會先嘗試標準預設密碼,若遭拒絕則提示輸入密碼;密碼絕不會包含在 LAN 公告中。" }, + { "key": "menu.servers.lanPassword.title", "value": "加入 {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "密碼遭拒絕。請輸入伺服器密碼。" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "直接連線調整。" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "虛擬形象同步率" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "每秒傳送給直接對等端的更新次數 — 受你的影格率限制。" }, diff --git a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh.json index dad0b6c502..63c0962627 100644 --- a/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh.json +++ b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/zh.json @@ -2087,6 +2087,12 @@ { "key": "menu.individualPlayer.directConnection.request", "value": "请求直接连接" }, { "key": "menu.individualPlayer.directConnection.requesting", "value": "请求中..." }, { "key": "menu.servers.source.savedServers", "value": "已保存的服务器" }, + { "key": "menu.servers.source.lanServers", "value": "LAN 服务器" }, + { "key": "menu.servers.hostShowToLan", "value": "在 LAN 上显示" }, + { "key": "menu.servers.hostShowToLan.description", "value": "将此托管服务器公布给本地网络上的 Basis 客户端。受密码保护的服务器会先尝试标准默认密码,如果被拒绝则提示输入密码;密码绝不会包含在 LAN 公告中。" }, + { "key": "menu.servers.lanPassword.title", "value": "加入 {0}" }, + { "key": "menu.servers.lanPassword.description", "value": "密码被拒绝。请输入服务器密码。" }, + { "key": "menu.servers.list.lanBadge", "value": "{0} (LAN)" }, { "key": "settings.general.networking.description", "value": "直接连接调节。" }, { "key": "settings.general.networking.p2pAvatarRate", "value": "虚拟形象同步速率" }, { "key": "settings.general.networking.p2pAvatarRate.description", "value": "每秒发送给直接对等方的更新次数 — 受你的帧率限制。" }, From d0b1ed30a5469b5ad3a6c3b39555d490f9ff9087 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 21:50:01 +0000 Subject: [PATCH 17/25] Keep LAN directory singleton assignable --- .../com.basis.framework/Networking/LanServersDirectorySource.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs index c6a6b06431..64d8338c0d 100644 --- a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs @@ -47,7 +47,7 @@ private sealed class DiscoveredServer private int _notificationQueued; private volatile bool _disposed; - public static LanServersDirectorySource Instance { get; private set; } + public static LanServersDirectorySource Instance; public string SourceId => Id; public string DisplayName => Basis.BasisUI.BasisLocalization.Get("menu.servers.source.lanServers"); From a663d63c195c515bdced96d033410ed1f8fbff0e Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 15 Jul 2026 22:09:12 +0000 Subject: [PATCH 18/25] Tag LAN networking logs --- .../Networking/BasisConnectionService.cs | 2 +- .../Networking/BasisNetworkEvents.cs | 2 +- .../Networking/LanServersDirectorySource.cs | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs index e72b15de43..c0b6690083 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs @@ -115,7 +115,7 @@ internal static bool TryHandleLanAuthenticationRejected() } catch (Exception ex) { - BasisDebug.LogError($"LanPasswordRequired handler threw: {ex.Message}"); + BasisDebug.LogError($"LanPasswordRequired handler threw: {ex.Message}", BasisDebug.LogTag.Networking); return false; } } diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs index db0ff11732..945eedbf8a 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs @@ -962,7 +962,7 @@ public static void HandleDisconnectionReason(DisconnectInfo disconnectInfo) && string.Equals(reason, BasisNetworkCommons.AuthenticationRejectedReason, StringComparison.Ordinal) && BasisConnectionService.TryHandleLanAuthenticationRejected()) { - BasisDebug.LogWarning("LAN server rejected authentication; requesting a password from the user."); + BasisDebug.LogWarning("LAN server rejected authentication; requesting a password from the user.", BasisDebug.LogTag.Networking); return; } title = rejected ? "Connection Rejected" : "Server Disconnected"; diff --git a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs index 64d8338c0d..4d5110b423 100644 --- a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs @@ -134,7 +134,7 @@ private void StartListening() } catch (Exception ex) { - BasisDebug.LogWarning($"LAN DNS-SD discovery could not start: {ex.Message}"); + BasisDebug.LogWarning($"LAN DNS-SD discovery could not start: {ex.Message}", BasisDebug.LogTag.Networking); ReleaseAndroidMulticastLock(); } } @@ -156,7 +156,7 @@ private void AcquireAndroidMulticastLock() { _androidMulticastLock?.Dispose(); _androidMulticastLock = null; - BasisDebug.LogWarning($"Could not acquire Android LAN discovery multicast lock: {ex.Message}"); + BasisDebug.LogWarning($"Could not acquire Android LAN discovery multicast lock: {ex.Message}", BasisDebug.LogTag.Networking); } #endif } @@ -178,7 +178,7 @@ private void ReleaseAndroidMulticastLock() } catch (Exception ex) { - BasisDebug.LogWarning($"Could not release Android LAN discovery multicast lock: {ex.Message}"); + BasisDebug.LogWarning($"Could not release Android LAN discovery multicast lock: {ex.Message}", BasisDebug.LogTag.Networking); } finally { @@ -392,7 +392,7 @@ private void QueueSourceChanged() } try { SourceChanged?.Invoke(); } - catch (Exception ex) { BasisDebug.LogError($"LanServersDirectorySource.SourceChanged threw: {ex.Message}"); } + catch (Exception ex) { BasisDebug.LogError($"LanServersDirectorySource.SourceChanged threw: {ex.Message}", BasisDebug.LogTag.Networking); } }); } @@ -407,7 +407,7 @@ public void Dispose() try { _cancellation.Cancel(); } catch (ObjectDisposedException) { } try { _browser?.Dispose(); } - catch (Exception ex) { BasisDebug.LogWarning($"LAN DNS-SD discovery shutdown failed: {ex.Message}"); } + catch (Exception ex) { BasisDebug.LogWarning($"LAN DNS-SD discovery shutdown failed: {ex.Message}", BasisDebug.LogTag.Networking); } _browser = null; ReleaseAndroidMulticastLock(); _cancellation.Dispose(); From 50909cf6eb5f0c51ad0c11782b93bc2f9ce2f026 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Thu, 16 Jul 2026 01:19:38 +0000 Subject: [PATCH 19/25] Fix Android LAN discovery connectivity --- .../BasisNetworkCore/BasisLanServerBrowser.cs | 50 +++++++++++---- .../BasisLanDiscoveryTests.cs | 61 +++++++++++++++++-- .../Networking/LanServersDirectorySource.cs | 10 +++ .../BasisNetworkCore/BasisLanServerBrowser.cs | 50 +++++++++++---- 4 files changed, 145 insertions(+), 26 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index 6a512c740b..c9c05f83fe 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -25,29 +25,44 @@ public sealed class BasisLanServerBrowser : IDisposable private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); private readonly Action _found; private readonly Action _removed; + private MulticastService _mdns; private ServiceDiscovery _discovery; private volatile bool _disposed; public BasisLanServerBrowser( Action found, - Action removed) + Action removed, + bool useIpv6 = true) { _found = found ?? throw new ArgumentNullException(nameof(found)); _removed = removed ?? throw new ArgumentNullException(nameof(removed)); + MulticastService mdns = null; + ServiceDiscovery discovery = null; try { - _discovery = new ServiceDiscovery(); - _discovery.Mdns.IgnoreDuplicateMessages = true; - _discovery.ServiceInstanceDiscovered += OnServiceDiscovered; - _discovery.ServiceInstanceShutdown += OnServiceShutdown; + mdns = new MulticastService + { + UseIpv4 = Socket.OSSupportsIPv4, + UseIpv6 = useIpv6 && Socket.OSSupportsIPv6, + IgnoreDuplicateMessages = true, + }; + discovery = new ServiceDiscovery(mdns); + discovery.ServiceInstanceDiscovered += OnServiceDiscovered; + discovery.ServiceInstanceShutdown += OnServiceShutdown; + + _mdns = mdns; + _discovery = discovery; + mdns.Start(); Query(); _ = Task.Run(() => QueryLoopAsync(_cancellation.Token)); } catch { - _discovery?.Dispose(); + discovery?.Dispose(); + mdns?.Dispose(); _discovery = null; + _mdns = null; _cancellation.Dispose(); throw; } @@ -273,11 +288,6 @@ private static IPAddress SelectAddress( DomainName hostName, IPAddress remoteAddress) { - if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) - { - return remoteAddress; - } - IPAddress selected = null; foreach (ResourceRecord record in records) { @@ -296,6 +306,20 @@ private static IPAddress SelectAddress( selected = candidate; } } + + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) + { + // The IPv4 packet source is the interface that actually reached us, so it is + // safer than unrelated VPN/cellular A records. Android can emit mDNS over IPv6 + // while its hosted LiteNetLib socket is only reachable over Wi-Fi IPv4; in that + // case prefer the advertised IPv4 address. + if (remoteAddress.AddressFamily == AddressFamily.InterNetwork + || selected?.AddressFamily != AddressFamily.InterNetwork) + { + return remoteAddress; + } + } + return selected; } @@ -316,6 +340,7 @@ private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress public void Dispose() { + MulticastService mdns; ServiceDiscovery discovery; lock (_gate) { @@ -329,7 +354,9 @@ public void Dispose() catch (ObjectDisposedException) { } discovery = _discovery; + mdns = _mdns; _discovery = null; + _mdns = null; if (discovery != null) { discovery.ServiceInstanceDiscovered -= OnServiceDiscovered; @@ -338,6 +365,7 @@ public void Dispose() } discovery?.Dispose(); + mdns?.Dispose(); _cancellation.Dispose(); } } diff --git a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs index 4ae457e29e..d4500109aa 100644 --- a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs +++ b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs @@ -88,13 +88,55 @@ public void EmptyAddressRecords_UseResponseSourceAddress() string.Empty, false, Array.Empty()); + IPAddress responseSource = IPAddress.Parse("192.168.1.25"); - BasisLanAdvertisement advertisement = Extract(profile); + BasisLanAdvertisement advertisement = Extract(profile, responseSource, out IPAddress address); Assert.Equal(id, advertisement.InstanceId); + Assert.Equal(responseSource, address); Assert.DoesNotContain(profile.Resources, resource => resource is AddressRecord); } + [Fact] + public void AdvertisedIpv4_IsPreferredOverIpv6ResponseSource() + { + IPAddress advertisedIpv4 = IPAddress.Parse("192.168.1.25"); + IPAddress responseSource = new IPAddress( + IPAddress.Parse("fe80::1234").GetAddressBytes(), + 7); + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Android host", + string.Empty, + false, + new[] { advertisedIpv4 }); + + Extract(profile, responseSource, out IPAddress address); + + Assert.Equal(advertisedIpv4, address); + } + + [Fact] + public void Ipv4ResponseSource_IsPreferredOverOtherAdvertisedIpv4() + { + IPAddress advertisedIpv4 = IPAddress.Parse("10.0.0.5"); + IPAddress responseSource = IPAddress.Parse("192.168.1.25"); + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Wi-Fi host", + string.Empty, + false, + new[] { advertisedIpv4 }); + + Extract(profile, responseSource, out IPAddress address); + + Assert.Equal(responseSource, address); + } + [Fact] public void AddressPreference_PrioritizesRoutableAddressesOverLinkLocal() { @@ -131,6 +173,17 @@ private static ServiceProfile CreateProfile( } private static BasisLanAdvertisement Extract(ServiceProfile profile) + { + IPAddress expectedAddress = IPAddress.Parse("192.168.1.25"); + BasisLanAdvertisement advertisement = Extract(profile, expectedAddress, out IPAddress address); + Assert.Equal(expectedAddress, address); + return advertisement; + } + + private static BasisLanAdvertisement Extract( + ServiceProfile profile, + IPAddress responseSource, + out IPAddress address) { Message message = new Message(); foreach (ResourceRecord resource in profile.Resources) @@ -148,10 +201,10 @@ private static BasisLanAdvertisement Extract(ServiceProfile profile) Assert.True(BasisLanServerBrowser.TryExtractAdvertisement( message, profile.FullyQualifiedName, - IPAddress.Parse("192.168.1.25"), + responseSource, out BasisLanAdvertisement advertisement, - out IPAddress? address)); - Assert.Equal(IPAddress.Parse("192.168.1.25"), address); + out IPAddress? selectedAddress)); + address = Assert.IsType(selectedAddress); return advertisement; } diff --git a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs index 4d5110b423..d38dcd9f6e 100644 --- a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs @@ -129,7 +129,17 @@ private void StartListening() AcquireAndroidMulticastLock(); try { +#if UNITY_ANDROID && !UNITY_EDITOR + // MeaMod's IPv6 multicast receiver can fail during construction on Android and + // prevent its IPv4 receiver from starting. LAN-hosted Basis servers already + // advertise IPv4 addresses, so browse over IPv4 on Android. + _browser = new BasisLanServerBrowser( + ProcessAdvertisement, + RemoveAdvertisement, + useIpv6: false); +#else _browser = new BasisLanServerBrowser(ProcessAdvertisement, RemoveAdvertisement); +#endif _ = Task.Run(() => CleanupLoopAsync(_cancellation.Token)); } catch (Exception ex) diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index 6a512c740b..c9c05f83fe 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -25,29 +25,44 @@ public sealed class BasisLanServerBrowser : IDisposable private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); private readonly Action _found; private readonly Action _removed; + private MulticastService _mdns; private ServiceDiscovery _discovery; private volatile bool _disposed; public BasisLanServerBrowser( Action found, - Action removed) + Action removed, + bool useIpv6 = true) { _found = found ?? throw new ArgumentNullException(nameof(found)); _removed = removed ?? throw new ArgumentNullException(nameof(removed)); + MulticastService mdns = null; + ServiceDiscovery discovery = null; try { - _discovery = new ServiceDiscovery(); - _discovery.Mdns.IgnoreDuplicateMessages = true; - _discovery.ServiceInstanceDiscovered += OnServiceDiscovered; - _discovery.ServiceInstanceShutdown += OnServiceShutdown; + mdns = new MulticastService + { + UseIpv4 = Socket.OSSupportsIPv4, + UseIpv6 = useIpv6 && Socket.OSSupportsIPv6, + IgnoreDuplicateMessages = true, + }; + discovery = new ServiceDiscovery(mdns); + discovery.ServiceInstanceDiscovered += OnServiceDiscovered; + discovery.ServiceInstanceShutdown += OnServiceShutdown; + + _mdns = mdns; + _discovery = discovery; + mdns.Start(); Query(); _ = Task.Run(() => QueryLoopAsync(_cancellation.Token)); } catch { - _discovery?.Dispose(); + discovery?.Dispose(); + mdns?.Dispose(); _discovery = null; + _mdns = null; _cancellation.Dispose(); throw; } @@ -273,11 +288,6 @@ private static IPAddress SelectAddress( DomainName hostName, IPAddress remoteAddress) { - if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) - { - return remoteAddress; - } - IPAddress selected = null; foreach (ResourceRecord record in records) { @@ -296,6 +306,20 @@ private static IPAddress SelectAddress( selected = candidate; } } + + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) + { + // The IPv4 packet source is the interface that actually reached us, so it is + // safer than unrelated VPN/cellular A records. Android can emit mDNS over IPv6 + // while its hosted LiteNetLib socket is only reachable over Wi-Fi IPv4; in that + // case prefer the advertised IPv4 address. + if (remoteAddress.AddressFamily == AddressFamily.InterNetwork + || selected?.AddressFamily != AddressFamily.InterNetwork) + { + return remoteAddress; + } + } + return selected; } @@ -316,6 +340,7 @@ private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress public void Dispose() { + MulticastService mdns; ServiceDiscovery discovery; lock (_gate) { @@ -329,7 +354,9 @@ public void Dispose() catch (ObjectDisposedException) { } discovery = _discovery; + mdns = _mdns; _discovery = null; + _mdns = null; if (discovery != null) { discovery.ServiceInstanceDiscovered -= OnServiceDiscovered; @@ -338,6 +365,7 @@ public void Dispose() } discovery?.Dispose(); + mdns?.Dispose(); _cancellation.Dispose(); } } From f2ea11ddc16bcf255cc4bf964f6813f049dc183a Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Thu, 16 Jul 2026 01:22:16 +0000 Subject: [PATCH 20/25] Request unicast LAN discovery replies --- Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs | 3 +++ .../com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index c9c05f83fe..f91bde4249 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -83,6 +83,9 @@ public void Query() try { discovery?.QueryServiceInstances(ServiceName); + // Some access points suppress multicast toward Wi-Fi clients. A QU query still + // reaches responders through multicast but asks them to return the answer unicast. + discovery?.QueryUnicastServiceInstances(ServiceName); } catch (ObjectDisposedException) { diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index c9c05f83fe..f91bde4249 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -83,6 +83,9 @@ public void Query() try { discovery?.QueryServiceInstances(ServiceName); + // Some access points suppress multicast toward Wi-Fi clients. A QU query still + // reaches responders through multicast but asks them to return the answer unicast. + discovery?.QueryUnicastServiceInstances(ServiceName); } catch (ObjectDisposedException) { From ebede91231ff1c9ed7a192583dbe5aa2f4fd2044 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Thu, 16 Jul 2026 04:46:04 +0000 Subject: [PATCH 21/25] Prefer reachable LAN discovery addresses --- .../BasisLanDiscoveryProtocol.cs | 145 ++++++++++++++++++ .../BasisLanServerAnnouncer.cs | 15 +- .../BasisNetworkCore/BasisLanServerBrowser.cs | 70 +++++++-- .../BasisLanDiscoveryTests.cs | 100 ++++++++++-- .../BasisLanDiscoveryProtocol.cs | 145 ++++++++++++++++++ .../BasisLanServerAnnouncer.cs | 15 +- .../BasisNetworkCore/BasisLanServerBrowser.cs | 70 +++++++-- 7 files changed, 504 insertions(+), 56 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 4c0bae5792..2585ac0e67 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -1,7 +1,9 @@ using MeaMod.DNS.Multicast; using System; using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text; @@ -34,6 +36,18 @@ public BasisLanAdvertisement( } } + internal readonly struct BasisLanIpv4Subnet + { + public readonly IPAddress Address; + public readonly IPAddress Mask; + + public BasisLanIpv4Subnet(IPAddress address, IPAddress mask) + { + Address = address; + Mask = mask; + } + } + /// Shared address filtering and preference rules for Basis LAN discovery. public static class BasisLanAddressUtility { @@ -69,6 +83,137 @@ public static int PreferenceRank(IPAddress address) ? address.IsIPv6LinkLocal ? 3 : 1 : int.MaxValue; } + + internal static IPAddress[] GetPreferredAdvertisedAddresses() + { + List gatewayAddresses = new List(); + List fallbackAddresses = new List(); + + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + { + continue; + } + + try + { + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + bool hasUsableGateway = properties.GatewayAddresses + .Any(gateway => IsUsable(gateway?.Address)); + + foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses) + { + if (!IsUsable(unicast?.Address)) + { + continue; + } + + fallbackAddresses.Add(unicast.Address); + if (hasUsableGateway) + { + gatewayAddresses.Add(unicast.Address); + } + } + } + catch + { + // Ignore interfaces that disappear or reject property queries mid-enumeration. + } + } + } + catch + { + // Fall through to an empty set; the DNS-SD response source remains usable as a fallback. + } + + List selected = gatewayAddresses.Count > 0 + ? gatewayAddresses + : fallbackAddresses; + return selected + .Distinct() + .OrderBy(PreferenceRank) + .ToArray(); + } + + internal static BasisLanIpv4Subnet[] GetLocalIpv4Subnets() + { + List gatewaySubnets = new List(); + List fallbackSubnets = new List(); + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + { + continue; + } + + try + { + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + bool hasUsableGateway = properties.GatewayAddresses + .Any(gateway => IsUsable(gateway?.Address)); + foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses) + { + if (unicast?.Address?.AddressFamily != AddressFamily.InterNetwork + || unicast.IPv4Mask?.AddressFamily != AddressFamily.InterNetwork + || !IsUsable(unicast.Address)) + { + continue; + } + + BasisLanIpv4Subnet subnet = + new BasisLanIpv4Subnet(unicast.Address, unicast.IPv4Mask); + fallbackSubnets.Add(subnet); + if (hasUsableGateway) + { + gatewaySubnets.Add(subnet); + } + } + } + catch + { + // Ignore interfaces that disappear or reject property queries mid-enumeration. + } + } + } + catch + { + } + return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray(); + } + + internal static bool IsOnSameIpv4Subnet( + IPAddress candidate, + BasisLanIpv4Subnet subnet) + { + if (candidate?.AddressFamily != AddressFamily.InterNetwork + || subnet.Address?.AddressFamily != AddressFamily.InterNetwork + || subnet.Mask?.AddressFamily != AddressFamily.InterNetwork) + { + return false; + } + + byte[] candidateBytes = candidate.GetAddressBytes(); + byte[] localBytes = subnet.Address.GetAddressBytes(); + byte[] maskBytes = subnet.Mask.GetAddressBytes(); + for (int index = 0; index < candidateBytes.Length; index++) + { + if ((candidateBytes[index] & maskBytes[index]) + != (localBytes[index] & maskBytes[index])) + { + return false; + } + } + return true; + } } /// Shared constants and bounded TXT metadata encoding for Basis LAN DNS-SD. diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 3cde391a36..fbbeffe256 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -1,7 +1,6 @@ using MeaMod.DNS.Model; using MeaMod.DNS.Multicast; using System; -using System.Linq; using System.Net; namespace Basis.Network.Core @@ -105,18 +104,12 @@ internal static ServiceProfile CreateProfile( private static IPAddress[] GetAdvertisedAddresses() { - try - { - return MulticastService.GetIPAddresses() - .Where(address => BasisLanAddressUtility.IsUsable(address)) - .Distinct() - .ToArray(); - } - catch (Exception ex) + IPAddress[] addresses = BasisLanAddressUtility.GetPreferredAdvertisedAddresses(); + if (addresses.Length == 0) { - BNL.LogWarning($"Basis LAN address discovery failed: {ex.Message}"); - return Array.Empty(); + BNL.LogWarning("Basis LAN address discovery found no usable local addresses."); } + return addresses; } public void Dispose() diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index f91bde4249..17cc910744 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -25,6 +25,7 @@ public sealed class BasisLanServerBrowser : IDisposable private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); private readonly Action _found; private readonly Action _removed; + private readonly BasisLanIpv4Subnet[] _localIpv4Subnets; private MulticastService _mdns; private ServiceDiscovery _discovery; private volatile bool _disposed; @@ -36,6 +37,7 @@ public BasisLanServerBrowser( { _found = found ?? throw new ArgumentNullException(nameof(found)); _removed = removed ?? throw new ArgumentNullException(nameof(removed)); + _localIpv4Subnets = BasisLanAddressUtility.GetLocalIpv4Subnets(); MulticastService mdns = null; ServiceDiscovery discovery = null; @@ -136,7 +138,8 @@ private void OnServiceDiscovered(object sender, ServiceInstanceDiscoveryEventArg args.ServiceInstanceName, args.RemoteEndPoint?.Address, out BasisLanAdvertisement advertisement, - out IPAddress address)) + out IPAddress address, + _localIpv4Subnets)) { _found(advertisement, address); } @@ -160,7 +163,8 @@ internal static bool TryExtractAdvertisement( DomainName serviceInstanceName, IPAddress remoteAddress, out BasisLanAdvertisement advertisement, - out IPAddress address) + out IPAddress address, + IReadOnlyList localIpv4Subnets = null) { advertisement = default; address = null; @@ -206,7 +210,7 @@ internal static bool TryExtractAdvertisement( return false; } - address = SelectAddress(records, service.Target, remoteAddress); + address = SelectAddress(records, service.Target, remoteAddress, localIpv4Subnets); if (address == null) { return false; @@ -289,9 +293,18 @@ private static Dictionary ReadProperties(IList values) private static IPAddress SelectAddress( IEnumerable records, DomainName hostName, - IPAddress remoteAddress) + IPAddress remoteAddress, + IReadOnlyList localIpv4Subnets) { + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) + && remoteAddress.AddressFamily == AddressFamily.InterNetwork) + { + // A usable IPv4 response source is the interface that actually reached us. + return remoteAddress; + } + IPAddress selected = null; + bool selectedOnLocalSubnet = false; foreach (ResourceRecord record in records) { if (!(record is AddressRecord addressRecord) @@ -302,22 +315,33 @@ private static IPAddress SelectAddress( } IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); + bool candidateOnLocalSubnet = IsOnLocalIpv4Subnet(candidate, localIpv4Subnets); if (selected == null - || BasisLanAddressUtility.PreferenceRank(candidate) - < BasisLanAddressUtility.PreferenceRank(selected)) + || (candidateOnLocalSubnet && !selectedOnLocalSubnet) + || (candidateOnLocalSubnet == selectedOnLocalSubnet + && BasisLanAddressUtility.PreferenceRank(candidate) + < BasisLanAddressUtility.PreferenceRank(selected))) { selected = candidate; + selectedOnLocalSubnet = candidateOnLocalSubnet; } } + if (selected?.AddressFamily == AddressFamily.InterNetwork + && localIpv4Subnets?.Count > 0 + && !selectedOnLocalSubnet) + { + // Do not create an entry from a partial response that only contains an unrelated + // virtual/VPN subnet. A later complete response can provide the same-subnet address. + selected = null; + } + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) { - // The IPv4 packet source is the interface that actually reached us, so it is - // safer than unrelated VPN/cellular A records. Android can emit mDNS over IPv6 - // while its hosted LiteNetLib socket is only reachable over Wi-Fi IPv4; in that - // case prefer the advertised IPv4 address. - if (remoteAddress.AddressFamily == AddressFamily.InterNetwork - || selected?.AddressFamily != AddressFamily.InterNetwork) + // Android can receive an IPv6 mDNS response while the hosted LiteNetLib server + // is reachable over IPv4. Prefer a same-subnet advertised IPv4 in that case. + if (!selectedOnLocalSubnet + && selected?.AddressFamily != AddressFamily.InterNetwork) { return remoteAddress; } @@ -326,6 +350,28 @@ private static IPAddress SelectAddress( return selected; } + private static bool IsOnLocalIpv4Subnet( + IPAddress candidate, + IReadOnlyList localIpv4Subnets) + { + if (candidate?.AddressFamily != AddressFamily.InterNetwork + || localIpv4Subnets == null) + { + return false; + } + + for (int index = 0; index < localIpv4Subnets.Count; index++) + { + if (BasisLanAddressUtility.IsOnSameIpv4Subnet( + candidate, + localIpv4Subnets[index])) + { + return true; + } + } + return false; + } + private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress) { if (address != null diff --git a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs index d4500109aa..ec25675aa9 100644 --- a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs +++ b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs @@ -137,6 +137,78 @@ public void Ipv4ResponseSource_IsPreferredOverOtherAdvertisedIpv4() Assert.Equal(responseSource, address); } + [Fact] + public void SameSubnetIpv4_IsPreferredOverHyperVAddress() + { + IPAddress hyperVAddress = IPAddress.Parse("172.24.32.1"); + IPAddress lanAddress = IPAddress.Parse("192.168.50.10"); + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Multi-homed PC", + string.Empty, + false, + new[] { hyperVAddress, lanAddress }); + BasisLanIpv4Subnet[] localSubnets = + { + new BasisLanIpv4Subnet( + IPAddress.Parse("192.168.50.25"), + IPAddress.Parse("255.255.255.0")), + }; + + Extract( + profile, + IPAddress.Parse("224.0.0.251"), + out IPAddress address, + localSubnets); + + Assert.Equal(lanAddress, address); + } + + [Fact] + public void UnrelatedOnlyIpv4_IsIgnoredWhenLocalSubnetIsKnown() + { + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Partial Hyper-V response", + string.Empty, + false, + new[] { IPAddress.Parse("172.24.32.1") }); + BasisLanIpv4Subnet[] localSubnets = + { + new BasisLanIpv4Subnet( + IPAddress.Parse("192.168.50.25"), + IPAddress.Parse("255.255.255.0")), + }; + Message message = CreateMessage(profile); + + Assert.False(BasisLanServerBrowser.TryExtractAdvertisement( + message, + profile.FullyQualifiedName, + IPAddress.Parse("224.0.0.251"), + out _, + out _, + localSubnets)); + } + + [Fact] + public void SubnetMatch_UsesReportedMask() + { + BasisLanIpv4Subnet subnet = new BasisLanIpv4Subnet( + IPAddress.Parse("10.20.31.40"), + IPAddress.Parse("255.255.240.0")); + + Assert.True(BasisLanAddressUtility.IsOnSameIpv4Subnet( + IPAddress.Parse("10.20.16.5"), + subnet)); + Assert.False(BasisLanAddressUtility.IsOnSameIpv4Subnet( + IPAddress.Parse("10.20.32.5"), + subnet)); + } + [Fact] public void AddressPreference_PrioritizesRoutableAddressesOverLinkLocal() { @@ -183,7 +255,23 @@ private static BasisLanAdvertisement Extract(ServiceProfile profile) private static BasisLanAdvertisement Extract( ServiceProfile profile, IPAddress responseSource, - out IPAddress address) + out IPAddress address, + IReadOnlyList? localIpv4Subnets = null) + { + Message message = CreateMessage(profile); + + Assert.True(BasisLanServerBrowser.TryExtractAdvertisement( + message, + profile.FullyQualifiedName, + responseSource, + out BasisLanAdvertisement advertisement, + out IPAddress? selectedAddress, + localIpv4Subnets)); + address = Assert.IsType(selectedAddress); + return advertisement; + } + + private static Message CreateMessage(ServiceProfile profile) { Message message = new Message(); foreach (ResourceRecord resource in profile.Resources) @@ -197,15 +285,7 @@ private static BasisLanAdvertisement Extract( message.AdditionalRecords.Add(resource); } } - - Assert.True(BasisLanServerBrowser.TryExtractAdvertisement( - message, - profile.FullyQualifiedName, - responseSource, - out BasisLanAdvertisement advertisement, - out IPAddress? selectedAddress)); - address = Assert.IsType(selectedAddress); - return advertisement; + return message; } private static Dictionary ReadProperties(ServiceProfile profile) diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 4c0bae5792..2585ac0e67 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -1,7 +1,9 @@ using MeaMod.DNS.Multicast; using System; using System.Collections.Generic; +using System.Linq; using System.Net; +using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text; @@ -34,6 +36,18 @@ public BasisLanAdvertisement( } } + internal readonly struct BasisLanIpv4Subnet + { + public readonly IPAddress Address; + public readonly IPAddress Mask; + + public BasisLanIpv4Subnet(IPAddress address, IPAddress mask) + { + Address = address; + Mask = mask; + } + } + /// Shared address filtering and preference rules for Basis LAN discovery. public static class BasisLanAddressUtility { @@ -69,6 +83,137 @@ public static int PreferenceRank(IPAddress address) ? address.IsIPv6LinkLocal ? 3 : 1 : int.MaxValue; } + + internal static IPAddress[] GetPreferredAdvertisedAddresses() + { + List gatewayAddresses = new List(); + List fallbackAddresses = new List(); + + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + { + continue; + } + + try + { + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + bool hasUsableGateway = properties.GatewayAddresses + .Any(gateway => IsUsable(gateway?.Address)); + + foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses) + { + if (!IsUsable(unicast?.Address)) + { + continue; + } + + fallbackAddresses.Add(unicast.Address); + if (hasUsableGateway) + { + gatewayAddresses.Add(unicast.Address); + } + } + } + catch + { + // Ignore interfaces that disappear or reject property queries mid-enumeration. + } + } + } + catch + { + // Fall through to an empty set; the DNS-SD response source remains usable as a fallback. + } + + List selected = gatewayAddresses.Count > 0 + ? gatewayAddresses + : fallbackAddresses; + return selected + .Distinct() + .OrderBy(PreferenceRank) + .ToArray(); + } + + internal static BasisLanIpv4Subnet[] GetLocalIpv4Subnets() + { + List gatewaySubnets = new List(); + List fallbackSubnets = new List(); + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + { + continue; + } + + try + { + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + bool hasUsableGateway = properties.GatewayAddresses + .Any(gateway => IsUsable(gateway?.Address)); + foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses) + { + if (unicast?.Address?.AddressFamily != AddressFamily.InterNetwork + || unicast.IPv4Mask?.AddressFamily != AddressFamily.InterNetwork + || !IsUsable(unicast.Address)) + { + continue; + } + + BasisLanIpv4Subnet subnet = + new BasisLanIpv4Subnet(unicast.Address, unicast.IPv4Mask); + fallbackSubnets.Add(subnet); + if (hasUsableGateway) + { + gatewaySubnets.Add(subnet); + } + } + } + catch + { + // Ignore interfaces that disappear or reject property queries mid-enumeration. + } + } + } + catch + { + } + return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray(); + } + + internal static bool IsOnSameIpv4Subnet( + IPAddress candidate, + BasisLanIpv4Subnet subnet) + { + if (candidate?.AddressFamily != AddressFamily.InterNetwork + || subnet.Address?.AddressFamily != AddressFamily.InterNetwork + || subnet.Mask?.AddressFamily != AddressFamily.InterNetwork) + { + return false; + } + + byte[] candidateBytes = candidate.GetAddressBytes(); + byte[] localBytes = subnet.Address.GetAddressBytes(); + byte[] maskBytes = subnet.Mask.GetAddressBytes(); + for (int index = 0; index < candidateBytes.Length; index++) + { + if ((candidateBytes[index] & maskBytes[index]) + != (localBytes[index] & maskBytes[index])) + { + return false; + } + } + return true; + } } /// Shared constants and bounded TXT metadata encoding for Basis LAN DNS-SD. diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs index 3cde391a36..fbbeffe256 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -1,7 +1,6 @@ using MeaMod.DNS.Model; using MeaMod.DNS.Multicast; using System; -using System.Linq; using System.Net; namespace Basis.Network.Core @@ -105,18 +104,12 @@ internal static ServiceProfile CreateProfile( private static IPAddress[] GetAdvertisedAddresses() { - try - { - return MulticastService.GetIPAddresses() - .Where(address => BasisLanAddressUtility.IsUsable(address)) - .Distinct() - .ToArray(); - } - catch (Exception ex) + IPAddress[] addresses = BasisLanAddressUtility.GetPreferredAdvertisedAddresses(); + if (addresses.Length == 0) { - BNL.LogWarning($"Basis LAN address discovery failed: {ex.Message}"); - return Array.Empty(); + BNL.LogWarning("Basis LAN address discovery found no usable local addresses."); } + return addresses; } public void Dispose() diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index f91bde4249..17cc910744 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -25,6 +25,7 @@ public sealed class BasisLanServerBrowser : IDisposable private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); private readonly Action _found; private readonly Action _removed; + private readonly BasisLanIpv4Subnet[] _localIpv4Subnets; private MulticastService _mdns; private ServiceDiscovery _discovery; private volatile bool _disposed; @@ -36,6 +37,7 @@ public BasisLanServerBrowser( { _found = found ?? throw new ArgumentNullException(nameof(found)); _removed = removed ?? throw new ArgumentNullException(nameof(removed)); + _localIpv4Subnets = BasisLanAddressUtility.GetLocalIpv4Subnets(); MulticastService mdns = null; ServiceDiscovery discovery = null; @@ -136,7 +138,8 @@ private void OnServiceDiscovered(object sender, ServiceInstanceDiscoveryEventArg args.ServiceInstanceName, args.RemoteEndPoint?.Address, out BasisLanAdvertisement advertisement, - out IPAddress address)) + out IPAddress address, + _localIpv4Subnets)) { _found(advertisement, address); } @@ -160,7 +163,8 @@ internal static bool TryExtractAdvertisement( DomainName serviceInstanceName, IPAddress remoteAddress, out BasisLanAdvertisement advertisement, - out IPAddress address) + out IPAddress address, + IReadOnlyList localIpv4Subnets = null) { advertisement = default; address = null; @@ -206,7 +210,7 @@ internal static bool TryExtractAdvertisement( return false; } - address = SelectAddress(records, service.Target, remoteAddress); + address = SelectAddress(records, service.Target, remoteAddress, localIpv4Subnets); if (address == null) { return false; @@ -289,9 +293,18 @@ private static Dictionary ReadProperties(IList values) private static IPAddress SelectAddress( IEnumerable records, DomainName hostName, - IPAddress remoteAddress) + IPAddress remoteAddress, + IReadOnlyList localIpv4Subnets) { + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) + && remoteAddress.AddressFamily == AddressFamily.InterNetwork) + { + // A usable IPv4 response source is the interface that actually reached us. + return remoteAddress; + } + IPAddress selected = null; + bool selectedOnLocalSubnet = false; foreach (ResourceRecord record in records) { if (!(record is AddressRecord addressRecord) @@ -302,22 +315,33 @@ private static IPAddress SelectAddress( } IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); + bool candidateOnLocalSubnet = IsOnLocalIpv4Subnet(candidate, localIpv4Subnets); if (selected == null - || BasisLanAddressUtility.PreferenceRank(candidate) - < BasisLanAddressUtility.PreferenceRank(selected)) + || (candidateOnLocalSubnet && !selectedOnLocalSubnet) + || (candidateOnLocalSubnet == selectedOnLocalSubnet + && BasisLanAddressUtility.PreferenceRank(candidate) + < BasisLanAddressUtility.PreferenceRank(selected))) { selected = candidate; + selectedOnLocalSubnet = candidateOnLocalSubnet; } } + if (selected?.AddressFamily == AddressFamily.InterNetwork + && localIpv4Subnets?.Count > 0 + && !selectedOnLocalSubnet) + { + // Do not create an entry from a partial response that only contains an unrelated + // virtual/VPN subnet. A later complete response can provide the same-subnet address. + selected = null; + } + if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) { - // The IPv4 packet source is the interface that actually reached us, so it is - // safer than unrelated VPN/cellular A records. Android can emit mDNS over IPv6 - // while its hosted LiteNetLib socket is only reachable over Wi-Fi IPv4; in that - // case prefer the advertised IPv4 address. - if (remoteAddress.AddressFamily == AddressFamily.InterNetwork - || selected?.AddressFamily != AddressFamily.InterNetwork) + // Android can receive an IPv6 mDNS response while the hosted LiteNetLib server + // is reachable over IPv4. Prefer a same-subnet advertised IPv4 in that case. + if (!selectedOnLocalSubnet + && selected?.AddressFamily != AddressFamily.InterNetwork) { return remoteAddress; } @@ -326,6 +350,28 @@ private static IPAddress SelectAddress( return selected; } + private static bool IsOnLocalIpv4Subnet( + IPAddress candidate, + IReadOnlyList localIpv4Subnets) + { + if (candidate?.AddressFamily != AddressFamily.InterNetwork + || localIpv4Subnets == null) + { + return false; + } + + for (int index = 0; index < localIpv4Subnets.Count; index++) + { + if (BasisLanAddressUtility.IsOnSameIpv4Subnet( + candidate, + localIpv4Subnets[index])) + { + return true; + } + } + return false; + } + private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress) { if (address != null From aa6f52131b46340c91acab1fa83be8ea016ef6af Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Thu, 16 Jul 2026 04:59:19 +0000 Subject: [PATCH 22/25] Support IPv6-only LAN discovery --- .../BasisLanDiscoveryProtocol.cs | 112 ++++++++++++ .../BasisNetworkCore/BasisLanServerBrowser.cs | 167 +++++++++++++----- .../BasisLanDiscoveryTests.cs | 134 +++++++++++++- .../Networking/LanServersDirectorySource.cs | 62 +++++-- .../BasisLanDiscoveryProtocol.cs | 112 ++++++++++++ .../BasisNetworkCore/BasisLanServerBrowser.cs | 167 +++++++++++++----- 6 files changed, 645 insertions(+), 109 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 2585ac0e67..d74343af58 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -48,6 +48,18 @@ public BasisLanIpv4Subnet(IPAddress address, IPAddress mask) } } + internal readonly struct BasisLanIpv6Subnet + { + public readonly IPAddress Address; + public readonly int PrefixLength; + + public BasisLanIpv6Subnet(IPAddress address, int prefixLength) + { + Address = address; + PrefixLength = prefixLength; + } + } + /// Shared address filtering and preference rules for Basis LAN discovery. public static class BasisLanAddressUtility { @@ -190,6 +202,63 @@ internal static BasisLanIpv4Subnet[] GetLocalIpv4Subnets() return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray(); } + internal static BasisLanIpv6Subnet[] GetLocalIpv6Subnets() + { + List gatewaySubnets = new List(); + List fallbackSubnets = new List(); + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + { + continue; + } + + try + { + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + bool hasUsableGateway = properties.GatewayAddresses + .Any(gateway => IsUsable(gateway?.Address)); + foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses) + { + if (unicast?.Address?.AddressFamily != AddressFamily.InterNetworkV6 + || !IsUsable(unicast.Address)) + { + continue; + } + + int prefixLength = unicast.PrefixLength; + if (prefixLength <= 0 || prefixLength > 128) + { + // SLAAC uses /64 by default. Use it only when a platform does not + // report a usable prefix length for an otherwise valid IPv6 address. + prefixLength = 64; + } + + BasisLanIpv6Subnet subnet = + new BasisLanIpv6Subnet(unicast.Address, prefixLength); + fallbackSubnets.Add(subnet); + if (hasUsableGateway) + { + gatewaySubnets.Add(subnet); + } + } + } + catch + { + // Ignore interfaces that disappear or reject property queries mid-enumeration. + } + } + } + catch + { + } + return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray(); + } + internal static bool IsOnSameIpv4Subnet( IPAddress candidate, BasisLanIpv4Subnet subnet) @@ -214,6 +283,49 @@ internal static bool IsOnSameIpv4Subnet( } return true; } + + internal static bool IsOnSameIpv6Subnet( + IPAddress candidate, + BasisLanIpv6Subnet subnet) + { + if (candidate?.AddressFamily != AddressFamily.InterNetworkV6 + || subnet.Address?.AddressFamily != AddressFamily.InterNetworkV6 + || subnet.PrefixLength <= 0 + || subnet.PrefixLength > 128) + { + return false; + } + + if (candidate.IsIPv6LinkLocal + && candidate.ScopeId != 0 + && subnet.Address.ScopeId != 0 + && candidate.ScopeId != subnet.Address.ScopeId) + { + return false; + } + + byte[] candidateBytes = candidate.GetAddressBytes(); + byte[] localBytes = subnet.Address.GetAddressBytes(); + int wholeBytes = subnet.PrefixLength / 8; + int remainingBits = subnet.PrefixLength % 8; + + for (int index = 0; index < wholeBytes; index++) + { + if (candidateBytes[index] != localBytes[index]) + { + return false; + } + } + + if (remainingBits == 0) + { + return true; + } + + int mask = 0xFF << (8 - remainingBits); + return (candidateBytes[wholeBytes] & mask) + == (localBytes[wholeBytes] & mask); + } } /// Shared constants and bounded TXT metadata encoding for Basis LAN DNS-SD. diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs index 17cc910744..238d817557 100644 --- a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -26,6 +26,7 @@ public sealed class BasisLanServerBrowser : IDisposable private readonly Action _found; private readonly Action _removed; private readonly BasisLanIpv4Subnet[] _localIpv4Subnets; + private readonly BasisLanIpv6Subnet[] _localIpv6Subnets; private MulticastService _mdns; private ServiceDiscovery _discovery; private volatile bool _disposed; @@ -33,11 +34,21 @@ public sealed class BasisLanServerBrowser : IDisposable public BasisLanServerBrowser( Action found, Action removed, + bool useIpv4 = true, bool useIpv6 = true) { _found = found ?? throw new ArgumentNullException(nameof(found)); _removed = removed ?? throw new ArgumentNullException(nameof(removed)); _localIpv4Subnets = BasisLanAddressUtility.GetLocalIpv4Subnets(); + _localIpv6Subnets = BasisLanAddressUtility.GetLocalIpv6Subnets(); + + bool ipv4Enabled = useIpv4 && Socket.OSSupportsIPv4; + bool ipv6Enabled = useIpv6 && Socket.OSSupportsIPv6; + if (!ipv4Enabled && !ipv6Enabled) + { + throw new PlatformNotSupportedException( + "LAN discovery requires at least one supported IP address family."); + } MulticastService mdns = null; ServiceDiscovery discovery = null; @@ -45,8 +56,8 @@ public BasisLanServerBrowser( { mdns = new MulticastService { - UseIpv4 = Socket.OSSupportsIPv4, - UseIpv6 = useIpv6 && Socket.OSSupportsIPv6, + UseIpv4 = ipv4Enabled, + UseIpv6 = ipv6Enabled, IgnoreDuplicateMessages = true, }; discovery = new ServiceDiscovery(mdns); @@ -139,7 +150,8 @@ private void OnServiceDiscovered(object sender, ServiceInstanceDiscoveryEventArg args.RemoteEndPoint?.Address, out BasisLanAdvertisement advertisement, out IPAddress address, - _localIpv4Subnets)) + _localIpv4Subnets, + _localIpv6Subnets)) { _found(advertisement, address); } @@ -164,7 +176,8 @@ internal static bool TryExtractAdvertisement( IPAddress remoteAddress, out BasisLanAdvertisement advertisement, out IPAddress address, - IReadOnlyList localIpv4Subnets = null) + IReadOnlyList localIpv4Subnets = null, + IReadOnlyList localIpv6Subnets = null) { advertisement = default; address = null; @@ -210,7 +223,12 @@ internal static bool TryExtractAdvertisement( return false; } - address = SelectAddress(records, service.Target, remoteAddress, localIpv4Subnets); + address = SelectAddress( + records, + service.Target, + remoteAddress, + localIpv4Subnets, + localIpv6Subnets); if (address == null) { return false; @@ -294,17 +312,17 @@ private static IPAddress SelectAddress( IEnumerable records, DomainName hostName, IPAddress remoteAddress, - IReadOnlyList localIpv4Subnets) + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) { - if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) - && remoteAddress.AddressFamily == AddressFamily.InterNetwork) - { - // A usable IPv4 response source is the interface that actually reached us. - return remoteAddress; - } + IPAddress selected = BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) + ? remoteAddress + : null; + bool selectedOnLocalSubnet = IsOnLocalSubnet( + selected, + localIpv4Subnets, + localIpv6Subnets); - IPAddress selected = null; - bool selectedOnLocalSubnet = false; foreach (ResourceRecord record in records) { if (!(record is AddressRecord addressRecord) @@ -314,8 +332,14 @@ private static IPAddress SelectAddress( continue; } - IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); - bool candidateOnLocalSubnet = IsOnLocalIpv4Subnet(candidate, localIpv4Subnets); + IPAddress candidate = RestoreScope( + addressRecord.Address, + remoteAddress, + localIpv6Subnets); + bool candidateOnLocalSubnet = IsOnLocalSubnet( + candidate, + localIpv4Subnets, + localIpv6Subnets); if (selected == null || (candidateOnLocalSubnet && !selectedOnLocalSubnet) || (candidateOnLocalSubnet == selectedOnLocalSubnet @@ -327,44 +351,65 @@ private static IPAddress SelectAddress( } } - if (selected?.AddressFamily == AddressFamily.InterNetwork - && localIpv4Subnets?.Count > 0 + if (selected != null + && HasLocalSubnetsForFamily(selected, localIpv4Subnets, localIpv6Subnets) && !selectedOnLocalSubnet) { // Do not create an entry from a partial response that only contains an unrelated - // virtual/VPN subnet. A later complete response can provide the same-subnet address. - selected = null; - } - - if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) - { - // Android can receive an IPv6 mDNS response while the hosted LiteNetLib server - // is reachable over IPv4. Prefer a same-subnet advertised IPv4 in that case. - if (!selectedOnLocalSubnet - && selected?.AddressFamily != AddressFamily.InterNetwork) - { - return remoteAddress; - } + // virtual/VPN subnet. A later complete response can provide the same-link address. + return null; } return selected; } - private static bool IsOnLocalIpv4Subnet( + private static bool HasLocalSubnetsForFamily( + IPAddress address, + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) + { + return address?.AddressFamily == AddressFamily.InterNetwork + ? localIpv4Subnets?.Count > 0 + : address?.AddressFamily == AddressFamily.InterNetworkV6 + && localIpv6Subnets?.Count > 0; + } + + private static bool IsOnLocalSubnet( IPAddress candidate, - IReadOnlyList localIpv4Subnets) + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) { - if (candidate?.AddressFamily != AddressFamily.InterNetwork - || localIpv4Subnets == null) + if (candidate?.AddressFamily == AddressFamily.InterNetwork) + { + if (localIpv4Subnets == null) + { + return false; + } + + for (int index = 0; index < localIpv4Subnets.Count; index++) + { + if (BasisLanAddressUtility.IsOnSameIpv4Subnet( + candidate, + localIpv4Subnets[index])) + { + return true; + } + } + return false; + } + + if (candidate?.AddressFamily != AddressFamily.InterNetworkV6 + || localIpv6Subnets == null + || (candidate.IsIPv6LinkLocal && candidate.ScopeId == 0)) { return false; } - for (int index = 0; index < localIpv4Subnets.Count; index++) + for (int index = 0; index < localIpv6Subnets.Count; index++) { - if (BasisLanAddressUtility.IsOnSameIpv4Subnet( + if (BasisLanAddressUtility.IsOnSameIpv6Subnet( candidate, - localIpv4Subnets[index])) + localIpv6Subnets[index])) { return true; } @@ -372,19 +417,51 @@ private static bool IsOnLocalIpv4Subnet( return false; } - private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress) + private static IPAddress RestoreScope( + IPAddress address, + IPAddress remoteAddress, + IReadOnlyList localIpv6Subnets) { - if (address != null - && address.AddressFamily == AddressFamily.InterNetworkV6 - && address.IsIPv6LinkLocal - && address.ScopeId == 0 - && remoteAddress != null + if (address == null + || address.AddressFamily != AddressFamily.InterNetworkV6 + || !address.IsIPv6LinkLocal + || address.ScopeId != 0) + { + return address; + } + + if (remoteAddress != null && remoteAddress.AddressFamily == AddressFamily.InterNetworkV6 && remoteAddress.ScopeId != 0) { return new IPAddress(address.GetAddressBytes(), remoteAddress.ScopeId); } - return address; + + long selectedScope = 0; + if (localIpv6Subnets != null) + { + for (int index = 0; index < localIpv6Subnets.Count; index++) + { + BasisLanIpv6Subnet subnet = localIpv6Subnets[index]; + if (subnet.Address?.ScopeId == 0 + || !BasisLanAddressUtility.IsOnSameIpv6Subnet(address, subnet)) + { + continue; + } + + if (selectedScope != 0 && selectedScope != subnet.Address.ScopeId) + { + // Multiple interfaces share fe80::/64 and the response did not identify + // which one received it. Leave it unscoped rather than choosing incorrectly. + return address; + } + selectedScope = subnet.Address.ScopeId; + } + } + + return selectedScope == 0 + ? address + : new IPAddress(address.GetAddressBytes(), selectedScope); } public void Dispose() diff --git a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs index ec25675aa9..3b80ba149f 100644 --- a/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs +++ b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs @@ -209,6 +209,134 @@ public void SubnetMatch_UsesReportedMask() subnet)); } + [Fact] + public void SameSubnetSlaacIpv6_IsPreferredOverOtherIpv6Prefix() + { + IPAddress unrelatedAddress = IPAddress.Parse("fd12:3456:789a:2::10"); + IPAddress slaacAddress = IPAddress.Parse("2001:db8:50:1::10"); + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "IPv6-only host", + string.Empty, + false, + new[] { unrelatedAddress, slaacAddress }); + BasisLanIpv6Subnet[] localSubnets = + { + new BasisLanIpv6Subnet(IPAddress.Parse("2001:db8:50:1::25"), 64), + }; + + Extract( + profile, + IPAddress.Parse("ff02::fb"), + out IPAddress address, + localIpv6Subnets: localSubnets); + + Assert.Equal(slaacAddress, address); + } + + [Fact] + public void UnrelatedOnlyIpv6_IsIgnoredWhenLocalPrefixIsKnown() + { + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Unrelated IPv6 response", + string.Empty, + false, + new[] { IPAddress.Parse("fd12:3456:789a:2::10") }); + BasisLanIpv6Subnet[] localSubnets = + { + new BasisLanIpv6Subnet(IPAddress.Parse("2001:db8:50:1::25"), 64), + }; + Message message = CreateMessage(profile); + + Assert.False(BasisLanServerBrowser.TryExtractAdvertisement( + message, + profile.FullyQualifiedName, + IPAddress.Parse("ff02::fb"), + out _, + out _, + localIpv6Subnets: localSubnets)); + } + + [Fact] + public void Ipv6SubnetMatch_UsesReportedPrefixLength() + { + BasisLanIpv6Subnet subnet = new BasisLanIpv6Subnet( + IPAddress.Parse("2001:db8:1234:5600::25"), + 56); + + Assert.True(BasisLanAddressUtility.IsOnSameIpv6Subnet( + IPAddress.Parse("2001:db8:1234:56ff::10"), + subnet)); + Assert.False(BasisLanAddressUtility.IsOnSameIpv6Subnet( + IPAddress.Parse("2001:db8:1234:5700::10"), + subnet)); + } + + [Fact] + public void LinkLocalIpv6_RestoresInterfaceScope() + { + IPAddress localLinkLocal = new IPAddress( + IPAddress.Parse("fe80::25").GetAddressBytes(), + 7); + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Link-local IPv6 host", + string.Empty, + false, + new[] { IPAddress.Parse("fe80::10") }); + BasisLanIpv6Subnet[] localSubnets = + { + new BasisLanIpv6Subnet(localLinkLocal, 64), + }; + + Extract( + profile, + IPAddress.Parse("ff02::fb"), + out IPAddress address, + localIpv6Subnets: localSubnets); + + Assert.True(address.IsIPv6LinkLocal); + Assert.Equal(7, address.ScopeId); + } + + [Fact] + public void AmbiguousLinkLocalIpv6WithoutScope_IsIgnored() + { + ServiceProfile profile = BasisLanServerAnnouncer.CreateProfile( + Guid.NewGuid(), + 4296, + BasisNetworkStackRegistry.LiteNetLibId, + "Ambiguous link-local host", + string.Empty, + false, + new[] { IPAddress.Parse("fe80::10") }); + BasisLanIpv6Subnet[] localSubnets = + { + new BasisLanIpv6Subnet( + new IPAddress(IPAddress.Parse("fe80::25").GetAddressBytes(), 7), + 64), + new BasisLanIpv6Subnet( + new IPAddress(IPAddress.Parse("fe80::35").GetAddressBytes(), 9), + 64), + }; + Message message = CreateMessage(profile); + + Assert.False(BasisLanServerBrowser.TryExtractAdvertisement( + message, + profile.FullyQualifiedName, + IPAddress.Parse("ff02::fb"), + out _, + out _, + localIpv6Subnets: localSubnets)); + } + [Fact] public void AddressPreference_PrioritizesRoutableAddressesOverLinkLocal() { @@ -256,7 +384,8 @@ private static BasisLanAdvertisement Extract( ServiceProfile profile, IPAddress responseSource, out IPAddress address, - IReadOnlyList? localIpv4Subnets = null) + IReadOnlyList? localIpv4Subnets = null, + IReadOnlyList? localIpv6Subnets = null) { Message message = CreateMessage(profile); @@ -266,7 +395,8 @@ private static BasisLanAdvertisement Extract( responseSource, out BasisLanAdvertisement advertisement, out IPAddress? selectedAddress, - localIpv4Subnets)); + localIpv4Subnets, + localIpv6Subnets)); address = Assert.IsType(selectedAddress); return advertisement; } diff --git a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs index d38dcd9f6e..fab25de781 100644 --- a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs @@ -40,7 +40,7 @@ private sealed class DiscoveredServer private readonly object _gate = new object(); private readonly Dictionary _servers = new Dictionary(); private readonly CancellationTokenSource _cancellation = new CancellationTokenSource(); - private BasisLanServerBrowser _browser; + private readonly List _browsers = new List(2); #if UNITY_ANDROID && !UNITY_EDITOR private AndroidJavaObject _androidMulticastLock; #endif @@ -116,7 +116,10 @@ public Task> ListAsync(CancellationToken can public Task RefreshAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - _browser?.Query(); + for (int index = 0; index < _browsers.Count; index++) + { + _browsers[index].Query(); + } if (PruneExpired()) { QueueSourceChanged(); @@ -127,25 +130,47 @@ public Task RefreshAsync(CancellationToken cancellationToken) private void StartListening() { AcquireAndroidMulticastLock(); +#if UNITY_ANDROID && !UNITY_EDITOR + // Keep the address families independent on Android. A platform/socket failure in one + // multicast family must not disable discovery on the other, including IPv6-only SLAAC LANs. + if (Socket.OSSupportsIPv4) + { + TryStartBrowser(useIpv4: true, useIpv6: false, familyName: "IPv4"); + } + if (Socket.OSSupportsIPv6) + { + TryStartBrowser(useIpv4: false, useIpv6: true, familyName: "IPv6"); + } +#else + TryStartBrowser( + useIpv4: Socket.OSSupportsIPv4, + useIpv6: Socket.OSSupportsIPv6, + familyName: "IP"); +#endif + if (_browsers.Count == 0) + { + ReleaseAndroidMulticastLock(); + return; + } + + _ = Task.Run(() => CleanupLoopAsync(_cancellation.Token)); + } + + private void TryStartBrowser(bool useIpv4, bool useIpv6, string familyName) + { try { -#if UNITY_ANDROID && !UNITY_EDITOR - // MeaMod's IPv6 multicast receiver can fail during construction on Android and - // prevent its IPv4 receiver from starting. LAN-hosted Basis servers already - // advertise IPv4 addresses, so browse over IPv4 on Android. - _browser = new BasisLanServerBrowser( + _browsers.Add(new BasisLanServerBrowser( ProcessAdvertisement, RemoveAdvertisement, - useIpv6: false); -#else - _browser = new BasisLanServerBrowser(ProcessAdvertisement, RemoveAdvertisement); -#endif - _ = Task.Run(() => CleanupLoopAsync(_cancellation.Token)); + useIpv4, + useIpv6)); } catch (Exception ex) { - BasisDebug.LogWarning($"LAN DNS-SD discovery could not start: {ex.Message}", BasisDebug.LogTag.Networking); - ReleaseAndroidMulticastLock(); + BasisDebug.LogWarning( + $"LAN DNS-SD {familyName} discovery could not start: {ex.Message}", + BasisDebug.LogTag.Networking); } } @@ -416,9 +441,12 @@ public void Dispose() try { _cancellation.Cancel(); } catch (ObjectDisposedException) { } - try { _browser?.Dispose(); } - catch (Exception ex) { BasisDebug.LogWarning($"LAN DNS-SD discovery shutdown failed: {ex.Message}", BasisDebug.LogTag.Networking); } - _browser = null; + for (int index = 0; index < _browsers.Count; index++) + { + try { _browsers[index].Dispose(); } + catch (Exception ex) { BasisDebug.LogWarning($"LAN DNS-SD discovery shutdown failed: {ex.Message}", BasisDebug.LogTag.Networking); } + } + _browsers.Clear(); ReleaseAndroidMulticastLock(); _cancellation.Dispose(); diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index 2585ac0e67..d74343af58 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -48,6 +48,18 @@ public BasisLanIpv4Subnet(IPAddress address, IPAddress mask) } } + internal readonly struct BasisLanIpv6Subnet + { + public readonly IPAddress Address; + public readonly int PrefixLength; + + public BasisLanIpv6Subnet(IPAddress address, int prefixLength) + { + Address = address; + PrefixLength = prefixLength; + } + } + /// Shared address filtering and preference rules for Basis LAN discovery. public static class BasisLanAddressUtility { @@ -190,6 +202,63 @@ internal static BasisLanIpv4Subnet[] GetLocalIpv4Subnets() return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray(); } + internal static BasisLanIpv6Subnet[] GetLocalIpv6Subnets() + { + List gatewaySubnets = new List(); + List fallbackSubnets = new List(); + try + { + foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces()) + { + if (networkInterface.OperationalStatus != OperationalStatus.Up + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback + || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + { + continue; + } + + try + { + IPInterfaceProperties properties = networkInterface.GetIPProperties(); + bool hasUsableGateway = properties.GatewayAddresses + .Any(gateway => IsUsable(gateway?.Address)); + foreach (UnicastIPAddressInformation unicast in properties.UnicastAddresses) + { + if (unicast?.Address?.AddressFamily != AddressFamily.InterNetworkV6 + || !IsUsable(unicast.Address)) + { + continue; + } + + int prefixLength = unicast.PrefixLength; + if (prefixLength <= 0 || prefixLength > 128) + { + // SLAAC uses /64 by default. Use it only when a platform does not + // report a usable prefix length for an otherwise valid IPv6 address. + prefixLength = 64; + } + + BasisLanIpv6Subnet subnet = + new BasisLanIpv6Subnet(unicast.Address, prefixLength); + fallbackSubnets.Add(subnet); + if (hasUsableGateway) + { + gatewaySubnets.Add(subnet); + } + } + } + catch + { + // Ignore interfaces that disappear or reject property queries mid-enumeration. + } + } + } + catch + { + } + return (gatewaySubnets.Count > 0 ? gatewaySubnets : fallbackSubnets).ToArray(); + } + internal static bool IsOnSameIpv4Subnet( IPAddress candidate, BasisLanIpv4Subnet subnet) @@ -214,6 +283,49 @@ internal static bool IsOnSameIpv4Subnet( } return true; } + + internal static bool IsOnSameIpv6Subnet( + IPAddress candidate, + BasisLanIpv6Subnet subnet) + { + if (candidate?.AddressFamily != AddressFamily.InterNetworkV6 + || subnet.Address?.AddressFamily != AddressFamily.InterNetworkV6 + || subnet.PrefixLength <= 0 + || subnet.PrefixLength > 128) + { + return false; + } + + if (candidate.IsIPv6LinkLocal + && candidate.ScopeId != 0 + && subnet.Address.ScopeId != 0 + && candidate.ScopeId != subnet.Address.ScopeId) + { + return false; + } + + byte[] candidateBytes = candidate.GetAddressBytes(); + byte[] localBytes = subnet.Address.GetAddressBytes(); + int wholeBytes = subnet.PrefixLength / 8; + int remainingBits = subnet.PrefixLength % 8; + + for (int index = 0; index < wholeBytes; index++) + { + if (candidateBytes[index] != localBytes[index]) + { + return false; + } + } + + if (remainingBits == 0) + { + return true; + } + + int mask = 0xFF << (8 - remainingBits); + return (candidateBytes[wholeBytes] & mask) + == (localBytes[wholeBytes] & mask); + } } /// Shared constants and bounded TXT metadata encoding for Basis LAN DNS-SD. diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs index 17cc910744..238d817557 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -26,6 +26,7 @@ public sealed class BasisLanServerBrowser : IDisposable private readonly Action _found; private readonly Action _removed; private readonly BasisLanIpv4Subnet[] _localIpv4Subnets; + private readonly BasisLanIpv6Subnet[] _localIpv6Subnets; private MulticastService _mdns; private ServiceDiscovery _discovery; private volatile bool _disposed; @@ -33,11 +34,21 @@ public sealed class BasisLanServerBrowser : IDisposable public BasisLanServerBrowser( Action found, Action removed, + bool useIpv4 = true, bool useIpv6 = true) { _found = found ?? throw new ArgumentNullException(nameof(found)); _removed = removed ?? throw new ArgumentNullException(nameof(removed)); _localIpv4Subnets = BasisLanAddressUtility.GetLocalIpv4Subnets(); + _localIpv6Subnets = BasisLanAddressUtility.GetLocalIpv6Subnets(); + + bool ipv4Enabled = useIpv4 && Socket.OSSupportsIPv4; + bool ipv6Enabled = useIpv6 && Socket.OSSupportsIPv6; + if (!ipv4Enabled && !ipv6Enabled) + { + throw new PlatformNotSupportedException( + "LAN discovery requires at least one supported IP address family."); + } MulticastService mdns = null; ServiceDiscovery discovery = null; @@ -45,8 +56,8 @@ public BasisLanServerBrowser( { mdns = new MulticastService { - UseIpv4 = Socket.OSSupportsIPv4, - UseIpv6 = useIpv6 && Socket.OSSupportsIPv6, + UseIpv4 = ipv4Enabled, + UseIpv6 = ipv6Enabled, IgnoreDuplicateMessages = true, }; discovery = new ServiceDiscovery(mdns); @@ -139,7 +150,8 @@ private void OnServiceDiscovered(object sender, ServiceInstanceDiscoveryEventArg args.RemoteEndPoint?.Address, out BasisLanAdvertisement advertisement, out IPAddress address, - _localIpv4Subnets)) + _localIpv4Subnets, + _localIpv6Subnets)) { _found(advertisement, address); } @@ -164,7 +176,8 @@ internal static bool TryExtractAdvertisement( IPAddress remoteAddress, out BasisLanAdvertisement advertisement, out IPAddress address, - IReadOnlyList localIpv4Subnets = null) + IReadOnlyList localIpv4Subnets = null, + IReadOnlyList localIpv6Subnets = null) { advertisement = default; address = null; @@ -210,7 +223,12 @@ internal static bool TryExtractAdvertisement( return false; } - address = SelectAddress(records, service.Target, remoteAddress, localIpv4Subnets); + address = SelectAddress( + records, + service.Target, + remoteAddress, + localIpv4Subnets, + localIpv6Subnets); if (address == null) { return false; @@ -294,17 +312,17 @@ private static IPAddress SelectAddress( IEnumerable records, DomainName hostName, IPAddress remoteAddress, - IReadOnlyList localIpv4Subnets) + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) { - if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) - && remoteAddress.AddressFamily == AddressFamily.InterNetwork) - { - // A usable IPv4 response source is the interface that actually reached us. - return remoteAddress; - } + IPAddress selected = BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) + ? remoteAddress + : null; + bool selectedOnLocalSubnet = IsOnLocalSubnet( + selected, + localIpv4Subnets, + localIpv6Subnets); - IPAddress selected = null; - bool selectedOnLocalSubnet = false; foreach (ResourceRecord record in records) { if (!(record is AddressRecord addressRecord) @@ -314,8 +332,14 @@ private static IPAddress SelectAddress( continue; } - IPAddress candidate = RestoreScope(addressRecord.Address, remoteAddress); - bool candidateOnLocalSubnet = IsOnLocalIpv4Subnet(candidate, localIpv4Subnets); + IPAddress candidate = RestoreScope( + addressRecord.Address, + remoteAddress, + localIpv6Subnets); + bool candidateOnLocalSubnet = IsOnLocalSubnet( + candidate, + localIpv4Subnets, + localIpv6Subnets); if (selected == null || (candidateOnLocalSubnet && !selectedOnLocalSubnet) || (candidateOnLocalSubnet == selectedOnLocalSubnet @@ -327,44 +351,65 @@ private static IPAddress SelectAddress( } } - if (selected?.AddressFamily == AddressFamily.InterNetwork - && localIpv4Subnets?.Count > 0 + if (selected != null + && HasLocalSubnetsForFamily(selected, localIpv4Subnets, localIpv6Subnets) && !selectedOnLocalSubnet) { // Do not create an entry from a partial response that only contains an unrelated - // virtual/VPN subnet. A later complete response can provide the same-subnet address. - selected = null; - } - - if (BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true)) - { - // Android can receive an IPv6 mDNS response while the hosted LiteNetLib server - // is reachable over IPv4. Prefer a same-subnet advertised IPv4 in that case. - if (!selectedOnLocalSubnet - && selected?.AddressFamily != AddressFamily.InterNetwork) - { - return remoteAddress; - } + // virtual/VPN subnet. A later complete response can provide the same-link address. + return null; } return selected; } - private static bool IsOnLocalIpv4Subnet( + private static bool HasLocalSubnetsForFamily( + IPAddress address, + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) + { + return address?.AddressFamily == AddressFamily.InterNetwork + ? localIpv4Subnets?.Count > 0 + : address?.AddressFamily == AddressFamily.InterNetworkV6 + && localIpv6Subnets?.Count > 0; + } + + private static bool IsOnLocalSubnet( IPAddress candidate, - IReadOnlyList localIpv4Subnets) + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) { - if (candidate?.AddressFamily != AddressFamily.InterNetwork - || localIpv4Subnets == null) + if (candidate?.AddressFamily == AddressFamily.InterNetwork) + { + if (localIpv4Subnets == null) + { + return false; + } + + for (int index = 0; index < localIpv4Subnets.Count; index++) + { + if (BasisLanAddressUtility.IsOnSameIpv4Subnet( + candidate, + localIpv4Subnets[index])) + { + return true; + } + } + return false; + } + + if (candidate?.AddressFamily != AddressFamily.InterNetworkV6 + || localIpv6Subnets == null + || (candidate.IsIPv6LinkLocal && candidate.ScopeId == 0)) { return false; } - for (int index = 0; index < localIpv4Subnets.Count; index++) + for (int index = 0; index < localIpv6Subnets.Count; index++) { - if (BasisLanAddressUtility.IsOnSameIpv4Subnet( + if (BasisLanAddressUtility.IsOnSameIpv6Subnet( candidate, - localIpv4Subnets[index])) + localIpv6Subnets[index])) { return true; } @@ -372,19 +417,51 @@ private static bool IsOnLocalIpv4Subnet( return false; } - private static IPAddress RestoreScope(IPAddress address, IPAddress remoteAddress) + private static IPAddress RestoreScope( + IPAddress address, + IPAddress remoteAddress, + IReadOnlyList localIpv6Subnets) { - if (address != null - && address.AddressFamily == AddressFamily.InterNetworkV6 - && address.IsIPv6LinkLocal - && address.ScopeId == 0 - && remoteAddress != null + if (address == null + || address.AddressFamily != AddressFamily.InterNetworkV6 + || !address.IsIPv6LinkLocal + || address.ScopeId != 0) + { + return address; + } + + if (remoteAddress != null && remoteAddress.AddressFamily == AddressFamily.InterNetworkV6 && remoteAddress.ScopeId != 0) { return new IPAddress(address.GetAddressBytes(), remoteAddress.ScopeId); } - return address; + + long selectedScope = 0; + if (localIpv6Subnets != null) + { + for (int index = 0; index < localIpv6Subnets.Count; index++) + { + BasisLanIpv6Subnet subnet = localIpv6Subnets[index]; + if (subnet.Address?.ScopeId == 0 + || !BasisLanAddressUtility.IsOnSameIpv6Subnet(address, subnet)) + { + continue; + } + + if (selectedScope != 0 && selectedScope != subnet.Address.ScopeId) + { + // Multiple interfaces share fe80::/64 and the response did not identify + // which one received it. Leave it unscoped rather than choosing incorrectly. + return address; + } + selectedScope = subnet.Address.ScopeId; + } + } + + return selectedScope == 0 + ? address + : new IPAddress(address.GetAddressBytes(), selectedScope); } public void Dispose() From 6115fcfa9e7e10eadbeb7be2b382e5dfa750da5d Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Sat, 18 Jul 2026 01:27:17 +0000 Subject: [PATCH 23/25] Rename LAN discovery DNS-SD service --- Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs | 2 +- .../BasisNetworkCore/BasisLanDiscoveryProtocol.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index d74343af58..2678e3baf9 100644 --- a/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -333,7 +333,7 @@ internal static class BasisLanDiscoveryProtocol { private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); - internal const string ServiceName = "_basisvr._udp"; + internal const string ServiceName = "_basisdemo._udp"; internal const string ProtocolVersion = "1"; internal const int MaxStackIdBytes = 64; internal const int MaxServerNameBytes = 128; diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs index d74343af58..2678e3baf9 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -333,7 +333,7 @@ internal static class BasisLanDiscoveryProtocol { private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); - internal const string ServiceName = "_basisvr._udp"; + internal const string ServiceName = "_basisdemo._udp"; internal const string ProtocolVersion = "1"; internal const int MaxStackIdBytes = 64; internal const int MaxServerNameBytes = 128; From 723d2501674dbfc611c0e4150ddeebd9328e8890 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Sat, 18 Jul 2026 01:27:17 +0000 Subject: [PATCH 24/25] Fix LAN config version test expectation --- Basis Server/BasisServerTests/ConfigAndRegistryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Basis Server/BasisServerTests/ConfigAndRegistryTests.cs b/Basis Server/BasisServerTests/ConfigAndRegistryTests.cs index 6f39378003..60354266ee 100644 --- a/Basis Server/BasisServerTests/ConfigAndRegistryTests.cs +++ b/Basis Server/BasisServerTests/ConfigAndRegistryTests.cs @@ -164,7 +164,7 @@ public void Defaults_RestApiAndDiagnostics() [Fact] public void Defaults_VersioningAndFolderConstants() { - Assert.Equal(4, Configuration.CurrentConfigVersion); + Assert.Equal(5, Configuration.CurrentConfigVersion); Assert.Equal(0, new Configuration().ConfigVersion); Assert.Equal("config", Configuration.ConfigFolderName); Assert.Equal("logs", Configuration.LogsFolderName); From f6930a95f5ca9649cd4955c24cf7e79b05cdd908 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Sat, 18 Jul 2026 02:22:13 +0000 Subject: [PATCH 25/25] Fix LAN password action row build --- .../com.basis.provider.servers/Runtime/ServersProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index 59bd71c8f3..874334666e 100644 --- a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs +++ b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs @@ -507,7 +507,7 @@ private void BuildLanPasswordSection(RectTransform container) _lanPasswordField.Descriptor.SetTitle(BasisLocalization.Get("menu.servers.password")); _lanPasswordField._inputField.onSubmit.AddListener(_ => SubmitLanPassword()); - RectTransform actions = BuildActionRow(_lanPasswordSection.ContentParent); + RectTransform actions = PanelElementDescriptor.BuildActionRow(_lanPasswordSection.ContentParent, "ServerRowActions"); _lanPasswordConnectButton = PanelButton.CreateNew(actions); _lanPasswordConnectButton.Descriptor.SetTitle(BasisLocalization.Get("menu.servers.connect"));