Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions src/Aspire.Hosting/ApplicationModel/EndpointReference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Diagnostics;
using System.Globalization;
using Aspire.Hosting.Dcp;

namespace Aspire.Hosting.ApplicationModel;

Expand Down Expand Up @@ -221,6 +222,11 @@ public ReferenceExpression GetTlsValue(ReferenceExpression enabledValue, Referen
GetAllocatedEndpoint()
?? throw new InvalidOperationException($"The endpoint `{EndpointName}` is not allocated for the resource `{Resource.Name}`.");

// The endpoint annotation, or null when the endpoint is not defined on the resource. Unlike
// EndpointAnnotation this never throws, so callers that only want to inspect the endpoint can do so
// without turning a missing endpoint into an exception.
internal EndpointAnnotation? EndpointAnnotationOrDefault => GetEndpointAnnotation();

private EndpointAnnotation? GetEndpointAnnotation()
{
if (_endpointAnnotation is not null)
Expand Down Expand Up @@ -364,11 +370,31 @@ public class EndpointReferenceExpression(EndpointReference endpointReference, En
{
EndpointProperty.Scheme => new(Endpoint.Scheme),
EndpointProperty.TlsEnabled => Endpoint.TlsEnabled ? bool.TrueString : bool.FalseString,
EndpointProperty.IPV4Host when networkContext == KnownNetworkIdentifiers.LocalhostNetwork => "127.0.0.1",
EndpointProperty.IPV4Host when networkContext == KnownNetworkIdentifiers.LocalhostNetwork && BindsToLocalhost() => "127.0.0.1",
Comment thread
krishnendu-2003 marked this conversation as resolved.
EndpointProperty.TargetPort when Endpoint.TargetPort is int port => new(port.ToString(CultureInfo.InvariantCulture)),
_ => await ResolveValueWithAllocatedAddress().ConfigureAwait(false)
};

// IPV4Host exists so that consumers which cannot use a hostname (SQL Server, the Azure emulators) get an
// IPv4 literal rather than "localhost", which may resolve to ::1. That substitution is only correct while the
// endpoint address is "localhost" - the default, a *.localhost TLD, a wildcard bind, or an arbitrary machine
// name, all of which NormalizeTargetHost maps to localhost. When TargetHost names one specific address
// instead ("[::1]", a LAN address), the endpoint is not reachable on 127.0.0.1, so fall through and use the
// address the orchestrator allocated, the same value EndpointProperty.Host resolves to.
bool BindsToLocalhost()
{
// A null annotation means the endpoint is not defined. Keep answering immediately rather than letting
// the fall-through path surface the missing-endpoint exception for a property that never needed it.
var targetHost = Endpoint.EndpointAnnotationOrDefault?.TargetHost;
if (targetHost is null)
{
return true;
}

var (address, _) = DcpModelUtilities.NormalizeTargetHost(targetHost);
return string.Equals(address, KnownHostNames.Localhost, StringComparison.OrdinalIgnoreCase);
}

async ValueTask<string?> ResolveValueWithAllocatedAddress()
{
var allocatedEndpoint = await Endpoint.EndpointAnnotation.AllAllocatedEndpoints.GetAllocatedEndpointAsync(networkContext, cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -412,8 +438,15 @@ public enum EndpointProperty
/// </summary>
Host,
/// <summary>
/// The IPv4 address of the endpoint.
/// The address of the endpoint, preferring an IP literal over a host name that may resolve to more than one address.
/// </summary>
/// <remarks>
/// An endpoint bound to localhost - the default, a <c>*.localhost</c> TLD, a wildcard address, or a machine name -
/// resolves to <c>127.0.0.1</c> rather than <c>localhost</c>, so that consumers which cannot use a host name are not
/// handed a name that may resolve to <c>::1</c>. An endpoint whose <see cref="EndpointAnnotation.TargetHost"/> names
/// one specific address resolves to the address the orchestrator allocated, which may be IPv6, for example
/// <c>[::1]</c>.
/// </remarks>
IPV4Host,
/// <summary>
/// The port of the endpoint.
Expand Down
42 changes: 42 additions & 0 deletions tests/Aspire.Hosting.Tests/EndpointReferenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,48 @@ public async Task GetValueAsync_IPV4Host_ReturnsImmediately()
Assert.Equal("127.0.0.1", ipv4);
}

[Fact]
public async Task GetValueAsync_IPV4Host_WithCustomTargetHost_UsesAllocatedAddress()
{
var resource = new TestResource("test");
var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "tcp", name: "tcp")
{
TargetHost = "[::1]"
};
resource.Annotations.Add(annotation);

var endpointRef = new EndpointReference(resource, annotation);
var ipv4Expr = endpointRef.Property(EndpointProperty.IPV4Host);

// A customized TargetHost means the endpoint is not on 127.0.0.1, so the value has to wait for the
// address the orchestrator actually allocated instead of answering with the loopback literal.
var getValueTask = ipv4Expr.GetValueAsync(CancellationToken.None);
Assert.False(getValueTask.IsCompleted);

annotation.AllocatedEndpoint = new AllocatedEndpoint(annotation, "[::1]", 1433);

var ipv4 = await getValueTask;
Assert.Equal("[::1]", ipv4);
}

[Fact]
public async Task GetValueAsync_IPV4Host_WithLocalhostTldTargetHost_ReturnsImmediately()
{
var resource = new TestResource("test");
var annotation = new EndpointAnnotation(ProtocolType.Tcp, uriScheme: "http", name: "http")
{
TargetHost = "myapp.dev.localhost"
};
resource.Annotations.Add(annotation);

var endpointRef = new EndpointReference(resource, annotation);
var ipv4Expr = endpointRef.Property(EndpointProperty.IPV4Host);

// A *.localhost name resolves to the caller's own loopback, so the literal is still correct here.
var ipv4 = await ipv4Expr.GetValueAsync(CancellationToken.None);
Assert.Equal("127.0.0.1", ipv4);
}

[Fact]
public async Task GetValueAsync_TargetPort_WithStaticPort_ReturnsImmediately()
{
Expand Down