From 13f53417703c7766d4db4067d8c88c6a5e8a2cd2 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Thu, 3 Jul 2025 16:48:28 -0300 Subject: [PATCH 01/37] init Akka.Discovery.Dns only A/AAAA records are supported at this moment --- Akka.Management.sln | 17 ++ .../Akka.Discovery.Dns.Tests.csproj | 26 +++ .../DnsServiceDiscoverySpec.cs | 93 +++++++++ .../Akka.Discovery.Dns.csproj | 24 +++ .../AkkaHostingExtensions.cs | 72 +++++++ .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 134 ++++++++++++ .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 194 ++++++++++++++++++ .../dns/Akka.Discovery.Dns/README.md | 5 + .../dns/Akka.Discovery.Dns/reference.conf | 11 + 9 files changed, 576 insertions(+) create mode 100644 src/discovery/dns/Akka.Discovery.Dns.Tests/Akka.Discovery.Dns.Tests.csproj create mode 100644 src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Akka.Discovery.Dns.csproj create mode 100644 src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/README.md create mode 100644 src/discovery/dns/Akka.Discovery.Dns/reference.conf diff --git a/Akka.Management.sln b/Akka.Management.sln index 2421dba64..4abb6a52d 100644 --- a/Akka.Management.sln +++ b/Akka.Management.sln @@ -80,6 +80,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.StressTest", "src\coo EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HoconKubernetesCluster", "src\cluster.bootstrap\examples\discovery\hocon-kubernetes\src\HoconKubernetesCluster\HoconKubernetesCluster.csproj", "{7015572E-6225-4F32-A00C-4D52280E6C94}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dns", "dns", "{CF2BA83A-4CF9-4E29-9128-A7A3D3ADBB35}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Discovery.Dns", "src\discovery\dns\Akka.Discovery.Dns\Akka.Discovery.Dns.csproj", "{5C5403F4-F496-4246-8BEC-E125E1A2BC94}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Discovery.Dns.Tests", "src\discovery\dns\Akka.Discovery.Dns.Tests\Akka.Discovery.Dns.Tests.csproj", "{2E13DE8F-0001-4008-A066-B66B4B640915}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -174,6 +180,14 @@ Global {7015572E-6225-4F32-A00C-4D52280E6C94}.Debug|Any CPU.Build.0 = Debug|Any CPU {7015572E-6225-4F32-A00C-4D52280E6C94}.Release|Any CPU.ActiveCfg = Release|Any CPU {7015572E-6225-4F32-A00C-4D52280E6C94}.Release|Any CPU.Build.0 = Release|Any CPU + {5C5403F4-F496-4246-8BEC-E125E1A2BC94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C5403F4-F496-4246-8BEC-E125E1A2BC94}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C5403F4-F496-4246-8BEC-E125E1A2BC94}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C5403F4-F496-4246-8BEC-E125E1A2BC94}.Release|Any CPU.Build.0 = Release|Any CPU + {2E13DE8F-0001-4008-A066-B66B4B640915}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2E13DE8F-0001-4008-A066-B66B4B640915}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E13DE8F-0001-4008-A066-B66B4B640915}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2E13DE8F-0001-4008-A066-B66B4B640915}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -209,6 +223,9 @@ Global {296A0309-2370-4153-B3B0-A4105622CC9A} = {C4C8BE03-A7C0-4F2E-8922-92854FE7A5BF} {24FEBA95-1FC2-4AC2-8386-6AB13AE87056} = {9B10ADF5-60D1-4EED-9E98-9CB2E1E84E98} {7015572E-6225-4F32-A00C-4D52280E6C94} = {24FEBA95-1FC2-4AC2-8386-6AB13AE87056} + {CF2BA83A-4CF9-4E29-9128-A7A3D3ADBB35} = {52ACBC5B-D5F0-4FEA-A27B-0A8577204E64} + {5C5403F4-F496-4246-8BEC-E125E1A2BC94} = {CF2BA83A-4CF9-4E29-9128-A7A3D3ADBB35} + {2E13DE8F-0001-4008-A066-B66B4B640915} = {CF2BA83A-4CF9-4E29-9128-A7A3D3ADBB35} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B99E6BB8-642A-4A68-86DF-69567CBA700A} diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/Akka.Discovery.Dns.Tests.csproj b/src/discovery/dns/Akka.Discovery.Dns.Tests/Akka.Discovery.Dns.Tests.csproj new file mode 100644 index 000000000..7e6aec488 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/Akka.Discovery.Dns.Tests.csproj @@ -0,0 +1,26 @@ + + + + $(TestsNet) + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs new file mode 100644 index 000000000..e8dea7a38 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs @@ -0,0 +1,93 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Linq; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Actor.Setup; +using Akka.Configuration; +using Akka.TestKit.Xunit2; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Discovery.Dns.Tests +{ + public class DnsServiceDiscoverySpec : TestKit.Xunit2.TestKit + { + // akka.io.dns.resolver = async-dns + public DnsServiceDiscoverySpec(ITestOutputHelper output) + : base(ConfigurationFactory.ParseString(@" + akka.discovery { + method = akka-dns + akka-dns { + class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" + } + } + "), "dns-discovery", output) + { + } + + [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] + public void DnsServiceDiscoveryShouldBeLoadableViaConfig() + { + var serviceDiscovery = Discovery.Get(Sys).LoadServiceDiscovery("akka-dns"); + serviceDiscovery.Should().BeOfType(); + } + + [Fact(DisplayName = "DnsServiceDiscovery should handle A/AAAA record lookup")] + public async Task DnsServiceDiscoveryShouldHandleIpLookup() + { + // Use the actual DNS resolver to look up a known domain + var discovery = Discovery.Get(Sys).LoadServiceDiscovery("akka-dns"); + + // Lookup a domain that should always exist and resolve + var host = "getakka.net"; + var lookup = new Lookup(host); + var resolved = await discovery.Lookup(lookup, TimeSpan.FromSeconds(10)); + + resolved.Should().NotBeNull(); + resolved.Addresses.Should().NotBeEmpty(); + + // The resolved addresses should have host names but no port (since we're doing A/AAAA lookup) + foreach (var address in resolved.Addresses) + { + address.Host.Should().NotBeNullOrEmpty(); + address.Port.HasValue.Should().BeFalse(); + } + this.Output.WriteLine("Resolved host {0} into addresses: {1}", host, resolved); + } + + [Fact(DisplayName = "DnsServiceDiscovery should construct correct SRV record query")] + public void DnsServiceDiscoveryShouldConstructCorrectSrvQuery() + { + // This test validates that the SRV record query is correctly formatted + var discovery = new TestDnsServiceDiscovery((ExtendedActorSystem)Sys); + + var lookup = new Lookup("myservice.example.com") + .WithPortName("http") + .WithProtocol("tcp"); + + var srvRequest = discovery.TestGetSrvRequest(lookup); + + srvRequest.Should().Be("_http._tcp.myservice.example.com"); + } + + // Helper test class that exposes some internals for testing + private class TestDnsServiceDiscovery : DnsServiceDiscovery + { + public TestDnsServiceDiscovery(ExtendedActorSystem system) : base(system) + { + } + + public string TestGetSrvRequest(Lookup lookup) + { + return $"_{lookup.PortName}._{lookup.Protocol}.{lookup.ServiceName}"; + } + } + } +} diff --git a/src/discovery/dns/Akka.Discovery.Dns/Akka.Discovery.Dns.csproj b/src/discovery/dns/Akka.Discovery.Dns/Akka.Discovery.Dns.csproj new file mode 100644 index 000000000..275c5e275 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Akka.Discovery.Dns.csproj @@ -0,0 +1,24 @@ + + + + $(LibraryFramework);$(NetFramework) + Akka.NET DNS discovery module + $(AkkaPackageTags);DNS; + true + README.md + + + + + + + + + + + + + + + + diff --git a/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs new file mode 100644 index 000000000..188750e15 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs @@ -0,0 +1,72 @@ + +using System; +using Akka.Actor.Setup; +using Akka.Hosting; + +namespace Akka.Discovery.Dns +{ + /// + /// Extensions for configuring DNS-based service discovery with the Akka.Hosting API. + /// + public static class AkkaHostingExtensions + { + /// + /// Adds DNS service discovery to the . + /// + /// The builder instance. + /// Action that configures the . + /// The same builder instance. + public static AkkaConfigurationBuilder WithDnsDiscovery( + this AkkaConfigurationBuilder builder, + Action configure) + { + var options = new DnsDiscoveryOptions(); + configure(options); + options.Apply(builder); + builder.AddSetup(new DnsDiscoverySetup { DiscoveryId = options.ConfigPath }); + + return builder; + } + + /// + /// Adds DNS service discovery to the with default options. + /// + /// The builder instance. + /// The same builder instance. + public static AkkaConfigurationBuilder WithDnsDiscovery( + this AkkaConfigurationBuilder builder) + { + return builder.WithDnsDiscovery(_ => { }); + } + + /// + /// Adds DNS service discovery to the with the specified options. + /// + /// The builder instance. + /// The options. + /// The same builder instance. + public static AkkaConfigurationBuilder WithDnsDiscovery( + this AkkaConfigurationBuilder builder, + DnsDiscoveryOptions options) + { + options.Apply(builder); + builder.AddSetup(new DnsDiscoverySetup { DiscoveryId = options.ConfigPath }); + + return builder; + } + + /// + /// Sets DNS as the default discovery method for Akka.NET. + /// + /// The builder instance. + /// The discovery ID. + /// The same builder instance. + public static AkkaConfigurationBuilder WithDnsDiscoveryDefault( + this AkkaConfigurationBuilder builder, + string discoveryId = DnsDiscoveryOptions.DefaultPath) + { + builder.AddHocon("akka.discovery.method = " + discoveryId, HoconAddMode.Prepend); + return builder; + } + } +} diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs new file mode 100644 index 000000000..e0f7933a4 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Akka.Actor.Setup; +using Akka.Hosting; + +namespace Akka.Discovery.Dns +{ + /// + /// Options class for configuring the DNS service discovery. + /// + public class DnsDiscoveryOptions : IDiscoveryOptions + { + /// + /// Default configuration path for DNS service discovery + /// + public const string DefaultPath = "akka-dns"; + + /// + /// The default configuration path for DNS service discovery + /// + public const string DefaultConfigPath = "akka.discovery." + DefaultPath; + + /// + /// Gets the full configuration path for the specified path. + /// + /// The path. + /// The full configuration path. + public static string FullPath(string path) => $"akka.discovery.{path}"; + + /// + /// Gets the type of service discovery class. + /// + public Type Class { get; } = typeof(DnsServiceDiscovery); + + /// + /// Gets or sets the configuration path. + /// + public string ConfigPath { get; set; } = DefaultPath; + + /// + /// Renders HOCON configuration based on current settings. + /// + /// HOCON configuration string. + private string ToHocon() + { + var sb = new StringBuilder(); + sb.AppendLine($"{FullPath(ConfigPath)} {{"); + sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\""); + sb.AppendLine("}"); + + return sb.ToString(); + } + /// + /// + /// + /// + public void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) + { + builder.AddHocon(ToHocon(), HoconAddMode.Prepend); + } + } + + /// + /// Setup class for configuring the DNS service discovery. + /// + public class DnsDiscoverySetup : Setup + { + /// + /// Gets or sets the discovery ID. + /// + public string DiscoveryId { get; set; } = DnsDiscoveryOptions.DefaultPath; + + // Other configuration options can be added here + + /// + /// Applies the setup to the provided settings. + /// + /// The updated settings. + internal DnsDiscoverySettings Apply(DnsDiscoverySettings settings) + { + return settings; // No custom settings yet + } + } + + /// + /// Settings class for the DNS service discovery. + /// + public class DnsDiscoverySettings + { + /// + /// Gets an empty settings instance. + /// + public static readonly DnsDiscoverySettings Empty = new DnsDiscoverySettings(); + + /// + /// Creates settings from an Akka ActorSystem. + /// + /// The actor system. + /// The settings. + public static DnsDiscoverySettings Create(Akka.Actor.ActorSystem system) + => Create(system.Settings.Config); + + /// + /// Creates settings from configuration. + /// + /// The configuration. + /// The settings. + public static DnsDiscoverySettings Create(Akka.Configuration.Config config) + { + return new DnsDiscoverySettings(); + } + } + + /// + /// Multi-setup class for configuring multiple DNS discovery instances. + /// + public class DnsDiscoveryMultiSetup : Setup + { + /// + /// Gets the setups. + /// + public IReadOnlyDictionary Setups { get; } + + /// + /// Creates a new instance of the class. + /// + /// The setups. + public DnsDiscoveryMultiSetup(IReadOnlyDictionary setups) + { + Setups = setups ?? throw new ArgumentNullException(nameof(setups)); + } + } +} diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs new file mode 100644 index 000000000..65f3fddfc --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Event; +using Akka.IO; + +namespace Akka.Discovery.Dns; + +/// +/// DNS-based service discovery implementation. +/// +public class DnsServiceDiscovery : ServiceDiscovery +{ + private readonly ILoggingAdapter _log; + private readonly DnsExt _dns; + private readonly ExtendedActorSystem _system; + + public DnsServiceDiscovery(ExtendedActorSystem system) + { + _system = system; + _log = Logging.GetLogger(system, typeof(DnsServiceDiscovery)); + + var dnsResolver = _system.Settings.Config.GetString("akka.io.dns.resolver"); + switch (dnsResolver) + { + case "inet-address": + _dns = Akka.IO.Dns.Instance.CreateExtension(_system); + break; + default: + throw new NotImplementedException(); + + } + } + + + /// + /// Cleans an IP string by removing leading '/' if present. + /// + private string CleanIpString(string ipString) => + ipString.StartsWith("/") ? ipString.Substring(1) : ipString; + + public override async Task Lookup(Lookup lookup, TimeSpan resolveTimeout) + { + if (!string.IsNullOrWhiteSpace(lookup.PortName) && !string.IsNullOrWhiteSpace(lookup.Protocol)) + return await LookupSrv(lookup, resolveTimeout); + else + return await LookupIp(lookup, resolveTimeout); + } + + private async Task LookupSrv(Lookup lookup, TimeSpan resolveTimeout) + { + var srvRequest = $"_{lookup.PortName}._{lookup.Protocol}.{lookup.ServiceName}"; + _log.Debug("Lookup [{0}] translated to SRV query [{1}] as contains portName and protocol", lookup, srvRequest); + var resolved = _dns.Cache.Cached(srvRequest); + if (resolved == null) + { + return await AskResolve(srvRequest, resolveTimeout); + } + return SrvRecordsToResolved(srvRequest, resolved); + } + + private async Task LookupIp(Lookup lookup, TimeSpan resolveTimeout) + { + _log.Debug("Lookup[{0}] translated to A/AAAA lookup as does not have portName and protocol", lookup); + + var resolved = _dns.Cache.Cached(lookup.ServiceName); + if (resolved == null) + { + return await AskResolveIp(lookup.ServiceName, resolveTimeout); + } + return IpRecordsToResolved(lookup.ServiceName, resolved); + } + + private async Task AskResolveIp(string serviceName, TimeSpan timeout) + { + try + { + var result = await _dns.Manager.Ask(new Akka.IO.Dns.Resolve(serviceName), timeout); + + if (result is IO.Dns.Resolved resolved) + { + _log.Debug("lookup result: {0}", resolved); + return IpRecordsToResolved(serviceName, resolved); + } + + _log.Warning("Resolved UNEXPECTED (resolving to Nil): {0}", result.GetType()); + return new Resolved(serviceName, ImmutableList.Empty); + } + catch (AskTimeoutException) + { + throw new TimeoutException($"Dns resolve did not respond within {timeout}"); + } + catch (Exception ex) + { + _log.Error(ex, "Error during DNS resolution"); + throw; + } + } + + private async Task AskResolve(string srvRequest, TimeSpan timeout) + { + try + { + var result = await _dns.Manager.Ask(new IO.Dns.Resolve(srvRequest), timeout); + + if (result is IO.Dns.Resolved resolved) + { + _log.Debug("Lookup result: {0}", resolved); + return SrvRecordsToResolved(srvRequest, resolved); + } + + _log.Warning("Resolved UNEXPECTED (resolving to Nil): {0}", result.GetType()); + return new Resolved(srvRequest, ImmutableList.Empty); + } + catch (AskTimeoutException) + { + throw new TimeoutException($"Dns resolve did not respond within {timeout}"); + } + catch (Exception ex) + { + _log.Error(ex, "Error during DNS resolution"); + throw; + } + } + + /// + /// Converts SRV records to a Resolved object. + /// + private Resolved SrvRecordsToResolved(string srvRequest, Akka.IO.Dns.Resolved resolved) + { + // var ips = new Dictionary>(); + + // Build a map of hostname to IP addresses from additional records + // foreach (var aRecord in resolved.Ipv4) + // { + // if (!ips.TryGetValue(aRecord.Name, out var aIps)) + // { + // aIps = new List(); + // ips[aRecord.Name] = aIps; + // } + // + // aIps.Add(aRecord.Ip); + // } + // foreach (var record in resolved.Ipv6) { + // if (!ips.TryGetValue(aaaaRecord.Name, out var aaaaIps)) + // { + // aaaaIps = new List(); + // ips[aaaaRecord.Name] = aaaaIps; + // } + // + // aaaaIps.Add(aaaaRecord.Ip); + // break; + // } + // + // var addresses = resolved.Records.OfType() + // .SelectMany(srv => + // { + // if (ips.TryGetValue(srv.Target, out var ipList) && ipList.Count > 0) + // { + // return ipList.Select(ip => new ResolvedTarget(srv.Target, srv.Port, ip)); + // } + // else + // { + // return new[] { new ResolvedTarget(srv.Target, srv.Port, null) }; + // } + // }) + // .ToImmutableList(); + + return new Resolved(srvRequest, []); + } + + /// + /// Converts IP records to a Resolved object. + /// + private Resolved IpRecordsToResolved(string serviceName, Akka.IO.Dns.Resolved resolved) + { + var addresses = + new[] + { + resolved.Ipv4.Select(aRecord => + new ResolvedTarget(CleanIpString(aRecord.ToString()), null, aRecord)), + resolved.Ipv6.Select(aaaaRecord => + new ResolvedTarget(CleanIpString(aaaaRecord.ToString()), null, aaaaRecord)) + } + .SelectMany(x => x) + .ToImmutableList(); + + return new Resolved(serviceName, addresses); + } +} diff --git a/src/discovery/dns/Akka.Discovery.Dns/README.md b/src/discovery/dns/Akka.Discovery.Dns/README.md new file mode 100644 index 000000000..119ba4d2c --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/README.md @@ -0,0 +1,5 @@ +# Discovery via DNS + +This module provides a service discovery mechanism that uses DNS to locate services. + +# TODO \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/reference.conf b/src/discovery/dns/Akka.Discovery.Dns/reference.conf new file mode 100644 index 000000000..74b811300 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/reference.conf @@ -0,0 +1,11 @@ +###################################################### +# Akka Service Discovery DNS Config # +###################################################### + +akka.discovery { + # Set the following in your application.conf if you want to use this discovery mechanism: + # method = akka-dns + akka-dns { + class = "Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns" + } +} From 94f10ebdd5481258dd2355b180fd45c5466ef479 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Fri, 4 Jul 2025 18:23:16 -0300 Subject: [PATCH 02/37] init dns cluster example --- Akka.Management.sln | 7 + .../examples/discovery/dns/README.md | 3 + .../examples/discovery/dns/build.ps1 | 9 + .../discovery/dns/src/DnsCluster.csproj | 39 ++++ .../examples/discovery/dns/src/Dockerfile | 18 ++ .../examples/discovery/dns/src/Program.cs | 221 ++++++++++++++++++ .../discovery/dns/src/docker-compose.yml | 70 ++++++ .../examples/discovery/dns/src/entrypoint.sh | 25 ++ .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 16 +- 9 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 src/cluster.bootstrap/examples/discovery/dns/README.md create mode 100755 src/cluster.bootstrap/examples/discovery/dns/build.ps1 create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/DnsCluster.csproj create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/Dockerfile create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/Program.cs create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.yml create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh diff --git a/Akka.Management.sln b/Akka.Management.sln index 4abb6a52d..28ad49ecb 100644 --- a/Akka.Management.sln +++ b/Akka.Management.sln @@ -86,6 +86,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Discovery.Dns", "src\d EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Akka.Discovery.Dns.Tests", "src\discovery\dns\Akka.Discovery.Dns.Tests\Akka.Discovery.Dns.Tests.csproj", "{2E13DE8F-0001-4008-A066-B66B4B640915}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DnsCluster", "src\cluster.bootstrap\examples\discovery\dns\src\DnsCluster.csproj", "{24B1214E-CFE2-44C0-9B0D-8139C269EED5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -188,6 +190,10 @@ Global {2E13DE8F-0001-4008-A066-B66B4B640915}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E13DE8F-0001-4008-A066-B66B4B640915}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E13DE8F-0001-4008-A066-B66B4B640915}.Release|Any CPU.Build.0 = Release|Any CPU + {24B1214E-CFE2-44C0-9B0D-8139C269EED5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24B1214E-CFE2-44C0-9B0D-8139C269EED5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24B1214E-CFE2-44C0-9B0D-8139C269EED5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24B1214E-CFE2-44C0-9B0D-8139C269EED5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -226,6 +232,7 @@ Global {CF2BA83A-4CF9-4E29-9128-A7A3D3ADBB35} = {52ACBC5B-D5F0-4FEA-A27B-0A8577204E64} {5C5403F4-F496-4246-8BEC-E125E1A2BC94} = {CF2BA83A-4CF9-4E29-9128-A7A3D3ADBB35} {2E13DE8F-0001-4008-A066-B66B4B640915} = {CF2BA83A-4CF9-4E29-9128-A7A3D3ADBB35} + {24B1214E-CFE2-44C0-9B0D-8139C269EED5} = {24FEBA95-1FC2-4AC2-8386-6AB13AE87056} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B99E6BB8-642A-4A68-86DF-69567CBA700A} diff --git a/src/cluster.bootstrap/examples/discovery/dns/README.md b/src/cluster.bootstrap/examples/discovery/dns/README.md new file mode 100644 index 000000000..99fdc2fc1 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/README.md @@ -0,0 +1,3 @@ +# DNS Cluster Bootstrap Example + +This example demonstrates how to use DNS-based service discovery with Akka.Management Cluster Bootstrap to form an Akka.NET Cluster. \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/build.ps1 b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 new file mode 100755 index 000000000..250636827 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 @@ -0,0 +1,9 @@ +#!/usr/bin/env pwsh +# Clean up previous containers first +podman-compose -f "$(pwd)/src/docker-compose.yml" down + +# Build and publish the container +dotnet publish --os linux --arch x64 -c Release /t:PublishContainer ./src/DnsCluster.csproj + +# Start with replace flag to handle container conflicts +podman-compose -f "$(pwd)/src/docker-compose.yml" up --build \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/DnsCluster.csproj b/src/cluster.bootstrap/examples/discovery/dns/src/DnsCluster.csproj new file mode 100644 index 000000000..11db11096 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/DnsCluster.csproj @@ -0,0 +1,39 @@ + + + + Exe + $(TestsNet) + Linux + false + latest + + + + + + + + + + + + + + + + + + + + + Actors\ChaosActor.cs + + + Actors\ClusterListener.cs + + + Actors\SubscriberActor.cs + + + + diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/Dockerfile b/src/cluster.bootstrap/examples/discovery/dns/src/Dockerfile new file mode 100644 index 000000000..2b756dc94 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/Dockerfile @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 + +# Install DNS utilities and diagnostic tools +RUN apt-get update && apt-get install -y \ + dnsutils \ + iputils-ping \ + net-tools \ + && rm -rf /var/lib/apt/lists/* +# Copy published app +COPY bin/Release/net9.0/linux-x64/publish/ /app + +# Copy entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Configure entry point +# ENTRYPOINT ["dotnet", "/app/DnsCluster.dll"] +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs new file mode 100644 index 000000000..3b21de955 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs @@ -0,0 +1,221 @@ +using System; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Cluster; +using Akka.Configuration; +using Akka.DependencyInjection; +using Akka.Event; +using Akka.Hosting; +using Akka.Cluster.Hosting; +using Akka.Cluster.Hosting.SBR; +using Akka.Cluster.Tools.PublishSubscribe; +using Akka.Discovery.Dns; +using Akka.Management; +using Akka.Management.Cluster.Bootstrap; +using Akka.Remote.Hosting; +using Akka.Util; +using KubernetesCluster.Actors; +using Petabridge.Cmd; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Petabridge.Cmd.Cluster; +using Petabridge.Cmd.Host; +using Petabridge.Cmd.Remote; +using LogLevel = Akka.Event.LogLevel; + +namespace DnsCluster; + +public class ClusterConfigOptions +{ + public string? Ip { get; set; } + public int? Port { get; set; } + public string[]? Seeds { get; set; } +} +public static class Extensions +{ + public static AkkaConfigurationBuilder BootstrapFromDocker( + this AkkaConfigurationBuilder builder, + IServiceProvider provider, + Action? remoteConfiguration = null, + Action? clusterConfiguration = null) + { + var configuration = provider.GetRequiredService(); + var clusterConfigOptions = configuration.GetSection("cluster").Get(); + + var remoteOptions = new RemoteOptions + { + HostName = "0.0.0.0", + PublicHostName = clusterConfigOptions.Ip ?? Dns.GetHostName(), + Port = clusterConfigOptions.Port + }; + remoteConfiguration?.Invoke(remoteOptions); + + var clusterOptions = new ClusterOptions + { + SeedNodes = clusterConfigOptions.Seeds + }; + clusterConfiguration?.Invoke(clusterOptions); + + var akkaConfig = configuration.GetSection("akka"); + if (akkaConfig.GetChildren().Any()) + builder.AddHocon(akkaConfig, HoconAddMode.Prepend); + + builder.WithRemoting(remoteOptions); + builder.WithClustering(clusterOptions); + + var managementPort = configuration.GetValue("management.port", 8558); + + builder.AddHocon($"akka.management.http.port = {managementPort}",HoconAddMode.Prepend); + return builder; + } +} +public static class Program +{ + public static async Task Main(string[] args) + { + var host = new HostBuilder() + .ConfigureAppConfiguration(builder => + { + builder.AddCommandLine(args); + builder.AddEnvironmentVariables(); + }) + .ConfigureServices((hostContext, services) => + { + services.AddLogging(); + + var systemName = hostContext.Configuration.GetValue("actorsystem")?.Trim() ?? "ClusterSystem"; + var serviceName = hostContext.Configuration.GetValue("servicename")?.Trim() ?? "akkacluster"; + var pbmPort = hostContext.Configuration.GetValue("pbm.port", 9110); + var managementPort = hostContext.Configuration.GetValue("management.port", 8558); + services.AddAkka(systemName, (builder, provider) => + { + builder.ConfigureLoggers(a => a.LogLevel = LogLevel.DebugLevel); + // Add HOCON configuration from Docker + builder.BootstrapFromDocker( + provider, + // Add Akka.Remote support. + // Empty hostname is intentional and necessary to make sure that remoting binds to the public IP address + remoteOptions => + { + remoteOptions.HostName = ""; + remoteOptions.Port = 4053; + }, + // Add Akka.Cluster support + clusterOptions => + { + clusterOptions.Roles = new []{ "cluster" }; + clusterOptions.SplitBrainResolver = new KeepMajorityOption(); + }); + + // Add Akka.Management.Cluster.Bootstrap support + builder.WithClusterBootstrap(setup => + { + // When running in Docker, use akkacluster service name + // Docker will automatically resolve this to all nodes with this DNS name + setup.ContactPointDiscovery.ServiceName = serviceName; + setup.ContactPoint.FallbackPort = managementPort; // Use management port (8558), not Akka.Remote port + // setup.ContactPointDiscovery.PortName = "management"; + }, autoStart: true); + + // Get container IP address for self-identification + string GetContainerIp() + { + try + { + // Get IP of the container's network interface (typically eth0 in Docker) + var addresses = System.Net.Dns.GetHostAddresses(System.Net.Dns.GetHostName()); + var ipv4 = addresses.FirstOrDefault(ip => ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); + return ipv4?.ToString() ?? "127.0.0.1"; + } + catch + { + return "127.0.0.1"; + } + } + + // Configure Akka.Management HTTP endpoint + builder.WithAkkaManagement(setup => { + // Listen on all interfaces (0.0.0.0) but advertise using the container IP + // This is critical for proper self-identification during bootstrap + setup.Http.BindHostName = "0.0.0.0"; + setup.Http.HostName = GetContainerIp(); // Use IP address instead of hostname + setup.Http.Port = managementPort; + }); + + // Add Akka.Discovery.Dns support + // Configure DNS discovery for Docker environment + builder.WithDnsDiscovery(options => { + // For Docker Compose DNS discovery, use default settings + // The service name is set in the bootstrap configuration + // and DNS discovery will use it automatically + }); + // and set it as the default discovery mechanism + builder.WithDnsDiscoveryDefault(); + + // Add https://cmd.petabridge.com/ for diagnostics + builder.WithPetabridgeCmd("0.0.0.0", pbmPort, ClusterCommands.Instance, new RemoteCommands()); + + // Add start-up code + builder.AddStartup((system, registry) => + { + var cluster = Cluster.Get(system); + cluster.RegisterOnMemberUp(() => + { + var chaos = system.ActorOf(ChaosActor.Props(), "chaos"); + var subscriber = system.ActorOf(SubscriberActor.Props(), "subscriber"); + var listener = system.ActorOf(ClusterListener.Props(), "listener"); + + var mediator = DistributedPubSub.Get(system).Mediator; + system.Scheduler.Advanced.ScheduleRepeatedly(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1), () => + { + mediator.Tell(new Publish("content", ThreadLocalRandom.Current.Next(0, 10))); + //chaos.Tell(ThreadLocalRandom.Current.Next(0,200)); + }); + }); + }); + }); + }) + .ConfigureLogging((hostContext, configLogging) => + { + configLogging.AddConsole(); + + }) + .Build(); + + await host.RunAsync(); + } + + private static AkkaConfigurationBuilder WithPetabridgeCmd( + this AkkaConfigurationBuilder builder, + string? hostname = null, + int? port = null, + params CommandPaletteHandler[] palettes) + { + var sb = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(hostname)) + sb.AppendFormat("host = {0}\n", hostname); + if(port != null) + sb.AppendFormat("port = {0}\n", port); + + if (sb.Length > 0) + { + sb.Insert(0, "petabridge.cmd {\n"); + sb.Append("}"); + + builder.AddHocon(sb.ToString(), HoconAddMode.Prepend); + } + + return builder.AddPetabridgeCmd(cmd => + { + foreach (var palette in palettes) + { + cmd.RegisterCommandPalette(palette); + } + }); + } +} \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.yml new file mode 100644 index 000000000..704537265 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.yml @@ -0,0 +1,70 @@ +version: '3.8' + +services: + node1: + build: + context: . + dockerfile: Dockerfile + image: akka-dns-cluster:latest + hostname: node1.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=8558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + ports: + - "4053:4053" + - "8558:8558" + - "9110:9110" + networks: + akkanet: + aliases: + - akkacluster + + node2: + image: akka-dns-cluster:latest + hostname: node2.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=8558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + ports: + - "4054:4053" + - "8559:8558" + - "9111:9110" + networks: + akkanet: + aliases: + - akkacluster + depends_on: + - node1 + + node3: + image: akka-dns-cluster:latest + hostname: node3.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=8558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + ports: + - "4055:4053" + - "8560:8558" + - "9112:9110" + networks: + akkanet: + aliases: + - akkacluster + depends_on: + - node1 + +networks: + akkanet: + driver: bridge diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh new file mode 100644 index 000000000..d505eb1d6 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -e + +# Print container info for debugging +echo "==== AKKA DNS CLUSTER NODE STARTING ====" +echo "Hostname: $(hostname)" +echo "IP addresses: $(hostname -I)" +echo "Environment variables:" +echo " CLUSTER__PORT: $CLUSTER__PORT" +echo " CLUSTER__IP: $CLUSTER__IP" +echo " MANAGEMENT__PORT: $MANAGEMENT__PORT" +echo " ACTORSYSTEM: $ACTORSYSTEM" +echo " SERVICENAME: $SERVICENAME" +echo "===================================" + +# Check DNS resolution +echo "\nPerforming DNS resolution test for '$SERVICENAME'..." +dig $SERVICENAME + +# Also try another DNS tool for verification +echo "\nNslookup verification:" +nslookup $SERVICENAME + +echo "\nStarting DnsCluster with DNS discovery..." +exec dotnet /app/DnsCluster.dll "$@" \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index 65f3fddfc..c8c6605a7 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -22,7 +22,7 @@ public class DnsServiceDiscovery : ServiceDiscovery public DnsServiceDiscovery(ExtendedActorSystem system) { _system = system; - _log = Logging.GetLogger(system, typeof(DnsServiceDiscovery)); + _log = Logging.GetLogger(system, this); var dnsResolver = _system.Settings.Config.GetString("akka.io.dns.resolver"); switch (dnsResolver) @@ -83,8 +83,14 @@ private async Task AskResolveIp(string serviceName, TimeSpan timeout) if (result is IO.Dns.Resolved resolved) { - _log.Debug("lookup result: {0}", resolved); - return IpRecordsToResolved(serviceName, resolved); + if (resolved.IsSuccess) + { + _log.Debug("lookup result: {0}", resolved); + return IpRecordsToResolved(serviceName, resolved); + } + + _log.Error(resolved.Exception, "Failed to resolve serviceName: {0}", serviceName); + return new Resolved(serviceName, ImmutableList.Empty); } _log.Warning("Resolved UNEXPECTED (resolving to Nil): {0}", result.GetType()); @@ -132,9 +138,9 @@ private async Task AskResolve(string srvRequest, TimeSpan timeout) /// private Resolved SrvRecordsToResolved(string srvRequest, Akka.IO.Dns.Resolved resolved) { - // var ips = new Dictionary>(); + var ips = new Dictionary>(); - // Build a map of hostname to IP addresses from additional records + //Build a map of hostname to IP addresses from additional records // foreach (var aRecord in resolved.Ipv4) // { // if (!ips.TryGetValue(aRecord.Name, out var aIps)) From a970620f439a6d2dd2d3b7e784c74880cdd65940 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 8 Jul 2025 16:09:00 -0300 Subject: [PATCH 03/37] SRV example cluster is starting --- .../examples/discovery/dns/build.ps1 | 20 +- .../examples/discovery/dns/src/Program.cs | 75 +-- .../discovery/dns/src/coredns/Corefile | 12 + .../src/coredns/zones/akkacluster.dns.podman | 21 + ...er-compose.yml => docker-compose.aaaa.yml} | 0 .../discovery/dns/src/docker-compose.srv.yml | 103 ++++ .../examples/discovery/dns/src/entrypoint.sh | 15 +- .../AkkaHostingExtensions.cs | 34 +- .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 247 ++++++---- .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 199 +++++--- .../Akka.Discovery.Dns/Internal/DnsClient.cs | 455 ++++++++++++++++++ .../Internal/DnsProtocol.cs | 421 ++++++++++++++++ .../Akka.Discovery.Dns/Internal/Polyfill.cs | 6 + .../Internal/ResourceRecord.cs | 193 ++++++++ .../Internal/TcpDnsClient.cs | 203 ++++++++ 15 files changed, 1774 insertions(+), 230 deletions(-) create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/coredns/Corefile create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/coredns/zones/akkacluster.dns.podman rename src/cluster.bootstrap/examples/discovery/dns/src/{docker-compose.yml => docker-compose.aaaa.yml} (100%) create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/Polyfill.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs diff --git a/src/cluster.bootstrap/examples/discovery/dns/build.ps1 b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 index 250636827..fb6bd4405 100755 --- a/src/cluster.bootstrap/examples/discovery/dns/build.ps1 +++ b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 @@ -1,9 +1,25 @@ #!/usr/bin/env pwsh + +param( + [Parameter(Mandatory=$false)] + [string]$recordType = "srv" +) + +if ($recordType -ne "srv" -and $recordType -ne "a" -and $recordType -ne "aaaa") { + Write-Error "Invalid record type. Must be 'srv' or 'a' or 'aaaa'." + exit 1 +} + +if ($recordType -eq "a") { + $recordType = "aaaa" +} + # Clean up previous containers first -podman-compose -f "$(pwd)/src/docker-compose.yml" down +podman-compose -f "$(pwd)/src/docker-compose.srv.yml" down +podman-compose -f "$(pwd)/src/docker-compose.aaaa.yml" down # Build and publish the container dotnet publish --os linux --arch x64 -c Release /t:PublishContainer ./src/DnsCluster.csproj # Start with replace flag to handle container conflicts -podman-compose -f "$(pwd)/src/docker-compose.yml" up --build \ No newline at end of file +podman-compose -f "$(pwd)/src/docker-compose.$recordType.yml" up --build \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs index 3b21de955..3a95d0b6c 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs +++ b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs @@ -76,6 +76,21 @@ public static AkkaConfigurationBuilder BootstrapFromDocker( } public static class Program { + // Get container IP address for self-identification + static string GetContainerIp() + { + try + { + // Get IP of the container's network interface (typically eth0 in Docker) + var addresses = System.Net.Dns.GetHostAddresses(System.Net.Dns.GetHostName()); + var ipv4 = addresses.FirstOrDefault(ip => ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); + return ipv4?.ToString() ?? "127.0.0.1"; + } + catch + { + return "127.0.0.1"; + } + } public static async Task Main(string[] args) { var host = new HostBuilder() @@ -87,14 +102,22 @@ public static async Task Main(string[] args) .ConfigureServices((hostContext, services) => { services.AddLogging(); - + + // if portName is set resolver uses SRV records, otherwise A/AAAA records + string? portName = hostContext.Configuration.GetValue("portname")?.Trim(); var systemName = hostContext.Configuration.GetValue("actorsystem")?.Trim() ?? "ClusterSystem"; var serviceName = hostContext.Configuration.GetValue("servicename")?.Trim() ?? "akkacluster"; - var pbmPort = hostContext.Configuration.GetValue("pbm.port", 9110); - var managementPort = hostContext.Configuration.GetValue("management.port", 8558); + var pbmPort = hostContext.Configuration.GetValue("pbm:port", 9110); + var managementPort = hostContext.Configuration.GetValue("management:port", 8558); + + services.AddAkka(systemName, (builder, provider) => { - builder.ConfigureLoggers(a => a.LogLevel = LogLevel.DebugLevel); + builder.ConfigureLoggers(a => + { + a.LogLevel = LogLevel.DebugLevel; + a.LogConfigOnStart = true; + }); // Add HOCON configuration from Docker builder.BootstrapFromDocker( provider, @@ -119,41 +142,28 @@ public static async Task Main(string[] args) // Docker will automatically resolve this to all nodes with this DNS name setup.ContactPointDiscovery.ServiceName = serviceName; setup.ContactPoint.FallbackPort = managementPort; // Use management port (8558), not Akka.Remote port - // setup.ContactPointDiscovery.PortName = "management"; + setup.ContactPointDiscovery.PortName = portName; }, autoStart: true); - // Get container IP address for self-identification - string GetContainerIp() - { - try - { - // Get IP of the container's network interface (typically eth0 in Docker) - var addresses = System.Net.Dns.GetHostAddresses(System.Net.Dns.GetHostName()); - var ipv4 = addresses.FirstOrDefault(ip => ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); - return ipv4?.ToString() ?? "127.0.0.1"; - } - catch - { - return "127.0.0.1"; - } - } + // Configure Akka.Management HTTP endpoint - builder.WithAkkaManagement(setup => { - // Listen on all interfaces (0.0.0.0) but advertise using the container IP - // This is critical for proper self-identification during bootstrap - setup.Http.BindHostName = "0.0.0.0"; - setup.Http.HostName = GetContainerIp(); // Use IP address instead of hostname - setup.Http.Port = managementPort; - }); + builder.WithAkkaManagement(hostName: GetContainerIp(), port: managementPort, bindHostname : "0.0.0.0"); + // setup.Http.HostName = GetContainerIp(); // Use IP address instead of hostname + // setup.Http.Port = managementPort; + // setup.Port = managementPort; + // }); // Add Akka.Discovery.Dns support // Configure DNS discovery for Docker environment - builder.WithDnsDiscovery(options => { - // For Docker Compose DNS discovery, use default settings - // The service name is set in the bootstrap configuration - // and DNS discovery will use it automatically - }); + builder.WithDnsDiscovery(); + if (portName != null) + { + // use SRV record resolver if portName was specified + var ns = hostContext.Configuration.GetValue("nameserver")?.Trim() ?? "127.0.0.1:53"; + builder.WithAsyncDnsResolver(opt => opt.Nameserver = ns ); + } + // and set it as the default discovery mechanism builder.WithDnsDiscoveryDefault(); @@ -163,6 +173,7 @@ string GetContainerIp() // Add start-up code builder.AddStartup((system, registry) => { + system.Log.Info($"Management port ENV = [{managementPort}]"); var cluster = Cluster.Get(system); cluster.RegisterOnMemberUp(() => { diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/coredns/Corefile b/src/cluster.bootstrap/examples/discovery/dns/src/coredns/Corefile new file mode 100644 index 000000000..05befc888 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/coredns/Corefile @@ -0,0 +1,12 @@ +.:1053 { + log + errors + auto + reload + hosts { + fallthrough + } + file /etc/coredns/zones/akkacluster.dns.podman akkacluster.dns.podman + prometheus + cache +} diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/coredns/zones/akkacluster.dns.podman b/src/cluster.bootstrap/examples/discovery/dns/src/coredns/zones/akkacluster.dns.podman new file mode 100644 index 000000000..96dd506e4 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/coredns/zones/akkacluster.dns.podman @@ -0,0 +1,21 @@ +$ORIGIN akkacluster.dns.podman. +@ 3600 IN SOA dns-server.akkacluster.dns.podman. admin.akkacluster.dns.podman. ( + 2023121001 ; serial + 7200 ; refresh + 3600 ; retry + 1209600 ; expire + 3600 ; minimum +) + +; Name servers +@ 3600 IN NS dns-server.akkacluster.dns.podman. + +; A records for each node +node1 IN A 172.28.0.10 +node2 IN A 172.28.0.20 +node3 IN A 172.28.0.30 + +; SRV records - _service._proto.name. TTL class SRV priority weight port target +_management._tcp IN SRV 10 10 18558 node1.akkacluster.dns.podman. +_management._tcp IN SRV 10 10 28558 node2.akkacluster.dns.podman. +_management._tcp IN SRV 10 10 38558 node3.akkacluster.dns.podman. diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml similarity index 100% rename from src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.yml rename to src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml new file mode 100644 index 000000000..2e80a47b0 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml @@ -0,0 +1,103 @@ +version: '3.8' + +services: + # CoreDNS server for SRV record resolution + coredns: + image: coredns/coredns:1.10.1 + hostname: dns-server + volumes: + - ./coredns:/etc/coredns + command: ["-conf", "/etc/coredns/Corefile"] + ports: + - "1053:1053/udp" + - "1053:1053/tcp" + networks: + akkanet: + ipv4_address: 172.28.0.2 + aliases: + - dns-server + + node1: + build: + context: . + dockerfile: Dockerfile + image: akka-dns-cluster:latest + hostname: node1.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=18558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + - PORTNAME=management + - DNS_PORT=1053 + - DNS_NAMESERVER=172.28.0.2 + ports: + - "4053:4053" + - "18558:18558" + - "9110:9110" + networks: + akkanet: + ipv4_address: 172.28.0.10 + aliases: + - akkacluster + depends_on: + - coredns + + node2: + image: akka-dns-cluster:latest + hostname: node2.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=28558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + - PORTNAME=management + - DNS_PORT=1053 + - DNS_NAMESERVER=172.28.0.2 + ports: + - "4054:4053" + - "28558:28558" + - "9111:9110" + networks: + akkanet: + ipv4_address: 172.28.0.20 + aliases: + - akkacluster + depends_on: + - node1 + + node3: + image: akka-dns-cluster:latest + hostname: node3.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=38558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + - PORTNAME=management + - DNS_PORT=1053 + - DNS_NAMESERVER=172.28.0.2 + ports: + - "4055:4053" + - "38558:38558" + - "9112:9110" + networks: + akkanet: + ipv4_address: 172.28.0.30 + aliases: + - akkacluster + depends_on: + - node1 + +networks: + akkanet: + driver: bridge + ipam: + config: + - subnet: 172.28.0.0/16 diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh index d505eb1d6..1c04f30d6 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh +++ b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh @@ -11,15 +11,18 @@ echo " CLUSTER__IP: $CLUSTER__IP" echo " MANAGEMENT__PORT: $MANAGEMENT__PORT" echo " ACTORSYSTEM: $ACTORSYSTEM" echo " SERVICENAME: $SERVICENAME" +echo " PORTNAME: $PORTNAME" +echo " DNS_PORT: $DNS_PORT" +echo " DNS_NAMESERVER: $DNS_NAMESERVER" echo "===================================" - -# Check DNS resolution +DNS_PORT=${DNS_PORT:-53} +# Check A/AAAA records echo "\nPerforming DNS resolution test for '$SERVICENAME'..." -dig $SERVICENAME +dig -p $DNS_PORT $SERVICENAME +# check SRV record +dig -p $DNS_PORT -t srv "_${PORTNAME}._tcp.$SERVICENAME" -# Also try another DNS tool for verification -echo "\nNslookup verification:" -nslookup $SERVICENAME +export NAMESERVER="${DNS_NAMESERVER:-127.0.0.1}:$DNS_PORT" echo "\nStarting DnsCluster with DNS discovery..." exec dotnet /app/DnsCluster.dll "$@" \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs index 188750e15..657115c69 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs @@ -18,26 +18,16 @@ public static class AkkaHostingExtensions /// The same builder instance. public static AkkaConfigurationBuilder WithDnsDiscovery( this AkkaConfigurationBuilder builder, - Action configure) + Action? configure = null) { var options = new DnsDiscoveryOptions(); - configure(options); + configure?.Invoke(options); options.Apply(builder); builder.AddSetup(new DnsDiscoverySetup { DiscoveryId = options.ConfigPath }); return builder; } - /// - /// Adds DNS service discovery to the with default options. - /// - /// The builder instance. - /// The same builder instance. - public static AkkaConfigurationBuilder WithDnsDiscovery( - this AkkaConfigurationBuilder builder) - { - return builder.WithDnsDiscovery(_ => { }); - } /// /// Adds DNS service discovery to the with the specified options. @@ -68,5 +58,25 @@ public static AkkaConfigurationBuilder WithDnsDiscoveryDefault( builder.AddHocon("akka.discovery.method = " + discoveryId, HoconAddMode.Prepend); return builder; } + + + public static AkkaConfigurationBuilder WithDnsResolver( + this AkkaConfigurationBuilder builder, + string resolverId = "inet-address") + { + builder.AddHocon($"akka.io.dns.resolver = {resolverId}", HoconAddMode.Prepend); + return builder; + } + + public static AkkaConfigurationBuilder WithAsyncDnsResolver( + this AkkaConfigurationBuilder builder, Action? configure = null) + { + builder.WithDnsResolver(AsyncDnsResolerOptions.DefaultPath); + // builder.AddHocon($"akka.io.dns.provider-object = {AsyncDnsResolerOptions.ProviderName}", HoconAddMode.Prepend); + var opt = new AsyncDnsResolerOptions(); + configure?.Invoke(opt); + opt.Apply(builder); + return builder; + } } } diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index e0f7933a4..edb317edb 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -2,133 +2,180 @@ using System.Collections.Generic; using System.Text; using Akka.Actor.Setup; +using Akka.Discovery.Dns.Internal; using Akka.Hosting; -namespace Akka.Discovery.Dns +namespace Akka.Discovery.Dns; + +/// +/// Options class for configuring the DNS service discovery. +/// +/// +public class DnsDiscoveryOptions : IDiscoveryOptions { /// - /// Options class for configuring the DNS service discovery. + /// Default configuration path for DNS service discovery /// - public class DnsDiscoveryOptions : IDiscoveryOptions - { - /// - /// Default configuration path for DNS service discovery - /// - public const string DefaultPath = "akka-dns"; + public const string DefaultPath = "akka-dns"; - /// - /// The default configuration path for DNS service discovery - /// - public const string DefaultConfigPath = "akka.discovery." + DefaultPath; + /// + /// The default configuration path for DNS service discovery + /// + public const string DefaultConfigPath = "akka.discovery." + DefaultPath; - /// - /// Gets the full configuration path for the specified path. - /// - /// The path. - /// The full configuration path. - public static string FullPath(string path) => $"akka.discovery.{path}"; + /// + /// Gets the full configuration path for the specified path. + /// + /// The path. + /// The full configuration path. + public static string FullPath(string path) => $"akka.discovery.{path}"; - /// - /// Gets the type of service discovery class. - /// - public Type Class { get; } = typeof(DnsServiceDiscovery); + /// + /// Gets the type of service discovery class. + /// + public Type Class { get; } = typeof(DnsServiceDiscovery); - /// - /// Gets or sets the configuration path. - /// - public string ConfigPath { get; set; } = DefaultPath; + /// + /// Gets or sets the configuration path. + /// + public string ConfigPath { get; set; } = DefaultPath; - /// - /// Renders HOCON configuration based on current settings. - /// - /// HOCON configuration string. - private string ToHocon() - { - var sb = new StringBuilder(); - sb.AppendLine($"{FullPath(ConfigPath)} {{"); - sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\""); - sb.AppendLine("}"); + /// + /// Renders HOCON configuration based on current settings. + /// + /// HOCON configuration string. + private string ToHocon() + { + var sb = new StringBuilder(); + sb.AppendLine($"{FullPath(ConfigPath)} {{"); + sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\""); + sb.AppendLine("}"); - return sb.ToString(); - } - /// - /// - /// - /// - public void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) - { - builder.AddHocon(ToHocon(), HoconAddMode.Prepend); - } + return sb.ToString(); } + /// + /// + /// + /// + public void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) + { + builder.AddHocon(ToHocon(), HoconAddMode.Prepend); + } +} +/// +/// Setup class for configuring the DNS service discovery. +/// +public class DnsDiscoverySetup : Setup +{ /// - /// Setup class for configuring the DNS service discovery. + /// Gets or sets the discovery ID. /// - public class DnsDiscoverySetup : Setup - { - /// - /// Gets or sets the discovery ID. - /// - public string DiscoveryId { get; set; } = DnsDiscoveryOptions.DefaultPath; + public string DiscoveryId { get; set; } = DnsDiscoveryOptions.DefaultPath; - // Other configuration options can be added here + // Other configuration options can be added here - /// - /// Applies the setup to the provided settings. - /// - /// The updated settings. - internal DnsDiscoverySettings Apply(DnsDiscoverySettings settings) - { - return settings; // No custom settings yet - } + /// + /// Applies the setup to the provided settings. + /// + /// The updated settings. + internal DnsDiscoverySettings Apply(DnsDiscoverySettings settings) + { + return settings; // No custom settings yet } +} +/// +/// Settings class for the DNS service discovery. +/// +public class DnsDiscoverySettings +{ /// - /// Settings class for the DNS service discovery. + /// Gets an empty settings instance. /// - public class DnsDiscoverySettings - { - /// - /// Gets an empty settings instance. - /// - public static readonly DnsDiscoverySettings Empty = new DnsDiscoverySettings(); + public static readonly DnsDiscoverySettings Empty = new DnsDiscoverySettings(); - /// - /// Creates settings from an Akka ActorSystem. - /// - /// The actor system. - /// The settings. - public static DnsDiscoverySettings Create(Akka.Actor.ActorSystem system) - => Create(system.Settings.Config); + /// + /// Creates settings from an Akka ActorSystem. + /// + /// The actor system. + /// The settings. + public static DnsDiscoverySettings Create(Akka.Actor.ActorSystem system) + => Create(system.Settings.Config); - /// - /// Creates settings from configuration. - /// - /// The configuration. - /// The settings. - public static DnsDiscoverySettings Create(Akka.Configuration.Config config) - { - return new DnsDiscoverySettings(); - } + /// + /// Creates settings from configuration. + /// + /// The configuration. + /// The settings. + public static DnsDiscoverySettings Create(Akka.Configuration.Config config) + { + return new DnsDiscoverySettings(); } +} + +/// +/// Multi-setup class for configuring multiple DNS discovery instances. +/// +public class DnsDiscoveryMultiSetup : Setup +{ + /// + /// Gets the setups. + /// + public IReadOnlyDictionary Setups { get; } /// - /// Multi-setup class for configuring multiple DNS discovery instances. + /// Creates a new instance of the class. /// - public class DnsDiscoveryMultiSetup : Setup + /// The setups. + public DnsDiscoveryMultiSetup(IReadOnlyDictionary setups) { - /// - /// Gets the setups. - /// - public IReadOnlyDictionary Setups { get; } - - /// - /// Creates a new instance of the class. - /// - /// The setups. - public DnsDiscoveryMultiSetup(IReadOnlyDictionary setups) - { - Setups = setups ?? throw new ArgumentNullException(nameof(setups)); - } + Setups = setups ?? throw new ArgumentNullException(nameof(setups)); } } + + +public class AsyncDnsResolerOptions : IHoconOption +{ + + + public const string DefaultPath = "async-dns"; + + public const string NameserversPath = "nameserver"; + public string Nameserver { get; set; } = "127.0.0.1:53"; + /// + /// Renders HOCON configuration based on current settings. + /// + /// HOCON configuration string. + public static string FullPath(string path) => $"akka.io.dns.{path}"; + + private string ToHocon() + { + var sb = new StringBuilder(); + sb.AppendLine($"{FullPath(ConfigPath)} {{"); + sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\","); + sb.AppendLine($" provider-object = \"{Provider.FullName}, {Provider.Assembly.GetName().Name}\","); + // sb.Append($" {NameserversPath} = ["); + // foreach (var name in Nameservers) + // { + // sb.Append($"\"{name}\","); + // } + // sb.AppendLine("]"); + sb.Append($" {NameserversPath} = \"{Nameserver}\","); + sb.AppendLine("}"); + + return sb.ToString(); + } + /// + /// + /// + /// + public void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) + { + builder.AddHocon(ToHocon(), HoconAddMode.Prepend); + } + public string ConfigPath => DefaultPath; + public Type Class => typeof(DnsClient); + public Type Provider => typeof(AsyncDnsProvider); + // static string ProviderName => $"{Provider.FullName}, {Provider.Assembly.GetName().Name}"; +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index c8c6605a7..a7b8f77ca 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -5,6 +5,7 @@ using System.Net; using System.Threading.Tasks; using Akka.Actor; +using Akka.Discovery.Dns.Internal; using Akka.Event; using Akka.IO; @@ -16,7 +17,8 @@ namespace Akka.Discovery.Dns; public class DnsServiceDiscovery : ServiceDiscovery { private readonly ILoggingAdapter _log; - private readonly DnsExt _dns; + // private readonly DnsExt _dns; + private readonly IActorRef _dns; private readonly ExtendedActorSystem _system; public DnsServiceDiscovery(ExtendedActorSystem system) @@ -27,12 +29,21 @@ public DnsServiceDiscovery(ExtendedActorSystem system) var dnsResolver = _system.Settings.Config.GetString("akka.io.dns.resolver"); switch (dnsResolver) { - case "inet-address": - _dns = Akka.IO.Dns.Instance.CreateExtension(_system); + case "inet-address": + { + var dns = Akka.IO.Dns.Instance.CreateExtension(_system); + _dns = dns.Manager; + break; + } + case "async-dns": + { + var dns = new AsyncDnsExt(_system); + _dns = dns.Manager; break; + } default: throw new NotImplementedException(); - + } } @@ -46,40 +57,58 @@ private string CleanIpString(string ipString) => public override async Task Lookup(Lookup lookup, TimeSpan resolveTimeout) { if (!string.IsNullOrWhiteSpace(lookup.PortName) && !string.IsNullOrWhiteSpace(lookup.Protocol)) + { return await LookupSrv(lookup, resolveTimeout); - else - return await LookupIp(lookup, resolveTimeout); + } + + return await LookupIp(lookup, resolveTimeout); } private async Task LookupSrv(Lookup lookup, TimeSpan resolveTimeout) { var srvRequest = $"_{lookup.PortName}._{lookup.Protocol}.{lookup.ServiceName}"; _log.Debug("Lookup [{0}] translated to SRV query [{1}] as contains portName and protocol", lookup, srvRequest); - var resolved = _dns.Cache.Cached(srvRequest); - if (resolved == null) + + try + { + // Generate a random query ID + short queryId = (short)new Random().Next(0, 65535); + + // Send SRV question and await response + var result = await _dns.Ask(new Internal.DnsClient.SrvQuestion(queryId, srvRequest), resolveTimeout); + + if (result is Internal.DnsClient.Answer answer) + { + return SrvRecordsToResolved(srvRequest, answer); + } + else if (result is Status.Failure failure) + { + throw failure.Cause; + } + + _log.Warning("Unexpected response type from DNS resolver: {0}", result.GetType()); + return new Resolved(srvRequest, ImmutableList.Empty); + } + catch (Exception ex) { - return await AskResolve(srvRequest, resolveTimeout); + _log.Error(ex, "SRV lookup failed for {0}", srvRequest); + throw; } - return SrvRecordsToResolved(srvRequest, resolved); } private async Task LookupIp(Lookup lookup, TimeSpan resolveTimeout) { _log.Debug("Lookup[{0}] translated to A/AAAA lookup as does not have portName and protocol", lookup); - var resolved = _dns.Cache.Cached(lookup.ServiceName); - if (resolved == null) - { - return await AskResolveIp(lookup.ServiceName, resolveTimeout); - } - return IpRecordsToResolved(lookup.ServiceName, resolved); + // For standard IP lookups, continue to use the built-in Akka.IO.Dns resolver + return await AskResolveIp(lookup.ServiceName, resolveTimeout); } private async Task AskResolveIp(string serviceName, TimeSpan timeout) { try { - var result = await _dns.Manager.Ask(new Akka.IO.Dns.Resolve(serviceName), timeout); + var result = await _dns.Ask(new Akka.IO.Dns.Resolve(serviceName), timeout); if (result is IO.Dns.Resolved resolved) { @@ -107,76 +136,90 @@ private async Task AskResolveIp(string serviceName, TimeSpan timeout) } } - private async Task AskResolve(string srvRequest, TimeSpan timeout) + // private async Task AskResolve(string srvRequest, TimeSpan timeout) + // { + // try + // { + // var result = await _dns.Ask(new IO.Dns.Resolve(srvRequest), timeout); + // + // if (result is IO.Dns.Resolved resolved) + // { + // _log.Debug("Lookup result: {0}", resolved); + // return SrvRecordsToResolved(srvRequest, resolved); + // } + // + // _log.Warning("Resolved UNEXPECTED (resolving to Nil): {0}", result.GetType()); + // return new Resolved(srvRequest, ImmutableList.Empty); + // } + // catch (AskTimeoutException) + // { + // throw new TimeoutException($"Dns resolve did not respond within {timeout}"); + // } + // catch (Exception ex) + // { + // _log.Error(ex, "Error during DNS resolution"); + // throw; + // } + // } + + /// + /// Converts SRV records to a Resolved object from our custom DNS client response. + /// + private Resolved SrvRecordsToResolved(string srvRequest, Internal.DnsClient.Answer resolved) { - try + var ips = new Dictionary>(); + + // Process SRV records + var srvRecords = resolved.Records.OfType().ToList(); + + // Process additional A/AAAA records for hostname resolution + foreach (var aRecord in resolved.AdditionalRecords.OfType()) { - var result = await _dns.Manager.Ask(new IO.Dns.Resolve(srvRequest), timeout); - - if (result is IO.Dns.Resolved resolved) + if (!ips.TryGetValue(aRecord.Name, out var aIps)) { - _log.Debug("Lookup result: {0}", resolved); - return SrvRecordsToResolved(srvRequest, resolved); + aIps = new List(); + ips[aRecord.Name] = aIps; } - - _log.Warning("Resolved UNEXPECTED (resolving to Nil): {0}", result.GetType()); - return new Resolved(srvRequest, ImmutableList.Empty); + + aIps.Add(aRecord.Ip); } - catch (AskTimeoutException) + + foreach (var aaaaRecord in resolved.AdditionalRecords.OfType()) { - throw new TimeoutException($"Dns resolve did not respond within {timeout}"); + if (!ips.TryGetValue(aaaaRecord.Name, out var aaaaIps)) + { + aaaaIps = new List(); + ips[aaaaRecord.Name] = aaaaIps; + } + + aaaaIps.Add(aaaaRecord.Ip); } - catch (Exception ex) + + // Build the list of resolved targets from SRV records + var targets = new List(); + + foreach (var record in srvRecords) { - _log.Error(ex, "Error during DNS resolution"); - throw; + // Remove trailing dot if present + string targetHost = record.Target.EndsWith(".") + ? record.Target.Substring(0, record.Target.Length - 1) + : record.Target; + + // Try to get IP from additional records + if (ips.TryGetValue(targetHost, out var hostIps) || ips.TryGetValue(targetHost + ".", out hostIps)) + { + foreach (var ip in hostIps) + { + targets.Add(new ResolvedTarget(targetHost, record.Port, ip)); + } + } + else + { + // If we don't have the IP, just use the hostname + targets.Add(new ResolvedTarget(targetHost, record.Port)); + } } - } - - /// - /// Converts SRV records to a Resolved object. - /// - private Resolved SrvRecordsToResolved(string srvRequest, Akka.IO.Dns.Resolved resolved) - { - var ips = new Dictionary>(); - - //Build a map of hostname to IP addresses from additional records - // foreach (var aRecord in resolved.Ipv4) - // { - // if (!ips.TryGetValue(aRecord.Name, out var aIps)) - // { - // aIps = new List(); - // ips[aRecord.Name] = aIps; - // } - // - // aIps.Add(aRecord.Ip); - // } - // foreach (var record in resolved.Ipv6) { - // if (!ips.TryGetValue(aaaaRecord.Name, out var aaaaIps)) - // { - // aaaaIps = new List(); - // ips[aaaaRecord.Name] = aaaaIps; - // } - // - // aaaaIps.Add(aaaaRecord.Ip); - // break; - // } - // - // var addresses = resolved.Records.OfType() - // .SelectMany(srv => - // { - // if (ips.TryGetValue(srv.Target, out var ipList) && ipList.Count > 0) - // { - // return ipList.Select(ip => new ResolvedTarget(srv.Target, srv.Port, ip)); - // } - // else - // { - // return new[] { new ResolvedTarget(srv.Target, srv.Port, null) }; - // } - // }) - // .ToImmutableList(); - - return new Resolved(srvRequest, []); + return new Resolved(srvRequest, targets.ToImmutableList()); } /// diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs new file mode 100644 index 000000000..cbe7e3d34 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using Akka.Event; +using Akka.IO; +using Akka.Pattern; + +namespace Akka.Discovery.Dns.Internal; + +public class AsyncDnsExt : DnsExt +{ + public AsyncDnsExt(ExtendedActorSystem system) : base(system) + { + _system = system; + } + private readonly ExtendedActorSystem _system; + private IActorRef? _manager; + public override IActorRef Manager + { + get + { + // base implementation doesn't respect custom provider/manager settings, perhpaps on purpouse + return _manager = _manager ?? _system.SystemActorOf(Props.Create(Provider.ManagerClass, this).WithDeploy(Deploy.Local).WithDispatcher(Settings.Dispatcher) + .WithDeploy(Deploy.Local) + .WithDispatcher(Settings.Dispatcher)); + } + } +} + +public class AsyncDnsProvider : IDnsProvider +{ + private readonly DnsBase _cache = new SimpleDnsCache(); + + /// + /// TBD + /// + public DnsBase Cache => _cache; + + /// + /// TBD + /// + public Type ActorClass => typeof (DnsClient); + + /// + /// TBD + /// + public Type ManagerClass => typeof (DnsClient); +} + +/// +/// DNS client actor for resolving DNS queries, including SRV records. +/// This is an internal implementation for the Akka.Discovery.Dns service. +/// +internal class DnsClient : UntypedActorWithStash +{ + #region Messages + + /// + /// Base class for DNS questions + /// + public abstract record DnsQuestion(short Id); + + /// + /// Question for SRV records + /// + public sealed record SrvQuestion(short Id, string Name) : DnsQuestion(Id); + + /// + /// Question for A records (IPv4) + /// + public sealed record Question4(short Id, string Name) : DnsQuestion(Id); + + /// + /// Question for AAAA records (IPv6) + /// + public sealed record Question6(short Id, string Name) : DnsQuestion(Id); + + /// + /// DNS answer containing resource records + /// + public sealed record Answer + { + public short Id { get; } + public ImmutableArray Records { get; } + public ImmutableArray AdditionalRecords { get; } + + public Answer(short id, IEnumerable? records = null, IEnumerable? additionalRecords = null) + { + Id = id; + Records = records?.ToImmutableArray() ?? ImmutableArray.Empty; + AdditionalRecords = additionalRecords?.ToImmutableArray() ?? ImmutableArray.Empty; + } + } + + /// + /// Request to drop a pending DNS question + /// + public sealed record DropRequest(DnsQuestion Question); + + /// + /// Notification that a request has been dropped + /// + public sealed record Dropped(short Id); + + /// + /// Internal message for UDP DNS answers + /// + private sealed record UdpAnswer + { + public ImmutableArray Questions { get; } + public Answer Content { get; } + + public UdpAnswer(IEnumerable questions, Answer content) + { + Questions = questions.ToImmutableArray(); + Content = content; + } + } + + /// + /// Message indicating TCP connection dropped + /// + public static readonly object TcpDropped = new object(); + + #endregion + + private readonly EndPoint _nameserver; + private readonly ILoggingAdapter _log; + private readonly IActorRef _tcpManager; + private readonly IActorRef _udpManager; + private readonly IStash _stash; + + // Tracks in-flight DNS requests + private Dictionary _inflightRequests = new Dictionary(); + private IActorRef _tcpDnsClient; + private IActorRef? _udpSocket; + + + /// + /// Information about an in-flight DNS request + /// + private class InFlightRequest + { + public IActorRef ReplyTo { get; } + public DnsProtocol.Message Message { get; } + public bool TcpRequest { get; set; } + + public InFlightRequest(IActorRef replyTo, DnsProtocol.Message message, bool tcpRequest = false) + { + ReplyTo = replyTo; + Message = message; + TcpRequest = tcpRequest; + } + } + + static EndPoint ParseEndPoint(string endpoint) + { + var parts = endpoint.Split(':'); + var ip = IPAddress.Parse(parts[0]); + var port = 0; + if (parts.Length > 1) + { + port = int.Parse(parts[1]); + } + + return new IPEndPoint(ip, port); + } + + public DnsClient(AsyncDnsExt ext) + { + _log = Context.GetLogger(); + var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolerOptions.NameserversPath) ?? throw new ConfigurationException("nameservers config was empty"); + _nameserver = ParseEndPoint(ns); + _udpManager = Akka.IO.Udp.Instance.Apply(Context.System).Manager; + _tcpManager = Akka.IO.Tcp.Manager(Context.System); + _stash = Context.CreateStash(typeof(DnsClient)); + _log.Log(LogLevel.DebugLevel, "Constructed!"); + } + + protected override void PreStart() + { + // Bind to UDP port for DNS resolution + _udpManager.Tell(new Udp.Bind(Self, new IPEndPoint(IPAddress.Any, 0))); + + // Create TCP client for fallback when UDP responses are truncated + _tcpDnsClient = CreateTcpClient(); + } + + protected override void Unhandled(object message) + { + _log.Error( "Unhandled message: [{0}]",message); + base.Unhandled(message); + } + + protected override void OnReceive(object message) + { + // _log.Debug("Received message:[{0}]", message); + switch (message) + { + case Udp.Bound bound: + _log.Debug("Bound to UDP address [{0}]", bound.LocalAddress); + _udpSocket = Context.Sender; + Context.Become(Ready); + _stash.UnstashAll(); + break; + case Question4 _: + case Question6 _: + case SrvQuestion _: + _stash.Stash(); + break; + } + } + + private void Ready(object message) + { + _log.Debug("Received message:[{0}]", message.GetType()); + switch (message) + { + case DropRequest dropRequest: + HandleDropRequest(dropRequest); + break; + + case Question4 question: + HandleQuestion(question.Id, question.Name, DnsProtocol.RecordType.A, Sender); + break; + + case Question6 question: + HandleQuestion(question.Id, question.Name, DnsProtocol.RecordType.Aaaa, Sender); + break; + + case SrvQuestion question: + HandleQuestion(question.Id, question.Name, DnsProtocol.RecordType.Srv, Sender); + break; + + case Udp.Received received: + try + { + var msg = DnsProtocol.Message.Parse(received.Data.ToArray()); + _log.Debug("Decoded UDP DNS response [{0}]", msg); + + if (msg.Flags.IsTruncated) + { + _log.Debug("DNS response truncated, falling back to TCP"); + if (_inflightRequests.TryGetValue(msg.Id, out var inFlight)) + { + inFlight.TcpRequest = true; + _tcpDnsClient.Tell(inFlight.Message); + } + else + { + _log.Debug("Client for id {0} not found. Discarding unsuccessful response.", msg.Id); + } + } + else + { + var records = msg.Flags.ResponseCode == DnsProtocol.ResponseCode.Success + ? msg.AnswerRecords : ImmutableList.Empty; + var additionalRecs = msg.Flags.ResponseCode == DnsProtocol.ResponseCode.Success + ? msg.AdditionalRecords : ImmutableList.Empty; + + Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, records, additionalRecs))); + } + } + catch (Exception ex) + { + _log.Error(ex, "Error processing DNS response"); + } + break; + + case UdpAnswer udpAnswer: + if (_inflightRequests.TryGetValue(udpAnswer.Content.Id, out var request)) + { + var sentQuestions = request.Message.Questions.SelectMany(WithAndWithoutTrailingDots).ToArray().ToImmutableArray(); + var answeredQuestions = udpAnswer.Questions.SelectMany(WithAndWithoutTrailingDots).ToImmutableArray(); + + if (answeredQuestions.Length == 0 || sentQuestions.Intersect(answeredQuestions).Any()) + { + request.ReplyTo.Tell(udpAnswer.Content); + _inflightRequests.Remove(udpAnswer.Content.Id); + } + else + { + _log.Warning("Martian DNS response for id [{0}]. Expected names [{1}], received names [{2}]. Discarding response", + udpAnswer.Content.Id, + string.Join(", ", sentQuestions), + string.Join(", ", answeredQuestions)); + } + } + else + { + _log.Debug("Client for id [{0}] not found. Discarding response.", udpAnswer.Content.Id); + } + break; + + case Answer answer: + { + if (_inflightRequests.TryGetValue(answer.Id, out var inFlight)) + { + inFlight.ReplyTo.Tell(answer); + _inflightRequests.Remove(answer.Id); + } + else + { + _log.Debug("Client for id [{0}] not found. Discarding response.", answer.Id); + } + + break; + } + + case Udp.CommandFailed { Cmd: Udp.Send send } cmdFailed: + { + try + { + var msg = DnsProtocol.Message.Parse(send.Payload.ToArray()); + if (_inflightRequests.TryGetValue(msg.Id, out var inFlight)) + { + inFlight.ReplyTo.Tell(new Status.Failure(new Exception("Send failed to nameserver"))); + _inflightRequests.Remove(msg.Id); + } + } + catch + { + _log.Warning("DNS client failed to send {0}", cmdFailed.Cmd); + } + + break; + } + case Udp.CommandFailed cmdFailed: + _log.Warning("DNS client failed to send {0}", cmdFailed.Cmd); + break; + + case Tcp.Aborted _: + _log.Warning("TCP client failed, clearing inflight resolves which were being resolved by TCP"); + _inflightRequests = _inflightRequests.Where(kv => !kv.Value.TcpRequest) + .ToDictionary(kv => kv.Key, kv => kv.Value); + break; + + case Udp.Unbind _: + Sender.Tell(Udp.Unbind.Instance); + break; + + case Udp.Unbound _: + Context.Stop(Self); + break; + } + } + + private void HandleQuestion(short id, string name, DnsProtocol.RecordType recordType, IActorRef sender) + { + if (_inflightRequests.ContainsKey(id)) + { + _log.Warning("DNS transaction ID collision encountered for ID [{0}], ignoring. This likely indicates a bug.", + id); + return; + } + + _log.Debug("Resolving [{0}] ({1})", name, recordType); + + var msg = CreateMessage(name, id, recordType); + _inflightRequests[id] = new InFlightRequest(sender, msg); + _log.Debug("Message [{0}] to [{1}]: [{2}]", id, _nameserver, msg); + + // Send via bound UDP socket - assumes Context has been switched to Ready state with socket as Sender + + var data = ByteString.FromBytes(msg.Write()); + _udpSocket.Tell(new Udp.Send( data, _nameserver, Udp.NoAck.Instance )); + } + + private void HandleDropRequest(DropRequest dropRequest) + { + var id = dropRequest.Question.Id; + if (_inflightRequests.TryGetValue(id, out var inFlight)) + { + var sentQuestions = inFlight.Message.Questions.Select(q => new { q.Name, q.Type }).ToList(); + + string expectedName = null; + DnsProtocol.RecordType expectedType = DnsProtocol.RecordType.A; + + switch (dropRequest.Question) + { + case Question4 q4: + expectedName = q4.Name; + expectedType = DnsProtocol.RecordType.A; + break; + case Question6 q6: + expectedName = q6.Name; + expectedType = DnsProtocol.RecordType.Aaaa; + break; + case SrvQuestion srv: + expectedName = srv.Name; + expectedType = DnsProtocol.RecordType.Srv; + break; + } + + if (sentQuestions.Any(q => q.Name == expectedName && q.Type == expectedType)) + { + _log.Debug("Dropping request [{0}]", id); + _inflightRequests.Remove(id); + Sender.Tell(new Dropped(id)); + } + else if (_log.IsInfoEnabled) + { + _log.Info("Requested to drop request for id [{0}] expecting [{1}/{2}] but found requests for [{3}]... ignoring drop request", + id, + expectedName, + expectedType, + string.Join(", ", sentQuestions.Select(q => $"{q.Name}/{q.Type}"))); + } + } + else + { + Sender.Tell(new Dropped(id)); + } + } + + private DnsProtocol.Message CreateMessage(string name, short id, DnsProtocol.RecordType recordType) + { + var question = new DnsProtocol.Question(name, recordType, DnsProtocol.RecordClass.In); + return new DnsProtocol.Message( + id, + new DnsProtocol.MessageFlags(), + ImmutableList.Create(question)); + } + + private IEnumerable<(string Name, DnsProtocol.RecordType Type)> WithAndWithoutTrailingDots(DnsProtocol.Question question) + { + yield return (question.Name, question.Type); + + if (question.Name.EndsWith(".")) + yield return (question.Name.Substring(0, question.Name.Length - 1), question.Type); + else + yield return (question.Name + ".", question.Type); + } + + private IActorRef CreateTcpClient() + { + var backoffOptions = Backoff.OnFailure( + childProps: Props.Create(() => new TcpDnsClient(_tcpManager, _nameserver, Self)), + childName: "tcpDnsClient", + minBackoff: TimeSpan.FromMilliseconds(10), + maxBackoff: TimeSpan.FromSeconds(20), + randomFactor: 0.1, + maxNrOfRetries: Int32.MinValue); + + return Context.ActorOf( + BackoffSupervisor.Props(backoffOptions), + "tcpDnsClientSupervisor"); + } +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs new file mode 100644 index 000000000..8d6b8eb10 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs @@ -0,0 +1,421 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Net; +using System.Text; + +namespace Akka.Discovery.Dns.Internal; + +/// +/// DNS protocol implementation supporting SRV records +/// +public static class DnsProtocol +{ + /// + /// DNS record types + /// + public enum RecordType : ushort + { + A = 1, // IPv4 address record + Ns = 2, // Nameserver record + Cname = 5, // Canonical name record + Soa = 6, // Start of authority record + Ptr = 12, // Pointer record + Mx = 15, // Mail exchange record + Txt = 16, // Text record + Aaaa = 28, // IPv6 address record + Srv = 33, // Service record + Any = 255 // Any record type + } + + /// + /// DNS record classes + /// + public enum RecordClass : ushort + { + In = 1, // Internet + Cs = 2, // CSNET + Ch = 3, // CHAOS + Hs = 4, // Hesiod + Any = 255 // Any class + } + + /// + /// DNS response codes + /// + public enum ResponseCode : byte + { + Success = 0, + FormatError = 1, + ServerFailure = 2, + NameError = 3, + NotImplemented = 4, + Refused = 5 + } + + /// + /// DNS message flags + /// + public record MessageFlags + { + public bool IsResponse { get; init; } + public byte OpCode { get; init; } + public bool IsAuthoritativeAnswer { get; init; } + public bool IsTruncated { get; init; } + public bool IsRecursionDesired { get; init; } + public bool IsRecursionAvailable { get; init; } + public ResponseCode ResponseCode { get; init; } + + public MessageFlags() + { + // Default values for a query + IsResponse = false; + OpCode = 0; + IsAuthoritativeAnswer = false; + IsTruncated = false; + IsRecursionDesired = true; + IsRecursionAvailable = false; + ResponseCode = ResponseCode.Success; + } + + /// + /// Parse message flags from a 16-bit value + /// + public static MessageFlags FromUInt16(ushort flags) + { + return new MessageFlags + { + IsResponse = (flags & 0x8000) != 0, + OpCode = (byte)((flags >> 11) & 0xF), + IsAuthoritativeAnswer = (flags & 0x0400) != 0, + IsTruncated = (flags & 0x0200) != 0, + IsRecursionDesired = (flags & 0x0100) != 0, + IsRecursionAvailable = (flags & 0x0080) != 0, + ResponseCode = (ResponseCode)(flags & 0xF) + }; + } + + /// + /// Convert message flags to a 16-bit value + /// + public ushort ToUInt16() + { + ushort flags = 0; + + if (IsResponse) flags |= 0x8000; + flags |= (ushort)((OpCode & 0xF) << 11); + if (IsAuthoritativeAnswer) flags |= 0x0400; + if (IsTruncated) flags |= 0x0200; + if (IsRecursionDesired) flags |= 0x0100; + if (IsRecursionAvailable) flags |= 0x0080; + flags |= (ushort)((int)ResponseCode & 0xF); + + return flags; + } + + public override string ToString() + { + return $"MessageFlags(response={IsResponse}, opCode={OpCode}, aa={IsAuthoritativeAnswer}, " + + $"tc={IsTruncated}, rd={IsRecursionDesired}, ra={IsRecursionAvailable}, rcode={ResponseCode})"; + } + } + + /// + /// DNS question + /// + public class Question + { + public string Name { get; } + public RecordType Type { get; } + public RecordClass Class { get; } + + public Question(string name, RecordType type, RecordClass @class) + { + Name = name; + Type = type; + Class = @class; + } + + public override string ToString() + { + return $"Question({Name}, {Type}, {Class})"; + } + } + + /// + /// DNS message + /// + public class Message + { + public short Id { get; } + public MessageFlags Flags { get; } + public ImmutableList Questions { get; } + public ImmutableList AnswerRecords { get; } + public ImmutableList AuthorityRecords { get; } + public ImmutableList AdditionalRecords { get; } + + public Message( + short id, + MessageFlags flags, + ImmutableList questions, + ImmutableList answerRecords = null, + ImmutableList authorityRecords = null, + ImmutableList additionalRecords = null) + { + Id = id; + Flags = flags ?? new MessageFlags(); + Questions = questions; + AnswerRecords = answerRecords ?? ImmutableList.Empty; + AuthorityRecords = authorityRecords ?? ImmutableList.Empty; + AdditionalRecords = additionalRecords ?? ImmutableList.Empty; + } + + public override string ToString() + { + return $"Message(id={Id}, flags={Flags}, questions=[{string.Join(", ", Questions)}], " + + $"answers=[{string.Join(", ", AnswerRecords)}], " + + $"authority=[{string.Join(", ", AuthorityRecords)}], " + + $"additional=[{string.Join(", ", AdditionalRecords)}])"; + } + + /// + /// Write the DNS message to a byte array + /// + public byte[] Write() + { + using (var ms = new MemoryStream()) + using (var writer = new BinaryWriter(ms)) + { + // Write header + writer.Write(IPAddress.HostToNetworkOrder((short)Id)); + writer.Write(IPAddress.HostToNetworkOrder((short)Flags.ToUInt16())); + writer.Write(IPAddress.HostToNetworkOrder((short)Questions.Count)); + writer.Write(IPAddress.HostToNetworkOrder((short)AnswerRecords.Count)); + writer.Write(IPAddress.HostToNetworkOrder((short)AuthorityRecords.Count)); + writer.Write(IPAddress.HostToNetworkOrder((short)AdditionalRecords.Count)); + + // Write questions + foreach (var question in Questions) + { + WriteDomainName(writer, question.Name); + writer.Write(IPAddress.HostToNetworkOrder((short)question.Type)); + writer.Write(IPAddress.HostToNetworkOrder((short)question.Class)); + } + + // Write resource records + WriteResourceRecords(writer, AnswerRecords); + WriteResourceRecords(writer, AuthorityRecords); + WriteResourceRecords(writer, AdditionalRecords); + + return ms.ToArray(); + } + } + + /// + /// Parse a DNS message from a byte array + /// + public static Message Parse(byte[] data) + { + using (var ms = new MemoryStream(data)) + using (var reader = new BinaryReader(ms)) + { + // Read header + short id = IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort flags = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort questionCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort answerCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort authorityCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort additionalCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + + var messageFlags = MessageFlags.FromUInt16(flags); + + // Read questions + var questions = ImmutableList.CreateBuilder(); + for (int i = 0; i < questionCount; i++) + { + string name = ReadDomainName(reader, ms); + ushort type = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort @class = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + + questions.Add(new Question(name, (RecordType)type, (RecordClass)@class)); + } + + // Read resource records + var answerRecords = ReadResourceRecords(reader, ms, answerCount); + var authorityRecords = ReadResourceRecords(reader, ms, authorityCount); + var additionalRecords = ReadResourceRecords(reader, ms, additionalCount); + + return new Message( + id, + messageFlags, + questions.ToImmutable(), + answerRecords, + authorityRecords, + additionalRecords); + } + } + + /// + /// Write domain name in DNS format + /// + private static void WriteDomainName(BinaryWriter writer, string name) + { + if (string.IsNullOrEmpty(name) || name == ".") + { + // Root domain + writer.Write((byte)0); + return; + } + + string normalizedName = name.EndsWith(".") ? name : name + "."; + string[] labels = normalizedName.Split('.'); + + foreach (string label in labels) + { + if (!string.IsNullOrEmpty(label)) + { + byte[] labelBytes = Encoding.ASCII.GetBytes(label); + writer.Write((byte)labelBytes.Length); + writer.Write(labelBytes); + } + } + + // Terminate with root label + writer.Write((byte)0); + } + + /// + /// Read domain name in DNS format, handling compression + /// + private static string ReadDomainName(BinaryReader reader, MemoryStream ms) + { + List labels = new List(); + int length; + + while ((length = reader.ReadByte()) > 0) + { + // Check for compression pointer + if ((length & 0xC0) == 0xC0) + { + int pointer = ((length & 0x3F) << 8) | reader.ReadByte(); + long currentPosition = ms.Position; + ms.Position = pointer; + + // Recursively read the pointed-to name + string pointerName = ReadDomainName(reader, ms); + + // Restore position and return combined name + ms.Position = currentPosition; + + if (labels.Count > 0) + { + return string.Join(".", labels) + "." + pointerName; + } + return pointerName; + } + + // Regular label + byte[] labelBytes = reader.ReadBytes(length); + labels.Add(Encoding.ASCII.GetString(labelBytes)); + } + + return string.Join(".", labels); + } + + /// + /// Write resource records + /// + private static void WriteResourceRecords(BinaryWriter writer, IEnumerable records) + { + foreach (var record in records) + { + WriteDomainName(writer, record.Name); + writer.Write(IPAddress.HostToNetworkOrder((short)record.Type)); + writer.Write(IPAddress.HostToNetworkOrder((short)record.Class)); + writer.Write(IPAddress.HostToNetworkOrder((int)record.TimeToLive)); + + // Write record data + byte[] data = record.WriteData(); + writer.Write(IPAddress.HostToNetworkOrder((short)data.Length)); + writer.Write(data); + } + } + + /// + /// Read resource records + /// + private static ImmutableList ReadResourceRecords( + BinaryReader reader, MemoryStream ms, int count) + { + var records = ImmutableList.CreateBuilder(); + + for (int i = 0; i < count; i++) + { + string name = ReadDomainName(reader, ms); + ushort type = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort @class = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + uint ttl = (uint)IPAddress.NetworkToHostOrder(reader.ReadInt32()); + ushort dataLength = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + + // Read the raw data + byte[] data = reader.ReadBytes(dataLength); + + // Create appropriate resource record based on type + ResourceRecord record; + switch ((RecordType)type) + { + case RecordType.A: + record = new ARecord(name, (RecordClass)@class, ttl, new IPAddress(data)); + break; + case RecordType.Aaaa: + record = new AaaaRecord(name, (RecordClass)@class, ttl, new IPAddress(data)); + break; + case RecordType.Cname: + record = new CnameRecord(name, (RecordClass)@class, ttl, + ReadDomainNameFromData(data)); + break; + case RecordType.Srv: + record = ReadSrvRecord(name, (RecordClass)@class, ttl, data); + break; + default: + record = new UnknownRecord(name, (RecordType)type, (RecordClass)@class, ttl, data); + break; + } + + records.Add(record); + } + + return records.ToImmutable(); + } + + /// + /// Read domain name from raw record data + /// + private static string ReadDomainNameFromData(byte[] data) + { + using (var ms = new MemoryStream(data)) + using (var reader = new BinaryReader(ms)) + { + return ReadDomainName(reader, ms); + } + } + + /// + /// Read SRV record from raw data + /// + private static SrvRecord ReadSrvRecord(string name, RecordClass @class, uint ttl, byte[] data) + { + using (var ms = new MemoryStream(data)) + using (var reader = new BinaryReader(ms)) + { + ushort priority = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort weight = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort port = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + string target = ReadDomainName(reader, ms); + + return new SrvRecord(name, @class, ttl, priority, weight, port, target); + } + } + } +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/Polyfill.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/Polyfill.cs new file mode 100644 index 000000000..d59965cf5 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/Polyfill.cs @@ -0,0 +1,6 @@ +#if NETSTANDARD2_0 +//this is required for compiling C# records for netstandard2_0 and other older targets +namespace System.Runtime.CompilerServices; +internal static class IsExternalInit {} + +#endif \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs new file mode 100644 index 000000000..7f07ed526 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using System.Net; +using System.Text; + +namespace Akka.Discovery.Dns.Internal; + +/// +/// Base class for DNS resource records +/// + +public abstract record ResourceRecord( + string Name, + DnsProtocol.RecordType Type, + DnsProtocol.RecordClass Class, + uint TimeToLive) +{ + public abstract byte[] WriteData(); + + + internal static void WriteDomainName(BinaryWriter writer, string name) + { + if (string.IsNullOrEmpty(name) || name == ".") + { + writer.Write((byte)0); + return; + } + + string normalizedName = name.EndsWith(".") ? name : name + "."; + string[] labels = normalizedName.Split('.'); + + foreach (string label in labels) + { + if (!string.IsNullOrEmpty(label)) + { + byte[] labelBytes = Encoding.ASCII.GetBytes(label); + writer.Write((byte)labelBytes.Length); + writer.Write(labelBytes); + } + } + + writer.Write((byte)0); + } +} + +/// +/// IPv4 address record (A) +/// +public sealed record ARecord( + string Name, + DnsProtocol.RecordClass Class, + uint TimeToLive, + IPAddress Ip) + : ResourceRecord(Name, DnsProtocol.RecordType.A, Class, TimeToLive) +{ + public override byte[] WriteData() + { + return Ip.GetAddressBytes(); + } +} + +/// +/// IPv6 address record (AAAA) +/// +public sealed record AaaaRecord( + string Name, + DnsProtocol.RecordClass Class, + uint TimeToLive, + IPAddress Ip) + : ResourceRecord(Name, DnsProtocol.RecordType.Aaaa, Class, TimeToLive) +{ + public override byte[] WriteData() + { + return Ip.GetAddressBytes(); + } +} + +/// +/// Canonical name record (CNAME) +/// +public sealed record CnameRecord ( + string Name, + DnsProtocol.RecordClass Class, + uint TimeToLive, + string CanonicalName) + : ResourceRecord(Name, DnsProtocol.RecordType.Cname, Class, TimeToLive) +{ +// public string CanonicalName { get; } +// +// public CnameRecord( +// string name, +// DnsProtocol.RecordClass @class, +// uint timeToLive, +// string canonicalName) +// : base(name, DnsProtocol.RecordType.Cname, @class, timeToLive) +// { +// CanonicalName = canonicalName; +// } +// + public override byte[] WriteData() + { + using (var ms = new MemoryStream()) + using (var writer = new BinaryWriter(ms)) + { + WriteDomainName(writer, CanonicalName); + return ms.ToArray(); + } + } + +// +// public override string ToString() +// { +// return $"CnameRecord({Name}, {CanonicalName}, ttl={TimeToLive})"; +// } +} + +/// +/// Service record (SRV) +/// +public sealed record SrvRecord( + string Name, + DnsProtocol.RecordClass Class, + uint TimeToLive, + ushort Priority, + ushort Weight, + ushort Port, + string Target) + : ResourceRecord(Name, DnsProtocol.RecordType.Srv, Class, TimeToLive) +{ + // { + // Priority = priority; + // Weight = weight; + // Port = port; + // Target = target; + // } + + public override byte[] WriteData() + { + using (var ms = new MemoryStream()) + using (var writer = new BinaryWriter(ms)) + { + writer.Write(IPAddress.HostToNetworkOrder((short)Priority)); + writer.Write(IPAddress.HostToNetworkOrder((short)Weight)); + writer.Write(IPAddress.HostToNetworkOrder((short)Port)); + WriteDomainName(writer, Target); + return ms.ToArray(); + } + } +// +// private void WriteDomainName(BinaryWriter writer, string name) +// { +// if (string.IsNullOrEmpty(name) || name == ".") +// { +// writer.Write((byte)0); +// return; +// } +// +// string normalizedName = name.EndsWith(".") ? name : name + "."; +// string[] labels = normalizedName.Split('.'); +// +// foreach (string label in labels) +// { +// if (!string.IsNullOrEmpty(label)) +// { +// byte[] labelBytes = Encoding.ASCII.GetBytes(label); +// writer.Write((byte)labelBytes.Length); +// writer.Write(labelBytes); +// } +// } +// +// writer.Write((byte)0); +// } +// +// public override string ToString() +// { +// return $"SrvRecord({Name}, priority={Priority}, weight={Weight}, port={Port}, target={Target}, ttl={TimeToLive})"; +// } +} + +/// +/// Generic record for types not specifically implemented +/// +public sealed record UnknownRecord( + string Name, + DnsProtocol.RecordType Type, + DnsProtocol.RecordClass Class, + uint TimeToLive, + byte[] Data) + : ResourceRecord(Name, Type, Class, TimeToLive) +{ + public override byte[] WriteData() => Data; + +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs new file mode 100644 index 000000000..2c0b58b60 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Net; +using System.Net.Sockets; +using Akka.Actor; +using Akka.Event; +using Akka.IO; + +namespace Akka.Discovery.Dns.Internal; + +/// +/// TCP DNS client actor for handling DNS requests over TCP. +/// Used as a fallback when UDP responses are truncated. +/// +internal class TcpDnsClient : UntypedActor +{ + private readonly EndPoint _nameserver; + private readonly IActorRef _parent; + private readonly ILoggingAdapter _log; + private readonly IActorRef _tcpManager; + + private IActorRef _connection; + private byte[] _readBuffer = new byte[2048]; // Buffer for reading DNS responses + private int _expectedLength = -1; // Expected length of current DNS response + private int _currentPosition = 0; // Current position in the buffer + + // Pending requests that need to be sent once connection is established + private Queue _pendingRequests = new Queue(); + + public TcpDnsClient(IActorRef tcpManager, EndPoint nameserver, IActorRef parent) + { + _tcpManager = tcpManager; + _nameserver = nameserver; + _parent = parent; + _log = Context.GetLogger(); + } + + protected override void PreStart() + { + // Connect to the DNS server over TCP + _tcpManager.Tell(new Tcp.Connect(_nameserver)); + } + + protected override void OnReceive(object message) + { + switch (message) + { + case Tcp.Connected connected: + _log.Debug("Connected to DNS server at [{0}]", connected.RemoteAddress); + _connection = Sender; + _connection.Tell(new Tcp.Register(Self)); + + // Send any pending requests + while (_pendingRequests.Count > 0) + { + SendMessage(_pendingRequests.Dequeue()); + } + break; + + case Tcp.CommandFailed failed when failed.Cmd is Tcp.Connect: + _log.Warning("Failed to connect to DNS server: {0}", failed); + _parent.Tell(DnsClient.TcpDropped); + Context.Stop(Self); + break; + + case Tcp.Received received: + ProcessReceivedData(received.Data.ToArray()); + break; + + case Tcp.ConnectionClosed _: + _log.Debug("Connection to DNS server closed"); + _parent.Tell(DnsClient.TcpDropped); + Context.Stop(Self); + break; + + case DnsProtocol.Message msg: + if (_connection != null) + { + SendMessage(msg); + } + else + { + // Store message to send after connection is established + _pendingRequests.Enqueue(msg); + } + break; + + case Status.Failure failure: + _log.Error(failure.Cause, "TCP DNS client failure"); + _parent.Tell(DnsClient.TcpDropped); + Context.Stop(Self); + break; + } + } + + private void SendMessage(DnsProtocol.Message message) + { + try + { + // For TCP DNS, we need to prefix the message with a 2-byte length field + byte[] dnsMessage = message.Write(); + byte[] lengthPrefixed = new byte[dnsMessage.Length + 2]; + + // Add length prefix in network byte order (big endian) + lengthPrefixed[0] = (byte)((dnsMessage.Length >> 8) & 0xFF); + lengthPrefixed[1] = (byte)(dnsMessage.Length & 0xFF); + + // Copy the DNS message after the length prefix + Array.Copy(dnsMessage, 0, lengthPrefixed, 2, dnsMessage.Length); + + // Send the message + _connection.Tell(Tcp.Write.Create(ByteString.FromBytes(lengthPrefixed))); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to send DNS message over TCP"); + _parent.Tell(new Status.Failure(ex)); + } + } + + private void ProcessReceivedData(byte[] data) + { + try + { + // Copy received data to the buffer + Array.Copy(data, 0, _readBuffer, _currentPosition, data.Length); + _currentPosition += data.Length; + + // Process all complete messages in the buffer + while (_currentPosition >= 2) + { + if (_expectedLength == -1) + { + // Extract the length prefix + _expectedLength = (_readBuffer[0] << 8) | _readBuffer[1]; + + if (_expectedLength <= 0) + { + _log.Warning("Invalid DNS message length: {0}", _expectedLength); + ResetBuffer(); + return; + } + } + + // Check if we have a complete message + if (_currentPosition >= _expectedLength + 2) + { + // Extract the DNS message (skipping the length prefix) + var messageData = new byte[_expectedLength]; + Array.Copy(_readBuffer, 2, messageData, 0, _expectedLength); + + // Parse and process the message + var dnsMessage = DnsProtocol.Message.Parse(messageData); + _log.Debug("Received DNS response over TCP: {0}", dnsMessage); + + // Get resource records based on the response code + var records = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success + ? dnsMessage.AnswerRecords : Array.Empty().ToImmutableList(); + var additionalRecs = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success + ? dnsMessage.AdditionalRecords : Array.Empty().ToImmutableList(); + + // Forward the answer to the parent + _parent.Tell(new DnsClient.Answer(dnsMessage.Id, records, additionalRecs)); + + // Remove the processed message from the buffer + var remaining = _currentPosition - (_expectedLength + 2); + if (remaining > 0) + { + Array.Copy(_readBuffer, _expectedLength + 2, _readBuffer, 0, remaining); + } + _currentPosition = remaining; + _expectedLength = -1; + } + else + { + // Need more data for a complete message + break; + } + } + } + catch (Exception ex) + { + _log.Error(ex, "Failed to process DNS response from TCP"); + ResetBuffer(); + } + } + + private void ResetBuffer() + { + _currentPosition = 0; + _expectedLength = -1; + } + + protected override void PostStop() + { + // Close the connection when the actor stops + if (_connection != null) + { + _connection.Tell(Tcp.Close.Instance); + } + } +} \ No newline at end of file From 398cecb7b74920262c97fe2694f855dbc66f9872 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 10:16:39 -0300 Subject: [PATCH 04/37] cleanup commented code and typos --- .../AkkaHostingExtensions.cs | 7 ++- .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 9 +--- .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 8 ++- .../Akka.Discovery.Dns/Internal/DnsClient.cs | 2 +- .../Internal/ResourceRecord.cs | 54 ------------------- 5 files changed, 8 insertions(+), 72 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs index 657115c69..5acbcc5a6 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs @@ -69,11 +69,10 @@ public static AkkaConfigurationBuilder WithDnsResolver( } public static AkkaConfigurationBuilder WithAsyncDnsResolver( - this AkkaConfigurationBuilder builder, Action? configure = null) + this AkkaConfigurationBuilder builder, Action? configure = null) { - builder.WithDnsResolver(AsyncDnsResolerOptions.DefaultPath); - // builder.AddHocon($"akka.io.dns.provider-object = {AsyncDnsResolerOptions.ProviderName}", HoconAddMode.Prepend); - var opt = new AsyncDnsResolerOptions(); + builder.WithDnsResolver(AsyncDnsResolverOptions.DefaultPath); + var opt = new AsyncDnsResolverOptions(); configure?.Invoke(opt); opt.Apply(builder); return builder; diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index edb317edb..82d95a45c 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -135,7 +135,7 @@ public DnsDiscoveryMultiSetup(IReadOnlyDictionary set } -public class AsyncDnsResolerOptions : IHoconOption +public class AsyncDnsResolverOptions : IHoconOption { @@ -155,12 +155,6 @@ private string ToHocon() sb.AppendLine($"{FullPath(ConfigPath)} {{"); sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\","); sb.AppendLine($" provider-object = \"{Provider.FullName}, {Provider.Assembly.GetName().Name}\","); - // sb.Append($" {NameserversPath} = ["); - // foreach (var name in Nameservers) - // { - // sb.Append($"\"{name}\","); - // } - // sb.AppendLine("]"); sb.Append($" {NameserversPath} = \"{Nameserver}\","); sb.AppendLine("}"); @@ -177,5 +171,4 @@ public void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) public string ConfigPath => DefaultPath; public Type Class => typeof(DnsClient); public Type Provider => typeof(AsyncDnsProvider); - // static string ProviderName => $"{Provider.FullName}, {Provider.Assembly.GetName().Name}"; } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index a7b8f77ca..faa7a60e9 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -17,7 +17,6 @@ namespace Akka.Discovery.Dns; public class DnsServiceDiscovery : ServiceDiscovery { private readonly ILoggingAdapter _log; - // private readonly DnsExt _dns; private readonly IActorRef _dns; private readonly ExtendedActorSystem _system; @@ -25,24 +24,23 @@ public DnsServiceDiscovery(ExtendedActorSystem system) { _system = system; _log = Logging.GetLogger(system, this); - var dnsResolver = _system.Settings.Config.GetString("akka.io.dns.resolver"); switch (dnsResolver) { - case "inet-address": + case "inet-address": // default akka setting { var dns = Akka.IO.Dns.Instance.CreateExtension(_system); _dns = dns.Manager; break; } - case "async-dns": + case AsyncDnsResolverOptions.DefaultPath: { var dns = new AsyncDnsExt(_system); _dns = dns.Manager; break; } default: - throw new NotImplementedException(); + throw new NotImplementedException($"Following configuration is not supported by this plugin: akka.io.dns.resolver = '{dnsResolver}'"); } } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs index cbe7e3d34..2b1abfc24 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs @@ -175,7 +175,7 @@ static EndPoint ParseEndPoint(string endpoint) public DnsClient(AsyncDnsExt ext) { _log = Context.GetLogger(); - var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolerOptions.NameserversPath) ?? throw new ConfigurationException("nameservers config was empty"); + var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolverOptions.NameserversPath) ?? throw new ConfigurationException("nameservers config was empty"); _nameserver = ParseEndPoint(ns); _udpManager = Akka.IO.Udp.Instance.Apply(Context.System).Manager; _tcpManager = Akka.IO.Tcp.Manager(Context.System); diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs index 7f07ed526..df00fca41 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs @@ -85,18 +85,6 @@ public sealed record CnameRecord ( string CanonicalName) : ResourceRecord(Name, DnsProtocol.RecordType.Cname, Class, TimeToLive) { -// public string CanonicalName { get; } -// -// public CnameRecord( -// string name, -// DnsProtocol.RecordClass @class, -// uint timeToLive, -// string canonicalName) -// : base(name, DnsProtocol.RecordType.Cname, @class, timeToLive) -// { -// CanonicalName = canonicalName; -// } -// public override byte[] WriteData() { using (var ms = new MemoryStream()) @@ -106,12 +94,6 @@ public override byte[] WriteData() return ms.ToArray(); } } - -// -// public override string ToString() -// { -// return $"CnameRecord({Name}, {CanonicalName}, ttl={TimeToLive})"; -// } } /// @@ -127,13 +109,6 @@ public sealed record SrvRecord( string Target) : ResourceRecord(Name, DnsProtocol.RecordType.Srv, Class, TimeToLive) { - // { - // Priority = priority; - // Weight = weight; - // Port = port; - // Target = target; - // } - public override byte[] WriteData() { using (var ms = new MemoryStream()) @@ -146,35 +121,6 @@ public override byte[] WriteData() return ms.ToArray(); } } -// -// private void WriteDomainName(BinaryWriter writer, string name) -// { -// if (string.IsNullOrEmpty(name) || name == ".") -// { -// writer.Write((byte)0); -// return; -// } -// -// string normalizedName = name.EndsWith(".") ? name : name + "."; -// string[] labels = normalizedName.Split('.'); -// -// foreach (string label in labels) -// { -// if (!string.IsNullOrEmpty(label)) -// { -// byte[] labelBytes = Encoding.ASCII.GetBytes(label); -// writer.Write((byte)labelBytes.Length); -// writer.Write(labelBytes); -// } -// } -// -// writer.Write((byte)0); -// } -// -// public override string ToString() -// { -// return $"SrvRecord({Name}, priority={Priority}, weight={Weight}, port={Port}, target={Target}, ttl={TimeToLive})"; -// } } /// From 20e50b2681145e233c09bdbffbac0cd28751df98 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 10:50:03 -0300 Subject: [PATCH 05/37] Custom override for DnsExt is no longer required see https://github.com/akkadotnet/akka.net/pull/7727 --- .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 20 +---------------- .../Akka.Discovery.Dns/Internal/DnsClient.cs | 22 +------------------ 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index faa7a60e9..0bc2adc74 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -24,25 +24,7 @@ public DnsServiceDiscovery(ExtendedActorSystem system) { _system = system; _log = Logging.GetLogger(system, this); - var dnsResolver = _system.Settings.Config.GetString("akka.io.dns.resolver"); - switch (dnsResolver) - { - case "inet-address": // default akka setting - { - var dns = Akka.IO.Dns.Instance.CreateExtension(_system); - _dns = dns.Manager; - break; - } - case AsyncDnsResolverOptions.DefaultPath: - { - var dns = new AsyncDnsExt(_system); - _dns = dns.Manager; - break; - } - default: - throw new NotImplementedException($"Following configuration is not supported by this plugin: akka.io.dns.resolver = '{dnsResolver}'"); - - } + _dns = Akka.IO.Dns.Instance.CreateExtension(_system).Manager; } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs index 2b1abfc24..7d3541c26 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs @@ -13,26 +13,6 @@ namespace Akka.Discovery.Dns.Internal; -public class AsyncDnsExt : DnsExt -{ - public AsyncDnsExt(ExtendedActorSystem system) : base(system) - { - _system = system; - } - private readonly ExtendedActorSystem _system; - private IActorRef? _manager; - public override IActorRef Manager - { - get - { - // base implementation doesn't respect custom provider/manager settings, perhpaps on purpouse - return _manager = _manager ?? _system.SystemActorOf(Props.Create(Provider.ManagerClass, this).WithDeploy(Deploy.Local).WithDispatcher(Settings.Dispatcher) - .WithDeploy(Deploy.Local) - .WithDispatcher(Settings.Dispatcher)); - } - } -} - public class AsyncDnsProvider : IDnsProvider { private readonly DnsBase _cache = new SimpleDnsCache(); @@ -172,7 +152,7 @@ static EndPoint ParseEndPoint(string endpoint) return new IPEndPoint(ip, port); } - public DnsClient(AsyncDnsExt ext) + public DnsClient(DnsExt ext) { _log = Context.GetLogger(); var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolverOptions.NameserversPath) ?? throw new ConfigurationException("nameservers config was empty"); From 2230205fc873c0742fe76a5ca50ff76d76453825 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 13:17:26 -0300 Subject: [PATCH 06/37] fix IPv6 formating --- .../Cluster/Bootstrap/Internal/BootstrapCoordinator.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/management/Akka.Management/Cluster/Bootstrap/Internal/BootstrapCoordinator.cs b/src/management/Akka.Management/Cluster/Bootstrap/Internal/BootstrapCoordinator.cs index 0a2f17699..e6a74fa85 100644 --- a/src/management/Akka.Management/Cluster/Bootstrap/Internal/BootstrapCoordinator.cs +++ b/src/management/Akka.Management/Cluster/Bootstrap/Internal/BootstrapCoordinator.cs @@ -1,4 +1,4 @@ -//----------------------------------------------------------------------- +//----------------------------------------------------------------------- // // Copyright (C) 2009-2021 Lightbend Inc. // Copyright (C) 2013-2021 .NET Foundation @@ -429,6 +429,12 @@ private void OnContactPointsResolved( } } + private static string ResolvedTargetHost(ResolvedTarget contactPoint) => + contactPoint.Address == null + ? contactPoint.Host + : contactPoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 + ? $"[{contactPoint.Address}]" // enclose IPv6 addresses into square brackets for proper URI formating + : contactPoint.Address.ToString(); protected virtual IActorRef? EnsureProbing(string selfContactPointScheme, ResolvedTarget contactPoint) { if (contactPoint.Address is null && contactPoint.Host is null) @@ -438,7 +444,7 @@ private void OnContactPointsResolved( } var targetPort = contactPoint.Port ?? _settings.ContactPoint.FallbackPort; - var rawBaseUri = $"{selfContactPointScheme}://{contactPoint.Address?.ToString() ?? contactPoint.Host}:{targetPort}"; + var rawBaseUri = $"{selfContactPointScheme}://{ResolvedTargetHost(contactPoint)}:{targetPort}"; if (!string.IsNullOrEmpty(_settings.ManagementBasePath)) rawBaseUri += $"/{_settings.ManagementBasePath}"; var baseUri = new Uri(rawBaseUri); From f7eccd68471cdb640feecd03d9986ab410994142 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 13:17:59 -0300 Subject: [PATCH 07/37] fix logging of parsed IP records --- src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index 0bc2adc74..8ec3aa714 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -94,8 +94,9 @@ private async Task AskResolveIp(string serviceName, TimeSpan timeout) { if (resolved.IsSuccess) { - _log.Debug("lookup result: {0}", resolved); - return IpRecordsToResolved(serviceName, resolved); + var parsed = IpRecordsToResolved(serviceName, resolved); + _log.Debug("lookup result: {0}", parsed); + return parsed; } _log.Error(resolved.Exception, "Failed to resolve serviceName: {0}", serviceName); From 533fe19344cbcccdd5e7da73817ba0a59d51f258 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 15:08:56 -0300 Subject: [PATCH 08/37] separate A and AAAA examples --- .../examples/discovery/dns/README.md | 10 ++- .../examples/discovery/dns/build.ps1 | 12 +-- .../examples/discovery/dns/src/Program.cs | 62 ++++++++++++--- .../discovery/dns/src/aaaa/coredns/Corefile | 17 ++++ .../aaaa/coredns/zones/akkacluster.dns.podman | 21 +++++ .../discovery/dns/src/aaaa/resolv.conf | 2 + .../discovery/dns/src/docker-compose.a.yml | 78 +++++++++++++++++++ .../discovery/dns/src/docker-compose.aaaa.yml | 49 ++++++++++++ .../discovery/dns/src/docker-compose.srv.yml | 2 +- .../examples/discovery/dns/src/entrypoint.sh | 7 +- .../dns/src/{ => srv}/coredns/Corefile | 0 .../coredns/zones/akkacluster.dns.podman | 0 12 files changed, 237 insertions(+), 23 deletions(-) create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.podman create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml rename src/cluster.bootstrap/examples/discovery/dns/src/{ => srv}/coredns/Corefile (100%) rename src/cluster.bootstrap/examples/discovery/dns/src/{ => srv}/coredns/zones/akkacluster.dns.podman (100%) diff --git a/src/cluster.bootstrap/examples/discovery/dns/README.md b/src/cluster.bootstrap/examples/discovery/dns/README.md index 99fdc2fc1..9344ff43d 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/README.md +++ b/src/cluster.bootstrap/examples/discovery/dns/README.md @@ -1,3 +1,11 @@ # DNS Cluster Bootstrap Example -This example demonstrates how to use DNS-based service discovery with Akka.Management Cluster Bootstrap to form an Akka.NET Cluster. \ No newline at end of file +This example demonstrates how to use DNS-based service discovery with Akka.Management Cluster Bootstrap to form an Akka.NET Cluster. + +Three types of records are supported: A, AAAA and SRV. + +To run exmample use: +```pwsh +./build.ps1 [a|aaaa|srv] +``` +This will build example on your host machine and spawn dedicated docker-compose file for each record type. \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/build.ps1 b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 index fb6bd4405..291a75c63 100755 --- a/src/cluster.bootstrap/examples/discovery/dns/build.ps1 +++ b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 @@ -9,17 +9,13 @@ if ($recordType -ne "srv" -and $recordType -ne "a" -and $recordType -ne "aaaa") Write-Error "Invalid record type. Must be 'srv' or 'a' or 'aaaa'." exit 1 } - -if ($recordType -eq "a") { - $recordType = "aaaa" -} - -# Clean up previous containers first +Write-Output "Cleaning up previous containers (ignore errors if containers do not exist)...." podman-compose -f "$(pwd)/src/docker-compose.srv.yml" down podman-compose -f "$(pwd)/src/docker-compose.aaaa.yml" down +podman-compose -f "$(pwd)/src/docker-compose.a.yml" down -# Build and publish the container +Write-Output "Building and publishing example..." dotnet publish --os linux --arch x64 -c Release /t:PublishContainer ./src/DnsCluster.csproj -# Start with replace flag to handle container conflicts +Write-Output "Start $recordType example" podman-compose -f "$(pwd)/src/docker-compose.$recordType.yml" up --build \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs index 3a95d0b6c..fe5113c55 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs +++ b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs @@ -77,18 +77,58 @@ public static AkkaConfigurationBuilder BootstrapFromDocker( public static class Program { // Get container IP address for self-identification - static string GetContainerIp() + static (bool, string) GetContainerIp() { try { + // Check if we should prefer IPv6 (default to false) + var preferIpv6Env = Environment.GetEnvironmentVariable("PREFER_IPV6"); + var preferIpv6 = !string.IsNullOrEmpty(preferIpv6Env) && + (preferIpv6Env.Equals("true", StringComparison.OrdinalIgnoreCase) || + preferIpv6Env == "1"); + + // Log the preference + Console.WriteLine($"IP Protocol Preference: {(preferIpv6 ? "IPv6" : "IPv4")}"); + // Get IP of the container's network interface (typically eth0 in Docker) var addresses = System.Net.Dns.GetHostAddresses(System.Net.Dns.GetHostName()); - var ipv4 = addresses.FirstOrDefault(ip => ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); - return ipv4?.ToString() ?? "127.0.0.1"; + + // Check if we have non-loopback IPv6 addresses + var ipv6 = addresses.FirstOrDefault(ip => + ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && + !System.Net.IPAddress.IsLoopback(ip)); + + // Get IPv4 addresses + var ipv4 = addresses.FirstOrDefault(ip => + ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && + !System.Net.IPAddress.IsLoopback(ip)); + + + // Choose based on preference and availability + if (preferIpv6 && ipv6 != null) + { + Console.WriteLine($"Using IPv6 address: {ipv6}"); + return (true, ipv6.ToString()); + } + else if (ipv4 != null) + { + Console.WriteLine($"Using IPv4 address: {ipv4}"); + return (false, ipv4.ToString()); + } + else if (ipv6 != null) // Fallback to IPv6 if IPv4 not available + { + Console.WriteLine($"Falling back to IPv6 address: {ipv6}"); + return (true, ipv6.ToString()); + } + + // Last resort fallback + Console.WriteLine("No suitable network interfaces found, using loopback"); + return (false, "127.0.0.1"); } - catch + catch (Exception ex) { - return "127.0.0.1"; + Console.WriteLine($"Error getting container IP: {ex.Message}"); + return (false, "127.0.0.1"); } } public static async Task Main(string[] args) @@ -148,11 +188,13 @@ public static async Task Main(string[] args) // Configure Akka.Management HTTP endpoint - builder.WithAkkaManagement(hostName: GetContainerIp(), port: managementPort, bindHostname : "0.0.0.0"); - // setup.Http.HostName = GetContainerIp(); // Use IP address instead of hostname - // setup.Http.Port = managementPort; - // setup.Port = managementPort; - // }); + // Detect if we're using IPv6 in the container + var (isIPv6, hostname) = GetContainerIp(); + + var bindAddress = isIPv6 ? "::" : "0.0.0.0"; + Console.WriteLine($"Binding Akka.Management to [{hostname}][{bindAddress}:{managementPort}]"); + + builder.WithAkkaManagement(hostName: hostname, port: managementPort, bindHostname: bindAddress); // Add Akka.Discovery.Dns support // Configure DNS discovery for Docker environment diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile new file mode 100644 index 000000000..51caa2867 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile @@ -0,0 +1,17 @@ +.:53 { + log { + class all + } + errors + auto + reload + hosts { + fallthrough + } + file /etc/coredns/zones/akkacluster.dns.podman akkacluster.dns.podman + prometheus + cache + template ANY A akkacluster.dns.podman { + rcode REFUSED + } +} diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.podman b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.podman new file mode 100644 index 000000000..45e64f9f1 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.podman @@ -0,0 +1,21 @@ +$ORIGIN akkacluster.dns.podman. +@ 3600 IN SOA dns-server.akkacluster.dns.podman. admin.akkacluster.dns.podman. ( + 2023121001 ; serial + 7200 ; refresh + 3600 ; retry + 1209600 ; expire + 3600 ; minimum +) + +; Name servers +@ 3600 IN NS dns-server.akkacluster.dns.podman. + +; AAAA records for the domain itself +@ IN AAAA 2001:db8:1::10 +@ IN AAAA 2001:db8:1::20 +@ IN AAAA 2001:db8:1::30 + +; AAAA records for each node +node1 IN AAAA 2001:db8:1::10 +node2 IN AAAA 2001:db8:1::20 +node3 IN AAAA 2001:db8:1::30 diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf new file mode 100644 index 000000000..6727c4caa --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf @@ -0,0 +1,2 @@ +search dns.podman +nameserver 2001:db8:1::2 \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml new file mode 100644 index 000000000..5e7ee7c3f --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml @@ -0,0 +1,78 @@ +version: '3.8' + +services: + node1: + build: + context: . + dockerfile: Dockerfile + image: akka-dns-cluster:latest + hostname: node1.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=8558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + ports: + - "4053:4053" + - "8558:8558" + - "9110:9110" + networks: + akkanet: + ipv6: false + aliases: + - akkacluster + + node2: + image: akka-dns-cluster:latest + hostname: node2.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=8558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + ports: + - "4054:4053" + - "8559:8558" + - "9111:9110" + networks: + akkanet: + ipv6: false + aliases: + - akkacluster + depends_on: + - node1 + + node3: + image: akka-dns-cluster:latest + hostname: node3.akkacluster + environment: + - CLUSTER__PORT=4053 + - CLUSTER__IP=0.0.0.0 + - ACTORSYSTEM=DnsCluster + - MANAGEMENT__PORT=8558 + - PBM__PORT=9110 + - SERVICENAME=akkacluster.dns.podman + ports: + - "4055:4053" + - "8560:8558" + - "9112:9110" + networks: + akkanet: + ipv6: false + aliases: + - akkacluster + depends_on: + - node1 + +networks: + akkanet: + driver: bridge + enable_ipv6: false + ipam: + config: + - subnet: 172.28.0.0/16 + gateway: 172.28.0.1 diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml index 704537265..a29e0648e 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml @@ -1,12 +1,35 @@ version: '3.8' services: + # CoreDNS server for AAAA-only record resolution + coredns: + image: coredns/coredns:1.10.1 + hostname: dns-server + volumes: + - ./aaaa/coredns:/etc/coredns + command: ["-conf", "/etc/coredns/Corefile"] + # No external ports exposed + # CoreDNS will listen on port 53 within the Docker network only + expose: + - "53/udp" + - "53/tcp" + networks: + akkanet: + ipv6: true + ipv6_address: 2001:db8:1::2 + aliases: + - dns-server node1: build: context: . dockerfile: Dockerfile image: akka-dns-cluster:latest hostname: node1.akkacluster + dns: + - 2001:db8:1::2 + # dotnet uses /etc/resolv.conf instead of above setting + volumes: + - ./aaaa/resolv.conf:/etc/resolv.conf environment: - CLUSTER__PORT=4053 - CLUSTER__IP=0.0.0.0 @@ -14,18 +37,27 @@ services: - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - SERVICENAME=akkacluster.dns.podman + - PREFER_IPV6=true ports: - "4053:4053" - "8558:8558" - "9110:9110" networks: akkanet: + ipv6: true + ipv6_address: 2001:db8:1::10 aliases: - akkacluster + depends_on: + - coredns node2: image: akka-dns-cluster:latest hostname: node2.akkacluster + dns: + - 2001:db8:1::2 + volumes: + - ./aaaa/resolv.conf:/etc/resolv.conf environment: - CLUSTER__PORT=4053 - CLUSTER__IP=0.0.0.0 @@ -33,20 +65,28 @@ services: - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - SERVICENAME=akkacluster.dns.podman + - PREFER_IPV6=true ports: - "4054:4053" - "8559:8558" - "9111:9110" networks: akkanet: + ipv6: true + ipv6_address: 2001:db8:1::20 aliases: - akkacluster depends_on: - node1 + - coredns node3: image: akka-dns-cluster:latest hostname: node3.akkacluster + dns: + - 2001:db8:1::2 + volumes: + - ./aaaa/resolv.conf:/etc/resolv.conf environment: - CLUSTER__PORT=4053 - CLUSTER__IP=0.0.0.0 @@ -54,17 +94,26 @@ services: - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - SERVICENAME=akkacluster.dns.podman + - PREFER_IPV6=true ports: - "4055:4053" - "8560:8558" - "9112:9110" networks: akkanet: + ipv6: true + ipv6_address: 2001:db8:1::30 aliases: - akkacluster depends_on: - node1 + - coredns networks: akkanet: driver: bridge + enable_ipv6: true + ipam: + config: + - subnet: 2001:db8:1::/64 + gateway: 2001:db8:1::1 \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml index 2e80a47b0..f1721f68d 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml @@ -6,7 +6,7 @@ services: image: coredns/coredns:1.10.1 hostname: dns-server volumes: - - ./coredns:/etc/coredns + - ./srv/coredns:/etc/coredns command: ["-conf", "/etc/coredns/Corefile"] ports: - "1053:1053/udp" diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh index 1c04f30d6..4d0548726 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh +++ b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh @@ -16,13 +16,14 @@ echo " DNS_PORT: $DNS_PORT" echo " DNS_NAMESERVER: $DNS_NAMESERVER" echo "===================================" DNS_PORT=${DNS_PORT:-53} -# Check A/AAAA records echo "\nPerforming DNS resolution test for '$SERVICENAME'..." +# A records dig -p $DNS_PORT $SERVICENAME -# check SRV record +# AAAA records +dig -p $DNS_PORT -t aaaa $SERVICENAME +# SRV records dig -p $DNS_PORT -t srv "_${PORTNAME}._tcp.$SERVICENAME" - export NAMESERVER="${DNS_NAMESERVER:-127.0.0.1}:$DNS_PORT" echo "\nStarting DnsCluster with DNS discovery..." exec dotnet /app/DnsCluster.dll "$@" \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/coredns/Corefile b/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/Corefile similarity index 100% rename from src/cluster.bootstrap/examples/discovery/dns/src/coredns/Corefile rename to src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/Corefile diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/coredns/zones/akkacluster.dns.podman b/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.podman similarity index 100% rename from src/cluster.bootstrap/examples/discovery/dns/src/coredns/zones/akkacluster.dns.podman rename to src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.podman From 8740cff619ed2cb91ac2fa19af1c2b075c3bf70c Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 15:24:14 -0300 Subject: [PATCH 09/37] use dns.oci root domain instead of default dns.podman for runtime independent expirience --- .../examples/discovery/dns/src/a/resolv.conf | 2 ++ .../discovery/dns/src/aaaa/coredns/Corefile | 4 ++-- ...cluster.dns.podman => akkacluster.dns.oci} | 6 +++--- .../discovery/dns/src/aaaa/resolv.conf | 2 +- .../discovery/dns/src/docker-compose.a.yml | 16 +++++++++++--- .../discovery/dns/src/docker-compose.aaaa.yml | 6 +++--- .../discovery/dns/src/docker-compose.srv.yml | 6 +++--- .../discovery/dns/src/srv/coredns/Corefile | 2 +- .../src/srv/coredns/zones/akkacluster.dns.oci | 21 +++++++++++++++++++ .../srv/coredns/zones/akkacluster.dns.podman | 21 ------------------- .../discovery/dns/src/srv/resolv.conf | 2 ++ 11 files changed, 51 insertions(+), 37 deletions(-) create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/a/resolv.conf rename src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/{akkacluster.dns.podman => akkacluster.dns.oci} (69%) create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.oci delete mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.podman create mode 100644 src/cluster.bootstrap/examples/discovery/dns/src/srv/resolv.conf diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/a/resolv.conf b/src/cluster.bootstrap/examples/discovery/dns/src/a/resolv.conf new file mode 100644 index 000000000..8b8f7a3d7 --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/a/resolv.conf @@ -0,0 +1,2 @@ +search dns.oci +nameserver 172.28.0.1 \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile index 51caa2867..d1639aaf4 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile +++ b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/Corefile @@ -8,10 +8,10 @@ hosts { fallthrough } - file /etc/coredns/zones/akkacluster.dns.podman akkacluster.dns.podman + file /etc/coredns/zones/akkacluster.dns.oci akkacluster.dns.oci prometheus cache - template ANY A akkacluster.dns.podman { + template ANY A akkacluster.dns.oci { rcode REFUSED } } diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.podman b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.oci similarity index 69% rename from src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.podman rename to src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.oci index 45e64f9f1..ec01fb2af 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.podman +++ b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/coredns/zones/akkacluster.dns.oci @@ -1,5 +1,5 @@ -$ORIGIN akkacluster.dns.podman. -@ 3600 IN SOA dns-server.akkacluster.dns.podman. admin.akkacluster.dns.podman. ( +$ORIGIN akkacluster.dns.oci. +@ 3600 IN SOA dns-server.akkacluster.dns.oci. admin.akkacluster.dns.oci. ( 2023121001 ; serial 7200 ; refresh 3600 ; retry @@ -8,7 +8,7 @@ $ORIGIN akkacluster.dns.podman. ) ; Name servers -@ 3600 IN NS dns-server.akkacluster.dns.podman. +@ 3600 IN NS dns-server.akkacluster.dns.oci. ; AAAA records for the domain itself @ IN AAAA 2001:db8:1::10 diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf index 6727c4caa..26efc2954 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf +++ b/src/cluster.bootstrap/examples/discovery/dns/src/aaaa/resolv.conf @@ -1,2 +1,2 @@ -search dns.podman +search dns.oci nameserver 2001:db8:1::2 \ No newline at end of file diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml index 5e7ee7c3f..3798a2aa9 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.a.yml @@ -7,13 +7,15 @@ services: dockerfile: Dockerfile image: akka-dns-cluster:latest hostname: node1.akkacluster + volumes: + - ./a/resolv.conf:/etc/resolv.conf environment: - CLUSTER__PORT=4053 - CLUSTER__IP=0.0.0.0 - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci ports: - "4053:4053" - "8558:8558" @@ -22,18 +24,21 @@ services: akkanet: ipv6: false aliases: + - akkacluster.dns.oci - akkacluster node2: image: akka-dns-cluster:latest hostname: node2.akkacluster + volumes: + - ./a/resolv.conf:/etc/resolv.conf environment: - CLUSTER__PORT=4053 - CLUSTER__IP=0.0.0.0 - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci ports: - "4054:4053" - "8559:8558" @@ -42,6 +47,7 @@ services: akkanet: ipv6: false aliases: + - akkacluster.dns.oci - akkacluster depends_on: - node1 @@ -49,13 +55,15 @@ services: node3: image: akka-dns-cluster:latest hostname: node3.akkacluster + volumes: + - ./a/resolv.conf:/etc/resolv.conf environment: - CLUSTER__PORT=4053 - CLUSTER__IP=0.0.0.0 - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci ports: - "4055:4053" - "8560:8558" @@ -64,6 +72,7 @@ services: akkanet: ipv6: false aliases: + - akkacluster.dns.oci - akkacluster depends_on: - node1 @@ -76,3 +85,4 @@ networks: config: - subnet: 172.28.0.0/16 gateway: 172.28.0.1 + diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml index a29e0648e..cf3efb506 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.aaaa.yml @@ -36,7 +36,7 @@ services: - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci - PREFER_IPV6=true ports: - "4053:4053" @@ -64,7 +64,7 @@ services: - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci - PREFER_IPV6=true ports: - "4054:4053" @@ -93,7 +93,7 @@ services: - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=8558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci - PREFER_IPV6=true ports: - "4055:4053" diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml index f1721f68d..1d59111e3 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml +++ b/src/cluster.bootstrap/examples/discovery/dns/src/docker-compose.srv.yml @@ -29,7 +29,7 @@ services: - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=18558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci - PORTNAME=management - DNS_PORT=1053 - DNS_NAMESERVER=172.28.0.2 @@ -54,7 +54,7 @@ services: - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=28558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci - PORTNAME=management - DNS_PORT=1053 - DNS_NAMESERVER=172.28.0.2 @@ -79,7 +79,7 @@ services: - ACTORSYSTEM=DnsCluster - MANAGEMENT__PORT=38558 - PBM__PORT=9110 - - SERVICENAME=akkacluster.dns.podman + - SERVICENAME=akkacluster.dns.oci - PORTNAME=management - DNS_PORT=1053 - DNS_NAMESERVER=172.28.0.2 diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/Corefile b/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/Corefile index 05befc888..2325027b9 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/Corefile +++ b/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/Corefile @@ -6,7 +6,7 @@ hosts { fallthrough } - file /etc/coredns/zones/akkacluster.dns.podman akkacluster.dns.podman + file /etc/coredns/zones/akkacluster.dns.oci akkacluster.dns.oci prometheus cache } diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.oci b/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.oci new file mode 100644 index 000000000..378b7a5cf --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.oci @@ -0,0 +1,21 @@ +$ORIGIN akkacluster.dns.oci. +@ 3600 IN SOA dns-server.akkacluster.dns.oci. admin.akkacluster.dns.oci. ( + 2023121001 ; serial + 7200 ; refresh + 3600 ; retry + 1209600 ; expire + 3600 ; minimum +) + +; Name servers +@ 3600 IN NS dns-server.akkacluster.dns.oci. + +; A records for each node +node1 IN A 172.28.0.10 +node2 IN A 172.28.0.20 +node3 IN A 172.28.0.30 + +; SRV records - _service._proto.name. TTL class SRV priority weight port target +_management._tcp IN SRV 10 10 18558 node1.akkacluster.dns.oci. +_management._tcp IN SRV 10 10 28558 node2.akkacluster.dns.oci. +_management._tcp IN SRV 10 10 38558 node3.akkacluster.dns.oci. diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.podman b/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.podman deleted file mode 100644 index 96dd506e4..000000000 --- a/src/cluster.bootstrap/examples/discovery/dns/src/srv/coredns/zones/akkacluster.dns.podman +++ /dev/null @@ -1,21 +0,0 @@ -$ORIGIN akkacluster.dns.podman. -@ 3600 IN SOA dns-server.akkacluster.dns.podman. admin.akkacluster.dns.podman. ( - 2023121001 ; serial - 7200 ; refresh - 3600 ; retry - 1209600 ; expire - 3600 ; minimum -) - -; Name servers -@ 3600 IN NS dns-server.akkacluster.dns.podman. - -; A records for each node -node1 IN A 172.28.0.10 -node2 IN A 172.28.0.20 -node3 IN A 172.28.0.30 - -; SRV records - _service._proto.name. TTL class SRV priority weight port target -_management._tcp IN SRV 10 10 18558 node1.akkacluster.dns.podman. -_management._tcp IN SRV 10 10 28558 node2.akkacluster.dns.podman. -_management._tcp IN SRV 10 10 38558 node3.akkacluster.dns.podman. diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/srv/resolv.conf b/src/cluster.bootstrap/examples/discovery/dns/src/srv/resolv.conf new file mode 100644 index 000000000..edbd1491f --- /dev/null +++ b/src/cluster.bootstrap/examples/discovery/dns/src/srv/resolv.conf @@ -0,0 +1,2 @@ +search dns.oci +nameserver 172.28.0.2 \ No newline at end of file From fbec5dce45c93df79ff8112e0c6bb672aaf9511e Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 15:25:13 -0300 Subject: [PATCH 10/37] use docker-compose to run examples as it is more common --- src/cluster.bootstrap/examples/discovery/dns/build.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cluster.bootstrap/examples/discovery/dns/build.ps1 b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 index 291a75c63..019e5f5e0 100755 --- a/src/cluster.bootstrap/examples/discovery/dns/build.ps1 +++ b/src/cluster.bootstrap/examples/discovery/dns/build.ps1 @@ -10,12 +10,12 @@ if ($recordType -ne "srv" -and $recordType -ne "a" -and $recordType -ne "aaaa") exit 1 } Write-Output "Cleaning up previous containers (ignore errors if containers do not exist)...." -podman-compose -f "$(pwd)/src/docker-compose.srv.yml" down -podman-compose -f "$(pwd)/src/docker-compose.aaaa.yml" down -podman-compose -f "$(pwd)/src/docker-compose.a.yml" down +docker-compose -f "$(pwd)/src/docker-compose.srv.yml" down +docker-compose -f "$(pwd)/src/docker-compose.aaaa.yml" down +docker-compose -f "$(pwd)/src/docker-compose.a.yml" down Write-Output "Building and publishing example..." dotnet publish --os linux --arch x64 -c Release /t:PublishContainer ./src/DnsCluster.csproj Write-Output "Start $recordType example" -podman-compose -f "$(pwd)/src/docker-compose.$recordType.yml" up --build \ No newline at end of file +docker-compose -f "$(pwd)/src/docker-compose.$recordType.yml" up --build \ No newline at end of file From e26b4df04d79fe35314e0bb41a5b12d2f25b920b Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 15:38:55 -0300 Subject: [PATCH 11/37] fix reference HOCON --- .../dns/Akka.Discovery.Dns/reference.conf | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/reference.conf b/src/discovery/dns/Akka.Discovery.Dns/reference.conf index 74b811300..15d936b09 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/reference.conf +++ b/src/discovery/dns/Akka.Discovery.Dns/reference.conf @@ -4,8 +4,29 @@ akka.discovery { # Set the following in your application.conf if you want to use this discovery mechanism: - # method = akka-dns + method = akka-dns akka-dns { class = "Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns" + + # How long to wait for a DNS query to resolve before timing out + resolve-timeout = 5s + + # When true, failing to resolve a service name will be logged as a warning + verbose-failure-logging = on + } +} + +# DNS resolver configuration +akka.io.dns { + # Default resolver is inet-address (uses System.Net.Dns.GetHostAddresses) + # For SRV record resolution use async-dns and add akka.io.dns.async-dns config as below + resolver = async-dns + + # Async DNS resolver using direct DNS query (not using System.Net.Dns.GetHostAddresses) + async-dns { + class = "Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns" + provider-object = "Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns" + # The hostname or IP address of the nameserver to use + nameserver = "127.0.0.1:53" } } From 3d8fc58006f11d369731f99e9084508c91716016 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 15:53:57 -0300 Subject: [PATCH 12/37] cleanup hosting extensions --- .../examples/discovery/dns/src/Program.cs | 9 ++-- .../AkkaHostingExtensions.cs | 43 +++++++------------ .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 30 +------------ 3 files changed, 21 insertions(+), 61 deletions(-) diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs index fe5113c55..7c07fd63d 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs +++ b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs @@ -38,7 +38,7 @@ public class ClusterConfigOptions } public static class Extensions { - public static AkkaConfigurationBuilder BootstrapFromDocker( + public static AkkaConfigurationBuilder ClusterSetup( this AkkaConfigurationBuilder builder, IServiceProvider provider, Action? remoteConfiguration = null, @@ -159,7 +159,7 @@ public static async Task Main(string[] args) a.LogConfigOnStart = true; }); // Add HOCON configuration from Docker - builder.BootstrapFromDocker( + builder.ClusterSetup( provider, // Add Akka.Remote support. // Empty hostname is intentional and necessary to make sure that remoting binds to the public IP address @@ -197,7 +197,7 @@ public static async Task Main(string[] args) builder.WithAkkaManagement(hostName: hostname, port: managementPort, bindHostname: bindAddress); // Add Akka.Discovery.Dns support - // Configure DNS discovery for Docker environment + // Configure DNS discovery and make it default resolver mechanism builder.WithDnsDiscovery(); if (portName != null) { @@ -205,9 +205,6 @@ public static async Task Main(string[] args) var ns = hostContext.Configuration.GetValue("nameserver")?.Trim() ?? "127.0.0.1:53"; builder.WithAsyncDnsResolver(opt => opt.Nameserver = ns ); } - - // and set it as the default discovery mechanism - builder.WithDnsDiscoveryDefault(); // Add https://cmd.petabridge.com/ for diagnostics builder.WithPetabridgeCmd("0.0.0.0", pbmPort, ClusterCommands.Instance, new RemoteCommands()); diff --git a/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs index 5acbcc5a6..80c49b221 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/AkkaHostingExtensions.cs @@ -10,38 +10,27 @@ namespace Akka.Discovery.Dns /// public static class AkkaHostingExtensions { - /// - /// Adds DNS service discovery to the . - /// - /// The builder instance. - /// Action that configures the . - /// The same builder instance. + // /// + // /// Adds DNS service discovery to the . + // /// + // /// The builder instance. + // /// Action that configures the . + // /// The same builder instance. public static AkkaConfigurationBuilder WithDnsDiscovery( this AkkaConfigurationBuilder builder, - Action? configure = null) + Action? configure = null, + string discoveryId = DnsDiscoveryOptions.DefaultPath, + bool makeDefault = true) { var options = new DnsDiscoveryOptions(); configure?.Invoke(options); options.Apply(builder); builder.AddSetup(new DnsDiscoverySetup { DiscoveryId = options.ConfigPath }); - - return builder; - } - - - /// - /// Adds DNS service discovery to the with the specified options. - /// - /// The builder instance. - /// The options. - /// The same builder instance. - public static AkkaConfigurationBuilder WithDnsDiscovery( - this AkkaConfigurationBuilder builder, - DnsDiscoveryOptions options) - { - options.Apply(builder); - builder.AddSetup(new DnsDiscoverySetup { DiscoveryId = options.ConfigPath }); - + if (makeDefault) + { + builder.SetDiscoveryMethod(discoveryId); + + } return builder; } @@ -51,9 +40,9 @@ public static AkkaConfigurationBuilder WithDnsDiscovery( /// The builder instance. /// The discovery ID. /// The same builder instance. - public static AkkaConfigurationBuilder WithDnsDiscoveryDefault( + public static AkkaConfigurationBuilder SetDiscoveryMethod( this AkkaConfigurationBuilder builder, - string discoveryId = DnsDiscoveryOptions.DefaultPath) + string discoveryId) { builder.AddHocon("akka.discovery.method = " + discoveryId, HoconAddMode.Prepend); return builder; diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index 82d95a45c..47b23c5d1 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -17,18 +17,13 @@ public class DnsDiscoveryOptions : IDiscoveryOptions /// Default configuration path for DNS service discovery /// public const string DefaultPath = "akka-dns"; - - /// - /// The default configuration path for DNS service discovery - /// - public const string DefaultConfigPath = "akka.discovery." + DefaultPath; - + /// /// Gets the full configuration path for the specified path. /// /// The path. /// The full configuration path. - public static string FullPath(string path) => $"akka.discovery.{path}"; + private static string FullPath(string path) => $"akka.discovery.{path}"; /// /// Gets the type of service discovery class. @@ -114,27 +109,6 @@ public static DnsDiscoverySettings Create(Akka.Configuration.Config config) } } -/// -/// Multi-setup class for configuring multiple DNS discovery instances. -/// -public class DnsDiscoveryMultiSetup : Setup -{ - /// - /// Gets the setups. - /// - public IReadOnlyDictionary Setups { get; } - - /// - /// Creates a new instance of the class. - /// - /// The setups. - public DnsDiscoveryMultiSetup(IReadOnlyDictionary setups) - { - Setups = setups ?? throw new ArgumentNullException(nameof(setups)); - } -} - - public class AsyncDnsResolverOptions : IHoconOption { From ec1f100fdedceb42dc24c7056af5357ee6a448df Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 16:09:52 -0300 Subject: [PATCH 13/37] make internals availiable to tests --- .../dns/Akka.Discovery.Dns/Internal/Friends.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/Friends.cs diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/Friends.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/Friends.cs new file mode 100644 index 000000000..b339f0a82 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/Friends.cs @@ -0,0 +1,10 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2021 Lightbend Inc. +// Copyright (C) 2013-2021 .NET Foundation +// +//----------------------------------------------------------------------- + +using System.Runtime.CompilerServices; + +[assembly:InternalsVisibleTo("Akka.Discovery.Dns.Tests")] \ No newline at end of file From ab83854c4720cc37eb668d4b329d5cb8abe5f354 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Mon, 14 Jul 2025 18:19:11 -0300 Subject: [PATCH 14/37] tests for DNS lookup and config parsing --- .../ARecordsDiscovery.cs | 70 +++++++++++ .../Akka.Discovery.Dns.Tests/DnsClientSpec.cs | 99 +++++++++++++++ .../DnsServiceDiscoverySpec.cs | 93 -------------- .../SrvRecordsDiscovery.cs | 78 ++++++++++++ .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 4 +- .../Akka.Discovery.Dns/Internal/DnsClient.cs | 113 +++++++++++++++--- 6 files changed, 343 insertions(+), 114 deletions(-) create mode 100644 src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns.Tests/DnsClientSpec.cs delete mode 100644 src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs new file mode 100644 index 000000000..887b7e4e3 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs @@ -0,0 +1,70 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Discovery.Dns.Tests; + +public class ARecordsDiscovery(ITestOutputHelper output) : TestKit.Xunit2.TestKit( + ConfigurationFactory.ParseString(@" + akka.loglevel = DEBUG + akka.discovery { + method = akka-dns + akka-dns { + class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" + } + } + "), "dns-discovery", output) +{ + [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] + public void DnsServiceDiscoveryShouldBeLoadableViaConfig() + { + var serviceDiscovery = Discovery.Get(Sys).LoadServiceDiscovery("akka-dns"); + serviceDiscovery.Should().BeOfType(); + } + + [Theory(DisplayName = "DnsServiceDiscovery should handle A/AAAA lookup with real DNS")] + [InlineData("jabber.org", "XMPP server")] + [InlineData("matrix.org", "Matrix server")] + [InlineData("gmail.com", "Gmail IMAPS")] + public async Task DnsServiceDiscoveryShouldHandleLookup(string serviceName, string description) + { + Output.WriteLine($"Testing A/AAAA lookup for {description}: {serviceName}"); + var serviceDiscovery = new Dns.DnsServiceDiscovery((ExtendedActorSystem)Sys); + + var lookup = new Lookup(serviceName); + var resolved = await serviceDiscovery.Lookup(lookup, TimeSpan.FromSeconds(60)); + + // Skip assertion if no records found (some services might not have SRV records) + if (resolved.Addresses.Count == 0) + { + Output.WriteLine($"No SRV records found for {description}. Skipping assertions."); + return; + } + + Output.WriteLine($"Found {resolved.Addresses.Count} records for {description}"); + + // Log information for diagnostic purposes + Output.WriteLine($"Resolved targets: {resolved.Addresses.Count}"); + foreach (var addr in resolved.Addresses) + { + Output.WriteLine($" Host: {addr.Host}, Address: {addr.Address}, Port: {addr.Port}"); + } + + resolved.Addresses.Count.Should().BeGreaterThan(0, "At least one SRV record should be found"); + foreach (var address in resolved.Addresses) + { + address.Host.Should().NotBeNullOrEmpty("Host should not be empty"); + address.Port.Should().BeNull("Port should not be specified for A/AAAA lookup"); + } + } +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsClientSpec.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsClientSpec.cs new file mode 100644 index 000000000..02e056baa --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsClientSpec.cs @@ -0,0 +1,99 @@ +using System; +using System.Net; +using Akka.Discovery.Dns.Internal; +using FluentAssertions; +using Xunit; + +namespace Akka.Discovery.Dns.Tests; + +public class DnsClientSpec { + + [Fact] + public void ParseEndPoint_ShouldParseIpv4WithPort() + { + var result = DnsClient.ParseEndPoint("192.168.1.1:53"); + var ipEndPoint = result as IPEndPoint; + ipEndPoint.Should().NotBeNull(); + ipEndPoint!.Address.ToString().Should().Be("192.168.1.1"); + ipEndPoint.Port.Should().Be(53); + } + + [Fact] + public void ParseEndPoint_ShouldParseHostnameWithPort() + { + var result = DnsClient.ParseEndPoint("1dot1dot1dot1.cloudflare-dns.com:53"); + var ipEndPoint = result as IPEndPoint; + ipEndPoint.Should().NotBeNull(); + //can be 1.0.0.1 or 1.1.1.1 + ipEndPoint!.Address.ToString().Should().StartWith("1."); + ipEndPoint!.Address.ToString().Should().EndWith(".1"); + ipEndPoint.Port.Should().Be(53); + } + + [Fact] + public void ParseEndPoint_ShouldParseHostnameWithoutPort() + { + var result = DnsClient.ParseEndPoint("1dot1dot1dot1.cloudflare-dns.com"); + var ipEndPoint = result as IPEndPoint; + ipEndPoint.Should().NotBeNull(); + //can be 1.0.0.1 or 1.1.1.1 + ipEndPoint!.Address.ToString().Should().StartWith("1."); + ipEndPoint!.Address.ToString().Should().EndWith(".1"); + ipEndPoint.Port.Should().Be(53); // Default port is now 53 + } + + [Fact] + public void ParseEndPoint_ShouldParseIpv6WithBracketsAndPort() + { + var result = DnsClient.ParseEndPoint("[2001:db8:1::2]:53"); + var ipEndPoint = result as IPEndPoint; + ipEndPoint.Should().NotBeNull(); + ipEndPoint!.Address.ToString().Should().Be("2001:db8:1::2"); + ipEndPoint.Port.Should().Be(53); + } + + [Fact] + public void ParseEndPoint_ShouldHandleEmptyOrNullInput() + { + // Should throw ArgumentException for null or empty input + Assert.Throws(() => DnsClient.ParseEndPoint(null!)); + Assert.Throws(() => DnsClient.ParseEndPoint("")); + Assert.Throws(() => DnsClient.ParseEndPoint(" ")); + } + + [Fact] + public void ParseEndPoint_ShouldHandleWrongFormatInput() + { + // Should throw ArgumentException for null or empty input + Assert.Throws(() => DnsClient.ParseEndPoint("[11111:11111")); + Assert.Throws(() => DnsClient.ParseEndPoint("[2001:db8:1::2]:whatever")); + Assert.Throws(() => DnsClient.ParseEndPoint("[2001:db8:1::2]:")); + Assert.Throws(() => DnsClient.ParseEndPoint("1.0.0.1:whatever")); + Assert.Throws(() => DnsClient.ParseEndPoint("whatever:1")); + Assert.Throws(() => DnsClient.ParseEndPoint("whatever:whatever")); + Assert.Throws(() => DnsClient.ParseEndPoint("whatever:")); + Assert.Throws(() => DnsClient.ParseEndPoint("whatever")); + Assert.Throws(() => DnsClient.ParseEndPoint("whatever.local")); + } + + [Fact] + public void ParseEndPoint_ShouldParseIpv6WithoutPort() + { + var result = DnsClient.ParseEndPoint("2001:db8:1::2"); + var ipEndPoint = result as IPEndPoint; + ipEndPoint.Should().NotBeNull(); + ipEndPoint!.Address.ToString().Should().Be("2001:db8:1::2"); + ipEndPoint.Port.Should().Be(53); // Default port is now 53 + } + + [Fact] + public void ParseEndPoint_ShouldHandleIpWithoutPort() + { + var result = DnsClient.ParseEndPoint("192.168.1.1"); + var ipEndPoint = result as IPEndPoint; + ipEndPoint.Should().NotBeNull(); + ipEndPoint.Address.ToString().Should().Be("192.168.1.1"); + ipEndPoint.Port.Should().Be(53); + } + +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs deleted file mode 100644 index e8dea7a38..000000000 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs +++ /dev/null @@ -1,93 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2013-2025 .NET Foundation -// -// ----------------------------------------------------------------------- - -using System; -using System.Linq; -using System.Threading.Tasks; -using Akka.Actor; -using Akka.Actor.Setup; -using Akka.Configuration; -using Akka.TestKit.Xunit2; -using FluentAssertions; -using Xunit; -using Xunit.Abstractions; - -namespace Akka.Discovery.Dns.Tests -{ - public class DnsServiceDiscoverySpec : TestKit.Xunit2.TestKit - { - // akka.io.dns.resolver = async-dns - public DnsServiceDiscoverySpec(ITestOutputHelper output) - : base(ConfigurationFactory.ParseString(@" - akka.discovery { - method = akka-dns - akka-dns { - class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" - } - } - "), "dns-discovery", output) - { - } - - [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] - public void DnsServiceDiscoveryShouldBeLoadableViaConfig() - { - var serviceDiscovery = Discovery.Get(Sys).LoadServiceDiscovery("akka-dns"); - serviceDiscovery.Should().BeOfType(); - } - - [Fact(DisplayName = "DnsServiceDiscovery should handle A/AAAA record lookup")] - public async Task DnsServiceDiscoveryShouldHandleIpLookup() - { - // Use the actual DNS resolver to look up a known domain - var discovery = Discovery.Get(Sys).LoadServiceDiscovery("akka-dns"); - - // Lookup a domain that should always exist and resolve - var host = "getakka.net"; - var lookup = new Lookup(host); - var resolved = await discovery.Lookup(lookup, TimeSpan.FromSeconds(10)); - - resolved.Should().NotBeNull(); - resolved.Addresses.Should().NotBeEmpty(); - - // The resolved addresses should have host names but no port (since we're doing A/AAAA lookup) - foreach (var address in resolved.Addresses) - { - address.Host.Should().NotBeNullOrEmpty(); - address.Port.HasValue.Should().BeFalse(); - } - this.Output.WriteLine("Resolved host {0} into addresses: {1}", host, resolved); - } - - [Fact(DisplayName = "DnsServiceDiscovery should construct correct SRV record query")] - public void DnsServiceDiscoveryShouldConstructCorrectSrvQuery() - { - // This test validates that the SRV record query is correctly formatted - var discovery = new TestDnsServiceDiscovery((ExtendedActorSystem)Sys); - - var lookup = new Lookup("myservice.example.com") - .WithPortName("http") - .WithProtocol("tcp"); - - var srvRequest = discovery.TestGetSrvRequest(lookup); - - srvRequest.Should().Be("_http._tcp.myservice.example.com"); - } - - // Helper test class that exposes some internals for testing - private class TestDnsServiceDiscovery : DnsServiceDiscovery - { - public TestDnsServiceDiscovery(ExtendedActorSystem system) : base(system) - { - } - - public string TestGetSrvRequest(Lookup lookup) - { - return $"_{lookup.PortName}._{lookup.Protocol}.{lookup.ServiceName}"; - } - } - } -} diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs new file mode 100644 index 000000000..a765e036b --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2013-2025 .NET Foundation +// +// ----------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using Akka.Actor; +using Akka.Configuration; +using FluentAssertions; +using Xunit; +using Xunit.Abstractions; + +namespace Akka.Discovery.Dns.Tests; + +public class SrvRecordsDiscovery(ITestOutputHelper output) : TestKit.Xunit2.TestKit( + ConfigurationFactory.ParseString(@" + akka.loglevel = DEBUG + akka.discovery { + method = akka-dns + akka-dns { + class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" + } + } + akka.io.dns.resolver = async-dns + akka.io.dns.async-dns { + class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" + provider-object = ""Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns"" + nameserver = ""1.0.0.1:53"" + + } + "), "dns-discovery", output) +{ + [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] + public void DnsServiceDiscoveryShouldBeLoadableViaConfig() + { + var serviceDiscovery = Discovery.Get(Sys).LoadServiceDiscovery("akka-dns"); + serviceDiscovery.Should().BeOfType(); + } + + [Theory(DisplayName = "DnsServiceDiscovery should handle SRV lookup with real DNS")] + [InlineData("jabber.org", "xmpp-server", "tcp", "XMPP server")] + [InlineData("matrix.org", "matrix", "tcp", "Matrix server")] + [InlineData("gmail.com", "imaps", "tcp", "Gmail IMAPS")] + public async Task DnsServiceDiscoveryShouldHandleLookup(string serviceName, string? portName, string? protocol, + string description) + { + Output.WriteLine($"Testing SRV lookup for {description}: _{portName}._{protocol}.{serviceName}"); + var serviceDiscovery = new Dns.DnsServiceDiscovery((ExtendedActorSystem)Sys); + + var lookup = new Lookup(serviceName, portName, protocol); + var resolved = await serviceDiscovery.Lookup(lookup, TimeSpan.FromSeconds(60)); + + // Skip assertion if no records found (some services might not have SRV records) + if (resolved.Addresses.Count == 0) + { + Output.WriteLine($"No SRV records found for {description}. Skipping assertions."); + return; + } + + Output.WriteLine($"Found {resolved.Addresses.Count} records for {description}"); + + // Log information for diagnostic purposes + Output.WriteLine($"Resolved targets: {resolved.Addresses.Count}"); + foreach (var addr in resolved.Addresses) + { + Output.WriteLine($" Host: {addr.Host}, Address: {addr.Address}, Port: {addr.Port}"); + } + + resolved.Addresses.Count.Should().BeGreaterThan(0, "At least one SRV record should be found"); + foreach (var address in resolved.Addresses) + { + address.Host.Should().NotBeNullOrEmpty("Host should not be empty"); + address.Port.Should().BeGreaterThan(0, "Port should be specified for SRV lookup"); + } + } +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index 47b23c5d1..b174e7a63 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -115,7 +115,7 @@ public class AsyncDnsResolverOptions : IHoconOption public const string DefaultPath = "async-dns"; - public const string NameserversPath = "nameserver"; + public const string NameserverPath = "nameserver"; public string Nameserver { get; set; } = "127.0.0.1:53"; /// /// Renders HOCON configuration based on current settings. @@ -129,7 +129,7 @@ private string ToHocon() sb.AppendLine($"{FullPath(ConfigPath)} {{"); sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\","); sb.AppendLine($" provider-object = \"{Provider.FullName}, {Provider.Assembly.GetName().Name}\","); - sb.Append($" {NameserversPath} = \"{Nameserver}\","); + sb.Append($" {NameserverPath} = \"{Nameserver}\","); sb.AppendLine("}"); return sb.ToString(); diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs index 7d3541c26..0ccc4a305 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs @@ -139,23 +139,11 @@ public InFlightRequest(IActorRef replyTo, DnsProtocol.Message message, bool tcpR } } - static EndPoint ParseEndPoint(string endpoint) - { - var parts = endpoint.Split(':'); - var ip = IPAddress.Parse(parts[0]); - var port = 0; - if (parts.Length > 1) - { - port = int.Parse(parts[1]); - } - - return new IPEndPoint(ip, port); - } public DnsClient(DnsExt ext) { _log = Context.GetLogger(); - var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolverOptions.NameserversPath) ?? throw new ConfigurationException("nameservers config was empty"); + var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolverOptions.NameserverPath) ?? throw new ConfigurationException("nameserver config was empty"); _nameserver = ParseEndPoint(ns); _udpManager = Akka.IO.Udp.Instance.Apply(Context.System).Manager; _tcpManager = Akka.IO.Tcp.Manager(Context.System); @@ -239,12 +227,15 @@ private void Ready(object message) } else { - var records = msg.Flags.ResponseCode == DnsProtocol.ResponseCode.Success - ? msg.AnswerRecords : ImmutableList.Empty; - var additionalRecs = msg.Flags.ResponseCode == DnsProtocol.ResponseCode.Success - ? msg.AdditionalRecords : ImmutableList.Empty; - - Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, records, additionalRecs))); + if (msg.Flags.ResponseCode != DnsProtocol.ResponseCode.Success) + { + _log.Warning("DNS response failed: [{0}]", msg); + Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, ImmutableList.Empty, ImmutableList.Empty))); + } + else + { + Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, msg.AnswerRecords, msg.AdditionalRecords))); + } } } catch (Exception ex) @@ -432,4 +423,88 @@ private IActorRef CreateTcpClient() BackoffSupervisor.Props(backoffOptions), "tcpDnsClientSupervisor"); } + + /// + /// Parse a string endpoint into an IPEndPoint + /// Handles IPv4 addresses, IPv6 addresses, and hostnames with optional port + /// + /// String in format "address:port" where address can be IPv4, IPv6, or hostname + /// IPEndPoint representing the parsed endpoint + internal static EndPoint ParseEndPoint(string endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint)) + throw new ArgumentException("Endpoint cannot be null or empty", nameof(endpoint)); + + // Default port if not specified + int port = 53; + string host; + + // Check if we have an IPv6 address with brackets + if (endpoint.StartsWith("[")) + { + // Format is [IPv6]:port + int closeBracketIndex = endpoint.IndexOf(']'); + if (closeBracketIndex == -1) + throw new FormatException($"Invalid IPv6 endpoint format: {endpoint}. Expected [IPv6]:port"); + + host = endpoint.Substring(1, closeBracketIndex - 1); // Extract IPv6 without brackets + + // Check if there's a port after the IPv6 address + if (closeBracketIndex + 1 < endpoint.Length && endpoint[closeBracketIndex + 1] == ':') + { + string portStr = endpoint.Substring(closeBracketIndex + 2); + if (!int.TryParse(portStr, out port)) + throw new FormatException($"Invalid port in endpoint: {portStr}"); + } + } + else if (endpoint.Contains(":")) + { + // Could be IPv4:port or IPv6 without brackets + if (endpoint.Count(c => c == ':') > 1) + { + // This is likely an IPv6 address without port + host = endpoint; + } + else + { + // This is likely IPv4:port + var parts = endpoint.Split(':'); + host = parts[0]; + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) + { + if (!int.TryParse(parts[1], out port)) + throw new FormatException($"Invalid port in endpoint: {parts[1]}"); + } + } + } + else + { + // Just a hostname or IP without port + host = endpoint; + } + + // Try to parse as IP address + if (IPAddress.TryParse(host, out var ipAddress)) + { + return new IPEndPoint(ipAddress, port); + } + + // If not an IP, try to resolve hostname + try + { + var addresses = System.Net.Dns.GetHostAddresses(host); + if (addresses.Length == 0) + throw new FormatException($"Could not resolve hostname: {host}"); + + // Prefer IPv4 address if available + var preferredAddress = addresses.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + ?? addresses[0]; // Fall back to first address if no IPv4 + + return new IPEndPoint(preferredAddress, port); + } + catch (Exception ex) when (!(ex is FormatException)) + { + throw new FormatException($"Failed to resolve hostname: {host}", ex); + } + } } \ No newline at end of file From e0604da3cf579f796a4fd20ba21451cc552b12bf Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 15 Jul 2025 12:52:17 -0300 Subject: [PATCH 15/37] Handle multiple nameservers --- .../examples/discovery/dns/src/Program.cs | 2 +- ...nsClientSpec.cs => AsyncDnsManagerSpec.cs} | 38 +-- .../SrvRecordsDiscovery.cs | 4 +- .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 14 +- .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 5 +- .../Internal/AsyncDnsManager.cs | 216 ++++++++++++++++++ .../Akka.Discovery.Dns/Internal/DnsClient.cs | 168 +++----------- .../dns/Akka.Discovery.Dns/reference.conf | 5 +- 8 files changed, 287 insertions(+), 165 deletions(-) rename src/discovery/dns/Akka.Discovery.Dns.Tests/{DnsClientSpec.cs => AsyncDnsManagerSpec.cs} (59%) create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs index 7c07fd63d..79f28fa36 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs +++ b/src/cluster.bootstrap/examples/discovery/dns/src/Program.cs @@ -203,7 +203,7 @@ public static async Task Main(string[] args) { // use SRV record resolver if portName was specified var ns = hostContext.Configuration.GetValue("nameserver")?.Trim() ?? "127.0.0.1:53"; - builder.WithAsyncDnsResolver(opt => opt.Nameserver = ns ); + builder.WithAsyncDnsResolver(opt => opt.Nameservers = [ ns ] ); } // Add https://cmd.petabridge.com/ for diagnostics diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsClientSpec.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/AsyncDnsManagerSpec.cs similarity index 59% rename from src/discovery/dns/Akka.Discovery.Dns.Tests/DnsClientSpec.cs rename to src/discovery/dns/Akka.Discovery.Dns.Tests/AsyncDnsManagerSpec.cs index 02e056baa..929bd4f16 100644 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsClientSpec.cs +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/AsyncDnsManagerSpec.cs @@ -6,12 +6,12 @@ namespace Akka.Discovery.Dns.Tests; -public class DnsClientSpec { +public class AsyncDnsManagerSpec { [Fact] public void ParseEndPoint_ShouldParseIpv4WithPort() { - var result = DnsClient.ParseEndPoint("192.168.1.1:53"); + var result = AsyncDnsManager.ParseEndPoint("192.168.1.1:53"); var ipEndPoint = result as IPEndPoint; ipEndPoint.Should().NotBeNull(); ipEndPoint!.Address.ToString().Should().Be("192.168.1.1"); @@ -21,7 +21,7 @@ public void ParseEndPoint_ShouldParseIpv4WithPort() [Fact] public void ParseEndPoint_ShouldParseHostnameWithPort() { - var result = DnsClient.ParseEndPoint("1dot1dot1dot1.cloudflare-dns.com:53"); + var result = AsyncDnsManager.ParseEndPoint("1dot1dot1dot1.cloudflare-dns.com:53"); var ipEndPoint = result as IPEndPoint; ipEndPoint.Should().NotBeNull(); //can be 1.0.0.1 or 1.1.1.1 @@ -33,7 +33,7 @@ public void ParseEndPoint_ShouldParseHostnameWithPort() [Fact] public void ParseEndPoint_ShouldParseHostnameWithoutPort() { - var result = DnsClient.ParseEndPoint("1dot1dot1dot1.cloudflare-dns.com"); + var result = AsyncDnsManager.ParseEndPoint("1dot1dot1dot1.cloudflare-dns.com"); var ipEndPoint = result as IPEndPoint; ipEndPoint.Should().NotBeNull(); //can be 1.0.0.1 or 1.1.1.1 @@ -45,7 +45,7 @@ public void ParseEndPoint_ShouldParseHostnameWithoutPort() [Fact] public void ParseEndPoint_ShouldParseIpv6WithBracketsAndPort() { - var result = DnsClient.ParseEndPoint("[2001:db8:1::2]:53"); + var result = AsyncDnsManager.ParseEndPoint("[2001:db8:1::2]:53"); var ipEndPoint = result as IPEndPoint; ipEndPoint.Should().NotBeNull(); ipEndPoint!.Address.ToString().Should().Be("2001:db8:1::2"); @@ -56,30 +56,30 @@ public void ParseEndPoint_ShouldParseIpv6WithBracketsAndPort() public void ParseEndPoint_ShouldHandleEmptyOrNullInput() { // Should throw ArgumentException for null or empty input - Assert.Throws(() => DnsClient.ParseEndPoint(null!)); - Assert.Throws(() => DnsClient.ParseEndPoint("")); - Assert.Throws(() => DnsClient.ParseEndPoint(" ")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint(null!)); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint(" ")); } [Fact] public void ParseEndPoint_ShouldHandleWrongFormatInput() { // Should throw ArgumentException for null or empty input - Assert.Throws(() => DnsClient.ParseEndPoint("[11111:11111")); - Assert.Throws(() => DnsClient.ParseEndPoint("[2001:db8:1::2]:whatever")); - Assert.Throws(() => DnsClient.ParseEndPoint("[2001:db8:1::2]:")); - Assert.Throws(() => DnsClient.ParseEndPoint("1.0.0.1:whatever")); - Assert.Throws(() => DnsClient.ParseEndPoint("whatever:1")); - Assert.Throws(() => DnsClient.ParseEndPoint("whatever:whatever")); - Assert.Throws(() => DnsClient.ParseEndPoint("whatever:")); - Assert.Throws(() => DnsClient.ParseEndPoint("whatever")); - Assert.Throws(() => DnsClient.ParseEndPoint("whatever.local")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("[11111:11111")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("[2001:db8:1::2]:whatever")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("[2001:db8:1::2]:")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("1.0.0.1:whatever")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("whatever:1")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("whatever:whatever")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("whatever:")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("whatever")); + Assert.Throws(() => AsyncDnsManager.ParseEndPoint("whatever.local")); } [Fact] public void ParseEndPoint_ShouldParseIpv6WithoutPort() { - var result = DnsClient.ParseEndPoint("2001:db8:1::2"); + var result = AsyncDnsManager.ParseEndPoint("2001:db8:1::2"); var ipEndPoint = result as IPEndPoint; ipEndPoint.Should().NotBeNull(); ipEndPoint!.Address.ToString().Should().Be("2001:db8:1::2"); @@ -89,7 +89,7 @@ public void ParseEndPoint_ShouldParseIpv6WithoutPort() [Fact] public void ParseEndPoint_ShouldHandleIpWithoutPort() { - var result = DnsClient.ParseEndPoint("192.168.1.1"); + var result = AsyncDnsManager.ParseEndPoint("192.168.1.1"); var ipEndPoint = result as IPEndPoint; ipEndPoint.Should().NotBeNull(); ipEndPoint.Address.ToString().Should().Be("192.168.1.1"); diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs index a765e036b..848710dee 100644 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs @@ -27,7 +27,9 @@ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" akka.io.dns.async-dns { class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" provider-object = ""Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns"" - nameserver = ""1.0.0.1:53"" + nameservers = [ + ""1dot1dot1dot1.cloudflare-dns.com"", + ""1.1.1.1"" ] } "), "dns-discovery", output) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index b174e7a63..c92b3ae2c 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -115,8 +115,8 @@ public class AsyncDnsResolverOptions : IHoconOption public const string DefaultPath = "async-dns"; - public const string NameserverPath = "nameserver"; - public string Nameserver { get; set; } = "127.0.0.1:53"; + public const string NameserversPath = "nameservers"; + public List Nameservers { get; set; } = [ "127.0.0.1:53" ]; /// /// Renders HOCON configuration based on current settings. /// @@ -129,7 +129,15 @@ private string ToHocon() sb.AppendLine($"{FullPath(ConfigPath)} {{"); sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\","); sb.AppendLine($" provider-object = \"{Provider.FullName}, {Provider.Assembly.GetName().Name}\","); - sb.Append($" {NameserverPath} = \"{Nameserver}\","); + sb.Append($" {NameserversPath} = ["); + var c = Nameservers.Count; + for (int i = 0; i < c; i++) + { + sb.Append($"\"{Nameservers[i]}\""); + if (i < c - 1) + sb.Append(", "); + } + sb.AppendLine("]"); sb.AppendLine("}"); return sb.ToString(); diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index 8ec3aa714..f2df1faea 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -51,11 +51,8 @@ private async Task LookupSrv(Lookup lookup, TimeSpan resolveTimeout) try { - // Generate a random query ID - short queryId = (short)new Random().Next(0, 65535); - // Send SRV question and await response - var result = await _dns.Ask(new Internal.DnsClient.SrvQuestion(queryId, srvRequest), resolveTimeout); + var result = await _dns.Ask(new Internal.DnsClient.DnsQuestion(DnsClient.NewQueryId(), srvRequest, DnsProtocol.RecordType.Srv), resolveTimeout); if (result is Internal.DnsClient.Answer answer) { diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs new file mode 100644 index 000000000..7915ca6e5 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using Akka.Actor; +using Akka.Dispatch; +using Akka.Event; +using Akka.IO; +using Akka.Routing; +using Akka.Util; + +namespace Akka.Discovery.Dns.Internal; + +internal class AsyncDnsManager : ActorBase, IRequiresMessageQueue +{ + private readonly ILoggingAdapter _log = Context.GetLogger(); + + // private readonly IActorRef _resolver; + // private IPeriodicCacheCleanup _cacheCleanup; + // private ICancelable _cleanupTimer; + + // private IReadOnlyList _nameservers; + private IActorRef[] _resolvers; + private IActorRef _resolver; + + /// + /// Creates a new instance of the AsyncDnsManager. + /// + /// The DNS extension that owns this manager. + public AsyncDnsManager(DnsExt ext) + { + var nameservers = ext.Settings.ResolverConfig.GetStringList(AsyncDnsResolverOptions.NameserversPath) + .ToImmutableList(); + if (nameservers.Count == 0) + { + throw NoNameServerConfigured.Instance; + } + _resolvers = SpawnClients(ext, nameservers); + _resolver = Context.ActorOf(Props.Empty.WithRouter(new RoundRobinGroup(_resolvers.Select(x => x.Path.ToString()))), "dns-router"); + } + IActorRef[] SpawnClients(DnsExt ext, IReadOnlyList nameservers) => + nameservers + .Select(ns => + { + try + { + return Option<(string name, EndPoint endpoint)> + .Create((ns,ParseEndPoint(ns))); + } + catch (Exception e) + { + _log.Error(e, "Failed parsing nameserver from {0}", ns); + return Option<(string name, EndPoint endpoint)>.None; + } + }) + .Where(x => x.HasValue) + .Select(opt => + Context.ActorOf( + Props.Create(typeof(DnsClient), opt.Value.endpoint) + .WithDeploy(Deploy.Local) + .WithDispatcher(ext.Settings.Dispatcher) + , opt.Value.name) + ).ToArray(); + + public class NoNameServerConfigured : Exception + { + private NoNameServerConfigured(string msg) : base (msg) {} + public static readonly NoNameServerConfigured Instance = new ("Nameservers were not configured"); + } + + bool HandleRequest(object message) + { + _resolver.Forward(message); + return true; + // foreach (var resolver in _resolvers) + // { + // resolver.Forward(message); + // } + // + // return true; + } + + /// + /// Translate SimpleDnsManager resolve request into DnsClient.DnsQuestion + /// + /// + /// + DnsClient.DnsQuestion Convert(IO.Dns.Resolve r) => new(DnsClient.NewQueryId(), r.Name, DnsProtocol.RecordType.Any); + + /// + /// Handles DNS resolution requests and cache cleanup messages. + /// + /// The message to process. + /// True if the message was handled, false otherwise. + protected override bool Receive(object message) + { + switch (message) + { + case IO.Dns.Resolve r: + { + return HandleRequest(Convert(r)); + } + case DnsClient.DnsQuestion question: + return HandleRequest(question); + default: + Unhandled(message); + return false; + } + } + + /// + /// Cancels the cleanup timer when the actor is stopped. + /// + protected override void PostStop() + { + // if (_cleanupTimer != null) + // _cleanupTimer.Cancel(); + } + + /// + /// Message sent to trigger DNS cache cleanup. + /// + // internal class CacheCleanup + // { + // /// + // /// Singleton instance of the cache cleanup message. + // /// + // public static readonly CacheCleanup Instance = new(); + // } + + /// + /// Parse a string endpoint into an IPEndPoint + /// Handles IPv4 addresses, IPv6 addresses, and hostnames with optional port + /// + /// String in format "address:port" where address can be IPv4, IPv6, or hostname + /// IPEndPoint representing the parsed endpoint + internal static EndPoint ParseEndPoint(string endpoint) + { + if (string.IsNullOrWhiteSpace(endpoint)) + throw new ArgumentException("Endpoint cannot be null or empty", nameof(endpoint)); + + // Default port if not specified + int port = 53; + string host; + + // Check if we have an IPv6 address with brackets + if (endpoint.StartsWith("[")) + { + // Format is [IPv6]:port + int closeBracketIndex = endpoint.IndexOf(']'); + if (closeBracketIndex == -1) + throw new FormatException($"Invalid IPv6 endpoint format: {endpoint}. Expected [IPv6]:port"); + + host = endpoint.Substring(1, closeBracketIndex - 1); // Extract IPv6 without brackets + + // Check if there's a port after the IPv6 address + if (closeBracketIndex + 1 < endpoint.Length && endpoint[closeBracketIndex + 1] == ':') + { + string portStr = endpoint.Substring(closeBracketIndex + 2); + if (!int.TryParse(portStr, out port)) + throw new FormatException($"Invalid port in endpoint: {portStr}"); + } + } + else if (endpoint.Contains(":")) + { + // Could be IPv4:port or IPv6 without brackets + if (endpoint.Count(c => c == ':') > 1) + { + // This is likely an IPv6 address without port + host = endpoint; + } + else + { + // This is likely IPv4:port + var parts = endpoint.Split(':'); + host = parts[0]; + if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) + { + if (!int.TryParse(parts[1], out port)) + throw new FormatException($"Invalid port in endpoint: {parts[1]}"); + } + } + } + else + { + // Just a hostname or IP without port + host = endpoint; + } + + // Try to parse as IP address + if (IPAddress.TryParse(host, out var ipAddress)) + { + return new IPEndPoint(ipAddress, port); + } + + // If not an IP, try to resolve hostname + try + { + var addresses = System.Net.Dns.GetHostAddresses(host); + if (addresses.Length == 0) + throw new FormatException($"Could not resolve hostname: {host}"); + + // Prefer IPv4 address if available + var preferredAddress = + addresses.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + ?? addresses[0]; // Fall back to first address if no IPv4 + + return new IPEndPoint(preferredAddress, port); + } + catch (Exception ex) when (!(ex is FormatException)) + { + throw new FormatException($"Failed to resolve hostname: {host}", ex); + } + } +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs index 0ccc4a305..d9ff66a84 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs @@ -30,7 +30,7 @@ public class AsyncDnsProvider : IDnsProvider /// /// TBD /// - public Type ManagerClass => typeof (DnsClient); + public Type ManagerClass => typeof (AsyncDnsManager); } /// @@ -44,23 +44,26 @@ internal class DnsClient : UntypedActorWithStash /// /// Base class for DNS questions /// - public abstract record DnsQuestion(short Id); + public record DnsQuestion(short Id, string Name, DnsProtocol.RecordType RecordType); /// /// Question for SRV records /// - public sealed record SrvQuestion(short Id, string Name) : DnsQuestion(Id); - - /// - /// Question for A records (IPv4) - /// - public sealed record Question4(short Id, string Name) : DnsQuestion(Id); - - /// - /// Question for AAAA records (IPv6) - /// - public sealed record Question6(short Id, string Name) : DnsQuestion(Id); - + // public sealed record SrvQuestion(short Id, string Name) : DnsQuestion(Id); + // + // /// + // /// Question for A records (IPv4) + // /// + // public sealed record Question4(short Id, string Name) : DnsQuestion(Id); + // + // /// + // /// Question for AAAA records (IPv6) + // /// + // public sealed record Question6(short Id, string Name) : DnsQuestion(Id); + // + // + // public sealed record QuestionAny(short Id, string Name) : DnsQuestion(Id); + /// /// DNS answer containing resource records /// @@ -77,7 +80,9 @@ public Answer(short id, IEnumerable? records = null, IEnumerable AdditionalRecords = additionalRecords?.ToImmutableArray() ?? ImmutableArray.Empty; } } - + private static readonly Random _random = new(); + //TODO: Maybe this should be more resilient, what if we have a lot of requests at the same time? + internal static short NewQueryId() => (short)_random.Next(0, 65535); /// /// Request to drop a pending DNS question /// @@ -140,11 +145,13 @@ public InFlightRequest(IActorRef replyTo, DnsProtocol.Message message, bool tcpR } - public DnsClient(DnsExt ext) + // public DnsClient(DnsExt ext) + public DnsClient(EndPoint nameserver) { _log = Context.GetLogger(); - var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolverOptions.NameserverPath) ?? throw new ConfigurationException("nameserver config was empty"); - _nameserver = ParseEndPoint(ns); + // var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolverOptions.NameserverPath) ?? throw new ConfigurationException("nameserver config was empty"); + // _nameserver = ParseEndPoint(ns); + _nameserver = nameserver; _udpManager = Akka.IO.Udp.Instance.Apply(Context.System).Manager; _tcpManager = Akka.IO.Tcp.Manager(Context.System); _stash = Context.CreateStash(typeof(DnsClient)); @@ -177,9 +184,7 @@ protected override void OnReceive(object message) Context.Become(Ready); _stash.UnstashAll(); break; - case Question4 _: - case Question6 _: - case SrvQuestion _: + case DnsQuestion _: _stash.Stash(); break; } @@ -187,23 +192,14 @@ protected override void OnReceive(object message) private void Ready(object message) { - _log.Debug("Received message:[{0}]", message.GetType()); switch (message) { case DropRequest dropRequest: HandleDropRequest(dropRequest); break; - - case Question4 question: - HandleQuestion(question.Id, question.Name, DnsProtocol.RecordType.A, Sender); - break; - - case Question6 question: - HandleQuestion(question.Id, question.Name, DnsProtocol.RecordType.Aaaa, Sender); - break; - - case SrvQuestion question: - HandleQuestion(question.Id, question.Name, DnsProtocol.RecordType.Srv, Sender); + + case DnsQuestion question: + HandleQuestion(question.Id, question.Name, question.RecordType, Sender); break; case Udp.Received received: @@ -350,25 +346,9 @@ private void HandleDropRequest(DropRequest dropRequest) { var sentQuestions = inFlight.Message.Questions.Select(q => new { q.Name, q.Type }).ToList(); - string expectedName = null; - DnsProtocol.RecordType expectedType = DnsProtocol.RecordType.A; - - switch (dropRequest.Question) - { - case Question4 q4: - expectedName = q4.Name; - expectedType = DnsProtocol.RecordType.A; - break; - case Question6 q6: - expectedName = q6.Name; - expectedType = DnsProtocol.RecordType.Aaaa; - break; - case SrvQuestion srv: - expectedName = srv.Name; - expectedType = DnsProtocol.RecordType.Srv; - break; - } - + string expectedName = dropRequest.Question.Name; + DnsProtocol.RecordType expectedType = dropRequest.Question.RecordType; + if (sentQuestions.Any(q => q.Name == expectedName && q.Type == expectedType)) { _log.Debug("Dropping request [{0}]", id); @@ -424,87 +404,5 @@ private IActorRef CreateTcpClient() "tcpDnsClientSupervisor"); } - /// - /// Parse a string endpoint into an IPEndPoint - /// Handles IPv4 addresses, IPv6 addresses, and hostnames with optional port - /// - /// String in format "address:port" where address can be IPv4, IPv6, or hostname - /// IPEndPoint representing the parsed endpoint - internal static EndPoint ParseEndPoint(string endpoint) - { - if (string.IsNullOrWhiteSpace(endpoint)) - throw new ArgumentException("Endpoint cannot be null or empty", nameof(endpoint)); - - // Default port if not specified - int port = 53; - string host; - - // Check if we have an IPv6 address with brackets - if (endpoint.StartsWith("[")) - { - // Format is [IPv6]:port - int closeBracketIndex = endpoint.IndexOf(']'); - if (closeBracketIndex == -1) - throw new FormatException($"Invalid IPv6 endpoint format: {endpoint}. Expected [IPv6]:port"); - - host = endpoint.Substring(1, closeBracketIndex - 1); // Extract IPv6 without brackets - - // Check if there's a port after the IPv6 address - if (closeBracketIndex + 1 < endpoint.Length && endpoint[closeBracketIndex + 1] == ':') - { - string portStr = endpoint.Substring(closeBracketIndex + 2); - if (!int.TryParse(portStr, out port)) - throw new FormatException($"Invalid port in endpoint: {portStr}"); - } - } - else if (endpoint.Contains(":")) - { - // Could be IPv4:port or IPv6 without brackets - if (endpoint.Count(c => c == ':') > 1) - { - // This is likely an IPv6 address without port - host = endpoint; - } - else - { - // This is likely IPv4:port - var parts = endpoint.Split(':'); - host = parts[0]; - if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1])) - { - if (!int.TryParse(parts[1], out port)) - throw new FormatException($"Invalid port in endpoint: {parts[1]}"); - } - } - } - else - { - // Just a hostname or IP without port - host = endpoint; - } - - // Try to parse as IP address - if (IPAddress.TryParse(host, out var ipAddress)) - { - return new IPEndPoint(ipAddress, port); - } - - // If not an IP, try to resolve hostname - try - { - var addresses = System.Net.Dns.GetHostAddresses(host); - if (addresses.Length == 0) - throw new FormatException($"Could not resolve hostname: {host}"); - - // Prefer IPv4 address if available - var preferredAddress = addresses.FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) - ?? addresses[0]; // Fall back to first address if no IPv4 - - return new IPEndPoint(preferredAddress, port); - } - catch (Exception ex) when (!(ex is FormatException)) - { - throw new FormatException($"Failed to resolve hostname: {host}", ex); - } - } + } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/reference.conf b/src/discovery/dns/Akka.Discovery.Dns/reference.conf index 15d936b09..9e41610ff 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/reference.conf +++ b/src/discovery/dns/Akka.Discovery.Dns/reference.conf @@ -26,7 +26,8 @@ akka.io.dns { async-dns { class = "Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns" provider-object = "Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns" - # The hostname or IP address of the nameserver to use - nameserver = "127.0.0.1:53" + # The hostname and/or IPv4/v6 address list of the nameservers to use + # Port is optional, defaults to 53 + nameservers = [ "127.0.0.1:53"; "my-dns.example.com"; "[::1]:53" ] } } From 63965f1e2f64590c41862b33aa4f74c3b31fb5b5 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 15 Jul 2025 15:27:33 -0300 Subject: [PATCH 16/37] Cache for async-dns resolver --- .../Internal/AsyncDnsCache.cs | 199 ++++++++++++++++++ .../Internal/AsyncDnsManager.cs | 56 +++-- .../Internal/AsyncDnsProvider.cs | 22 ++ .../Akka.Discovery.Dns/Internal/DnsClient.cs | 121 ++++++++--- .../Internal/DnsProtocol.cs | 10 + .../Internal/TcpDnsClient.cs | 4 +- .../dns/Akka.Discovery.Dns/reference.conf | 12 ++ 7 files changed, 363 insertions(+), 61 deletions(-) create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs create mode 100644 src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs new file mode 100644 index 000000000..eaa2261cd --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs @@ -0,0 +1,199 @@ + +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2025 .NET Foundation +// +//----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using Akka.IO; +using Akka.Util; +using TResolved = Akka.Discovery.Dns.Internal.DnsClient.Answer; +namespace Akka.Discovery.Dns.Internal; + +/// +/// Interface for DNS caches that support periodic cleanup of expired entries. +/// +internal interface IPeriodicCacheCleanup +{ + /// + /// Cleans up expired entries from the cache. + /// + void CleanUp(); +} + +/// +/// A simple in-memory DNS cache that stores resolved DNS entries with TTL-based expiration. +/// This class is a copy of SimpleDnsCache adjusted for DnsClient.Answer use +/// +public class AsyncDnsCache : DnsBase, IPeriodicCacheCleanup +{ + private readonly AtomicReference _cache; + private readonly long _ticksBase; + + /// + /// Initializes a new instance of the AsyncDnsCache. + /// + public AsyncDnsCache() + { + _cache = new AtomicReference(new Cache(new SortedSet(new ExpiryEntryComparer()), new Dictionary(), Clock)); + _ticksBase = DateTime.Now.Ticks; + } + + // /// + // /// Gets a cached DNS resolution result for the specified hostname. + // /// + // /// The hostname to lookup in the cache. + // /// The cached DNS resolution result, or null if not found or expired. + internal TResolved? GetCached(string name) => _cache.Value.Get(name); + + internal static IO.Dns.Resolved? Convert(TResolved? answer) => + answer == null ? null : + new(answer.Name, + TResolved.ToIpAddresses(answer, DnsProtocol.RecordType.A), + TResolved.ToIpAddresses(answer, DnsProtocol.RecordType.Aaaa) + ); + + + /// + /// Gets a cached DNS resolution result for the specified hostname. + /// + /// The hostname to lookup in the cache. + /// The cached DNS resolution result, or null if not found or expired. + public override IO.Dns.Resolved? Cached(string name) => Convert(GetCached(name)); + + /// + /// Gets the current clock time in milliseconds since cache initialization. + /// + /// The current clock time in milliseconds. + protected virtual long Clock() + { + var now = DateTime.Now.Ticks; + return now - _ticksBase < 0 + ? 0 + : (now - _ticksBase) / 10000; + } + + /// + /// Adds a resolved DNS entry to the cache with the specified TTL. + /// + /// The resolved DNS entry to add to the cache. + /// Time-to-live in milliseconds for the entry. + internal void Put(TResolved r, long ttl) + { + var c = _cache.Value; + if (!_cache.CompareAndSet(c, c.Put(r, ttl))) + Put(r, ttl); + } + + /// + /// Cleans up expired entries from the cache. + /// + public void CleanUp() + { + var c = _cache.Value; + if (!_cache.CompareAndSet(c, c.Cleanup())) + CleanUp(); + } + + class Cache + { + private readonly SortedSet _queue; + private readonly Dictionary _cache; + private readonly Func _clock; + private readonly object _queueCleanupLock = new(); + + public Cache(SortedSet queue, Dictionary cache, Func clock) + { + _queue = queue; + _cache = cache; + _clock = clock; + } + + public TResolved? Get(string name) + { + if (_cache.TryGetValue(name, out var e) && e.IsValid(_clock())) + return e.Answer; + return null; + } + + public Cache Put(TResolved answer, long ttl) + { + var until = _clock() + ttl; + + var cache = new Dictionary(_cache); + + cache[answer.Name] = new CacheEntry(answer, until); + + return new Cache( + queue: new SortedSet(_queue, new ExpiryEntryComparer()) { new(answer.Name, until) }, + cache: cache, + clock: _clock); + } + + public Cache Cleanup() + { + lock (_queueCleanupLock) + { + var now = _clock(); + while (_queue.Any() && !_queue.First().IsValid(now)) + { + var minEntry = _queue.First(); + var name = minEntry.Name; + _queue.Remove(minEntry); + + if (_cache.TryGetValue(name, out var cacheEntry) && !cacheEntry.IsValid(now)) + _cache.Remove(name); + } + } + + return new Cache(new SortedSet(), new Dictionary(_cache), _clock); + } + } + + class CacheEntry + { + public CacheEntry(TResolved answer, long until) + { + Answer = answer; + Until = until; + } + + public TResolved Answer { get; private set; } + public long Until { get; private set; } + + public bool IsValid(long clock) + { + return clock < Until; + } + } + + class ExpiryEntry + { + public ExpiryEntry(string name, long until) + { + Name = name; + Until = until; + } + + public string Name { get; private set; } + public long Until { get; private set; } + + public bool IsValid(long clock) + { + return clock < Until; + } + } + + class ExpiryEntryComparer : IComparer + { + /// + public int Compare(ExpiryEntry x, ExpiryEntry y) + { + return x.Until.CompareTo(y.Until); + } + } +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs index 7915ca6e5..4f990ff75 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs @@ -15,14 +15,9 @@ namespace Akka.Discovery.Dns.Internal; internal class AsyncDnsManager : ActorBase, IRequiresMessageQueue { private readonly ILoggingAdapter _log = Context.GetLogger(); - - // private readonly IActorRef _resolver; - // private IPeriodicCacheCleanup _cacheCleanup; - // private ICancelable _cleanupTimer; - - // private IReadOnlyList _nameservers; - private IActorRef[] _resolvers; - private IActorRef _resolver; + private readonly IPeriodicCacheCleanup? _cacheCleanup; + private readonly ICancelable? _cleanupTimer; + private readonly IActorRef _resolver; /// /// Creates a new instance of the AsyncDnsManager. @@ -36,12 +31,20 @@ public AsyncDnsManager(DnsExt ext) { throw NoNameServerConfigured.Instance; } - _resolvers = SpawnClients(ext, nameservers); - _resolver = Context.ActorOf(Props.Empty.WithRouter(new RoundRobinGroup(_resolvers.Select(x => x.Path.ToString()))), "dns-router"); + var resolvers = SpawnClients(ext, nameservers); + _resolver = Context.ActorOf(Props.Empty.WithRouter(new RoundRobinGroup(resolvers.Select(x => x.Path.ToString()))), "dns-router"); + _cacheCleanup = ext.Cache as IPeriodicCacheCleanup; + + if (_cacheCleanup != null) + { + var interval = ext.Settings.ResolverConfig.GetTimeSpan("cache-cleanup-interval", TimeSpan.FromSeconds(120)); + _cleanupTimer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable(interval, interval, Self, CacheCleanup.Instance, Self); + } + } IActorRef[] SpawnClients(DnsExt ext, IReadOnlyList nameservers) => nameservers - .Select(ns => + .Select(ns => //string -> Option<(string name, EndPoint endpoint)> { try { @@ -57,7 +60,7 @@ IActorRef[] SpawnClients(DnsExt ext, IReadOnlyList nameservers) => .Where(x => x.HasValue) .Select(opt => Context.ActorOf( - Props.Create(typeof(DnsClient), opt.Value.endpoint) + Props.Create(typeof(DnsClient), ext.Cache, ext.Settings.ResolverConfig, opt.Value.endpoint) .WithDeploy(Deploy.Local) .WithDispatcher(ext.Settings.Dispatcher) , opt.Value.name) @@ -73,12 +76,6 @@ bool HandleRequest(object message) { _resolver.Forward(message); return true; - // foreach (var resolver in _resolvers) - // { - // resolver.Forward(message); - // } - // - // return true; } /// @@ -98,11 +95,12 @@ protected override bool Receive(object message) switch (message) { case IO.Dns.Resolve r: - { return HandleRequest(Convert(r)); - } case DnsClient.DnsQuestion question: return HandleRequest(question); + case CacheCleanup _: + _cacheCleanup?.CleanUp(); + return true; default: Unhandled(message); return false; @@ -114,20 +112,20 @@ protected override bool Receive(object message) /// protected override void PostStop() { - // if (_cleanupTimer != null) - // _cleanupTimer.Cancel(); + if (_cleanupTimer != null) + _cleanupTimer.Cancel(); } /// /// Message sent to trigger DNS cache cleanup. /// - // internal class CacheCleanup - // { - // /// - // /// Singleton instance of the cache cleanup message. - // /// - // public static readonly CacheCleanup Instance = new(); - // } + internal class CacheCleanup + { + /// + /// Singleton instance of the cache cleanup message. + /// + public static readonly CacheCleanup Instance = new(); + } /// /// Parse a string endpoint into an IPEndPoint diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs new file mode 100644 index 000000000..057d7b603 --- /dev/null +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs @@ -0,0 +1,22 @@ +using System; +using Akka.IO; + +namespace Akka.Discovery.Dns.Internal; + +public class AsyncDnsProvider : IDnsProvider +{ + /// + /// TBD + /// + public DnsBase Cache { get; } = new AsyncDnsCache(); + + /// + /// TBD + /// + public Type ActorClass => typeof (DnsClient); + + /// + /// TBD + /// + public Type ManagerClass => typeof (AsyncDnsManager); +} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs index d9ff66a84..66ac1acab 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs @@ -10,29 +10,10 @@ using Akka.Event; using Akka.IO; using Akka.Pattern; +using Akka.Util; namespace Akka.Discovery.Dns.Internal; -public class AsyncDnsProvider : IDnsProvider -{ - private readonly DnsBase _cache = new SimpleDnsCache(); - - /// - /// TBD - /// - public DnsBase Cache => _cache; - - /// - /// TBD - /// - public Type ActorClass => typeof (DnsClient); - - /// - /// TBD - /// - public Type ManagerClass => typeof (AsyncDnsManager); -} - /// /// DNS client actor for resolving DNS queries, including SRV records. /// This is an internal implementation for the Akka.Discovery.Dns service. @@ -70,15 +51,47 @@ public record DnsQuestion(short Id, string Name, DnsProtocol.RecordType RecordTy public sealed record Answer { public short Id { get; } + public string Name { get; } public ImmutableArray Records { get; } public ImmutableArray AdditionalRecords { get; } - public Answer(short id, IEnumerable? records = null, IEnumerable? additionalRecords = null) + public Answer(short id, string name = "", IEnumerable? records = null, IEnumerable? additionalRecords = null) { Id = id; + Name = name; Records = records?.ToImmutableArray() ?? ImmutableArray.Empty; AdditionalRecords = additionalRecords?.ToImmutableArray() ?? ImmutableArray.Empty; } + + public static uint MinTtl(Answer answer) + { + + uint rm = UInt32.MaxValue; + uint arm = UInt32.MaxValue; + if(answer.Records.IsEmpty == false) + rm = answer.Records.Select(x => x.TimeToLive).Min(); + if(answer.AdditionalRecords.IsEmpty == false) + arm = answer.AdditionalRecords.Select(x => x.TimeToLive).Min(); + if (rm == UInt32.MaxValue && arm == UInt32.MaxValue) + return 0; + return rm < arm ? rm : arm; + } + + public static IEnumerable RecordsOfType(Answer answer, DnsProtocol.RecordType recordType) => + new[] + { + answer.Records.Where(x => x.Type == recordType), + answer.AdditionalRecords.Where(x => x.Type == recordType) + } + .SelectMany(x => x); + + public static IEnumerable ToIpAddresses(Answer answer, DnsProtocol.RecordType recordType) => + RecordsOfType(answer, recordType) + .Select(x => IPAddress.TryParse(x.Name, out var ip) + ? Option.Create(ip) : Option.None) //this might lose data if answer is hostname + .Where(x => x.HasValue) + .Select(x => x.Value); + } private static readonly Random _random = new(); //TODO: Maybe this should be more resilient, what if we have a lot of requests at the same time? @@ -115,6 +128,7 @@ public UdpAnswer(IEnumerable questions, Answer content) #endregion + private readonly AsyncDnsCache _cache; private readonly EndPoint _nameserver; private readonly ILoggingAdapter _log; private readonly IActorRef _tcpManager; @@ -125,6 +139,46 @@ public UdpAnswer(IEnumerable questions, Answer content) private Dictionary _inflightRequests = new Dictionary(); private IActorRef _tcpDnsClient; private IActorRef? _udpSocket; + private readonly PositiveTtl _positiveTtl; + + abstract record PositiveTtl; + + record Forever : PositiveTtl + { + public static readonly Forever Instance = new Forever(); + } + + record Never : PositiveTtl + { + public static readonly Never Instance = new Never(); + } + + record TtlTimeSpan(TimeSpan TimeSpan) : PositiveTtl; + + static PositiveTtl ParseTTl(Configuration.Config config) => + config.GetString("positive-ttl", "forever").ToLowerInvariant() switch + { + "forever" => Forever.Instance, + "never" => Never.Instance, + _ => new TtlTimeSpan(config.GetTimeSpan("positive-ttl")), + }; + + bool UseTtl(Answer answer, out long ttl) + { + switch (_positiveTtl) + { + case Never: + ttl = long.MinValue; + return false; + case TtlTimeSpan ts: + ttl = (long)ts.TimeSpan.TotalMilliseconds; + return true; + default: + ttl = Answer.MinTtl(answer); + return true; + + } + } /// @@ -143,19 +197,16 @@ public InFlightRequest(IActorRef replyTo, DnsProtocol.Message message, bool tcpR TcpRequest = tcpRequest; } } - - - // public DnsClient(DnsExt ext) - public DnsClient(EndPoint nameserver) + public DnsClient(AsyncDnsCache cache, Configuration.Config config, EndPoint nameserver) { _log = Context.GetLogger(); - // var ns = ext.Settings.ResolverConfig.GetString(AsyncDnsResolverOptions.NameserverPath) ?? throw new ConfigurationException("nameserver config was empty"); - // _nameserver = ParseEndPoint(ns); + _cache = cache; _nameserver = nameserver; + _positiveTtl = ParseTTl(config); + _udpManager = Akka.IO.Udp.Instance.Apply(Context.System).Manager; _tcpManager = Akka.IO.Tcp.Manager(Context.System); _stash = Context.CreateStash(typeof(DnsClient)); - _log.Log(LogLevel.DebugLevel, "Constructed!"); } protected override void PreStart() @@ -226,11 +277,11 @@ private void Ready(object message) if (msg.Flags.ResponseCode != DnsProtocol.ResponseCode.Success) { _log.Warning("DNS response failed: [{0}]", msg); - Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, ImmutableList.Empty, ImmutableList.Empty))); + Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, msg.FirstQuestionName, ImmutableList.Empty, ImmutableList.Empty))); } else { - Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, msg.AnswerRecords, msg.AdditionalRecords))); + Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, msg.FirstQuestionName, msg.AnswerRecords, msg.AdditionalRecords))); } } } @@ -250,6 +301,8 @@ private void Ready(object message) { request.ReplyTo.Tell(udpAnswer.Content); _inflightRequests.Remove(udpAnswer.Content.Id); + if(UseTtl(udpAnswer.Content, out var ttl)) + _cache.Put(udpAnswer.Content, ttl); } else { @@ -270,6 +323,7 @@ private void Ready(object message) if (_inflightRequests.TryGetValue(answer.Id, out var inFlight)) { inFlight.ReplyTo.Tell(answer); + _inflightRequests.Remove(answer.Id); } else @@ -326,6 +380,13 @@ private void HandleQuestion(short id, string name, DnsProtocol.RecordType record id); return; } + + var answer = _cache.GetCached(name); + if (answer != null) + { + sender.Tell(answer); + return; + } _log.Debug("Resolving [{0}] ({1})", name, recordType); diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs index 8d6b8eb10..d01d1a48e 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs @@ -212,6 +212,16 @@ public byte[] Write() } } + public string FirstQuestionName + { + get + { + if (Questions.IsEmpty) + return string.Empty; + return Questions[0].Name; + } + } + /// /// Parse a DNS message from a byte array /// diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs index 2c0b58b60..81ac28544 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs @@ -161,8 +161,8 @@ private void ProcessReceivedData(byte[] data) ? dnsMessage.AdditionalRecords : Array.Empty().ToImmutableList(); // Forward the answer to the parent - _parent.Tell(new DnsClient.Answer(dnsMessage.Id, records, additionalRecs)); - + _parent.Tell(new DnsClient.Answer(dnsMessage.Id, dnsMessage.FirstQuestionName, records, additionalRecs)); + // Remove the processed message from the buffer var remaining = _currentPosition - (_expectedLength + 2); if (remaining > 0) diff --git a/src/discovery/dns/Akka.Discovery.Dns/reference.conf b/src/discovery/dns/Akka.Discovery.Dns/reference.conf index 9e41610ff..a27029779 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/reference.conf +++ b/src/discovery/dns/Akka.Discovery.Dns/reference.conf @@ -29,5 +29,17 @@ akka.io.dns { # The hostname and/or IPv4/v6 address list of the nameservers to use # Port is optional, defaults to 53 nameservers = [ "127.0.0.1:53"; "my-dns.example.com"; "[::1]:53" ] + + # How often to sweep out expired cache entries. + # Note that this interval has nothing to do with TTLs + cache-cleanup-interval = 120s + # Set upper bound for caching successfully resolved dns entries + # if the DNS record has a smaller TTL value than the setting that + # will be used. Default is to use the record TTL with no cap. + # Possible values: + # forever: always use the minimum TTL from the found records + # never: never cache + # n [time unit] = cap the caching to this value + positive-ttl = forever } } From 3a56d5d5f4d9d014e0ade344a97b05743f87f7eb Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Wed, 16 Jul 2025 16:15:21 -0300 Subject: [PATCH 17/37] Refactor AsyncDnsClient: - Rename DnsClient to AsyncDnsClient.cs - Drop answer wrapper messages and use DnsProtocol.Message directly - Fix handling of legacy IO.Dns.Resolve requests - Add tests for various combinations of positive-ttl parameter which affect caching strategy --- .../SrvRecordsDiscovery.cs | 103 ++++- .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 2 +- .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 110 +++-- .../Internal/AsyncDnsCache.cs | 8 +- .../{DnsClient.cs => AsyncDnsClient.cs} | 400 +++++++++--------- .../Internal/AsyncDnsManager.cs | 14 +- .../Internal/AsyncDnsProvider.cs | 2 +- .../Internal/DnsProtocol.cs | 62 ++- .../Internal/TcpDnsClient.cs | 18 +- 9 files changed, 423 insertions(+), 296 deletions(-) rename src/discovery/dns/Akka.Discovery.Dns/Internal/{DnsClient.cs => AsyncDnsClient.cs} (57%) diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs index 848710dee..35ac7ff43 100644 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using System; +using System.Linq; using System.Threading.Tasks; using Akka.Actor; using Akka.Configuration; @@ -14,7 +15,7 @@ namespace Akka.Discovery.Dns.Tests; -public class SrvRecordsDiscovery(ITestOutputHelper output) : TestKit.Xunit2.TestKit( +public class SrvRecordsDiscovery(ITestOutputHelper output) : BaseSrvRecordsDiscovery( ConfigurationFactory.ParseString(@" akka.loglevel = DEBUG akka.discovery { @@ -32,7 +33,61 @@ class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" ""1.1.1.1"" ] } - "), "dns-discovery", output) + "), output) +{ + +} + + +public class SrvNoCacheRecordsDiscovery(ITestOutputHelper output) : BaseSrvRecordsDiscovery( + ConfigurationFactory.ParseString(@" + akka.loglevel = DEBUG + akka.discovery { + method = akka-dns + akka-dns { + class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" + } + } + akka.io.dns.resolver = async-dns + akka.io.dns.async-dns { + class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" + provider-object = ""Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns"" + nameservers = [ + ""1dot1dot1dot1.cloudflare-dns.com"", + ""1.1.1.1"" ] + positive-ttl = never + + } + "), output) +{ + +} + + +public class SrvTimeRecordsDiscovery(ITestOutputHelper output) : BaseSrvRecordsDiscovery( + ConfigurationFactory.ParseString(@" + akka.loglevel = DEBUG + akka.discovery { + method = akka-dns + akka-dns { + class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" + } + } + akka.io.dns.resolver = async-dns + akka.io.dns.async-dns { + class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" + provider-object = ""Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns"" + nameservers = [ + ""1dot1dot1dot1.cloudflare-dns.com"", + ""1.1.1.1"" ] + positive-ttl = 10s + + } + "), output) +{ + +} +public abstract class BaseSrvRecordsDiscovery(Configuration.Config config , ITestOutputHelper output) : TestKit.Xunit2.TestKit(config, "dns-discovery", output) { [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] public void DnsServiceDiscoveryShouldBeLoadableViaConfig() @@ -54,14 +109,37 @@ public async Task DnsServiceDiscoveryShouldHandleLookup(string serviceName, stri var lookup = new Lookup(serviceName, portName, protocol); var resolved = await serviceDiscovery.Lookup(lookup, TimeSpan.FromSeconds(60)); - // Skip assertion if no records found (some services might not have SRV records) - if (resolved.Addresses.Count == 0) + resolved.Addresses.Count.Should().BeGreaterThan(0, $"No SRV records found for {description}."); + // Log information for diagnostic purposes + Output.WriteLine($"Resolved targets: {resolved.Addresses.Count}"); + foreach (var addr in resolved.Addresses) + { + Output.WriteLine($" Host: {addr.Host}, Address: {addr.Address}, Port: {addr.Port}"); + } + + resolved.Addresses.Count.Should().BeGreaterThan(0, "At least one SRV record should be found"); + foreach (var address in resolved.Addresses) { - Output.WriteLine($"No SRV records found for {description}. Skipping assertions."); - return; + address.Host.Should().NotBeNullOrEmpty("Host should not be empty"); + address.Port.Should().BeGreaterThan(0, "Port should be specified for SRV lookup"); } + } + + + [Theory(DisplayName = "DnsServiceDiscovery should handle A/AAAA lookup with real DNS")] + [InlineData("jabber.org", "XMPP server")] + [InlineData("matrix.org", "Matrix server")] + [InlineData("gmail.com", "Gmail IMAPS")] + public async Task DnsServiceDiscoveryShouldHandleLookupOfA(string serviceName, + string description) + { + Output.WriteLine($"Testing A/AAAA lookup for {description}: {serviceName}"); + var serviceDiscovery = new Dns.DnsServiceDiscovery((ExtendedActorSystem)Sys); - Output.WriteLine($"Found {resolved.Addresses.Count} records for {description}"); + var lookup = new Lookup(serviceName); + var resolved = await serviceDiscovery.Lookup(lookup, TimeSpan.FromSeconds(60)); + + resolved.Addresses.Count.Should().BeGreaterThan(0, $"No A/AAAA records found for {description}."); // Log information for diagnostic purposes Output.WriteLine($"Resolved targets: {resolved.Addresses.Count}"); @@ -70,11 +148,20 @@ public async Task DnsServiceDiscoveryShouldHandleLookup(string serviceName, stri Output.WriteLine($" Host: {addr.Host}, Address: {addr.Address}, Port: {addr.Port}"); } + resolved.Addresses + .Sum(x => x.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 1 : 0) + .Should().BeGreaterThan(0, "At least one IPv6 record should be found"); + + resolved.Addresses + .Sum(x => x.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 1 : 0) + .Should().BeGreaterThan(0, "At least one IPv4 record should be found"); + + resolved.Addresses.Count.Should().BeGreaterThan(0, "At least one SRV record should be found"); foreach (var address in resolved.Addresses) { address.Host.Should().NotBeNullOrEmpty("Host should not be empty"); - address.Port.Should().BeGreaterThan(0, "Port should be specified for SRV lookup"); + address.Port.Should().BeNull( "Port should be specified for SRV lookup"); } } } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index c92b3ae2c..ce792554e 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -151,6 +151,6 @@ public void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) builder.AddHocon(ToHocon(), HoconAddMode.Prepend); } public string ConfigPath => DefaultPath; - public Type Class => typeof(DnsClient); + public Type Class => typeof(AsyncDnsClient); public Type Provider => typeof(AsyncDnsProvider); } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index f2df1faea..1ca8cb4fc 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -31,7 +31,7 @@ public DnsServiceDiscovery(ExtendedActorSystem system) /// /// Cleans an IP string by removing leading '/' if present. /// - private string CleanIpString(string ipString) => + private static string CleanIpString(string ipString) => ipString.StartsWith("/") ? ipString.Substring(1) : ipString; public override async Task Lookup(Lookup lookup, TimeSpan resolveTimeout) @@ -52,9 +52,9 @@ private async Task LookupSrv(Lookup lookup, TimeSpan resolveTimeout) try { // Send SRV question and await response - var result = await _dns.Ask(new Internal.DnsClient.DnsQuestion(DnsClient.NewQueryId(), srvRequest, DnsProtocol.RecordType.Srv), resolveTimeout); + var result = await _dns.Ask(new Internal.AsyncDnsClient.DnsQuestion(AsyncDnsClient.NewQueryId(), srvRequest, DnsProtocol.RecordType.Srv), resolveTimeout); - if (result is Internal.DnsClient.Answer answer) + if (result is DnsProtocol.Message answer) { return SrvRecordsToResolved(srvRequest, answer); } @@ -72,36 +72,44 @@ private async Task LookupSrv(Lookup lookup, TimeSpan resolveTimeout) throw; } } - - private async Task LookupIp(Lookup lookup, TimeSpan resolveTimeout) - { - _log.Debug("Lookup[{0}] translated to A/AAAA lookup as does not have portName and protocol", lookup); - - // For standard IP lookups, continue to use the built-in Akka.IO.Dns resolver - return await AskResolveIp(lookup.ServiceName, resolveTimeout); - } - - private async Task AskResolveIp(string serviceName, TimeSpan timeout) + + private async Task LookupIp(Lookup lookup, TimeSpan timeout) { try { - var result = await _dns.Ask(new Akka.IO.Dns.Resolve(serviceName), timeout); + _log.Debug("Lookup[{0}] translated to A/AAAA lookup as does not have portName and protocol", lookup.ServiceName); + + // use IO.Dns.Resolve for compatibility with both InetAddressResolver and AsyncDnsClient + var result = await _dns.Ask(new Akka.IO.Dns.Resolve(lookup.ServiceName), timeout); + //inet-address response if (result is IO.Dns.Resolved resolved) { if (resolved.IsSuccess) { - var parsed = IpRecordsToResolved(serviceName, resolved); + var parsed = IoDnsMessageToResolved(lookup.ServiceName, resolved); _log.Debug("lookup result: {0}", parsed); return parsed; } - _log.Error(resolved.Exception, "Failed to resolve serviceName: {0}", serviceName); - return new Resolved(serviceName, ImmutableList.Empty); + _log.Error(resolved.Exception, "Failed to resolve serviceName: {0}", lookup.ServiceName); + return new Resolved(lookup.ServiceName, ImmutableList.Empty); + } + //async-dns response + if (result is DnsProtocol.Message answer) + { + if (answer.Flags.ResponseCode == DnsProtocol.ResponseCode.Success) + { + var parsed = DnsMessageToResolved(lookup.ServiceName, answer); + _log.Debug("lookup result: {0}", parsed); + return parsed; + } + _log.Error("Failed to resolve serviceName=[{0}], answer=[{1}]", lookup.ServiceName, answer); + return new Resolved(lookup.ServiceName, ImmutableList.Empty); } _log.Warning("Resolved UNEXPECTED (resolving to Nil): {0}", result.GetType()); - return new Resolved(serviceName, ImmutableList.Empty); + return new Resolved(lookup.ServiceName, ImmutableList.Empty); } catch (AskTimeoutException) { @@ -114,41 +122,15 @@ private async Task AskResolveIp(string serviceName, TimeSpan timeout) } } - // private async Task AskResolve(string srvRequest, TimeSpan timeout) - // { - // try - // { - // var result = await _dns.Ask(new IO.Dns.Resolve(srvRequest), timeout); - // - // if (result is IO.Dns.Resolved resolved) - // { - // _log.Debug("Lookup result: {0}", resolved); - // return SrvRecordsToResolved(srvRequest, resolved); - // } - // - // _log.Warning("Resolved UNEXPECTED (resolving to Nil): {0}", result.GetType()); - // return new Resolved(srvRequest, ImmutableList.Empty); - // } - // catch (AskTimeoutException) - // { - // throw new TimeoutException($"Dns resolve did not respond within {timeout}"); - // } - // catch (Exception ex) - // { - // _log.Error(ex, "Error during DNS resolution"); - // throw; - // } - // } - /// /// Converts SRV records to a Resolved object from our custom DNS client response. /// - private Resolved SrvRecordsToResolved(string srvRequest, Internal.DnsClient.Answer resolved) + private static Resolved SrvRecordsToResolved(string srvRequest, Internal.DnsProtocol.Message resolved) { var ips = new Dictionary>(); // Process SRV records - var srvRecords = resolved.Records.OfType().ToList(); + var srvRecords = resolved.AnswerRecords.OfType().ToList(); // Process additional A/AAAA records for hostname resolution foreach (var aRecord in resolved.AdditionalRecords.OfType()) @@ -203,19 +185,29 @@ private Resolved SrvRecordsToResolved(string srvRequest, Internal.DnsClient.Answ /// /// Converts IP records to a Resolved object. /// - private Resolved IpRecordsToResolved(string serviceName, Akka.IO.Dns.Resolved resolved) - { - var addresses = + + private static Resolved IpsToResolved(string serviceName, IEnumerableresolved) => + new( + serviceName, + resolved.Select(ipAddress => + new ResolvedTarget(CleanIpString(ipAddress.ToString()), null, ipAddress)) + .ToImmutableList() + ); + + private static Resolved IoDnsMessageToResolved(string serviceName, Akka.IO.Dns.Resolved resolved) => + IpsToResolved(serviceName, new[] { - resolved.Ipv4.Select(aRecord => - new ResolvedTarget(CleanIpString(aRecord.ToString()), null, aRecord)), - resolved.Ipv6.Select(aaaaRecord => - new ResolvedTarget(CleanIpString(aaaaRecord.ToString()), null, aaaaRecord)) - } - .SelectMany(x => x) - .ToImmutableList(); - - return new Resolved(serviceName, addresses); - } + resolved.Ipv4, + resolved.Ipv6 + }.SelectMany(x => x)); + + private static Resolved DnsMessageToResolved(string serviceName, DnsProtocol.Message resolved) => + IpsToResolved(serviceName, + new[] + { + DnsProtocol.Message.ToIpAddresses(resolved, DnsProtocol.RecordType.A), + DnsProtocol.Message.ToIpAddresses(resolved, DnsProtocol.RecordType.Aaaa) + } + .SelectMany(x => x)); } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs index eaa2261cd..52db92081 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs @@ -11,7 +11,7 @@ using System.Linq; using Akka.IO; using Akka.Util; -using TResolved = Akka.Discovery.Dns.Internal.DnsClient.Answer; +using TResolved = Akka.Discovery.Dns.Internal.DnsProtocol.Message; namespace Akka.Discovery.Dns.Internal; /// @@ -52,7 +52,7 @@ public AsyncDnsCache() internal static IO.Dns.Resolved? Convert(TResolved? answer) => answer == null ? null : - new(answer.Name, + new(answer.FirstQuestionName, TResolved.ToIpAddresses(answer, DnsProtocol.RecordType.A), TResolved.ToIpAddresses(answer, DnsProtocol.RecordType.Aaaa) ); @@ -126,10 +126,10 @@ public Cache Put(TResolved answer, long ttl) var cache = new Dictionary(_cache); - cache[answer.Name] = new CacheEntry(answer, until); + cache[answer.FirstQuestionName] = new CacheEntry(answer, until); return new Cache( - queue: new SortedSet(_queue, new ExpiryEntryComparer()) { new(answer.Name, until) }, + queue: new SortedSet(_queue, new ExpiryEntryComparer()) { new(answer.FirstQuestionName, until) }, cache: cache, clock: _clock); } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs similarity index 57% rename from src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs rename to src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index 66ac1acab..663250591 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -3,14 +3,11 @@ using System.Collections.Immutable; using System.Linq; using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; using Akka.Actor; -using Akka.Configuration; using Akka.Event; using Akka.IO; using Akka.Pattern; -using Akka.Util; +using Akka.Routing; namespace Akka.Discovery.Dns.Internal; @@ -18,84 +15,18 @@ namespace Akka.Discovery.Dns.Internal; /// DNS client actor for resolving DNS queries, including SRV records. /// This is an internal implementation for the Akka.Discovery.Dns service. /// -internal class DnsClient : UntypedActorWithStash +internal class AsyncDnsClient(AsyncDnsCache cache, Configuration.Config config, EndPoint nameserver) + : UntypedActorWithStash { - #region Messages - + #region Messages and internal types /// /// Base class for DNS questions /// - public record DnsQuestion(short Id, string Name, DnsProtocol.RecordType RecordType); - - /// - /// Question for SRV records - /// - // public sealed record SrvQuestion(short Id, string Name) : DnsQuestion(Id); - // - // /// - // /// Question for A records (IPv4) - // /// - // public sealed record Question4(short Id, string Name) : DnsQuestion(Id); - // - // /// - // /// Question for AAAA records (IPv6) - // /// - // public sealed record Question6(short Id, string Name) : DnsQuestion(Id); - // - // - // public sealed record QuestionAny(short Id, string Name) : DnsQuestion(Id); - - /// - /// DNS answer containing resource records - /// - public sealed record Answer + public record DnsQuestion(short Id, string Name, DnsProtocol.RecordType RecordType) : IConsistentHashable { - public short Id { get; } - public string Name { get; } - public ImmutableArray Records { get; } - public ImmutableArray AdditionalRecords { get; } - - public Answer(short id, string name = "", IEnumerable? records = null, IEnumerable? additionalRecords = null) - { - Id = id; - Name = name; - Records = records?.ToImmutableArray() ?? ImmutableArray.Empty; - AdditionalRecords = additionalRecords?.ToImmutableArray() ?? ImmutableArray.Empty; - } - - public static uint MinTtl(Answer answer) - { - - uint rm = UInt32.MaxValue; - uint arm = UInt32.MaxValue; - if(answer.Records.IsEmpty == false) - rm = answer.Records.Select(x => x.TimeToLive).Min(); - if(answer.AdditionalRecords.IsEmpty == false) - arm = answer.AdditionalRecords.Select(x => x.TimeToLive).Min(); - if (rm == UInt32.MaxValue && arm == UInt32.MaxValue) - return 0; - return rm < arm ? rm : arm; - } - - public static IEnumerable RecordsOfType(Answer answer, DnsProtocol.RecordType recordType) => - new[] - { - answer.Records.Where(x => x.Type == recordType), - answer.AdditionalRecords.Where(x => x.Type == recordType) - } - .SelectMany(x => x); - - public static IEnumerable ToIpAddresses(Answer answer, DnsProtocol.RecordType recordType) => - RecordsOfType(answer, recordType) - .Select(x => IPAddress.TryParse(x.Name, out var ip) - ? Option.Create(ip) : Option.None) //this might lose data if answer is hostname - .Where(x => x.HasValue) - .Select(x => x.Value); - + public object ConsistentHashKey { get; } = Name; //not sure is this it the right approach } - private static readonly Random _random = new(); - //TODO: Maybe this should be more resilient, what if we have a lot of requests at the same time? - internal static short NewQueryId() => (short)_random.Next(0, 65535); + /// /// Request to drop a pending DNS question /// @@ -109,38 +40,39 @@ public sealed record Dropped(short Id); /// /// Internal message for UDP DNS answers /// - private sealed record UdpAnswer - { - public ImmutableArray Questions { get; } - public Answer Content { get; } - - public UdpAnswer(IEnumerable questions, Answer content) - { - Questions = questions.ToImmutableArray(); - Content = content; - } - } + // private sealed record UdpAnswer + // { + // public ImmutableArray Questions { get; } + // public DnsProtocol.Message Content { get; } + // + // public UdpAnswer(IEnumerable questions, DnsProtocol.Message content) + // { + // Questions = questions.ToImmutableArray(); + // Content = content; + // } + // } /// /// Message indicating TCP connection dropped /// public static readonly object TcpDropped = new object(); - #endregion - - private readonly AsyncDnsCache _cache; - private readonly EndPoint _nameserver; - private readonly ILoggingAdapter _log; - private readonly IActorRef _tcpManager; - private readonly IActorRef _udpManager; - private readonly IStash _stash; - - // Tracks in-flight DNS requests - private Dictionary _inflightRequests = new Dictionary(); - private IActorRef _tcpDnsClient; - private IActorRef? _udpSocket; - private readonly PositiveTtl _positiveTtl; - + /// + /// Information about an in-flight DNS request + /// + private class InFlightRequest( + IActorRef replyTo, + DnsProtocol.Message message, + bool tcpRequest = false, + short? linkedRequestId = null) + { + public IActorRef ReplyTo { get; } = replyTo; + public DnsProtocol.Message Message { get; } = message; + public DnsProtocol.Message? Response { get; set; } + public bool TcpRequest { get; set; } = tcpRequest; + public short? LinkedRequestId { get; set; } = linkedRequestId; + } + abstract record PositiveTtl; record Forever : PositiveTtl @@ -155,60 +87,20 @@ record Never : PositiveTtl record TtlTimeSpan(TimeSpan TimeSpan) : PositiveTtl; - static PositiveTtl ParseTTl(Configuration.Config config) => - config.GetString("positive-ttl", "forever").ToLowerInvariant() switch - { - "forever" => Forever.Instance, - "never" => Never.Instance, - _ => new TtlTimeSpan(config.GetTimeSpan("positive-ttl")), - }; - - bool UseTtl(Answer answer, out long ttl) - { - switch (_positiveTtl) - { - case Never: - ttl = long.MinValue; - return false; - case TtlTimeSpan ts: - ttl = (long)ts.TimeSpan.TotalMilliseconds; - return true; - default: - ttl = Answer.MinTtl(answer); - return true; - - } - } - - - /// - /// Information about an in-flight DNS request - /// - private class InFlightRequest - { - public IActorRef ReplyTo { get; } - public DnsProtocol.Message Message { get; } - public bool TcpRequest { get; set; } - - public InFlightRequest(IActorRef replyTo, DnsProtocol.Message message, bool tcpRequest = false) - { - ReplyTo = replyTo; - Message = message; - TcpRequest = tcpRequest; - } - } - public DnsClient(AsyncDnsCache cache, Configuration.Config config, EndPoint nameserver) - { - _log = Context.GetLogger(); - _cache = cache; - _nameserver = nameserver; - _positiveTtl = ParseTTl(config); - - _udpManager = Akka.IO.Udp.Instance.Apply(Context.System).Manager; - _tcpManager = Akka.IO.Tcp.Manager(Context.System); - _stash = Context.CreateStash(typeof(DnsClient)); - } + #endregion + private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly IActorRef _tcpManager = Akka.IO.Tcp.Manager(Context.System); + private readonly IActorRef _udpManager = Akka.IO.Udp.Instance.Apply(Context.System).Manager; + private readonly IStash _stash = Context.CreateStash(typeof(AsyncDnsClient)); + + // Tracks in-flight DNS requests + private Dictionary _inflightRequests = new(); + private IActorRef? _tcpDnsClient; + private IActorRef? _udpSocket; + private readonly PositiveTtl _positiveTtl = ParsePositiveTTl(config); + private static readonly Random Random = new(); + protected override void PreStart() { // Bind to UDP port for DNS resolution @@ -238,6 +130,12 @@ protected override void OnReceive(object message) case DnsQuestion _: _stash.Stash(); break; + case IO.Dns.Resolve r: + _stash.Stash(); + break; + default: + Unhandled(message); + break; } } @@ -250,7 +148,7 @@ private void Ready(object message) break; case DnsQuestion question: - HandleQuestion(question.Id, question.Name, question.RecordType, Sender); + HandleQuestion(question.Id, question.Name, question.RecordType); break; case Udp.Received received: @@ -277,12 +175,72 @@ private void Ready(object message) if (msg.Flags.ResponseCode != DnsProtocol.ResponseCode.Success) { _log.Warning("DNS response failed: [{0}]", msg); - Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, msg.FirstQuestionName, ImmutableList.Empty, ImmutableList.Empty))); + } + if (_inflightRequests.TryGetValue(msg.Id, out var request)) + { + var sentQuestions = request.Message.Questions.SelectMany(WithAndWithoutTrailingDots).ToArray().ToImmutableArray(); + var answeredQuestions = msg.Questions.SelectMany(WithAndWithoutTrailingDots).ToImmutableArray(); + + if (answeredQuestions.Length == 0 || sentQuestions.Intersect(answeredQuestions).Any()) + { + // Check if this is part of a linked request that needs both A and AAAA records + if (request.LinkedRequestId.HasValue) + { + var linkedId = request.LinkedRequestId.Value; + if (_inflightRequests.TryGetValue(linkedId, out var linkedReq)) + { + + if (linkedReq.Response != null) + { + // We have both responses now, combine them + var combinedMessage = + DnsProtocol.Message.CombineResponses(linkedReq.Response, msg); + request.ReplyTo.Tell(combinedMessage); + + // Clean up + _inflightRequests.Remove(msg.Id); + _inflightRequests.Remove(linkedId); + + // Cache the combined results + if (GetCacheTtl(combinedMessage, out long combinedTtl)) + cache.Put(combinedMessage, combinedTtl); + } + else + { + request.Response = msg; + // Cache first result + if (GetCacheTtl(msg, out long combinedTtl)) + cache.Put(msg, combinedTtl); + } + + } + // We're waiting for the other response + } + else + { + // This is a regular request, not part of a resolve context + request.ReplyTo.Tell(msg); + _inflightRequests.Remove(msg.Id); + + if (GetCacheTtl(msg, out long ttl)) + cache.Put(msg, ttl); + } + } + else + { + _log.Warning("Martian DNS response for id [{0}]. Expected names [{1}], received names [{2}]. Discarding response", + msg.Id, + string.Join(", ", sentQuestions), + string.Join(", ", answeredQuestions)); + } } else { - Self.Tell(new UdpAnswer(msg.Questions, new Answer(msg.Id, msg.FirstQuestionName, msg.AnswerRecords, msg.AdditionalRecords))); + _log.Warning("Client for id [{0}] not found. Discarding response.", msg.Id); } + + + } } catch (Exception ex) @@ -291,34 +249,8 @@ private void Ready(object message) } break; - case UdpAnswer udpAnswer: - if (_inflightRequests.TryGetValue(udpAnswer.Content.Id, out var request)) - { - var sentQuestions = request.Message.Questions.SelectMany(WithAndWithoutTrailingDots).ToArray().ToImmutableArray(); - var answeredQuestions = udpAnswer.Questions.SelectMany(WithAndWithoutTrailingDots).ToImmutableArray(); - if (answeredQuestions.Length == 0 || sentQuestions.Intersect(answeredQuestions).Any()) - { - request.ReplyTo.Tell(udpAnswer.Content); - _inflightRequests.Remove(udpAnswer.Content.Id); - if(UseTtl(udpAnswer.Content, out var ttl)) - _cache.Put(udpAnswer.Content, ttl); - } - else - { - _log.Warning("Martian DNS response for id [{0}]. Expected names [{1}], received names [{2}]. Discarding response", - udpAnswer.Content.Id, - string.Join(", ", sentQuestions), - string.Join(", ", answeredQuestions)); - } - } - else - { - _log.Debug("Client for id [{0}] not found. Discarding response.", udpAnswer.Content.Id); - } - break; - - case Answer answer: + case DnsProtocol.Message answer: { if (_inflightRequests.TryGetValue(answer.Id, out var inFlight)) { @@ -369,10 +301,54 @@ private void Ready(object message) case Udp.Unbound _: Context.Stop(Self); break; + case IO.Dns.Resolve r: + HandleLegacyResolveRequest(r); + break; + + default: + Unhandled(message); + break; } } - private void HandleQuestion(short id, string name, DnsProtocol.RecordType recordType, IActorRef sender) + /// + /// Handle both A and AAAA record types for a single Resolve request + /// + private void HandleLegacyResolveRequest(IO.Dns.Resolve request) + { + // First try to get from cache + var answer = cache.GetCached(request.Name); + if (answer != null) + { + Sender.Tell(answer); + return; + } + + _log.Debug("Resolving both A and AAAA records for [{0}]", request.Name); + + // Generate unique IDs for both requests + short idA = NewQueryId(); + short idAaaa = NewQueryId(); + + SendDnsQuestion(idA, request.Name, DnsProtocol.RecordType.A, idAaaa); + SendDnsQuestion(idAaaa, request.Name, DnsProtocol.RecordType.Aaaa, idA); + } + + /// + /// Send a DNS question to the configured nameserver + /// + private void SendDnsQuestion(short id, string name, DnsProtocol.RecordType recordType, short? linkedId = null) + { + var msg = CreateMessage(name, id, recordType); + _inflightRequests[id] = new InFlightRequest(Sender, msg, false, linkedId); + _log.Debug("Message [{0}] to [{1}]: [{2}]", id, nameserver, msg); + + var data = ByteString.FromBytes(msg.Write()); + _udpSocket.Tell(new Udp.Send(data, nameserver, Udp.NoAck.Instance)); + } + + + private void HandleQuestion(short id, string name, DnsProtocol.RecordType recordType) { if (_inflightRequests.ContainsKey(id)) { @@ -381,23 +357,14 @@ private void HandleQuestion(short id, string name, DnsProtocol.RecordType record return; } - var answer = _cache.GetCached(name); + var answer = cache.GetCached(name); if (answer != null) { - sender.Tell(answer); + Sender.Tell(answer); return; } - _log.Debug("Resolving [{0}] ({1})", name, recordType); - - var msg = CreateMessage(name, id, recordType); - _inflightRequests[id] = new InFlightRequest(sender, msg); - _log.Debug("Message [{0}] to [{1}]: [{2}]", id, _nameserver, msg); - - // Send via bound UDP socket - assumes Context has been switched to Ready state with socket as Sender - - var data = ByteString.FromBytes(msg.Write()); - _udpSocket.Tell(new Udp.Send( data, _nameserver, Udp.NoAck.Instance )); + SendDnsQuestion(id, name, recordType); } private void HandleDropRequest(DropRequest dropRequest) @@ -453,7 +420,7 @@ private DnsProtocol.Message CreateMessage(string name, short id, DnsProtocol.Rec private IActorRef CreateTcpClient() { var backoffOptions = Backoff.OnFailure( - childProps: Props.Create(() => new TcpDnsClient(_tcpManager, _nameserver, Self)), + childProps: Props.Create(() => new TcpDnsClient(_tcpManager, nameserver, Self)), childName: "tcpDnsClient", minBackoff: TimeSpan.FromMilliseconds(10), maxBackoff: TimeSpan.FromSeconds(20), @@ -463,7 +430,38 @@ private IActorRef CreateTcpClient() return Context.ActorOf( BackoffSupervisor.Props(backoffOptions), "tcpDnsClientSupervisor"); - } - + } + //TODO: Maybe this should be more resilient, what if we have a lot of requests at the same time? + internal static short NewQueryId() => (short)Random.Next(0, 65535); + + static PositiveTtl ParsePositiveTTl(Configuration.Config config) => + config.GetString("positive-ttl", "forever").ToLowerInvariant() switch + { + "forever" => Forever.Instance, + "never" => Never.Instance, + _ => new TtlTimeSpan(config.GetTimeSpan("positive-ttl")), + }; + /// + /// Determine if need to cache DNS response and for how long + /// + /// Dns message response + /// Store item in cache for this amount of ms + /// false if positive-ttl = false, or true otherwise + bool GetCacheTtl(DnsProtocol.Message answer, out long ttl) + { + switch (_positiveTtl) + { + case Never: + ttl = long.MinValue; + return false; + case TtlTimeSpan ts: + ttl = (long)ts.TimeSpan.TotalMilliseconds; + return true; + default: + ttl = DnsProtocol.Message.MinTtl(answer); + return true; + + } + } } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs index 4f990ff75..8afe15695 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs @@ -60,7 +60,7 @@ IActorRef[] SpawnClients(DnsExt ext, IReadOnlyList nameservers) => .Where(x => x.HasValue) .Select(opt => Context.ActorOf( - Props.Create(typeof(DnsClient), ext.Cache, ext.Settings.ResolverConfig, opt.Value.endpoint) + Props.Create(typeof(AsyncDnsClient), ext.Cache, ext.Settings.ResolverConfig, opt.Value.endpoint) .WithDeploy(Deploy.Local) .WithDispatcher(ext.Settings.Dispatcher) , opt.Value.name) @@ -77,13 +77,6 @@ bool HandleRequest(object message) _resolver.Forward(message); return true; } - - /// - /// Translate SimpleDnsManager resolve request into DnsClient.DnsQuestion - /// - /// - /// - DnsClient.DnsQuestion Convert(IO.Dns.Resolve r) => new(DnsClient.NewQueryId(), r.Name, DnsProtocol.RecordType.Any); /// /// Handles DNS resolution requests and cache cleanup messages. @@ -94,9 +87,10 @@ protected override bool Receive(object message) { switch (message) { + // Handle standard DNS resolve requests by forwarding both A and AAAA requests case IO.Dns.Resolve r: - return HandleRequest(Convert(r)); - case DnsClient.DnsQuestion question: + return HandleRequest(r); + case AsyncDnsClient.DnsQuestion question: return HandleRequest(question); case CacheCleanup _: _cacheCleanup?.CleanUp(); diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs index 057d7b603..3c66d63b7 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs @@ -13,7 +13,7 @@ public class AsyncDnsProvider : IDnsProvider /// /// TBD /// - public Type ActorClass => typeof (DnsClient); + public Type ActorClass => typeof (AsyncDnsClient); /// /// TBD diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs index d01d1a48e..9cc7d1b61 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs @@ -2,8 +2,10 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Net; using System.Text; +using Akka.Util; namespace Akka.Discovery.Dns.Internal; @@ -146,7 +148,7 @@ public override string ToString() /// /// DNS message /// - public class Message + public record Message { public short Id { get; } public MessageFlags Flags { get; } @@ -159,9 +161,9 @@ public Message( short id, MessageFlags flags, ImmutableList questions, - ImmutableList answerRecords = null, - ImmutableList authorityRecords = null, - ImmutableList additionalRecords = null) + ImmutableList? answerRecords = null, + ImmutableList? authorityRecords = null, + ImmutableList? additionalRecords = null) { Id = id; Flags = flags ?? new MessageFlags(); @@ -427,5 +429,57 @@ private static SrvRecord ReadSrvRecord(string name, RecordClass @class, uint ttl return new SrvRecord(name, @class, ttl, priority, weight, port, target); } } + + /// + /// Find minimal TTL value + /// + /// + /// + public static uint MinTtl(Message answer) + { + uint rm = UInt32.MaxValue; + uint arm = UInt32.MaxValue; + if(answer.AnswerRecords.IsEmpty == false) + rm = answer.AnswerRecords.Select(x => x.TimeToLive).Min(); + if(answer.AdditionalRecords.IsEmpty == false) + arm = answer.AdditionalRecords.Select(x => x.TimeToLive).Min(); + if (rm == UInt32.MaxValue && arm == UInt32.MaxValue) + return 0; + return rm < arm ? rm : arm; + } + + public static IEnumerable RecordsOfType(Message answer, DnsProtocol.RecordType recordType) => + new[] + { + answer.AnswerRecords.Where(x => x.Type == recordType), + answer.AdditionalRecords.Where(x => x.Type == recordType) + } + .SelectMany(x => x); + + public static IEnumerable ToIpAddresses(Message answer, DnsProtocol.RecordType recordType) => + RecordsOfType(answer, recordType) + .Select(x => + x switch { + AaaaRecord aaaa => aaaa.Ip, + ARecord a => a.Ip, + + _ => + IPAddress.TryParse(x.Name, out var ip) + ? Option.Create(ip) + : Option.None }) //this might lose data if answer is hostname + .Where(x => x.HasValue) + .Select(x => x.Value); + + /// + /// Combine two DNS messages (typically one with A records and one with AAAA records) + /// + public static Message CombineResponses(Message message1, Message message2) => + new( + message1.Id, + message1.Flags, + message1.Questions.AddRange(message2.Questions), + message1.AnswerRecords.AddRange(message2.AnswerRecords), + message1.AuthorityRecords.AddRange(message2.AuthorityRecords), + message1.AdditionalRecords.AddRange(message2.AdditionalRecords)); } } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs index 81ac28544..bf4bc92b2 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs @@ -60,7 +60,7 @@ protected override void OnReceive(object message) case Tcp.CommandFailed failed when failed.Cmd is Tcp.Connect: _log.Warning("Failed to connect to DNS server: {0}", failed); - _parent.Tell(DnsClient.TcpDropped); + _parent.Tell(AsyncDnsClient.TcpDropped); Context.Stop(Self); break; @@ -70,7 +70,7 @@ protected override void OnReceive(object message) case Tcp.ConnectionClosed _: _log.Debug("Connection to DNS server closed"); - _parent.Tell(DnsClient.TcpDropped); + _parent.Tell(AsyncDnsClient.TcpDropped); Context.Stop(Self); break; @@ -88,7 +88,7 @@ protected override void OnReceive(object message) case Status.Failure failure: _log.Error(failure.Cause, "TCP DNS client failure"); - _parent.Tell(DnsClient.TcpDropped); + _parent.Tell(AsyncDnsClient.TcpDropped); Context.Stop(Self); break; } @@ -155,13 +155,15 @@ private void ProcessReceivedData(byte[] data) _log.Debug("Received DNS response over TCP: {0}", dnsMessage); // Get resource records based on the response code - var records = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success - ? dnsMessage.AnswerRecords : Array.Empty().ToImmutableList(); - var additionalRecs = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success - ? dnsMessage.AdditionalRecords : Array.Empty().ToImmutableList(); + // var records = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success + // ? dnsMessage.AnswerRecords : Array.Empty().ToImmutableList(); + // var additionalRecs = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success + // ? dnsMessage.AdditionalRecords : Array.Empty().ToImmutableList(); // Forward the answer to the parent - _parent.Tell(new DnsClient.Answer(dnsMessage.Id, dnsMessage.FirstQuestionName, records, additionalRecs)); + // _parent.Tell(new DnsClient.Answer(dnsMessage.Id, dnsMessage.FirstQuestionName, records, additionalRecs)); + // _parent.Tell(new DnsClient.Answer(dnsMessage.Id, dnsMessage.FirstQuestionName, records, additionalRecs)); + _parent.Tell(dnsMessage); // Remove the processed message from the buffer var remaining = _currentPosition - (_expectedLength + 2); From d90920fb00a85196a1169fe4eb07ae44e8bbb075 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Wed, 16 Jul 2025 16:53:07 -0300 Subject: [PATCH 18/37] Add IDnsProviderWithSrvLookup and refactor tests --- .../ARecordsDiscovery.cs | 70 ------------------- ...iscovery.cs => DnsServiceDiscoverySpec.cs} | 32 +++++++-- .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 11 +-- .../Internal/AsyncDnsProvider.cs | 9 ++- 4 files changed, 40 insertions(+), 82 deletions(-) delete mode 100644 src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs rename src/discovery/dns/Akka.Discovery.Dns.Tests/{SrvRecordsDiscovery.cs => DnsServiceDiscoverySpec.cs} (84%) diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs deleted file mode 100644 index 887b7e4e3..000000000 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/ARecordsDiscovery.cs +++ /dev/null @@ -1,70 +0,0 @@ -// ----------------------------------------------------------------------- -// -// Copyright (C) 2013-2025 .NET Foundation -// -// ----------------------------------------------------------------------- - -using System; -using System.Threading.Tasks; -using Akka.Actor; -using Akka.Configuration; -using FluentAssertions; -using Xunit; -using Xunit.Abstractions; - -namespace Akka.Discovery.Dns.Tests; - -public class ARecordsDiscovery(ITestOutputHelper output) : TestKit.Xunit2.TestKit( - ConfigurationFactory.ParseString(@" - akka.loglevel = DEBUG - akka.discovery { - method = akka-dns - akka-dns { - class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" - } - } - "), "dns-discovery", output) -{ - [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] - public void DnsServiceDiscoveryShouldBeLoadableViaConfig() - { - var serviceDiscovery = Discovery.Get(Sys).LoadServiceDiscovery("akka-dns"); - serviceDiscovery.Should().BeOfType(); - } - - [Theory(DisplayName = "DnsServiceDiscovery should handle A/AAAA lookup with real DNS")] - [InlineData("jabber.org", "XMPP server")] - [InlineData("matrix.org", "Matrix server")] - [InlineData("gmail.com", "Gmail IMAPS")] - public async Task DnsServiceDiscoveryShouldHandleLookup(string serviceName, string description) - { - Output.WriteLine($"Testing A/AAAA lookup for {description}: {serviceName}"); - var serviceDiscovery = new Dns.DnsServiceDiscovery((ExtendedActorSystem)Sys); - - var lookup = new Lookup(serviceName); - var resolved = await serviceDiscovery.Lookup(lookup, TimeSpan.FromSeconds(60)); - - // Skip assertion if no records found (some services might not have SRV records) - if (resolved.Addresses.Count == 0) - { - Output.WriteLine($"No SRV records found for {description}. Skipping assertions."); - return; - } - - Output.WriteLine($"Found {resolved.Addresses.Count} records for {description}"); - - // Log information for diagnostic purposes - Output.WriteLine($"Resolved targets: {resolved.Addresses.Count}"); - foreach (var addr in resolved.Addresses) - { - Output.WriteLine($" Host: {addr.Host}, Address: {addr.Address}, Port: {addr.Port}"); - } - - resolved.Addresses.Count.Should().BeGreaterThan(0, "At least one SRV record should be found"); - foreach (var address in resolved.Addresses) - { - address.Host.Should().NotBeNullOrEmpty("Host should not be empty"); - address.Port.Should().BeNull("Port should not be specified for A/AAAA lookup"); - } - } -} \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs similarity index 84% rename from src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs rename to src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs index 35ac7ff43..5904eed69 100644 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/SrvRecordsDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs @@ -15,7 +15,21 @@ namespace Akka.Discovery.Dns.Tests; -public class SrvRecordsDiscovery(ITestOutputHelper output) : BaseSrvRecordsDiscovery( + +public class DnsDiscoveryWithDefaultResolver(ITestOutputHelper output) : DnsServiceDiscoveryBaseSpec( + ConfigurationFactory.ParseString(@" + akka.loglevel = DEBUG + akka.discovery { + method = akka-dns + akka-dns { + class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" + } + } + "), output) +{ + +} +public class DnsServiceDiscoveryWithDefaultCache(ITestOutputHelper output) : DnsServiceDiscoveryBaseSpec( ConfigurationFactory.ParseString(@" akka.loglevel = DEBUG akka.discovery { @@ -39,7 +53,7 @@ class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" } -public class SrvNoCacheRecordsDiscovery(ITestOutputHelper output) : BaseSrvRecordsDiscovery( +public class DnsServiceDiscoveryWithoutCache(ITestOutputHelper output) : DnsServiceDiscoveryBaseSpec( ConfigurationFactory.ParseString(@" akka.loglevel = DEBUG akka.discovery { @@ -63,8 +77,7 @@ class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" } - -public class SrvTimeRecordsDiscovery(ITestOutputHelper output) : BaseSrvRecordsDiscovery( +public class DnsServiceDiscoveryWithFixedCacheTime(ITestOutputHelper output) : DnsServiceDiscoveryBaseSpec( ConfigurationFactory.ParseString(@" akka.loglevel = DEBUG akka.discovery { @@ -87,7 +100,7 @@ class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" { } -public abstract class BaseSrvRecordsDiscovery(Configuration.Config config , ITestOutputHelper output) : TestKit.Xunit2.TestKit(config, "dns-discovery", output) +public abstract class DnsServiceDiscoveryBaseSpec(Configuration.Config config , ITestOutputHelper output) : TestKit.Xunit2.TestKit(config, "dns-discovery", output) { [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] public void DnsServiceDiscoveryShouldBeLoadableViaConfig() @@ -121,7 +134,14 @@ public async Task DnsServiceDiscoveryShouldHandleLookup(string serviceName, stri foreach (var address in resolved.Addresses) { address.Host.Should().NotBeNullOrEmpty("Host should not be empty"); - address.Port.Should().BeGreaterThan(0, "Port should be specified for SRV lookup"); + if (serviceDiscovery.CanLookupSrv) + { + address.Port.Should().BeGreaterThan(0, "Port should be specified for SRV lookup"); + } + else + { + address.Port.Should().BeNull( "Port should not be specified if resolver doesn't support SRV lookup"); + } } } diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index 1ca8cb4fc..3da19d375 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -18,16 +18,17 @@ public class DnsServiceDiscovery : ServiceDiscovery { private readonly ILoggingAdapter _log; private readonly IActorRef _dns; - private readonly ExtendedActorSystem _system; + internal bool CanLookupSrv { get; } public DnsServiceDiscovery(ExtendedActorSystem system) { - _system = system; _log = Logging.GetLogger(system, this); - _dns = Akka.IO.Dns.Instance.CreateExtension(_system).Manager; + var dns = Akka.IO.Dns.Instance.CreateExtension(system); + _dns = dns.Manager; + CanLookupSrv = dns.Provider is IDnsProviderWithSrvLookup; } - + /// /// Cleans an IP string by removing leading '/' if present. /// @@ -36,7 +37,7 @@ private static string CleanIpString(string ipString) => public override async Task Lookup(Lookup lookup, TimeSpan resolveTimeout) { - if (!string.IsNullOrWhiteSpace(lookup.PortName) && !string.IsNullOrWhiteSpace(lookup.Protocol)) + if (CanLookupSrv && !string.IsNullOrWhiteSpace(lookup.PortName) && !string.IsNullOrWhiteSpace(lookup.Protocol)) { return await LookupSrv(lookup, resolveTimeout); } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs index 3c66d63b7..5c36ab3ad 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs @@ -3,7 +3,14 @@ namespace Akka.Discovery.Dns.Internal; -public class AsyncDnsProvider : IDnsProvider +/// +/// +/// +public interface IDnsProviderWithSrvLookup : IDnsProvider +{ + +} +public class AsyncDnsProvider : IDnsProviderWithSrvLookup { /// /// TBD From 59b0b2022b67e329c5412a62ee99df768ec9e885 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Thu, 17 Jul 2025 09:15:12 -0300 Subject: [PATCH 19/37] test TCP fallback client --- nuget.config | 48 ++++++ .../DnsServiceDiscoverySpec.cs | 68 ++++++++- .../Internal/AsyncDnsClient.cs | 142 ++++++++---------- .../Internal/AsyncDnsManager.cs | 2 +- .../Internal/AsyncDnsProvider.cs | 4 +- .../Internal/TcpDnsClient.cs | 41 ++--- 6 files changed, 193 insertions(+), 112 deletions(-) create mode 100644 nuget.config diff --git a/nuget.config b/nuget.config new file mode 100644 index 000000000..d7f19338f --- /dev/null +++ b/nuget.config @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs index 5904eed69..f7c030cdc 100644 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs @@ -6,9 +6,12 @@ using System; using System.Linq; +using System.Net; using System.Threading.Tasks; using Akka.Actor; using Akka.Configuration; +using Akka.Discovery.Dns.Internal; +using Akka.IO; using FluentAssertions; using Xunit; using Xunit.Abstractions; @@ -40,7 +43,6 @@ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" } akka.io.dns.resolver = async-dns akka.io.dns.async-dns { - class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" provider-object = ""Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns"" nameservers = [ ""1dot1dot1dot1.cloudflare-dns.com"", @@ -64,7 +66,6 @@ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" } akka.io.dns.resolver = async-dns akka.io.dns.async-dns { - class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" provider-object = ""Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns"" nameservers = [ ""1dot1dot1dot1.cloudflare-dns.com"", @@ -88,7 +89,6 @@ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" } akka.io.dns.resolver = async-dns akka.io.dns.async-dns { - class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" provider-object = ""Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns"" nameservers = [ ""1dot1dot1dot1.cloudflare-dns.com"", @@ -100,6 +100,68 @@ class = ""Akka.Discovery.Dns.Internal.DnsClient, Akka.Discovery.Dns"" { } + + +public class DnsServiceDiscoveryWithTcpFallback(ITestOutputHelper output) : DnsServiceDiscoveryBaseSpec( + ConfigurationFactory.ParseString($$""" + { + akka.loglevel = DEBUG + akka.discovery { + method = akka-dns + akka-dns { + class = "Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns" + } + } + akka.io.dns.resolver = async-dns + akka.io.dns.async-dns { + provider-object = "{{typeof(ForceTcpDnsProvider).FullName }}, {{typeof(ForceTcpDnsProvider).Assembly.GetName().Name}}" + nameservers = [ + "1dot1dot1dot1.cloudflare-dns.com", + "1.1.1.1" ] + } + } + """), output) +{ + public class ForceTcpDnsProvider : AsyncDnsProvider + { + public override Type ActorClass { get; } = typeof(ForceTcpDnsClient); + } + internal class ForceTcpDnsClient(AsyncDnsCache cache, Configuration.Config config, EndPoint nameserver) : AsyncDnsClient(cache, config, nameserver) + { + // Override to force TCP mode by simulating truncation + protected override void Ready(object message) + { + if (message is Udp.Received received) + { + // Get the data as a byte array + var data = received.Data.ToArray(); + + // DNS header: bytes 2-3 contain the flags field + // TC flag is bit 9 (0-based, counting from the right) or 0x0200 + // We need to set this bit to indicate truncation + if (data.Length >= 4) // Make sure we have at least the header + { + // Set the TC bit in the flags field (network byte order) + data[2] |= 0x02; // Set bit 1 in byte 2 (the TC flag) + + // Create a new received message with the modified data + var modifiedReceived = new Udp.Received( + ByteString.FromBytes(data), + received.Sender); + + // Process with the modified data + base.Ready(modifiedReceived); + return; + } + } + + // For all other messages, use normal behavior + base.Ready(message); + + } + } +} + public abstract class DnsServiceDiscoveryBaseSpec(Configuration.Config config , ITestOutputHelper output) : TestKit.Xunit2.TestKit(config, "dns-discovery", output) { [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index 663250591..868c0212b 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -139,7 +139,7 @@ protected override void OnReceive(object message) } } - private void Ready(object message) + protected virtual void Ready(object message) { switch (message) { @@ -172,97 +172,87 @@ private void Ready(object message) } else { - if (msg.Flags.ResponseCode != DnsProtocol.ResponseCode.Success) - { - _log.Warning("DNS response failed: [{0}]", msg); - } - if (_inflightRequests.TryGetValue(msg.Id, out var request)) - { - var sentQuestions = request.Message.Questions.SelectMany(WithAndWithoutTrailingDots).ToArray().ToImmutableArray(); - var answeredQuestions = msg.Questions.SelectMany(WithAndWithoutTrailingDots).ToImmutableArray(); + Self.Tell(msg); + } + } + catch (Exception ex) + { + _log.Error(ex, "Error processing DNS response"); + } + break; + - if (answeredQuestions.Length == 0 || sentQuestions.Intersect(answeredQuestions).Any()) + case DnsProtocol.Message msg: + { + if (msg.Flags.ResponseCode != DnsProtocol.ResponseCode.Success) + { + _log.Warning("DNS response failed: [{0}]", msg); + } + + if (_inflightRequests.TryGetValue(msg.Id, out var request)) + { + var sentQuestions = request.Message.Questions.SelectMany(WithAndWithoutTrailingDots).ToArray() + .ToImmutableArray(); + var answeredQuestions = msg.Questions.SelectMany(WithAndWithoutTrailingDots).ToImmutableArray(); + + if (answeredQuestions.Length == 0 || sentQuestions.Intersect(answeredQuestions).Any()) + { + // Check if this is part of a linked request that needs both A and AAAA records + if (request.LinkedRequestId.HasValue) + { + var linkedId = request.LinkedRequestId.Value; + if (_inflightRequests.TryGetValue(linkedId, out var linkedReq)) { - // Check if this is part of a linked request that needs both A and AAAA records - if (request.LinkedRequestId.HasValue) + + if (linkedReq.Response != null) { - var linkedId = request.LinkedRequestId.Value; - if (_inflightRequests.TryGetValue(linkedId, out var linkedReq)) - { - - if (linkedReq.Response != null) - { - // We have both responses now, combine them - var combinedMessage = - DnsProtocol.Message.CombineResponses(linkedReq.Response, msg); - request.ReplyTo.Tell(combinedMessage); - - // Clean up - _inflightRequests.Remove(msg.Id); - _inflightRequests.Remove(linkedId); - - // Cache the combined results - if (GetCacheTtl(combinedMessage, out long combinedTtl)) - cache.Put(combinedMessage, combinedTtl); - } - else - { - request.Response = msg; - // Cache first result - if (GetCacheTtl(msg, out long combinedTtl)) - cache.Put(msg, combinedTtl); - } - - } - // We're waiting for the other response + // We have both responses now, combine them + var combinedMessage = + DnsProtocol.Message.CombineResponses(linkedReq.Response, msg); + request.ReplyTo.Tell(combinedMessage); + + // Clean up + _inflightRequests.Remove(msg.Id); + _inflightRequests.Remove(linkedId); + + // Cache the combined results + if (GetCacheTtl(combinedMessage, out long combinedTtl)) + cache.Put(combinedMessage, combinedTtl); } else { - // This is a regular request, not part of a resolve context - request.ReplyTo.Tell(msg); - _inflightRequests.Remove(msg.Id); - - if (GetCacheTtl(msg, out long ttl)) - cache.Put(msg, ttl); + request.Response = msg; + // Cache first result + if (GetCacheTtl(msg, out long combinedTtl)) + cache.Put(msg, combinedTtl); } + } - else - { - _log.Warning("Martian DNS response for id [{0}]. Expected names [{1}], received names [{2}]. Discarding response", - msg.Id, - string.Join(", ", sentQuestions), - string.Join(", ", answeredQuestions)); - } + // We're waiting for the other response } else { - _log.Warning("Client for id [{0}] not found. Discarding response.", msg.Id); + // This is a regular request, not part of a resolve context + request.ReplyTo.Tell(msg); + _inflightRequests.Remove(msg.Id); + + if (GetCacheTtl(msg, out long ttl)) + cache.Put(msg, ttl); } - - - } - } - catch (Exception ex) - { - _log.Error(ex, "Error processing DNS response"); - } - break; - - - case DnsProtocol.Message answer: - { - if (_inflightRequests.TryGetValue(answer.Id, out var inFlight)) - { - inFlight.ReplyTo.Tell(answer); - - _inflightRequests.Remove(answer.Id); + else + { + _log.Warning( + "Martian DNS response for id [{0}]. Expected names [{1}], received names [{2}]. Discarding response", + msg.Id, + string.Join(", ", sentQuestions), + string.Join(", ", answeredQuestions)); + } } else { - _log.Debug("Client for id [{0}] not found. Discarding response.", answer.Id); + _log.Warning("Client for id [{0}] not found. Discarding response.", msg.Id); } - break; } @@ -398,7 +388,7 @@ private void HandleDropRequest(DropRequest dropRequest) } } - private DnsProtocol.Message CreateMessage(string name, short id, DnsProtocol.RecordType recordType) + internal virtual DnsProtocol.Message CreateMessage(string name, short id, DnsProtocol.RecordType recordType) { var question = new DnsProtocol.Question(name, recordType, DnsProtocol.RecordClass.In); return new DnsProtocol.Message( diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs index 8afe15695..23fede746 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs @@ -60,7 +60,7 @@ IActorRef[] SpawnClients(DnsExt ext, IReadOnlyList nameservers) => .Where(x => x.HasValue) .Select(opt => Context.ActorOf( - Props.Create(typeof(AsyncDnsClient), ext.Cache, ext.Settings.ResolverConfig, opt.Value.endpoint) + Props.Create(ext.Provider.ActorClass, ext.Cache, ext.Settings.ResolverConfig, opt.Value.endpoint) .WithDeploy(Deploy.Local) .WithDispatcher(ext.Settings.Dispatcher) , opt.Value.name) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs index 5c36ab3ad..0b7010863 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs @@ -20,10 +20,10 @@ public class AsyncDnsProvider : IDnsProviderWithSrvLookup /// /// TBD /// - public Type ActorClass => typeof (AsyncDnsClient); + public virtual Type ActorClass => typeof (AsyncDnsClient); /// /// TBD /// - public Type ManagerClass => typeof (AsyncDnsManager); + public virtual Type ManagerClass => typeof (AsyncDnsManager); } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs index bf4bc92b2..b3f412d63 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs @@ -13,14 +13,12 @@ namespace Akka.Discovery.Dns.Internal; /// TCP DNS client actor for handling DNS requests over TCP. /// Used as a fallback when UDP responses are truncated. /// -internal class TcpDnsClient : UntypedActor +internal class TcpDnsClient(IActorRef tcpManager, EndPoint nameserver, IActorRef parent) + : UntypedActor { - private readonly EndPoint _nameserver; - private readonly IActorRef _parent; - private readonly ILoggingAdapter _log; - private readonly IActorRef _tcpManager; + private readonly ILoggingAdapter _log = Context.GetLogger(); - private IActorRef _connection; + private IActorRef? _connection; private byte[] _readBuffer = new byte[2048]; // Buffer for reading DNS responses private int _expectedLength = -1; // Expected length of current DNS response private int _currentPosition = 0; // Current position in the buffer @@ -28,18 +26,10 @@ internal class TcpDnsClient : UntypedActor // Pending requests that need to be sent once connection is established private Queue _pendingRequests = new Queue(); - public TcpDnsClient(IActorRef tcpManager, EndPoint nameserver, IActorRef parent) - { - _tcpManager = tcpManager; - _nameserver = nameserver; - _parent = parent; - _log = Context.GetLogger(); - } - protected override void PreStart() { // Connect to the DNS server over TCP - _tcpManager.Tell(new Tcp.Connect(_nameserver)); + tcpManager.Tell(new Tcp.Connect(nameserver)); } protected override void OnReceive(object message) @@ -60,7 +50,7 @@ protected override void OnReceive(object message) case Tcp.CommandFailed failed when failed.Cmd is Tcp.Connect: _log.Warning("Failed to connect to DNS server: {0}", failed); - _parent.Tell(AsyncDnsClient.TcpDropped); + parent.Tell(AsyncDnsClient.TcpDropped); Context.Stop(Self); break; @@ -70,7 +60,7 @@ protected override void OnReceive(object message) case Tcp.ConnectionClosed _: _log.Debug("Connection to DNS server closed"); - _parent.Tell(AsyncDnsClient.TcpDropped); + parent.Tell(AsyncDnsClient.TcpDropped); Context.Stop(Self); break; @@ -88,7 +78,7 @@ protected override void OnReceive(object message) case Status.Failure failure: _log.Error(failure.Cause, "TCP DNS client failure"); - _parent.Tell(AsyncDnsClient.TcpDropped); + parent.Tell(AsyncDnsClient.TcpDropped); Context.Stop(Self); break; } @@ -115,7 +105,7 @@ private void SendMessage(DnsProtocol.Message message) catch (Exception ex) { _log.Error(ex, "Failed to send DNS message over TCP"); - _parent.Tell(new Status.Failure(ex)); + parent.Tell(new Status.Failure(ex)); } } @@ -153,17 +143,8 @@ private void ProcessReceivedData(byte[] data) // Parse and process the message var dnsMessage = DnsProtocol.Message.Parse(messageData); _log.Debug("Received DNS response over TCP: {0}", dnsMessage); - - // Get resource records based on the response code - // var records = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success - // ? dnsMessage.AnswerRecords : Array.Empty().ToImmutableList(); - // var additionalRecs = dnsMessage.Flags.ResponseCode == DnsProtocol.ResponseCode.Success - // ? dnsMessage.AdditionalRecords : Array.Empty().ToImmutableList(); - - // Forward the answer to the parent - // _parent.Tell(new DnsClient.Answer(dnsMessage.Id, dnsMessage.FirstQuestionName, records, additionalRecs)); - // _parent.Tell(new DnsClient.Answer(dnsMessage.Id, dnsMessage.FirstQuestionName, records, additionalRecs)); - _parent.Tell(dnsMessage); + + parent.Tell(dnsMessage); // Remove the processed message from the buffer var remaining = _currentPosition - (_expectedLength + 2); From 23763fa7df7d78e22eff5be349169ba605fd974f Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Thu, 17 Jul 2025 09:34:38 -0300 Subject: [PATCH 20/37] Add missing options to hosting extension --- .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 43 ++++++++++++++++--- .../Internal/AsyncDnsClient.cs | 27 ++---------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index ce792554e..02240feb4 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -109,6 +109,35 @@ public static DnsDiscoverySettings Create(Akka.Configuration.Config config) } } +public abstract record PositiveTtl +{ + + public static PositiveTtl ParseFromConfig(Configuration.Config config) => + config.GetString("positive-ttl", "forever").ToLowerInvariant() switch + { + "forever" => Forever.Instance, + "never" => Never.Instance, + _ => new TtlTimeSpan(config.GetTimeSpan("positive-ttl")), + }; + public record Forever : PositiveTtl + { + public static readonly Forever Instance = new(); + public override string ToString() => "forever"; + } + + public record Never : PositiveTtl + { + public static readonly Never Instance = new(); + public override string ToString() => "never"; + + } + + public record TtlTimeSpan(TimeSpan TimeSpan) : PositiveTtl + { + public override string ToString() => $"{TimeSpan.TotalSeconds}s"; + } +} + public class AsyncDnsResolverOptions : IHoconOption { @@ -117,6 +146,9 @@ public class AsyncDnsResolverOptions : IHoconOption public const string NameserversPath = "nameservers"; public List Nameservers { get; set; } = [ "127.0.0.1:53" ]; + public TimeSpan CacheCleanupInterval { get; set; } = TimeSpan.FromSeconds(120); + public PositiveTtl PositiveTTl { get; set; } = PositiveTtl.Forever.Instance; + /// /// Renders HOCON configuration based on current settings. /// @@ -127,8 +159,7 @@ private string ToHocon() { var sb = new StringBuilder(); sb.AppendLine($"{FullPath(ConfigPath)} {{"); - sb.AppendLine($" class = \"{Class.FullName}, {Class.Assembly.GetName().Name}\","); - sb.AppendLine($" provider-object = \"{Provider.FullName}, {Provider.Assembly.GetName().Name}\","); + sb.AppendLine($" provider-object = \"{Class.FullName}, {Class.Assembly.GetName().Name}\","); sb.Append($" {NameserversPath} = ["); var c = Nameservers.Count; for (int i = 0; i < c; i++) @@ -137,7 +168,10 @@ private string ToHocon() if (i < c - 1) sb.Append(", "); } - sb.AppendLine("]"); + sb.AppendLine("],"); + sb.AppendLine($" cache-cleanup-interval = {CacheCleanupInterval.TotalSeconds}s,"); + sb.AppendLine($" positive-ttl = {PositiveTTl.ToString()},"); + sb.AppendLine("}"); return sb.ToString(); @@ -151,6 +185,5 @@ public void Apply(AkkaConfigurationBuilder builder, Setup? setup = null) builder.AddHocon(ToHocon(), HoconAddMode.Prepend); } public string ConfigPath => DefaultPath; - public Type Class => typeof(AsyncDnsClient); - public Type Provider => typeof(AsyncDnsProvider); + public Type Class => typeof(AsyncDnsProvider); } \ No newline at end of file diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index 868c0212b..2ee8b7db8 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -73,19 +73,6 @@ private class InFlightRequest( public short? LinkedRequestId { get; set; } = linkedRequestId; } - abstract record PositiveTtl; - - record Forever : PositiveTtl - { - public static readonly Forever Instance = new Forever(); - } - - record Never : PositiveTtl - { - public static readonly Never Instance = new Never(); - } - - record TtlTimeSpan(TimeSpan TimeSpan) : PositiveTtl; #endregion @@ -98,7 +85,7 @@ record TtlTimeSpan(TimeSpan TimeSpan) : PositiveTtl; private Dictionary _inflightRequests = new(); private IActorRef? _tcpDnsClient; private IActorRef? _udpSocket; - private readonly PositiveTtl _positiveTtl = ParsePositiveTTl(config); + private readonly PositiveTtl _positiveTtl = PositiveTtl.ParseFromConfig(config); private static readonly Random Random = new(); protected override void PreStart() @@ -423,14 +410,6 @@ private IActorRef CreateTcpClient() } //TODO: Maybe this should be more resilient, what if we have a lot of requests at the same time? internal static short NewQueryId() => (short)Random.Next(0, 65535); - - static PositiveTtl ParsePositiveTTl(Configuration.Config config) => - config.GetString("positive-ttl", "forever").ToLowerInvariant() switch - { - "forever" => Forever.Instance, - "never" => Never.Instance, - _ => new TtlTimeSpan(config.GetTimeSpan("positive-ttl")), - }; /// /// Determine if need to cache DNS response and for how long @@ -442,10 +421,10 @@ bool GetCacheTtl(DnsProtocol.Message answer, out long ttl) { switch (_positiveTtl) { - case Never: + case PositiveTtl.Never: ttl = long.MinValue; return false; - case TtlTimeSpan ts: + case PositiveTtl.TtlTimeSpan ts: ttl = (long)ts.TimeSpan.TotalMilliseconds; return true; default: From 152962be0685086a2ccddfa48ebc621338623e36 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Thu, 17 Jul 2025 09:40:18 -0300 Subject: [PATCH 21/37] remove leaked local dev settings --- nuget.config | 48 ------------------------------------------------ 1 file changed, 48 deletions(-) delete mode 100644 nuget.config diff --git a/nuget.config b/nuget.config deleted file mode 100644 index d7f19338f..000000000 --- a/nuget.config +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 24b66416ca806270928edb6d82b4c5d00110c21d Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Thu, 17 Jul 2025 14:47:58 -0300 Subject: [PATCH 22/37] Upgrade to Akka.NET v1.5.46 --- Directory.Build.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 68802ff91..e620a4674 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -28,8 +28,8 @@ 7.0.0 17.13.0 1.8.4 - 1.5.45 - 1.5.45 + 1.5.46 + 1.5.46 1.4.4 4.0.26 13.0.26 From 9d5fb6430115392373740e689e24e3f0275cec56 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Thu, 17 Jul 2025 16:05:51 -0300 Subject: [PATCH 23/37] skip check of AAAA records on Windows with default resolver --- .../DnsServiceDiscoverySpec.cs | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs index f7c030cdc..57dd162b1 100644 --- a/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs +++ b/src/discovery/dns/Akka.Discovery.Dns.Tests/DnsServiceDiscoverySpec.cs @@ -28,9 +28,11 @@ public class DnsDiscoveryWithDefaultResolver(ITestOutputHelper output) : DnsServ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" } } - "), output) + "), "DnsDiscoveryWithDefaultResolver", output) { - + // skip check of AAAA records on Windows with default resolver + internal override bool DoNotExpectAAAARecordsFromInetResolver { get; } + = Environment.OSVersion.Platform != PlatformID.Unix; } public class DnsServiceDiscoveryWithDefaultCache(ITestOutputHelper output) : DnsServiceDiscoveryBaseSpec( ConfigurationFactory.ParseString(@" @@ -49,7 +51,7 @@ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" ""1.1.1.1"" ] } - "), output) + "), "DnsServiceDiscoveryWithDefaultCache", output) { } @@ -73,7 +75,7 @@ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" positive-ttl = never } - "), output) + "), "DnsServiceDiscoveryWithoutCache", output) { } @@ -96,7 +98,7 @@ class = ""Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns"" positive-ttl = 10s } - "), output) + "), "DnsServiceDiscoveryWithFixedCacheTime", output) { } @@ -120,7 +122,7 @@ class = "Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns" "1.1.1.1" ] } } - """), output) + """), "DnsServiceDiscoveryWithTcpFallback", output) { public class ForceTcpDnsProvider : AsyncDnsProvider { @@ -162,8 +164,10 @@ protected override void Ready(object message) } } -public abstract class DnsServiceDiscoveryBaseSpec(Configuration.Config config , ITestOutputHelper output) : TestKit.Xunit2.TestKit(config, "dns-discovery", output) +public abstract class DnsServiceDiscoveryBaseSpec(Configuration.Config config , string actorSystemName, ITestOutputHelper output) : TestKit.Xunit2.TestKit(config, actorSystemName, output) { + internal virtual bool DoNotExpectAAAARecordsFromInetResolver { get; } = false; + [Fact(DisplayName = "DnsServiceDiscovery should be loadable via config")] public void DnsServiceDiscoveryShouldBeLoadableViaConfig() { @@ -229,11 +233,15 @@ public async Task DnsServiceDiscoveryShouldHandleLookupOfA(string serviceName, { Output.WriteLine($" Host: {addr.Host}, Address: {addr.Address}, Port: {addr.Port}"); } - - resolved.Addresses - .Sum(x => x.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 1 : 0) - .Should().BeGreaterThan(0, "At least one IPv6 record should be found"); + // skip this on windows for inet-resolver as it doesn't return AAAA + if (!DoNotExpectAAAARecordsFromInetResolver) + { + resolved.Addresses + .Sum(x => x.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? 1 : 0) + .Should().BeGreaterThan(0, "At least one IPv6 record should be found"); + } + resolved.Addresses .Sum(x => x.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 1 : 0) .Should().BeGreaterThan(0, "At least one IPv4 record should be found"); From 6a864445981ebdcfa2e7bd7d519f4e1842ea5111 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Thu, 17 Jul 2025 16:23:18 -0300 Subject: [PATCH 24/37] update docs --- README.md | 1 + .../dns/Akka.Discovery.Dns/README.md | 62 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8da764c06..4c3b4fca5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ These tools aims to help with cluster management in various dynamic environments * [`Akka.Discovery.AwsApi`](/src/discovery/aws/Akka.Discovery.AwsApi) - Akka.Cluster bootstrapping discovery service using EC2, ECS, and the AWS API. You can read more in the documentation [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/discovery/aws/Akka.Discovery.AwsApi/README.md). * [`Akka.Discovery.KubernetesApi`](/src/discovery/kubernetes/Akka.Discovery.KubernetesApi) - Akka.Cluster bootstrapping discovery service using Kubernetes API. You can read more in the documentation [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/discovery/kubernetes/Akka.Discovery.KubernetesApi/README.md). * [`Akka.Discovery.Azure`](src/discovery/azure/Akka.Discovery.Azure) - Akka.Cluster bootstrapping discovery service using Azure Table Storage. You can read more in the documentation [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/discovery/azure/Akka.Discovery.Azure/README.md). +* [`Akka.Discovery.Dns`](src/discovery/dns/Akka.Discovery.Dns) - Akka.Cluster bootstrapping discovery service using DNS. You can read more in the documentation [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/discovery/dns/Akka.Discovery.Dns/README.md). * [`Akka.Coordination.KubernetesApi`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/coordination/kubernetes/Akka.Coordination.KubernetesApi) - provides a lease-based distributed lock mechanism backed by [Kubernetes CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/) for [Akka.NET Split Brain Resolver](https://getakka.net/articles/clustering/split-brain-resolver.html), [Akka.Cluster.Sharding](https://getakka.net/articles/clustering/cluster-sharding.html), and [Akka.Cluster.Singleton](https://getakka.net/articles/clustering/cluster-singleton.html). Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/coordination/kubernetes/Akka.Coordination.KubernetesApi/README.md) * [`Akka.Coordination.Azure`](https://github.com/akkadotnet/Akka.Management/tree/dev/src/coordination/azure/Akka.Coordination.Azure) - provides a lease-based distributed lock mechanism backed by [Microsoft Azure Blob Storage](https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-overview) for [Akka.NET Split Brain Resolver](https://getakka.net/articles/clustering/split-brain-resolver.html), [Akka.Cluster.Sharding](https://getakka.net/articles/clustering/cluster-sharding.html), and [Akka.Cluster.Singleton](https://getakka.net/articles/clustering/cluster-singleton.html). Documentation can be read [here](https://github.com/akkadotnet/Akka.Management/blob/dev/src/coordination/azure/Akka.Coordination.Azure/README.md) diff --git a/src/discovery/dns/Akka.Discovery.Dns/README.md b/src/discovery/dns/Akka.Discovery.Dns/README.md index 119ba4d2c..8e8a25ec5 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/README.md +++ b/src/discovery/dns/Akka.Discovery.Dns/README.md @@ -1,5 +1,63 @@ # Discovery via DNS -This module provides a service discovery mechanism that uses DNS to locate services. +This module provides a service discovery mechanism that uses DNS to locate services. It supports both A/AAAA record resolution and SRV record resolution for service discovery. -# TODO \ No newline at end of file +DNS discovery can resolve services using: +- **A/AAAA records**: Standard DNS address records that return IP addresses +- **SRV records**: Service records that provide both IP addresses and port information + +## Enabling DNS Discovery Using Akka.Hosting + +To enable DNS discovery using Akka.Hosting, you can use the `WithDnsDiscovery` extension method: + +```csharp +builder.WithDnsDiscovery(); +``` + +For SRV record resolution, you also need to configure the async DNS resolver with custom nameservers: + +```csharp +builder.WithAsyncDnsResolver(opt => +{ + opt.Nameservers = [ // at least one nameserver is required + "127.0.0.1:1053", // IPv4 with port + "[fd::aa:aa:aa:aa]", // IPv6 without port (default is 53) + "1dot1dot1dot1.cloudflare-dns.com" // Hostname + ]; +}); +``` + +Note: The default port for DNS is 53, so if you don't specify a port, it will use 53. DNS hostname would be resolved using the default System.Net.Dns.GetHostAddresses method. + +## Enabling DNS Discovery Using HOCON Configuration + +To enable DNS discovery via HOCON, you will need to modify your HOCON configuration: + +```text +akka.discovery.method = dns +``` + +Below, you'll find the default configuration. It can be customized by changing these values in your HOCON configuration: + +``` +akka.discovery.dns { + class = "Akka.Discovery.Dns.DnsServiceDiscovery, Akka.Discovery.Dns" +} +``` + +To enable async-dns resolver, you need to configure it in your HOCON configuration: + +```text +akka.io.dns.resolver = async-dns +akka.io.dns.async-dns { + provider-object = "Akka.Discovery.Dns.Internal.AsyncDnsProvider, Akka.Discovery.Dns" + nameservers = [ "127.0.0.1:53"; "my-dns.example.com"; "[::1]:53" ] + cache-cleanup-interval = 120s + positive-ttl = forever + } +``` + +__NOTES__ + +- `async-dns` resolver doesn't use System.Net.Dns.GetHostAddresses method to resolve DNS hostname, therefore it has no way to determine DNS nameserver from the runtime environment. You need to configure it explicitly. +- `async-dns` resolver is required to resolve SRV records. From bdeb2a276e6b5329058c0eff20ce172e3b198904 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Fri, 18 Jul 2025 07:33:36 -0300 Subject: [PATCH 25/37] fix compiler warnings and slight cache refactor --- .../Internal/AsyncDnsCache.cs | 68 ++++++------------- .../Internal/AsyncDnsClient.cs | 15 ---- 2 files changed, 22 insertions(+), 61 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs index 52db92081..893b6bc08 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs @@ -29,6 +29,7 @@ internal interface IPeriodicCacheCleanup /// A simple in-memory DNS cache that stores resolved DNS entries with TTL-based expiration. /// This class is a copy of SimpleDnsCache adjusted for DnsClient.Answer use /// +// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global public class AsyncDnsCache : DnsBase, IPeriodicCacheCleanup { private readonly AtomicReference _cache; @@ -99,89 +100,61 @@ public void CleanUp() CleanUp(); } - class Cache + class Cache(SortedSet queue, Dictionary cache, Func clock) { - private readonly SortedSet _queue; - private readonly Dictionary _cache; - private readonly Func _clock; private readonly object _queueCleanupLock = new(); - public Cache(SortedSet queue, Dictionary cache, Func clock) - { - _queue = queue; - _cache = cache; - _clock = clock; - } - public TResolved? Get(string name) { - if (_cache.TryGetValue(name, out var e) && e.IsValid(_clock())) + if (cache.TryGetValue(name, out var e) && e.IsValid(clock())) return e.Answer; return null; } public Cache Put(TResolved answer, long ttl) { - var until = _clock() + ttl; + var until = clock() + ttl; - var cache = new Dictionary(_cache); + var cache1 = new Dictionary(cache); - cache[answer.FirstQuestionName] = new CacheEntry(answer, until); + cache1[answer.FirstQuestionName] = new CacheEntry(answer, until); return new Cache( - queue: new SortedSet(_queue, new ExpiryEntryComparer()) { new(answer.FirstQuestionName, until) }, - cache: cache, - clock: _clock); + queue: new SortedSet(queue, new ExpiryEntryComparer()) { new(answer.FirstQuestionName, until) }, + cache: cache1, + clock: clock); } public Cache Cleanup() { lock (_queueCleanupLock) { - var now = _clock(); - while (_queue.Any() && !_queue.First().IsValid(now)) + var now = clock(); + while (queue.Any() && !queue.First().IsValid(now)) { - var minEntry = _queue.First(); + var minEntry = queue.First(); var name = minEntry.Name; - _queue.Remove(minEntry); + queue.Remove(minEntry); - if (_cache.TryGetValue(name, out var cacheEntry) && !cacheEntry.IsValid(now)) - _cache.Remove(name); + if (cache.TryGetValue(name, out var cacheEntry) && !cacheEntry.IsValid(now)) + cache.Remove(name); } } - return new Cache(new SortedSet(), new Dictionary(_cache), _clock); + return new Cache(new SortedSet(), new Dictionary(cache), clock); } } - class CacheEntry + record CacheEntry(TResolved Answer, long Until) { - public CacheEntry(TResolved answer, long until) - { - Answer = answer; - Until = until; - } - - public TResolved Answer { get; private set; } - public long Until { get; private set; } - public bool IsValid(long clock) { return clock < Until; } } - class ExpiryEntry + record ExpiryEntry(string Name, long Until) { - public ExpiryEntry(string name, long until) - { - Name = name; - Until = until; - } - - public string Name { get; private set; } - public long Until { get; private set; } - public bool IsValid(long clock) { return clock < Until; @@ -191,8 +164,11 @@ public bool IsValid(long clock) class ExpiryEntryComparer : IComparer { /// - public int Compare(ExpiryEntry x, ExpiryEntry y) + public int Compare(ExpiryEntry? x, ExpiryEntry? y) { + if(x == null && y == null) return 0; + if(y == null) return 1; + if(x == null) return -1; return x.Until.CompareTo(y.Until); } } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index 2ee8b7db8..ca0208e03 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -37,21 +37,6 @@ public sealed record DropRequest(DnsQuestion Question); /// public sealed record Dropped(short Id); - /// - /// Internal message for UDP DNS answers - /// - // private sealed record UdpAnswer - // { - // public ImmutableArray Questions { get; } - // public DnsProtocol.Message Content { get; } - // - // public UdpAnswer(IEnumerable questions, DnsProtocol.Message content) - // { - // Questions = questions.ToImmutableArray(); - // Content = content; - // } - // } - /// /// Message indicating TCP connection dropped /// From 0cc31bbea6d986ec18b683627422491467c7dd68 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Fri, 18 Jul 2025 07:50:41 -0300 Subject: [PATCH 26/37] Add additional records to lookup result --- src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index 3da19d375..16387c854 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -27,7 +27,6 @@ public DnsServiceDiscovery(ExtendedActorSystem system) _dns = dns.Manager; CanLookupSrv = dns.Provider is IDnsProviderWithSrvLookup; } - /// /// Cleans an IP string by removing leading '/' if present. @@ -143,6 +142,7 @@ private static Resolved SrvRecordsToResolved(string srvRequest, Internal.DnsProt } aIps.Add(aRecord.Ip); + ips.Add(aRecord.Name, aIps); } foreach (var aaaaRecord in resolved.AdditionalRecords.OfType()) @@ -154,6 +154,7 @@ private static Resolved SrvRecordsToResolved(string srvRequest, Internal.DnsProt } aaaaIps.Add(aaaaRecord.Ip); + ips.Add(aaaaRecord.Name, aaaaIps); } // Build the list of resolved targets from SRV records From f20720bc66d0b6feb41eac3b6910553b73eb6ee3 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Fri, 18 Jul 2025 07:51:17 -0300 Subject: [PATCH 27/37] add doc comments --- .../Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 17 +++++++++++++---- .../Internal/AsyncDnsProvider.cs | 7 ++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index 02240feb4..aa1149aa3 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -108,7 +108,9 @@ public static DnsDiscoverySettings Create(Akka.Configuration.Config config) return new DnsDiscoverySettings(); } } - +/// +/// Base record to define caching behaviour of AsyncDnsClient +/// public abstract record PositiveTtl { @@ -119,19 +121,28 @@ public static PositiveTtl ParseFromConfig(Configuration.Config config) => "never" => Never.Instance, _ => new TtlTimeSpan(config.GetTimeSpan("positive-ttl")), }; + + /// + /// Cache DNS records for the amount of time defined in the response TTL flag + /// public record Forever : PositiveTtl { public static readonly Forever Instance = new(); public override string ToString() => "forever"; } + /// + /// Never cache DNS records + /// public record Never : PositiveTtl { public static readonly Never Instance = new(); public override string ToString() => "never"; - } + /// + /// Cache DNS records for a fixed amount of time + /// public record TtlTimeSpan(TimeSpan TimeSpan) : PositiveTtl { public override string ToString() => $"{TimeSpan.TotalSeconds}s"; @@ -140,8 +151,6 @@ public record TtlTimeSpan(TimeSpan TimeSpan) : PositiveTtl public class AsyncDnsResolverOptions : IHoconOption { - - public const string DefaultPath = "async-dns"; public const string NameserversPath = "nameservers"; diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs index 0b7010863..31e88f652 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsProvider.cs @@ -4,12 +4,9 @@ namespace Akka.Discovery.Dns.Internal; /// -/// +/// This interface is used by DnsServiceDiscovery to determine if SRV or A/AAAA lookup should be performed /// -public interface IDnsProviderWithSrvLookup : IDnsProvider -{ - -} +public interface IDnsProviderWithSrvLookup : IDnsProvider; public class AsyncDnsProvider : IDnsProviderWithSrvLookup { /// From 13e8c078527c3dcc15ea40d1f614c005baaf9139 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Fri, 18 Jul 2025 07:52:10 -0300 Subject: [PATCH 28/37] reduce line noise --- .../Internal/DnsProtocol.cs | 184 ++++++++---------- .../Internal/ResourceRecord.cs | 16 +- 2 files changed, 83 insertions(+), 117 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs index 9cc7d1b61..7f1289ccc 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs @@ -61,26 +61,14 @@ public enum ResponseCode : byte /// public record MessageFlags { - public bool IsResponse { get; init; } - public byte OpCode { get; init; } - public bool IsAuthoritativeAnswer { get; init; } - public bool IsTruncated { get; init; } - public bool IsRecursionDesired { get; init; } - public bool IsRecursionAvailable { get; init; } - public ResponseCode ResponseCode { get; init; } - - public MessageFlags() - { - // Default values for a query - IsResponse = false; - OpCode = 0; - IsAuthoritativeAnswer = false; - IsTruncated = false; - IsRecursionDesired = true; - IsRecursionAvailable = false; - ResponseCode = ResponseCode.Success; - } - + public bool IsResponse { get; init; } = false; + public byte OpCode { get; init; } = 0; + public bool IsAuthoritativeAnswer { get; init; } = false; + public bool IsTruncated { get; init; } = false; + public bool IsRecursionDesired { get; init; } = true; + public bool IsRecursionAvailable { get; init; } = false; + public ResponseCode ResponseCode { get; init; } = ResponseCode.Success; + /// /// Parse message flags from a 16-bit value /// @@ -126,19 +114,8 @@ public override string ToString() /// /// DNS question /// - public class Question + public record Question(string Name, RecordType Type, RecordClass Class) { - public string Name { get; } - public RecordType Type { get; } - public RecordClass Class { get; } - - public Question(string name, RecordType type, RecordClass @class) - { - Name = name; - Type = type; - Class = @class; - } - public override string ToString() { return $"Question({Name}, {Type}, {Class})"; @@ -186,32 +163,30 @@ public override string ToString() /// public byte[] Write() { - using (var ms = new MemoryStream()) - using (var writer = new BinaryWriter(ms)) + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + // Write header + writer.Write(IPAddress.HostToNetworkOrder((short)Id)); + writer.Write(IPAddress.HostToNetworkOrder((short)Flags.ToUInt16())); + writer.Write(IPAddress.HostToNetworkOrder((short)Questions.Count)); + writer.Write(IPAddress.HostToNetworkOrder((short)AnswerRecords.Count)); + writer.Write(IPAddress.HostToNetworkOrder((short)AuthorityRecords.Count)); + writer.Write(IPAddress.HostToNetworkOrder((short)AdditionalRecords.Count)); + + // Write questions + foreach (var question in Questions) { - // Write header - writer.Write(IPAddress.HostToNetworkOrder((short)Id)); - writer.Write(IPAddress.HostToNetworkOrder((short)Flags.ToUInt16())); - writer.Write(IPAddress.HostToNetworkOrder((short)Questions.Count)); - writer.Write(IPAddress.HostToNetworkOrder((short)AnswerRecords.Count)); - writer.Write(IPAddress.HostToNetworkOrder((short)AuthorityRecords.Count)); - writer.Write(IPAddress.HostToNetworkOrder((short)AdditionalRecords.Count)); - - // Write questions - foreach (var question in Questions) - { - WriteDomainName(writer, question.Name); - writer.Write(IPAddress.HostToNetworkOrder((short)question.Type)); - writer.Write(IPAddress.HostToNetworkOrder((short)question.Class)); - } + WriteDomainName(writer, question.Name); + writer.Write(IPAddress.HostToNetworkOrder((short)question.Type)); + writer.Write(IPAddress.HostToNetworkOrder((short)question.Class)); + } - // Write resource records - WriteResourceRecords(writer, AnswerRecords); - WriteResourceRecords(writer, AuthorityRecords); - WriteResourceRecords(writer, AdditionalRecords); + // Write resource records + WriteResourceRecords(writer, AnswerRecords); + WriteResourceRecords(writer, AuthorityRecords); + WriteResourceRecords(writer, AdditionalRecords); - return ms.ToArray(); - } + return ms.ToArray(); } public string FirstQuestionName @@ -229,43 +204,41 @@ public string FirstQuestionName /// public static Message Parse(byte[] data) { - using (var ms = new MemoryStream(data)) - using (var reader = new BinaryReader(ms)) + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + // Read header + short id = IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort flags = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort questionCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort answerCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort authorityCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort additionalCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + + var messageFlags = MessageFlags.FromUInt16(flags); + + // Read questions + var questions = ImmutableList.CreateBuilder(); + for (int i = 0; i < questionCount; i++) { - // Read header - short id = IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort flags = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort questionCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort answerCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort authorityCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort additionalCount = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - - var messageFlags = MessageFlags.FromUInt16(flags); - - // Read questions - var questions = ImmutableList.CreateBuilder(); - for (int i = 0; i < questionCount; i++) - { - string name = ReadDomainName(reader, ms); - ushort type = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort @class = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - - questions.Add(new Question(name, (RecordType)type, (RecordClass)@class)); - } + string name = ReadDomainName(reader, ms); + ushort type = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort @class = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - // Read resource records - var answerRecords = ReadResourceRecords(reader, ms, answerCount); - var authorityRecords = ReadResourceRecords(reader, ms, authorityCount); - var additionalRecords = ReadResourceRecords(reader, ms, additionalCount); - - return new Message( - id, - messageFlags, - questions.ToImmutable(), - answerRecords, - authorityRecords, - additionalRecords); + questions.Add(new Question(name, (RecordType)type, (RecordClass)@class)); } + + // Read resource records + var answerRecords = ReadResourceRecords(reader, ms, answerCount); + var authorityRecords = ReadResourceRecords(reader, ms, authorityCount); + var additionalRecords = ReadResourceRecords(reader, ms, additionalCount); + + return new Message( + id, + messageFlags, + questions.ToImmutable(), + answerRecords, + authorityRecords, + additionalRecords); } /// @@ -406,11 +379,9 @@ record = ReadSrvRecord(name, (RecordClass)@class, ttl, data); /// private static string ReadDomainNameFromData(byte[] data) { - using (var ms = new MemoryStream(data)) - using (var reader = new BinaryReader(ms)) - { - return ReadDomainName(reader, ms); - } + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + return ReadDomainName(reader, ms); } /// @@ -418,16 +389,14 @@ private static string ReadDomainNameFromData(byte[] data) /// private static SrvRecord ReadSrvRecord(string name, RecordClass @class, uint ttl, byte[] data) { - using (var ms = new MemoryStream(data)) - using (var reader = new BinaryReader(ms)) - { - ushort priority = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort weight = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - ushort port = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); - string target = ReadDomainName(reader, ms); - - return new SrvRecord(name, @class, ttl, priority, weight, port, target); - } + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + ushort priority = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort weight = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + ushort port = (ushort)IPAddress.NetworkToHostOrder(reader.ReadInt16()); + string target = ReadDomainName(reader, ms); + + return new SrvRecord(name, @class, ttl, priority, weight, port, target); } /// @@ -462,11 +431,10 @@ public static IEnumerable ToIpAddresses(Message answer, DnsProtocol.R x switch { AaaaRecord aaaa => aaaa.Ip, ARecord a => a.Ip, - _ => - IPAddress.TryParse(x.Name, out var ip) - ? Option.Create(ip) - : Option.None }) //this might lose data if answer is hostname + IPAddress.TryParse(x.Name, out var ip) + ? Option.Create(ip) + : Option.None }) //this might lose data if answer is hostname .Where(x => x.HasValue) .Select(x => x.Value); diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs index df00fca41..9f6dc2ad3 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/ResourceRecord.cs @@ -111,15 +111,13 @@ public sealed record SrvRecord( { public override byte[] WriteData() { - using (var ms = new MemoryStream()) - using (var writer = new BinaryWriter(ms)) - { - writer.Write(IPAddress.HostToNetworkOrder((short)Priority)); - writer.Write(IPAddress.HostToNetworkOrder((short)Weight)); - writer.Write(IPAddress.HostToNetworkOrder((short)Port)); - WriteDomainName(writer, Target); - return ms.ToArray(); - } + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + writer.Write(IPAddress.HostToNetworkOrder((short)Priority)); + writer.Write(IPAddress.HostToNetworkOrder((short)Weight)); + writer.Write(IPAddress.HostToNetworkOrder((short)Port)); + WriteDomainName(writer, Target); + return ms.ToArray(); } } From 17c189620111090bca97ce33ef71bddebbacbe2c Mon Sep 17 00:00:00 2001 From: Gregorius Soedharmo Date: Tue, 22 Jul 2025 01:43:39 +0700 Subject: [PATCH 29/37] Fix/add documentation --- .../Internal/AsyncDnsCache.cs | 10 +- .../Internal/DnsProtocol.cs | 179 +++++++++++++++++- .../{Internal => Properties}/Friends.cs | 0 3 files changed, 181 insertions(+), 8 deletions(-) rename src/discovery/dns/Akka.Discovery.Dns/{Internal => Properties}/Friends.cs (100%) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs index 893b6bc08..31f8878f9 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsCache.cs @@ -44,11 +44,11 @@ public AsyncDnsCache() _ticksBase = DateTime.Now.Ticks; } - // /// - // /// Gets a cached DNS resolution result for the specified hostname. - // /// - // /// The hostname to lookup in the cache. - // /// The cached DNS resolution result, or null if not found or expired. + /// + /// Gets a cached DNS resolution result for the specified hostname. + /// + /// The hostname to lookup in the cache. + /// The cached DNS resolution result, or null if not found or expired. internal TResolved? GetCached(string name) => _cache.Value.Get(name); internal static IO.Dns.Resolved? Convert(TResolved? answer) => diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs index 7f1289ccc..abf6d2122 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs @@ -9,13 +9,178 @@ namespace Akka.Discovery.Dns.Internal; +/* + This documentation is copied directly from RFC-1035 + + ## 3.1. Name space definitions + + Domain names in messages are expressed in terms of a sequence of labels. + Each label is represented as a one octet length field followed by that + number of octets. Since every domain name ends with the null label of + the root, a domain name is terminated by a length byte of zero. The + high order two bits of every length octet must be zero, and the + remaining six bits of the length field limit the label to 63 octets or + less. + + To simplify implementations, the total length of a domain name (i.e., + label octets and label length octets) is restricted to 255 octets or + less. + + Although labels can contain any 8 bit values in octets that make up a + label, it is strongly recommended that labels follow the preferred + syntax described elsewhere in this memo, which is compatible with + existing host naming conventions. Name servers and resolvers must + compare labels in a case-insensitive manner (i.e., A=a), assuming ASCII + with zero parity. Non-alphabetic codes must match exactly. + + # 4. Message + + ## 4.1. Format + + All communications inside the domain protocol are carried in a single + format called a message. The top level format of message is divided + into 5 sections (some of which are empty in certain cases) shown below: + + ``` + +---------------------+ + | Header | + +---------------------+ + | Question | the question for the name server + +---------------------+ + | Answer | RRs answering the question + +---------------------+ + | Authority | RRs pointing toward an authority + +---------------------+ + | Additional | RRs holding additional information + +---------------------+ + ``` + + The header section is always present. The header includes fields that + specify which of the remaining sections are present, and also specify + whether the message is a query or a response, a standard query or some + other opcode, etc. + + The names of the sections after the header are derived from their use in + standard queries. The question section contains fields that describe a + question to a name server. These fields are a query type (QTYPE), a + query class (QCLASS), and a query domain name (QNAME). The last three + sections have the same format: a possibly empty list of concatenated + resource records (RRs). The answer section contains RRs that answer the + question; the authority section contains RRs that point toward an + authoritative name server; the additional records section contains RRs + which relate to the query, but are not strictly answers for the + question. + + ### 4.1.1. Header section format + + The header contains the following fields: + ``` + 1 1 1 1 1 1 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + | ID | + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + |QR| Opcode |AA|TC|RD|RA| Z | RCODE | // <-- FLAGS + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + | QDCOUNT | + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + | ANCOUNT | + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + | NSCOUNT | + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + | ARCOUNT | + +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + ``` + where: + + * ID: + A 16 bit identifier assigned by the program that generates any kind of query. + This identifier is copied the corresponding reply and can be used by the + requester to match up replies to outstanding queries. + + * QR: + A one bit field that specifies whether this message is a query (0), or a + response (1). + + * OPCODE: + A four bit field that specifies kind of query in this message. This value is + set by the originator of a query and copied into the response. The values are: + + | Value | Description | + | ----- | -------------------------------- | + | 0 | a standard query (QUERY) | + | 1 | an inverse query (IQUERY) | + | 2 | a server status request (STATUS) | + | 3-15 | reserved for future use | + + * AA (Authoritative Answer): + This bit is valid in responses, and specifies that the responding name server + is an authority for the domain name in question section. + + Note that the contents of the answer section may have multiple owner names + because of aliases. The AA bit corresponds to the name which matches the + query name, or the first owner name in the answer section. + + * TC (TrunCation): + Specifies that this message was truncated due to length greater than that + permitted on the transmission channel. + + * RD (Recursion Desired): + This bit may be set in a query and is copied into the response. If RD is + set, it directs the name server to pursue the query recursively. + Recursive query support is optional. + + * RA (Recursion Available): + This bit is set or cleared in a response, and denotes whether recursive query + support is available in the name server. + + * Z: + Reserved for future use. Must be zero in all queries and responses. + + * RCODE (Response Code): + This 4 bit field is set as part of responses. The values have the following + interpretation: + + * 0: No error condition + * 1: Format error - The name server was unable to interpret the query. + * 2: Server failure - The name server was unable to process this query due to + a problem with the name server. + * 3: Name Error - Meaningful only for responses from an authoritative name + server, this code signifies that the domain name referenced in the + query does not exist. + * 4: Not Implemented - The name server does not support the requested kind + of query. + * 5: Refused - The name server refuses to perform the specified operation for + policy reasons. For example, a name server may not wish to provide the + information to the particular requester, or a name server may not wish + to perform a particular operation (e.g., zone transfer) for particular + data. + * 6-15: Reserved for future use. + + * QDCOUNT: + An unsigned 16 bit integer specifying the number of entries in the question + section. + + * ANCOUNT: + An unsigned 16 bit integer specifying the number of resource records in the + answer section. + + * NSCOUNT: + an unsigned 16 bit integer specifying the number of name server resource + records in the authority records section. + + * ARCOUNT: + an unsigned 16 bit integer specifying the number of resource records in the + additional records section + */ + /// /// DNS protocol implementation supporting SRV records /// public static class DnsProtocol { /// - /// DNS record types + /// DNS record types (QTYPE) /// public enum RecordType : ushort { @@ -32,7 +197,7 @@ public enum RecordType : ushort } /// - /// DNS record classes + /// DNS record classes (QCLASS) /// public enum RecordClass : ushort { @@ -44,7 +209,7 @@ public enum RecordClass : ushort } /// - /// DNS response codes + /// DNS response codes (RCODE) /// public enum ResponseCode : byte { @@ -56,6 +221,14 @@ public enum ResponseCode : byte Refused = 5 } + /* + * FLAGS format: + * 1 1 1 1 1 1 + * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 + * +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + * |QR| Opcode |AA|TC|RD|RA| Z | RCODE | + * +--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+ + */ /// /// DNS message flags /// diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/Friends.cs b/src/discovery/dns/Akka.Discovery.Dns/Properties/Friends.cs similarity index 100% rename from src/discovery/dns/Akka.Discovery.Dns/Internal/Friends.cs rename to src/discovery/dns/Akka.Discovery.Dns/Properties/Friends.cs From e3c532083b3d646a32986f3e345d2ed3b4f4912b Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 22 Jul 2025 08:26:57 -0300 Subject: [PATCH 30/37] make constructors for immutable messages private --- src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs | 2 ++ .../dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs index aa1149aa3..ea78784cc 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsDiscoveryOptions.cs @@ -127,6 +127,7 @@ public static PositiveTtl ParseFromConfig(Configuration.Config config) => /// public record Forever : PositiveTtl { + private Forever() { } public static readonly Forever Instance = new(); public override string ToString() => "forever"; } @@ -136,6 +137,7 @@ public record Forever : PositiveTtl /// public record Never : PositiveTtl { + private Never() { } public static readonly Never Instance = new(); public override string ToString() => "never"; } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs index 23fede746..02947210a 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsManager.cs @@ -113,8 +113,9 @@ protected override void PostStop() /// /// Message sent to trigger DNS cache cleanup. /// - internal class CacheCleanup + internal record CacheCleanup { + private CacheCleanup() { } /// /// Singleton instance of the cache cleanup message. /// From 29f58832e2c53bbe1506b0f04a4a87347cc219ae Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 22 Jul 2025 08:33:59 -0300 Subject: [PATCH 31/37] Make TcpDropped message immutable and handle it --- .../Akka.Discovery.Dns/Internal/AsyncDnsClient.cs | 14 ++++++++------ .../Akka.Discovery.Dns/Internal/TcpDnsClient.cs | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index ca0208e03..786bdc803 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -40,7 +40,11 @@ public sealed record Dropped(short Id); /// /// Message indicating TCP connection dropped /// - public static readonly object TcpDropped = new object(); + public sealed record TcpDropped + { + private TcpDropped() { } + public static readonly TcpDropped Instance = new(); + } /// /// Information about an in-flight DNS request @@ -118,7 +122,6 @@ protected virtual void Ready(object message) case DropRequest dropRequest: HandleDropRequest(dropRequest); break; - case DnsQuestion question: HandleQuestion(question.Id, question.Name, question.RecordType); break; @@ -249,13 +252,13 @@ protected virtual void Ready(object message) case Udp.CommandFailed cmdFailed: _log.Warning("DNS client failed to send {0}", cmdFailed.Cmd); break; - + case TcpDropped _: case Tcp.Aborted _: _log.Warning("TCP client failed, clearing inflight resolves which were being resolved by TCP"); - _inflightRequests = _inflightRequests.Where(kv => !kv.Value.TcpRequest) + _inflightRequests = _inflightRequests + .Where(kv => !kv.Value.TcpRequest) .ToDictionary(kv => kv.Key, kv => kv.Value); break; - case Udp.Unbind _: Sender.Tell(Udp.Unbind.Instance); break; @@ -266,7 +269,6 @@ protected virtual void Ready(object message) case IO.Dns.Resolve r: HandleLegacyResolveRequest(r); break; - default: Unhandled(message); break; diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs index b3f412d63..27158a43d 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs @@ -50,7 +50,7 @@ protected override void OnReceive(object message) case Tcp.CommandFailed failed when failed.Cmd is Tcp.Connect: _log.Warning("Failed to connect to DNS server: {0}", failed); - parent.Tell(AsyncDnsClient.TcpDropped); + parent.Tell(AsyncDnsClient.TcpDropped.Instance); Context.Stop(Self); break; @@ -60,7 +60,7 @@ protected override void OnReceive(object message) case Tcp.ConnectionClosed _: _log.Debug("Connection to DNS server closed"); - parent.Tell(AsyncDnsClient.TcpDropped); + parent.Tell(AsyncDnsClient.TcpDropped.Instance); Context.Stop(Self); break; @@ -78,7 +78,7 @@ protected override void OnReceive(object message) case Status.Failure failure: _log.Error(failure.Cause, "TCP DNS client failure"); - parent.Tell(AsyncDnsClient.TcpDropped); + parent.Tell(AsyncDnsClient.TcpDropped.Instance); Context.Stop(Self); break; } From 287fb0be113f320cadc311f02a0a4af6a39f44da Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 22 Jul 2025 09:16:27 -0300 Subject: [PATCH 32/37] Remove unreferenced DropRequest message and handlers --- .../Internal/AsyncDnsClient.cs | 51 ++----------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index 786bdc803..09f384fb1 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -27,16 +27,6 @@ public record DnsQuestion(short Id, string Name, DnsProtocol.RecordType RecordTy public object ConsistentHashKey { get; } = Name; //not sure is this it the right approach } - /// - /// Request to drop a pending DNS question - /// - public sealed record DropRequest(DnsQuestion Question); - - /// - /// Notification that a request has been dropped - /// - public sealed record Dropped(short Id); - /// /// Message indicating TCP connection dropped /// @@ -119,13 +109,9 @@ protected virtual void Ready(object message) { switch (message) { - case DropRequest dropRequest: - HandleDropRequest(dropRequest); - break; case DnsQuestion question: - HandleQuestion(question.Id, question.Name, question.RecordType); + HandleQuestion(question.Name, question.RecordType); break; - case Udp.Received received: try { @@ -330,39 +316,8 @@ private void HandleQuestion(short id, string name, DnsProtocol.RecordType record SendDnsQuestion(id, name, recordType); } - - private void HandleDropRequest(DropRequest dropRequest) - { - var id = dropRequest.Question.Id; - if (_inflightRequests.TryGetValue(id, out var inFlight)) - { - var sentQuestions = inFlight.Message.Questions.Select(q => new { q.Name, q.Type }).ToList(); - - string expectedName = dropRequest.Question.Name; - DnsProtocol.RecordType expectedType = dropRequest.Question.RecordType; - - if (sentQuestions.Any(q => q.Name == expectedName && q.Type == expectedType)) - { - _log.Debug("Dropping request [{0}]", id); - _inflightRequests.Remove(id); - Sender.Tell(new Dropped(id)); - } - else if (_log.IsInfoEnabled) - { - _log.Info("Requested to drop request for id [{0}] expecting [{1}/{2}] but found requests for [{3}]... ignoring drop request", - id, - expectedName, - expectedType, - string.Join(", ", sentQuestions.Select(q => $"{q.Name}/{q.Type}"))); - } - } - else - { - Sender.Tell(new Dropped(id)); - } - } - - internal virtual DnsProtocol.Message CreateMessage(string name, short id, DnsProtocol.RecordType recordType) + + internal virtual DnsProtocol.Message CreateMessage(string name, int id, DnsProtocol.RecordType recordType) { var question = new DnsProtocol.Question(name, recordType, DnsProtocol.RecordClass.In); return new DnsProtocol.Message( From b61745bcb93665b23cf106e2e2eb9dab09980fdc Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 22 Jul 2025 09:17:22 -0300 Subject: [PATCH 33/37] Reply with error on TCP failure --- .../dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index 09f384fb1..cb5ebad10 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -241,8 +241,16 @@ protected virtual void Ready(object message) case TcpDropped _: case Tcp.Aborted _: _log.Warning("TCP client failed, clearing inflight resolves which were being resolved by TCP"); + var tcpRequests = _inflightRequests + .Where(kv => kv.Value.TcpRequest) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + foreach (var inFlight in tcpRequests.Values) + { + inFlight.ReplyTo.Tell(new Status.Failure(new Exception("TCP connection to nameserver failed"))); + } _inflightRequests = _inflightRequests - .Where(kv => !kv.Value.TcpRequest) + .Where(kv => tcpRequests.ContainsKey(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value); break; case Udp.Unbind _: From 1b2deab7b0a5742f9d9c4d73b0d875f9e133c940 Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 22 Jul 2025 09:22:55 -0300 Subject: [PATCH 34/37] Refactor query ID generation --- .../Akka.Discovery.Dns/DnsServiceDiscovery.cs | 2 +- .../Internal/AsyncDnsClient.cs | 40 +++++++++++-------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index 16387c854..55eba1628 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -52,7 +52,7 @@ private async Task LookupSrv(Lookup lookup, TimeSpan resolveTimeout) try { // Send SRV question and await response - var result = await _dns.Ask(new Internal.AsyncDnsClient.DnsQuestion(AsyncDnsClient.NewQueryId(), srvRequest, DnsProtocol.RecordType.Srv), resolveTimeout); + var result = await _dns.Ask(new Internal.AsyncDnsClient.DnsQuestion(srvRequest, DnsProtocol.RecordType.Srv), resolveTimeout); if (result is DnsProtocol.Message answer) { diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index cb5ebad10..62febe872 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -22,7 +22,7 @@ internal class AsyncDnsClient(AsyncDnsCache cache, Configuration.Config config, /// /// Base class for DNS questions /// - public record DnsQuestion(short Id, string Name, DnsProtocol.RecordType RecordType) : IConsistentHashable + public record DnsQuestion(string Name, DnsProtocol.RecordType RecordType) : IConsistentHashable { public object ConsistentHashKey { get; } = Name; //not sure is this it the right approach } @@ -285,9 +285,13 @@ private void HandleLegacyResolveRequest(IO.Dns.Resolve request) _log.Debug("Resolving both A and AAAA records for [{0}]", request.Name); // Generate unique IDs for both requests - short idA = NewQueryId(); - short idAaaa = NewQueryId(); - + var idA = NewQueryId(); + var idAaaa = NewQueryId(); + //make sure both IDs are unique + while (idAaaa == idA) + { + idAaaa = NewQueryId(); + } SendDnsQuestion(idA, request.Name, DnsProtocol.RecordType.A, idAaaa); SendDnsQuestion(idAaaa, request.Name, DnsProtocol.RecordType.Aaaa, idA); } @@ -306,15 +310,8 @@ private void SendDnsQuestion(short id, string name, DnsProtocol.RecordType recor } - private void HandleQuestion(short id, string name, DnsProtocol.RecordType recordType) + private void HandleQuestion(string name, DnsProtocol.RecordType recordType) { - if (_inflightRequests.ContainsKey(id)) - { - _log.Warning("DNS transaction ID collision encountered for ID [{0}], ignoring. This likely indicates a bug.", - id); - return; - } - var answer = cache.GetCached(name); if (answer != null) { @@ -322,10 +319,10 @@ private void HandleQuestion(short id, string name, DnsProtocol.RecordType record return; } - SendDnsQuestion(id, name, recordType); + SendDnsQuestion(NewQueryId(), name, recordType); } - internal virtual DnsProtocol.Message CreateMessage(string name, int id, DnsProtocol.RecordType recordType) + internal virtual DnsProtocol.Message CreateMessage(string name, short id, DnsProtocol.RecordType recordType) { var question = new DnsProtocol.Question(name, recordType, DnsProtocol.RecordClass.In); return new DnsProtocol.Message( @@ -358,8 +355,19 @@ private IActorRef CreateTcpClient() BackoffSupervisor.Props(backoffOptions), "tcpDnsClientSupervisor"); } - //TODO: Maybe this should be more resilient, what if we have a lot of requests at the same time? - internal static short NewQueryId() => (short)Random.Next(0, 65535); + /// + /// Generate random unique query ID + /// + /// + private short NewQueryId() + { + var r = (short)Random.Next(0, 65535); + while (_inflightRequests.ContainsKey(r)) + { + r++; + } + return r; + } /// /// Determine if need to cache DNS response and for how long From 5351c9cc7abc8b09c60deb0830f30c1471dd5c6e Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 29 Jul 2025 08:33:51 -0300 Subject: [PATCH 35/37] applied copilot suggestions --- .../dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs | 12 ++++-------- .../Akka.Discovery.Dns/Internal/AsyncDnsClient.cs | 4 ++-- .../dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs | 2 +- .../dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs | 4 +++- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs index 55eba1628..f661ab78b 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/DnsServiceDiscovery.cs @@ -137,24 +137,20 @@ private static Resolved SrvRecordsToResolved(string srvRequest, Internal.DnsProt { if (!ips.TryGetValue(aRecord.Name, out var aIps)) { - aIps = new List(); - ips[aRecord.Name] = aIps; + aIps = []; } - aIps.Add(aRecord.Ip); - ips.Add(aRecord.Name, aIps); + ips[aRecord.Name] = aIps; } foreach (var aaaaRecord in resolved.AdditionalRecords.OfType()) { if (!ips.TryGetValue(aaaaRecord.Name, out var aaaaIps)) { - aaaaIps = new List(); - ips[aaaaRecord.Name] = aaaaIps; + aaaaIps = []; } - aaaaIps.Add(aaaaRecord.Ip); - ips.Add(aaaaRecord.Name, aaaaIps); + ips[aaaaRecord.Name] = aaaaIps; } // Build the list of resolved targets from SRV records diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs index 62febe872..522f8f076 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/AsyncDnsClient.cs @@ -250,7 +250,7 @@ protected virtual void Ready(object message) inFlight.ReplyTo.Tell(new Status.Failure(new Exception("TCP connection to nameserver failed"))); } _inflightRequests = _inflightRequests - .Where(kv => tcpRequests.ContainsKey(kv.Key)) + .Where(kv => !tcpRequests.ContainsKey(kv.Key)) .ToDictionary(kv => kv.Key, kv => kv.Value); break; case Udp.Unbind _: @@ -361,7 +361,7 @@ private IActorRef CreateTcpClient() /// private short NewQueryId() { - var r = (short)Random.Next(0, 65535); + var r = (short)Random.Next(short.MinValue, short.MaxValue); while (_inflightRequests.ContainsKey(r)) { r++; diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs index abf6d2122..5bf5f116e 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs @@ -272,7 +272,7 @@ public ushort ToUInt16() if (IsTruncated) flags |= 0x0200; if (IsRecursionDesired) flags |= 0x0100; if (IsRecursionAvailable) flags |= 0x0080; - flags |= (ushort)((int)ResponseCode & 0xF); + flags |= (ushort)((ushort)ResponseCode & 0xF); return flags; } diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs index 27158a43d..b71f1c9bf 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/TcpDnsClient.cs @@ -19,7 +19,9 @@ internal class TcpDnsClient(IActorRef tcpManager, EndPoint nameserver, IActorRef private readonly ILoggingAdapter _log = Context.GetLogger(); private IActorRef? _connection; - private byte[] _readBuffer = new byte[2048]; // Buffer for reading DNS responses + private const int DnsMessageBufferSize = 2048; // DNS message buffer size + + private byte[] _readBuffer = new byte[DnsMessageBufferSize]; // Buffer for reading DNS responses private int _expectedLength = -1; // Expected length of current DNS response private int _currentPosition = 0; // Current position in the buffer From 04917ac76977b48b68e261e6f21688bd588fa6ae Mon Sep 17 00:00:00 2001 From: Pavel Anpin Date: Tue, 29 Jul 2025 08:39:50 -0300 Subject: [PATCH 36/37] add better comment --- .../dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs index 5bf5f116e..5e44e3948 100644 --- a/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs +++ b/src/discovery/dns/Akka.Discovery.Dns/Internal/DnsProtocol.cs @@ -607,7 +607,11 @@ public static IEnumerable ToIpAddresses(Message answer, DnsProtocol.R _ => IPAddress.TryParse(x.Name, out var ip) ? Option.Create(ip) - : Option.None }) //this might lose data if answer is hostname + // This can happen if the record is not an A/AAAA record but a SRV, CNAME, NS, MX, + // or other record, where the answer is a hostname instead of an IP address. + // In this case, we ignore it as this method is only interested in IP addresses. + : Option.None + }) .Where(x => x.HasValue) .Select(x => x.Value); From b01100e34b60f9574e61b5f27497334e6dade532 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 29 Aug 2025 12:10:13 -0500 Subject: [PATCH 37/37] Fix DNS resolution in cluster bootstrap examples The dig commands in entrypoint.sh were not specifying the DNS server address, causing them to use the default Docker resolver (127.0.0.11) instead of the configured CoreDNS server. This prevented the demo containers from properly resolving service discovery records. Added @$DNS_SERVER parameter to all dig commands to ensure they query the correct DNS server specified by the DNS_NAMESERVER environment variable. --- .../examples/discovery/dns/src/entrypoint.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh index 4d0548726..036496db9 100644 --- a/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh +++ b/src/cluster.bootstrap/examples/discovery/dns/src/entrypoint.sh @@ -16,13 +16,14 @@ echo " DNS_PORT: $DNS_PORT" echo " DNS_NAMESERVER: $DNS_NAMESERVER" echo "===================================" DNS_PORT=${DNS_PORT:-53} -echo "\nPerforming DNS resolution test for '$SERVICENAME'..." +DNS_SERVER="${DNS_NAMESERVER:-127.0.0.1}" +echo "\nPerforming DNS resolution test for '$SERVICENAME' using DNS server $DNS_SERVER:$DNS_PORT..." # A records -dig -p $DNS_PORT $SERVICENAME +dig @$DNS_SERVER -p $DNS_PORT $SERVICENAME # AAAA records -dig -p $DNS_PORT -t aaaa $SERVICENAME +dig @$DNS_SERVER -p $DNS_PORT -t aaaa $SERVICENAME # SRV records -dig -p $DNS_PORT -t srv "_${PORTNAME}._tcp.$SERVICENAME" +dig @$DNS_SERVER -p $DNS_PORT -t srv "_${PORTNAME}._tcp.$SERVICENAME" export NAMESERVER="${DNS_NAMESERVER:-127.0.0.1}:$DNS_PORT" echo "\nStarting DnsCluster with DNS discovery..."