diff --git a/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs b/Basis Server/BasisNetworkCore/BasisConfigXmlDocs.cs index ba4fe59eff..e1b6bca5d0 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 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 new file mode 100644 index 0000000000..2678e3baf9 --- /dev/null +++ b/Basis Server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -0,0 +1,426 @@ +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; + +namespace Basis.Network.Core +{ + /// Metadata published through the Basis DNS-SD service. + 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; + } + } + + internal readonly struct BasisLanIpv4Subnet + { + public readonly IPAddress Address; + public readonly IPAddress Mask; + + public BasisLanIpv4Subnet(IPAddress address, IPAddress mask) + { + Address = address; + Mask = 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 + { + public static bool IsUsable(IPAddress address, bool allowLoopback = false) + { + if (address == null || (!allowLoopback && IPAddress.IsLoopback(address))) + { + return false; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte firstOctet = address.GetAddressBytes()[0]; + return !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.Broadcast) + && (firstOctet < 224 || firstOctet > 239); + } + + return address.AddressFamily == AddressFamily.InterNetworkV6 + && !address.Equals(IPAddress.IPv6Any) + && !address.IsIPv6Multicast; + } + + public static int PreferenceRank(IPAddress address) + { + if (address?.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes[0] == 169 && bytes[1] == 254 ? 2 : 0; + } + + return address?.AddressFamily == AddressFamily.InterNetworkV6 + ? 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 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) + { + 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; + } + + 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. + internal static class BasisLanDiscoveryProtocol + { + private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); + + internal const string ServiceName = "_basisdemo._udp"; + internal const string ProtocolVersion = "1"; + internal const int MaxStackIdBytes = 64; + internal const int MaxServerNameBytes = 128; + internal const int MaxMotdBytes = 384; + + 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); + } + + internal static void AddMetadata( + ServiceProfile profile, + string encodedKey, + string value, + int maxBytes) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("TXT key is required.", nameof(encodedKey)); + + string encoded = Convert.ToBase64String( + Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes))); + for (int index = 0, offset = 0; offset < encoded.Length; index++) + { + string chunkKey = $"{encodedKey}-{index}"; + int chunkLength = Math.Min(TxtValueBudget(chunkKey), encoded.Length - offset); + profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); + offset += chunkLength; + } + } + + internal static string ReadMetadata( + Dictionary properties, + string encodedKey, + int maxBytes) + { + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; + StringBuilder encoded = new StringBuilder(maxEncodedLength); + for (int index = 0; + properties.TryGetValue($"{encodedKey}-{index}", out string chunk); + index++) + { + if (chunk.Length > maxEncodedLength - encoded.Length) + { + return string.Empty; + } + encoded.Append(chunk); + } + + if (encoded.Length == 0) + { + return string.Empty; + } + + 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) + { + return 254 - Encoding.ASCII.GetByteCount(key); + } + } +} diff --git a/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs new file mode 100644 index 0000000000..fbbeffe256 --- /dev/null +++ b/Basis Server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -0,0 +1,155 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System; +using System.Net; + +namespace Basis.Network.Core +{ + /// + /// 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 + { + private readonly object _gate = new object(); + private ServiceDiscovery _discovery; + private ServiceProfile _profile; + 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)); + } + + ServiceDiscovery discovery = null; + try + { + discovery = new ServiceDiscovery(); + discovery.Mdns.IgnoreDuplicateMessages = true; + + ServiceProfile profile = CreateProfile( + instanceId, + serverPort, + networkStackId, + serverName, + motd, + requiresPassword, + GetAdvertisedAddresses()); + + discovery.Advertise(profile); + _profile = profile; + _discovery = discovery; + } + catch + { + discovery?.Dispose(); + throw; + } + } + + internal static ServiceProfile CreateProfile( + Guid instanceId, + ushort serverPort, + string networkStackId, + string serverName, + string motd, + bool requiresPassword, + IPAddress[] addresses) + { + 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, + "stack64", + effectiveStackId, + BasisLanDiscoveryProtocol.MaxStackIdBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "name64", + effectiveServerName, + BasisLanDiscoveryProtocol.MaxServerNameBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "motd64", + motd ?? string.Empty, + BasisLanDiscoveryProtocol.MaxMotdBytes); + profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + return profile; + } + + private static IPAddress[] GetAdvertisedAddresses() + { + IPAddress[] addresses = BasisLanAddressUtility.GetPreferredAdvertisedAddresses(); + if (addresses.Length == 0) + { + BNL.LogWarning("Basis LAN address discovery found no usable local addresses."); + } + return addresses; + } + + public void Dispose() + { + ServiceDiscovery discovery; + ServiceProfile profile; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + discovery = _discovery; + profile = _profile; + _discovery = null; + _profile = null; + } + + if (discovery == null) + { + return; + } + + try + { + if (profile != null) + { + discovery.Unadvertise(profile); + } + } + catch (Exception ex) + { + BNL.LogWarning($"Basis LAN DNS-SD goodbye failed: {ex.Message}"); + } + finally + { + discovery.Dispose(); + } + } + } +} diff --git a/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs new file mode 100644 index 0000000000..238d817557 --- /dev/null +++ b/Basis Server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -0,0 +1,498 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Linq; +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 readonly BasisLanIpv4Subnet[] _localIpv4Subnets; + private readonly BasisLanIpv6Subnet[] _localIpv6Subnets; + private MulticastService _mdns; + private ServiceDiscovery _discovery; + private volatile bool _disposed; + + 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; + try + { + mdns = new MulticastService + { + UseIpv4 = ipv4Enabled, + UseIpv6 = ipv6Enabled, + 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(); + mdns?.Dispose(); + _discovery = null; + _mdns = null; + _cancellation.Dispose(); + throw; + } + } + + public void Query() + { + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + discovery = _discovery; + } + + 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) + { + } + 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, + _localIpv4Subnets, + _localIpv6Subnets)) + { + _found(advertisement, address); + } + } + + private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs args) + { + if (_disposed || args == null) + { + return; + } + + if (TryReadInstanceId(args.ServiceInstanceName, out Guid instanceId)) + { + _removed(instanceId); + } + } + + internal static bool TryExtractAdvertisement( + Message message, + DomainName serviceInstanceName, + IPAddress remoteAddress, + out BasisLanAdvertisement advertisement, + out IPAddress address, + IReadOnlyList localIpv4Subnets = null, + IReadOnlyList localIpv6Subnets = null) + { + advertisement = default; + address = null; + if (message == null || serviceInstanceName == null) + { + return false; + } + + IEnumerable records = EnumerateRecords(message); + SRVRecord service = null; + TXTRecord text = null; + foreach (ResourceRecord record in records) + { + if (!Equals(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, + localIpv4Subnets, + localIpv6Subnets); + if (address == null) + { + return false; + } + + string stackId = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "stack64", + BasisLanDiscoveryProtocol.MaxStackIdBytes); + string serverName = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "name64", + BasisLanDiscoveryProtocol.MaxServerNameBytes); + string motd = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "motd64", + BasisLanDiscoveryProtocol.MaxMotdBytes); + advertisement = new BasisLanAdvertisement( + instanceId, + service.Port, + password == "1", + stackId, + serverName, + motd); + return true; + } + + private static bool TryReadInstanceId( + DomainName serviceInstanceName, + out Guid instanceId) + { + instanceId = Guid.Empty; + 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 IEnumerable EnumerateRecords(Message message) + { + return message.Answers + .Concat(message.AuthorityRecords) + .Concat(message.AdditionalRecords) + .Where(record => record != null) + .Take(MaxRecords); + } + + 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 IPAddress SelectAddress( + IEnumerable records, + DomainName hostName, + IPAddress remoteAddress, + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) + { + IPAddress selected = BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) + ? remoteAddress + : null; + bool selectedOnLocalSubnet = IsOnLocalSubnet( + selected, + localIpv4Subnets, + localIpv6Subnets); + + foreach (ResourceRecord record in records) + { + if (!(record is AddressRecord addressRecord) + || !Equals(record.Name, hostName) + || !BasisLanAddressUtility.IsUsable(addressRecord.Address, allowLoopback: true)) + { + continue; + } + + IPAddress candidate = RestoreScope( + addressRecord.Address, + remoteAddress, + localIpv6Subnets); + bool candidateOnLocalSubnet = IsOnLocalSubnet( + candidate, + localIpv4Subnets, + localIpv6Subnets); + if (selected == null + || (candidateOnLocalSubnet && !selectedOnLocalSubnet) + || (candidateOnLocalSubnet == selectedOnLocalSubnet + && BasisLanAddressUtility.PreferenceRank(candidate) + < BasisLanAddressUtility.PreferenceRank(selected))) + { + selected = candidate; + selectedOnLocalSubnet = candidateOnLocalSubnet; + } + } + + 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-link address. + return null; + } + + return selected; + } + + 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 localIpv6Subnets) + { + 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 < localIpv6Subnets.Count; index++) + { + if (BasisLanAddressUtility.IsOnSameIpv6Subnet( + candidate, + localIpv6Subnets[index])) + { + return true; + } + } + return false; + } + + private static IPAddress RestoreScope( + IPAddress address, + IPAddress remoteAddress, + IReadOnlyList localIpv6Subnets) + { + 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); + } + + 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() + { + MulticastService mdns; + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + + discovery = _discovery; + mdns = _mdns; + _discovery = null; + _mdns = null; + if (discovery != null) + { + discovery.ServiceInstanceDiscovered -= OnServiceDiscovered; + discovery.ServiceInstanceShutdown -= OnServiceShutdown; + } + } + + discovery?.Dispose(); + mdns?.Dispose(); + _cancellation.Dispose(); + } + } +} diff --git a/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs b/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs index d22cabb6d9..8137ad69b1 100644 --- a/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs +++ b/Basis Server/BasisNetworkCore/BasisNetworkCommons.cs @@ -378,6 +378,8 @@ 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. + /// 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. diff --git a/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj b/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj index 28d9835d20..c3214fc8b7 100644 --- a/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj +++ b/Basis Server/BasisNetworkCore/BasisNetworkCore.csproj @@ -15,7 +15,9 @@ + + diff --git a/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs b/Basis Server/BasisNetworkCore/BasisServerConfiguration.cs index e909d8d1dd..b6e31646b6 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 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"; /// 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/BasisServerHandleEvents.cs b/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs index 031207051f..625e310a01 100644 --- a/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs +++ b/Basis Server/BasisNetworkServer/BasisServerHandleEvents.cs @@ -254,7 +254,7 @@ public static void HandleConnectionRequest(ConnectionRequest ConReq) } if (NetworkServer.Auth.IsAuthenticated(AuthBytes) == false) { - RejectWithReason(ConReq, "Authentication failed, Auth rejected"); + RejectWithReason(ConReq, BasisNetworkCommons.AuthenticationRejectedReason); return; } } diff --git a/Basis Server/BasisNetworkServer/NetworkServer.cs b/Basis Server/BasisNetworkServer/NetworkServer.cs index 1ce02f3a6f..effb496b0f 100644 --- a/Basis Server/BasisNetworkServer/NetworkServer.cs +++ b/Basis Server/BasisNetworkServer/NetworkServer.cs @@ -24,6 +24,9 @@ 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; /// /// Allow-list consulted at /// when is set to AllowList. @@ -93,6 +96,12 @@ public static void StartServer(Configuration configuration) SetupServer(configuration); SubscribeEvents(Configuration); + lock (_lanAnnouncementGate) + { + _lanServerInstanceId = Guid.NewGuid(); + } + SetLanAdvertising(configuration.AnnounceToLan); + if (configuration.EnableStatistics) { BasisStatistics.StartWorkerThread(Server); @@ -103,8 +112,54 @@ 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 (_lanServerInstanceId == Guid.Empty || 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() { + lock (_lanAnnouncementGate) + { + _lanServerInstanceId = Guid.Empty; + } + SetLanAdvertising(false); + 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 Server/BasisServerTests/BasisLanDiscoveryTests.cs b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs new file mode 100644 index 0000000000..3b80ba149f --- /dev/null +++ b/Basis Server/BasisServerTests/BasisLanDiscoveryTests.cs @@ -0,0 +1,433 @@ +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_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.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); + 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_ReturnsEmpty() + { + Dictionary properties = new(StringComparer.OrdinalIgnoreCase) + { + ["name64-0"] = "not valid base64!", + }; + + string value = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "name64", + BasisLanDiscoveryProtocol.MaxServerNameBytes); + + Assert.Empty(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()); + IPAddress responseSource = IPAddress.Parse("192.168.1.25"); + + 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 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 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() + { + 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) + { + 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, + IReadOnlyList? localIpv4Subnets = null, + IReadOnlyList? localIpv6Subnets = null) + { + Message message = CreateMessage(profile); + + Assert.True(BasisLanServerBrowser.TryExtractAdvertisement( + message, + profile.FullyQualifiedName, + responseSource, + out BasisLanAdvertisement advertisement, + out IPAddress? selectedAddress, + localIpv4Subnets, + localIpv6Subnets)); + address = Assert.IsType(selectedAddress); + return advertisement; + } + + private static Message CreateMessage(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); + } + } + return message; + } + + 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 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); diff --git a/Basis/Assets/Plugins/Android/AndroidManifest.xml b/Basis/Assets/Plugins/Android/AndroidManifest.xml index 59fe93ef62..7d0d01318a 100644 --- a/Basis/Assets/Plugins/Android/AndroidManifest.xml +++ b/Basis/Assets/Plugins/Android/AndroidManifest.xml @@ -27,4 +27,6 @@ + + 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/en.json b/Basis/Packages/com.basis.framework/BasisUI/Localization/Languages/en.json index 51299ed632..0ff62ffc2c 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 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", + "value": "Join {0}" + }, + { + "key": "menu.servers.lanPassword.description", + "value": "The password was rejected. Enter the server password." + }, + { + "key": "menu.servers.list.lanBadge", + "value": "{0} (LAN)" + }, { "key": "menu.servers.status.connecting", "value": "Connecting" 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": "每秒发送给直接对等方的更新次数 — 受你的帧率限制。" }, diff --git a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs index 6843577c80..c0b6690083 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisConnectionService.cs @@ -26,7 +26,23 @@ public static class BasisConnectionService public const string LastConnectedServerIdFile = "LastConnectedServerId.BAS"; public static bool AutoConnectAttempted; - private static bool _connectInProgress; + private static int _connectInProgress; + private static ServerDirectoryEntry _pendingLanPasswordEntry; + + /// + /// 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. + /// + public static event Action LanPasswordRequired; + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetLanPasswordState() + { + Interlocked.Exchange(ref _pendingLanPasswordEntry, null); + LanPasswordRequired = null; + Volatile.Write(ref _connectInProgress, 0); + } // 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 +84,47 @@ public static void ReportConnectionError(string message) public static void CompleteConnectionProgress() => BasisSceneLoad.progressCallback.ReportProgress(ConnectionProgressKey, 100f, string.Empty); + private static void SetPendingLanPasswordAttempt(ServerDirectoryEntry entry) => + Interlocked.Exchange(ref _pendingLanPasswordEntry, entry); + + internal static void ClearPendingLanPasswordAttempt() => + Interlocked.Exchange(ref _pendingLanPasswordEntry, null); + + internal static bool TryHandleLanAuthenticationRejected() + { + ServerDirectoryEntry entry = Interlocked.Exchange(ref _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}", BasisDebug.LogTag.Networking); + 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 @@ -78,12 +135,11 @@ public static void CompleteConnectionProgress() => /// 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")); @@ -111,7 +167,23 @@ 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); + 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; @@ -151,17 +223,19 @@ 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()); } finally { - _connectInProgress = false; + Volatile.Write(ref _connectInProgress, 0); } } 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..945eedbf8a 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. @@ -953,9 +955,16 @@ 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, BasisNetworkCommons.AuthenticationRejectedReason, StringComparison.Ordinal) + && BasisConnectionService.TryHandleLanAuthenticationRejected()) + { + BasisDebug.LogWarning("LAN server rejected authentication; requesting a password from the user.", BasisDebug.LogTag.Networking); + return; + } title = rejected ? "Connection Rejected" : "Server Disconnected"; body = !string.IsNullOrEmpty(reason) ? reason @@ -964,6 +973,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.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..7bf610e9a1 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,35 +9,64 @@ public class BasisNetworkServerRunner { public Task serverTask; - CancellationTokenSource cancellationTokenSource; + private readonly object lifecycleGate = new object(); + private 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) + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetLanAdvertising() + { + NetworkServer.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 { - NetworkServer.StartServer(Configuration); + lock (lifecycleGate) + { + cancellationToken.ThrowIfCancellationRequested(); + NetworkServer.StartServer(Configuration); + } - PermissionIntegration.Manager.AddUserNode(UUIDTomarkAsAdmin,"*"); + cancellationToken.ThrowIfCancellationRequested(); + NetworkServer.SetLanAdvertising(BasisNetworkManagement.HostShowToLan); + PermissionIntegration.Manager.AddUserNode(UUIDTomarkAsAdmin, "*"); PermissionIntegration.Manager.AddUserToGroup(UUIDTomarkAsAdmin, "admin"); } + catch (OperationCanceledException) + { + } catch (Exception ex) { + NetworkServer.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) + { + NetworkServer.SetLanAdvertising(enabled); + } + public void Stop() { - cancellationTokenSource?.Cancel(); + lock (lifecycleGate) + { + cancellationTokenSource?.Cancel(); + } + + // 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/LanServersDirectorySource.cs b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs new file mode 100644 index 0000000000..fab25de781 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs @@ -0,0 +1,459 @@ +using Basis.Network.Core; +using Basis.Scripts.Device_Management; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +namespace Basis.Scripts.Networking +{ + /// + /// 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 long AddressLastSeenTicks; + 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 readonly List _browsers = new List(2); +#if UNITY_ANDROID && !UNITY_EDITOR + private AndroidJavaObject _androidMulticastLock; +#endif + private int _notificationQueued; + private volatile bool _disposed; + + public static LanServersDirectorySource Instance; + + 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(); + for (int index = 0; index < _browsers.Count; index++) + { + _browsers[index].Query(); + } + if (PruneExpired()) + { + QueueSourceChanged(); + } + return Task.CompletedTask; + } + + 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 + { + _browsers.Add(new BasisLanServerBrowser( + ProcessAdvertisement, + RemoveAdvertisement, + useIpv4, + useIpv6)); + } + catch (Exception ex) + { + BasisDebug.LogWarning( + $"LAN DNS-SD {familyName} discovery could not start: {ex.Message}", + BasisDebug.LogTag.Networking); + } + } + + 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}", BasisDebug.LogTag.Networking); + } +#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}", BasisDebug.LogTag.Networking); + } + finally + { + _androidMulticastLock.Dispose(); + _androidMulticastLock = null; + } +#endif + } + + private void ProcessAdvertisement(BasisLanAdvertisement advertisement, IPAddress address) + { + if (_disposed || address == null) + { + return; + } + + bool changed; + long nowTicks = Stopwatch.GetTimestamp(); + lock (_gate) + { + if (_disposed) + { + return; + } + + changed = PruneExpiredLocked(nowTicks); + bool isNew = !_servers.TryGetValue(advertisement.InstanceId, out DiscoveredServer existing); + changed |= isNew; + if (existing == null) + { + if (_servers.Count >= MaxTrackedServers) + { + RemoveOldestServerLocked(); + } + existing = new DiscoveredServer { InstanceId = advertisement.InstanceId }; + _servers.Add(advertisement.InstanceId, existing); + } + + changed |= UpdateAddressLocked(existing, address, nowTicks); + + if (!isNew) + { + 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.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) + { + } + catch (ObjectDisposedException) + { + } + } + + 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 static bool UpdateAddressLocked(DiscoveredServer server, IPAddress address, long nowTicks) + { + if (server.Address == null + || (!Equals(server.Address, address) + && nowTicks - server.AddressLastSeenTicks > EntryLifetimeTicks)) + { + server.Address = address; + server.AddressLastSeenTicks = nowTicks; + return true; + } + + if (Equals(server.Address, address)) + { + server.AddressLastSeenTicks = nowTicks; + } + return false; + } + + 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}", BasisDebug.LogTag.Networking); } + }); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + 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(); + + lock (_gate) + { + _servers.Clear(); + } + } + } +} diff --git a/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs.meta b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs.meta new file mode 100644 index 0000000000..fec3e292c9 --- /dev/null +++ b/Basis/Packages/com.basis.framework/Networking/LanServersDirectorySource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d8b2a2ac49142a084e2a7f24d6880d5 diff --git a/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs b/Basis/Packages/com.basis.provider.servers/Runtime/ServersProvider.cs index faaac52084..874334666e 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"; @@ -37,6 +54,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"; @@ -51,19 +69,37 @@ 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); + 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 +107,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 +124,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 +157,7 @@ public override void RunAction() BuildHeader(container); BuildEditorSection(container); + BuildLanPasswordSection(container); _listContainer = container; _emptyState = PanelElementDescriptor.CreateNew(PanelElementDescriptor.ElementStyles.Group, container); @@ -126,6 +167,7 @@ public override void RunAction() BuildAdvancedSection(container); HideEditor(); + HideLanPasswordPrompt(); SubscribeSourceEvents(); _ = ReloadEntriesAsync(probeAfter: true, autoConnectAfter: true); } @@ -137,6 +179,7 @@ private void OnPanelClosed() _rows.Clear(); _entries.Clear(); _pendingUsernameEntry = null; + _pendingLanPasswordEntry = null; UnsubscribeSourceEvents(); _panel = null; } @@ -147,17 +190,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(); } @@ -168,9 +212,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) @@ -283,6 +329,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 +374,22 @@ 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; + } + + runner.SetLanAdvertising(value); + } + private void PopulateHostStackDropdown() { if (_hostStackDropdown == null) return; @@ -430,6 +498,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 = PanelElementDescriptor.BuildActionRow(_lanPasswordSection.ContentParent, "ServerRowActions"); + + _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 +583,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 +763,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 +771,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 +786,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 +904,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 +924,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 +947,12 @@ private static string DisplayAddress(string address, ushort port) // ── Connection ─────────────────────────────────────────────────────── + private void OnConnectClicked(ServerDirectoryEntry entry) + { + HideLanPasswordPrompt(); + _ = ConnectToAsync(entry); + } + private void TryAutoConnect() { if (BasisConnectionService.AutoConnectAttempted) return; @@ -833,7 +970,7 @@ private void TryAutoConnect() if (target == null) return; _usernameField?.SetValueWithoutNotify(username); - _ = ConnectToAsync(target); + OnConnectClicked(target); } private ServerDirectoryEntry ResolveAutoConnectTarget(string lastId) @@ -867,6 +1004,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; diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisConfigXmlDocs.cs index ba4fe59eff..e1b6bca5d0 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 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 new file mode 100644 index 0000000000..2678e3baf9 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanDiscoveryProtocol.cs @@ -0,0 +1,426 @@ +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; + +namespace Basis.Network.Core +{ + /// Metadata published through the Basis DNS-SD service. + 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; + } + } + + internal readonly struct BasisLanIpv4Subnet + { + public readonly IPAddress Address; + public readonly IPAddress Mask; + + public BasisLanIpv4Subnet(IPAddress address, IPAddress mask) + { + Address = address; + Mask = 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 + { + public static bool IsUsable(IPAddress address, bool allowLoopback = false) + { + if (address == null || (!allowLoopback && IPAddress.IsLoopback(address))) + { + return false; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + byte firstOctet = address.GetAddressBytes()[0]; + return !address.Equals(IPAddress.Any) + && !address.Equals(IPAddress.Broadcast) + && (firstOctet < 224 || firstOctet > 239); + } + + return address.AddressFamily == AddressFamily.InterNetworkV6 + && !address.Equals(IPAddress.IPv6Any) + && !address.IsIPv6Multicast; + } + + public static int PreferenceRank(IPAddress address) + { + if (address?.AddressFamily == AddressFamily.InterNetwork) + { + byte[] bytes = address.GetAddressBytes(); + return bytes[0] == 169 && bytes[1] == 254 ? 2 : 0; + } + + return address?.AddressFamily == AddressFamily.InterNetworkV6 + ? 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 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) + { + 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; + } + + 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. + internal static class BasisLanDiscoveryProtocol + { + private static readonly UTF8Encoding StrictUtf8 = new UTF8Encoding(false, true); + + internal const string ServiceName = "_basisdemo._udp"; + internal const string ProtocolVersion = "1"; + internal const int MaxStackIdBytes = 64; + internal const int MaxServerNameBytes = 128; + internal const int MaxMotdBytes = 384; + + 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); + } + + internal static void AddMetadata( + ServiceProfile profile, + string encodedKey, + string value, + int maxBytes) + { + if (profile == null) throw new ArgumentNullException(nameof(profile)); + if (string.IsNullOrEmpty(encodedKey)) throw new ArgumentException("TXT key is required.", nameof(encodedKey)); + + string encoded = Convert.ToBase64String( + Encoding.UTF8.GetBytes(LimitUtf8(value, maxBytes))); + for (int index = 0, offset = 0; offset < encoded.Length; index++) + { + string chunkKey = $"{encodedKey}-{index}"; + int chunkLength = Math.Min(TxtValueBudget(chunkKey), encoded.Length - offset); + profile.AddProperty(chunkKey, encoded.Substring(offset, chunkLength)); + offset += chunkLength; + } + } + + internal static string ReadMetadata( + Dictionary properties, + string encodedKey, + int maxBytes) + { + int maxEncodedLength = ((maxBytes + 2) / 3) * 4; + StringBuilder encoded = new StringBuilder(maxEncodedLength); + for (int index = 0; + properties.TryGetValue($"{encodedKey}-{index}", out string chunk); + index++) + { + if (chunk.Length > maxEncodedLength - encoded.Length) + { + return string.Empty; + } + encoded.Append(chunk); + } + + if (encoded.Length == 0) + { + return string.Empty; + } + + 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) + { + return 254 - Encoding.ASCII.GetByteCount(key); + } + } +} 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/BasisLanServerAnnouncer.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs new file mode 100644 index 0000000000..fbbeffe256 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerAnnouncer.cs @@ -0,0 +1,155 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System; +using System.Net; + +namespace Basis.Network.Core +{ + /// + /// 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 + { + private readonly object _gate = new object(); + private ServiceDiscovery _discovery; + private ServiceProfile _profile; + 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)); + } + + ServiceDiscovery discovery = null; + try + { + discovery = new ServiceDiscovery(); + discovery.Mdns.IgnoreDuplicateMessages = true; + + ServiceProfile profile = CreateProfile( + instanceId, + serverPort, + networkStackId, + serverName, + motd, + requiresPassword, + GetAdvertisedAddresses()); + + discovery.Advertise(profile); + _profile = profile; + _discovery = discovery; + } + catch + { + discovery?.Dispose(); + throw; + } + } + + internal static ServiceProfile CreateProfile( + Guid instanceId, + ushort serverPort, + string networkStackId, + string serverName, + string motd, + bool requiresPassword, + IPAddress[] addresses) + { + 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, + "stack64", + effectiveStackId, + BasisLanDiscoveryProtocol.MaxStackIdBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "name64", + effectiveServerName, + BasisLanDiscoveryProtocol.MaxServerNameBytes); + BasisLanDiscoveryProtocol.AddMetadata( + profile, + "motd64", + motd ?? string.Empty, + BasisLanDiscoveryProtocol.MaxMotdBytes); + profile.AddProperty("pwd", requiresPassword ? "1" : "0"); + return profile; + } + + private static IPAddress[] GetAdvertisedAddresses() + { + IPAddress[] addresses = BasisLanAddressUtility.GetPreferredAdvertisedAddresses(); + if (addresses.Length == 0) + { + BNL.LogWarning("Basis LAN address discovery found no usable local addresses."); + } + return addresses; + } + + public void Dispose() + { + ServiceDiscovery discovery; + ServiceProfile profile; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + discovery = _discovery; + profile = _profile; + _discovery = null; + _profile = null; + } + + if (discovery == null) + { + return; + } + + try + { + if (profile != null) + { + discovery.Unadvertise(profile); + } + } + catch (Exception ex) + { + BNL.LogWarning($"Basis LAN DNS-SD goodbye failed: {ex.Message}"); + } + finally + { + discovery.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/BasisLanServerBrowser.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs new file mode 100644 index 0000000000..238d817557 --- /dev/null +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisLanServerBrowser.cs @@ -0,0 +1,498 @@ +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Linq; +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 readonly BasisLanIpv4Subnet[] _localIpv4Subnets; + private readonly BasisLanIpv6Subnet[] _localIpv6Subnets; + private MulticastService _mdns; + private ServiceDiscovery _discovery; + private volatile bool _disposed; + + 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; + try + { + mdns = new MulticastService + { + UseIpv4 = ipv4Enabled, + UseIpv6 = ipv6Enabled, + 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(); + mdns?.Dispose(); + _discovery = null; + _mdns = null; + _cancellation.Dispose(); + throw; + } + } + + public void Query() + { + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + discovery = _discovery; + } + + 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) + { + } + 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, + _localIpv4Subnets, + _localIpv6Subnets)) + { + _found(advertisement, address); + } + } + + private void OnServiceShutdown(object sender, ServiceInstanceShutdownEventArgs args) + { + if (_disposed || args == null) + { + return; + } + + if (TryReadInstanceId(args.ServiceInstanceName, out Guid instanceId)) + { + _removed(instanceId); + } + } + + internal static bool TryExtractAdvertisement( + Message message, + DomainName serviceInstanceName, + IPAddress remoteAddress, + out BasisLanAdvertisement advertisement, + out IPAddress address, + IReadOnlyList localIpv4Subnets = null, + IReadOnlyList localIpv6Subnets = null) + { + advertisement = default; + address = null; + if (message == null || serviceInstanceName == null) + { + return false; + } + + IEnumerable records = EnumerateRecords(message); + SRVRecord service = null; + TXTRecord text = null; + foreach (ResourceRecord record in records) + { + if (!Equals(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, + localIpv4Subnets, + localIpv6Subnets); + if (address == null) + { + return false; + } + + string stackId = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "stack64", + BasisLanDiscoveryProtocol.MaxStackIdBytes); + string serverName = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "name64", + BasisLanDiscoveryProtocol.MaxServerNameBytes); + string motd = BasisLanDiscoveryProtocol.ReadMetadata( + properties, + "motd64", + BasisLanDiscoveryProtocol.MaxMotdBytes); + advertisement = new BasisLanAdvertisement( + instanceId, + service.Port, + password == "1", + stackId, + serverName, + motd); + return true; + } + + private static bool TryReadInstanceId( + DomainName serviceInstanceName, + out Guid instanceId) + { + instanceId = Guid.Empty; + 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 IEnumerable EnumerateRecords(Message message) + { + return message.Answers + .Concat(message.AuthorityRecords) + .Concat(message.AdditionalRecords) + .Where(record => record != null) + .Take(MaxRecords); + } + + 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 IPAddress SelectAddress( + IEnumerable records, + DomainName hostName, + IPAddress remoteAddress, + IReadOnlyList localIpv4Subnets, + IReadOnlyList localIpv6Subnets) + { + IPAddress selected = BasisLanAddressUtility.IsUsable(remoteAddress, allowLoopback: true) + ? remoteAddress + : null; + bool selectedOnLocalSubnet = IsOnLocalSubnet( + selected, + localIpv4Subnets, + localIpv6Subnets); + + foreach (ResourceRecord record in records) + { + if (!(record is AddressRecord addressRecord) + || !Equals(record.Name, hostName) + || !BasisLanAddressUtility.IsUsable(addressRecord.Address, allowLoopback: true)) + { + continue; + } + + IPAddress candidate = RestoreScope( + addressRecord.Address, + remoteAddress, + localIpv6Subnets); + bool candidateOnLocalSubnet = IsOnLocalSubnet( + candidate, + localIpv4Subnets, + localIpv6Subnets); + if (selected == null + || (candidateOnLocalSubnet && !selectedOnLocalSubnet) + || (candidateOnLocalSubnet == selectedOnLocalSubnet + && BasisLanAddressUtility.PreferenceRank(candidate) + < BasisLanAddressUtility.PreferenceRank(selected))) + { + selected = candidate; + selectedOnLocalSubnet = candidateOnLocalSubnet; + } + } + + 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-link address. + return null; + } + + return selected; + } + + 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 localIpv6Subnets) + { + 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 < localIpv6Subnets.Count; index++) + { + if (BasisLanAddressUtility.IsOnSameIpv6Subnet( + candidate, + localIpv6Subnets[index])) + { + return true; + } + } + return false; + } + + private static IPAddress RestoreScope( + IPAddress address, + IPAddress remoteAddress, + IReadOnlyList localIpv6Subnets) + { + 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); + } + + 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() + { + MulticastService mdns; + ServiceDiscovery discovery; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + try { _cancellation.Cancel(); } + catch (ObjectDisposedException) { } + + discovery = _discovery; + mdns = _mdns; + _discovery = null; + _mdns = null; + if (discovery != null) + { + discovery.ServiceInstanceDiscovered -= OnServiceDiscovered; + discovery.ServiceInstanceShutdown -= OnServiceShutdown; + } + } + + discovery?.Dispose(); + mdns?.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/BasisNetworkCommons.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs index d22cabb6d9..8137ad69b1 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisNetworkCommons.cs @@ -378,6 +378,8 @@ 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. + /// 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. diff --git a/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs b/Basis/Packages/com.basis.server/BasisNetworkCore/BasisServerConfiguration.cs index e909d8d1dd..b6e31646b6 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 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"; /// 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/BasisServerHandleEvents.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs index 031207051f..625e310a01 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/BasisServerHandleEvents.cs @@ -254,7 +254,7 @@ public static void HandleConnectionRequest(ConnectionRequest ConReq) } if (NetworkServer.Auth.IsAuthenticated(AuthBytes) == false) { - RejectWithReason(ConReq, "Authentication failed, Auth rejected"); + RejectWithReason(ConReq, BasisNetworkCommons.AuthenticationRejectedReason); return; } } diff --git a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs index 1ce02f3a6f..effb496b0f 100644 --- a/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs +++ b/Basis/Packages/com.basis.server/BasisNetworkServer/NetworkServer.cs @@ -24,6 +24,9 @@ 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; /// /// Allow-list consulted at /// when is set to AllowList. @@ -93,6 +96,12 @@ public static void StartServer(Configuration configuration) SetupServer(configuration); SubscribeEvents(Configuration); + lock (_lanAnnouncementGate) + { + _lanServerInstanceId = Guid.NewGuid(); + } + SetLanAdvertising(configuration.AnnounceToLan); + if (configuration.EnableStatistics) { BasisStatistics.StartWorkerThread(Server); @@ -103,8 +112,54 @@ 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 (_lanServerInstanceId == Guid.Empty || 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() { + lock (_lanAnnouncementGate) + { + _lanServerInstanceId = Guid.Empty; + } + SetLanAdvertising(false); + 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(); 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" } }