/ 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  }