Skip to content

Fix support for non-localhost endpoint targets #9977

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Jun 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4b91ecc
Fix support for non-localhost endpoint targets
danegsta Jun 20, 2025
4fc7974
Remove comment that is no longer relevant
danegsta Jun 20, 2025
057c941
We should use TargetHost even for proxied containers
danegsta Jun 20, 2025
921f92a
Handle 0.0.0.0 binding better (use machine hostname as allocated endp…
danegsta Jun 20, 2025
7227e0d
Also handle [::] for IPv6
danegsta Jun 21, 2025
a3fafb9
Handle Kestrel specific syntax for any address
danegsta Jun 23, 2025
cfee4ce
Remove unnecessary normalization
danegsta Jun 23, 2025
f7c1ec4
Fix tests failing due to new kestrel URL processing
danegsta Jun 23, 2025
21db332
Fix one more test
danegsta Jun 23, 2025
cb01647
Use Environment.HostName instead of Dns.GetHostName
danegsta Jun 23, 2025
195593b
Use Environment.HostName
danegsta Jun 24, 2025
37c89bd
Update to use machine name for all interface equivalent
danegsta Jun 24, 2025
32fbef2
Merge remote-tracking branch 'upstream/main' into danegsta/nonLocalho…
danegsta Jun 25, 2025
a6baf8c
Fix comment
danegsta Jun 26, 2025
2804137
Merge remote-tracking branch 'upstream/main' into danegsta/nonLocalho…
danegsta Jun 26, 2025
f8017fc
Use localhost for the allocated endpoint
danegsta Jun 26, 2025
38e0a31
Merge remote-tracking branch 'upstream/main' into danegsta/nonLocalho…
danegsta Jun 27, 2025
b1a636b
Use switch expression, remove commented out code
danegsta Jun 27, 2025
99a1443
Fix tests again now that host behavior is adjusted
danegsta Jun 27, 2025
a949911
Fix another test
danegsta Jun 27, 2025
f1fce3a
Merge branch 'main' into danegsta/nonLocalhostBindings
danegsta Jun 27, 2025
d561649
Don't change types on existing constructor
danegsta Jun 30, 2025
0fad54d
Make the property non-optional
danegsta Jun 30, 2025
30fc20c
Re-order arguments
danegsta Jun 30, 2025
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
49 changes: 48 additions & 1 deletion src/Aspire.Hosting/ApplicationModel/AllocatedEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@

namespace Aspire.Hosting.ApplicationModel;

/// <summary>
/// Specifies how an endpoint is bound to network addresses.
/// </summary>
public enum EndpointBindingMode
{
/// <summary>
/// The endpoint is bound to a specific address.
/// </summary>
SingleAddress,

/// <summary>
/// The endpoint is bound to all addresses.
/// </summary>
DualStackAnyAddresses,

/// <summary>
/// The endpoint is bound to all IPv4 addresses.
/// </summary>
IPv4AnyAddresses,

/// <summary>
/// The endpoint is bound to all IPv6 addresses.
/// </summary>
IPv6AnyAddresses,
}

/// <summary>
/// Represents an endpoint allocated for a service instance.
/// </summary>
Expand All @@ -19,19 +45,34 @@ public class AllocatedEndpoint
/// <param name="containerHostAddress">The address of the container host.</param>
/// <param name="port">The port number of the endpoint.</param>
/// <param name="targetPortExpression">A string representing how to retrieve the target port of the <see cref="AllocatedEndpoint"/> instance.</param>
public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, string? containerHostAddress = null, string? targetPortExpression = null)
/// <param name="bindingMode">The binding mode of the endpoint.</param>
public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, EndpointBindingMode bindingMode, string? containerHostAddress = null, string? targetPortExpression = null)
{
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentOutOfRangeException.ThrowIfLessThan(port, 1, nameof(port));
ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535, nameof(port));

Endpoint = endpoint;
Address = address;
BindingMode = bindingMode;
ContainerHostAddress = containerHostAddress;
Port = port;
TargetPortExpression = targetPortExpression;
}

/// <summary>
/// Initializes a new instance of the <see cref="AllocatedEndpoint"/> class.
/// </summary>
/// <param name="endpoint">The endpoint.</param>
/// <param name="address">The IP address of the endpoint.</param>
/// <param name="containerHostAddress">The address of the container host.</param>
/// <param name="port">The port number of the endpoint.</param>
/// <param name="targetPortExpression">A string representing how to retrieve the target port of the <see cref="AllocatedEndpoint"/> instance.</param>
public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port, string? containerHostAddress = null, string? targetPortExpression = null)
: this(endpoint, address, port, EndpointBindingMode.SingleAddress, containerHostAddress, targetPortExpression)
{
}

/// <summary>
/// Gets the endpoint which this allocation is associated with.
/// </summary>
Expand All @@ -42,6 +83,12 @@ public AllocatedEndpoint(EndpointAnnotation endpoint, string address, int port,
/// </summary>
public string Address { get; private set; }

/// <summary>
/// The binding mode of the endpoint, indicating whether it is a single address endpoint or is bound to all
/// IPv4 or IPv6 addresses (or both).
/// </summary>
public EndpointBindingMode BindingMode { get; private set; }

/// <summary>
/// The address of the container host. This is only set for containerized services.
/// </summary>
Expand Down
83 changes: 77 additions & 6 deletions src/Aspire.Hosting/Dcp/DcpExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Data;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text.Json;
Expand Down Expand Up @@ -753,10 +754,13 @@ private void AddAllocatedEndpointInfo(IEnumerable<AppResource> resources)
throw new InvalidOperationException($"Service '{svc.Metadata.Name}' needs to specify a port for endpoint '{sp.EndpointAnnotation.Name}' since it isn't using a proxy.");
}

var (targetHost, bindingMode) = NormalizeTargetHost(sp.EndpointAnnotation.TargetHost);

sp.EndpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(
sp.EndpointAnnotation,
"localhost",
targetHost,
(int)svc.AllocatedPort!,
bindingMode,
containerHostAddress: appResource.ModelResource.IsContainer() ? containerHost : null,
targetPortExpression: $$$"""{{- portForServing "{{{svc.Metadata.Name}}}" -}}""");
}
Expand Down Expand Up @@ -791,12 +795,19 @@ private void PrepareServices()
var port = _options.Value.RandomizePorts && endpoint.IsProxied ? null : endpoint.Port;
svc.Spec.Port = port;
svc.Spec.Protocol = PortProtocol.FromProtocolType(endpoint.Protocol);
svc.Spec.Address = endpoint.TargetHost switch
if (string.Equals("localhost", endpoint.TargetHost, StringComparison.OrdinalIgnoreCase))
{
"*" or "+" => "0.0.0.0",
_ => endpoint.TargetHost
};
svc.Spec.AddressAllocationMode = endpoint.IsProxied ? AddressAllocationModes.Localhost : AddressAllocationModes.Proxyless;
svc.Spec.Address = "localhost";
}
else
{
svc.Spec.Address = endpoint.TargetHost;
}

if (!endpoint.IsProxied)
{
svc.Spec.AddressAllocationMode = AddressAllocationModes.Proxyless;
}

// So we can associate the service with the resource that produced it and the endpoint it represents.
svc.Annotate(CustomResource.ResourceNameAnnotation, sp.ModelResource.Name);
Expand Down Expand Up @@ -1441,6 +1452,7 @@ private void AddServicesProducedInfo(IResource modelResource, IAnnotationHolder
}

var spAnn = new ServiceProducerAnnotation(sp.Service.Metadata.Name);
spAnn.Address = NormalizeServiceProducerTargetHost(ea.TargetHost, ea.IsProxied);
spAnn.Port = ea.TargetPort;
dcpResource.AnnotateAsObjectList(CustomResource.ServiceProducerAnnotation, spAnn);
appResource.ServicesProduced.Add(sp);
Expand All @@ -1456,6 +1468,60 @@ static bool HasMultipleReplicas(CustomResource resource)
}
}

private static string NormalizeServiceProducerTargetHost(string targetHost, bool isProxied)
{
// When proxied, the individual services are always bound to localhost even if the proxy
// is bound to different addresses.
if (isProxied)
{
return "localhost";
}
else
{
if (string.IsNullOrEmpty(targetHost) || string.Equals(targetHost, "localhost", StringComparison.OrdinalIgnoreCase))
{
return "localhost";
}
else if (IPAddress.TryParse(targetHost, out _))
{
// Use an IP address as is
return targetHost;
}
else
{
// Use 0.0.0.0 if the target host is not a valid IP address or hostname
return IPAddress.Any.ToString();
}
}
}

/// <summary>
/// Normalize the target host to a tuple of (address, binding mode). A user may have configured
/// an endpoint target host that isn't itself a valid IP address or hostname that can be resolved
/// by other services or clients. For example, 0.0.0.0 is considered to mean that the service should
/// bind to all IPv4 addresses. When the target host indicates that the service should bind to all
/// IPv4 or IPv6 addresses, we instead return "localhost" as the address. The binding mode is metdata
/// that indicates whether an endpoint is bound to a single address or some set of multiple addresses
/// on the system.
/// </summary>
/// <param name="targetHost">The target host from an EndpointAnnotation</param>
/// <returns>A tuple of (address, binding mode).</returns>
private static (string, EndpointBindingMode) NormalizeTargetHost(string targetHost)
{
return targetHost switch
{
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 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)
var ip when IPAddress.IPv6Any.Equals(ip) => ("localhost", EndpointBindingMode.IPv6AnyAddresses), // :: (IPv6 all addreses)
_ => (s, EndpointBindingMode.SingleAddress), // Any other IP address is returned as-is
},
_ => ("localhost", EndpointBindingMode.DualStackAnyAddresses), // Any other target host is treated as binding to all IPv4 AND IPv6 addresses
};
}

private async Task CreateResourcesAsync<RT>(CancellationToken cancellationToken) where RT : CustomResource
{
try
Expand Down Expand Up @@ -1805,6 +1871,11 @@ private static List<ContainerPortSpec> BuildContainerPorts(AppResource cr)
break;
}

if (sp.EndpointAnnotation.TargetHost != "localhost")
{
portSpec.HostIP = sp.EndpointAnnotation.TargetHost;
}

ports.Add(portSpec);
}

Expand Down
22 changes: 15 additions & 7 deletions src/Aspire.Hosting/ProjectResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -395,11 +395,12 @@ private static IResourceBuilder<ProjectResource> WithProjectDefaults(this IResou
e.Port = endpoint.BindingAddress.Port;
}
e.UriScheme = endpoint.BindingAddress.Scheme;
e.TargetHost = endpoint.BindingAddress.Host;

e.TargetHost = ParseKestrelHost(endpoint.BindingAddress.Host);

adjustTransport(e, endpoint.Protocols);
// Keep track of the host separately since EndpointAnnotation doesn't have a host property
builder.Resource.KestrelEndpointAnnotationHosts[e] = endpoint.BindingAddress.Host;
builder.Resource.KestrelEndpointAnnotationHosts[e] = e.TargetHost;
},
createIfNotExists: true);
}
Expand Down Expand Up @@ -451,7 +452,7 @@ private static IResourceBuilder<ProjectResource> WithProjectDefaults(this IResou
builder.WithEndpoint(endpointName, e =>
{
e.Port = bindingAddress.Port;
e.TargetHost = bindingAddress.Host;
e.TargetHost = ParseKestrelHost(bindingAddress.Host);
e.UriScheme = bindingAddress.Scheme;
e.FromLaunchProfile = true;
adjustTransport(e);
Expand Down Expand Up @@ -759,10 +760,7 @@ private static void SetAspNetCoreUrls(this IResourceBuilder<ProjectResource> bui
processedHttpsPort = true;
}

// If the endpoint is proxied, we will use localhost as the target host since DCP will be forwarding the traffic
var targetHost = e.EndpointAnnotation.IsProxied && builder.Resource.SupportsProxy() ? "localhost" : e.EndpointAnnotation.TargetHost;

aspnetCoreUrls.Append($"{e.Property(EndpointProperty.Scheme)}://{targetHost}:{e.Property(EndpointProperty.TargetPort)}");
aspnetCoreUrls.Append($"{e.Property(EndpointProperty.Scheme)}://{e.EndpointAnnotation.TargetHost}:{e.Property(EndpointProperty.TargetPort)}");
first = false;
}

Expand Down Expand Up @@ -848,6 +846,16 @@ private static void SetKestrelUrlOverrideEnvVariables(this IResourceBuilder<Proj
});
}

private static string ParseKestrelHost(string host)
{
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
// Localhost is used as-is rather than being resolved to a specific loopback IP address.
return "localhost";
}
return host;
}

// Allows us to mirror annotations from ProjectContainerResource to ContainerResource
private sealed class ProjectContainerResource(ProjectResource pr) : ContainerResource(pr.Name)
{
Expand Down
10 changes: 5 additions & 5 deletions tests/Aspire.Hosting.Tests/ProjectResourceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -600,9 +600,9 @@ public async Task AddProjectWithArgs()
}

[Theory]
[InlineData(true, "localhost")]
[InlineData(false, "*")]
public async Task AddProjectWithWildcardUrlInLaunchSettings(bool isProxied, string expectedHost)
[InlineData(true)]
[InlineData(false)]
public async Task AddProjectWithWildcardUrlInLaunchSettings(bool isProxied)
{
var appBuilder = CreateBuilder(operation: DistributedApplicationOperation.Run);

Expand Down Expand Up @@ -635,11 +635,11 @@ public async Task AddProjectWithWildcardUrlInLaunchSettings(bool isProxied, stri
if (isProxied)
{
// When the end point is proxied, the host should be localhost and the port should match the targetPortExpression
Assert.Equal($"http://{expectedHost}:p0;https://{expectedHost}:p1", config["ASPNETCORE_URLS"]);
Assert.Equal("http://*:p0;https://*:p1", config["ASPNETCORE_URLS"]);
}
else
{
Assert.Equal($"http://{expectedHost}:{http.TargetPort};https://{expectedHost}:{https.TargetPort}", config["ASPNETCORE_URLS"]);
Assert.Equal($"http://*:{http.TargetPort};https://*:{https.TargetPort}", config["ASPNETCORE_URLS"]);
}

Assert.Equal(https.Port.ToString(), config["ASPNETCORE_HTTPS_PORT"]);
Expand Down
Loading