diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs index 2b3bd106e20..da9ed8739c0 100644 --- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs +++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs @@ -1581,6 +1581,7 @@ private static (string, EndpointBindingMode) NormalizeTargetHost(string targetHo { null or "" => ("localhost", EndpointBindingMode.SingleAddress), // Default is localhost var s when string.Equals(s, "localhost", StringComparison.OrdinalIgnoreCase) => ("localhost", EndpointBindingMode.SingleAddress), // Explicitly set to localhost + var s when s.Length > 10 && s.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase) => ("localhost", EndpointBindingMode.SingleAddress), // Explicitly set to localhost when using .localhost subdomain var s when IPAddress.TryParse(s, out var ipAddress) => ipAddress switch // The host is an IP address { var ip when IPAddress.Any.Equals(ip) => ("localhost", EndpointBindingMode.IPv4AnyAddresses), // 0.0.0.0 (IPv4 all addresses) diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs index 153431f6b96..709cc47000b 100644 --- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs +++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs @@ -212,8 +212,23 @@ private async Task ProcessResourceUrlCallbacks(IResource resource, CancellationT Debug.Assert(endpoint.AllocatedEndpoint is not null, "Endpoint should be allocated at this point as we're calling this from ResourceEndpointsAllocatedEvent handler."); if (endpoint.AllocatedEndpoint is { } allocatedEndpoint) { - var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = new EndpointReference(resourceWithEndpoints, endpoint) }; + var endpointReference = new EndpointReference(resourceWithEndpoints, endpoint); + var url = new ResourceUrlAnnotation { Url = allocatedEndpoint.UriString, Endpoint = endpointReference }; urls.Add(url); + if (allocatedEndpoint.BindingMode != EndpointBindingMode.SingleAddress && (endpoint.TargetHost is not "localhost" or "127.0.0.1")) + { + // Endpoint is listening on multiple addresses so add another URL based on the declared target hostname + // For endpoints targeting all external addresses (IPv4 0.0.0.0 or IPv6 ::) use the machine name + var address = endpoint.TargetHost is "0.0.0.0" or "::" ? Environment.MachineName : endpoint.TargetHost; + url = new ResourceUrlAnnotation { Url = $"{allocatedEndpoint.UriScheme}://{address}:{allocatedEndpoint.Port}", Endpoint = endpointReference }; + urls.Add(url); + } + else if (endpoint.TargetHost.Length > 10 && endpoint.TargetHost.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)) + { + // Add the originally declared *.localhost URL + url = new ResourceUrlAnnotation { Url = $"{allocatedEndpoint.UriScheme}://{endpoint.TargetHost}:{allocatedEndpoint.Port}", Endpoint = endpointReference }; + urls.Add(url); + } } } } diff --git a/tests/Aspire.Hosting.Tests/WithEndpointTests.cs b/tests/Aspire.Hosting.Tests/WithEndpointTests.cs index 32c25dc9fd1..1a2fbcbec4b 100644 --- a/tests/Aspire.Hosting.Tests/WithEndpointTests.cs +++ b/tests/Aspire.Hosting.Tests/WithEndpointTests.cs @@ -617,6 +617,72 @@ public void WithEndpoint_WithAllArguments_ForwardsAllArguments() Assert.Equal(System.Net.Sockets.ProtocolType.Tcp, endpoint.Protocol); } + [Fact] + public async Task LocalhostTopLevelDomainSetsAnnotationValues() + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var tcs = new TaskCompletionSource(); + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint() + .WithEndpoint("https", e => e.TargetHost = "example.localhost", createIfNotExists: false) + .OnBeforeResourceStarted((_, _, _) => + { + tcs.SetResult(); + return Task.CompletedTask; + }); + + var app = await builder.BuildAsync(); + await app.StartAsync(); + await tcs.Task; + + var urls = projectA.Resource.Annotations.OfType(); + Assert.Collection(urls, + url => Assert.StartsWith("https://localhost:", url.Url), + url => Assert.StartsWith("https://example.localhost:", url.Url)); + + EndpointAnnotation endpoint = Assert.Single(projectA.Resource.Annotations.OfType()); + Assert.NotNull(endpoint.AllocatedEndpoint); + Assert.Equal(EndpointBindingMode.SingleAddress, endpoint.AllocatedEndpoint.BindingMode); + Assert.Equal("localhost", endpoint.AllocatedEndpoint.Address); + + await app.StopAsync(); + } + + [Theory] + [InlineData("0.0.0.0", EndpointBindingMode.IPv4AnyAddresses)] + //[InlineData("::", EndpointBindingMode.IPv6AnyAddresses)] // Need to figure out a good way to check that Ipv6 binding is supported + public async Task TopLevelDomainSetsAnnotationValues(string host, EndpointBindingMode endpointBindingMode) + { + using var builder = TestDistributedApplicationBuilder.Create(); + + var tcs = new TaskCompletionSource(); + var projectA = builder.AddProject("projecta") + .WithHttpsEndpoint() + .WithEndpoint("https", e => e.TargetHost = host, createIfNotExists: false) + .OnBeforeResourceStarted((_, _, _) => + { + tcs.SetResult(); + return Task.CompletedTask; + }); + + var app = await builder.BuildAsync(); + await app.StartAsync(); + await tcs.Task; + + var urls = projectA.Resource.Annotations.OfType(); + Assert.Collection(urls, + url => Assert.StartsWith("https://localhost:", url.Url), + url => Assert.StartsWith($"https://{Environment.MachineName}:", url.Url)); + + EndpointAnnotation endpoint = Assert.Single(projectA.Resource.Annotations.OfType()); + Assert.NotNull(endpoint.AllocatedEndpoint); + Assert.Equal(endpointBindingMode, endpoint.AllocatedEndpoint.BindingMode); + Assert.Equal("localhost", endpoint.AllocatedEndpoint.Address); + + await app.StopAsync(); + } + private sealed class TestProject : IProjectMetadata { public string ProjectPath => "projectpath";