/ GUNRPG.ConsoleClient / LanDiscoveryService.cs
LanDiscoveryService.cs
1 using System.Net; 2 using Makaretu.Dns; 3 4 /// <summary> 5 /// Discovers GUNRPG servers on the local network using mDNS. 6 /// </summary> 7 internal static class LanDiscoveryService 8 { 9 public record DiscoveredServer(string DisplayName, string Hostname, int Port, string? Version, string? Environment, string Scheme = "http"); 10 11 /// <summary> 12 /// Queries for _gunrpg._tcp service instances on the LAN and returns any found within the given timeout. 13 /// </summary> 14 public static async Task<List<DiscoveredServer>> DiscoverAsync(TimeSpan timeout) 15 { 16 var discovered = new List<DiscoveredServer>(); 17 using var cts = new CancellationTokenSource(timeout); 18 using var mdns = new MulticastService(); 19 using var sd = new ServiceDiscovery(mdns); 20 21 sd.ServiceInstanceDiscovered += (_, e) => 22 { 23 if (cts.IsCancellationRequested) return; 24 25 // Filter to only _gunrpg._tcp service instances; the mDNS multicast can 26 // surface responses from unrelated services on the same network. 27 var instLabels = e.ServiceInstanceName.Labels; 28 if (!instLabels.Any(l => string.Equals(l, "_gunrpg", StringComparison.OrdinalIgnoreCase))) 29 return; 30 31 var srv = e.Message.Answers.OfType<SRVRecord>().FirstOrDefault() 32 ?? e.Message.AdditionalRecords.OfType<SRVRecord>().FirstOrDefault(); 33 if (srv == null) return; 34 35 var txt = e.Message.Answers.OfType<TXTRecord>().FirstOrDefault() 36 ?? e.Message.AdditionalRecords.OfType<TXTRecord>().FirstOrDefault(); 37 38 var version = txt?.Strings 39 .FirstOrDefault(s => s.StartsWith("version=", StringComparison.Ordinal)) 40 ?["version=".Length..]; 41 var env = txt?.Strings 42 .FirstOrDefault(s => s.StartsWith("environment=", StringComparison.Ordinal)) 43 ?["environment=".Length..]; 44 var scheme = txt?.Strings 45 .FirstOrDefault(s => s.StartsWith("scheme=", StringComparison.Ordinal)) 46 ?["scheme=".Length..] ?? "http"; 47 48 // DomainName.Labels gives us the decoded label strings without DNS escape 49 // sequences — no regex needed. Strip the trailing empty root label if present. 50 var hostname = string.Join(".", srv.Target.Labels.Where(l => !string.IsNullOrEmpty(l))); 51 52 // If the decoded hostname is still not valid in a URI (e.g., the SRV target 53 // was derived from an instance name containing spaces), fall back to a routable 54 // address from the A/AAAA records included in the mDNS response. Search both 55 // the Answers and AdditionalRecords sections in case the responder places them 56 // in either section. 57 if (Uri.CheckHostName(hostname) == UriHostNameType.Unknown) 58 { 59 var ip = e.Message.Answers.Concat(e.Message.AdditionalRecords) 60 .OfType<AddressRecord>() 61 .Select(r => r.Address) 62 .FirstOrDefault(a => 63 !IPAddress.IsLoopback(a) 64 && !(a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && a.IsIPv6LinkLocal) 65 && !(a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork 66 && a.GetAddressBytes() is [169, 254, ..])); 67 if (ip == null) return; 68 hostname = ip.ToString(); 69 } 70 71 // Human-readable display name from the first label of the service instance name 72 // (e.g., "GUNRPG Server" from "GUNRPG Server._gunrpg._tcp.local"). 73 var displayName = e.ServiceInstanceName.Labels.FirstOrDefault(l => !string.IsNullOrEmpty(l)) ?? "Unknown Server"; 74 75 var port = srv.Port; 76 77 lock (discovered) 78 { 79 if (!discovered.Any(d => d.Hostname == hostname && d.Port == port)) 80 discovered.Add(new DiscoveredServer(displayName, hostname, port, version, env, scheme)); 81 } 82 }; 83 84 try 85 { 86 mdns.Start(); 87 sd.QueryServiceInstances("_gunrpg._tcp"); 88 89 try 90 { 91 await Task.Delay(timeout, cts.Token); 92 } 93 catch (OperationCanceledException) 94 { 95 // Timeout expired normally — discovery window has closed. 96 } 97 } 98 finally 99 { 100 mdns.Stop(); 101 } 102 return discovered; 103 } 104 }